Skip to content

Commit

Permalink
support login delay: indication (countdown timer + icon)
Browse files Browse the repository at this point in the history
  • Loading branch information
vladimiry committed Mar 29, 2019
1 parent fdcfde2 commit 1cb2d62
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 62 deletions.
4 changes: 4 additions & 0 deletions src/web/src/app/_accounts/account-title.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
<div class="d-flex ml-2">
<i [class]="state.account.notifications.loggedIn ? 'fa fa-unlock' : 'fa fa-lock'"></i>
</div>
<div class="d-flex ml-1 login-delay" *ngIf="state.loginDelayed">
<i class="fa fa-hand-pointer-o" *ngIf="state.account.loginDelayedUntilSelected"></i>
<span *ngIf="state.account.loginDelayedSeconds; let remainingSeconds">{{ remainingSeconds }}</span>
</div>
<div class="ml-2 d-flex flex-grow-1">{{ state.account.accountConfig.login }}</div>
</a>
<button (click)="toggleViewMode($event)" *ngIf="state.stored" class="btn b-toggle-view" title="Toggle online/database view mode">
Expand Down
15 changes: 12 additions & 3 deletions src/web/src/app/_accounts/account-title.component.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@import "~src/web/src/variables";

@mixin button($color) {
@mixin account-title-button($color) {
@include button-variant($color, $color);

&:focus {
Expand All @@ -23,7 +23,7 @@

&:not(.selected) {
.btn {
@include button($btn-default-color);
@include account-title-button($btn-default-color);
}

.b-toggle-view {
Expand All @@ -33,7 +33,7 @@

&.selected {
.btn {
@include button($btn-selected-bg-color);
@include account-title-button($btn-selected-bg-color);
color: $btn-selected-color;
}

Expand Down Expand Up @@ -73,4 +73,13 @@
.fa {
line-height: $line-height-base;
}

.login-delay {
color: $text-muted;
font-size: 85%;

.fa ~ span {
margin-left: $app-spacer-1 / 2;
}
}
}
18 changes: 12 additions & 6 deletions src/web/src/app/_accounts/account-title.component.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {BehaviorSubject, Subscription} from "rxjs";
import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit} from "@angular/core";
import {Store, select} from "@ngrx/store";
import {distinctUntilChanged, filter} from "rxjs/operators";
import {distinctUntilChanged, filter, map} from "rxjs/operators";

import {ACCOUNTS_ACTIONS} from "src/web/src/app/store/actions";
import {AccountsSelectors} from "src/web/src/app/store/selectors";
Expand Down Expand Up @@ -35,18 +35,20 @@ export class AccountTitleComponent implements OnInit, OnDestroy {
state$ = this.stateSubject$
.asObservable()
.pipe(
filter((s) => Boolean(s.account)),
filter((state) => Boolean(state.account)),
// .pipe(debounceTime(200)),
map((state) => {
return {
...state,
loginDelayed: Boolean(state.account.loginDelayedSeconds || state.account.loginDelayedUntilSelected),
};
}),
);

private accountLogin!: string;

private subscription = new Subscription();

constructor(
private store: Store<State>,
) {}

@Input()
set account(account: WebAccount) {
this.accountLogin = account.accountConfig.login;
Expand All @@ -57,6 +59,10 @@ export class AccountTitleComponent implements OnInit, OnDestroy {
});
}

constructor(
private store: Store<State>,
) {}

ngOnInit() {
if (!this.highlighting) {
return;
Expand Down
126 changes: 79 additions & 47 deletions src/web/src/app/_accounts/accounts.effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export class AccountsEffects {
const zoneName = logger.zoneName();

// TODO make sure passwords submitting looping doesn't happen, until then a workaround is enabled below
const rateLimitingCheck = (password: string) => {
const rateLimitCheck = (password: string) => {
const key = String([login, pageType, password]);
const timeLeft = this.twoPerTenSecLimiter(key);

Expand Down Expand Up @@ -268,49 +268,75 @@ export class AccountsEffects {

logger.info(`login delay configs: ${JSON.stringify({loginDelayUntilSelected, loginDelaySecondsRange})}`);

this.store.dispatch(ACCOUNTS_ACTIONS.Patch({login, patch: {loginDelayedSeconds: undefined}}));
this.store.dispatch(ACCOUNTS_ACTIONS.Patch({login, patch: {loginDelayedUntilSelected: undefined}}));

if (loginDelaySecondsRange) {
const {start, end} = loginDelaySecondsRange;
const delayTime = getRandomInt(start, end) * ONE_SECOND_MS;
const delayTimeMs = getRandomInt(start, end) * ONE_SECOND_MS;

logger.info(`resolved login delay (ms): ${delayTime}`);
logger.info(`resolved login delay (ms): ${delayTimeMs}`);

delayTriggers.push(
timer(delayTime).pipe(
map(() => ({trigger: `triggered on login delay expiration (ms): ${delayTime}`})),
merge(
timer(delayTimeMs).pipe(
map(() => ({trigger: `triggered on login delay expiration (ms): ${delayTimeMs}`})),
),
timer(0, ONE_SECOND_MS).pipe(
mergeMap((value) => {
const loginDelayedSeconds = (delayTimeMs / ONE_SECOND_MS) - value;
this.store.dispatch(
ACCOUNTS_ACTIONS.Patch({login, patch: {loginDelayedSeconds}}),
);
return EMPTY;
}),
),
),
);
}

if (loginDelayUntilSelected) {
delayTriggers.push(
this.store.pipe(
select(AccountsSelectors.FEATURED.selectedLogin),
filter((selectedLogin) => selectedLogin === login),
map(() => ({trigger: "triggered on account selection"})),
merge(
(() => {
this.store.dispatch(
ACCOUNTS_ACTIONS.Patch({login, patch: {loginDelayedUntilSelected: true}}),
);
return EMPTY;
})(),
this.store.pipe(
select(AccountsSelectors.FEATURED.selectedLogin),
filter((selectedLogin) => selectedLogin === login),
// delay handles the case if the app has no selected account and "on select" trigger gets disabled
// if there is no selected account the app will select the account automatically
// and previously setup "on select" trigger kicks in before it gets reset by new TryToLogin action
delay(ONE_SECOND_MS * 1.5),
map(() => ({trigger: "triggered on account selection"})),
),
),
);
}

const triggerReset$ = race([
this.actions$.pipe(
unionizeActionFilter(ACCOUNTS_ACTIONS.is.TryToLogin),
filter(({payload: anoterPayload}) => {
return payload.account.accountConfig.login === anoterPayload.account.accountConfig.login;
filter(({payload: livePayload}) => {
return payload.account.accountConfig.login === livePayload.account.accountConfig.login;
}),
map(({type: actionType}) => {
return `another "${actionType}" action triggered`;
}),
),
this.store.pipe(
select(AccountsSelectors.ACCOUNTS.pickAccount({login})),
map((storeAccount) => {
if (!storeAccount) {
map((liveAccount) => {
if (!liveAccount) {
return;
}
if (storeAccount.notifications.pageType.type !== "login") {
return `page type changed to ${JSON.stringify(storeAccount.notifications.pageType)}`;
if (liveAccount.notifications.pageType.type !== "login") {
return `page type changed to ${JSON.stringify(liveAccount.notifications.pageType)}`;
}
if (storeAccount.progress.password) {
if (liveAccount.progress.password) {
return `"login" action performing is already in progress`;
}
return;
Expand All @@ -331,42 +357,50 @@ export class AccountsEffects {
takeUntil(triggerReset$),
)
: of({trigger: "triggered immediate login (as no delays defined)"});
const executeLoginAction = (password: string) => merge(
of(ACCOUNTS_ACTIONS.PatchProgress({login, patch: {password: true}})),
resetNotificationsState$,
this.api.webViewClient(webView, type).pipe(
mergeMap((webViewClient) => webViewClient("login")({login, password, zoneName})),
mergeMap(() => this.store.pipe(
select(AccountsSelectors.FEATURED.selectedLogin),
take(1),
mergeMap((selectedLogin) => {
if (selectedLogin) {
return EMPTY;
}
// let's select the account if none has been selected
return of(ACCOUNTS_ACTIONS.Activate({login}));
}),
)),
catchError((error) => of(CORE_ACTIONS.Fail(error))),
finalize(() => this.store.dispatch(ACCOUNTS_ACTIONS.PatchProgress({login, patch: {password: false}}))),
),
);
const executeLoginAction = (password: string) => {
rateLimitCheck(password);

logger.info("login");

return merge(
of(ACCOUNTS_ACTIONS.Patch({login, patch: {loginDelayedSeconds: undefined}})),
of(ACCOUNTS_ACTIONS.Patch({login, patch: {loginDelayedUntilSelected: undefined}})),
of(ACCOUNTS_ACTIONS.PatchProgress({login, patch: {password: true}})),
resetNotificationsState$,
this.api.webViewClient(webView, type).pipe(
mergeMap((webViewClient) => webViewClient("login")({login, password, zoneName})),
mergeMap(() => this.store.pipe(
select(AccountsSelectors.FEATURED.selectedLogin),
take(1),
mergeMap((selectedLogin) => {
if (selectedLogin) {
return EMPTY;
}
// let's select the account if none has been selected
return of(ACCOUNTS_ACTIONS.Activate({login}));
}),
)),
catchError((error) => of(CORE_ACTIONS.Fail(error))),
finalize(() => this.store.dispatch(ACCOUNTS_ACTIONS.PatchProgress({login, patch: {password: false}}))),
),
);
};

return trigger$.pipe(
mergeMap(({trigger}) => this.store.pipe(
select(AccountsSelectors.ACCOUNTS.pickAccount({login})),
// WARN: do not react to every account change notification
// but only reaction to just pick the up to date password (it can be changed during login delay)
// otherwise multiple login form submitting attempts can happen
take(1),
mergeMap((value) => {
if (!value) {
// early skipping if account got removed during login delaying
// early skipping if account got removed during login delay
logger.info("account got removed during login delaying?");
return EMPTY;
}
return [{password: value.accountConfig.credentials.password}];
}),
// WARN: do not react to all the notifications
// but to only one in order to just pick up the to date password
// or multiple login form submitting attempts can happen
take(1),
mergeMap(({password}) => {
logger.info(`login trigger: ${trigger})`);

Expand All @@ -375,10 +409,6 @@ export class AccountsEffects {
return EMPTY;
}

rateLimitingCheck(password);

logger.info("login");

return executeLoginAction(password);
}),
)),
Expand All @@ -391,7 +421,7 @@ export class AccountsEffects {
break;
}

rateLimitingCheck(secret);
rateLimitCheck(secret);

logger.info("login2fa");

Expand Down Expand Up @@ -419,7 +449,9 @@ export class AccountsEffects {
break;
}

rateLimitingCheck(mailPassword);
rateLimitCheck(mailPassword);

logger.info("unlock");

return merge(
of(ACCOUNTS_ACTIONS.PatchProgress({login, patch: {mailPassword: true}})),
Expand Down
2 changes: 2 additions & 0 deletions src/web/src/app/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ interface GenericWebAccount<C extends AccountConfig, NS extends Notifications> {
syncingActivated?: boolean;
databaseView?: boolean;
loginFilledOnce?: boolean;
loginDelayedSeconds?: number;
loginDelayedUntilSelected?: boolean;
}

export type WebAccountProtonmail = GenericWebAccount<AccountConfigProtonmail, NotificationsProtonmail>;
Expand Down
10 changes: 6 additions & 4 deletions src/web/src/app/store/actions/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ export const ACCOUNTS_ACTIONS = unionize({
PatchProgress: ofType<{ login: string; patch: WebAccountProgress; }>(),
Patch: ofType<{
login: string;
// TODO apply "deep partial" transformation instead of explicit individual per-field partitioning
patch: Partial<{
notifications: Partial<WebAccount["notifications"]>,
syncingActivated: Partial<WebAccount["syncingActivated"]>,
loginFilledOnce: Partial<WebAccount["loginFilledOnce"]>,
[k in keyof Pick<WebAccount,
| "notifications"
| "syncingActivated"
| "loginFilledOnce"
| "loginDelayedSeconds"
| "loginDelayedUntilSelected">]: Partial<WebAccount[k]>
}>;
ignoreNoAccount?: boolean
}>(),
Expand Down
13 changes: 11 additions & 2 deletions src/web/src/app/store/reducers/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,18 @@ export function reducer(state = initialState, action: UnionOf<typeof ACCOUNTS_AC
}
accounts.push(account);
} else {
accounts.push({
const webAccount = {
accountConfig,
progress: {},
notifications: {
loggedIn: false,
unread: 0,
pageType: {url: "", type: "unknown"},
},
} as WebAccount); // TODO ger rid of "TS as" casting
loginDelay: {},
} as WebAccount; // TODO ger rid of "TS as" casting

accounts.push(webAccount);
}

return accounts;
Expand Down Expand Up @@ -102,6 +105,12 @@ export function reducer(state = initialState, action: UnionOf<typeof ACCOUNTS_AC
if ("loginFilledOnce" in patch) {
account.loginFilledOnce = patch.loginFilledOnce;
}
if ("loginDelayedSeconds" in patch) {
account.loginDelayedSeconds = patch.loginDelayedSeconds;
}
if ("loginDelayedUntilSelected" in patch) {
account.loginDelayedUntilSelected = patch.loginDelayedUntilSelected;
}
},
ToggleDatabaseView: ({login, forced}) => {
const {account} = pickAccountBundle(draftState.accounts, {login});
Expand Down

0 comments on commit 1cb2d62

Please sign in to comment.