feat: Move admin route to own namespace (#2405)
Resolves https://github.com/webrecorder/browsertrix/issues/2382 ## Changes - Moves superadmin to `/admin` URL namespace - Removes superadmin views from main webpack chunks
This commit is contained in:
parent
8db80f5570
commit
06f6d9d4f2
@ -725,7 +725,7 @@ export class OrgsList extends BtrixElement {
|
||||
<btrix-table-cell class="p-2" rowClickTarget="a">
|
||||
<a
|
||||
class=${org.readOnly ? "text-neutral-500" : "text-neutral-900"}
|
||||
href="/orgs/${org.slug}"
|
||||
href="/orgs/${org.slug}/dashboard"
|
||||
@click=${this.navigate.link}
|
||||
aria-disabled="${!isUserOrg}"
|
||||
>
|
||||
|
@ -79,7 +79,7 @@ describe("browsertrix-app", () => {
|
||||
expect(el.shadowRoot?.childElementCount).to.equal(0);
|
||||
});
|
||||
|
||||
it("renders home when authenticated", async () => {
|
||||
it("renders org when authenticated", async () => {
|
||||
stub(AuthService, "initSessionStorage").returns(
|
||||
Promise.resolve({
|
||||
headers: { Authorization: "_fake_headers_" },
|
||||
@ -89,14 +89,15 @@ describe("browsertrix-app", () => {
|
||||
);
|
||||
// @ts-expect-error checkFreshness is private
|
||||
stub(AuthService.prototype, "checkFreshness");
|
||||
AppStateService.updateOrgSlug("fake-org");
|
||||
const el = await fixture<App>(
|
||||
html` <browsertrix-app .settings=${mockAppSettings}></browsertrix-app>`,
|
||||
);
|
||||
await el.updateComplete;
|
||||
expect(el.shadowRoot?.querySelector("btrix-home")).to.exist;
|
||||
expect(el.shadowRoot?.querySelector("btrix-org")).to.exist;
|
||||
});
|
||||
|
||||
it("renders home when not authenticated", async () => {
|
||||
it("renders log in when not authenticated", async () => {
|
||||
stub(AuthService, "initSessionStorage").returns(Promise.resolve(null));
|
||||
// @ts-expect-error checkFreshness is private
|
||||
stub(AuthService.prototype, "checkFreshness");
|
||||
@ -110,7 +111,7 @@ describe("browsertrix-app", () => {
|
||||
const el = await fixture<App>(
|
||||
html` <browsertrix-app .settings=${mockAppSettings}></browsertrix-app>`,
|
||||
);
|
||||
expect(el.shadowRoot?.querySelector("btrix-home")).to.exist;
|
||||
expect(el.shadowRoot?.querySelector("btrix-log-in")).to.exist;
|
||||
});
|
||||
|
||||
// TODO move tests to AuthService
|
||||
|
@ -10,6 +10,7 @@ import type {
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { until } from "lit/directives/until.js";
|
||||
import { when } from "lit/directives/when.js";
|
||||
import isEqual from "lodash/fp/isEqual";
|
||||
|
||||
@ -24,10 +25,10 @@ import "./assets/fonts/Recursive/recursive.css";
|
||||
import "./styles.css";
|
||||
|
||||
import { viewStateContext } from "./context/view-state";
|
||||
import { OrgTab, RouteNamespace, ROUTES } from "./routes";
|
||||
import { OrgTab, RouteNamespace } from "./routes";
|
||||
import type { UserInfo, UserOrg } from "./types/user";
|
||||
import { pageView, type AnalyticsTrackProps } from "./utils/analytics";
|
||||
import APIRouter, { type ViewState } from "./utils/APIRouter";
|
||||
import { type ViewState } from "./utils/APIRouter";
|
||||
import AuthService, {
|
||||
type AuthEventDetail,
|
||||
type LoggedInEventDetail,
|
||||
@ -47,6 +48,7 @@ import { type AppSettings } from "@/utils/app";
|
||||
import { DEFAULT_MAX_SCALE } from "@/utils/crawler";
|
||||
import localize from "@/utils/localize";
|
||||
import { toast } from "@/utils/notify";
|
||||
import router, { urlForName } from "@/utils/router";
|
||||
import { AppStateService } from "@/utils/state";
|
||||
import { formatAPIUser } from "@/utils/user";
|
||||
import brandLockupColor from "~assets/brand/browsertrix-lockup-color.svg";
|
||||
@ -94,7 +96,9 @@ export class App extends BtrixElement {
|
||||
@property({ type: Object })
|
||||
settings?: AppSettings;
|
||||
|
||||
private readonly router = new APIRouter(ROUTES);
|
||||
// TODO Refactor into context
|
||||
private readonly router = router;
|
||||
|
||||
authService = new AuthService();
|
||||
|
||||
@state()
|
||||
@ -120,6 +124,22 @@ export class App extends BtrixElement {
|
||||
return this.viewState.params.slug || "";
|
||||
}
|
||||
|
||||
private get homePath() {
|
||||
let path = "/log-in";
|
||||
if (this.authState) {
|
||||
if (this.userInfo?.isSuperAdmin) {
|
||||
path = `/${RouteNamespace.Superadmin}`;
|
||||
} else if (this.appState.orgSlug) {
|
||||
path = `${this.navigate.orgBasePath}/${OrgTab.Dashboard}`;
|
||||
} else if (this.userInfo?.orgs[0]) {
|
||||
path = `/${RouteNamespace.PrivateOrgs}/${this.userInfo.orgs[0].slug}/${OrgTab.Dashboard}`;
|
||||
} else {
|
||||
path = "/account/settings";
|
||||
}
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
get isUserInCurrentOrg(): boolean {
|
||||
const slug = this.orgSlugInPath;
|
||||
if (!this.userInfo || !slug) return false;
|
||||
@ -201,6 +221,10 @@ export class App extends BtrixElement {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "home":
|
||||
// Redirect base URL
|
||||
this.routeTo(this.homePath);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -268,7 +292,7 @@ export class App extends BtrixElement {
|
||||
this.authService.authState,
|
||||
);
|
||||
this.clearUser();
|
||||
this.routeTo(ROUTES.login);
|
||||
this.routeTo(urlForName("login"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -429,10 +453,6 @@ export class App extends BtrixElement {
|
||||
|
||||
private renderNavBar() {
|
||||
const isSuperAdmin = this.userInfo?.isSuperAdmin;
|
||||
let homeHref = "/";
|
||||
if (!isSuperAdmin && this.appState.orgSlug && this.authState) {
|
||||
homeHref = `${this.navigate.orgBasePath}/${OrgTab.Dashboard}`;
|
||||
}
|
||||
|
||||
const showFullLogo =
|
||||
this.viewState.route === "login" || !this.authService.authState;
|
||||
@ -446,7 +466,7 @@ export class App extends BtrixElement {
|
||||
<a
|
||||
class="items-between flex gap-2"
|
||||
aria-label="home"
|
||||
href=${homeHref}
|
||||
href=${this.homePath}
|
||||
@click=${(e: MouseEvent) => {
|
||||
if (isSuperAdmin) {
|
||||
this.clearSelectedOrg();
|
||||
@ -474,7 +494,7 @@ export class App extends BtrixElement {
|
||||
></div>
|
||||
<a
|
||||
class="flex items-center gap-2 font-medium text-primary-700 transition-colors hover:text-primary"
|
||||
href="/"
|
||||
href="/${RouteNamespace.Superadmin}"
|
||||
@click=${(e: MouseEvent) => {
|
||||
this.clearSelectedOrg();
|
||||
this.navigate.link(e);
|
||||
@ -538,7 +558,8 @@ export class App extends BtrixElement {
|
||||
</sl-menu-item>
|
||||
${this.userInfo?.isSuperAdmin
|
||||
? html` <sl-menu-item
|
||||
@click=${() => this.routeTo(ROUTES.usersInvite)}
|
||||
@click=${() =>
|
||||
this.routeTo(urlForName("adminUsersInvite"))}
|
||||
>
|
||||
<sl-icon slot="prefix" name="person-plus"></sl-icon>
|
||||
${msg("Invite Users")}
|
||||
@ -580,7 +601,7 @@ export class App extends BtrixElement {
|
||||
>
|
||||
<a
|
||||
class="font-medium text-neutral-500 hover:text-primary"
|
||||
href="/crawls"
|
||||
href=${urlForName("adminCrawls")}
|
||||
@click=${this.navigate.link}
|
||||
>${msg("Running Crawls")}</a
|
||||
>
|
||||
@ -817,8 +838,12 @@ export class App extends BtrixElement {
|
||||
.viewState=${this.viewState}
|
||||
></btrix-reset-password>`;
|
||||
|
||||
case "home":
|
||||
return html`<btrix-home class="w-full md:bg-neutral-50"></btrix-home>`;
|
||||
case "admin":
|
||||
return this.renderAdminPage(
|
||||
() => html`
|
||||
<btrix-admin class="w-full md:bg-neutral-50"></btrix-admin>
|
||||
`,
|
||||
);
|
||||
|
||||
case "orgs":
|
||||
return html`<btrix-orgs class="w-full md:bg-neutral-50"></btrix-orgs>`;
|
||||
@ -869,36 +894,25 @@ export class App extends BtrixElement {
|
||||
tab=${this.viewState.params.settingsTab}
|
||||
></btrix-account-settings>`;
|
||||
|
||||
case "usersInvite": {
|
||||
if (this.userInfo) {
|
||||
if (this.userInfo.isSuperAdmin) {
|
||||
return html`<btrix-users-invite
|
||||
case "adminUsers":
|
||||
case "adminUsersInvite":
|
||||
return this.renderAdminPage(
|
||||
() =>
|
||||
html`<btrix-users-invite
|
||||
class="mx-auto box-border w-full max-w-screen-desktop p-2 md:py-8"
|
||||
></btrix-users-invite>`;
|
||||
} else {
|
||||
return this.renderNotFoundPage();
|
||||
}
|
||||
} else {
|
||||
return this.renderSpinner();
|
||||
}
|
||||
}
|
||||
></btrix-users-invite>`,
|
||||
);
|
||||
|
||||
case "crawls":
|
||||
case "crawl": {
|
||||
if (this.userInfo) {
|
||||
if (this.userInfo.isSuperAdmin) {
|
||||
return html`<btrix-crawls
|
||||
case "adminCrawls":
|
||||
case "adminCrawl":
|
||||
return this.renderAdminPage(
|
||||
() =>
|
||||
html`<btrix-crawls
|
||||
class="w-full"
|
||||
@notify=${this.onNotify}
|
||||
crawlId=${this.viewState.params.crawlId}
|
||||
></btrix-crawls>`;
|
||||
} else {
|
||||
return this.renderNotFoundPage();
|
||||
}
|
||||
} else {
|
||||
return this.renderSpinner();
|
||||
}
|
||||
}
|
||||
></btrix-crawls>`,
|
||||
);
|
||||
|
||||
case "awpUploadRedirect": {
|
||||
const { orgId, uploadId } = this.viewState.params;
|
||||
@ -915,6 +929,21 @@ export class App extends BtrixElement {
|
||||
}
|
||||
}
|
||||
|
||||
private renderAdminPage(renderer: () => TemplateResult) {
|
||||
// if (!this.userInfo) return this.renderSpinner();
|
||||
|
||||
if (this.userInfo?.isSuperAdmin) {
|
||||
// Dynamically import admin pages
|
||||
return until(
|
||||
import(/* webpackChunkName: "admin" */ "@/pages/admin/index").then(
|
||||
renderer,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return this.renderNotFoundPage();
|
||||
}
|
||||
|
||||
private renderSpinner() {
|
||||
return html`
|
||||
<div class="flex w-full items-center justify-center text-3xl">
|
||||
@ -962,7 +991,7 @@ export class App extends BtrixElement {
|
||||
this.clearUser();
|
||||
|
||||
if (redirect) {
|
||||
this.routeTo(ROUTES.login);
|
||||
this.routeTo(urlForName("login"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -976,10 +1005,7 @@ export class App extends BtrixElement {
|
||||
});
|
||||
|
||||
if (!detail.api) {
|
||||
this.routeTo(
|
||||
detail.redirectUrl ||
|
||||
`${this.navigate.orgBasePath}/${OrgTab.Dashboard}`,
|
||||
);
|
||||
this.routeTo(detail.redirectUrl || this.homePath);
|
||||
}
|
||||
|
||||
if (detail.firstLogin) {
|
||||
@ -1000,7 +1026,7 @@ export class App extends BtrixElement {
|
||||
|
||||
this.clearUser();
|
||||
const redirectUrl = e.detail.redirectUrl;
|
||||
this.routeTo(ROUTES.login, {
|
||||
this.routeTo(urlForName("login"), {
|
||||
redirectUrl,
|
||||
});
|
||||
if (redirectUrl && redirectUrl !== "/") {
|
||||
@ -1105,7 +1131,7 @@ export class App extends BtrixElement {
|
||||
this.syncViewState();
|
||||
} else {
|
||||
this.clearUser();
|
||||
this.routeTo(ROUTES.login);
|
||||
this.routeTo(urlForName("login"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { localized, msg, str } from "@lit/localize";
|
||||
import type { SlInput, SlInputEvent } from "@shoelace-style/shoelace";
|
||||
import { serialize } from "@shoelace-style/shoelace/dist/utilities/form.js";
|
||||
import { html, type PropertyValues } from "lit";
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
|
||||
import { BtrixElement } from "@/classes/BtrixElement";
|
||||
import needLogin from "@/decorators/needLogin";
|
||||
import type { InviteSuccessDetail } from "@/features/accounts/invite-form";
|
||||
import type { APIUser } from "@/index";
|
||||
import { OrgTab, RouteNamespace } from "@/routes";
|
||||
import type { APIPaginatedList } from "@/types/api";
|
||||
import { isApiError } from "@/utils/api";
|
||||
import { maxLengthValidator } from "@/utils/form";
|
||||
@ -17,16 +17,11 @@ import { AppStateService } from "@/utils/state";
|
||||
import { formatAPIUser } from "@/utils/user";
|
||||
|
||||
/**
|
||||
* Home page when org is not selected.
|
||||
*
|
||||
* Uses custom redirect instead of needLogin decorator to suppress "need login"
|
||||
* message when accessing root URL.
|
||||
*
|
||||
* Only accessed by superadmins. Regular users will be redirected their org.
|
||||
* See https://github.com/webrecorder/browsertrix/issues/1972
|
||||
* Browsertrix superadmin dashboard
|
||||
*/
|
||||
@customElement("btrix-home")
|
||||
@customElement("btrix-admin")
|
||||
@localized()
|
||||
@needLogin
|
||||
export class Admin extends BtrixElement {
|
||||
@state()
|
||||
private orgList?: OrgData[];
|
||||
@ -52,38 +47,12 @@ export class Admin extends BtrixElement {
|
||||
|
||||
private readonly validateOrgNameMax = maxLengthValidator(40);
|
||||
|
||||
connectedCallback() {
|
||||
if (this.authState) {
|
||||
if (this.slug) {
|
||||
this.navigate.to(`/orgs/${this.slug}`);
|
||||
} else {
|
||||
super.connectedCallback();
|
||||
}
|
||||
} else {
|
||||
this.navigate.to("/log-in");
|
||||
}
|
||||
}
|
||||
|
||||
willUpdate(changedProperties: PropertyValues) {
|
||||
if (changedProperties.has("appState.userInfo") && this.userInfo) {
|
||||
if (this.userInfo.isSuperAdmin) {
|
||||
this.initSuperAdmin();
|
||||
} else if (this.userInfo.orgs.length) {
|
||||
this.navigate.to(
|
||||
`/${RouteNamespace.PrivateOrgs}/${this.userInfo.orgs[0].slug}/${OrgTab.Dashboard}`,
|
||||
);
|
||||
} else {
|
||||
this.navigate.to(`/account/settings`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated(): void {
|
||||
this.initSuperAdmin();
|
||||
}
|
||||
|
||||
private initSuperAdmin() {
|
||||
if (this.userInfo?.isSuperAdmin && !this.orgList) {
|
||||
if (this.userInfo?.isSuperAdmin) {
|
||||
if (this.userInfo.orgs.length) {
|
||||
void this.fetchOrgs();
|
||||
} else {
|
||||
@ -94,7 +63,7 @@ export class Admin extends BtrixElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.userInfo || !this.userInfo.isSuperAdmin) {
|
||||
if (!this.userInfo?.isSuperAdmin) {
|
||||
return;
|
||||
}
|
||||
|
2
frontend/src/pages/admin/index.ts
Normal file
2
frontend/src/pages/admin/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import "./admin";
|
||||
import "./users-invite";
|
@ -1,13 +1,11 @@
|
||||
import "./org";
|
||||
|
||||
import(/* webpackChunkName: "admin" */ "./admin");
|
||||
import(/* webpackChunkName: "sign-up" */ "./sign-up");
|
||||
import(/* webpackChunkName: "log-in" */ "./log-in");
|
||||
import(/* webpackChunkName: "crawls" */ "./crawls");
|
||||
import(/* webpackChunkName: "join" */ "./invite/join");
|
||||
import(/* webpackChunkName: "verify" */ "./verify");
|
||||
import(/* webpackChunkName: "reset-password" */ "./reset-password");
|
||||
import(/* webpackChunkName: "users-invite" */ "./users-invite");
|
||||
import(/* webpackChunkName: "accept-invite" */ "./invite/accept");
|
||||
import(/* webpackChunkName: "account-settings" */ "./account-settings");
|
||||
import(/* webpackChunkName: "collections" */ "./collections");
|
||||
|
@ -10,6 +10,7 @@ export enum OrgTab {
|
||||
export enum RouteNamespace {
|
||||
PrivateOrgs = "orgs",
|
||||
PublicOrgs = "explore",
|
||||
Superadmin = "admin",
|
||||
}
|
||||
|
||||
export const ROUTES = {
|
||||
@ -37,10 +38,12 @@ export const ROUTES = {
|
||||
publicOrgs: `/${RouteNamespace.PublicOrgs}(/)`,
|
||||
publicOrg: `/${RouteNamespace.PublicOrgs}/:slug(/)`,
|
||||
publicCollection: `/${RouteNamespace.PublicOrgs}/:slug/collections/:collectionSlug(/:collectionTab)`,
|
||||
users: "/users",
|
||||
usersInvite: "/users/invite",
|
||||
crawls: "/crawls",
|
||||
crawl: "/crawls/crawl/:crawlId",
|
||||
// Superadmin routes
|
||||
admin: `/${RouteNamespace.Superadmin}(/)`,
|
||||
adminUsers: `/${RouteNamespace.Superadmin}/users(/)`,
|
||||
adminUsersInvite: `/${RouteNamespace.Superadmin}/users/invite`,
|
||||
adminCrawls: `/${RouteNamespace.Superadmin}/crawls(/)`,
|
||||
adminCrawl: `/${RouteNamespace.Superadmin}/crawls/crawl/:crawlId`,
|
||||
// Redirect for https://github.com/webrecorder/browsertrix-cloud/issues/935
|
||||
awpUploadRedirect: `/${RouteNamespace.PrivateOrgs}/:orgId/artifacts/upload/:uploadId`,
|
||||
} as const;
|
||||
|
@ -86,4 +86,27 @@ describe("APIRouter", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("urlForName", () => {
|
||||
it("returns the correct path for home", () => {
|
||||
const apiRouter = new APIRouter(ROUTES);
|
||||
const path = apiRouter.urlForName("home");
|
||||
|
||||
expect(path).to.equal("/");
|
||||
});
|
||||
|
||||
it("returns the correct path for orgs", () => {
|
||||
const apiRouter = new APIRouter(ROUTES);
|
||||
const path = apiRouter.urlForName("orgs");
|
||||
|
||||
expect(path).to.equal("/orgs");
|
||||
});
|
||||
|
||||
it("returns the correct path for org", () => {
|
||||
const apiRouter = new APIRouter(ROUTES);
|
||||
const path = apiRouter.urlForName("org", { slug: "_fake_org_id_" });
|
||||
|
||||
expect(path).to.equal("/orgs/_fake_org_id_");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -57,4 +57,14 @@ export default class APIRouter {
|
||||
|
||||
return { route: null, pathname: relativePath, params: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get URL for a route name, with optional parameters
|
||||
*/
|
||||
public readonly urlForName = (
|
||||
name: RouteName,
|
||||
params?: { [key: string]: unknown },
|
||||
) => {
|
||||
return this.routes[name].stringify(params);
|
||||
};
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { APIError } from "./api";
|
||||
import { urlForName } from "./router";
|
||||
import appState, { AppStateService } from "./state";
|
||||
|
||||
import type { APIUser } from "@/index";
|
||||
import { ROUTES } from "@/routes";
|
||||
import type { Auth } from "@/types/auth";
|
||||
|
||||
type AuthState = Auth | null;
|
||||
@ -351,7 +351,7 @@ export default class AuthService {
|
||||
this.logout();
|
||||
const { pathname, search, hash } = window.location;
|
||||
const redirectUrl =
|
||||
pathname !== ROUTES.login && pathname !== "/"
|
||||
pathname !== urlForName("login") && pathname !== "/"
|
||||
? `${pathname}${search}${hash}`
|
||||
: "";
|
||||
window.dispatchEvent(AuthService.createNeedLoginEvent({ redirectUrl }));
|
||||
|
9
frontend/src/utils/router.ts
Normal file
9
frontend/src/utils/router.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { ROUTES } from "@/routes";
|
||||
import APIRouter from "@/utils/APIRouter";
|
||||
import { cached } from "@/utils/weakCache";
|
||||
|
||||
const router = new APIRouter(ROUTES);
|
||||
|
||||
export const urlForName = cached(router.urlForName);
|
||||
|
||||
export default router;
|
@ -58,7 +58,7 @@ export function makeAppStateService() {
|
||||
)) ||
|
||||
null;
|
||||
|
||||
if (appState.userInfo && !userOrg) {
|
||||
if (appState.orgSlug && appState.userInfo && !userOrg) {
|
||||
console.debug("no user org matching slug in state");
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user