Skip to content

Commit

Permalink
Server: Immediately ask user to set password after Stripe checkout
Browse files Browse the repository at this point in the history
  • Loading branch information
laurent22 committed Nov 4, 2021
1 parent 373c041 commit 9e1cb9d
Show file tree
Hide file tree
Showing 16 changed files with 164 additions and 72 deletions.
2 changes: 1 addition & 1 deletion packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "2.6.2",
"private": true,
"scripts": {
"start-dev": "npm run build && nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
"start-dev": "npm run build && JOPLIN_IS_TESTING=1 nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
"start-dev-no-watch": "node dist/app.js --env dev",
"rebuild": "npm run clean && npm run build && npm run tsc",
"build": "gulp build",
Expand Down
42 changes: 32 additions & 10 deletions packages/server/src/middleware/notificationHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models,
import { Notification, UserFlagType } from '../services/database/types';
import { defaultAdminEmail, defaultAdminPassword } from '../db';
import notificationHandler from './notificationHandler';
import { AppContext } from '../utils/types';

const runNotificationHandler = async (sessionId: string): Promise<AppContext> => {
const context = await koaAppContext({ sessionId: sessionId });
await notificationHandler(context, koaNext);
return context;
};

describe('notificationHandler', function() {

Expand All @@ -18,22 +25,25 @@ describe('notificationHandler', function() {
});

test('should check admin password', async function() {
const { session } = await createUserAndSession(1, true);
const r = await createUserAndSession(1, true);
const session = r.session;
let admin = r.user;

// The default admin password actually doesn't pass the complexity
// check, so we need to skip validation for testing here. Eventually, a
// better mechanism to set the initial default admin password should
// probably be implemented.

const admin = await models().user().save({
admin = await models().user().save({
id: admin.id,
email: defaultAdminEmail,
password: defaultAdminPassword,
is_admin: 1,
email_confirmed: 1,
}, { skipValidation: true });

{
const ctx = await koaAppContext({ sessionId: session.id });
await notificationHandler(ctx, koaNext);
const ctx = await runNotificationHandler(session.id);

const notifications: Notification[] = await models().notification().all();
expect(notifications.length).toBe(1);
Expand All @@ -49,8 +59,7 @@ describe('notificationHandler', function() {
password: 'changed!',
}, { skipValidation: true });

const ctx = await koaAppContext({ sessionId: session.id });
await notificationHandler(ctx, koaNext);
const ctx = await runNotificationHandler(session.id);

const notifications: Notification[] = await models().notification().all();
expect(notifications.length).toBe(1);
Expand All @@ -69,8 +78,7 @@ describe('notificationHandler', function() {
password: defaultAdminPassword,
});

const context = await koaAppContext({ sessionId: session.id });
await notificationHandler(context, koaNext);
await runNotificationHandler(session.id);

const notifications: Notification[] = await models().notification().all();
expect(notifications.length).toBe(0);
Expand All @@ -81,10 +89,24 @@ describe('notificationHandler', function() {

await models().userFlag().add(user.id, UserFlagType.FailedPaymentFinal);

const ctx = await koaAppContext({ sessionId: session.id });
await notificationHandler(ctx, koaNext);
const ctx = await runNotificationHandler(session.id);

expect(ctx.joplin.notifications.find(v => v.id === 'accountDisabled')).toBeTruthy();
});

test('should display a banner if the email is not confirmed', async function() {
const { session, user } = await createUserAndSession(1);

{
const ctx = await runNotificationHandler(session.id);
expect(ctx.joplin.notifications.find(v => v.id === 'confirmEmail')).toBeTruthy();
}

{
await models().user().save({ id: user.id, email_confirmed: 1 });
const ctx = await runNotificationHandler(session.id);
expect(ctx.joplin.notifications.find(v => v.id === 'confirmEmail')).toBeFalsy();
}
});

});
29 changes: 26 additions & 3 deletions packages/server/src/middleware/notificationHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
_('The default admin password is insecure and has not been changed! [Change it now](%s)', profileUrl())
);
} else {
await notificationModel.markAsRead(ctx.joplin.owner.id, NotificationKey.ChangeAdminPassword);
await notificationModel.setRead(ctx.joplin.owner.id, NotificationKey.ChangeAdminPassword);
}
}

Expand Down Expand Up @@ -57,6 +57,22 @@ async function handleUserFlags(ctx: AppContext): Promise<NotificationView> {
return null;
}

async function handleConfirmEmailNotification(ctx: AppContext): Promise<NotificationView> {
if (!ctx.joplin.owner) return null;

if (!ctx.joplin.owner.email_confirmed) {
return {
id: 'confirmEmail',
messageHtml: renderMarkdown('An email has been sent to you containing an activation link to complete your registration.\n\nMake sure you click it to secure your account and keep access to it.'),
levelClassName: levelClassName(NotificationLevel.Important),
closeUrl: '',
};
}

return null;
}


// async function handleSqliteInProdNotification(ctx: AppContext) {
// if (!ctx.joplin.owner.is_admin) return;

Expand Down Expand Up @@ -104,11 +120,18 @@ export default async function(ctx: AppContext, next: KoaNext): Promise<void> {
if (!ctx.joplin.owner) return next();

await handleChangeAdminPasswordNotification(ctx);
await handleConfirmEmailNotification(ctx);
// await handleSqliteInProdNotification(ctx);
const notificationViews = await makeNotificationViews(ctx);

const userFlagView = await handleUserFlags(ctx);
if (userFlagView) notificationViews.push(userFlagView);
const nonDismisableViews = [
await handleUserFlags(ctx),
await handleConfirmEmailNotification(ctx),
];

for (const nonDismisableView of nonDismisableViews) {
if (nonDismisableView) notificationViews.push(nonDismisableView);
}

ctx.joplin.notifications = notificationViews;
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/middleware/routeHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,6 @@ export default async function(ctx: AppContext) {
// Technically this is not the total request duration because there are
// other middlewares but that should give a good approximation
const requestDuration = Date.now() - requestStartTime;
ctx.joplin.appLogger().info(`${ctx.request.method} ${ctx.path} (${requestDuration}ms)`);
ctx.joplin.appLogger().info(`${ctx.request.method} ${ctx.path} (${ctx.response.status}) (${requestDuration}ms)`);
}
}
20 changes: 10 additions & 10 deletions packages/server/src/models/NotificationModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,34 +17,34 @@ describe('NotificationModel', function() {
});

test('should require a user to create the notification', async function() {
await expectThrow(async () => models().notification().add('', NotificationKey.ConfirmEmail, NotificationLevel.Normal, NotificationKey.ConfirmEmail));
await expectThrow(async () => models().notification().add('', NotificationKey.EmailConfirmed, NotificationLevel.Normal, NotificationKey.EmailConfirmed));
});

test('should create a notification', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification();
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
const n: Notification = await model.loadByKey(user.id, NotificationKey.ConfirmEmail);
expect(n.key).toBe(NotificationKey.ConfirmEmail);
await model.add(user.id, NotificationKey.EmailConfirmed, NotificationLevel.Important, 'testing');
const n: Notification = await model.loadByKey(user.id, NotificationKey.EmailConfirmed);
expect(n.key).toBe(NotificationKey.EmailConfirmed);
expect(n.message).toBe('testing');
expect(n.level).toBe(NotificationLevel.Important);
});

test('should create only one notification per key', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification();
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
await model.add(user.id, NotificationKey.EmailConfirmed, NotificationLevel.Important, 'testing');
await model.add(user.id, NotificationKey.EmailConfirmed, NotificationLevel.Important, 'testing');
expect((await model.all()).length).toBe(1);
});

test('should mark a notification as read', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification();
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
expect((await model.loadByKey(user.id, NotificationKey.ConfirmEmail)).read).toBe(0);
await model.markAsRead(user.id, NotificationKey.ConfirmEmail);
expect((await model.loadByKey(user.id, NotificationKey.ConfirmEmail)).read).toBe(1);
await model.add(user.id, NotificationKey.EmailConfirmed, NotificationLevel.Important, 'testing');
expect((await model.loadByKey(user.id, NotificationKey.EmailConfirmed)).read).toBe(0);
await model.setRead(user.id, NotificationKey.EmailConfirmed);
expect((await model.loadByKey(user.id, NotificationKey.EmailConfirmed)).read).toBe(1);
});

});
14 changes: 7 additions & 7 deletions packages/server/src/models/NotificationModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import BaseModel, { ValidateOptions } from './BaseModel';

