diff --git a/src/api/__tests__/live-settings.test.ts b/src/api/__tests__/live-settings.test.ts new file mode 100644 index 0000000..6de427d --- /dev/null +++ b/src/api/__tests__/live-settings.test.ts @@ -0,0 +1,116 @@ +/** + * @jest-environment node + */ +import { + changeMyPasswordAuthMePasswordPost, + loginAuthLoginPost, + meAuthMeGet, + registerAuthRegisterPost, + signOutEverywhereRouteAuthMeSignOutEverywherePost, + updateMeAuthMePatch, +} from '@/api/generated/endpoints/auth/auth'; +import { + getNotificationSettingsNotificationsSettingsGet, + updateNotificationSettingsNotificationsSettingsPatch, +} from '@/api/generated/endpoints/notifications/notifications'; +import { setInstanceUrl } from '@/api/instance'; +import { getAccessToken, persistToken } from '@/auth/session'; + +/** + * Account settings against a real instance. + * + * Uses a throwaway account rather than the demo one, because these change a + * password and invalidate sessions -- doing that to the account every other + * suite signs in as would be antisocial. + */ +const LIVE_URL = process.env.SOFTTRACK_LIVE_URL; +const describeLive = LIVE_URL ? describe : describe.skip; + +const RUN = Date.now().toString(36).slice(-5); +const EMAIL = `mob-settings-${RUN}@example.com`; +const PASSWORD = 'password123'; + +describeLive('account settings against a live instance', () => { + beforeAll(async () => { + await setInstanceUrl(LIVE_URL!); + const token = await registerAuthRegisterPost({ + email: EMAIL, + password: PASSWORD, + full_name: 'Settings Tester', + }); + await persistToken(token.access_token); + }); + + it('edits the profile fields the form offers', async () => { + const updated = await updateMeAuthMePatch({ + full_name: 'Renamed Tester', + username: `mob${RUN}`, + avatar_color: '#14b8a6', + }); + + expect(updated.full_name).toBe('Renamed Tester'); + expect(updated.username).toBe(`mob${RUN}`); + expect(updated.avatar_color).toBe('#14b8a6'); + }); + + it('refuses an email change without the current password', async () => { + // Which is exactly why the field only appears once the address is edited. + await expect( + updateMeAuthMePatch({ email: `changed-${RUN}@example.com` }), + ).rejects.toMatchObject({ response: { status: 400 } }); + + const withPassword = await updateMeAuthMePatch({ + email: `changed-${RUN}@example.com`, + current_password: PASSWORD, + }); + expect(withPassword.email).toBe(`changed-${RUN}@example.com`); + }); + + it('reports whether the instance can actually send email', async () => { + const settings = await getNotificationSettingsNotificationsSettingsGet(); + expect(typeof settings.email_notifications).toBe('boolean'); + // The toggle is disabled and explained when this is false, rather than + // silently doing nothing. + expect(typeof settings.email_delivery_configured).toBe('boolean'); + + const toggled = await updateNotificationSettingsNotificationsSettingsPatch({ + email_notifications: !settings.email_notifications, + }); + expect(toggled.email_notifications).toBe(!settings.email_notifications); + }); + + it('hands back a working token when the password changes', async () => { + const before = getAccessToken(); + + const reissued = await changeMyPasswordAuthMePasswordPost({ + current_password: PASSWORD, + new_password: 'password456', + }); + expect(reissued.access_token).not.toBe(before); + + // The old token is dead -- which is why the app adopts the new one rather + // than leaving the user signed out of the account they just secured. + await persistToken(before); + await expect(meAuthMeGet()).rejects.toMatchObject({ response: { status: 401 } }); + + await persistToken(reissued.access_token); + expect((await meAuthMeGet()).email).toBe(`changed-${RUN}@example.com`); + }); + + it('signs other devices out while keeping this one', async () => { + // A second session, as if from another device. + const other = await loginAuthLoginPost({ + username: `changed-${RUN}@example.com`, + password: 'password456', + }); + + const reissued = await signOutEverywhereRouteAuthMeSignOutEverywherePost(); + await persistToken(reissued.access_token); + expect((await meAuthMeGet()).email).toBe(`changed-${RUN}@example.com`); + + await persistToken(other.access_token); + await expect(meAuthMeGet()).rejects.toMatchObject({ response: { status: 401 } }); + + await persistToken(reissued.access_token); + }); +}); diff --git a/src/settings/__tests__/username.test.ts b/src/settings/__tests__/username.test.ts new file mode 100644 index 0000000..00566e5 --- /dev/null +++ b/src/settings/__tests__/username.test.ts @@ -0,0 +1,24 @@ +import { isValidUsername } from '@/settings/username'; + +describe('isValidUsername', () => { + it('accepts what the backend accepts', () => { + expect(isValidUsername('ada')).toBe(true); + expect(isValidUsername('ada.lovelace')).toBe(true); + expect(isValidUsername('ada_2')).toBe(true); + expect(isValidUsername('a1')).toBe(true); + }); + + it('refuses what it would reject', () => { + // Must start with a letter or digit. + expect(isValidUsername('.ada')).toBe(false); + expect(isValidUsername('-ada')).toBe(false); + // Lowercase only, and no spaces or @. + expect(isValidUsername('Ada')).toBe(false); + expect(isValidUsername('ada lovelace')).toBe(false); + expect(isValidUsername('ada@example.com')).toBe(false); + // Two to thirty-nine characters. + expect(isValidUsername('a')).toBe(false); + expect(isValidUsername('a'.repeat(40))).toBe(false); + expect(isValidUsername('a'.repeat(39))).toBe(true); + }); +}); diff --git a/src/settings/username.ts b/src/settings/username.ts new file mode 100644 index 0000000..fe4597c --- /dev/null +++ b/src/settings/username.ts @@ -0,0 +1,12 @@ +/** + * The username rule, mirrored from `backend/lib_identity/usernames.py`. + * + * Checked before submitting so the form answers rather than the server -- a + * round trip to be told a dash is fine but a space is not reads as a fault in + * the app. + */ +const USERNAME_PATTERN = /^[a-z0-9][a-z0-9._-]{1,38}$/; + +export function isValidUsername(username: string): boolean { + return USERNAME_PATTERN.test(username); +} diff --git a/src/settings/you-screen.tsx b/src/settings/you-screen.tsx index edad7fe..e0f5768 100644 --- a/src/settings/you-screen.tsx +++ b/src/settings/you-screen.tsx @@ -1,24 +1,51 @@ -import { ScrollView, View } from 'react-native'; +import { useState } from 'react'; +import { Alert as RNAlert, Pressable, ScrollView, Switch, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { useQueryClient } from '@tanstack/react-query'; -import { instanceLabel } from '@/api/instance'; +import { + changeMyPasswordAuthMePasswordPost, + signOutEverywhereRouteAuthMeSignOutEverywherePost, + updateMeAuthMePatch, +} from '@/api/generated/endpoints/auth/auth'; +import { + updateNotificationSettingsNotificationsSettingsPatch, + useGetNotificationSettingsNotificationsSettingsGet, +} from '@/api/generated/endpoints/notifications/notifications'; +import { errorDetail } from '@/api/errors'; +import { instanceLabel, setInstanceUrl } from '@/api/instance'; import { useAuth } from '@/auth/auth-context'; +import { isValidUsername } from '@/settings/username'; import { useIsMultiPane } from '@/ui/layout'; -import { AppText, Avatar, Button, Card } from '@/ui/primitives'; +import { AppText, Alert, Avatar, Button, Card, Field } from '@/ui/primitives'; +import { Sheet } from '@/ui/sheet'; import { ThemePreferenceControl } from '@/ui/theme-control'; import { useTokens } from '@/ui/theme'; /** - * The You destination, per `docs/design/mobile/152-settings.svg`. + * Account settings, per `docs/design/mobile/152-settings.svg`. * - * Deliberately minimal -- issue #15 owns real account settings. What is here is - * what makes issue #1's acceptance criteria exercisable from the running app: - * the theme control, the connected instance, and sign out. + * The seven the backend picks from; see AVATAR_COLORS in lib_identity. Offered + * as swatches rather than a free colour because the server will only keep one + * of these. */ +const AVATAR_COLORS = [ + '#6366f1', + '#ec4899', + '#14b8a6', + '#f59e0b', + '#8b5cf6', + '#ef4444', + '#22c55e', +]; + +type Panel = 'profile' | 'security' | 'notifications' | 'instance' | null; + export function YouScreen() { const t = useTokens(); const multiPane = useIsMultiPane(); - const { user, instanceUrl, signOut } = useAuth(); + const { user, instanceUrl, signOut, setSession } = useAuth(); + const [panel, setPanel] = useState(null); return ( ) : null} + + ACCOUNT + + setPanel('profile')} /> + setPanel('security')} /> + setPanel('notifications')} /> + + + APPEARANCE @@ -49,13 +85,12 @@ export function YouScreen() { INSTANCE - - - {instanceUrl ? instanceLabel(instanceUrl) : 'Not connected'} - - - Switching instances arrives with the settings work. - + + setPanel('instance')} + /> @@ -63,6 +98,379 @@ export function YouScreen() { Sign out + + setPanel(null)} + onSaved={setSession} + /> + setPanel(null)} + onReissued={setSession} + /> + setPanel(null)} + /> + setPanel(null)} + onSwitch={signOut} + /> ); } + +function SettingsRow({ + label, + detail, + onPress, +}: { + label: string; + detail?: string; + onPress: () => void; +}) { + const t = useTokens(); + return ( + + + {label} + + {detail ? ( + + {detail} + + ) : null} + + ); +} + +function ProfilePanel({ + visible, + onClose, + onSaved, +}: { + visible: boolean; + onClose: () => void; + onSaved: (token: string, user: import('@/api/generated/models').UserMe) => void; +}) { + const t = useTokens(); + const queryClient = useQueryClient(); + const { user } = useAuth(); + + const [fullName, setFullName] = useState(''); + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [currentPassword, setCurrentPassword] = useState(''); + const [color, setColor] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + if (!visible || !user) return null; + + const chosenColor = color ?? user.avatar_color; + const emailChanged = email.trim() !== '' && email.trim() !== user.email; + + return ( + + + {error ? {error} : null} + + + {AVATAR_COLORS.map((swatch) => ( + setColor(swatch)} + style={{ + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: swatch, + borderWidth: chosenColor === swatch ? 3 : 0, + borderColor: t.brand[600], + }} + /> + ))} + + + + setUsername(value.toLowerCase())} + autoCapitalize="none" + autoCorrect={false} + hint="Lowercase letters, digits, dot, dash or underscore. 2 to 39 characters." + /> + + + {/* Only asked for when it is actually needed, which is the one case the + server demands it. */} + {emailChanged ? ( + + ) : null} + + + + + ); +} + +function SecurityPanel({ + visible, + onClose, + onReissued, +}: { + visible: boolean; + onClose: () => void; + onReissued: (token: string, user: import('@/api/generated/models').UserMe) => void; +}) { + const [current, setCurrent] = useState(''); + const [next, setNext] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + if (!visible) return null; + + return ( + + + {error ? {error} : null} + + + + + + + + + + + ); +} + +function NotificationsPanel({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const settings = useGetNotificationSettingsNotificationsSettingsGet({ + query: { enabled: visible }, + }); + const [error, setError] = useState(null); + + if (!visible) return null; + const data = settings.data; + + return ( + + + {error ? {error} : null} + + + { + setError(null); + try { + await updateNotificationSettingsNotificationsSettingsPatch({ + email_notifications: value, + }); + await queryClient.invalidateQueries({ + queryKey: ['/notifications/settings'], + }); + } catch (err) { + setError(errorDetail(err, 'Could not change that setting.')); + } + }} + /> + + Email me about my issues + + + + {/* The toggle is meaningless where the instance cannot send email, and + silently doing nothing would be worse than saying so. */} + {data && !data.email_delivery_configured ? ( + + This instance has no email configured, so nothing would be sent. Ask + an administrator to set up SMTP. + + ) : null} + + + ); +} + +function InstancePanel({ + visible, + onClose, + onSwitch, +}: { + visible: boolean; + onClose: () => void; + onSwitch: () => void; +}) { + const { instanceUrl } = useAuth(); + if (!visible) return null; + + return ( + + + + Connected to {instanceUrl ? instanceLabel(instanceUrl) : 'nothing'}. + + + Switching signs you out of this instance. Your account here is not + affected. + + + + + ); +}