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; this.isRequesting = false;
} }
} }
customElements.define("bt-request-verify", RequestVerify); customElements.define("btrix-request-verify", RequestVerify);
type FormContext = { type FormContext = {
successMessage?: string; successMessage?: string;
@ -224,7 +224,9 @@ export class AccountSettings extends LiteElement {
})}</sl-tag })}</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 }) => { import("./account-settings").then(({ AccountSettings }) => {
customElements.define("btrix-account-settings", 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 }) => { import("./invite-form").then(({ InviteForm }) => {
customElements.define("btrix-invite-form", InviteForm); customElements.define("btrix-invite-form", InviteForm);
}); });
import("./sign-up-form").then(({ SignUpForm }) => { import("./sign-up-form").then(({ SignUpForm }) => {
customElements.define("btrix-sign-up-form", SignUpForm); customElements.define("btrix-sign-up-form", SignUpForm);
}); });
import("./not-found").then(({ NotFound }) => {
customElements.define("btrix-not-found", NotFound);
});
customElements.define("btrix-alert", Alert); customElements.define("btrix-alert", Alert);

View File

@ -1,15 +1,14 @@
import { state, property } from "lit/decorators.js"; 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 type { AuthState } from "../utils/AuthService";
import LiteElement, { html } from "../utils/LiteElement"; import LiteElement, { html } from "../utils/LiteElement";
import { AccessCode } from "../utils/archives";
/**
* @event success
*/
@localized() @localized()
export class InviteForm extends LiteElement { export class InviteForm extends LiteElement {
@property({ type: String })
archiveId?: string;
@property({ type: Object }) @property({ type: Object })
authState?: AuthState; authState?: AuthState;
@ -35,7 +34,7 @@ export class InviteForm extends LiteElement {
return html` return html`
<sl-form <sl-form
class="max-w-md" class="max-w-md"
@sl-submit=${this.onSubmitInvite} @sl-submit=${this.onSubmit}
aria-describedby="formError" aria-describedby="formError"
> >
<div class="mb-5"> <div class="mb-5">
@ -43,26 +42,14 @@ export class InviteForm extends LiteElement {
id="inviteEmail" id="inviteEmail"
name="inviteEmail" name="inviteEmail"
type="email" type="email"
label="${msg("Email")}" label=${msg("Email")}
placeholder="team-member@email.com" placeholder=${msg("person@email.com", {
desc: "Placeholder text for email to invite",
})}
required required
> >
</sl-input> </sl-input>
</div> </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} ${formError}
@ -74,17 +61,12 @@ export class InviteForm extends LiteElement {
?disabled=${this.isSubmitting} ?disabled=${this.isSubmitting}
>${msg("Invite")}</sl-button >${msg("Invite")}</sl-button
> >
<sl-button
type="text"
@click=${() => this.dispatchEvent(new CustomEvent("cancel"))}
>${msg("Cancel")}</sl-button
>
</div> </div>
</sl-form> </sl-form>
`; `;
} }
async onSubmitInvite(event: { detail: { formData: FormData } }) { async onSubmit(event: { detail: { formData: FormData } }) {
if (!this.authState) return; if (!this.authState) return;
this.isSubmitting = true; this.isSubmitting = true;
@ -94,7 +76,8 @@ export class InviteForm extends LiteElement {
try { try {
const data = await this.apiFetch( const data = await this.apiFetch(
`/archives/${this.archiveId}/invite`, // TODO actual path
`/invite`,
this.authState, this.authState,
{ {
method: "POST", 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", email: "test-user@example.com",
name: "Test User", name: "Test User",
is_verified: false, is_verified: false,
is_superuser: false,
}) })
); );
}); });
@ -55,6 +56,7 @@ describe("browsertrix-app", () => {
email: "test-user@example.com", email: "test-user@example.com",
name: "Test User", name: "Test User",
isVerified: false, 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 { CurrentUser } from "./types/user";
import type { AuthState } from "./utils/AuthService"; import type { AuthState } from "./utils/AuthService";
import theme from "./theme"; import theme from "./theme";
import { ROUTES, DASHBOARD_ROUTE } from "./routes";
import "./shoelace"; import "./shoelace";
import "./components"; import "./components";
import "./pages"; import "./pages";
const REGISTRATION_ENABLED = process.env.REGISTRATION_ENABLED === "true"; 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 = { type DialogContent = {
label?: TemplateResult | string; label?: TemplateResult | string;
@ -126,6 +108,7 @@ export class App extends LiteElement {
email: data.email, email: data.email,
name: data.name, name: data.name,
isVerified: data.is_verified, isVerified: data.is_verified,
isAdmin: data.is_superuser,
}; };
} catch (err: any) { } catch (err: any) {
if (err?.message === "Unauthorized") { if (err?.message === "Unauthorized") {
@ -159,6 +142,10 @@ export class App extends LiteElement {
render() { render() {
return html` return html`
<style> <style>
.uppercase {
letter-spacing: 0.06em;
}
${theme} ${theme}
</style> </style>
@ -249,23 +236,43 @@ export class App extends LiteElement {
${navLink({ ${navLink({
activeRoutes: ["archives", "archive"], activeRoutes: ["archives", "archive"],
href: DASHBOARD_ROUTE, href: DASHBOARD_ROUTE,
label: "Archives", label: msg("Archives"),
})} })}
</ul> </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> </nav>
<div class="p-4 md:p-8 flex-1">${template}</div> <div class="p-4 md:p-8 flex-1">${template}</div>
</div> </div>
`; `;
switch (this.viewState.route) { switch (this.viewState.route) {
case "signUp": case "signUp": {
return html`<btrix-sign-up if (REGISTRATION_ENABLED) {
class="w-full md:bg-gray-100 flex items-center justify-center" return html`<btrix-sign-up
@navigate="${this.onNavigateTo}" class="w-full md:bg-gray-100 flex items-center justify-center"
@logged-in="${this.onLoggedIn}" @navigate="${this.onNavigateTo}"
@log-out="${this.onLogOut}" @logged-in="${this.onLoggedIn}"
.authState="${this.authService.authState}" @log-out="${this.onLogOut}"
></btrix-sign-up>`; .authState="${this.authService.authState}"
></btrix-sign-up>`;
} else {
return this.renderNotFoundPage();
}
}
case "verify": case "verify":
return html`<btrix-verify return html`<btrix-verify
@ -359,11 +366,31 @@ export class App extends LiteElement {
tab="${this.viewState.tab || "running"}" tab="${this.viewState.tab || "running"}"
></btrix-archive>`); ></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: 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>) { onLogOut(event: CustomEvent<{ redirect?: boolean } | null>) {
const detail = event.detail || {}; const detail = event.detail || {};
const redirect = detail.redirect !== false; 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"> <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> <h2 class="text-lg font-medium mb-4">${msg("Add New Member")}</h2>
<btrix-invite-form <btrix-archive-invite-form
@success=${this.onInviteSuccess} @success=${this.onInviteSuccess}
@cancel=${() => this.navTo(`/archives/${this.archiveId}/members`)} @cancel=${() => this.navTo(`/archives/${this.archiveId}/members`)}
.authState=${this.authState} .authState=${this.authState}
.archiveId=${this.archiveId} .archiveId=${this.archiveId}
></btrix-invite-form> ></btrix-archive-invite-form>
</div> </div>
`; `;
} }

View File

@ -51,7 +51,9 @@ export class Archives extends LiteElement {
${this.userInfo && ${this.userInfo &&
archive.users && archive.users &&
isOwner(archive.users[this.userInfo.id].role) 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> </li>
` `

View File

@ -21,3 +21,8 @@ import(/* webpackChunkName: "reset-password" */ "./reset-password").then(
customElements.define("btrix-reset-password", ResetPassword); 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 { msg, localized, str } from "@lit/localize";
import { createMachine, interpret, assign } from "@xstate/fsm"; import { createMachine, interpret, assign } from "@xstate/fsm";
import { DASHBOARD_ROUTE } from "../routes";
import type { AuthState } from "../utils/AuthService"; import type { AuthState } from "../utils/AuthService";
import LiteElement, { html } from "../utils/LiteElement"; import LiteElement, { html } from "../utils/LiteElement";
import type { LoggedInEvent } from "../utils/AuthService"; import type { LoggedInEvent } from "../utils/AuthService";
@ -238,7 +239,7 @@ export class Join extends LiteElement {
try { try {
await this.apiFetch(`/invite/accept/${this.token}`, this.authState); await this.apiFetch(`/invite/accept/${this.token}`, this.authState);
this.navTo("/archives"); this.navTo(DASHBOARD_ROUTE);
} catch (err: any) { } catch (err: any) {
if (err.isApiError && err.message === "Invalid Invite Code") { if (err.isApiError && err.message === "Invalid Invite Code") {
this.joinStateService.send({ 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; email: string;
name: string; name: string;
isVerified: boolean; isVerified: boolean;
isAdmin: boolean;
}; };

View File

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