Brave Account: login flow (UI-only) (#32497)

This commit is contained in:
Szilard Szaloki
2025-11-25 02:10:19 +01:00
committed by GitHub
parent 0463d43b61
commit 88da05d130
9 changed files with 146 additions and 77 deletions
@@ -159,6 +159,10 @@ class BraveAccountUIBase {
IDS_BRAVE_ACCOUNT_ERROR_DIALOG_CLIENT_ERROR},
{"braveAccountErrorDialogServerError",
IDS_BRAVE_ACCOUNT_ERROR_DIALOG_SERVER_ERROR},
{"braveAccountErrorDialogIncorrectEmail",
IDS_BRAVE_ACCOUNT_ERROR_DIALOG_INCORRECT_EMAIL},
{"braveAccountErrorDialogIncorrectPassword",
IDS_BRAVE_ACCOUNT_ERROR_DIALOG_INCORRECT_PASSWORD},
{"braveAccountErrorDialogAccountExists",
IDS_BRAVE_ACCOUNT_ERROR_DIALOG_ACCOUNT_EXISTS},
{"braveAccountErrorDialogEmailDomainNotSupported",
@@ -3,22 +3,11 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/. */
import { RegisterError } from './brave_account.mojom-webui.js'
import { LoginError, RegisterError } from './brave_account.mojom-webui.js'
type FlowToError = {
register: RegisterError
}
export type Flow = keyof FlowToError
export type FlowToErrorCode = {
[F in Flow]: NonNullable<FlowToError[F]['errorCode']>
}
export type Error<F extends Flow = Flow> = {
flow: F
details: FlowToError[F]
}
export type Error =
| { flow: 'login'; details: LoginError }
| { flow: 'register'; details: RegisterError }
export function onEyeIconClicked(event: Event) {
event.preventDefault()
@@ -209,7 +209,7 @@ export class BraveAccountCreateDialogElement extends CrLitElement {
this.fire('error-occurred', {
flow: 'register',
details,
} satisfies Error<'register'>)
} satisfies Extract<Error, { flow: 'register' }>)
}
}
@@ -3,11 +3,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/. */
import {
html,
nothing,
TemplateResult,
} from '//resources/lit/v3_0/lit.rollup.js'
import { html, TemplateResult } from '//resources/lit/v3_0/lit.rollup.js'
import './brave_account_create_dialog.js'
import './brave_account_entry_dialog.js'
@@ -18,16 +14,12 @@ import { BraveAccountDialogs, Dialog } from './brave_account_dialogs.js'
import { Error } from './brave_account_common.js'
export function getHtml(this: BraveAccountDialogs) {
const dialogHtml: Record<
Dialog['type'],
() => TemplateResult | typeof nothing
> = {
NONE: () => nothing,
const dialogHtml: Record<Dialog['type'], () => TemplateResult> = {
ENTRY: () => html`
<brave-account-entry-dialog
@close-dialog=${this.onCloseDialog}
@create-button-clicked=${() => (this.dialog = { type: 'CREATE' })}
@self-custody-button-clicked=${() => (this.dialog = { type: 'NONE' })}
@self-custody-button-clicked=${() => (this.dialog = { type: 'ENTRY' })}
@sign-in-button-clicked=${() => (this.dialog = { type: 'SIGN_IN' })}
>
</brave-account-entry-dialog>
@@ -48,12 +40,13 @@ export function getHtml(this: BraveAccountDialogs) {
<brave-account-sign-in-dialog
@back-button-clicked=${this.onBackButtonClicked}
@close-dialog=${this.onCloseDialog}
@error-occurred=${(e: CustomEvent<Error>) =>
(this.dialog = {
type: 'ERROR',
error: e.detail,
})}
@forgot-password-button-clicked=${() =>
(this.dialog = { type: 'FORGOT_PASSWORD' })}
@sign-in-button-clicked=${() => {
this.dialog = { type: 'NONE' }
this.signedIn = true
}}
>
</brave-account-sign-in-dialog>
`,
@@ -61,6 +54,7 @@ export function getHtml(this: BraveAccountDialogs) {
<brave-account-forgot-password-dialog
@back-button-clicked=${this.onBackButtonClicked}
@close-dialog=${this.onCloseDialog}
>
</brave-account-forgot-password-dialog>
`,
ERROR: () => html`
@@ -13,7 +13,7 @@ import { getHtml } from './brave_account_dialogs.html.js'
import { Error } from './brave_account_common.js'
export type Dialog =
| { type: 'NONE' | 'CREATE' | 'ENTRY' | 'FORGOT_PASSWORD' | 'SIGN_IN' }
| { type: 'CREATE' | 'ENTRY' | 'FORGOT_PASSWORD' | 'SIGN_IN' }
| { type: 'ERROR'; error: Error }
export class BraveAccountDialogs extends CrLitElement {
@@ -28,7 +28,6 @@ export class BraveAccountDialogs extends CrLitElement {
static override get properties() {
return {
dialog: { type: Object },
signedIn: { type: Boolean, reflect: true },
}
}
@@ -47,7 +46,6 @@ export class BraveAccountDialogs extends CrLitElement {
BraveAccountBrowserProxyImpl.getInstance()
protected accessor dialog: Dialog = { type: 'ENTRY' }
protected accessor signedIn: boolean = false
}
declare global {
@@ -9,51 +9,68 @@ import { html } from '//resources/lit/v3_0/lit.rollup.js'
import { loadTimeData } from '//resources/js/load_time_data.js'
import { BraveAccountErrorDialogElement } from './brave_account_error_dialog.js'
import { Flow, FlowToErrorCode } from './brave_account_common.js'
import { RegisterErrorCode } from './brave_account.mojom-webui.js'
import {
LoginErrorCode,
RegisterErrorCode,
} from './brave_account.mojom-webui.js'
export function getHtml(this: BraveAccountErrorDialogElement) {
return html`<!--_html_template_start_-->
<brave-account-dialog
alert-message=${(() => {
const REGISTER_ERROR_STRINGS = {
const LOGIN_ERROR_STRINGS: Partial<Record<LoginErrorCode, string>> = {
[LoginErrorCode.kIncorrectEmail]:
'$i18n{braveAccountErrorDialogIncorrectEmail}',
[LoginErrorCode.kIncorrectPassword]:
'$i18n{braveAccountErrorDialogIncorrectPassword}',
}
const REGISTER_ERROR_STRINGS: Partial<
Record<RegisterErrorCode, string>
> = {
[RegisterErrorCode.kAccountExists]:
'$i18n{braveAccountErrorDialogAccountExists}',
[RegisterErrorCode.kEmailDomainNotSupported]:
'$i18n{braveAccountErrorDialogEmailDomainNotSupported}',
[RegisterErrorCode.kTooManyVerifications]:
'$i18n{braveAccountErrorDialogTooManyVerifications}',
} satisfies Partial<Record<RegisterErrorCode, string>>
const ERROR_STRINGS: {
[F in Flow]: Partial<Record<FlowToErrorCode[F], string>>
} = {
register: REGISTER_ERROR_STRINGS,
}
const { statusCode, errorCode } = this.error.details
const getErrorMessage = <T extends LoginErrorCode | RegisterErrorCode>(
errorStrings: Partial<Record<T, string>>,
details: { statusCode: number | null; errorCode: T | null },
): string => {
const { statusCode, errorCode } = details
if (statusCode == null) {
// client-side error
return loadTimeData.getStringF(
'braveAccountErrorDialogClientError',
errorCode != null
? ` ($i18n{braveAccountErrorDialogError}=${errorCode})`
: '',
if (statusCode == null) {
// client-side error
return loadTimeData.getStringF(
'braveAccountErrorDialogClientError',
errorCode != null
? ` ($i18n{braveAccountErrorDialogError}=${errorCode})`
: '',
)
}
// server-side error
return (
(errorCode != null ? errorStrings[errorCode] : null)
?? loadTimeData.getStringF(
'braveAccountErrorDialogServerError',
statusCode,
errorCode != null
? `, $i18n{braveAccountErrorDialogError}=${errorCode}`
: '',
)
)
}
// server-side error
return (
(errorCode != null ? ERROR_STRINGS[this.error.flow][errorCode] : null)
?? loadTimeData.getStringF(
'braveAccountErrorDialogServerError',
statusCode,
errorCode != null
? `, $i18n{braveAccountErrorDialogError}=${errorCode}`
: '',
)
)
switch (this.error.flow) {
case 'login':
return getErrorMessage(LOGIN_ERROR_STRINGS, this.error.details)
case 'register':
return getErrorMessage(REGISTER_ERROR_STRINGS, this.error.details)
}
})()}
dialog-description="$i18n{braveAccountErrorDialogDescription}"
dialog-title="$i18n{braveAccountErrorDialogTitle}"
@@ -21,11 +21,7 @@ export function getHtml(this: BraveAccountSignInDialogElement) {
placeholder="$i18n{braveAccountEmailInputPlaceholder}"
@input=${this.onEmailInput}
>
<div
class="label ${this.email.length !== 0 && !this.isEmailValid
? 'error'
: ''}"
>
<div class="label ${this.shouldShowEmailError ? 'error' : ''}">
$i18n{braveAccountEmailInputLabel}
</div>
</leo-input>
@@ -54,7 +50,7 @@ export function getHtml(this: BraveAccountSignInDialogElement) {
<leo-button
slot="buttons"
?isDisabled=${!this.isEmailValid || !this.isPasswordValid}
@click=${() => this.fire('sign-in-button-clicked')}
@click=${this.onSignInButtonClicked}
>
$i18n{braveAccountSignInButtonLabel}
</leo-button>
@@ -5,9 +5,14 @@
import { CrLitElement } from '//resources/lit/v3_0/lit.rollup.js'
import {
BraveAccountBrowserProxy,
BraveAccountBrowserProxyImpl,
} from './brave_account_browser_proxy.js'
import { getCss } from './brave_account_sign_in_dialog.css.js'
import { getHtml } from './brave_account_sign_in_dialog.html.js'
import { isEmailValid } from './brave_account_common.js'
import { Error, isEmailValid } from './brave_account_common.js'
import { LoginError, LoginErrorCode } from './brave_account.mojom-webui.js'
// @ts-expect-error
import { Login } from 'chrome://resources/brave/opaque_ke.bundle.js'
@@ -28,24 +33,84 @@ export class BraveAccountSignInDialogElement extends CrLitElement {
static override get properties() {
return {
email: { type: String },
isEmailValid: { type: Boolean },
isPasswordValid: { type: Boolean },
password: { type: String },
}
}
protected onEmailInput(detail: { value: string }) {
this.email = detail.value
this.isEmailValid = isEmailValid(this.email)
this.email = detail.value.trim()
}
protected onPasswordInput(detail: { value: string }) {
this.isPasswordValid = detail.value.length !== 0
this.password = detail.value
}
protected accessor email: string = ''
protected accessor isEmailValid: boolean = false
protected accessor isPasswordValid: boolean = false
// The reason this happens here (rather than in BraveAccountService) is that
// both `login.start()` and `login.finish()` invoke the OPAQUE
// protocol in our WASM (compiled from Rust), and so the flow must run in the
// renderer to manage the transient cryptographic state — the service only
// transports the two server round trips
// (`loginInitialize`/`loginFinalize`). We'll revisit handling this
// through Mojo in C++ if that proves practical.
protected async onSignInButtonClicked() {
try {
const serializedKE1 = this.login.start(this.password)
const { encryptedLoginToken, serializedKE2 } =
await this.browserProxy.authentication.loginInitialize(
this.email,
serializedKE1,
)
const clientMac = this.login.finish(
serializedKE2,
this.password,
this.email,
)
await this.browserProxy.authentication.loginFinalize(
encryptedLoginToken,
clientMac,
)
this.fire('close-dialog')
} catch (error) {
let details: LoginError
if (error && typeof error === 'object') {
details = error as LoginError
} else if (typeof error === 'string') {
details = {
statusCode: null,
errorCode: LoginErrorCode.kOpaqueError,
}
} else {
console.error('Unexpected error:', error)
details = { statusCode: null, errorCode: null }
}
this.fire('error-occurred', {
flow: 'login',
details,
} satisfies Extract<Error, { flow: 'login' }>)
}
}
private browserProxy: BraveAccountBrowserProxy =
BraveAccountBrowserProxyImpl.getInstance()
protected login = new Login()
protected accessor email: string = ''
protected accessor password: string = ''
protected get isEmailValid(): boolean {
return isEmailValid(this.email)
}
protected get shouldShowEmailError(): boolean {
return this.email.length !== 0 && !this.isEmailValid
}
protected get isPasswordValid(): boolean {
return this.password.length !== 0
}
}
declare global {
@@ -111,6 +111,12 @@
<message name="IDS_BRAVE_ACCOUNT_ERROR_DIALOG_SERVER_ERROR" translateable="false" desc="">
Internal server error (HTTP=<ph name="STATUS_CODE">$1</ph><ph name="ERROR_CODE">$2</ph>). Please try again later.
</message>
<message name="IDS_BRAVE_ACCOUNT_ERROR_DIALOG_INCORRECT_EMAIL" translateable="false" desc="">
No account found for that email address. Please try again, or create a new account.
</message>
<message name="IDS_BRAVE_ACCOUNT_ERROR_DIALOG_INCORRECT_PASSWORD" translateable="false" desc="">
Incorrect password. Please try again.
</message>
<message name="IDS_BRAVE_ACCOUNT_ERROR_DIALOG_ACCOUNT_EXISTS" translateable="false" desc="">
An account already exists for this email address. Try logging in instead of creating an account.
</message>