Fix archive invite flow (#61)

closes #49
This commit is contained in:
sua yoo 2021-12-05 18:23:24 -08:00 committed by GitHub
parent ba69ac02bc
commit d5c665be8e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 178 additions and 226 deletions

View File

@ -288,13 +288,25 @@ export class App extends LiteElement {
case "join":
return html`<btrix-join
class="w-full md:bg-gray-100 flex items-center justify-center"
@navigate="${this.onNavigateTo}"
@logged-in="${this.onLoggedIn}"
.authState="${this.authService.authState}"
token="${this.viewState.params.token}"
email="${this.viewState.params.email}"
></btrix-join>`;
case "acceptInvite":
return html`<btrix-accept-invite
class="w-full md:bg-gray-100 flex items-center justify-center"
@navigate="${this.onNavigateTo}"
@logged-in="${this.onLoggedIn}"
@notify="${this.onNotify}"
.authState="${this.authService.authState}"
token="${this.viewState.params.token}"
email="${this.viewState.params.email}"
></btrix-accept-invite>`;
case "login":
case "loginWithRedirect":
case "forgotPassword":
return html`<btrix-log-in
class="w-full md:bg-gray-100 flex items-center justify-center"
@ -302,6 +314,7 @@ export class App extends LiteElement {
@logged-in=${this.onLoggedIn}
.authState=${this.authService.authState}
.viewState=${this.viewState}
redirectUrl=${this.viewState.params.redirectUrl}
></btrix-log-in>`;
case "resetPassword":
@ -413,7 +426,7 @@ export class App extends LiteElement {
});
if (!detail.api) {
this.navigate(DASHBOARD_ROUTE);
this.navigate(detail.redirectUrl || DASHBOARD_ROUTE);
}
if (detail.firstLogin) {

View File

@ -0,0 +1,129 @@
import { state, property } from "lit/decorators.js";
import { msg, localized } from "@lit/localize";
import LiteElement, { html } from "../utils/LiteElement";
import type { AuthState, LoggedInEvent } from "../utils/AuthService";
import AuthService from "../utils/AuthService";
import { DASHBOARD_ROUTE } from "../routes";
@localized()
export class AcceptInvite extends LiteElement {
@property({ type: Object })
authState?: AuthState;
@property({ type: String })
token?: string;
@property({ type: String })
email?: string;
@state()
serverError?: string;
connectedCallback(): void {
if (this.token && this.email) {
super.connectedCallback();
} else {
throw new Error("Missing email or token");
}
}
private get isLoggedIn(): boolean {
return Boolean(
this.authState && this.email && this.authState.username === this.email
);
}
firstUpdated() {
if (!this.isLoggedIn) {
this.dispatchEvent(
new CustomEvent("notify", {
detail: {
message: msg("Log in to continue."),
type: "success",
icon: "check2-circle",
duration: 10000,
},
})
);
this.navTo(
`/log-in?redirectUrl=${encodeURIComponent(
`${window.location.pathname}${window.location.search}`
)}`
);
}
}
render() {
let serverError;
if (this.serverError) {
serverError = html`
<div class="mb-5">
<btrix-alert id="formError" type="danger"
>${this.serverError}</btrix-alert
>
</div>
`;
}
return html`
<article class="w-full max-w-sm grid gap-5">
${serverError}
<main class="md:bg-white md:shadow-xl md:rounded-lg md:px-12 md:py-12">
<h1 class="text-3xl text-center font-semibold mb-5">
${msg("Join archive")}
</h1>
<!-- TODO archive details -->
<div class="text-center">
<sl-button type="primary" @click=${this.onAccept}
>${msg("Accept invitation")}</sl-button
>
</div>
</main>
</article>
`;
}
private async onAccept() {
if (!this.authState || !this.isLoggedIn) {
// TODO handle error
this.serverError = msg("Something unexpected went wrong");
return;
}
try {
await this.apiFetch(
`/archives/invite-accept/${this.token}`,
this.authState,
{
method: "POST",
}
);
this.dispatchEvent(
new CustomEvent("notify", {
detail: {
// TODO archive details
message: msg("You've joined the archive."),
type: "success",
icon: "check2-circle",
},
})
);
this.navTo(DASHBOARD_ROUTE);
} catch (err: any) {
if (err.isApiError && err.message === "Invalid Invite Code") {
this.serverError = msg("This invitation is not valid.");
} else {
this.serverError = msg("Something unexpected went wrong");
}
}
}
}

View File

@ -36,12 +36,12 @@ export class Archives extends LiteElement {
return html`<div class="grid gap-4">
<h1 class="text-xl font-bold">${msg("Archives")}</h1>
<ul class="border rounded-lg grid gap-6 overflow-hidden">
<ul class="border rounded-lg overflow-hidden">
${this.archiveList.map(
(archive) =>
(archive, i) =>
html`
<li
class="p-3 md:p-6 hover:bg-gray-50"
class="p-3 md:p-6 hover:bg-gray-50${i > 0 ? " border-t" : ""}"
role="button"
@click=${this.makeOnArchiveClick(archive)}
>

View File

@ -26,3 +26,8 @@ import(/* webpackChunkName: "users-invite" */ "./users-invite").then(
customElements.define("btrix-users-invite", UsersInvite);
}
);
import(/* webpackChunkName: "accept-invite" */ "./accept-invite").then(
({ AcceptInvite }) => {
customElements.define("btrix-accept-invite", AcceptInvite);
}
);

View File

@ -1,99 +1,12 @@
import { state, property } from "lit/decorators.js";
import { msg, localized, str } from "@lit/localize";
import { createMachine, interpret, assign } from "@xstate/fsm";
import { msg, localized } from "@lit/localize";
import { DASHBOARD_ROUTE } from "../routes";
import type { AuthState } from "../utils/AuthService";
import LiteElement, { html } from "../utils/LiteElement";
import type { LoggedInEvent } from "../utils/AuthService";
import AuthService from "../utils/AuthService";
type JoinContext = {
serverError?: string;
};
type JoinErrorEvent = {
type: "ERROR";
detail: {
serverError?: JoinContext["serverError"];
};
};
type JoinEvent =
| { type: "SUBMIT_SIGN_UP" }
| { type: "ACCEPT_INVITE" }
| { type: "SIGN_UP_SUCCESS" }
| JoinErrorEvent;
type JoinTypestate =
| {
value: "initial";
context: JoinContext;
}
| {
value: "submittingForm";
context: JoinContext;
}
| {
value: "acceptInvite";
context: JoinContext;
}
| {
value: "acceptingInvite";
context: JoinContext;
};
const initialContext = {};
const machine = createMachine<JoinContext, JoinEvent, JoinTypestate>(
{
id: "join",
initial: "initial",
context: initialContext,
states: {
["initial"]: {
on: {
SUBMIT_SIGN_UP: "submittingForm",
},
},
["submittingForm"]: {
on: {
SIGN_UP_SUCCESS: "acceptInvite",
ERROR: {
target: "initial",
actions: "setError",
},
},
},
["acceptInvite"]: {
on: {
ACCEPT_INVITE: "acceptingInvite",
},
},
["acceptingInvite"]: {
on: {
ERROR: {
target: "acceptInvite",
actions: "setError",
},
},
},
},
},
{
actions: {
setError: assign((context, event) => ({
...context,
...(event as JoinErrorEvent).detail,
})),
},
}
);
@localized()
export class Join extends LiteElement {
private joinStateService = interpret(machine);
@property({ type: Object })
authState?: AuthState;
@property({ type: String })
token?: string;
@ -101,7 +14,7 @@ export class Join extends LiteElement {
email?: string;
@state()
private joinState = machine.initialState;
serverError?: string;
connectedCallback(): void {
if (this.token && this.email) {
@ -111,151 +24,33 @@ export class Join extends LiteElement {
}
}
firstUpdated() {
// Enable state machine
this.joinStateService.subscribe((state) => {
this.joinState = state;
});
this.joinStateService.start();
}
disconnectedCallback() {
this.joinStateService.stop();
}
render() {
const isSignUp =
this.joinState.value === "initial" ||
this.joinState.value === "submittingForm";
const isAcceptInvite =
this.joinState.value === "acceptInvite" ||
this.joinState.value === "acceptingInvite";
let content;
if (isSignUp) {
content = this.renderSignUp();
} else if (isAcceptInvite) {
content = this.renderAccept();
}
return html`
<article class="w-full max-w-sm grid gap-5">
<h1 class="text-3xl font-semibold mb-3">${msg("Join archive")}</h1>
<!-- TODO invitation details -->
<div class="flex items-center text-sm font-medium">
<div
class="flex-0 mx-3 ${isSignUp ? "text-primary" : "text-blue-400"}"
>
1. Create account
</div>
<hr
class="flex-1 mx-3 ${isSignUp
? "border-gray-400"
: "border-blue-400"}"
/>
<div
class="flex-0 mx-3 ${isSignUp ? "text-gray-400" : "text-primary"}"
>
2. Accept invite
</div>
</div>
<main class="md:bg-white md:shadow-xl md:rounded-lg md:px-12 md:py-12">
${content}
<h1 class="text-3xl font-semibold mb-5">
${msg("Join Browsertrix Cloud")}
</h1>
<!-- TODO archive details if available -->
<btrix-sign-up-form
email=${this.email!}
inviteToken=${this.token!}
@authenticated=${this.onAuthenticated}
></btrix-sign-up-form>
</main>
</article>
`;
}
private renderSignUp() {
return html`
<btrix-sign-up-form
email=${this.email!}
inviteToken=${this.token!}
@submit=${this.onSignUp}
@error=${() => this.joinStateService.send("ERROR")}
@authenticated=${this.onAuthenticated}
></btrix-sign-up-form>
`;
}
private renderAccept() {
let serverError;
if (this.joinState.context.serverError) {
serverError = html`
<div class="mb-5">
<btrix-alert id="formError" type="danger"
>${this.joinState.context.serverError}</btrix-alert
>
</div>
`;
}
return html`
${serverError}
<div class="text-center">
<sl-button type="primary" @click=${this.onAccept}
>Accept invitation</sl-button
>
</div>
`;
}
private onSignUp() {
this.joinStateService.send("SUBMIT_SIGN_UP");
}
private onAuthenticated(event: LoggedInEvent) {
this.joinStateService.send("SIGN_UP_SUCCESS");
this.dispatchEvent(
AuthService.createLoggedInEvent({
...event.detail,
api: true,
// TODO separate logic for confirmation message
// firstLogin: true,
})
);
}
private async onAccept() {
this.joinStateService.send("ACCEPT_INVITE");
if (!this.authState) {
this.joinStateService.send({
type: "ERROR",
detail: {
serverError: msg("Something unexpected went wrong"),
},
});
return;
}
try {
await this.apiFetch(`/invite/accept/${this.token}`, this.authState);
this.navTo(DASHBOARD_ROUTE);
} catch (err: any) {
if (err.isApiError && err.message === "Invalid Invite Code") {
this.joinStateService.send({
type: "ERROR",
detail: {
serverError: msg("This invitation is not valid."),
},
});
} else {
this.joinStateService.send({
type: "ERROR",
detail: {
serverError: msg("Something unexpected went wrong"),
},
});
}
}
}
}

View File

@ -4,7 +4,9 @@ import { createMachine, interpret, assign } from "@xstate/fsm";
import type { ViewState } from "../utils/APIRouter";
import LiteElement, { html } from "../utils/LiteElement";
import type { LoggedInEventDetail } from "../utils/AuthService";
import AuthService from "../utils/AuthService";
import { DASHBOARD_ROUTE } from "../routes";
type FormContext = {
successMessage?: string;
@ -126,6 +128,9 @@ export class LogInPage extends LiteElement {
@property({ type: Object })
viewState!: ViewState;
@property({ type: String })
redirectUrl: string = DASHBOARD_ROUTE;
private formStateService = interpret(machine);
@state()
@ -308,6 +313,8 @@ export class LogInPage extends LiteElement {
try {
const data = await AuthService.login({ email: username, password });
(data as LoggedInEventDetail).redirectUrl = this.redirectUrl;
this.dispatchEvent(AuthService.createLoggedInEvent(data));
// no state update here, since "logged-in" event

View File

@ -2,8 +2,10 @@ export const ROUTES = {
home: "/",
join: "/join/:token?email",
signUp: "/sign-up",
acceptInvite: "/invite/accept/:token?email",
verify: "/verify?token",
login: "/log-in",
loginWithRedirect: "/log-in?redirectUrl",
forgotPassword: "/log-in/forgot-password",
resetPassword: "/reset-password?token",
myAccount: "/my-account",

View File

@ -14,9 +14,10 @@ type Session = {
export type AuthState = (Auth & Session) | null;
type LoggedInEventDetail = Auth & {
export type LoggedInEventDetail = Auth & {
api?: boolean;
firstLogin?: boolean;
redirectUrl?: string;
};
export interface LoggedInEvent<T = LoggedInEventDetail> extends CustomEvent {