chore: auth verification (#2867) [skip e2e]

This commit is contained in:
Mo
2024-04-08 10:52:56 -05:00
committed by GitHub
parent a37e095907
commit b6eda707bd
30 changed files with 516 additions and 205 deletions

View File

@@ -72,6 +72,7 @@ export class AuthApiService implements AuthApiServiceInterface {
password: string
codeVerifier: string
recoveryCodes: string
hvmToken?: string
}): Promise<HttpResponse<SignInWithRecoveryCodesResponseBody>> {
if (this.operationsInProgress.get(AuthApiOperations.SignInWithRecoveryCodes)) {
throw new ApiCallError(ErrorMessage.GenericInProgress)
@@ -86,6 +87,7 @@ export class AuthApiService implements AuthApiServiceInterface {
password: dto.password,
recovery_codes: dto.recoveryCodes,
username: dto.username,
hvm_token: dto.hvmToken,
})
return response

View File

@@ -17,5 +17,6 @@ export interface AuthApiServiceInterface {
password: string
codeVerifier: string
recoveryCodes: string
hvmToken?: string
}): Promise<HttpResponse<SignInWithRecoveryCodesResponseBody>>
}

View File

@@ -66,6 +66,7 @@ export class UserApiService implements UserApiServiceInterface {
async register(registerDTO: {
email: string
serverPassword: string
hvmToken?: string
keyParams: RootKeyParamsInterface
ephemeral: boolean
}): Promise<HttpResponse<UserRegistrationResponseBody>> {
@@ -76,6 +77,7 @@ export class UserApiService implements UserApiServiceInterface {
[ApiEndpointParam.ApiVersion]: this.apiVersion,
password: registerDTO.serverPassword,
email: registerDTO.email,
hvm_token: registerDTO.hvmToken,
ephemeral: registerDTO.ephemeral,
...registerDTO.keyParams.getPortableValue(),
})

View File

@@ -11,6 +11,7 @@ export interface UserApiServiceInterface {
register(registerDTO: {
email: string
serverPassword: string
hvmToken?: string
keyParams: RootKeyParamsInterface
ephemeral: boolean
}): Promise<HttpResponse<UserRegistrationResponseBody>>

View File

@@ -4,4 +4,5 @@ export interface SignInWithRecoveryCodesRequestParams {
password: string
code_verifier: string
recovery_codes: string
hvm_token?: string
}

View File

@@ -6,5 +6,6 @@ export type UserRegistrationRequestParams = AnyKeyParamsContent & {
[additionalParam: string]: unknown
password: string
email: string
hvm_token?: string
ephemeral: boolean
}

View File

@@ -158,7 +158,7 @@ platform :android do
desc 'Deploy production app'
lane :prod do
version = 3_002_000 + ENV['BUILD_NUMBER'].to_i
version = 3_004_000 + ENV['BUILD_NUMBER'].to_i
deploy_android 'prod', version
end
end

View File

@@ -0,0 +1,3 @@
export type MetaEndpointResponse = {
captchaUIUrl: string | null
}

View File

@@ -26,6 +26,30 @@ export function isErrorResponse<T>(response: HttpResponse<T>): response is HttpE
return (response.data as HttpErrorResponseBody)?.error != undefined || response.status >= 400
}
export function getCaptchaHeader<T>(response: HttpResponse<T>) {
const captchaHeader = response.headers?.get('x-captcha-required')
if (captchaHeader) {
return captchaHeader
}
return null
}
export function getErrorMessageFromErrorResponseBody(data: HttpErrorResponseBody, defaultMessage?: string): string {
let errorMessage = defaultMessage || 'Unknown error'
if (
data &&
typeof data === 'object' &&
'error' in data &&
data.error &&
typeof data.error === 'object' &&
'message' in data.error
) {
errorMessage = data.error.message as string
}
return errorMessage
}
export function getErrorFromErrorResponse(response: HttpErrorResponse): HttpError {
const embeddedError = response.data.error
if (embeddedError) {

View File

@@ -13,6 +13,7 @@ export * from './Auth/SignInData'
export * from './Auth/SignInResponse'
export * from './Auth/SignOutResponse'
export * from './Auth/User'
export * from './Auth/MetaEndpointResponse'
/** Temps are awaiting final publish state on server repo */
export * from './Temp/SharedVaultMoveType'

View File

@@ -13,8 +13,10 @@ export interface AuthClientInterface {
password: string
codeVerifier: string
recoveryCodes: string
hvmToken?: string
}): Promise<
| {
success: true
keyParams: AnyKeyParamsContent
session: SessionBody
user: {
@@ -23,6 +25,9 @@ export interface AuthClientInterface {
protocolVersion: string
}
}
| false
| {
success: false
captchaURL: string
}
>
}

View File

@@ -1,6 +1,6 @@
import { AuthApiServiceInterface } from '@standardnotes/api'
import { AnyKeyParamsContent } from '@standardnotes/common'
import { isErrorResponse, SessionBody } from '@standardnotes/responses'
import { isErrorResponse, getCaptchaHeader } from '@standardnotes/responses'
import { InternalEventBusInterface } from '../Internal/InternalEventBusInterface'
import { AbstractService } from '../Service/AbstractService'
@@ -45,37 +45,39 @@ export class AuthManager extends AbstractService implements AuthClientInterface
}
}
async signInWithRecoveryCodes(dto: {
username: string
password: string
codeVerifier: string
recoveryCodes: string
}): Promise<
| {
keyParams: AnyKeyParamsContent
session: SessionBody
user: {
uuid: string
email: string
protocolVersion: string
}
}
| false
> {
async signInWithRecoveryCodes(
dto: Parameters<AuthClientInterface['signInWithRecoveryCodes']>[0],
): ReturnType<AuthClientInterface['signInWithRecoveryCodes']> {
try {
const result = await this.authApiService.signInWithRecoveryCodes(dto)
const captchaURL = getCaptchaHeader(result)
if (captchaURL) {
return {
success: false,
captchaURL,
}
}
if (isErrorResponse(result)) {
return false
return {
success: false,
captchaURL: '',
}
}
return {
success: true,
keyParams: result.data.key_params as AnyKeyParamsContent,
session: result.data.session,
user: result.data.user,
}
} catch (error) {
return false
return {
success: false,
captchaURL: '',
}
}
}
}

