From 9e1cb9db2c398f0487ed2ed72649e9b4a2b73e6c Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Thu, 4 Nov 2021 12:49:51 +0000 Subject: [PATCH] Server: Immediately ask user to set password after Stripe checkout --- packages/server/package.json | 2 +- .../middleware/notificationHandler.test.ts | 42 +++++++++++++---- .../src/middleware/notificationHandler.ts | 29 ++++++++++-- .../server/src/middleware/routeHandler.ts | 2 +- .../src/models/NotificationModel.test.ts | 20 ++++---- .../server/src/models/NotificationModel.ts | 14 +++--- .../server/src/models/SubscriptionModel.ts | 2 +- .../src/routes/index/notifications.test.ts | 6 +-- .../server/src/routes/index/signup.test.ts | 6 --- packages/server/src/routes/index/signup.ts | 3 -- .../server/src/routes/index/stripe.test.ts | 1 + packages/server/src/routes/index/stripe.ts | 47 ++++++++++++++++--- .../server/src/routes/index/users.test.ts | 21 +++++++++ packages/server/src/routes/index/users.ts | 33 ++++++------- packages/server/src/utils/urlUtils.ts | 4 +- readme/spec/server_sharing.md | 4 +- 16 files changed, 164 insertions(+), 72 deletions(-) diff --git a/packages/server/package.json b/packages/server/package.json index db068cef3..152d959a2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/middleware/notificationHandler.test.ts b/packages/server/src/middleware/notificationHandler.test.ts index d7d07d96f..025bac3b8 100644 --- a/packages/server/src/middleware/notificationHandler.test.ts +++ b/packages/server/src/middleware/notificationHandler.test.ts @@ -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 => { + const context = await koaAppContext({ sessionId: sessionId }); + await notificationHandler(context, koaNext); + return context; +}; describe('notificationHandler', function() { @@ -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); @@ -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); @@ -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); @@ -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(); + } + }); + }); diff --git a/packages/server/src/middleware/notificationHandler.ts b/packages/server/src/middleware/notificationHandler.ts index 98e734142..f53c79d65 100644 --- a/packages/server/src/middleware/notificationHandler.ts +++ b/packages/server/src/middleware/notificationHandler.ts @@ -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); } } @@ -57,6 +57,22 @@ async function handleUserFlags(ctx: AppContext): Promise { return null; } +async function handleConfirmEmailNotification(ctx: AppContext): Promise { + 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; @@ -104,11 +120,18 @@ export default async function(ctx: AppContext, next: KoaNext): Promise { 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) { diff --git a/packages/server/src/middleware/routeHandler.ts b/packages/server/src/middleware/routeHandler.ts index 47791f9e4..684c0f92c 100644 --- a/packages/server/src/middleware/routeHandler.ts +++ b/packages/server/src/middleware/routeHandler.ts @@ -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)`); } } diff --git a/packages/server/src/models/NotificationModel.test.ts b/packages/server/src/models/NotificationModel.test.ts index 5b50998ca..ca5ac4c17 100644 --- a/packages/server/src/models/NotificationModel.test.ts +++ b/packages/server/src/models/NotificationModel.test.ts @@ -17,15 +17,15 @@ 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); }); @@ -33,18 +33,18 @@ describe('NotificationModel', function() { 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); }); }); diff --git a/packages/server/src/models/NotificationModel.ts b/packages/server/src/models/NotificationModel.ts index dfff82c2a..65722cf74 100644 --- a/packages/server/src/models/NotificationModel.ts +++ b/packages/server/src/models/NotificationModel.ts @@ -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', @@ -31,10 +31,10 @@ export default class NotificationModel extends BaseModel { public async add(userId: Uuid, key: NotificationKey, level: NotificationLevel = null, message: string = null): Promise { const notificationTypes: Record = { - [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', @@ -83,12 +83,12 @@ export default class NotificationModel extends BaseModel { return this.save({ key: actualKey, message, level, owner_id: userId }); } - public async markAsRead(userId: Uuid, key: NotificationKey): Promise { + public async setRead(userId: Uuid, key: NotificationKey, read: boolean = true): Promise { 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); } diff --git a/packages/server/src/models/SubscriptionModel.ts b/packages/server/src/models/SubscriptionModel.ts index 52216f0b0..b631e757c 100644 --- a/packages/server/src/models/SubscriptionModel.ts +++ b/packages/server/src/models/SubscriptionModel.ts @@ -134,7 +134,7 @@ export default class SubscriptionModel extends BaseModel { 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, }); diff --git a/packages/server/src/routes/index/notifications.test.ts b/packages/server/src/routes/index/notifications.test.ts index 2465b4f35..8cb168258 100644 --- a/packages/server/src/routes/index/notifications.test.ts +++ b/packages/server/src/routes/index/notifications.test.ts @@ -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); @@ -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); }); }); diff --git a/packages/server/src/routes/index/signup.test.ts b/packages/server/src/routes/index/signup.test.ts index 319bbc4d6..5d1e0ed26 100644 --- a/packages/server/src/routes/index/signup.test.ts +++ b/packages/server/src/routes/index/signup.test.ts @@ -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'; @@ -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); }); }); diff --git a/packages/server/src/routes/index/signup.ts b/packages/server/src/routes/index/signup.ts index 8e2d24e33..5f1893b83 100644 --- a/packages/server/src/routes/index/signup.ts +++ b/packages/server/src/routes/index/signup.ts @@ -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'; @@ -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); diff --git a/packages/server/src/routes/index/stripe.test.ts b/packages/server/src/routes/index/stripe.test.ts index 718ef7588..7caf14c62 100644 --- a/packages/server/src/routes/index/stripe.test.ts +++ b/packages/server/src/routes/index/stripe.test.ts @@ -87,6 +87,7 @@ describe('index/stripe', function() { const user = await models().user().loadByEmail('toto@example.com'); 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'); diff --git a/packages/server/src/routes/index/stripe.ts b/packages/server/src/routes/index/stripe.ts index acf49317c..1f91e7720 100644 --- a/packages/server/src/routes/index/stripe.ts +++ b/packages/server/src/routes/index/stripe.ts @@ -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'; @@ -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'); @@ -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 => { + 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) => { @@ -365,11 +385,24 @@ export const postHandlers: PostHandlers = { const getHandlers: Record = { - success: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => { - return ` -

Thank you for signing up for ${globalConfig().appName}! You should receive an email shortly with instructions on how to connect to your account.

-

Go back to JoplinApp.org

- `; + 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 ` +

Thank you for signing up for ${globalConfig().appName}! You should receive an email shortly with instructions on how to connect to your account.

+

Go back to JoplinApp.org

+ `; + } }, cancel: async (_stripe: Stripe, _path: SubPath, _ctx: AppContext) => { diff --git a/packages/server/src/routes/index/users.test.ts b/packages/server/src/routes/index/users.test.ts index c2e171964..73679b84f 100644 --- a/packages/server/src/routes/index/users.test.ts +++ b/packages/server/src/routes/index/users.test.ts @@ -286,6 +286,27 @@ describe('index/users', function() { expect(notification.key).toBe('passwordSet'); }); + test('should not confirm email if not requested', async function() { + let user1 = await models().user().save({ + email: 'user1@localhost', + must_set_password: 1, + email_confirmed: 0, + password: uuidgen(), + }); + + const email = (await models().email().all()).find(e => e.recipient_id === user1.id); + const matches = email.body.match(/\/(users\/.*)(\?token=)(.{32})/); + const path = matches[1]; + const token = matches[3]; + + await execRequest('', 'GET', path, null, { query: { token, confirm_email: '0' } }); + + // In this case, the email should not be confirmed, because + // "confirm_email" is set to 0. + user1 = await models().user().load(user1.id); + expect(user1.email_confirmed).toBe(0); + }); + test('should allow user to verify their email', async function() { let user1 = await models().user().save({ email: 'user1@localhost', diff --git a/packages/server/src/routes/index/users.ts b/packages/server/src/routes/index/users.ts index 8d1501ef2..52be0c262 100644 --- a/packages/server/src/routes/index/users.ts +++ b/packages/server/src/routes/index/users.ts @@ -3,7 +3,7 @@ import Router from '../../utils/Router'; import { RouteType } from '../../utils/types'; import { AppContext, HttpMethod } from '../../utils/types'; import { bodyFields, contextSessionId, formParse } from '../../utils/requestUtils'; -import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors'; +import { ErrorBadRequest, ErrorForbidden, ErrorNotFound, ErrorUnprocessableEntity } from '../../utils/errors'; import { User, UserFlag, UserFlagType, Uuid } from '../../services/database/types'; import config from '../../config'; import { View } from '../../services/MustacheService'; @@ -214,26 +214,27 @@ router.get('users/:id/confirm', async (path: SubPath, ctx: AppContext, error: Er const userId = path.id; const token = ctx.query.token; - if (token) { - const beforeChangingEmailHandler = async (newEmail: string) => { - if (config().stripe.enabled) { - try { - await updateCustomerEmail(models, userId, newEmail); - } catch (error) { - if (['no_sub', 'no_stripe_sub'].includes(error.code)) { - // ok - the user just doesn't have a subscription - } else { - error.message = `Your Stripe subscription email could not be updated. As a result your account email has not been changed. Please try again or contact support. Error was: ${error.message}`; - throw error; - } + if (!token) throw new ErrorBadRequest('Missing token'); + + const beforeChangingEmailHandler = async (newEmail: string) => { + if (config().stripe.enabled) { + try { + await updateCustomerEmail(models, userId, newEmail); + } catch (error) { + if (['no_sub', 'no_stripe_sub'].includes(error.code)) { + // ok - the user just doesn't have a subscription + } else { + error.message = `Your Stripe subscription email could not be updated. As a result your account email has not been changed. Please try again or contact support. Error was: ${error.message}`; + throw error; } } - }; + } + }; - await models.user().processEmailConfirmation(userId, token, beforeChangingEmailHandler); - } + if (ctx.query.confirm_email !== '0') await models.user().processEmailConfirmation(userId, token, beforeChangingEmailHandler); const user = await models.user().load(userId); + if (!user) throw new ErrorNotFound(`No such user: ${userId}`); if (user.must_set_password) { const view: View = { diff --git a/packages/server/src/utils/urlUtils.ts b/packages/server/src/utils/urlUtils.ts index 4554d11e8..ab7f5a33d 100644 --- a/packages/server/src/utils/urlUtils.ts +++ b/packages/server/src/utils/urlUtils.ts @@ -30,8 +30,8 @@ export function helpUrl(): string { return `${config().baseUrl}/help`; } -export function confirmUrl(userId: Uuid, validationToken: string): string { - return `${config().baseUrl}/users/${userId}/confirm?token=${validationToken}`; +export function confirmUrl(userId: Uuid, validationToken: string, autoConfirmEmail: boolean = true): string { + return `${config().baseUrl}/users/${userId}/confirm?token=${validationToken}${autoConfirmEmail ? '' : '&confirm_email=0'}`; } export function stripePortalUrl(): string { diff --git a/readme/spec/server_sharing.md b/readme/spec/server_sharing.md index 63381fc24..a5fde73ad 100644 --- a/readme/spec/server_sharing.md +++ b/readme/spec/server_sharing.md @@ -37,7 +37,7 @@ Any resource attached to the note is also shared - so for example images will be Any linked note will **not** be shared, due to the following reasons: -- Privacy issue - you don't want to accidentally share a note just because it was linked to another note. +- Privacy issue - you don't want to accidentally share a note just because it was linked from another note. - Even if the linked note has been shared separately, we still don't give access to it. We don't know who that link has been shared with - it could be a different recipient. @@ -45,4 +45,4 @@ Any linked note will **not** be shared, due to the following reasons: It should be possible to have multiple share links for a given note. For example: I share a note with one person, then the same note with a different person. I revoke the share for one person, but I sill want the other person to access the note. -So when a share link is created for a note, the API always return a new link. +So when a share link is created for a note, the API always returns a new link.