Skip to content

Commit a08197f

Browse files
✨ Added One-Time-Code flow to member sign-in (#25187)
closes https://linear.app/ghost/issue/BER-2520/ - Portal's sign-in flow will now include a one-time-code (OTC) in emails and display a code input form after submitting the standard email sign-in form - OTC usage is optional for members, magic links are still included alongside the code - reduces sign-in friction in scenarios such as in-app browsers - custom sign-in forms can opt in to the same OTC flow by adding a `data-members-otc=true"` - in this scenario, Portal will open the code input modal once a member submits their email in the custom form
1 parent 4be89f9 commit a08197f

File tree

19 files changed

+329
-753
lines changed

19 files changed

+329
-753
lines changed

apps/admin-x-settings/src/components/settings/advanced/labs/PrivateFeatures.tsx

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,6 @@ const features: Feature[] = [{
2727
title: 'Explore',
2828
description: 'Enables keeping in touch with the new Explore API',
2929
flag: 'explore'
30-
}, {
31-
title: 'Members sign-in OTC (private beta)',
32-
description: 'Enables one-time codes alongside magic links for members signin',
33-
flag: 'membersSigninOTC'
34-
}, {
35-
title: 'Members sign-in OTC (internal alpha)',
36-
description: 'Testing changes to members sign-in OTC prior to private beta release',
37-
flag: 'membersSigninOTCAlpha'
3830
}, {
3931
title: 'Tags X',
4032
description: 'Enables the new Tags UI',

apps/portal/src/actions.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,21 +79,17 @@ async function signout({api, state}) {
7979
}
8080

8181
async function signin({data, api, state}) {
82-
const {labs} = state;
83-
84-
const includeOTC = labs?.membersSigninOTC ? true : undefined;
85-
8682
try {
8783
const integrityToken = await api.member.getIntegrityToken();
8884
const payload = {
8985
...data,
9086
emailType: 'signin',
9187
integrityToken,
92-
...(includeOTC ? {includeOTC: true} : {})
88+
includeOTC: true
9389
};
9490
const response = await api.member.sendMagicLink(payload);
9591

96-
if (includeOTC && response?.otc_ref) {
92+
if (response?.otc_ref) {
9793
return {
9894
page: 'magiclink',
9995
lastPage: 'signin',

apps/portal/src/components/pages/MagicLinkPage.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,10 +222,10 @@ export default class MagicLinkPage extends React.Component {
222222
}
223223

224224
renderOTCForm() {
225-
const {action, actionErrorMessage, labs, otcRef} = this.context;
225+
const {action, actionErrorMessage, otcRef} = this.context;
226226
const errors = this.state.errors || {};
227227

228-
if (!labs?.membersSigninOTC || !otcRef) {
228+
if (!otcRef) {
229229
return null;
230230
}
231231

@@ -281,8 +281,8 @@ export default class MagicLinkPage extends React.Component {
281281
}
282282

283283
render() {
284-
const {labs, otcRef} = this.context;
285-
const showOTCForm = labs?.membersSigninOTC && otcRef;
284+
const {otcRef} = this.context;
285+
const showOTCForm = !!otcRef;
286286

287287
return (
288288
<div className='gh-portal-content'>

apps/portal/src/components/pages/MagicLinkPage.test.js

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ const OTC_ERROR_REGEX = /Enter code/i;
66

77
const setupTest = (options = {}) => {
88
const {
9-
labs = {membersSigninOTC: false},
9+
labs = {},
1010
otcRef = null,
1111
action = 'init:success',
1212
...contextOverrides
@@ -33,7 +33,7 @@ const setupTest = (options = {}) => {
3333
// Helper for OTC-enabled tests
3434
const setupOTCTest = (options = {}) => {
3535
return setupTest({
36-
labs: {membersSigninOTC: true},
36+
labs: {},
3737
otcRef: 'test-otc-ref',
3838
...options
3939
});
@@ -77,7 +77,7 @@ describe('MagicLinkPage', () => {
7777
});
7878

7979
describe('OTC form conditional rendering', () => {
80-
test('renders OTC form when lab flag enabled and otcRef exists', () => {
80+
test('renders OTC form when otcRef exists', () => {
8181
const utils = setupOTCTest();
8282

8383
expect(utils.getByLabelText(OTC_LABEL_REGEX)).toBeInTheDocument();
@@ -86,9 +86,7 @@ describe('MagicLinkPage', () => {
8686

8787
test('does not render OTC form when conditions not met', () => {
8888
const scenarios = [
89-
{labs: {membersSigninOTC: false}, otcRef: 'test-ref'},
90-
{labs: {membersSigninOTC: true}, otcRef: null},
91-
{labs: {membersSigninOTC: false}, otcRef: null}
89+
{labs: {}, otcRef: null}
9290
];
9391

9492
scenarios.forEach(({labs, otcRef}) => {
@@ -313,9 +311,9 @@ describe('MagicLinkPage', () => {
313311
});
314312

315313
describe('OTC flow edge cases', () => {
316-
test('does not render form without otcRef even with lab flag', () => {
314+
test('does not render form without otcRef', () => {
317315
const utils = setupTest({
318-
labs: {membersSigninOTC: true},
316+
labs: {},
319317
otcRef: null
320318
});
321319

apps/portal/src/data-attributes.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ function handleError(error, form, errorEl) {
1616
}
1717

1818
export async function formSubmitHandler(
19-
{event, form, errorEl, siteUrl, submitHandler, labs = {}, doAction, captureException}
19+
{event, form, errorEl, siteUrl, submitHandler, doAction, captureException}
2020
) {
2121
form.removeEventListener('submit', submitHandler);
2222
event.preventDefault();
@@ -47,7 +47,7 @@ export async function formSubmitHandler(
4747
emailType = form.dataset.membersForm;
4848
}
4949

50-
const wantsOTC = emailType === 'signin' && form?.dataset?.membersOtc === 'true' && labs?.membersSigninOTC;
50+
const wantsOTC = emailType === 'signin' && form?.dataset?.membersOtc === 'true';
5151

5252
form.classList.add('loading');
5353
const urlHistory = getUrlHistory();

apps/portal/src/index.js

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,6 @@ function getSiteData() {
2525
const locale = scriptTag.dataset.locale; // not providing a fallback here but will do it within the app.
2626

2727
const labs = {};
28-
// NOTE: dataset converts always lowercase dash-attrs to camelCase
29-
labs.membersSigninOTC = scriptTag.dataset.membersSigninOtc === 'true';
30-
labs.membersSigninOTCAlpha = scriptTag.dataset.membersSigninOtcAlpha === 'true';
3128

3229
return {siteUrl, apiKey, apiUrl, siteI18nEnabled, locale, labs};
3330
}

apps/portal/src/tests/SigninFlow.test.js

Lines changed: 25 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,16 @@ const multiTierSetup = async ({site, member = null}) => {
120120

121121
const realLocation = window.location;
122122

123+
// Helper function to verify OTC-enabled API calls
124+
const expectOTCEnabledSendMagicLinkAPICall = (ghostApi, email) => {
125+
expect(ghostApi.member.sendMagicLink).toHaveBeenCalledWith({
126+
email,
127+
emailType: 'signin',
128+
integrityToken: 'testtoken',
129+
includeOTC: true
130+
});
131+
};
132+
123133
describe('Signin', () => {
124134
describe('on single tier site', () => {
125135
beforeEach(() => {
@@ -140,6 +150,11 @@ describe('Signin', () => {
140150
site: FixtureSite.singleTier.basic
141151
});
142152

153+
// Mock sendMagicLink to return otc_ref for OTC flow
154+
ghostApi.member.sendMagicLink = vi.fn(() => {
155+
return Promise.resolve({success: true, otc_ref: 'test-otc-ref-123'});
156+
});
157+
143158
expect(popupFrame).toBeInTheDocument();
144159
expect(triggerButtonFrame).toBeInTheDocument();
145160
expect(emailInput).toBeInTheDocument();
@@ -152,41 +167,12 @@ describe('Signin', () => {
152167

153168
fireEvent.click(submitButton);
154169

155-
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
156-
expect(magicLink).toBeInTheDocument();
157-
158-
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
159-
email: 'jamie@example.com',
160-
emailType: 'signin',
161-
integrityToken: 'testtoken'
162-
});
163-
});
164-
165-
test('with OTC enabled', async () => {
166-
const {ghostApi, emailInput, submitButton, popupIframeDocument} = await setup({
167-
site: FixtureSite.singleTier.basic,
168-
labs: {membersSigninOTC: true}
169-
});
170-
171-
// Mock sendMagicLink to return otc_ref for OTC flow
172-
ghostApi.member.sendMagicLink = vi.fn(() => {
173-
return Promise.resolve({success: true, otc_ref: 'test-otc-ref-123'});
174-
});
175-
176-
fireEvent.change(emailInput, {target: {value: 'jamie@example.com'}});
177-
fireEvent.click(submitButton);
178-
179170
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
180171
expect(magicLink).toBeInTheDocument();
181172
const description = await within(popupIframeDocument).findByText(/An email has been sent to jamie@example.com/i);
182173
expect(description).toBeInTheDocument();
183174

184-
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
185-
email: 'jamie@example.com',
186-
emailType: 'signin',
187-
integrityToken: 'testtoken',
188-
includeOTC: true
189-
});
175+
expectOTCEnabledSendMagicLinkAPICall(ghostApi, 'jamie@example.com');
190176
});
191177

192178
test('without name field', async () => {
@@ -210,11 +196,7 @@ describe('Signin', () => {
210196
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
211197
expect(magicLink).toBeInTheDocument();
212198

213-
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
214-
email: 'jamie@example.com',
215-
emailType: 'signin',
216-
integrityToken: 'testtoken'
217-
});
199+
expectOTCEnabledSendMagicLinkAPICall(ghostApi, 'jamie@example.com');
218200
});
219201

220202
test('with only free plan', async () => {
@@ -238,11 +220,7 @@ describe('Signin', () => {
238220
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
239221
expect(magicLink).toBeInTheDocument();
240222

241-
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
242-
email: 'jamie@example.com',
243-
emailType: 'signin',
244-
integrityToken: 'testtoken'
245-
});
223+
expectOTCEnabledSendMagicLinkAPICall(ghostApi, 'jamie@example.com');
246224
});
247225
});
248226
});
@@ -282,11 +260,7 @@ describe('Signin', () => {
282260
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
283261
expect(magicLink).toBeInTheDocument();
284262

285-
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
286-
email: 'jamie@example.com',
287-
emailType: 'signin',
288-
integrityToken: 'testtoken'
289-
});
263+
expectOTCEnabledSendMagicLinkAPICall(ghostApi, 'jamie@example.com');
290264
});
291265

292266
test('without name field', async () => {
@@ -310,11 +284,7 @@ describe('Signin', () => {
310284
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
311285
expect(magicLink).toBeInTheDocument();
312286

313-
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
314-
email: 'jamie@example.com',
315-
emailType: 'signin',
316-
integrityToken: 'testtoken'
317-
});
287+
expectOTCEnabledSendMagicLinkAPICall(ghostApi, 'jamie@example.com');
318288
});
319289

320290
test('with only free plan available', async () => {
@@ -338,11 +308,7 @@ describe('Signin', () => {
338308
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
339309
expect(magicLink).toBeInTheDocument();
340310

341-
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
342-
email: 'jamie@example.com',
343-
emailType: 'signin',
344-
integrityToken: 'testtoken'
345-
});
311+
expectOTCEnabledSendMagicLinkAPICall(ghostApi, 'jamie@example.com');
346312
});
347313
});
348314

@@ -447,7 +413,7 @@ describe('OTC Integration Flow', () => {
447413
});
448414

449415
const utils = appRender(
450-
<App api={ghostApi} labs={{membersSigninOTC: true}} />
416+
<App api={ghostApi} labs={{}} />
451417
);
452418

453419
await utils.findByTitle(/portal-trigger/i);
@@ -481,23 +447,14 @@ describe('OTC Integration Flow', () => {
481447
fireEvent.click(verifyButton);
482448
};
483449

484-
const expectOTCEnabledApiCall = (ghostApi, email) => {
485-
expect(ghostApi.member.sendMagicLink).toHaveBeenCalledWith({
486-
email,
487-
emailType: 'signin',
488-
integrityToken: 'testtoken',
489-
includeOTC: true
490-
});
491-
};
492-
493450
test('complete OTC flow from signin to verification', async () => {
494451
const {ghostApi, popupIframeDocument} = await setupOTCFlow({
495452
site: FixtureSite.singleTier.basic
496453
});
497454

498455
await submitSigninForm(popupIframeDocument, 'jamie@example.com');
499456

500-
expectOTCEnabledApiCall(ghostApi, 'jamie@example.com');
457+
expectOTCEnabledSendMagicLinkAPICall(ghostApi, 'jamie@example.com');
501458
expect(ghostApi.member.sendMagicLink).toHaveBeenCalledTimes(1);
502459

503460
submitOTCForm(popupIframeDocument, '123456');
@@ -524,7 +481,7 @@ describe('OTC Integration Flow', () => {
524481

525482
await submitSigninForm(popupIframeDocument, 'jamie@example.com');
526483

527-
expectOTCEnabledApiCall(ghostApi, 'jamie@example.com');
484+
expectOTCEnabledSendMagicLinkAPICall(ghostApi, 'jamie@example.com');
528485
expect(ghostApi.member.sendMagicLink).toHaveBeenCalledTimes(1);
529486

530487
const otcInput = within(popupIframeDocument).queryByLabelText(OTC_LABEL_REGEX);
@@ -541,7 +498,7 @@ describe('OTC Integration Flow', () => {
541498

542499
await submitSigninForm(popupIframeDocument, 'jamie@example.com');
543500

544-
expectOTCEnabledApiCall(ghostApi, 'jamie@example.com');
501+
expectOTCEnabledSendMagicLinkAPICall(ghostApi, 'jamie@example.com');
545502

546503
const otcInput = within(popupIframeDocument).getByLabelText(OTC_LABEL_REGEX);
547504

apps/portal/src/tests/data-attributes.test.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ describe('Member Data attributes:', () => {
170170
expect(window.fetch).toHaveBeenLastCalledWith('https://portal.localhost/members/api/send-magic-link/', {body: expectedBody, headers: {'Content-Type': 'application/json'}, method: 'POST'});
171171
});
172172

173-
test('requests OTC magic link and opens Portal when flagged', async () => {
173+
test('requests OTC magic link and opens Portal when flagged with data-members-otc=true', async () => {
174174
const {event, form, errorEl, siteUrl, submitHandler} = getMockData();
175175
form.dataset.membersForm = 'signin';
176176
form.dataset.membersOtc = 'true';
@@ -183,7 +183,7 @@ describe('Member Data attributes:', () => {
183183
return originalQuerySelector(selector);
184184
});
185185

186-
const labs = {membersSigninOTC: true};
186+
const labs = {};
187187
const doAction = vi.fn(() => Promise.resolve());
188188

189189
const json = async () => ({otc_ref: 'otc_test_ref'});
@@ -231,7 +231,7 @@ describe('Member Data attributes:', () => {
231231
return originalQuerySelector(selector);
232232
});
233233

234-
const labs = {membersSigninOTC: true};
234+
const labs = {};
235235
const actionErrorMessage = new Error('failed to start OTC sign-in');
236236
const doAction = vi.fn(() => {
237237
throw actionErrorMessage;

ghost/admin/app/services/feature.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,6 @@ export default class FeatureService extends Service {
7070
@feature('editorExcerpt') editorExcerpt;
7171
@feature('contentVisibility') contentVisibility;
7272
@feature('contentVisibilityAlpha') contentVisibilityAlpha;
73-
@feature('membersSigninOTC') membersSigninOTC;
74-
@feature('membersSigninOTCAlpha') membersSigninOTCAlpha;
7573
@feature('tagsX') tagsX;
7674
@feature('utmTracking') utmTracking;
7775

ghost/core/core/frontend/helpers/ghost_head.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,7 @@ function getMembersHelper(data, frontendKey, excludeList) {
6363
ghost: urlUtils.getSiteUrl(),
6464
key: frontendKey,
6565
api: urlUtils.urlFor('api', {type: 'content'}, true),
66-
locale: settingsCache.get('locale') || 'en',
67-
'members-signin-otc': labs.isSet('membersSigninOTC'), // html.dataset converts dash-attrs to camelCase
68-
'members-signin-otc-alpha': labs.isSet('membersSigninOTCAlpha') // html.dataset converts dash-attrs to camelCase
66+
locale: settingsCache.get('locale') || 'en'
6967
};
7068
if (colorString) {
7169
attributes['accent-color'] = colorString;

0 commit comments

Comments
 (0)