View File

@@ -30,13 +30,14 @@ export interface SessionsClientInterface {
revokeAllOtherSessions(): Promise<void>
isCurrentSessionReadOnly(): boolean | undefined
register(email: string, password: string, ephemeral: boolean): Promise<UserRegistrationResponseBody>
register(email: string, password: string, hvmToken: string, ephemeral: boolean): Promise<UserRegistrationResponseBody>
signIn(
email: string,
password: string,
strict: boolean,
ephemeral: boolean,
minAllowedVersion?: ProtocolVersion,
hvmToken?: string,
): Promise<SessionManagerResponse>
bypassChecksAndSignInWithRootKey(
email: string,

View File

@@ -142,6 +142,7 @@ export class UserService
public async register(
email: string,
password: string,
hvmToken: string,
ephemeral = false,
mergeLocal = true,
): Promise<UserRegistrationResponseBody> {
@@ -157,7 +158,7 @@ export class UserService
try {
this.lockSyncing()
const response = await this.sessions.register(email, password, ephemeral)
const response = await this.sessions.register(email, password, hvmToken, ephemeral)
await this.notifyEventSync(AccountEvent.SignedInOrRegistered, {
payload: {
@@ -190,6 +191,7 @@ export class UserService
ephemeral = false,
mergeLocal = true,
awaitSync = false,
hvmToken?: string,
): Promise<HttpResponse<SignInResponse>> {
if (this.encryption.hasAccount()) {
throw Error('Tried to sign in when an account already exists.')
@@ -205,7 +207,7 @@ export class UserService
/** Prevent a timed sync from occuring while signing in. */
this.lockSyncing()
const { response } = await this.sessions.signIn(email, password, strict, ephemeral)
const { response } = await this.sessions.signIn(email, password, strict, ephemeral, undefined, hvmToken)
if (!isErrorResponse(response)) {
const notifyingFunction = awaitSync ? this.notifyEventSync.bind(this) : this.notifyEvent.bind(this)

View File

@@ -21,6 +21,7 @@ export interface UserServiceInterface extends AbstractService<AccountEvent, Acco
register(
email: string,
password: string,
hvmToken: string,
ephemeral: boolean,
mergeLocal: boolean,
): Promise<UserRegistrationResponseBody>
@@ -31,6 +32,7 @@ export interface UserServiceInterface extends AbstractService<AccountEvent, Acco
ephemeral: boolean,
mergeLocal: boolean,
awaitSync: boolean,
hvmToken?: string,
): Promise<HttpResponse<SignInResponse>>
deleteAccount(): Promise<{
error: boolean

View File

@@ -203,7 +203,6 @@ export * from './User/SignedInOrRegisteredEventPayload'
export * from './User/SignedOutEventPayload'
export * from './User/UserService'
export * from './User/UserServiceInterface'
export * from './User/UserServiceInterface'
export * from './UserEvent/NotificationService'
export * from './UserEvent/NotificationServiceEvent'
export * from './Vault/UseCase/AuthorizeVaultDeletion'

View File

@@ -98,6 +98,7 @@ import {
SignInResponse,
ClientDisplayableError,
SessionListEntry,
MetaEndpointResponse,
} from '@standardnotes/responses'
import {
SyncService,
@@ -117,7 +118,7 @@ import {
LoggerInterface,
canBlockDeinit,
} from '@standardnotes/utils'
import { UuidString, ApplicationEventPayload } from '../Types'
import { UuidString } from '../Types'
import { applicationEventForSyncEvent } from '@Lib/Application/Event'
import { BackupServiceInterface, FilesClientInterface } from '@standardnotes/files'
import { ComputePrivateUsername } from '@standardnotes/encryption'
@@ -275,12 +276,12 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
}),
)
const syncEventCallback = async (eventName: SyncEvent) => {
const syncEventCallback = async (eventName: SyncEvent, data?: unknown) => {
const appEvent = applicationEventForSyncEvent(eventName)
if (appEvent) {
await encryptionService.onSyncEvent(eventName)
await this.notifyEvent(appEvent)
await this.notifyEvent(appEvent, data)
if (appEvent === ApplicationEvent.CompletedFullSync) {
if (!this.handledFullSyncStage) {
@@ -535,7 +536,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
return this.addEventObserver(filteredCallback, event)
}
private async notifyEvent(event: ApplicationEvent, data?: ApplicationEventPayload) {
private async notifyEvent(event: ApplicationEvent, data?: unknown) {
if (event === ApplicationEvent.Started) {
this.onStart()
} else if (event === ApplicationEvent.Launched) {
@@ -768,10 +769,11 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
public async register(
email: string,
password: string,
hvmToken: string,
ephemeral = false,
mergeLocal = true,
): Promise<UserRegistrationResponseBody> {
return this.user.register(email, password, ephemeral, mergeLocal)
return this.user.register(email, password, hvmToken, ephemeral, mergeLocal)
}
/**
@@ -785,8 +787,13 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
ephemeral = false,
mergeLocal = true,
awaitSync = false,
hvmToken?: string,
): Promise<HttpResponse<SignInResponse>> {
return this.user.signIn(email, password, strict, ephemeral, mergeLocal, awaitSync)
return this.user.signIn(email, password, strict, ephemeral, mergeLocal, awaitSync, hvmToken)
}
public async getCaptchaUrl(): Promise<HttpResponse<MetaEndpointResponse>> {
return this.legacyApi.getCaptchaUrl()
}
public async changeEmail(

View File

@@ -166,7 +166,9 @@ describe('SignInWithRecoveryCodes', () => {
})
it('should fail if the sign in with recovery code fails', async () => {
authManager.signInWithRecoveryCodes = jest.fn().mockReturnValue(false)
authManager.signInWithRecoveryCodes = jest.fn().mockReturnValue({
success: false,
})
const useCase = createUseCase()
const result = await useCase.execute({

View File

@@ -68,9 +68,18 @@ export class SignInWithRecoveryCodes implements UseCaseInterface<void> {
recoveryCodes: dto.recoveryCodes,
username: dto.username,
password: rootKey.serverPassword as string,
hvmToken: dto.hvmToken,
})
if (signInResult === false) {
if (signInResult.success === false) {
if (signInResult.captchaURL) {
return Result.fail(
JSON.stringify({
captchaURL: signInResult.captchaURL,
}),
)
}
return Result.fail('Could not sign in with recovery code')
}

View File

@@ -2,4 +2,5 @@ export interface SignInWithRecoveryCodesDTO {
recoveryCodes: string
username: string
password: string
hvmToken?: string
}

View File

@@ -68,6 +68,7 @@ import {
isErrorResponse,
MoveFileResponse,
ValetTokenOperation,
MetaEndpointResponse,
} from '@standardnotes/responses'
import { LegacySession, MapperInterface, Session, SessionToken } from '@standardnotes/domain-core'
import { HttpServiceInterface } from '@standardnotes/api'
@@ -290,6 +291,7 @@ export class LegacyApiService
email: string
serverPassword: string
ephemeral: boolean
hvmToken?: string
}): Promise<HttpResponse<SignInResponse>> {
if (this.authenticating) {
return this.createErrorResponse(API_MESSAGE_LOGIN_IN_PROGRESS, HttpStatusCode.BadRequest)
@@ -301,6 +303,7 @@ export class LegacyApiService
password: dto.serverPassword,
ephemeral: dto.ephemeral,
code_verifier: this.inMemoryStore.getValue(StorageKey.CodeVerifier) as string,
hvm_token: dto.hvmToken,
})
const response = await this.request<SignInResponse>({
@@ -958,4 +961,9 @@ export class LegacyApiService
return this.session.accessToken
}
public getCaptchaUrl() {
const response = this.httpService.get<MetaEndpointResponse>(Paths.v1.meta)
return response
}
}

View File

@@ -70,6 +70,7 @@ export const Paths = {
...SettingsPaths,
...SubscriptionPaths,
...UserPaths,
meta: '/v1/meta',
},
v2: {
...UserPathsV2,

View File

@@ -404,7 +404,12 @@ export class SessionManager
return undefined
}
async register(email: string, password: string, ephemeral: boolean): Promise<UserRegistrationResponseBody> {
async register(
email: string,
password: string,
hvmToken: string,
ephemeral: boolean,
): Promise<UserRegistrationResponseBody> {
if (password.length < MINIMUM_PASSWORD_LENGTH) {
throw new ApiCallError(
ErrorMessage.InsufficientPasswordMessage.replace('%LENGTH%', MINIMUM_PASSWORD_LENGTH.toString()),
@@ -429,6 +434,7 @@ export class SessionManager
const registerResponse = await this.userApiService.register({
email,
serverPassword,
hvmToken,
keyParams,
ephemeral,
})
@@ -503,8 +509,9 @@ export class SessionManager
strict = false,
ephemeral = false,
minAllowedVersion?: Common.ProtocolVersion,
hvmToken?: string,
): Promise<SessionManagerResponse> {
const result = await this.performSignIn(email, password, strict, ephemeral, minAllowedVersion)
const result = await this.performSignIn(email, password, strict, ephemeral, minAllowedVersion, hvmToken)
if (
isErrorResponse(result.response) &&
getErrorFromErrorResponse(result.response).tag !== ErrorTag.ClientValidationError &&
@@ -515,7 +522,7 @@ export class SessionManager
/**
* Try signing in with trimmed + lowercase version of email
*/
return this.performSignIn(cleanedEmail, password, strict, ephemeral, minAllowedVersion)
return this.performSignIn(cleanedEmail, password, strict, ephemeral, minAllowedVersion, hvmToken)
} else {
return result
}
@@ -530,6 +537,7 @@ export class SessionManager
strict = false,
ephemeral = false,
minAllowedVersion?: Common.ProtocolVersion,
hvmToken?: string,
): Promise<SessionManagerResponse> {
const paramsResult = await this.retrieveKeyParams({
email,
@@ -593,7 +601,7 @@ export class SessionManager
}
}
const rootKey = await this.encryptionService.computeRootKey(password, keyParams)
const signInResponse = await this.bypassChecksAndSignInWithRootKey(email, rootKey, ephemeral)
const signInResponse = await this.bypassChecksAndSignInWithRootKey(email, rootKey, ephemeral, hvmToken)
return {
response: signInResponse,
@@ -604,6 +612,7 @@ export class SessionManager
email: string,
rootKey: SNRootKey,
ephemeral = false,
hvmToken?: string,
): Promise<HttpResponse<SignInResponse>> {
const { wrappingKey, canceled } = await this.challengeService.getWrappingKeyIfApplicable()
@@ -619,6 +628,7 @@ export class SessionManager
email,
serverPassword: rootKey.serverPassword as string,
ephemeral,
hvmToken,
})
if (!signInResponse.data || isErrorResponse(signInResponse)) {

View File

@@ -1,6 +1,6 @@
{
"name": "@standardnotes/web",
"version": "3.192.10",
"version": "3.192.12",
"license": "AGPL-3.0",
"main": "dist/app.js",
"author": "Standard Notes",

View File

@@ -16,6 +16,8 @@ import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import Icon from '@/Components/Icon/Icon'
import IconButton from '@/Components/Button/IconButton'
import { useApplication } from '../ApplicationProvider'
import { useCaptcha } from '@/Hooks/useCaptcha'
import { isErrorResponse } from '@standardnotes/snjs'
type Props = {
setMenuPane: (pane: AccountMenuPane) => void
@@ -33,6 +35,39 @@ const ConfirmPassword: FunctionComponent<Props> = ({ setMenuPane, email, passwor
const [shouldMergeLocal, setShouldMergeLocal] = useState(true)
const [error, setError] = useState('')
const [hvmToken, setHVMToken] = useState('')
const [captchaURL, setCaptchaURL] = useState('')
const register = useCallback(() => {
setIsRegistering(true)
application
.register(email, password, hvmToken, isEphemeral, shouldMergeLocal)
.then(() => {
application.accountMenuController.closeAccountMenu()
application.accountMenuController.setCurrentPane(AccountMenuPane.GeneralMenu)
})
.catch((err) => {
console.error(err)
setError(err.message)
})
.finally(() => {
setIsRegistering(false)
})
}, [application, email, hvmToken, isEphemeral, password, shouldMergeLocal])
const captchaIframe = useCaptcha(captchaURL, (token) => {
setHVMToken(token)
setCaptchaURL('')
})
useEffect(() => {
if (!hvmToken) {
return
}
register()
}, [hvmToken, register])
const passwordInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
@@ -51,6 +86,28 @@ const ConfirmPassword: FunctionComponent<Props> = ({ setMenuPane, email, passwor
setShouldMergeLocal(!shouldMergeLocal)
}, [shouldMergeLocal])
const checkIfCaptchaRequiredAndRegister = useCallback(() => {
application
.getCaptchaUrl()
.then((response) => {
if (isErrorResponse(response)) {
throw new Error()
}
const { captchaUIUrl } = response.data
if (captchaUIUrl) {
setCaptchaURL(captchaUIUrl)
} else {
setCaptchaURL('')
register()
}
})
.catch((error) => {
console.error(error)
setCaptchaURL('')
register()
})
}, [application, register])
const handleConfirmFormSubmit: FormEventHandler = useCallback(
(e) => {
e.preventDefault()
@@ -60,28 +117,16 @@ const ConfirmPassword: FunctionComponent<Props> = ({ setMenuPane, email, passwor
return
}
if (password === confirmPassword) {
setIsRegistering(true)
application
.register(email, password, isEphemeral, shouldMergeLocal)
.then(() => {
application.accountMenuController.closeAccountMenu()
application.accountMenuController.setCurrentPane(AccountMenuPane.GeneralMenu)
})
.catch((err) => {
console.error(err)
setError(err.message)
})
.finally(() => {
setIsRegistering(false)
})
} else {
if (password !== confirmPassword) {
setError(STRING_NON_MATCHING_PASSWORDS)
setConfirmPassword('')
passwordInputRef.current?.focus()
return
}
checkIfCaptchaRequiredAndRegister()
},
[application, confirmPassword, email, isEphemeral, password, shouldMergeLocal],
[checkIfCaptchaRequiredAndRegister, confirmPassword, password],
)
const handleKeyDown: KeyboardEventHandler = useCallback(
@@ -100,35 +145,26 @@ const ConfirmPassword: FunctionComponent<Props> = ({ setMenuPane, email, passwor
setMenuPane(AccountMenuPane.Register)
}, [setMenuPane])
return (
const confirmPasswordForm = (
<>
<div className="mb-3 mt-1 flex items-center px-3">
<IconButton
icon="arrow-left"
title="Go back"
className="mr-2 flex p-0 text-neutral"
onClick={handleGoBack}
focusable={true}
disabled={isRegistering}
/>
<div className="text-base font-bold">Confirm password</div>
</div>
<div className="mb-3 px-3 text-sm">
Because your notes are encrypted using your password,{' '}
<span className="text-danger">Standard Notes does not have a password reset option</span>. If you forget your
password, you will permanently lose access to your data.
</div>
<form onSubmit={handleConfirmFormSubmit} className="mb-1 px-3">
<DecoratedPasswordInput
className={{ container: 'mb-2' }}
disabled={isRegistering}
left={[<Icon type="password" className="text-neutral" />]}
onChange={handlePasswordChange}
onKeyDown={handleKeyDown}
placeholder="Confirm password"
ref={passwordInputRef}
value={confirmPassword}
/>
{!isRegistering && (
<DecoratedPasswordInput
className={{ container: 'mb-2' }}
disabled={isRegistering}
left={[<Icon type="password" className="text-neutral" />]}
onChange={handlePasswordChange}
onKeyDown={handleKeyDown}
placeholder="Confirm password"
ref={passwordInputRef}
value={confirmPassword}
/>
)}
{error ? <div className="my-2 text-danger">{error}</div> : null}
<Button
primary
@@ -157,6 +193,23 @@ const ConfirmPassword: FunctionComponent<Props> = ({ setMenuPane, email, passwor
</form>
</>
)
return (
<>
<div className="mb-3 mt-1 flex items-center px-3">
<IconButton
icon="arrow-left"
title="Go back"
className="mr-2 flex p-0 text-neutral"
onClick={handleGoBack}
focusable={true}
disabled={isRegistering}
/>
<div className="text-base font-bold">{captchaURL ? 'Human verification' : 'Confirm password'}</div>
</div>
{captchaURL ? <div className="p-[10px]">{captchaIframe}</div> : confirmPasswordForm}
</>
)
}
export default observer(ConfirmPassword)

View File

@@ -10,8 +10,9 @@ import Icon from '@/Components/Icon/Icon'
import IconButton from '@/Components/Button/IconButton'
import AdvancedOptions from './AdvancedOptions'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import { getErrorFromErrorResponse, isErrorResponse } from '@standardnotes/snjs'
import { getErrorFromErrorResponse, isErrorResponse, getCaptchaHeader } from '@standardnotes/snjs'
import { useApplication } from '../ApplicationProvider'
import { useCaptcha } from '@/Hooks/useCaptcha'
type Props = {
setMenuPane: (pane: AccountMenuPane) => void
@@ -34,6 +35,15 @@ const SignInPane: FunctionComponent<Props> = ({ setMenuPane }) => {
const [isRecoverySignIn, setIsRecoverySignIn] = useState(false)
const [captchaURL, setCaptchaURL] = useState('')
const [showCaptcha, setShowCaptcha] = useState(false)
const [hvmToken, setHVMToken] = useState('')
const captchaIframe = useCaptcha(captchaURL, (token) => {
setHVMToken(token)
setShowCaptcha(false)
setCaptchaURL('')
})
const emailInputRef = useRef<HTMLInputElement>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
@@ -95,8 +105,12 @@ const SignInPane: FunctionComponent<Props> = ({ setMenuPane }) => {
passwordInputRef?.current?.blur()
application
.signIn(email, password, isStrictSignin, isEphemeral, shouldMergeLocal)
.signIn(email, password, isStrictSignin, isEphemeral, shouldMergeLocal, false, hvmToken)
.then((response) => {
const captchaURL = getCaptchaHeader(response)
if (captchaURL) {
setCaptchaURL(captchaURL)
}
if (isErrorResponse(response)) {
throw new Error(getErrorFromErrorResponse(response).message)
}
@@ -106,12 +120,13 @@ const SignInPane: FunctionComponent<Props> = ({ setMenuPane }) => {
console.error(err)
setError(err.message ?? err.toString())
setPassword('')
setHVMToken('')
passwordInputRef?.current?.blur()
})
.finally(() => {
setIsSigningIn(false)
})
}, [application, email, isEphemeral, isStrictSignin, password, shouldMergeLocal])
}, [application, email, hvmToken, isEphemeral, isStrictSignin, password, shouldMergeLocal])
const recoverySignIn = useCallback(() => {
setIsSigningIn(true)
@@ -123,10 +138,21 @@ const SignInPane: FunctionComponent<Props> = ({ setMenuPane }) => {
recoveryCodes,
username: email,
password: password,
hvmToken,
})
.then((result) => {
if (result.isFailed()) {
throw new Error(result.getError())
const error = result.getError()
try {
const parsed = JSON.parse(error)
if (parsed.captchaURL) {
setCaptchaURL(parsed.captchaURL)
return
}
} catch (e) {
setCaptchaURL('')
}
throw new Error(error)
}
application.accountMenuController.closeAccountMenu()
})
@@ -134,12 +160,13 @@ const SignInPane: FunctionComponent<Props> = ({ setMenuPane }) => {
console.error(err)
setError(err.message ?? err.toString())
setPassword('')
setHVMToken('')
passwordInputRef?.current?.blur()
})
.finally(() => {
setIsSigningIn(false)
})
}, [application, email, password, recoveryCodes])
}, [application.accountMenuController, application.signInWithRecoveryCodes, email, hvmToken, password, recoveryCodes])
const onPrivateUsernameChange = useCallback(
(newisPrivateUsername: boolean, privateUsernameIdentifier?: string) => {
@@ -151,28 +178,37 @@ const SignInPane: FunctionComponent<Props> = ({ setMenuPane }) => {
[setEmail],
)
const performSignIn = useCallback(() => {
if (!email || email.length === 0) {
emailInputRef?.current?.focus()
return
}
if (!password || password.length === 0) {
passwordInputRef?.current?.focus()
return
}
if (isRecoverySignIn) {
recoverySignIn()
return
}
signIn()
}, [email, isRecoverySignIn, password, recoverySignIn, signIn])
const handleSignInFormSubmit = useCallback(
(e: React.SyntheticEvent) => {
e.preventDefault()
if (!email || email.length === 0) {
emailInputRef?.current?.focus()
if (captchaURL) {
setShowCaptcha(true)
return
}
if (!password || password.length === 0) {
passwordInputRef?.current?.focus()
return
}
if (isRecoverySignIn) {
recoverySignIn()
return
}
signIn()
performSignIn()
},
[email, password, isRecoverySignIn, signIn, recoverySignIn],
[captchaURL, performSignIn],
)
const handleKeyDown: KeyboardEventHandler = useCallback(
@@ -184,19 +220,16 @@ const SignInPane: FunctionComponent<Props> = ({ setMenuPane }) => {
[handleSignInFormSubmit],
)
return (
useEffect(() => {
if (!hvmToken) {
return
}
performSignIn()
}, [hvmToken, performSignIn])
const signInForm = (
<>
<div className="mb-3 mt-1 flex items-center px-3">
<IconButton
icon="arrow-left"
title="Go back"
className="mr-2 flex p-0 text-neutral"
onClick={() => setMenuPane(AccountMenuPane.GeneralMenu)}
focusable={true}
disabled={isSigningIn}
/>
<div className="text-base font-bold">Sign in</div>
</div>
<div className="mb-1 px-3">
<DecoratedInput
className={{ container: `mb-2 ${error ? 'border-danger' : null}` }}
@@ -257,6 +290,23 @@ const SignInPane: FunctionComponent<Props> = ({ setMenuPane }) => {
/>
</>
)
return (
<>
<div className="mb-3 mt-1 flex items-center px-3">
<IconButton
icon="arrow-left"
title="Go back"
className="mr-2 flex p-0 text-neutral"
onClick={() => setMenuPane(AccountMenuPane.GeneralMenu)}
focusable={true}
disabled={isSigningIn}
/>
<div className="text-base font-bold">{showCaptcha ? 'Human verification' : 'Sign in'}</div>
</div>
{showCaptcha ? <div className="p-[10px]">{captchaIframe}</div> : signInForm}
</>
)
}
export default observer(SignInPane)

View File

@@ -1,6 +1,13 @@
import { WebApplicationGroup } from '@/Application/WebApplicationGroup'
import { getPlatformString } from '@/Utils'
import { ApplicationEvent, Challenge, removeFromArray, WebAppEvent } from '@standardnotes/snjs'
import {
ApplicationEvent,
Challenge,
getErrorMessageFromErrorResponseBody,
HttpErrorResponseBody,
removeFromArray,
WebAppEvent,
} from '@standardnotes/snjs'
import { alertDialog, isIOS, RouteType } from '@standardnotes/ui-services'
import { WebApplication } from '@/Application/WebApplication'
import Footer from '@/Components/Footer/Footer'
@@ -117,7 +124,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
onAppLaunch()
}
const removeAppObserver = application.addEventObserver(async (eventName) => {
const removeAppObserver = application.addEventObserver(async (eventName, data?: unknown) => {
if (eventName === ApplicationEvent.Started) {
onAppStart()
} else if (eventName === ApplicationEvent.Launched) {
@@ -147,6 +154,14 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
type: ToastType.Error,
message: 'Too many requests. Please try again later.',
})
} else if (eventName === ApplicationEvent.FailedSync) {
addToast({
type: ToastType.Error,
message: getErrorMessageFromErrorResponseBody(
data as HttpErrorResponseBody,
'Sync error. Please try again later.',
),
})
}
})

View File

@@ -2,10 +2,13 @@ import Button from '@/Components/Button/Button'
import { WebApplication } from '@/Application/WebApplication'
import { PurchaseFlowPane } from '@/Controllers/PurchaseFlow/PurchaseFlowPane'
import { observer } from 'mobx-react-lite'
import { ChangeEventHandler, FunctionComponent, useEffect, useRef, useState } from 'react'
import { ChangeEventHandler, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import FloatingLabelInput from '@/Components/Input/FloatingLabelInput'
import { isEmailValid } from '@/Utils'
import { BlueDotIcon, CircleIcon, DiamondIcon, CreateAccountIllustration } from '@standardnotes/icons'
import { useCaptcha } from '@/Hooks/useCaptcha'
import { AccountMenuPane } from '../../AccountMenu/AccountMenuPane'
import { isErrorResponse } from '@standardnotes/snjs'
type Props = {
application: WebApplication
@@ -20,6 +23,61 @@ const CreateAccount: FunctionComponent<Props> = ({ application }) => {
const [isEmailInvalid, setIsEmailInvalid] = useState(false)
const [isPasswordNotMatching, setIsPasswordNotMatching] = useState(false)
const [hvmToken, setHVMToken] = useState('')
const [captchaURL, setCaptchaURL] = useState('')
const register = useCallback(() => {
setIsCreatingAccount(true)
application
.register(email, password, hvmToken)
.then(() => {
application.accountMenuController.closeAccountMenu()
application.accountMenuController.setCurrentPane(AccountMenuPane.GeneralMenu)
})
.catch((err) => {
console.error(err)
application.alerts.alert(err as string).catch(console.error)
})
.finally(() => {
setIsCreatingAccount(false)
})
}, [application, email, hvmToken, password])
const captchaIframe = useCaptcha(captchaURL, (token) => {
setHVMToken(token)
setCaptchaURL('')
})
useEffect(() => {
if (!hvmToken) {
return
}
register()
}, [hvmToken, register])
const checkIfCaptchaRequiredAndRegister = useCallback(() => {
application
.getCaptchaUrl()
.then((response) => {
if (isErrorResponse(response)) {
throw new Error()
}
const { captchaUIUrl } = response.data
if (captchaUIUrl) {
setCaptchaURL(captchaUIUrl)
} else {
setCaptchaURL('')
register()
}
})
.catch((error) => {
console.error(error)
setCaptchaURL('')
register()
})
}, [application, register])
const emailInputRef = useRef<HTMLInputElement>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
const confirmPasswordInputRef = useRef<HTMLInputElement>(null)
@@ -81,21 +139,52 @@ const CreateAccount: FunctionComponent<Props> = ({ application }) => {
return
}
setIsCreatingAccount(true)
try {
await application.register(email, password)
application.purchaseFlowController.closePurchaseFlow()
void application.purchaseFlowController.openPurchaseFlow()
} catch (err) {
console.error(err)
application.alerts.alert(err as string).catch(console.error)
} finally {
setIsCreatingAccount(false)
}
checkIfCaptchaRequiredAndRegister()
}
const CreateAccountForm = (
<form onSubmit={handleCreateAccount}>
<div className="flex flex-col">
<FloatingLabelInput
className={`min-w-auto md:min-w-90 ${isEmailInvalid ? 'mb-2' : 'mb-4'}`}
id="purchase-sign-in-email"
type="email"
label="Email"
value={email}
onChange={handleEmailChange}
ref={emailInputRef}
disabled={isCreatingAccount}
isInvalid={isEmailInvalid}
/>
{isEmailInvalid ? <div className="mb-4 text-danger">Please provide a valid email.</div> : null}
<FloatingLabelInput
className="min-w-auto mb-4 md:min-w-90"
id="purchase-create-account-password"
type="password"
label="Password"
value={password}
onChange={handlePasswordChange}
ref={passwordInputRef}
disabled={isCreatingAccount}
/>
<FloatingLabelInput
className={`min-w-auto md:min-w-90 ${isPasswordNotMatching ? 'mb-2' : 'mb-4'}`}
id="create-account-confirm"
type="password"
label="Repeat password"
value={confirmPassword}
onChange={handleConfirmPasswordChange}
ref={confirmPasswordInputRef}
disabled={isCreatingAccount}
isInvalid={isPasswordNotMatching}
/>
{isPasswordNotMatching ? (
<div className="mb-4 text-danger">Passwords don't match. Please try again.</div>
) : null}
</div>
</form>
)
return (
<div className="flex items-center">
<CircleIcon className="absolute -left-28 top-[40%] h-8 w-8" />
@@ -109,46 +198,7 @@ const CreateAccount: FunctionComponent<Props> = ({ application }) => {
<div className="mr-0 lg:mr-12">
<h1 className="mb-2 mt-0 text-2xl font-bold">Create your free account</h1>
<div className="mb-4 text-sm font-medium">to continue to Standard Notes.</div>
<form onSubmit={handleCreateAccount}>
<div className="flex flex-col">
<FloatingLabelInput
className={`min-w-auto md:min-w-90 ${isEmailInvalid ? 'mb-2' : 'mb-4'}`}
id="purchase-sign-in-email"
type="email"
label="Email"
value={email}
onChange={handleEmailChange}
ref={emailInputRef}
disabled={isCreatingAccount}
isInvalid={isEmailInvalid}
/>
{isEmailInvalid ? <div className="mb-4 text-danger">Please provide a valid email.</div> : null}
<FloatingLabelInput
className="min-w-auto mb-4 md:min-w-90"
id="purchase-create-account-password"
type="password"
label="Password"
value={password}
onChange={handlePasswordChange}
ref={passwordInputRef}
disabled={isCreatingAccount}
/>
<FloatingLabelInput
className={`min-w-auto md:min-w-90 ${isPasswordNotMatching ? 'mb-2' : 'mb-4'}`}
id="create-account-confirm"
type="password"
label="Repeat password"
value={confirmPassword}
onChange={handleConfirmPasswordChange}
ref={confirmPasswordInputRef}
disabled={isCreatingAccount}
isInvalid={isPasswordNotMatching}
/>
{isPasswordNotMatching ? (
<div className="mb-4 text-danger">Passwords don't match. Please try again.</div>
) : null}
</div>
</form>
{captchaURL ? captchaIframe : CreateAccountForm}
<div className="flex flex-col-reverse items-start justify-between md:flex-row md:items-center">
<div className="flex flex-col">
<button

View File

@@ -6,7 +6,8 @@ import { ChangeEventHandler, FunctionComponent, useEffect, useRef, useState } fr
import FloatingLabelInput from '@/Components/Input/FloatingLabelInput'
import { isEmailValid } from '@/Utils'
import { BlueDotIcon, CircleIcon, DiamondIcon } from '@standardnotes/icons'
import { isErrorResponse } from '@standardnotes/snjs'
import { isErrorResponse, getCaptchaHeader } from '@standardnotes/snjs'
import { useCaptcha } from '@/Hooks/useCaptcha'
type Props = {
application: WebApplication
@@ -21,6 +22,15 @@ const SignIn: FunctionComponent<Props> = ({ application }) => {
const [isPasswordInvalid, setIsPasswordInvalid] = useState(false)
const [otherErrorMessage, setOtherErrorMessage] = useState('')
const [captchaURL, setCaptchaURL] = useState('')
const [showCaptcha, setShowCaptcha] = useState(false)
const [hvmToken, setHVMToken] = useState('')
const captchaIframe = useCaptcha(captchaURL, (token) => {
setHVMToken(token)
setShowCaptcha(false)
setCaptchaURL('')
})
const emailInputRef = useRef<HTMLInputElement>(null)
const passwordInputRef = useRef<HTMLInputElement>(null)
@@ -65,10 +75,22 @@ const SignIn: FunctionComponent<Props> = ({ application }) => {
return
}
if (captchaURL) {
setShowCaptcha(true)
return
}
setIsSigningIn(true)
try {
const response = await application.signIn(email, password)
const response = await application.signIn(email, password, undefined, undefined, undefined, undefined, hvmToken)
const captchaURL = getCaptchaHeader(response)
if (captchaURL) {
setCaptchaURL(captchaURL)
return
} else {
setCaptchaURL('')
}
if (isErrorResponse(response)) {
throw new Error(response.data.error?.message)
} else {
@@ -78,7 +100,6 @@ const SignIn: FunctionComponent<Props> = ({ application }) => {
} catch (err) {
console.error(err)
if ((err as Error).toString().includes('Invalid email or password')) {
setIsSigningIn(false)
setIsEmailInvalid(true)
setIsPasswordInvalid(true)
setOtherErrorMessage('Invalid email or password.')
@@ -86,9 +107,51 @@ const SignIn: FunctionComponent<Props> = ({ application }) => {
} else {
application.alerts.alert(err as string).catch(console.error)
}
} finally {
setIsSigningIn(false)
}
}
const signInForm = (
<form onSubmit={handleSignIn}>
<div className="flex flex-col">
<FloatingLabelInput
className={`min-w-auto sm:min-w-90 ${isEmailInvalid && !otherErrorMessage ? 'mb-2' : 'mb-4'}`}
id="purchase-sign-in-email"
type="email"
label="Email"
value={email}
onChange={handleEmailChange}
ref={emailInputRef}
disabled={isSigningIn}
isInvalid={isEmailInvalid}
/>
{isEmailInvalid && !otherErrorMessage ? (
<div className="mb-4 text-danger">Please provide a valid email.</div>
) : null}
<FloatingLabelInput
className={`min-w-auto sm:min-w-90 ${otherErrorMessage ? 'mb-2' : 'mb-4'}`}
id="purchase-sign-in-password"
type="password"
label="Password"
value={password}
onChange={handlePasswordChange}
ref={passwordInputRef}
disabled={isSigningIn}
isInvalid={isPasswordInvalid}
/>
{otherErrorMessage ? <div className="mb-4 text-danger">{otherErrorMessage}</div> : null}
</div>
<Button
className={`${isSigningIn ? 'min-w-30' : 'min-w-24'} mb-5 py-2.5`}
primary
label={isSigningIn ? 'Signing in...' : 'Sign in'}
onClick={handleSignIn}
disabled={isSigningIn}
/>
</form>
)
return (
<div className="flex items-center">
<CircleIcon className="absolute -left-56 top-[35%] h-8 w-8" />
@@ -102,43 +165,7 @@ const SignIn: FunctionComponent<Props> = ({ application }) => {
<div>
<h1 className="mb-2 mt-0 text-2xl font-bold">Sign in</h1>
<div className="mb-4 text-sm font-medium">to continue to Standard Notes.</div>
<form onSubmit={handleSignIn}>
<div className="flex flex-col">
<FloatingLabelInput
className={`min-w-auto sm:min-w-90 ${isEmailInvalid && !otherErrorMessage ? 'mb-2' : 'mb-4'}`}
id="purchase-sign-in-email"
type="email"
label="Email"
value={email}
onChange={handleEmailChange}
ref={emailInputRef}
disabled={isSigningIn}
isInvalid={isEmailInvalid}
/>
{isEmailInvalid && !otherErrorMessage ? (
<div className="mb-4 text-danger">Please provide a valid email.</div>
) : null}
<FloatingLabelInput
className={`min-w-auto sm:min-w-90 ${otherErrorMessage ? 'mb-2' : 'mb-4'}`}
id="purchase-sign-in-password"
type="password"
label="Password"
value={password}
onChange={handlePasswordChange}
ref={passwordInputRef}
disabled={isSigningIn}
isInvalid={isPasswordInvalid}
/>
{otherErrorMessage ? <div className="mb-4 text-danger">{otherErrorMessage}</div> : null}
</div>
<Button
className={`${isSigningIn ? 'min-w-30' : 'min-w-24'} mb-5 py-2.5`}
primary
label={isSigningIn ? 'Signing in...' : 'Sign in'}
onClick={handleSignIn}
disabled={isSigningIn}
/>
</form>
{showCaptcha ? captchaIframe : signInForm}
<div className="text-sm font-medium text-passive-1">
Dont have an account yet?{' '}
<a

View File

@@ -0,0 +1,31 @@
import { useEffect } from 'react'
export const useCaptcha = (captchaURL: string, callback: (token: string) => void) => {
useEffect(() => {
function handleCaptchaEvent(event: any) {
if (!captchaURL) {
return
}
if (event.origin !== new URL(captchaURL).origin) {
return
}
if (event?.data?.type?.includes('captcha')) {
callback(event.data.token)
}
}
window.addEventListener('message', handleCaptchaEvent)
return () => {
window.removeEventListener('message', handleCaptchaEvent)
}
}, [callback])
if (!captchaURL) {
return null
}
return <iframe src={captchaURL} height={480}></iframe>
}