diff --git a/src/env.js b/src/env.js index f721719..1dab66d 100644 --- a/src/env.js +++ b/src/env.js @@ -54,6 +54,11 @@ export const env = createEnv({ APPLE_TEAM_ID: z.string().optional(), APPLE_KEY_ID: z.string().optional(), APPLE_PRIVATE_KEY: z.string().optional(), + // A login for App Store and Play reviewers, who can't receive our emails: requesting a code for + // REVIEW_LOGIN_EMAIL sends nothing and the code is always REVIEW_LOGIN_CODE (6 characters from + // the sign-in code alphabet). Normal code expiry and attempt limits still apply. + REVIEW_LOGIN_EMAIL: z.string().email().optional(), + REVIEW_LOGIN_CODE: z.string().optional(), R2_ACCESS_KEY: z.string().optional(), R2_SECRET_KEY: z.string().optional(), R2_BUCKET: z.string().optional(), @@ -118,6 +123,8 @@ export const env = createEnv({ APPLE_TEAM_ID: process.env.APPLE_TEAM_ID, APPLE_KEY_ID: process.env.APPLE_KEY_ID, APPLE_PRIVATE_KEY: process.env.APPLE_PRIVATE_KEY, + REVIEW_LOGIN_EMAIL: process.env.REVIEW_LOGIN_EMAIL, + REVIEW_LOGIN_CODE: process.env.REVIEW_LOGIN_CODE, R2_ACCESS_KEY: process.env.R2_ACCESS_KEY, R2_SECRET_KEY: process.env.R2_SECRET_KEY, R2_BUCKET: process.env.R2_BUCKET, diff --git a/src/pages/delete-account.tsx b/src/pages/delete-account.tsx new file mode 100644 index 0000000..bb27316 --- /dev/null +++ b/src/pages/delete-account.tsx @@ -0,0 +1,75 @@ +import { type NextPage } from 'next'; +import Link from 'next/link'; + +import { Seo } from '~/components/Site/Seo'; +import { SiteLayout } from '~/components/Site/SiteLayout'; +import { env } from '~/env'; + +/** Public page the app stores link to: how to delete a SplitPro account and what happens. */ +const DeleteAccount: NextPage<{ feedbackEmail: string }> = ({ feedbackEmail }) => ( + + +
+

+ Delete your SplitPro account +

+

+ You can delete your SplitPro account yourself, at any time, from the app or the website. +

+ +

In the SplitPro app

+
    +
  1. Open the Account tab.
  2. +
  3. Tap Delete account.
  4. +
  5. Enter the code we email you to confirm.
  6. +
+ +

On the website

+
    +
  1. + Sign in at{' '} + + splitpro.app/account + + . +
  2. +
  3. Choose Delete account.
  4. +
  5. Enter the code we email you to confirm.
  6. +
+ +

What gets deleted

+

+ Your name, email address, profile photo, sign-in methods (Google, Apple, email), sessions, + notification settings and your friends list are deleted straight away. +

+ +

What is kept

+

+ Expenses you shared with other people stay, so their balances remain correct. They show you + as "Deleted user", with no name or email. Groups you created pass to another + member, or are deleted if nobody else is in them. Deleted data can stay in our encrypted + backups for a limited time until those backups expire. +

+ + {feedbackEmail ? ( +

+ Can't sign in any more? Email{' '} + + {feedbackEmail} + {' '} + from the address on your account and we'll delete it for you. +

+ ) : null} +
+
+); + +export default DeleteAccount; + +export const getServerSideProps = () => ({ + props: { feedbackEmail: env.FEEDBACK_EMAIL ?? '' }, +}); diff --git a/src/server/mobileAuth.ts b/src/server/mobileAuth.ts index eac8f4a..c6b13e2 100644 --- a/src/server/mobileAuth.ts +++ b/src/server/mobileAuth.ts @@ -241,6 +241,11 @@ export const deleteMobileSession = async (sessionToken: string, pushToken?: stri /** * Sends the same sign-in code as the web email provider, stored the way next-auth stores it. */ +const isReviewLoginEmail = (email: string) => + !!env.REVIEW_LOGIN_EMAIL && + !!env.REVIEW_LOGIN_CODE && + email === tryNormalizeEmail(env.REVIEW_LOGIN_EMAIL); + export const sendEmailCode = async (rawEmail: string, ip: string) => { const email = tryNormalizeEmail(rawEmail); @@ -268,13 +273,19 @@ export const sendEmailCode = async (rawEmail: string, ip: string) => { ); } - const token = generateEmailToken(); + // App store reviewers can't receive our emails: one configured address gets a fixed code instead + const isReviewLogin = isReviewLoginEmail(email); + const token = isReviewLogin ? env.REVIEW_LOGIN_CODE!.trim().toUpperCase() : generateEmailToken(); await adapter.createVerificationToken({ identifier: email, token: hashVerificationToken(token, getAuthSecret()), expires: fromNow(EMAIL_TOKEN_MAX_AGE_SECONDS), }); + if (isReviewLogin) { + return; + } + // The same link the web sends, so it still works when opened in a browser const url = new URL('/api/auth/callback/email', env.NEXTAUTH_URL); url.searchParams.set('callbackUrl', new URL('/balances', env.NEXTAUTH_URL).toString()); diff --git a/src/tests/reviewLogin.integration.test.ts b/src/tests/reviewLogin.integration.test.ts new file mode 100644 index 0000000..0adb80e --- /dev/null +++ b/src/tests/reviewLogin.integration.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +const databaseUrl = process.env.TEST_DATABASE_URL; + +void test( + 'Review login: the configured email signs in with its fixed code and nothing else changes', + { skip: !databaseUrl }, + async () => { + assert.match(new URL(databaseUrl!).pathname, /test/i, 'Refusing to use a non-test database'); + process.env.DATABASE_URL = databaseUrl; + process.env.SKIP_ENV_VALIDATION = '1'; + process.env.NEXTAUTH_SECRET ??= 'review-login-test-secret'; + process.env.NEXTAUTH_URL ??= 'http://localhost:3000'; + + const [{ db }, { env }, { sendEmailCode, verifyEmailCode }] = await Promise.all([ + import('~/server/db'), + import('~/env'), + import('~/server/mobileAuth'), + ]); + const mutableEnv = env as { -readonly [Key in keyof typeof env]: (typeof env)[Key] }; + const originalEnv = { ...env }; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const reviewEmail = `review-${suffix}@example.com`; + const otherEmail = `other-${suffix}@example.com`; + mutableEnv.REVIEW_LOGIN_EMAIL = reviewEmail.toUpperCase(); + mutableEnv.REVIEW_LOGIN_CODE = 'rv7k2m'; + mutableEnv.NODE_ENV = 'production'; + + try { + await sendEmailCode(reviewEmail, `ip-${suffix}`); + const signedIn = await verifyEmailCode(reviewEmail, 'RV7K2M', `ip-${suffix}`); + assert.ok(signedIn.token); + assert.equal(signedIn.user.email, reviewEmail); + + // Single use like any code + await assert.rejects(verifyEmailCode(reviewEmail, 'RV7K2M', `ip-${suffix}`), /Invalid/); + + // Without a requested code, the fixed code doesn't work + await assert.rejects(verifyEmailCode(reviewEmail, 'RV7K2M', `ip-${suffix}`), /Invalid/); + + // Other addresses never accept it + await db.verificationToken.deleteMany({ where: { identifier: otherEmail } }); + await assert.rejects(verifyEmailCode(otherEmail, 'RV7K2M', `ip-${suffix}`), /Invalid/); + } finally { + Object.assign(mutableEnv, originalEnv); + await db.verificationToken.deleteMany({ + where: { identifier: { in: [reviewEmail, otherEmail] } }, + }); + await db.user.deleteMany({ where: { email: { in: [reviewEmail, otherEmail] } } }); + await db.$disconnect(); + } + }, +);