padloc/packages/app/src/elements/login-signup.ts

844 lines
34 KiB
TypeScript

import "./password-input";
import { translate as $l } from "@padloc/locale/src/translate";
import { ErrorCode } from "@padloc/core/src/error";
import { AccountStatus, AuthPurpose } from "@padloc/core/src/auth";
import { router } from "../globals";
import { StartForm } from "./start-form";
import { Input } from "./input";
import { Button } from "./button";
import { alert, choose, dialog, prompt, confirm } from "../lib/dialog";
import "./logo";
import { customElement, query, state } from "lit/decorators.js";
import { css, html } from "lit";
import { completeAuthRequest, startAuthRequest } from "@padloc/core/src/platform";
import { mixins } from "../styles";
import { isTouch, passwordStrength } from "../lib/util";
import { generatePassphrase } from "@padloc/core/src/diceware";
import { GeneratorDialog } from "./generator-dialog";
import "./scroller";
import { Drawer } from "./drawer";
import { AccountProvisioning, ProvisioningStatus } from "@padloc/core/src/provisioning";
import "./rich-content";
import { displayProvisioning } from "../lib/provisioning";
import { StartAuthRequestResponse } from "@padloc/core/src/api";
import { Confetti } from "./confetti";
import { singleton } from "../lib/singleton";
@customElement("pl-login-signup")
export class LoginOrSignup extends StartForm {
readonly routePattern = /^(start|login|signup)(?:\/(consent|choose-password|confirm-password|success))?/;
@state()
private _page = "";
@state()
private _step = "";
@state()
private _password: string = "";
@state()
private _loginError: string = "";
private _loginFailedCount = 0;
@query("#emailInput")
private _emailInput: Input;
@query("#nameInput")
private _nameInput: Input;
@query("#tosCheckbox")
private _tosCheckbox: HTMLInputElement;
@query("#loginPasswordInput")
private _loginPasswordInput: Input;
@query("#repeatPasswordInput")
private _repeatPasswordInput: Input;
@query("#submitEmailButton")
private _submitEmailButton: Button;
@query("#loginButton")
private _loginButton: Button;
@query("#consentDrawer")
private _consentDrawer: Drawer;
@query("#confirmPasswordButton")
private _confirmPasswordButton: Button;
@query("#masterPasswordDrawer")
private _masterPasswordDrawer: Drawer;
@singleton("pl-confetti")
private _confetti: Confetti;
@dialog("pl-generator-dialog")
private _generatorDialog: GeneratorDialog;
async reset() {
await this.updateComplete;
this._emailInput.value = router.params.email || "";
// this._nameInput.value = router.params.name || "";
this._loginPasswordInput.value = "";
this._repeatPasswordInput.value = "";
this._submitEmailButton.stop();
this._tosCheckbox.checked = false;
super.reset();
}
async handleRoute([page, step]: [string, string]) {
if (!this._authToken && !(page === "start" || (page === "signup" && step === "success"))) {
this.redirect("start");
return;
}
if (page === "signup" && !step) {
this.redirect("signup/consent");
return;
}
if (page === "signup" && step === "confirm-password" && !this._password) {
this.redirect("signup/choose-password");
return;
}
this._page = page;
this._step = step;
if (this._email && this._emailInput && !this._emailInput.value) {
this._emailInput.value = this._email;
}
if (this._page === "start") {
const pendingRequest = await this._getPendingAuth();
if (pendingRequest) {
this._emailInput.value = pendingRequest.email;
this._submitEmail(pendingRequest);
}
}
if (this._page === "signup" && this._step === "consent") {
this._nameInput?.focus();
}
if (this._page === "signup" && this._step === "choose-password") {
!this._password ? this._generatePassphrase() : this._revealPassphrase();
}
if (this._page === "signup" && this._step === "success") {
this._confetti.pop();
}
if (this._page === "login") {
this._loginPasswordInput?.focus();
}
}
private async _getPendingAuth() {
if (!this.router.params.pendingAuth) {
return null;
}
try {
return await this.app.storage.get(StartAuthRequestResponse, this.router.params.pendingAuth);
} catch (e) {
return null;
}
}
private async _authenticate({
email,
pendingRequest: req,
authenticatorIndex = 0,
}: {
email: string;
authenticatorIndex?: number;
pendingRequest?: StartAuthRequestResponse;
}): Promise<{
token: string;
accountStatus: AccountStatus;
provisioning: AccountProvisioning;
deviceTrusted: boolean;
} | null> {
try {
if (!req) {
req = await startAuthRequest({
purpose: AuthPurpose.Login,
email: this._emailInput.value,
authenticatorIndex,
});
await this.app.storage.save(req);
this.router.setParams({ pendingAuth: req.id });
}
try {
const res = await completeAuthRequest(req);
return res;
} finally {
this.router.setParams({ pendingAuth: undefined });
this.app.storage.delete(req);
}
} catch (e: any) {
if (e.code === ErrorCode.NOT_FOUND) {
await alert(e.message, { title: $l("Authentication Failed"), options: [$l("Cancel")] });
return null;
}
const choice = await alert(e.message, {
title: $l("Authentication Failed"),
options: [$l("Try Again"), $l("Try Another Method"), $l("Cancel")],
});
switch (choice) {
case 0:
return this._authenticate({ email, authenticatorIndex });
case 1:
return this._authenticate({ email, authenticatorIndex: authenticatorIndex + 1 });
default:
return null;
}
}
}
private async _submitEmail(pendingRequest?: StartAuthRequestResponse): Promise<void> {
if (this._submitEmailButton.state === "loading") {
return;
}
if (!this._emailInput.reportValidity()) {
return;
}
const email = this._emailInput.value;
this._emailInput.blur();
if (this._emailInput.invalid) {
alert($l("Please enter a valid email address!"));
this.rumble();
this._emailInput.focus();
return;
}
this._submitEmailButton.start();
const authRes = await this._authenticate({ email, pendingRequest });
if (!authRes) {
this._submitEmailButton.fail();
return;
}
this._submitEmailButton.success();
if ([ProvisioningStatus.Unprovisioned, ProvisioningStatus.Suspended].includes(authRes.provisioning.status)) {
await displayProvisioning(authRes.provisioning);
return;
}
router.go(authRes.accountStatus === AccountStatus.Active ? "login" : "signup", {
...this.router.params,
email,
authToken: authRes.token,
deviceTrusted: authRes.deviceTrusted.toString(),
});
}
private async _accountDoesntExist(email: string) {
const signup = await confirm(
$l("An account with this email address does not exist!"),
$l("Sign Up"),
$l("Cancel"),
{
icon: "info",
}
);
if (signup) {
router.go("start", { email });
}
}
private async _login(): Promise<void> {
if (this._loginButton.state === "loading") {
return;
}
if (!this._emailInput.reportValidity()) {
return;
}
this._emailInput.blur();
this._loginPasswordInput.blur();
const email = this._emailInput.value;
let password = this._loginPasswordInput.value;
if (this._emailInput.invalid) {
await alert($l("Please enter a valid email address!"));
this.go("start");
return;
}
if (!password) {
this._loginError = $l("Please enter your master password!");
this.rumble();
this._loginPasswordInput.focus();
return;
}
this._loginError = "";
this._loginButton.start();
try {
let addTrustedDevice = false;
if (!this._deviceTrusted) {
addTrustedDevice = await confirm(
$l("Do you want to add this device as a trusted device?"),
$l("Yes"),
$l("No"),
{ title: $l("Add Trusted Device") }
);
}
await this.app.login(email, password, this._authToken, addTrustedDevice);
this._loginButton.success();
const { email: _email, authToken, deviceTrusted, invite: _invite, ...params } = this.router.params;
const invite = this._invite;
this.go(invite ? `invite/${invite.orgId}/${invite.id}` : "items", params);
} catch (e: any) {
switch (e.code) {
case ErrorCode.AUTHENTICATION_REQUIRED:
this._loginButton.stop();
await alert($l("We failed to verify your email address. Please start over!"), {
type: "warning",
title: $l("Authentication Failed"),
});
this.go("start", { email });
// if (!verify.hasAccount) {
// if (verify.hasLegacyAccount) {
// this._migrateAccount(email, password, verify.legacyToken!, verify.token);
// } else {
// this._accountDoesntExist(email);
// }
// return;
// }
return;
case ErrorCode.INVALID_CREDENTIALS:
this._loginError = $l("Wrong master password. Please try again!");
this._loginButton.fail();
this.rumble();
this._loginFailedCount++;
if (this._loginFailedCount > 2) {
const recover = await confirm(
$l("Can't remember your master password?"),
$l("Recover Account"),
$l("Try Again")
);
if (recover) {
router.go("recover", { email });
}
} else {
this._loginPasswordInput.focus();
}
return;
case ErrorCode.NOT_FOUND:
this._loginButton.fail();
this._accountDoesntExist(email);
return;
default:
alert(e.message, { type: "warning" });
throw e;
}
}
}
private async _submitName() {
this.go("signup/choose-password", { ...this.router.params, name: this._nameInput.value });
}
private async _generatePassphrase() {
this._password = await generatePassphrase(4, " ", [this.app.state.device.locale]);
this._masterPasswordDrawer.updateInnerSize();
this._revealPassphrase();
}
private async _revealPassphrase(duration = 2000) {
const wrapper = this.renderRoot.querySelector(".master-password")!;
wrapper.classList.add("reveal");
setTimeout(() => wrapper.classList.remove("reveal"), duration);
}
private async _editMasterPassword(): Promise<void> {
const choice = await choose(
$l("We recommend using a randomly generated password that is both strong and easy to remember."),
[$l("Keep This One"), $l("Generate Another"), $l("Choose My Own")],
{ title: $l("Want A Different Master Password?") }
);
let newPwd;
switch (choice) {
case 0:
break;
case 1:
newPwd = await this._generatorDialog.show();
break;
case 2:
newPwd = await prompt(
$l("We recommend using a randomly generated password that is both strong and easy to remember."),
{ title: $l("Choose Own Master Password"), label: $l("Enter Master Password"), type: "password" }
);
break;
}
if (newPwd) {
const strength = await passwordStrength(newPwd);
if (strength.score < 2) {
const choice = await choose(
$l(
"The password you entered is weak which makes it easier for attackers to break " +
"the encryption used to protect your data. Try to use a longer password or include a " +
"variation of uppercase, lowercase and special characters as well as numbers!"
),
[$l("Choose Different Password"), $l("Use Anyway")],
{
type: "warning",
title: $l("WARNING: Weak Password"),
icon: null,
preventDismiss: true,
}
);
if (choice === 0) {
return this._editMasterPassword();
}
}
this._password = newPwd;
this._revealPassphrase();
}
}
private _submitPassword() {
this.go("signup/confirm-password");
this._repeatPasswordInput.focus();
}
private async _confirmPassword() {
if (this._confirmPasswordButton.state === "loading") {
return;
}
if (this._password !== this._repeatPasswordInput.value) {
await alert($l("You didn't repeat your master password correctly. Try again!"), {
type: "warning",
title: "Incorrect Master Password",
});
return;
}
const email = this._email;
const name = this._name;
const password = this._password;
this._confirmPasswordButton.start();
try {
await this.app.signup({ email, password, name, authToken: this._authToken });
this._confirmPasswordButton.success();
const { email: _email, name: _name, authToken, deviceTrusted, ...params } = this.router.params;
this.go("signup/success", params);
} catch (e) {
this._confirmPasswordButton.fail();
switch (e.code) {
case ErrorCode.ACCOUNT_EXISTS:
this._accountExists();
return;
default:
alert(e.message, { type: "warning" });
throw e;
}
}
this._password = "";
}
private _done() {
const invite = this._invite;
const { invite: _inv, ...params } = this.router.params;
this.go(invite ? `invite/${invite.orgId}/${invite.id}` : "items", params);
}
private async _accountExists() {
const choice = await choose(
$l("An account with this email address already exists!"),
[$l("Login"), $l("Change Email")],
{ type: "warning", title: $l("Account Exists") }
);
if (choice === 0) {
router.go("login");
} else {
const { authToken, ...params } = router.params;
router.go("signup", params);
this._emailInput.focus();
}
}
static styles = [
...StartForm.styles,
css`
pl-input:not([focused]) + .hint,
pl-password-input:not([focused]) + .hint {
opacity: 0.5;
text-shadow: none;
}
.master-password {
position: relative;
background: var(--shade-2-color);
font-family: var(--font-family-mono);
font-size: var(--font-size-medium);
overflow-wrap: break-word;
text-align: center;
padding: 1em;
border: solid 1px var(--color-shade-2);
border-radius: 0.5em;
background: var(--color-background);
}
.master-password-cover {
${mixins.fullbleed()};
height: 2em;
line-height: 2em;
margin: auto;
text-shadow: none;
color: var(--color-shade-6);
}
.master-password:hover {
background: var(--shade-3-color);
}
.master-password > * {
transition: transform 0.2s cubic-bezier(1, -0.3, 0, 1.3), opacity 0.2s;
}
.master-password:not(:hover):not(.reveal) .master-password-value,
.master-password:hover .master-password-cover,
.master-password.reveal .master-password-cover {
opacity: 0;
transform: scale(0);
}
`,
];
render() {
const invite = this._invite;
return html`
<div class="fullbleed scrolling">
<div class="fill centering double-padded vertical layout">
<pl-logo class="animated"></pl-logo>
${invite
? html`
<div
class="double-padded small box background animated"
style="max-width: 25em; margin-bottom: 1.5em"
>
Hi there! <strong>${invite.invitor}</strong>
<span>${$l("has invited you to join their organization")}</span>
<strong class="highlighted">${invite.orgName}</strong>.
${this._page === "signup"
? html`
Before you can accept, we'll need to <strong>create an account</strong> for
you. This will only take a few moments.
`
: html`
Before you can accept, you'll need to
<strong>login</strong>.
`}
${this._emailInput && invite.email !== this._emailInput.value
? html`
<div class="negative highlight top-margined">
<strong>Warning:</strong> This invite is meant for
<strong>${invite.email}</strong>, but you've entered
<strong>${this._emailInput.value}</strong>.
<a
href="#"
@click=${() => {
this.go("start", {
...this.router.params,
email: invite.email,
});
this._emailInput.value = invite.email;
}}
>
<pl-icon icon="arrow-right" class="inline"></pl-icon>Switch to
${invite.email}
</a>
</div>
`
: ""}
</div>
`
: html``}
<form class="double-padded animated" style="box-sizing: border-box" autocomplete="off">
<pl-drawer .collapsed=${this._page === "signup" && this._step === "success"} class="springy">
<div class="vertical layout" style="flex-direction: column-reverse">
<pl-input
id="emailInput"
type="email"
required
select-on-focus
.label=${$l("Email Address")}
@enter=${() => this._submitEmail()}
?disabled=${this._page !== "start"}
@input=${() => this.requestUpdate()}
>
</pl-input>
<div class="hint">${$l("Welcome! Please enter your email address to continue.")}</div>
</div>
</pl-drawer>
<pl-drawer .collapsed=${this._page !== "start"} class="springy">
<div class="spacer"></div>
<div class="horizontal spacing evenly stretching layout">
<pl-button
id="submitEmailButton"
@click=${() => this._submitEmail()}
?disabled=${!this._emailInput?.value}
>
<div>${$l("Continue")}</div>
<pl-icon icon="forward" class="left-margined"></pl-icon>
</pl-button>
</div>
</pl-drawer>
<pl-drawer
.collapsed=${this._page !== "signup" || this._step !== "consent"}
class="springy"
id="consentDrawer"
>
<div class="spacer"></div>
<div class="hint">
Hi there, <strong>${this._nameInput?.value || "Stranger"}</strong>! Let's set up your
brand new ${process.env.PL_APP_NAME} account! (This will only take a few moments.)
</div>
<pl-input
id="nameInput"
.label=${$l("Your Name (Optional)")}
.value=${this._name}
@enter=${() => this._tosCheckbox?.focus()}
?disabled=${this._page !== "signup" || this._step !== "consent"}
@input=${() => {
this.requestUpdate();
this._consentDrawer.updateInnerSize();
this._masterPasswordDrawer.updateInnerSize();
}}
>
</pl-input>
<div class="spacer"></div>
<div class="small padded">
<label>
<input type="checkbox" id="tosCheckbox" @input=${() => this.requestUpdate()} />
I have read and agree to the
<a href="${process.env.PL_TERMS_OF_SERVICE || "#"}">Terms of Service</a>
</label>
</div>
<div class="spacer"></div>
<div class="horizontal center-aligning stretching spacing layout">
<pl-button class="tiny transparent" @click=${() => this.go("start")}>
<pl-icon icon="backward" class="right-margined"></pl-icon>
<div>${$l("Change Email")}</div>
</pl-button>
<pl-button @click=${() => this._submitName()} ?disabled=${!this._tosCheckbox?.checked}>
<div>${$l("Create Account")}</div>
<pl-icon icon="forward" class="left-margined"></pl-icon>
</pl-button>
</div>
</pl-drawer>
<pl-drawer .collapsed=${this._page !== "login"} class="springy">
<div class="spacer"></div>
<pl-password-input
id="loginPasswordInput"
required
select-on-focus
.label=${$l("Master Password")}
class="bottom-margined"
@enter=${() => this._login()}
@input=${() => this.requestUpdate()}
>
</pl-password-input>
${this._loginError
? html`
<div class="negative inverted padded text-centering bottom-margined card">
${this._loginError}
</div>
`
: ""}
<div class="horizontal spacing evenly stretching layout">
<pl-button
id="loginButton"
@click=${() => this._login()}
?disabled=${!this._loginPasswordInput?.value}
class="primary"
>
<pl-icon icon="login" class="right-margined"></pl-icon>
<div>${$l("Login")}</div>
</pl-button>
</div>
</pl-drawer>
<pl-drawer
.collapsed=${this._page !== "signup" ||
!["choose-password", "confirm-password"].includes(this._step)}
class="springy"
id="masterPasswordDrawer"
>
<div class="padded spacer"></div>
<div class="text-centering section-header">
<div>
<div class="small subtle">
${this._nameInput?.value ? `${this._nameInput.value}, ` : ""}
${$l("Say hello to your")}
</div>
<div class="large bold">${$l("Master Password")}</div>
<pl-icon class="tiny subtle" icon="arrow-down"></pl-icon>
</div>
</div>
<div class="master-password margined">
<div class="master-password-value">
<span>${this._password}</span>
</div>
<div class="master-password-cover">
${isTouch() ? $l("[Tap To Reveal]") : $l("[Hover To Reveal]")}
</div>
</div>
</pl-drawer>
<pl-drawer
class="springy"
.collapsed=${this._page !== "signup" || this._step !== "choose-password"}
>
<div class="horizontally-margined hint">
<div>
${$l(
"This random passphrase was generated just for you and is designed " +
"to be both secure and easy to remember."
)}
</div>
</div>
<div class="top-margined tiny text-centering subtle">${$l("Don't like it?")}</div>
<div class="centering horizontal layout">
<pl-button class="tiny ghost" @click=${this._generatePassphrase}>
<pl-icon icon="refresh" class="right-margined"></pl-icon>
${$l("Try Another One")}
</pl-button>
<div class="small double-margined">or</div>
<pl-button class="tiny ghost" @click=${this._editMasterPassword}>
<pl-icon icon="edit" class="right-margined"></pl-icon>
${$l("Choose Your Own")}
</pl-button>
</div>
<div class="padded spacer"></div>
<div class="center-aligning spacing horizontal layout">
<pl-button class="tiny transparent" @click=${() => this.go("signup/consent")}>
<pl-icon icon="backward" class="right-margined"></pl-icon>
<div>${$l("Change Name")}</div>
</pl-button>
<pl-button class="stretch" @click=${() => this._submitPassword()}>
<div>${$l("Continue")}</div>
<pl-icon icon="forward" class="left-margined"></pl-icon>
</pl-button>
</div>
</pl-drawer>
<pl-drawer
.collapsed=${this._page !== "signup" || this._step !== "confirm-password"}
class="springy"
>
<div class="spacer"></div>
<pl-password-input
id="repeatPasswordInput"
required
.label=${$l("Repeat Master Password")}
class="repeat-master-password"
@enter=${() => this._confirmPassword()}
@focus=${() => this._revealPassphrase()}
>
</pl-password-input>
<div class="hint margined padded">
${$l(
"Your master password is the last password you'll ever have to remember! " +
"Please memorize it and never reveal it to anyone - not even us! " +
"We recommend writing it down on a piece of paper and " +
"storing it somewhere safe, at least until you have it safely memorized."
)}
</div>
<div class="center-aligning spacing horizontal layout">
<pl-button class="tiny transparent" @click=${() => this.go("signup/choose-password")}>
<pl-icon icon="backward" class="right-margined"></pl-icon>
<div>${$l("Change Password")}</div>
</pl-button>
<pl-button
id="confirmPasswordButton"
class="stretch"
@click=${() => this._confirmPassword()}
>
<div>${$l("Continue")}</div>
<pl-icon icon="forward" class="left-margined"></pl-icon>
</pl-button>
</div>
</pl-drawer>
<pl-drawer class="springy" .collapsed=${this._page !== "signup" || this._step !== "success"}>
<div class="huge spacer"></div>
<div class="big highlighted text-centering">
${$l("All set!")} <pl-icon icon="celebrate" class="inline"></pl-icon>
</div>
<div class="padded bottom-margined text-centering">
${$l(
"Your account was created successfully. Enjoy using {0}!",
process.env.PL_APP_NAME!
)}
</div>
<pl-button class="primary" @click=${() => this._done()}>
<div>${$l("Get Started")}</div>
<pl-icon icon="arrow-right" class="left-margined"></pl-icon>
</pl-button>
</pl-drawer>
</form>
</div>
</div>
`;
}
}