Skip to content

Commit f2ab1b8

Browse files
feat(authenticator): listen to tokenRefresh events (#1809)
* First draft * Add missing initial state * Remove unused util * Add e2e test for token hub events * Run change detection only on state changes * Remove unused imports * Add comment * Formatting change * Create two-turkeys-wash.md * Revert "Remove unused imports" This reverts commit f94d593. * Revert "Run change detection only on state changes" This reverts commit 8def06f. * Add explicit change detection for Angular * Add comment * handle hub event explicitly * Update comment * Update comment * More comment * Create brave-coins-shave.md * Update .changeset/two-turkeys-wash.md * Use single quotes + Use const Co-authored-by: Caleb Pollman <[email protected]> Co-authored-by: Caleb Pollman <[email protected]>
1 parent 4c6ca0a commit f2ab1b8

File tree

12 files changed

+146
-19
lines changed

12 files changed

+146
-19
lines changed

.changeset/brave-coins-shave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@aws-amplify/ui-angular": patch
3+
---
4+
5+
Update Angular Authenticator to manually trigger change detection after hub events.

.changeset/two-turkeys-wash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@aws-amplify/ui": minor
3+
---
4+
5+
feat(authenticator): listen to tokenRefresh events

examples/angular/src/app/app.component.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Component } from '@angular/core';
2-
import { Amplify } from 'aws-amplify';
2+
import { Amplify, Hub } from 'aws-amplify';
33

