diff --git a/locales/rn/en.json b/locales/rn/en.json index 414d1f28..c2337d91 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1518,9 +1518,12 @@ "choose_another": "Choose another", "clear": "Clear", "clear_message": "New requests will no longer present a certificate. The certificate stays installed in Android.", + "clear_message_ios": "New requests will no longer present a certificate. The certificate is removed from the app; to use it again, pick the file again.", "clear_title": "Stop using client certificate?", "description": "Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS.", + "description_ios": "Imports a certificate file to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS.", "hint": "Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here.", + "hint_ios": "Pick the certificate file (.p12 or .pfx) in Files and enter its password.", "not_set": "Not set", "pick": "Pick certificate", "pick_failed": "Pick failed", diff --git a/src/components/settings/AccountSecuritySettings.tsx b/src/components/settings/AccountSecuritySettings.tsx index 7c2d4185..86ef7105 100644 --- a/src/components/settings/AccountSecuritySettings.tsx +++ b/src/components/settings/AccountSecuritySettings.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { Alert, View, Text, StyleSheet, Pressable, Linking, ActivityIndicator } from 'react-native'; +import { Alert, View, Text, StyleSheet, Pressable, Linking, ActivityIndicator, Platform } from 'react-native'; import DateTimePicker, { type DateTimePickerEvent } from '@react-native-community/datetimepicker'; import { Key, Smartphone, Lock, Eye, EyeOff, ShieldCheck, Monitor, Trash2, @@ -20,6 +20,7 @@ import { import { useLocaleStore } from '../../stores/locale-store'; import { useAuthStore } from '../../stores/auth-store'; import { passwordChangeOutcome } from './password-change'; +import { clientCertCopy } from './client-cert-copy'; import { AccountSecurityForbiddenError, isLimitedStalwartSignIn, @@ -50,7 +51,7 @@ import { import { generateTotpEnrolment, type TotpEnrolment } from '../../lib/totp'; // ── TLS client certificate (mobile-only) ────────────────── -// Picks an installed Android cert for mTLS handshakes. Independent of the +// Picks an installed Android cert (iOS: imports a .p12 file) for mTLS handshakes. Independent of the // Stalwart account features below, so it stays visible even on non-Stalwart // servers. function ClientCertSection() { @@ -59,6 +60,7 @@ function ClientCertSection() { const t = useLocaleStore((s) => s.t); const [certAlias, setCertAlias] = useState(null); const [certBusy, setCertBusy] = useState(false); + const copy = clientCertCopy(Platform.OS, t); useEffect(() => { void getClientCertAlias().then(setCertAlias); @@ -82,7 +84,7 @@ function ClientCertSection() { if (certBusy) return; Alert.alert( t('settings.security.client_cert.clear_title', "Stop using client certificate?"), - t('settings.security.client_cert.clear_message', "New requests will no longer present a certificate. The certificate stays installed in Android."), + copy.clearMessage, [ { text: t('common.cancel', "Cancel"), style: 'cancel' }, { @@ -105,7 +107,7 @@ function ClientCertSection() { return ( @@ -116,7 +118,7 @@ function ClientCertSection() { : t('settings.security.client_cert.not_set', "Not set")} - {t('settings.security.client_cert.hint', "Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here.")} + {copy.hint} diff --git a/src/components/settings/__tests__/client-cert-copy.test.ts b/src/components/settings/__tests__/client-cert-copy.test.ts new file mode 100644 index 00000000..78120efb --- /dev/null +++ b/src/components/settings/__tests__/client-cert-copy.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { clientCertCopy } from '../client-cert-copy'; + +// Settings > Security > TLS client certificate told iPhone users to install +// the certificate in Android settings. iOS imports a .p12/.pfx file into the +// app instead, and Clear removes it from the app. + +const t = (_key: string, fallback: string) => fallback; +const keys = (os: string) => { + const used: string[] = []; + clientCertCopy(os, (key, fallback) => { used.push(key); return fallback; }); + return used; +}; + +describe('client certificate texts', () => { + it('speaks of a certificate file on iOS', () => { + const copy = clientCertCopy('ios', t); + expect(copy.hint).toBe('Pick the certificate file (.p12 or .pfx) in Files and enter its password.'); + expect(copy.clearMessage).toContain('removed from the app'); + expect(`${copy.description} ${copy.hint} ${copy.clearMessage}`).not.toMatch(/Android/); + expect(keys('ios')).toEqual([ + 'settings.security.client_cert.description_ios', + 'settings.security.client_cert.hint_ios', + 'settings.security.client_cert.clear_message_ios', + ]); + }); + + it('keeps the Android texts on Android', () => { + expect(keys('android')).toEqual([ + 'settings.security.client_cert.description', + 'settings.security.client_cert.hint', + 'settings.security.client_cert.clear_message', + ]); + expect(clientCertCopy('android', t).clearMessage).toContain('stays installed in Android'); + }); + + it('is what the settings section shows', () => { + const src = fs.readFileSync(path.resolve(__dirname, '..', 'AccountSecuritySettings.tsx'), 'utf8'); + expect(src).toMatch(/const copy = clientCertCopy\(Platform\.OS, t\);/); + expect(src).toMatch(/description=\{copy\.description\}/); + expect(src).toMatch(/\{copy\.hint\}/); + expect(src).toMatch(/copy\.clearMessage,/); + expect(src).not.toMatch(/t\('settings\.security\.client_cert\.(hint|description|clear_message)'/); + }); +}); diff --git a/src/components/settings/client-cert-copy.ts b/src/components/settings/client-cert-copy.ts new file mode 100644 index 00000000..fa2e4dc9 --- /dev/null +++ b/src/components/settings/client-cert-copy.ts @@ -0,0 +1,28 @@ +// The texts of Settings > Security > TLS client certificate, per platform. +// Android picks a certificate installed in the system (KeyChain); iOS has no +// such store apps can pick from, so the user brings a .p12 or .pfx file that +// is imported into the app's keychain and removed again on Clear +// (plugins/ios-client-cert). + +type Translate = (key: string, fallback: string) => string; + +export interface ClientCertCopy { + description: string; + hint: string; + clearMessage: string; +} + +export function clientCertCopy(os: string, t: Translate): ClientCertCopy { + if (os === 'ios') { + return { + description: t('settings.security.client_cert.description_ios', 'Imports a certificate file to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS.'), + hint: t('settings.security.client_cert.hint_ios', 'Pick the certificate file (.p12 or .pfx) in Files and enter its password.'), + clearMessage: t('settings.security.client_cert.clear_message_ios', 'New requests will no longer present a certificate. The certificate is removed from the app; to use it again, pick the file again.'), + }; + } + return { + description: t('settings.security.client_cert.description', 'Picks an installed Android certificate to authenticate to the server during the TLS handshake. Useful when the reverse proxy enforces mTLS.'), + hint: t('settings.security.client_cert.hint', 'Install the certificate via Android Settings → Security → Encryption & credentials, then pick it here.'), + clearMessage: t('settings.security.client_cert.clear_message', 'New requests will no longer present a certificate. The certificate stays installed in Android.'), + }; +}