From c29cbcd549f72840565fa1b1c3e7dad877765c8d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 1 Aug 2026 13:28:31 +0200 Subject: [PATCH] feat: step-by-step sign-in with server discovery from the email address --- mockups/login.html | 953 +++++++++++++++++++++ src/components/QrScanModal.tsx | 16 +- src/lib/__tests__/login-errors.test.ts | 59 ++ src/lib/__tests__/server-discovery.test.ts | 184 ++++ src/lib/login-errors.ts | 102 +++ src/lib/server-discovery.ts | 170 ++++ src/screens/LoginScreen.tsx | 608 +++++++------ src/screens/login/ChooseStep.tsx | 184 ++++ src/screens/login/ConfirmStep.tsx | 119 +++ src/screens/login/EmailStep.tsx | 87 ++ src/screens/login/LoginNotice.tsx | 52 ++ src/screens/login/LoginShell.tsx | 107 +++ src/screens/login/OptionTile.tsx | 91 ++ src/screens/login/PasswordStep.tsx | 98 +++ src/screens/login/ServerStep.tsx | 88 ++ src/screens/login/SigningInStep.tsx | 102 +++ 16 files changed, 2744 insertions(+), 276 deletions(-) create mode 100644 mockups/login.html create mode 100644 src/lib/__tests__/login-errors.test.ts create mode 100644 src/lib/__tests__/server-discovery.test.ts create mode 100644 src/lib/login-errors.ts create mode 100644 src/lib/server-discovery.ts create mode 100644 src/screens/login/ChooseStep.tsx create mode 100644 src/screens/login/ConfirmStep.tsx create mode 100644 src/screens/login/EmailStep.tsx create mode 100644 src/screens/login/LoginNotice.tsx create mode 100644 src/screens/login/LoginShell.tsx create mode 100644 src/screens/login/OptionTile.tsx create mode 100644 src/screens/login/PasswordStep.tsx create mode 100644 src/screens/login/ServerStep.tsx create mode 100644 src/screens/login/SigningInStep.tsx diff --git a/mockups/login.html b/mockups/login.html new file mode 100644 index 00000000..62d00855 --- /dev/null +++ b/mockups/login.html @@ -0,0 +1,953 @@ +Bulwark Mobile — Sign-in redesign + + + + + + +
+ +

Bulwark Mobile · sign-in redesign

+

Three fields nobody knows the answer to

+

+ The current sign-in screen opens with a Server URL field. Most people don't know their + JMAP endpoint, and the two paths that don't require knowing it — the webmail handoff and the QR code — sit + below a divider, greyed out until the URL is filled in anyway. This proposes an entry screen that leads with + the easy paths and asks for a server only when we genuinely can't work it out. +

+ +
+ src/screens/LoginScreen.tsx + expo · react-native + light + dark, both shown +
+ +
+

What changes

+
+
+
+
To reach the inbox
+
3 fields typed1 field, or none with a code
+
+
+
Server URL
+
Asked first, alwaysLooked up from the address
+
+
+
Password in the app
+
Default pathFallback only
+
+
+
QR sign-in
+
Ghost button, disabledFirst option offered
+
+
+
+ +
+

Today

+

+ Everything at once, in the least helpful order. The webmail handoff is the safer route — it never puts a + password into the app — but it's disabled until you've supplied the server, so it can't rescue anyone who's stuck. +

+
+ +
+
+
+
+
+
+
+ +

Bulwark Mail

+

Secure. Private. Yours.

+
+ +
+
Server URL
+
https://mail.example.com
+
+
+
Email or Username
+
you@example.com
+
+
+
Password
+
+ Enter your password + +
+
+ +
Sign In
+ +
or continue with
+
+
Sign in via webmail
+ +
+
+
Bulwark Mobile v1.4.0
Privacy Policy
+
+
+
+ Current build + One screen, six controls +

+ The two disabled controls at the bottom are the ones that would have helped. Nothing tells you where + to find a server URL, and nothing happens if you don't have one. +

+
+
+
+
+ +
+

Proposed flow

+

+ One question per screen, cheapest question first. The screens below are the happy path, left to right. +

+
+ +
+ + +
+
+
+
+
+
+ +

Bulwark Mail

+

Secure. Private. Yours.

+
+ +
+
+ + + Scan a sign-in code + Already signed in on the web? Fastest way in. + + +
+
+ + + Use my email address + We'll find your mail server + + +
+
+ + +
+
Bulwark Mobile v1.4.0
Privacy Policy
+
+
+
+ Step 1 + Pick a way in +

+ Two live options instead of six mixed ones. Each tile says what it costs you before you tap it. + Manual setup stays reachable in one tap — it just isn't the greeting. +

+
+
+ + +
+
+
+
+
+ +

What's your email address?

+

That's usually all we need — we'll look up the mail server for you.

+ +
+
+ + ada@example.com +
+

Works with Bulwark, Stalwart, and any JMAP mail server.

+
+ +
Continue
+
+ +
+
+
+
+ Step 2 + One field, keyboard already up +

+ Autofocus with the email keyboard, autocorrect off. The button stays enabled and validates on tap, + so nobody is left staring at a dead grey button wondering which field is wrong. +

+
+
+ + +
+
+
+
+
+ +

Found your mail server

+

Next you'll sign in on your provider's own page.

+ +
+ + + mail.example.com + JMAP · secure connection + +
+ +
+ + Your password is typed on your provider's page, never into this app. Bulwark stores a token it can revoke. +
+ +
Continue
+ +
+
+
+
+ Step 3 + Confirm before the browser opens +

+ A browser jump with no warning feels like a bug. Naming the server first — and saying why the + password isn't asked for here — turns the detour into a security feature. +

+
+
+ + +
+
+
+
+
+
+ +

Bulwark Mail

+
+
+
+
+
+ + mail.example.com + Done +
+
+
+ + Sign in to Bulwark +
+
Email
+
ada@example.com
+
Password
+
+ •••••••••• +
+
Sign in
+
or
+
+ + Continue with passkey +
+
+
+
+
+
+ Step 4 + The provider's page, in a sheet +

+ This is the existing runWebmailHandoff flow, unchanged. It already supports whatever + the server offers — password, OAuth, passkeys, SSO — which the in-app form never will. +

+
+
+ + +
+
+
+
+
+
+ AL +

Welcome back, Ada

+

ada@example.com

+
+ +
+
+ Mailboxes +
+
+ Recent mail +
+
+ Contacts and calendars +
+
+
+
+
+
+ Step 5 + Land on progress, not a spinner +

+ The first sync is the slowest moment in the app. Naming what's arriving makes the wait legible, and + the inbox opens as soon as mailboxes land — the rest fills in behind it. +

+
+
+ +
+
+ +
+

When it doesn't go smoothly

+

+ Self-hosted servers, typo'd passwords, and second accounts are the normal case for this audience, not the + exception. These four screens carry most of the redesign's weight. +

+
+ +
+ + +
+
+
+
+
+
+ + Sign-in code + +
+
+ +
+ + + + + + + + + +
+
+
+

Open Bulwark on your computer

+

Settings → Devices → Add phone shows a code. Point the camera at it.

+ +
+
+
+
+ Path A + The scanner tells you where the code is +

+ A scanner with no instructions is a dead end for anyone who hasn't seen the webmail's Devices page. + Redeeming a paired code skips the browser entirely — see redeemPairingCode. +

+
+
+ + +
+
+
+
+
+ +

Where does your mail live?

+

We couldn't find a server for example.org. If you self-host, the address is the one you use for webmail.

+ +
+
Server address
+
+ + mail.example.org +
+

Same address you type in the browser. We'll add https://.

+
+ +
Continue
+
+
+ + Not sure? Scan a sign-in code from the webmail instead — it carries the address with it. +
+
+
+
+
+ Path B + Ask for the server people know +