44
@Component({
55
selector: 'app-root',
@@ -10,8 +10,10 @@ export class AppComponent {
1010
title = 'angular';
1111

1212
constructor() {
13+
// This exists to expose `Amplify` & its categories on `window` for e2e testing
1314
if (typeof window !== 'undefined') {
1415
(window as any)['Amplify'] = Amplify;
16+
(window as any)['Hub'] = Hub;
1517
}
1618
}
1719
}

examples/next/pages/_app.page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22

33
// https://nextjs.org/docs/advanced-features/custom-app
44
import App from 'next/app';
5-
import { Amplify } from 'aws-amplify';
5+
import { Amplify, Hub } from 'aws-amplify';
66
import { Authenticator, AmplifyProvider } from '@aws-amplify/ui-react';
77

88
if (typeof window !== 'undefined') {
99
window['Amplify'] = Amplify;
10+
window['Hub'] = Hub;
1011
}
1112

1213
export default function MyApp(props) {

examples/vue/src/main.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { Amplify } from 'aws-amplify';
1+
import { Amplify, Hub } from 'aws-amplify';
22
import { createApp } from 'vue';
33
import App from './App.vue';
44
import router from './router';
55

66
// This only exists to expose `Amplify` & its categories on `window` for e2e testing
77
if (typeof window !== 'undefined') {
88
window['Amplify'] = Amplify;
9+
window['Hub'] = Hub;
910
}
1011

1112
createApp(App).use(router).mount('#app');

packages/angular/projects/ui-angular/src/lib/components/authenticator/components/authenticator/authenticator.component.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
AfterContentInit,
3+
ChangeDetectorRef,
34
Component,
45
ContentChildren,
56
Input,
@@ -11,6 +12,7 @@ import {
1112
} from '@angular/core';
1213
import {
1314
AuthenticatorMachineOptions,
15+
listenToAuthHub,
1416
SocialProvider,
1517
translate,
1618
} from '@aws-amplify/ui';
@@ -44,11 +46,14 @@ export class AuthenticatorComponent
4446
public signUpTitle = translate('Create Account');
4547

4648
private hasInitialized = false;
49+
private isHandlingHubEvent = false;
4750
private unsubscribeMachine: () => void;
51+
private unsubscribeHub: ReturnType<typeof listenToAuthHub>;
4852

4953
constructor(
5054
private authenticator: AuthenticatorService,
51-
private contextService: CustomComponentsService
55+
private contextService: CustomComponentsService,
56+
private changeDetector: ChangeDetectorRef
5257
) {}
5358

5459
ngOnInit(): void {
@@ -61,12 +66,47 @@ export class AuthenticatorComponent
6166
formFields,
6267
} = this;
6368

69+
this.unsubscribeHub = listenToAuthHub((event) => {
70+
/**
71+
* Hub events aren't properly caught by Angular, because they are
72+
* synchronous events. Angular tracks async network events and
73+
* html events, but not synchronous events like hub.
74+
*
75+
* On any notable hub events, we run change detection manually.
76+
*/
77+
const state = this.authenticator.authService.send(event);
78+
this.changeDetector.detectChanges();
79+
80+
/**
81+
* Hub events that we handle can lead to multiple state changes:
82+
* e.g. `authenticated` -> `signOut` -> initialState.
83+
*
84+
* We want to ensure change detection runs all the way, until
85+
* we reach back to the initial state. Setting the below flag
86+
* to true to until we reach initial state.
87+
*/
88+
this.isHandlingHubEvent = true;
89+
return state;
90+
});
91+
6492
/**
6593
* Subscribes to state machine changes and sends INIT event
6694
* once machine reaches 'setup' state.
6795
*/
6896
this.unsubscribeMachine = this.authenticator.subscribe(() => {
6997
const { route } = this.authenticator;
98+
99+
if (this.isHandlingHubEvent) {
100+
this.changeDetector.detectChanges();
101+
102+
const initialStateWithDefault = initialState ?? 'signIn';
103+
104+
// We can stop manual change detection if we're back to the initial state
105+
if (route === initialStateWithDefault) {
106+
this.isHandlingHubEvent = false;
107+
}
108+
}
109+
70110
if (!this.hasInitialized && route === 'setup') {
71111
this.authenticator.send({
72112
type: 'INIT',
@@ -103,6 +143,7 @@ export class AuthenticatorComponent
103143

104144
ngOnDestroy(): void {
105145
if (this.unsubscribeMachine) this.unsubscribeMachine();
146+
if (this.unsubscribeHub) this.unsubscribeHub();
106147
}
107148

108149
/**

packages/angular/projects/ui-angular/src/lib/services/authenticator.service.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ export class AuthenticatorService implements OnDestroy {
2727
private _authService: AuthInterpreter;
2828
private _sendEventAliases: ReturnType<typeof getSendEventAliases>;
2929
private _machineSubscription: Subscription;
30-
private _hubSubscription: ReturnType<typeof listenToAuthHub>;
3130
private _facade: ReturnType<typeof getServiceContextFacade>;
3231

3332
constructor() {
@@ -40,14 +39,12 @@ export class AuthenticatorService implements OnDestroy {
4039
this._facade = getServiceContextFacade(state);
4140
});
4241

43-
this._hubSubscription = listenToAuthHub(authService.send);
4442
this._sendEventAliases = getSendEventAliases(authService.send);
4543
this._authService = authService;
4644
}
4745

4846
ngOnDestroy(): void {
4947
if (this._machineSubscription) this._machineSubscription.unsubscribe();
50-
if (this._hubSubscription) this._hubSubscription();
5148
}
5249

5350
/**

packages/e2e/cypress/integration/common/shared.ts

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,28 @@ import { get, escapeRegExp } from 'lodash';
88
let language = 'en-US';
99
let window = null;
1010

11+
/**
12+
* Given dot delimited paths to a method (e.g. Amplify.Auth.signIn) on window,
13+
* returns the object that holds the method (Amplify.Auth) and the method (signIn).
14+
*
15+
* Used for mocking and spying Amplify methods.
16+
*/
17+
const getMethodFromWindow = (path: string) => {
18+
const paths = path.split('.');
19+
const method = paths.pop();
20+
const obj = get(window, paths);
21+
22+
if (!window) {
23+
throw new Error('window has not been set in the Cypress tests');
24+
}
25+
26+
if (!obj || !method) {
27+
throw new Error(`Could not find "${path}" on the window`);
28+
}
29+
30+
return { obj, method };
31+
};
32+
1133
Given("I'm running the example {string}", (example: string) => {
1234
cy.visit(example, {
1335
// See: https://glebbahmutov.com/blog/cypress-tips-and-tricks/#control-navigatorlanguage
@@ -79,17 +101,7 @@ Given(
79101
Given(
80102
'I mock {string} with fixture {string}',
81103
(path: string, fixture: string) => {
82-
let paths = path.split('.');
83-
const method = paths.pop();
84-
const obj = get(window, paths);
85-
86-
if (!window) {
87-
throw new Error(`window has not been set in the Cypress tests`);
88-
}
89-
90-
if (!obj || !method) {
91-
throw new Error(`Could not find "${path}" on the window`);
92-
}
104+
const { obj, method } = getMethodFromWindow(path);
93105

94106
cy.fixture(fixture).then((result) => {
95107
console.info('`%s` mocked with %o', path, result);
@@ -135,7 +147,7 @@ When('I click the {string} button', (name: string) => {
135147
Then('I see the {string} button', (name: string) => {
136148
cy.findByRole('button', {
137149
name: new RegExp(`^${escapeRegExp(name)}$`, 'i'),
138-
}).should('be.visible');
150+
}).should('exist');
139151
});
140152

141153
When('I click the {string} checkbox', (label: string) => {
@@ -291,6 +303,28 @@ When('I see {string} as the {string} input', (custom, order) => {
291303
// cy.findByLabelText(custom).type(Cypress.env('VALID_PASSWORD'));
292304
});
293305

306+
When('I mock {string} event', (eventName: string) => {
307+
if (!window) {
308+
throw new Error('window has not been set in the Cypress tests');
309+
}
310+
311+
const Hub = window['Hub'];
312+
if (!Hub) {
313+
throw new Error('Hub is not available on the window.');
314+
}
315+
316+
Hub.dispatch('auth', { event: eventName });
317+
});
318+
319+
Given('I spy {string} method', (path) => {
320+
const { obj, method } = getMethodFromWindow(path);
321+
cy.spy(obj, method).as(path);
322+
});
323+
324+
Then('{string} method is called', (path) => {
325+
cy.get(`@${path}`).should('have.been.calledOnce');
326+
});
327+
294328
When('I type a valid code', () => {
295329
/**
296330
* Confirmation code differs on React/Vue vs Angular. Testing for both for

packages/e2e/features/ui/components/authenticator/hub-events.feature

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,21 @@ Feature: Hub Events
1515
And I click the "Sign out" button
1616
Then I see "Sign in"
1717

18+
@angular @react @vue
19+
Scenario: Unsuccessful token refresh logs out the user
20+
When I type my "email" with status "CONFIRMED"
21+
And I type my password
22+
And I click the "Sign in" button
23+
Then I see "Sign out"
24+
When I mock "tokenRefresh_failure" event
25+
Then I see "Sign in"
26+
27+
@angular @react @vue
28+
Scenario: Successful token refresh calls currentAuthenticatedUser
29+
When I type my "email" with status "CONFIRMED"
30+
And I type my password
31+
And I click the "Sign in" button
32+
Then I see "Sign out"
33+
Given I spy "Amplify.Auth.currentAuthenticatedUser" method
34+
When I mock "tokenRefresh" event
35+
And "Amplify.Auth.currentAuthenticatedUser" method is called

packages/ui/src/helpers/authenticator/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ export const listenToAuthHub = (send: AuthMachineSend) => {
4545
switch (data.payload.event) {
4646
// TODO: We can add more cases here, according to
4747
// https://docs.amplify.aws/lib/auth/auth-events/q/platform/js/
48+
case 'tokenRefresh':
49+
send('TOKEN_REFRESH');
50+
break;
4851
case 'signOut':
52+
case 'tokenRefresh_failure':
4953
send('SIGN_OUT');
5054
break;
5155
}

0 commit comments

Comments
 (0)