Add admin page for inviting users (#56)

closes #37
This commit is contained in:
sua yoo 2021-12-05 15:23:28 -08:00 committed by GitHub
parent 53beb84c01
commit 9c4bec1411
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 307 additions and 64 deletions

View File

@ -72,7 +72,7 @@ class RequestVerify extends LitElement {
this.isRequesting = false;
}
}
customElements.define("bt-request-verify", RequestVerify);
customElements.define("btrix-request-verify", RequestVerify);
type FormContext = {
successMessage?: string;
@ -224,7 +224,9 @@ export class AccountSettings extends LiteElement {
})}</sl-tag
>
<bt-request-verify email=${this.userInfo.email}></bt-request-verify>
<btrix-request-verify
email=${this.userInfo.email}
></btrix-request-verify>
`;
}
}

View File

@ -0,0 +1,128 @@
import { state, property } from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize";
import type { AuthState } from "../utils/AuthService";
import LiteElement, { html } from "../utils/LiteElement";
import { AccessCode } from "../utils/archives";
@localized()
export class ArchiveInviteForm extends LiteElement {
@property({ type: String })
archiveId?: string;
@property({ type: Object })
authState?: AuthState;
@state()
private isSubmitting: boolean = false;
@state()
private serverError?: string;
render() {
let formError;
if (this.serverError) {
formError = html`
<div class="mb-5">
<btrix-alert id="formError" type="danger"
>${this.serverError}</btrix-alert
>
</div>
`;
}
return html`
<sl-form
class="max-w-md"
@sl-submit=${this.onSubmit}
aria-describedby="formError"
>
<div class="mb-5">
<sl-input
id="inviteEmail"
name="inviteEmail"
type="email"
label=${msg("Email")}
placeholder=${msg("team-member@email.com", {
desc: "Placeholder text for email to invite",
})}
required
>
</sl-input>
</div>
<div class="mb-5">
<sl-radio-group label="Select an option">
<sl-radio name="role" value=${AccessCode.owner}>
${msg("Admin")}
<span class="text-gray-500">
- ${msg("Can start & configure crawls and invite others")}</span
>
</sl-radio>
<sl-radio name="role" value=${AccessCode.viewer} checked>
${msg("Viewer")}
<span class="text-gray-500"> - ${msg("Can view crawls")}</span>
</sl-radio>
</sl-radio-group>
</div>
${formError}
<div>
<sl-button
type="primary"
submit
?loading=${this.isSubmitting}
?disabled=${this.isSubmitting}
>${msg("Invite")}</sl-button
>
<sl-button
type="text"
@click=${() => this.dispatchEvent(new CustomEvent("cancel"))}
>${msg("Cancel")}</sl-button
>
</div>
</sl-form>
`;
}
async onSubmit(event: { detail: { formData: FormData } }) {
if (!this.authState) return;
this.isSubmitting = true;
const { formData } = event.detail;
const inviteEmail = formData.get("inviteEmail") as string;
try {
const data = await this.apiFetch(
`/archives/${this.archiveId}/invite`,
this.authState,
{
method: "POST",
body: JSON.stringify({
email: inviteEmail,
role: Number(formData.get("role")),
}),
}
);
this.dispatchEvent(
new CustomEvent("success", {
detail: {
inviteEmail,
isExistingUser: data.invited === "existing_user",
},
})
);
} catch (e: any) {
if (e?.isApiError) {
this.serverError = e?.message;
} else {
this.serverError = msg("Something unexpected went wrong");
}
}
this.isSubmitting = false;
}
}

View File

@ -5,11 +5,17 @@ import("./locale-picker").then(({ LocalePicker }) => {
import("./account-settings").then(({ AccountSettings }) => {
customElements.define("btrix-account-settings", AccountSettings);
});
import("./archive-invite-form").then(({ ArchiveInviteForm }) => {
customElements.define("btrix-archive-invite-form", ArchiveInviteForm);
});
import("./invite-form").then(({ InviteForm }) => {
customElements.define("btrix-invite-form", InviteForm);
});
import("./sign-up-form").then(({ SignUpForm }) => {
customElements.define("btrix-sign-up-form", SignUpForm);
});
import("./not-found").then(({ NotFound }) => {
customElements.define("btrix-not-found", NotFound);
});
customElements.define("btrix-alert", Alert);

View File

@ -1,15 +1,14 @@
import { state, property } from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize";
import { msg, localized } from "@lit/localize";
import type { AuthState } from "../utils/AuthService";
import LiteElement, { html } from "../utils/LiteElement";
import { AccessCode } from "../utils/archives";
/**
* @event success
*/
@localized()
export class InviteForm extends LiteElement {
@property({ type: String })
archiveId?: string;
@property({ type: Object })
authState?: AuthState;
@ -35,7 +34,7 @@ export class InviteForm extends LiteElement {
return html`
<sl-form
class="max-w-md"
@sl-submit=${this.onSubmitInvite}
@sl-submit=${this.onSubmit}
aria-describedby="formError"
>
<div class="mb-5">
@ -43,26 +42,14 @@ export class InviteForm extends LiteElement {
id="inviteEmail"
name="inviteEmail"
type="email"
label="${msg("Email")}"
placeholder="team-member@email.com"
label=${msg("Email")}
placeholder=${msg("person@email.com", {
desc: "Placeholder text for email to invite",
})}
required
>
</sl-input>
</div>
<div class="mb-5">
<sl-radio-group label="Select an option">
<sl-radio name="role" value=${AccessCode.owner}>
${msg("Admin")}
<span class="text-gray-500">
- ${msg("Can start & configure crawls and invite others")}</span
>
</sl-radio>
<sl-radio name="role" value=${AccessCode.viewer} checked>
${msg("Viewer")}
<span class="text-gray-500"> - ${msg("Can view crawls")}</span>
</sl-radio>
</sl-radio-group>
</div>
${formError}
@ -74,17 +61,12 @@ export class InviteForm extends LiteElement {
?disabled=${this.isSubmitting}
>${msg("Invite")}</sl-button
>
<sl-button
type="text"
@click=${() => this.dispatchEvent(new CustomEvent("cancel"))}
>${msg("Cancel")}</sl-button
>
</div>
</sl-form>
`;
}
async onSubmitInvite(event: { detail: { formData: FormData } }) {
async onSubmit(event: { detail: { formData: FormData } }) {
if (!this.authState) return;
this.isSubmitting = true;
@ -94,7 +76,8 @@ export class InviteForm extends LiteElement {
try {
const data = await this.apiFetch(
`/archives/${this.archiveId}/invite`,
// TODO actual path
`/invite`,
this.authState,
{
method: "POST",

View File

@ -0,0 +1,14 @@
import { LitElement, html } from "lit";
import { msg, localized } from "@lit/localize";
@localized()
export class NotFound extends LitElement {
createRenderRoot() {
return this;
}
render() {
return html`
<div class="text-2xl text-gray-400">${msg("Page not found")}</div>
`;
}
}

View File

@ -12,6 +12,7 @@ describe("browsertrix-app", () => {
email: "test-user@example.com",
name: "Test User",
is_verified: false,
is_superuser: false,
})
);
});
@ -55,6 +56,7 @@ describe("browsertrix-app", () => {
email: "test-user@example.com",
name: "Test User",
isVerified: false,
isAdmin: false,
});
});
});

View File

@ -14,30 +14,12 @@ import type { ViewState, NavigateEvent } from "./utils/APIRouter";
import type { CurrentUser } from "./types/user";
import type { AuthState } from "./utils/AuthService";
import theme from "./theme";
import { ROUTES, DASHBOARD_ROUTE } from "./routes";
import "./shoelace";
import "./components";
import "./pages";
const REGISTRATION_ENABLED = process.env.REGISTRATION_ENABLED === "true";
const ROUTES = {
home: "/",
join: "/join/:token?email",
verify: "/verify?token",
login: "/log-in",
forgotPassword: "/log-in/forgot-password",
resetPassword: "/reset-password?token",
myAccount: "/my-account",
accountSettings: "/account/settings",
archives: "/archives",
archive: "/archives/:id/:tab",
archiveAddMember: "/archives/:id/:tab/add-member",
} as const;
if (REGISTRATION_ENABLED) {
(ROUTES as any).signUp = "/sign-up";
}
const DASHBOARD_ROUTE = ROUTES.archives;
type DialogContent = {
label?: TemplateResult | string;
@ -126,6 +108,7 @@ export class App extends LiteElement {
email: data.email,
name: data.name,
isVerified: data.is_verified,
isAdmin: data.is_superuser,
};
} catch (err: any) {
if (err?.message === "Unauthorized") {
@ -159,6 +142,10 @@ export class App extends LiteElement {
render() {
return html`
<style>
.uppercase {
letter-spacing: 0.06em;
}
${theme}
</style>
@ -249,23 +236,43 @@ export class App extends LiteElement {
${navLink({
activeRoutes: ["archives", "archive"],
href: DASHBOARD_ROUTE,
label: "Archives",
label: msg("Archives"),
})}
</ul>
${this.userInfo?.isAdmin
? html` <span class="uppercase text-sm font-medium"
>${msg("Admin", {
desc: "Heading for links to administrative pages",
})}</span
>
<ul class="flex md:flex-col">
${navLink({
// activeRoutes: ["users", "usersInvite"],
activeRoutes: ["usersInvite"],
href: ROUTES.usersInvite,
label: msg("Invite Users"),
})}
</ul>`
: ""}
</nav>
<div class="p-4 md:p-8 flex-1">${template}</div>
</div>
`;
switch (this.viewState.route) {
case "signUp":
return html`<btrix-sign-up
class="w-full md:bg-gray-100 flex items-center justify-center"
@navigate="${this.onNavigateTo}"
@logged-in="${this.onLoggedIn}"
@log-out="${this.onLogOut}"
.authState="${this.authService.authState}"
></btrix-sign-up>`;
case "signUp": {
if (REGISTRATION_ENABLED) {
return html`<btrix-sign-up
class="w-full md:bg-gray-100 flex items-center justify-center"
@navigate="${this.onNavigateTo}"
@logged-in="${this.onLoggedIn}"
@log-out="${this.onLogOut}"
.authState="${this.authService.authState}"
></btrix-sign-up>`;
} else {
return this.renderNotFoundPage();
}
}
case "verify":
return html`<btrix-verify
@ -359,11 +366,31 @@ export class App extends LiteElement {
tab="${this.viewState.tab || "running"}"
></btrix-archive>`);
case "usersInvite": {
if (this.userInfo?.isAdmin) {
return appLayout(html`<btrix-users-invite
class="w-full"
@navigate="${this.onNavigateTo}"
@need-login="${this.onNeedLogin}"
.authState="${this.authService.authState}"
.userInfo="${this.userInfo}"
></btrix-users-invite>`);
} else {
return this.renderNotFoundPage();
}
}
default:
return html`<div>Not Found!</div>`;
return this.renderNotFoundPage();
}
}
renderNotFoundPage() {
return html`<btrix-not-found
class="w-full md:bg-gray-100 flex items-center justify-center"
></btrix-not-found>`;
}
onLogOut(event: CustomEvent<{ redirect?: boolean } | null>) {
const detail = event.detail || {};
const redirect = detail.redirect !== false;

View File

@ -182,12 +182,12 @@ export class Archive extends LiteElement {
<div class="mt-3 border rounded-lg p-4 md:p-8 md:pt-6">
<h2 class="text-lg font-medium mb-4">${msg("Add New Member")}</h2>
<btrix-invite-form
<btrix-archive-invite-form
@success=${this.onInviteSuccess}
@cancel=${() => this.navTo(`/archives/${this.archiveId}/members`)}
.authState=${this.authState}
.archiveId=${this.archiveId}
></btrix-invite-form>
></btrix-archive-invite-form>
</div>
`;
}

View File

@ -51,7 +51,9 @@ export class Archives extends LiteElement {
${this.userInfo &&
archive.users &&
isOwner(archive.users[this.userInfo.id].role)
? html`<sl-tag size="small" type="primary">Owner</sl-tag>`
? html`<sl-tag size="small" type="primary"
>${msg("Owner")}</sl-tag
>`
: ""}
</li>
`

View File

@ -21,3 +21,8 @@ import(/* webpackChunkName: "reset-password" */ "./reset-password").then(
customElements.define("btrix-reset-password", ResetPassword);
}
);
import(/* webpackChunkName: "users-invite" */ "./users-invite").then(
({ UsersInvite }) => {
customElements.define("btrix-users-invite", UsersInvite);
}
);

View File

@ -2,6 +2,7 @@ import { state, property } from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize";
import { createMachine, interpret, assign } from "@xstate/fsm";
import { DASHBOARD_ROUTE } from "../routes";
import type { AuthState } from "../utils/AuthService";
import LiteElement, { html } from "../utils/LiteElement";
import type { LoggedInEvent } from "../utils/AuthService";
@ -238,7 +239,7 @@ export class Join extends LiteElement {
try {
await this.apiFetch(`/invite/accept/${this.token}`, this.authState);
this.navTo("/archives");
this.navTo(DASHBOARD_ROUTE);
} catch (err: any) {
if (err.isApiError && err.message === "Invalid Invite Code") {
this.joinStateService.send({

View File

@ -0,0 +1,52 @@
import { state, property } from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize";
import type { AuthState } from "../utils/AuthService";
import LiteElement, { html } from "../utils/LiteElement";
import { needLogin } from "../utils/auth";
@needLogin
@localized()
export class UsersInvite extends LiteElement {
@property({ type: Object })
authState?: AuthState;
@state()
private invitedEmail?: string;
render() {
let successMessage;
if (this.invitedEmail) {
successMessage = html`
<div>
<btrix-alert type="success"
>${msg(str`Sent invite to ${this.invitedEmail}`)}</btrix-alert
>
</div>
`;
}
return html`<div class="grid gap-4">
<header class="text-xl font-bold">
<h1 class="inline-block mr-2">${msg("Users")}</h1>
<sl-tag class="uppercase" type="primary" size="small"
>${msg("admin")}</sl-tag
>
</header>
${successMessage}
<main class="border rounded-lg p-4 md:p-8 md:pt-6">
<h2 class="text-lg font-medium mb-4">${msg("Invite Users")}</h2>
<btrix-invite-form
.authState=${this.authState}
@success=${this.onSuccess}
></btrix-invite-form>
</main>
</div>`;
}
private onSuccess(event: CustomEvent<{ inviteEmail: string }>) {
this.invitedEmail = event.detail.inviteEmail;
}
}

18
frontend/src/routes.ts Normal file
View File

@ -0,0 +1,18 @@
export const ROUTES = {
home: "/",
join: "/join/:token?email",
signUp: "/sign-up",
verify: "/verify?token",
login: "/log-in",
forgotPassword: "/log-in/forgot-password",
resetPassword: "/reset-password?token",
myAccount: "/my-account",
accountSettings: "/account/settings",
archives: "/archives",
archive: "/archives/:id/:tab",
archiveAddMember: "/archives/:id/:tab/add-member",
users: "/users",
usersInvite: "/users/invite",
} as const;
export const DASHBOARD_ROUTE = ROUTES.archives;

View File

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

View File

@ -1,5 +1,7 @@
import { DASHBOARD_ROUTE } from "../routes";
import LiteElement from "../utils/LiteElement";
import type { AuthState } from "../utils/AuthService";
import type { CurrentUser } from "../types/user";
/**
* Block rendering and dispatch event if user is not logged in