+ Not the JMAP session URL — the webmail address they type every day. The scheme is added for them, and + a trailing slash or full /jmap path is accepted and trimmed. +

+
+
+ + +
+
+
+
+
+ +

Sign in to mail.example.org

+

This server doesn't support the browser sign-in, so we'll ask here.

+ +
+
Email or username
+
ada@example.org
+
+ +
+
Password
+
+ •••••••• +
+
+ +
+ + That password didn't work. If your account has two-factor sign-in, you'll need an app password from the webmail. +
+ +
Sign in
+
+
+
+
+ Path C + Errors that name the likely cause +

+ The store's raw error string is replaced with mapped copy per failure: wrong credentials, unreachable + host, bad certificate, 2FA-needs-an-app-password. Each says what to do next. +

+
+
+ + +
+
+
+
+
+
+

Add another account

+

You're signed in to these already.

+ +
+
+ AL + Ada Lovelaceada@example.com +
+
+ AL + Worka.lovelace@analytical.co +
+
+ +
add
+ +
+
+ + + Another account on example.com + Server already known + + +
+
+ + A different provider + +
+
+
+
+
+
+ Add mode + Reuse what we already know +

+ isAddMode currently shows the same blank three-field form. Most second accounts live on + the same server as the first, so offer that first and skip discovery entirely. +

+
+
+ +
+
+ +
+

Notes for the build

+
+ +
+ +

Mockup · repos/react-native/mockups/login.html · static HTML, no interaction wired up