export enum NotificationKey {
Any = 'any',
ConfirmEmail = 'confirmEmail',
// ConfirmEmail = 'confirmEmail',
PasswordSet = 'passwordSet',
EmailConfirmed = 'emailConfirmed',
ChangeAdminPassword = 'change_admin_password',
Expand All @@ -31,10 +31,10 @@ export default class NotificationModel extends BaseModel<Notification> {

public async add(userId: Uuid, key: NotificationKey, level: NotificationLevel = null, message: string = null): Promise<Notification> {
const notificationTypes: Record<string, NotificationType> = {
[NotificationKey.ConfirmEmail]: {
level: NotificationLevel.Normal,
message: `Welcome to ${this.appName}! An email has been sent to you containing an activation link to complete your registration.`,
},
// [NotificationKey.ConfirmEmail]: {
// level: NotificationLevel.Normal,
// message: `Welcome to ${this.appName}! An email has been sent to you containing an activation link to complete your registration. Make sure you click it to secure your account and keep access to it.`,
// },
[NotificationKey.EmailConfirmed]: {
level: NotificationLevel.Normal,
message: 'Your email has been confirmed',
Expand Down Expand Up @@ -83,12 +83,12 @@ export default class NotificationModel extends BaseModel<Notification> {
return this.save({ key: actualKey, message, level, owner_id: userId });
}

public async markAsRead(userId: Uuid, key: NotificationKey): Promise<void> {
public async setRead(userId: Uuid, key: NotificationKey, read: boolean = true): Promise<void> {
const n = await this.loadByKey(userId, key);
if (!n) return;

await this.db(this.tableName)
.update({ read: 1 })
.update({ read: read ? 1 : 0 })
.where('key', '=', key)
.andWhere('owner_id', '=', userId);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/models/SubscriptionModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export default class SubscriptionModel extends BaseModel<Subscription> {
account_type: accountType,
email,
full_name: fullName,
email_confirmed: 1,
email_confirmed: 0, // Email is not confirmed, because Stripe doesn't check this
password: uuidgen(),
must_set_password: 1,
});
Expand Down
6 changes: 3 additions & 3 deletions packages/server/src/routes/index/notifications.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ describe('index_notification', function() {

const model = models().notification();

await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Normal, 'testing notification');
await model.add(user.id, NotificationKey.EmailConfirmed, NotificationLevel.Normal, 'testing notification');

const notification = await model.loadByKey(user.id, NotificationKey.ConfirmEmail);
const notification = await model.loadByKey(user.id, NotificationKey.EmailConfirmed);

expect(notification.read).toBe(0);

Expand All @@ -41,7 +41,7 @@ describe('index_notification', function() {

await routeHandler(context);

expect((await model.loadByKey(user.id, NotificationKey.ConfirmEmail)).read).toBe(1);
expect((await model.loadByKey(user.id, NotificationKey.EmailConfirmed)).read).toBe(1);
});

});
6 changes: 0 additions & 6 deletions packages/server/src/routes/index/signup.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import config from '../../config';
import { NotificationKey } from '../../models/NotificationModel';
import { AccountType } from '../../models/UserModel';
import { getCanShareFolder, getMaxItemSize } from '../../models/utils/user';
import { MB } from '../../utils/bytes';
Expand Down Expand Up @@ -53,11 +52,6 @@ describe('index_signup', function() {
// Check that the user is logged in
const session = await models().session().load(cookieGet(context, 'sessionId'));
expect(session.user_id).toBe(user.id);

// Check that the notification has been created
const notifications = await models().notification().allUnreadByUserId(user.id);
expect(notifications.length).toBe(1);
expect(notifications[0].key).toBe(NotificationKey.ConfirmEmail);
});

});
3 changes: 0 additions & 3 deletions packages/server/src/routes/index/signup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import config from '../../config';
import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
import { checkRepeatPassword } from './users';
import { NotificationKey } from '../../models/NotificationModel';
import { AccountType } from '../../models/UserModel';
import { ErrorForbidden } from '../../utils/errors';
import { cookieSet } from '../../utils/cookies';
Expand Down Expand Up @@ -54,8 +53,6 @@ router.post('signup', async (_path: SubPath, ctx: AppContext) => {
const session = await ctx.joplin.models.session().createUserSession(user.id);
cookieSet(ctx, 'sessionId', session.id);

await ctx.joplin.models.notification().add(user.id, NotificationKey.ConfirmEmail);

return redirect(ctx, `${config().baseUrl}/home`);
} catch (error) {
return makeView(error);
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/routes/index/stripe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ describe('index/stripe', function() {

const user = await models().user().loadByEmail('[email protected]');
expect(user.account_type).toBe(AccountType.Pro);
expect(user.email_confirmed).toBe(0);

const sub = await models().subscription().byUserId(user.id);
expect(sub.stripe_subscription_id).toBe('sub_123');
Expand Down
47 changes: 40 additions & 7 deletions packages/server/src/routes/index/stripe.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SubPath } from '../../utils/routeUtils';
import { redirect, SubPath } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { Env, RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
Expand All @@ -10,9 +10,11 @@ import Logger from '@joplin/lib/Logger';
import getRawBody = require('raw-body');
import { AccountType } from '../../models/UserModel';
import { betaUserTrialPeriodDays, cancelSubscription, initStripe, isBetaUser, priceIdToAccountType, stripeConfig } from '../../utils/stripe';
import { Subscription, UserFlagType } from '../../services/database/types';
import { Subscription, User, UserFlagType } from '../../services/database/types';
import { findPrice, PricePeriod } from '@joplin/lib/utils/joplinCloud';
import { Models } from '../../models/factory';
import { confirmUrl } from '../../utils/urlUtils';
import { msleep } from '../../utils/time';

const logger = Logger.create('/stripe');

Expand Down Expand Up @@ -116,6 +118,24 @@ export const handleSubscriptionCreated = async (stripe: Stripe, models: Models,
}
};

// For some reason, after checkout Stripe redirects to success_url immediately,
// without waiting for the "checkout.session.completed" event to be completed.
// It may be because they expect the webhook to immediately return code 200,
// which is not how it's currently implemented here.
// https://stripe.com/docs/payments/checkout/fulfill-orders#fulfill
//
// It means that by the time success_url is called, the user hasn't been created
// yet. So here we wait for the user to be available and return it. It shouldn't
// wait for more than 2-3 seconds.
const waitForUserCreation = async (models: Models, userEmail: string): Promise<User | null> => {
for (let i = 0; i < 10; i++) {
const user = await models.user().loadByEmail(userEmail);
if (user) return user;
await msleep(1000);
}
return null;
};

export const postHandlers: PostHandlers = {

createCheckoutSession: async (stripe: Stripe, __path: SubPath, ctx: AppContext) => {
Expand Down Expand Up @@ -365,11 +385,24 @@ export const postHandlers: PostHandlers = {

const getHandlers: Record<string, StripeRouteHandler> = {

success: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => {
return `
<p>Thank you for signing up for ${globalConfig().appName}! You should receive an email shortly with instructions on how to connect to your account.</p>
<p><a href="https://joplinapp.org">Go back to JoplinApp.org</a></p>
`;
success: async (stripe: Stripe, _path: SubPath, ctx: AppContext) => {
try {
const models = ctx.joplin.models;
const checkoutSession = await stripe.checkout.sessions.retrieve(ctx.query.session_id);
const userEmail = checkoutSession.customer_details.email || checkoutSession.customer_email; // customer_email appears to be always null but fallback to it just in case
if (!userEmail) throw new Error(`Could not find email from checkout session: ${JSON.stringify(checkoutSession)}`);
const user = await waitForUserCreation(models, userEmail);
if (!user) throw new Error(`Could not find user from checkout session: ${JSON.stringify(checkoutSession)}`);
const validationToken = await ctx.joplin.models.token().generate(user.id);
const redirectUrl = encodeURI(confirmUrl(user.id, validationToken, false));
return redirect(ctx, redirectUrl);
} catch (error) {
logger.error('Could not automatically redirect user to account confirmation page. They will have to follow the link in the confirmation email. Error was:', error);
return `
<p>Thank you for signing up for ${globalConfig().appName}! You should receive an email shortly with instructions on how to connect to your account.</p>
<p><a href="https://joplinapp.org">Go back to JoplinApp.org</a></p>
`;
}
},

cancel: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => {
Expand Down
Loading

0 comments on commit 9e1cb9d

Please sign in to comment.