parent
53beb84c01
commit
9c4bec1411
@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
128
frontend/src/components/archive-invite-form.ts
Normal file
128
frontend/src/components/archive-invite-form.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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",
|
||||
|
14
frontend/src/components/not-found.ts
Normal file
14
frontend/src/components/not-found.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
}
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
`;
|
||||
}
|
||||
|
@ -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>
|
||||
`
|
||||
|
@ -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);
|
||||
}
|
||||
);
|
||||
|
@ -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({
|
||||
|
52
frontend/src/pages/users-invite.ts
Normal file
52
frontend/src/pages/users-invite.ts
Normal 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
18
frontend/src/routes.ts
Normal 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;
|
@ -3,4 +3,5 @@ export type CurrentUser = {
|
||||
email: string;
|
||||
name: string;
|
||||
isVerified: boolean;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user