+
diff --git a/src/components/QrScanModal.tsx b/src/components/QrScanModal.tsx index 2f227406..cac3bc30 100644 --- a/src/components/QrScanModal.tsx +++ b/src/components/QrScanModal.tsx @@ -53,16 +53,19 @@ export function QrScanModal({ visible, onClose, onScanned }: QrScanModalProps) { - Scan QR code + Sign-in code {permission?.granted ? ( - - Point your camera at the QR code shown on the webmail settings screen. - + + Open Bulwark on your computer + + Settings → Devices → Add phone shows a code. Point the camera at it. + + ) : ( @@ -105,11 +108,12 @@ function makeStyles(c: ThemePalette) { borderRadius: radius.lg, backgroundColor: 'transparent', }, + hintWrap: { alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.xxl }, + hintTitle: { ...typography.bodySemibold, color: '#ffffff', textAlign: 'center' }, hint: { ...typography.caption, - color: '#ffffff', + color: 'rgba(255,255,255,0.8)', textAlign: 'center', - paddingHorizontal: spacing.xxl, }, permissionWrap: { flex: 1, diff --git a/src/lib/__tests__/login-errors.test.ts b/src/lib/__tests__/login-errors.test.ts new file mode 100644 index 00000000..cef5c52b --- /dev/null +++ b/src/lib/__tests__/login-errors.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { describeLoginError } from '../login-errors'; + +function named(name: string, message: string): Error { + const err = new Error(message); + err.name = name; + return err; +} + +describe('describeLoginError', () => { + it('turns a rejected credential into an actionable message', () => { + const copy = describeLoginError(named('AuthenticationError', 'Invalid credentials')); + expect(copy.title).toBe("That didn't work"); + expect(copy.detail).toMatch(/app password/i); + }); + + it('names the host it could not reach', () => { + const copy = describeLoginError(named('NetworkError', 'Network request failed'), { + serverUrl: 'https://mail.example.com', + }); + expect(copy.title).toBe("Can't reach mail.example.com"); + }); + + it('falls back to a generic host label when the server is unknown', () => { + const copy = describeLoginError(named('TypeError', 'Network request failed')); + expect(copy.title).toBe("Can't reach the server"); + }); + + it('explains a 404 from session discovery as a wrong address', () => { + const copy = describeLoginError(new Error('Session discovery failed: 404 Not Found'), { + serverUrl: 'https://example.com', + }); + expect(copy.title).toBe('No mail server at example.com'); + }); + + it('flags a certificate problem separately from a connection problem', () => { + const copy = describeLoginError(new Error('SSL certificate has expired'), { + serverUrl: 'https://mail.example.com', + }); + expect(copy.title).toBe("Couldn't verify mail.example.com"); + }); + + it('explains an expired pairing code', () => { + const copy = describeLoginError(new Error('Pairing code is invalid or has expired')); + expect(copy.title).toBe('That code has expired'); + expect(copy.detail).toMatch(/fresh one/i); + }); + + it('passes through an unrecognised message rather than inventing a cause', () => { + const copy = describeLoginError(new Error('Teapot refused to brew')); + expect(copy.title).toBe('Sign-in failed'); + expect(copy.detail).toBe('Teapot refused to brew'); + }); + + it('handles non-Error throws', () => { + expect(describeLoginError(undefined).title).toBe('Sign-in failed'); + expect(describeLoginError('boom').detail).toBe('boom'); + }); +}); diff --git a/src/lib/__tests__/server-discovery.test.ts b/src/lib/__tests__/server-discovery.test.ts new file mode 100644 index 00000000..1e4e2d06 --- /dev/null +++ b/src/lib/__tests__/server-discovery.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockSecureFetch } = vi.hoisted(() => ({ + mockSecureFetch: vi.fn(), +})); + +vi.mock('../client-cert', () => ({ + secureFetch: mockSecureFetch, +})); + +import { + normalizeServerUrl, + emailDomain, + isEmailAddress, + serverCandidates, + probeJmapServer, + discoverServerForEmail, +} from '../server-discovery'; + +const SESSION_DOC = { capabilities: {}, apiUrl: '/jmap/', primaryAccounts: {} }; + +/** + * Answers `status` for the listed base URLs and rejects everything else. A 200 + * yields a JMAP session document unless a body is given explicitly. + */ +function respondFor(statuses: Record) { + mockSecureFetch.mockImplementation(async (url: string) => { + const base = url.replace(/\/\.well-known\/jmap$/, ''); + const entry = statuses[base]; + if (entry === undefined) throw new TypeError('Network request failed'); + const { status, body } = + typeof entry === 'number' ? { status: entry, body: SESSION_DOC } : entry; + return { status, json: async () => body } as Response; + }); +} + +describe('normalizeServerUrl', () => { + it('adds https:// to a bare host', () => { + expect(normalizeServerUrl('mail.example.com')).toBe('https://mail.example.com'); + }); + + it('strips trailing slashes', () => { + expect(normalizeServerUrl('https://mail.example.com//')).toBe('https://mail.example.com'); + }); + + it('strips a pasted JMAP session endpoint', () => { + expect(normalizeServerUrl('https://mail.example.com/.well-known/jmap')).toBe( + 'https://mail.example.com', + ); + expect(normalizeServerUrl('mail.example.com/jmap')).toBe('https://mail.example.com'); + }); + + it('keeps an explicit http scheme and port', () => { + expect(normalizeServerUrl('http://localhost:8080')).toBe('http://localhost:8080'); + }); + + it('rejects non-http schemes', () => { + expect(normalizeServerUrl('ftp://mail.example.com')).toBeNull(); + expect(normalizeServerUrl('bulwarkmail://connect')).toBeNull(); + }); + + it('rejects input that is not a host', () => { + expect(normalizeServerUrl('')).toBeNull(); + expect(normalizeServerUrl(' ')).toBeNull(); + expect(normalizeServerUrl('not a host')).toBeNull(); + expect(normalizeServerUrl('ada@example.com')).toBeNull(); + // A single label can't be a public host — except localhost. + expect(normalizeServerUrl('example')).toBeNull(); + expect(normalizeServerUrl('localhost')).toBe('https://localhost'); + }); +}); + +describe('emailDomain', () => { + it('extracts and lowercases the domain', () => { + expect(emailDomain('Ada@Example.COM')).toBe('example.com'); + }); + + it('rejects anything that is not an address', () => { + expect(emailDomain('ada')).toBeNull(); + expect(emailDomain('ada@localhost')).toBeNull(); + expect(emailDomain('a@b@example.com')).toBeNull(); + expect(isEmailAddress('ada@example.com')).toBe(true); + expect(isEmailAddress('ada')).toBe(false); + }); +}); + +describe('serverCandidates', () => { + it('orders the bare domain ahead of the conventional subdomains', () => { + expect(serverCandidates('example.com')).toEqual([ + 'https://example.com', + 'https://mail.example.com', + 'https://webmail.example.com', + ]); + }); +}); + +describe('probeJmapServer', () => { + beforeEach(() => vi.clearAllMocks()); + + it('treats 200 as a hit', async () => { + respondFor({ 'https://mail.example.com': 200 }); + await expect(probeJmapServer('https://mail.example.com')).resolves.toBe(true); + }); + + it('treats 401 as a hit — an unauthenticated probe is meant to be rejected', async () => { + respondFor({ 'https://mail.example.com': 401 }); + await expect(probeJmapServer('https://mail.example.com')).resolves.toBe(true); + }); + + it('treats 404 and transport failures as a miss', async () => { + respondFor({ 'https://mail.example.com': 404 }); + await expect(probeJmapServer('https://mail.example.com')).resolves.toBe(false); + await expect(probeJmapServer('https://nothing.example.com')).resolves.toBe(false); + }); + + it('rejects a 200 that is not a session document', async () => { + // A catch-all web server or captive portal answering 200 for every path. + respondFor({ 'https://example.com': { status: 200, body: '' } }); + await expect(probeJmapServer('https://example.com')).resolves.toBe(false); + }); + + it('rejects a 200 whose body is not JSON at all', async () => { + mockSecureFetch.mockResolvedValue({ + status: 200, + json: async () => { + throw new SyntaxError('Unexpected token <'); + }, + } as unknown as Response); + await expect(probeJmapServer('https://example.com')).resolves.toBe(false); + }); + + it('gives up at the deadline instead of hanging', async () => { + mockSecureFetch.mockImplementation( + () => new Promise(() => {/* never settles */}), + ); + await expect(probeJmapServer('https://slow.example.com', 20)).resolves.toBe(false); + }); +}); + +describe('discoverServerForEmail', () => { + beforeEach(() => vi.clearAllMocks()); + + it('returns a known account server for the same domain without probing', async () => { + respondFor({}); + const found = await discoverServerForEmail('ada@example.com', { + knownServerUrls: ['https://mail.example.com'], + }); + expect(found).toBe('https://mail.example.com'); + expect(mockSecureFetch).not.toHaveBeenCalled(); + }); + + it('prefers the bare domain when several hosts answer', async () => { + respondFor({ 'https://example.com': 401, 'https://mail.example.com': 200 }); + await expect(discoverServerForEmail('ada@example.com')).resolves.toBe('https://example.com'); + }); + + it('falls back to mail. when the bare domain is not a JMAP server', async () => { + respondFor({ 'https://example.com': 404, 'https://mail.example.com': 401 }); + await expect(discoverServerForEmail('ada@example.com')).resolves.toBe( + 'https://mail.example.com', + ); + }); + + it('skips a bare domain that only serves a marketing site', async () => { + respondFor({ + 'https://example.com': { status: 200, body: '' }, + 'https://mail.example.com': 200, + }); + await expect(discoverServerForEmail('ada@example.com')).resolves.toBe( + 'https://mail.example.com', + ); + }); + + it('returns null when nothing answers, so the caller can ask', async () => { + respondFor({}); + await expect(discoverServerForEmail('ada@example.org')).resolves.toBeNull(); + }); + + it('returns null without touching the network for a non-address', async () => { + respondFor({}); + await expect(discoverServerForEmail('ada')).resolves.toBeNull(); + expect(mockSecureFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/login-errors.ts b/src/lib/login-errors.ts new file mode 100644 index 00000000..880b8889 --- /dev/null +++ b/src/lib/login-errors.ts @@ -0,0 +1,102 @@ +// Sign-in failures reach the UI as raw strings from the JMAP client — "Session +// discovery failed: 404 Not Found", "Network request failed". Those describe +// what the code was doing, not what the person should do next. This maps the +// ones we can recognise onto copy that names a likely cause and an action. +// +// Errors are matched by `name` and message text rather than `instanceof` so +// this module stays free of the api/ and expo dependency graph. + +export interface LoginErrorCopy { + title: string; + detail?: string; +} + +export interface LoginErrorContext { + /** Host shown in "can't reach X" copy. */ + serverUrl?: string | null; +} + +function messageOf(err: unknown): string { + return err instanceof Error ? err.message : typeof err === 'string' ? err : ''; +} + +function nameOf(err: unknown): string { + return err instanceof Error ? err.name : ''; +} + +function hostLabel(serverUrl: string | null | undefined): string { + if (!serverUrl) return 'the server'; + const host = serverUrl.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0]; + return host || 'the server'; +} + +export function describeLoginError(err: unknown, context: LoginErrorContext = {}): LoginErrorCopy { + const name = nameOf(err); + const message = messageOf(err); + const lower = message.toLowerCase(); + const host = hostLabel(context.serverUrl); + + if (name === 'AuthenticationError' || lower.includes('invalid username or password')) { + return { + title: "That didn't work", + detail: + 'Check your email and password. If your account uses two-factor sign-in, create an app password in the webmail and use that here.', + }; + } + + if (lower.includes('certificate') || lower.includes('ssl') || lower.includes('tls')) { + return { + title: `Couldn't verify ${host}`, + detail: + "The server's security certificate was rejected. If this is your own server, check the certificate is valid and not expired.", + }; + } + + // The endpoint answered, but it isn't a JMAP server — almost always a + // mistyped host or a webmail that lives on a subpath. + if (lower.includes('session discovery failed') && /\b40[34]\b/.test(message)) { + return { + title: `No mail server at ${host}`, + detail: 'Double-check the address, or scan a sign-in code from the webmail instead.', + }; + } + + if ( + name === 'NetworkError' || + name === 'TypeError' || + name === 'AbortError' || + lower.includes('network request failed') || + lower.includes('failed to fetch') || + lower.includes('timeout') || + lower.includes('timed out') + ) { + return { + title: `Can't reach ${host}`, + detail: 'Check your connection and the server address, then try again.', + }; + } + + if (lower.includes('pairing code')) { + return { + title: 'That code has expired', + detail: 'Sign-in codes are good for a few minutes. Generate a fresh one in the webmail and scan again.', + }; + } + + if (lower.includes('state mismatch')) { + return { + title: 'Sign-in was interrupted', + detail: "The response didn't match the request we started. Try signing in again.", + }; + } + + if (lower.includes('maximum of') && lower.includes('accounts')) { + return { title: message }; + } + + // Unrecognised: show what we were told rather than inventing a cause. + return { + title: 'Sign-in failed', + detail: message || 'Something went wrong. Try again.', + }; +} diff --git a/src/lib/server-discovery.ts b/src/lib/server-discovery.ts new file mode 100644 index 00000000..89d5f921 --- /dev/null +++ b/src/lib/server-discovery.ts @@ -0,0 +1,170 @@ +// Turn an email address into a JMAP server URL so the sign-in flow doesn't +// have to open with a "Server URL" field nobody knows the answer to. +// +// The probe is deliberately cheap and short-lived: a GET of +// `/.well-known/jmap` against a handful of conventional hosts, all in +// parallel, with a hard deadline. Discovery is an optimisation — if it comes +// back empty the caller falls through to asking for the server by hand, so a +// slow or hostile network must never be worse than typing it in. + +import { secureFetch } from './client-cert'; + +export const DISCOVERY_TIMEOUT_MS = 2500; + +const SCHEME_RE = /^([a-z][a-z0-9+.-]*):\/\//i; +const EMAIL_RE = /^[^\s@]+@([^\s@]+\.[^\s@]+)$/; + +/** + * Accept what people actually type — `mail.example.com`, `example.com/`, + * `https://mail.example.com/.well-known/jmap` — and return the base URL the + * JMAP client expects, or null if it can't be one. + */ +export function normalizeServerUrl(input: string): string | null { + let value = input.trim(); + if (!value) return null; + + const scheme = SCHEME_RE.exec(value); + if (scheme) { + const protocol = scheme[1].toLowerCase(); + if (protocol !== 'http' && protocol !== 'https') return null; + value = `${protocol}://${value.slice(scheme[0].length)}`; + } else { + value = `https://${value}`; + } + + // Someone pasting the endpoint they found in the webmail's settings gets the + // same result as someone typing the address they use in the browser. + value = value + .replace(/\/+$/, '') + .replace(/\/\.well-known\/jmap$/i, '') + .replace(/\/jmap$/i, '') + .replace(/\/+$/, ''); + + const host = value.slice(value.indexOf('://') + 3).split('/')[0]; + if (!host || /[\s@]/.test(host)) return null; + + const hostname = host.split(':')[0].toLowerCase(); + if (!hostname) return null; + if (!hostname.includes('.') && hostname !== 'localhost') return null; + + return value; +} + +export function emailDomain(email: string): string | null { + const match = EMAIL_RE.exec(email.trim()); + return match ? match[1].toLowerCase() : null; +} + +export function isEmailAddress(value: string): boolean { + return emailDomain(value) !== null; +} + +function hostOf(url: string): string { + return url.slice(url.indexOf('://') + 3).split('/')[0].split(':')[0].toLowerCase(); +} + +/** + * Hosts worth probing for `domain`, most likely first. The order is also the + * tie-break order when several respond. + */ +export function serverCandidates(domain: string): string[] { + return [`https://${domain}`, `https://mail.${domain}`, `https://webmail.${domain}`]; +} + +function withTimeout(promise: Promise, ms: number, fallback: T): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(fallback), ms); + promise + .then((value) => { + clearTimeout(timer); + resolve(value); + }) + .catch(() => { + clearTimeout(timer); + resolve(fallback); + }); + }); +} + +function looksLikeSession(body: unknown): boolean { + if (!body || typeof body !== 'object') return false; + return 'capabilities' in body || 'apiUrl' in body || 'primaryAccounts' in body; +} + +/** + * True when `baseUrl` looks like a JMAP server. + * + * A 401 counts as a hit — an unauthenticated probe of Stalwart's session + * endpoint is *supposed* to be rejected, and that rejection is proof the + * endpoint is there. A 200, on the other hand, has to actually contain a + * session document: a captive portal or a catch-all web server will happily + * answer 200 with HTML for any path we ask for. + */ +export async function probeJmapServer( + baseUrl: string, + timeoutMs: number = DISCOVERY_TIMEOUT_MS, +): Promise { + const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; + const abortTimer = setTimeout(() => controller?.abort(), timeoutMs); + + const request = (async () => { + try { + const response = await secureFetch(`${baseUrl}/.well-known/jmap`, { + headers: { Accept: 'application/json' }, + signal: controller?.signal, + }); + if (response.status === 401) return true; + if (response.status !== 200) return false; + return looksLikeSession(await response.json()); + } catch { + return false; + } + })(); + + try { + // The abort signal doesn't reach the native client-certificate path, so + // race the whole thing rather than trusting the request to give up. + return await withTimeout(request, timeoutMs, false); + } finally { + clearTimeout(abortTimer); + } +} + +export interface DiscoveryOptions { + /** Servers of already-registered accounts, trusted without a probe. */ + knownServerUrls?: string[]; + timeoutMs?: number; +} + +/** + * Best guess at the mail server for `email`, or null if nothing answered in + * time. Callers treat null as "ask the user". + */ +export async function discoverServerForEmail( + email: string, + options: DiscoveryOptions = {}, +): Promise { + const domain = emailDomain(email); + if (!domain) return null; + + // A server we're already signed in to for this domain needs no probe, and + // covers the common case of adding a second account on the same host. + const known = (options.knownServerUrls ?? []) + .map(normalizeServerUrl) + .filter((url): url is string => url !== null); + const alreadyKnown = known.find((url) => { + const host = hostOf(url); + return host === domain || host.endsWith(`.${domain}`); + }); + if (alreadyKnown) return alreadyKnown; + + const timeoutMs = options.timeoutMs ?? DISCOVERY_TIMEOUT_MS; + const candidates = serverCandidates(domain); + const results = await Promise.all( + candidates.map(async (candidate) => ((await probeJmapServer(candidate, timeoutMs)) ? candidate : null)), + ); + + // `find` keeps candidate order, so a bare domain wins over `mail.` even when + // both answer. + return results.find((url): url is string => url !== null) ?? null; +} diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index 6bd12fbd..540aa2ee 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -1,16 +1,23 @@ import React from 'react'; -import { View, Text, Pressable, StyleSheet, KeyboardAvoidingView, Platform, Image, Alert } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import Constants from 'expo-constants'; -import { Eye, EyeOff, X, QrCode } from 'lucide-react-native'; -import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; -import { useColors } from '../theme/colors'; -import { Button, Input } from '../components'; +import { BackHandler } from 'react-native'; +import { useAuthStore } from '../stores/auth-store'; +import { useAccountStore } from '../stores/account-store'; import { QrScanModal } from '../components/QrScanModal'; import { parseQrLoginPayload } from '../lib/oauth'; -import { useAuthStore } from '../stores/auth-store'; - -const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0'; +import { + discoverServerForEmail, + emailDomain, + isEmailAddress, + normalizeServerUrl, +} from '../lib/server-discovery'; +import { describeLoginError, type LoginErrorCopy } from '../lib/login-errors'; +import LoginShell from './login/LoginShell'; +import ChooseStep from './login/ChooseStep'; +import EmailStep from './login/EmailStep'; +import ServerStep from './login/ServerStep'; +import ConfirmStep from './login/ConfirmStep'; +import PasswordStep from './login/PasswordStep'; +import SigningInStep, { type SigningInPhase } from './login/SigningInStep'; interface LoginScreenProps { onLogin?: () => void; @@ -18,288 +25,349 @@ interface LoginScreenProps { onCancel?: () => void; } -export default function LoginScreen({ onLogin, isAddMode, onCancel }: LoginScreenProps) { - const c = useColors(); - const styles = React.useMemo(() => makeStyles(c), [c]); +type StepName = 'choose' | 'email' | 'server' | 'confirm' | 'password'; + +/** + * Sign-in, one question per screen, cheapest question first. + * + * The routes that need no server address — a paired QR code, or an email we + * can discover a server from — come first; the manual server + password form + * is the fallback rather than the greeting. Every step drives one of the + * existing auth-store actions, so the credential handling below is unchanged + * from the single-form version this replaced. + */ +export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: LoginScreenProps) { const login = useAuthStore((state) => state.login); const loginViaWebmail = useAuthStore((state) => state.loginViaWebmail); const loginViaPairing = useAuthStore((state) => state.loginViaPairing); - const isLoading = useAuthStore((state) => state.isLoading); - const error = useAuthStore((state) => state.error); const clearError = useAuthStore((state) => state.clearError); + const storeError = useAuthStore((state) => state.error); - const [serverUrl, setServerUrl] = React.useState(''); + const accounts = useAccountStore((state) => state.accounts); + const activeAccountId = useAccountStore((state) => state.activeAccountId); + + const [step, setStep] = React.useState('choose'); + const [history, setHistory] = React.useState([]); const [email, setEmail] = React.useState(''); + const [serverInput, setServerInput] = React.useState(''); + const [serverUrl, setServerUrl] = React.useState(''); const [password, setPassword] = React.useState(''); - const [showPassword, setShowPassword] = React.useState(false); + const [failedDomain, setFailedDomain] = React.useState(null); + const [notice, setNotice] = React.useState(null); + const [searching, setSearching] = React.useState(false); + const [busy, setBusy] = React.useState(null); const [scannerVisible, setScannerVisible] = React.useState(false); - const canSubmit = Boolean(serverUrl.trim() && email.trim() && password); - const canHandoff = Boolean(serverUrl.trim()); + // Servers we already hold credentials for. Discovery trusts these without a + // probe, which makes "second account on the same host" instant. + const knownServerUrls = React.useMemo(() => accounts.map((a) => a.serverUrl), [accounts]); + const knownServerUrl = React.useMemo(() => { + const active = accounts.find((a) => a.id === activeAccountId); + return active?.serverUrl ?? accounts[0]?.serverUrl ?? null; + }, [accounts, activeAccountId]); - const handleLogin = async () => { - if (!canSubmit) { - return; - } - - try { - await login(serverUrl.trim(), email.trim(), password, { addAccount: isAddMode }); - onLogin?.(); - } catch { - // Store state already contains the user-facing error. - } - }; - - const handleWebmailHandoff = async () => { - if (!canHandoff) return; - try { - const before = useAuthStore.getState().isAuthenticated; - await loginViaWebmail(serverUrl.trim(), { addAccount: isAddMode }); - // loginViaWebmail swallows the "user cancelled the browser" case - // silently — only fire the navigation hook when authentication - // actually completed. - const after = useAuthStore.getState().isAuthenticated; - if (after && !before) { - onLogin?.(); - } else if (isAddMode && after) { - onLogin?.(); - } - } catch { - // Store state already contains the user-facing error. - } - }; - - const handleScanned = async (data: string) => { - setScannerVisible(false); - const payload = parseQrLoginPayload(data); - if (!payload) { - Alert.alert('Unrecognized QR code', "That QR code isn't a Bulwark Mail sign-in code."); - return; - } - try { - const before = useAuthStore.getState().isAuthenticated; - if (payload.kind === 'connect') { - // Server-bootstrap QR: fill the field and run the normal browser - // handoff so the user still authenticates in the webmail. - setServerUrl(payload.webmailUrl); - await loginViaWebmail(payload.webmailUrl, { addAccount: isAddMode }); - } else { - // Cross-device pairing QR: redeem the one-time code for tokens, no - // browser round-trip needed. - await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode }); - } - const after = useAuthStore.getState().isAuthenticated; - if ((after && !before) || (isAddMode && after)) { - onLogin?.(); - } - } catch { - // Store state already contains the user-facing error. - } - }; - - const updateField = React.useCallback( - (setter: React.Dispatch>) => (value: string) => { - if (useAuthStore.getState().error) { - clearError(); - } - setter(value); + const goTo = React.useCallback( + (next: StepName) => { + clearError(); + setNotice(null); + setHistory((prev) => [...prev, step]); + setStep(next); }, - [clearError], + [clearError, step], ); - const onChangeServerUrl = React.useMemo(() => updateField(setServerUrl), [updateField]); - const onChangeEmail = React.useMemo(() => updateField(setEmail), [updateField]); - const onChangePassword = React.useMemo(() => updateField(setPassword), [updateField]); + const goBack = React.useCallback(() => { + if (history.length === 0) return; + clearError(); + setNotice(null); + setStep(history[history.length - 1]); + setHistory((prev) => prev.slice(0, -1)); + }, [clearError, history]); + + // Android hardware back mirrors the on-screen back: one step at a time, and + // out of the flow entirely from the first step. + React.useEffect(() => { + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + if (busy || searching) return true; + if (scannerVisible) { + setScannerVisible(false); + return true; + } + if (history.length > 0) { + goBack(); + return true; + } + if (isAddMode && onCancel) { + onCancel(); + return true; + } + return false; + }); + return () => subscription.remove(); + }, [busy, searching, scannerVisible, history.length, goBack, isAddMode, onCancel]); + + // ── flows ────────────────────────────────────────────── + + const finishIfSignedIn = React.useCallback( + (wasAuthenticated: boolean) => { + const isAuthenticated = useAuthStore.getState().isAuthenticated; + // In add mode the store was already authenticated before we started, so + // the before/after comparison can't be the only signal. + if ((isAuthenticated && !wasAuthenticated) || (isAddMode && isAuthenticated)) { + onLogin?.(); + return true; + } + return false; + }, + [isAddMode, onLogin], + ); + + const runHandoff = React.useCallback( + async (target: string) => { + setNotice(null); + setBusy('browser'); + const wasAuthenticated = useAuthStore.getState().isAuthenticated; + try { + await loginViaWebmail(target, { addAccount: isAddMode }); + // loginViaWebmail resolves quietly when the user closes the browser — + // that's a cancellation, not a failure, so leave them where they were. + finishIfSignedIn(wasAuthenticated); + } catch (err) { + setNotice(describeLoginError(err, { serverUrl: target })); + } finally { + setBusy(null); + } + }, + [finishIfSignedIn, isAddMode, loginViaWebmail], + ); + + const handleEmailContinue = React.useCallback(async () => { + const value = email.trim(); + if (!isEmailAddress(value)) { + setNotice({ + title: 'Enter a full email address', + detail: 'Like ada@example.com. If you sign in with a username instead, use “I know my server address”.', + }); + return; + } + + setNotice(null); + setSearching(true); + try { + const found = await discoverServerForEmail(value, { knownServerUrls }); + if (found) { + setServerUrl(found); + setServerInput(found); + goTo('confirm'); + } else { + setFailedDomain(emailDomain(value)); + setServerInput(''); + goTo('server'); + } + } finally { + setSearching(false); + } + }, [email, goTo, knownServerUrls]); + + const handleServerContinue = React.useCallback(() => { + const normalized = normalizeServerUrl(serverInput); + if (!normalized) { + setNotice({ + title: "That doesn't look like a server address", + detail: 'Try the address you use for webmail, like mail.example.com.', + }); + return; + } + setServerUrl(normalized); + goTo('confirm'); + }, [goTo, serverInput]); + + const handlePasswordSubmit = React.useCallback(async () => { + const target = serverUrl || normalizeServerUrl(serverInput); + if (!target) { + setNotice({ title: 'We need a server address first' }); + return; + } + if (!email.trim() || !password) { + setNotice({ title: 'Enter your email and password' }); + return; + } + + setNotice(null); + setBusy('connecting'); + const wasAuthenticated = useAuthStore.getState().isAuthenticated; + try { + await login(target, email.trim(), password, { addAccount: isAddMode }); + finishIfSignedIn(wasAuthenticated); + } catch (err) { + setNotice(describeLoginError(err, { serverUrl: target })); + } finally { + setBusy(null); + } + }, [email, finishIfSignedIn, isAddMode, login, password, serverInput, serverUrl]); + + const handleScanned = React.useCallback( + async (data: string) => { + setScannerVisible(false); + const payload = parseQrLoginPayload(data); + if (!payload) { + setNotice({ + title: "That code isn't a Bulwark sign-in code", + detail: 'Open Bulwark on the web, then Settings → Devices to show one.', + }); + return; + } + + if (payload.kind === 'connect') { + // Server-bootstrap code: it carries the address, the user still + // authenticates in the webmail. + setServerUrl(payload.webmailUrl); + setServerInput(payload.webmailUrl); + await runHandoff(payload.webmailUrl); + return; + } + + // Cross-device pairing code: redeem it for tokens, no browser needed. + setNotice(null); + setBusy('pairing'); + const wasAuthenticated = useAuthStore.getState().isAuthenticated; + try { + await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode }); + finishIfSignedIn(wasAuthenticated); + } catch (err) { + setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl })); + } finally { + setBusy(null); + } + }, + [finishIfSignedIn, isAddMode, loginViaPairing, runHandoff], + ); + + // ── render ───────────────────────────────────────────── + + if (busy) { + return ( + + ); + } + + const canGoBack = history.length > 0; + const shellProps = { + onBack: canGoBack ? goBack : undefined, + onClose: isAddMode && onCancel ? onCancel : undefined, + }; + + if (step === 'choose') { + // A leftover store error ("Session expired") is the reason some people + // land back here, so it belongs on this screen. + const chooseNotice = notice ?? (storeError ? { title: storeError } : null); + return ( + <> + + setScannerVisible(true)} + onUseEmail={() => goTo('email')} + onUseKnownServer={() => { + if (!knownServerUrl) return; + setServerUrl(knownServerUrl); + setServerInput(knownServerUrl); + goTo('confirm'); + }} + onManualSetup={() => { + setFailedDomain(null); + goTo('server'); + }} + /> + + setScannerVisible(false)} + onScanned={(data) => void handleScanned(data)} + /> + + ); + } return ( - - {isAddMode && onCancel ? ( - - - - ) : null} - - - {/* Logo & Branding */} - - - - - {isAddMode ? 'Add account' : 'Bulwark Mail'} - - {isAddMode ? 'Sign in to a second account' : 'Secure. Private. Yours.'} - - + <> + + {step === 'email' ? ( + { + setNotice(null); + setEmail(value); + }} + onSubmit={() => void handleEmailContinue()} + onKnowServer={() => { + setFailedDomain(null); + goTo('server'); + }} + isSearching={searching} + notice={notice} + /> + ) : null} - {/* Login Form */} - - + {step === 'server' ? ( + { + setNotice(null); + setServerInput(value); + }} + onSubmit={handleServerContinue} + onScan={() => setScannerVisible(true)} + failedDomain={failedDomain} + notice={notice} + /> + ) : null} - + {step === 'confirm' ? ( + void runHandoff(serverUrl)} + onChangeServer={() => { + setFailedDomain(null); + goTo('server'); + }} + onUsePassword={() => goTo('password')} + notice={notice} + /> + ) : null} - { - void handleLogin(); - }} - rightIcon={ - setShowPassword(!showPassword)}> - {showPassword ? ( - - ) : ( - - )} - - } - /> - - - - {error ? {error} : null} - - {/* OAuth divider */} - - - or continue with - - - - {/* Webmail-mediated login */} - - - - - - - {/* Footer */} - - Bulwark Mobile v{APP_VERSION} - Privacy Policy - - - + {step === 'password' ? ( + { + setNotice(null); + setEmail(value); + }} + onChangePassword={(value) => { + setNotice(null); + setPassword(value); + }} + onSubmit={() => void handlePasswordSubmit()} + notice={notice} + /> + ) : null} + setScannerVisible(false)} - onScanned={(data) => { - void handleScanned(data); - }} + onScanned={(data) => void handleScanned(data)} /> - + ); } - -function makeStyles(c: ThemePalette) { - return StyleSheet.create({ - container: { flex: 1, backgroundColor: c.background }, - cancelButton: { - position: 'absolute', - top: 50, - left: 16, - zIndex: 10, - padding: 8, - }, - keyboardView: { flex: 1 }, - content: { - flex: 1, - justifyContent: 'center', - paddingHorizontal: spacing.xxl, - }, - branding: { alignItems: 'center', marginBottom: 40 }, - logoContainer: { - width: 80, - height: 80, - alignItems: 'center', - justifyContent: 'center', - marginBottom: spacing.lg, - }, - logoImage: { - width: 72, - height: 72, - }, - appName: { ...typography.h1, color: c.text }, - tagline: { ...typography.caption, color: c.textMuted, marginTop: spacing.xs }, - - form: { gap: spacing.lg }, - - loginButton: { - marginTop: spacing.sm, - }, - errorText: { - ...typography.caption, - color: c.error, - textAlign: 'center', - }, - - divider: { - flexDirection: 'row', - alignItems: 'center', - marginVertical: spacing.md, - gap: spacing.md, - }, - dividerLine: { flex: 1, height: 1, backgroundColor: c.border }, - dividerText: { ...typography.caption, color: c.textMuted }, - - oauthRow: { gap: spacing.md }, - - footer: { alignItems: 'center', marginTop: 40, gap: spacing.xs }, - footerText: { ...typography.caption, color: c.textMuted }, - footerLink: { ...typography.caption, color: c.textLink }, - }); -} diff --git a/src/screens/login/ChooseStep.tsx b/src/screens/login/ChooseStep.tsx new file mode 100644 index 00000000..56c3ce65 --- /dev/null +++ b/src/screens/login/ChooseStep.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet, Image } from 'react-native'; +import { QrCode, Mail, Plus, Server } from 'lucide-react-native'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors, useResolvedTheme } from '../../theme/colors'; +import type { AccountEntry } from '../../stores/account-store'; +import OptionTile from './OptionTile'; +import LoginNotice from './LoginNotice'; + +// The white mark disappears on the light palette, so pick per theme. `require` +// can't take an expression, hence the pair. +const LOGO_LIGHT = require('../../../assets/logos/Bulwark Logo Dark.png'); +const LOGO_DARK = require('../../../assets/logos/Bulwark Logo White.png'); + +interface ChooseStepProps { + isAddMode: boolean; + accounts: AccountEntry[]; + /** Host of the signed-in account, offered as a shortcut in add mode. */ + knownServerUrl: string | null; + onScan: () => void; + onUseEmail: () => void; + onUseKnownServer: () => void; + onManualSetup: () => void; + notice?: { title: string; detail?: string } | null; + disabled?: boolean; +} + +function hostOf(url: string): string { + return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0]; +} + +function initialsOf(account: AccountEntry): string { + const source = account.displayName || account.email || account.username; + const parts = source.replace(/@.*$/, '').split(/[.\s_-]+/).filter(Boolean); + const letters = parts.slice(0, 2).map((part) => part[0]); + return (letters.join('') || source[0] || '?').toUpperCase(); +} + +/** + * The entry point. Leads with the two routes that don't require knowing a + * server address; manual setup stays one tap away for people who do. + */ +export default function ChooseStep({ + isAddMode, + accounts, + knownServerUrl, + onScan, + onUseEmail, + onUseKnownServer, + onManualSetup, + notice, + disabled = false, +}: ChooseStepProps) { + const c = useColors(); + const theme = useResolvedTheme(); + const styles = React.useMemo(() => makeStyles(c), [c]); + + return ( + + {isAddMode ? ( + + Add another account + {accounts.length > 0 ? ( + You're signed in to these already. + ) : null} + + ) : ( + + + Bulwark Mail + Secure. Private. Yours. + + )} + + {isAddMode && accounts.length > 0 ? ( + + {accounts.map((account) => ( + + + {initialsOf(account)} + + + + {account.displayName || account.username} + + + {account.email || account.username} + + + + ))} + + ) : null} + + {notice ? ( + + + + ) : null} + + + {isAddMode && knownServerUrl ? ( + } + onPress={onUseKnownServer} + disabled={disabled} + /> + ) : ( + } + onPress={onScan} + disabled={disabled} + /> + )} + + } + onPress={onUseEmail} + disabled={disabled} + /> + + {isAddMode && knownServerUrl ? ( + } + onPress={onScan} + disabled={disabled} + /> + ) : null} + + + + + Enter server details manually + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + root: { gap: spacing.xl }, + branding: { alignItems: 'center', gap: spacing.xs, marginBottom: spacing.md }, + logo: { width: 64, height: 64, marginBottom: spacing.sm }, + appName: { ...typography.h1, color: c.text }, + tagline: { ...typography.caption, color: c.textMuted }, + + addHeading: { gap: spacing.xs }, + title: { ...typography.h1, color: c.text }, + subtitle: { ...typography.body, color: c.textSecondary }, + + accountList: { gap: spacing.md }, + accountRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md }, + avatar: { width: 36, height: 36, borderRadius: radius.full, alignItems: 'center', justifyContent: 'center' }, + avatarText: { ...typography.captionMedium, color: '#ffffff' }, + accountText: { flex: 1 }, + accountName: { ...typography.bodyMedium, color: c.text }, + accountEmail: { ...typography.caption, color: c.textMuted }, + + notice: { marginTop: -spacing.xs }, + options: { gap: spacing.md }, + + manual: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingVertical: spacing.sm, + }, + manualText: { ...typography.body, color: c.textMuted }, + }); +} diff --git a/src/screens/login/ConfirmStep.tsx b/src/screens/login/ConfirmStep.tsx new file mode 100644 index 00000000..8fd39cc5 --- /dev/null +++ b/src/screens/login/ConfirmStep.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import { Globe } from 'lucide-react-native'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { Button } from '../../components'; +import LoginNotice from './LoginNotice'; + +interface ConfirmStepProps { + serverUrl: string; + onContinue: () => void; + onChangeServer: () => void; + onUsePassword: () => void; + notice?: { title: string; detail?: string } | null; +} + +function hostOf(url: string): string { + return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0]; +} + +/** + * Names the server before the browser opens. A sign-in that jumps to a browser + * with no warning reads as a bug; saying which server, and why the password + * isn't asked for here, turns the detour into the point. + */ +export default function ConfirmStep({ + serverUrl, + onContinue, + onChangeServer, + onUsePassword, + notice, +}: ConfirmStepProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + + return ( + + + Found your mail server + Next you'll sign in on your provider's own page. + + + + + + + + + {hostOf(serverUrl)} + + + + + {serverUrl.startsWith('https://') ? 'JMAP · secure connection' : 'JMAP · unencrypted connection'} + + + + + + + + {notice ? : null} + + + + + + Sign in with a password instead + + + Use a different server + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + root: { gap: spacing.lg }, + heading: { gap: spacing.sm }, + title: { ...typography.h1, color: c.text }, + subtitle: { ...typography.body, color: c.textSecondary }, + + card: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + borderRadius: radius.lg, + padding: spacing.lg, + }, + cardIcon: { + width: 38, + height: 38, + borderRadius: radius.md, + backgroundColor: c.primaryBg, + alignItems: 'center', + justifyContent: 'center', + }, + cardText: { flex: 1, gap: 2 }, + cardTitle: { ...typography.baseMedium, color: c.text }, + cardMetaRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs }, + dot: { width: 5, height: 5, borderRadius: radius.full, backgroundColor: c.success }, + cardMeta: { ...typography.caption, color: c.textSecondary }, + + links: { alignItems: 'center', gap: spacing.md, paddingVertical: spacing.xs }, + link: { ...typography.body, color: c.textLink }, + linkMuted: { ...typography.body, color: c.textMuted }, + }); +} diff --git a/src/screens/login/EmailStep.tsx b/src/screens/login/EmailStep.tsx new file mode 100644 index 00000000..66666875 --- /dev/null +++ b/src/screens/login/EmailStep.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import { Mail } from 'lucide-react-native'; +import { spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { Button, Input } from '../../components'; +import LoginNotice from './LoginNotice'; + +interface EmailStepProps { + isAddMode: boolean; + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + onKnowServer: () => void; + isSearching: boolean; + notice?: { title: string; detail?: string } | null; +} + +/** + * One field, keyboard already up. The button stays enabled and validates on + * press, so nobody is left guessing which field disabled it. + */ +export default function EmailStep({ + isAddMode, + value, + onChange, + onSubmit, + onKnowServer, + isSearching, + notice, +}: EmailStepProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + + return ( + + + + {isAddMode ? "What's the other address?" : "What's your email address?"} + + + That's usually all we need — we'll look up the mail server for you. + + + + } + /> + + Works with Bulwark, Stalwart, and any JMAP mail server. + + {notice ? : null} + + + + + I know my server address + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + root: { gap: spacing.lg }, + heading: { gap: spacing.sm }, + title: { ...typography.h1, color: c.text }, + subtitle: { ...typography.body, color: c.textSecondary }, + hint: { ...typography.caption, color: c.textMuted, marginTop: -spacing.sm }, + link: { alignItems: 'center', paddingVertical: spacing.sm }, + linkText: { ...typography.body, color: c.textMuted }, + }); +} diff --git a/src/screens/login/LoginNotice.tsx b/src/screens/login/LoginNotice.tsx new file mode 100644 index 00000000..0d972203 --- /dev/null +++ b/src/screens/login/LoginNotice.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { AlertTriangle, Lock } from 'lucide-react-native'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; + +interface LoginNoticeProps { + title: string; + detail?: string; + tone?: 'error' | 'info'; +} + +/** Inline banner for a sign-in failure, or a reassurance about where a + * password ends up. Errors lead with what went wrong, then what to do. */ +export default function LoginNotice({ title, detail, tone = 'error' }: LoginNoticeProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const isError = tone === 'error'; + const Icon = isError ? AlertTriangle : Lock; + + return ( + + + + {title} + {detail ? {detail} : null} + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + banner: { + flexDirection: 'row', + gap: spacing.sm, + borderRadius: radius.md, + padding: spacing.md, + }, + bannerError: { backgroundColor: c.errorBg, borderWidth: 1, borderColor: c.errorBorder }, + bannerInfo: { backgroundColor: 'transparent', paddingHorizontal: 0 }, + icon: { marginTop: 1 }, + text: { flex: 1, gap: 2 }, + title: { ...typography.captionMedium }, + titleError: { color: c.error }, + titleInfo: { color: c.textSecondary }, + detail: { ...typography.caption, color: c.textSecondary, lineHeight: 17 }, + }); +} diff --git a/src/screens/login/LoginShell.tsx b/src/screens/login/LoginShell.tsx new file mode 100644 index 00000000..767164f7 --- /dev/null +++ b/src/screens/login/LoginShell.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { + View, + Text, + Pressable, + ScrollView, + StyleSheet, + KeyboardAvoidingView, + Platform, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import Constants from 'expo-constants'; +import { ArrowLeft, X } from 'lucide-react-native'; +import { spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; + +const APP_VERSION = Constants.expoConfig?.version ?? '0.0.0'; + +interface LoginShellProps { + children: React.ReactNode; + /** Renders a back chevron. Omit on the first step of the flow. */ + onBack?: () => void; + /** Renders a close X on the right — add-account mode only. */ + onClose?: () => void; + /** Vertically centres the content instead of stacking from the top. */ + centered?: boolean; + showFooter?: boolean; +} + +/** + * Shared chrome for every sign-in step: safe area, keyboard avoidance, the + * back/close affordances, and the version footer. Steps supply only their own + * content so they all sit on the same grid. + */ +export default function LoginShell({ + children, + onBack, + onClose, + centered = false, + showFooter = false, +}: LoginShellProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + + return ( + + + + {onBack ? ( + + + + ) : ( + + )} + {onClose ? ( + + + + ) : ( + + )} + + + + {children} + + + {showFooter ? ( + + Bulwark Mobile v{APP_VERSION} + + ) : null} + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: c.background }, + flex: { flex: 1 }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.lg, + height: 44, + }, + headerButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, + content: { + paddingHorizontal: spacing.xxl, + paddingBottom: spacing.xxxl, + }, + contentCentered: { flexGrow: 1, justifyContent: 'center' }, + footer: { alignItems: 'center', paddingBottom: spacing.lg, gap: spacing.xs }, + footerText: { ...typography.caption, color: c.textMuted }, + }); +} diff --git a/src/screens/login/OptionTile.tsx b/src/screens/login/OptionTile.tsx new file mode 100644 index 00000000..1904cea2 --- /dev/null +++ b/src/screens/login/OptionTile.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import { ChevronRight } from 'lucide-react-native'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; + +interface OptionTileProps { + title: string; + description?: string; + /** Receives the colour the tile's text renders in. */ + renderIcon: (color: string, size: number) => React.ReactNode; + onPress: () => void; + emphasis?: 'primary' | 'default'; + disabled?: boolean; +} + +/** + * A full-width choice on the sign-in screen: icon, what it does, and what it + * costs you. Used instead of a stack of buttons so each route can say enough + * to be picked without trial and error. + */ +export default function OptionTile({ + title, + description, + renderIcon, + onPress, + emphasis = 'default', + disabled = false, +}: OptionTileProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const isPrimary = emphasis === 'primary'; + const contentColor = isPrimary ? c.primaryForeground : c.text; + + return ( + [ + styles.tile, + isPrimary ? styles.tilePrimary : styles.tileDefault, + pressed && !disabled && (isPrimary ? styles.pressedPrimary : styles.pressedDefault), + disabled && styles.disabled, + ]} + > + {renderIcon(contentColor, 22)} + + {title} + {description ? ( + + {description} + + ) : null} + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + tile: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + borderRadius: radius.lg, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.lg, + borderWidth: 1, + }, + tileDefault: { backgroundColor: c.surface, borderColor: c.border }, + tilePrimary: { backgroundColor: c.primary, borderColor: c.primary }, + pressedDefault: { backgroundColor: c.surfaceHover }, + pressedPrimary: { backgroundColor: c.primaryDark }, + disabled: { opacity: 0.5 }, + icon: { width: 22, alignItems: 'center' }, + text: { flex: 1, gap: 2 }, + title: { ...typography.baseMedium }, + description: { ...typography.caption }, + descriptionPrimary: { opacity: 0.8 }, + chevron: { opacity: 0.5 }, + }); +} diff --git a/src/screens/login/PasswordStep.tsx b/src/screens/login/PasswordStep.tsx new file mode 100644 index 00000000..8deafde5 --- /dev/null +++ b/src/screens/login/PasswordStep.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import { Eye, EyeOff } from 'lucide-react-native'; +import { spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { Button, Input } from '../../components'; +import LoginNotice from './LoginNotice'; + +interface PasswordStepProps { + serverUrl: string; + email: string; + password: string; + onChangeEmail: (value: string) => void; + onChangePassword: (value: string) => void; + onSubmit: () => void; + notice?: { title: string; detail?: string } | null; +} + +function hostOf(url: string): string { + return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0]; +} + +/** The fallback for servers without the browser hand-off — and the only step + * where a password is typed into the app. */ +export default function PasswordStep({ + serverUrl, + email, + password, + onChangeEmail, + onChangePassword, + onSubmit, + notice, +}: PasswordStepProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const [showPassword, setShowPassword] = React.useState(false); + + return ( + + + Sign in to {hostOf(serverUrl)} + + Your password is sent to this server only, and stored in the device keychain. + + + + + + setShowPassword((v) => !v)} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={showPassword ? 'Hide password' : 'Show password'} + > + {showPassword ? : } + + } + /> + + {notice ? : null} + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + root: { gap: spacing.lg }, + heading: { gap: spacing.sm }, + title: { ...typography.h1, color: c.text }, + subtitle: { ...typography.body, color: c.textSecondary }, + }); +} diff --git a/src/screens/login/ServerStep.tsx b/src/screens/login/ServerStep.tsx new file mode 100644 index 00000000..e20a00cf --- /dev/null +++ b/src/screens/login/ServerStep.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import { Globe, QrCode } from 'lucide-react-native'; +import { spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors } from '../../theme/colors'; +import { Button, Input } from '../../components'; +import LoginNotice from './LoginNotice'; + +interface ServerStepProps { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + onScan: () => void; + /** Set when we got here because discovery came back empty for this domain. */ + failedDomain?: string | null; + notice?: { title: string; detail?: string } | null; +} + +/** + * Asks for the address people actually know — the one they type in the browser + * to reach their webmail — not the JMAP session endpoint. The scheme and the + * `/.well-known/jmap` suffix are our problem, not theirs. + */ +export default function ServerStep({ + value, + onChange, + onSubmit, + onScan, + failedDomain, + notice, +}: ServerStepProps) { + const c = useColors(); + const styles = React.useMemo(() => makeStyles(c), [c]); + + return ( + + + Where does your mail live? + + {failedDomain + ? `We couldn't find a server for ${failedDomain}. If you self-host, the address is the one you use for webmail.` + : 'Enter the address you use to reach your webmail.'} + + + + } + /> + + Same address you type in the browser. We'll add https:// for you. + + {notice ? : null} + + + + + + + Not sure? Scan a sign-in code instead — it carries the address with it. + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + root: { gap: spacing.lg }, + heading: { gap: spacing.sm }, + title: { ...typography.h1, color: c.text }, + subtitle: { ...typography.body, color: c.textSecondary }, + hint: { ...typography.caption, color: c.textMuted, marginTop: -spacing.sm }, + scanRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm, paddingVertical: spacing.sm }, + scanText: { ...typography.caption, color: c.textMuted, flex: 1, lineHeight: 17 }, + }); +} diff --git a/src/screens/login/SigningInStep.tsx b/src/screens/login/SigningInStep.tsx new file mode 100644 index 00000000..683f04f7 --- /dev/null +++ b/src/screens/login/SigningInStep.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { View, Text, StyleSheet, ActivityIndicator, Image } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; +import { useColors, useResolvedTheme } from '../../theme/colors'; + +// The white mark disappears on the light palette, so pick per theme. +const LOGO_LIGHT = require('../../../assets/logos/Bulwark Logo Dark.png'); +const LOGO_DARK = require('../../../assets/logos/Bulwark Logo White.png'); + +export type SigningInPhase = 'browser' | 'connecting' | 'pairing'; + +interface SigningInStepProps { + phase: SigningInPhase; + serverUrl: string | null; + email: string | null; +} + +function hostOf(url: string | null): string { + if (!url) return 'your server'; + return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0]; +} + +function initialsOf(email: string): string { + const local = email.replace(/@.*$/, ''); + const parts = local.split(/[.\s_-]+/).filter(Boolean); + const letters = parts.slice(0, 2).map((part) => part[0]); + return (letters.join('') || local[0] || '?').toUpperCase(); +} + +/** + * Covers the wait while a sign-in is in flight. Naming the server and the + * current stage makes a slow first connection legible instead of a bare + * spinner that could mean anything. + */ +export default function SigningInStep({ phase, serverUrl, email }: SigningInStepProps) { + const c = useColors(); + const theme = useResolvedTheme(); + const styles = React.useMemo(() => makeStyles(c), [c]); + const host = hostOf(serverUrl); + + const copy: Record = { + browser: { + title: `Opening ${host}`, + detail: 'Finish signing in on the page that just opened. We’ll take it from there.', + }, + connecting: { title: `Connecting to ${host}`, detail: 'Setting up your mailbox…' }, + pairing: { title: 'Redeeming your sign-in code', detail: `Pairing this device with ${host}…` }, + }; + + return ( + + + {email ? ( + + {initialsOf(email)} + + ) : ( + + )} + + {email ? {email} : null} + {copy[phase].title} + {copy[phase].detail} + + + + + ); +} + +function makeStyles(c: ThemePalette) { + return StyleSheet.create({ + container: { flex: 1, backgroundColor: c.background }, + content: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.xxl, + gap: spacing.xs, + }, + avatar: { + width: 60, + height: 60, + borderRadius: radius.full, + backgroundColor: c.primary, + alignItems: 'center', + justifyContent: 'center', + marginBottom: spacing.md, + }, + avatarText: { ...typography.h2, color: c.primaryForeground }, + logo: { width: 60, height: 60, marginBottom: spacing.md }, + email: { ...typography.caption, color: c.textMuted }, + title: { ...typography.h2, color: c.text, textAlign: 'center' }, + detail: { ...typography.body, color: c.textSecondary, textAlign: 'center' }, + spinner: { marginTop: spacing.xl }, + }); +}