diff --git a/changelog.d/next/48-new-bitkit-profile.fixed.md b/changelog.d/next/48-new-bitkit-profile.fixed.md new file mode 100644 index 0000000000..fe406aabd9 --- /dev/null +++ b/changelog.d/next/48-new-bitkit-profile.fixed.md @@ -0,0 +1 @@ +Create profile no longer invents a random name. It prefills name, bio and links from an existing Pubky App or Bitkit profile, and leaves the name empty otherwise. When the homeserver account does not allow Pubky App data (Bitkit accounts created through Homegate), saving shows that reason instead of "Could not save profile". diff --git a/changelog.d/next/48-signin-both-qrs.changed.md b/changelog.d/next/48-signin-both-qrs.changed.md new file mode 100644 index 0000000000..7effe1f431 --- /dev/null +++ b/changelog.d/next/48-signin-both-qrs.changed.md @@ -0,0 +1 @@ +Sign in shows the Pubky Ring and Bitkit QR codes side by side, each labelled, and both stay scannable until one is approved. On mobile both "Authorize with" buttons are shown. diff --git a/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx b/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx index 2c9b6aa870..63af8f8ff3 100644 --- a/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx +++ b/src/components/organisms/CreateProfileForm/CreateProfileForm.test.tsx @@ -72,6 +72,7 @@ vi.mock('@/controllers/profile/profile', () => ({ upload: vi.fn(), create: vi.fn(), commitCreate: vi.fn(), + readProfileSeed: vi.fn(async () => null), }, })); vi.mock('@/controllers/file/file', () => ({ diff --git a/src/components/organisms/SignIn/SignIn.test.tsx b/src/components/organisms/SignIn/SignIn.test.tsx index f1cbb2c124..742faf3647 100644 --- a/src/components/organisms/SignIn/SignIn.test.tsx +++ b/src/components/organisms/SignIn/SignIn.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { useGrantSignInAvailable } from '@/hooks/useGrantSignInAvailable/useGrantSignInAvailable'; import { useMobileAuth } from '@/hooks/useMobileAuth/useMobileAuth'; @@ -536,28 +536,59 @@ describe('SignInContent - Bitkit grant sign-in', () => { expect(useMobileAuth).not.toHaveBeenCalledWith({ type: 'grant' }); }); - it('switches to a Bitkit grant QR and back to a fresh Ring QR', async () => { + it('shows the Ring and Bitkit QRs side by side, each labelled, with no link to click', async () => { vi.mocked(useGrantSignInAvailable).mockReturnValue(true); await act(async () => { render(); }); - expect(useMobileAuth).not.toHaveBeenCalledWith({ type: 'grant' }); + expect(useMobileAuth).toHaveBeenCalledWith(); + expect(useMobileAuth).toHaveBeenCalledWith({ type: 'grant' }); + const ring = screen.getByTestId('sign-in-ring-option'); + const bitkit = screen.getByTestId('sign-in-bitkit-option'); + expect(within(ring).getByText('Pubky Ring')).toBeInTheDocument(); + expect(within(ring).getByRole('button', { name: 'Copy authentication link' })).toBeInTheDocument(); + expect(within(bitkit).getByText('Bitkit')).toBeInTheDocument(); + expect(within(bitkit).getByText('Scan with Bitkit 2.5 or newer.')).toBeInTheDocument(); + expect(within(bitkit).getByRole('button', { name: 'Copy Bitkit authentication link' })).toBeInTheDocument(); + expect(screen.queryByTestId('sign-in-use-grant')).not.toBeInTheDocument(); + expect(screen.queryByTestId('sign-in-use-ring')).not.toBeInTheDocument(); + }); + + it('offers both authorize buttons on mobile', async () => { + vi.mocked(useGrantSignInAvailable).mockReturnValue(true); await act(async () => { - fireEvent.click(screen.getAllByTestId('sign-in-use-grant')[0]); + render(); }); - expect(useMobileAuth).toHaveBeenCalledWith({ type: 'grant' }); - expect(screen.getByTestId('sign-in-grant-qr-card')).toBeInTheDocument(); - expect(screen.queryByTestId('sign-in-qr-card')).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Copy Bitkit authentication link' })).toBeInTheDocument(); + expect(screen.getByText('Authorize with Pubky Ring')).toBeInTheDocument(); + expect(screen.getByText('Authorize with Bitkit')).toBeInTheDocument(); + expect(screen.getByTestId('sign-in-grant-button')).toBeInTheDocument(); + }); + + it('copying the Bitkit QR copies the Bitkit flow URL, not the Ring one', async () => { + vi.mocked(useGrantSignInAvailable).mockReturnValue(true); + const ringCopy = vi.fn().mockResolvedValue(undefined); + const bitkitCopy = vi.fn().mockResolvedValue(undefined); + vi.mocked(useMobileAuth).mockImplementation((options) => ({ + url: options?.type === 'grant' ? 'pubkyauth://signin_grant?x' : 'pubkyauth://signin?x', + isLoading: false, + isExpired: false, + fetchUrl: mockFetchUrl, + copyAuthUrl: options?.type === 'grant' ? bitkitCopy : ringCopy, + isOpeningRing: false, + onAuthorizeClick: mockOnAuthorizeClick, + })); + await act(async () => { + render(); + }); await act(async () => { - fireEvent.click(screen.getAllByTestId('sign-in-use-ring')[0]); + fireEvent.click(screen.getByRole('button', { name: 'Copy Bitkit authentication link' })); }); - expect(screen.getByTestId('sign-in-qr-card')).toBeInTheDocument(); - expect(mockFetchUrl).toHaveBeenCalledTimes(1); + expect(bitkitCopy).toHaveBeenCalledTimes(1); + expect(ringCopy).not.toHaveBeenCalled(); }); }); diff --git a/src/components/organisms/SignIn/SignIn.tsx b/src/components/organisms/SignIn/SignIn.tsx index 8e041c272f..5d2b33e915 100644 --- a/src/components/organisms/SignIn/SignIn.tsx +++ b/src/components/organisms/SignIn/SignIn.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import Image from 'next/image'; import { CheckCircle, Circle, Key, Loader2, RefreshCw } from 'lucide-react'; import { Button } from '@/atoms/Button/Button'; +import { Card } from '@/atoms/Card/Card'; import { Container } from '@/atoms/Container/Container'; import { FooterLinks } from '@/atoms/FooterLinks/FooterLinks'; import { Link } from '@/atoms/Link/Link'; @@ -107,100 +108,125 @@ async function copyWithToast(copy: () => Promise) { } } -/** - * Bitkit sign-in: a grant QR (`pubkyauth://signin_grant`). Mounting it starts - * the grant flow, which supersedes the Ring QR's flow. - */ -const SignInGrantPanel = ({ onUseRing }: { onUseRing: () => void }) => { - const { url, isLoading, isExpired, fetchUrl, copyAuthUrl, isOpeningRing, onAuthorizeClick } = useMobileAuth({ - type: 'grant', - }); - const isMobileLaunching = isLoading || isOpeningRing; +type TSignerAuth = ReturnType; + +const SIGNERS = { + ring: { + name: 'Pubky Ring', + hint: 'Scan with Pubky Ring.', + copyLabel: 'Copy authentication link', + reloadLabel: 'Reload sign-in QR code', + openingLabel: 'Opening Pubky Ring...', + showRingLogo: true, + }, + bitkit: { + name: 'Bitkit', + hint: 'Scan with Bitkit 2.5 or newer.', + copyLabel: 'Copy Bitkit authentication link', + reloadLabel: 'Reload Bitkit sign-in QR code', + openingLabel: 'Opening Bitkit...', + showRingLogo: false, + }, +} as const; + +/** One signer's labelled QR in the side-by-side desktop layout. */ +const SignInQrOption = ({ signer, auth }: { signer: keyof typeof SIGNERS; auth: TSignerAuth }) => { + const copy = SIGNERS[signer]; + const { url, isLoading, isExpired, fetchUrl, copyAuthUrl } = auth; const handleQRClick = async () => { if (!url) return; await copyWithToast(copyAuthUrl); }; - const ringSwitch = ( - + + {copy.hint} + + + ); +}; + +/** One signer's deeplink button in the stacked mobile layout. */ +const SignInAuthorizeButton = ({ signer, auth }: { signer: keyof typeof SIGNERS; auth: TSignerAuth }) => { + const copy = SIGNERS[signer]; + const { url, isLoading, isExpired, isOpeningRing, onAuthorizeClick } = auth; + const isMobileLaunching = isLoading || isOpeningRing; + return ( + ); +}; +/** + * Ring and Bitkit side by side. Each QR runs its own flow; the first approval + * wins and the controller cancels the other. + */ +const SignInBothSigners = ({ ring }: { ring: TSignerAuth }) => { + const bitkit = useMobileAuth({ type: 'grant' }); return ( <> - - - } + + - - - - - {'Scan with Bitkit 2.5 or newer.'} - - {ringSwitch} - + + + - + - - - {ringSwitch} + + + @@ -209,10 +235,10 @@ const SignInGrantPanel = ({ onUseRing }: { onUseRing: () => void }) => { }; export const SignInContent = () => { - const { url, isLoading, isExpired, fetchUrl, copyAuthUrl, isOpeningRing, onAuthorizeClick } = useMobileAuth(); + const ringAuth = useMobileAuth(); + const { url, isLoading, isExpired, fetchUrl, copyAuthUrl, isOpeningRing, onAuthorizeClick } = ringAuth; const authUrlResolved = useSignInStore((state) => state.authUrlResolved); const isGrantSignInAvailable = useGrantSignInAvailable(); - const [signer, setSigner] = useState<'ring' | 'bitkit'>('ring'); useEffect(() => { // Clear onboarding storage when sign-in flow begins to prevent backup reminders from showing for existing users useOnboardingStore.getState().reset(); @@ -252,22 +278,9 @@ export const SignInContent = () => { ); } - if (signer === 'bitkit') { - return ( - { - setSigner('ring'); - // The grant flow superseded the Ring flow; mint a fresh Ring QR. - void fetchUrl(); - }} - /> - ); + if (isGrantSignInAvailable) { + return ; } - const bitkitSwitch = isGrantSignInAvailable ? ( - - ) : null; return ( <> @@ -302,7 +315,6 @@ export const SignInContent = () => { /> - {bitkitSwitch} {/** Mobile view */} @@ -321,7 +333,6 @@ export const SignInContent = () => { > {mobileAuthorizeContent} - {bitkitSwitch} @@ -341,7 +352,7 @@ export const SignInFooter = () => { ); }; -export const SignInHeader = ({ signer = 'ring' }: { signer?: 'ring' | 'bitkit' }) => { +export const SignInHeader = ({ signer = 'ring' }: { signer?: 'ring' | 'both' }) => { return ( @@ -350,7 +361,13 @@ export const SignInHeader = ({ signer = 'ring' }: { signer?: 'ring' | 'bitkit' } {'Authorize with '} - {signer === 'bitkit' ? 'Bitkit' : 'Pubky Ring'} + {'Pubky Ring'} + {signer === 'both' ? ( + <> + {' or '} + {'Bitkit'} + + ) : null} {' to sign in.'} diff --git a/src/core/application/profile/profile.seed.test.ts b/src/core/application/profile/profile.seed.test.ts new file mode 100644 index 0000000000..423f9900d4 --- /dev/null +++ b/src/core/application/profile/profile.seed.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { USER_BIO_MAX_LENGTH, USER_MAX_LINKS, USER_NAME_MAX_LENGTH } from '@/config/user'; +import { parseBitkitProfileSeed, parsePubkyAppProfileSeed } from './profile.seed'; + +// Paykit rc55 `PaykitProfile` as Bitkit publishes it (PubkyRepo.writeProfile → +// publishPaykitProfile): display_name + image_uri, name/bio/links/tags in extra. +// Authored from paykit-rs v0.1.0-rc55 and bitkit-android master sources; no live +// Bitkit capture exists yet. +const bitkitProfile = { + display_name: 'Dusty Dolphin', + image_uri: 'pubky://example/pub/bitkit.to/bitkit/wallet/blobs/0001', + extra: { + name: 'Dusty Dolphin', + bio: 'Sells coffee', + image: null, + links: [ + { label: 'Website', url: 'https://example.com' }, + { label: 'Script', url: 'javascript:alert(1)' }, + ], + tags: ['coffee'], + }, +}; + +describe('parseBitkitProfileSeed', () => { + it('takes the name, bio and safe links from a Bitkit profile', () => { + expect(parseBitkitProfileSeed(bitkitProfile)).toEqual({ + name: 'Dusty Dolphin', + bio: 'Sells coffee', + links: [{ title: 'Website', url: 'https://example.com' }], + }); + }); + + it('falls back to extra.name when display_name is missing', () => { + expect(parseBitkitProfileSeed({ extra: { name: 'From Extra' } })?.name).toBe('From Extra'); + }); + + it('returns null when the profile carries no usable field', () => { + expect(parseBitkitProfileSeed({ display_name: ' ', image_uri: 'x' })).toBeNull(); + expect(parseBitkitProfileSeed('not a profile')).toBeNull(); + expect(parseBitkitProfileSeed(null)).toBeNull(); + }); + + it('ignores fields of the wrong type instead of failing', () => { + expect(parseBitkitProfileSeed({ display_name: 42, extra: { name: 'Typed', links: 'nope' } })).toEqual({ + name: 'Typed', + bio: '', + links: [], + }); + }); + + it('bounds every field to the Pubky App limits', () => { + const seed = parseBitkitProfileSeed({ + display_name: 'n'.repeat(USER_NAME_MAX_LENGTH + 20), + extra: { + bio: 'b'.repeat(USER_BIO_MAX_LENGTH + 20), + links: Array.from({ length: USER_MAX_LINKS + 3 }, (_, index) => ({ + label: `L${index}`, + url: `https://example.com/${index}`, + })), + }, + }); + expect(seed?.name).toHaveLength(USER_NAME_MAX_LENGTH); + expect(seed?.bio).toHaveLength(USER_BIO_MAX_LENGTH); + expect(seed?.links).toHaveLength(USER_MAX_LINKS); + }); +}); + +describe('parsePubkyAppProfileSeed', () => { + it('takes the name, bio and links from a Pubky App profile', () => { + expect( + parsePubkyAppProfileSeed({ + name: 'Alice', + bio: 'Hi', + image: null, + links: [{ title: 'Site', url: 'https://alice.example' }], + status: '', + }), + ).toEqual({ name: 'Alice', bio: 'Hi', links: [{ title: 'Site', url: 'https://alice.example' }] }); + }); + + it('returns null for an empty profile', () => { + expect(parsePubkyAppProfileSeed({ name: '', bio: '', links: [] })).toBeNull(); + }); +}); diff --git a/src/core/application/profile/profile.seed.ts b/src/core/application/profile/profile.seed.ts new file mode 100644 index 0000000000..8632773227 --- /dev/null +++ b/src/core/application/profile/profile.seed.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; +import { + USER_BIO_MAX_LENGTH, + USER_LINK_LABEL_MAX_LENGTH, + USER_LINK_URL_MAX_LENGTH, + USER_MAX_LINKS, + USER_NAME_MAX_LENGTH, +} from '@/config/user'; +import { getSafeExternalUrl } from '@/libs/utils/safeExternalUrl'; +import type { TProfileSeed } from './profile.types'; + +/** The Pubky App profile. Present means the account already finished onboarding elsewhere. */ +export const PUBKY_APP_PROFILE_PATH = '/pub/pubky.app/profile.json'; + +/** + * Bitkit's public profile, newest layout first: Paykit rc55 writes under the + * `bitkit/wallet` receiver, earlier Paykit releases (Bitkit 2.4.x store + * builds) at the namespace root. Both use the `bitkit.to` mainnet namespace. + */ +export const BITKIT_PROFILE_PATHS = [ + '/pub/bitkit.to/bitkit/wallet/profile.json', + '/pub/bitkit.to/profile.json', +] as const; + +const optionalText = z + .unknown() + .optional() + .transform((value) => (typeof value === 'string' ? value.trim() : '')); + +const linkSchema = z + .object({ title: optionalText, label: optionalText, url: optionalText }) + .passthrough() + .transform(({ title, label, url }) => ({ title: title || label, url })); + +const linksSchema = z + .unknown() + .optional() + .transform((value) => { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + const parsed = linkSchema.safeParse(entry); + return parsed.success ? [parsed.data] : []; + }); + }); + +const pubkyAppProfileSchema = z.object({ name: optionalText, bio: optionalText, links: linksSchema }).passthrough(); + +/** Paykit `PaykitProfile` (`display_name`, `image_uri`, `extra`); Bitkit keeps name, bio and links in `extra`. */ +const bitkitProfileSchema = z + .object({ + display_name: optionalText, + extra: z + .unknown() + .optional() + .transform((value) => pubkyAppProfileSchema.safeParse(value ?? {})) + .transform((parsed) => (parsed.success ? parsed.data : { name: '', bio: '', links: [] })), + }) + .passthrough(); + +function toSeed({ name, bio, links }: { name: string; bio: string; links: { title: string; url: string }[] }) { + const seed: TProfileSeed = { + name: name.slice(0, USER_NAME_MAX_LENGTH), + bio: bio.slice(0, USER_BIO_MAX_LENGTH), + links: links + .flatMap(({ title, url }) => { + const safeUrl = url.length <= USER_LINK_URL_MAX_LENGTH ? getSafeExternalUrl(url) : null; + return title && safeUrl ? [{ title: title.slice(0, USER_LINK_LABEL_MAX_LENGTH), url: safeUrl }] : []; + }) + .slice(0, USER_MAX_LINKS), + }; + return seed.name || seed.bio || seed.links.length > 0 ? seed : null; +} + +/** Name, bio and links from a Pubky App `profile.json`, or null when it carries none. */ +export function parsePubkyAppProfileSeed(json: unknown): TProfileSeed | null { + const parsed = pubkyAppProfileSchema.safeParse(json); + return parsed.success ? toSeed(parsed.data) : null; +} + +/** Name, bio and links from a Bitkit (Paykit) profile, or null when it carries none. */ +export function parseBitkitProfileSeed(json: unknown): TProfileSeed | null { + const parsed = bitkitProfileSchema.safeParse(json); + if (!parsed.success) return null; + const { display_name: displayName, extra } = parsed.data; + return toSeed({ name: displayName || extra.name, bio: extra.bio, links: extra.links }); +} diff --git a/src/core/application/profile/profile.test.ts b/src/core/application/profile/profile.test.ts index e6c0473f15..e0256b71f8 100644 --- a/src/core/application/profile/profile.test.ts +++ b/src/core/application/profile/profile.test.ts @@ -29,6 +29,7 @@ vi.mock('@/services/homeserver/homeserver', () => ({ HomeserverService: { putBlob: vi.fn(), request: vi.fn(), + exists: vi.fn(), }, })); @@ -268,4 +269,62 @@ describe('ProfileApplication', () => { ); }); }); + + describe('readProfileSeed', () => { + const pubky = 'seedpubky' as Pubky; + const pubkyAppUrl = `pubky://${pubky}/pub/pubky.app/profile.json`; + const bitkitUrl = `pubky://${pubky}/pub/bitkit.to/bitkit/wallet/profile.json`; + const bitkitLegacyUrl = `pubky://${pubky}/pub/bitkit.to/profile.json`; + + function withFiles(files: Record) { + vi.mocked(HomeserverService.exists).mockImplementation(async (url: string) => url in files); + vi.mocked(HomeserverService.request).mockImplementation(async ({ url }) => files[url] as never); + } + + it('prefers an existing Pubky App profile', async () => { + withFiles({ [pubkyAppUrl]: { name: 'Alice' }, [bitkitUrl]: { display_name: 'Bitkit Alice' } }); + + await expect(ProfileApplication.readProfileSeed({ pubky })).resolves.toEqual({ + name: 'Alice', + bio: '', + links: [], + }); + }); + + it('falls back to the Bitkit profile, newest layout first', async () => { + withFiles({ [bitkitUrl]: { display_name: 'Rc55 Name' }, [bitkitLegacyUrl]: { display_name: 'Rc31 Name' } }); + + await expect(ProfileApplication.readProfileSeed({ pubky })).resolves.toMatchObject({ name: 'Rc55 Name' }); + expect(HomeserverService.request).toHaveBeenCalledWith({ method: HttpMethod.GET, url: bitkitUrl }); + }); + + it('reads the Bitkit 2.4 layout when only it exists', async () => { + withFiles({ [bitkitLegacyUrl]: { display_name: 'Rc31 Name' } }); + + await expect(ProfileApplication.readProfileSeed({ pubky })).resolves.toMatchObject({ name: 'Rc31 Name' }); + }); + + it('returns null when no profile exists, without reading any file', async () => { + withFiles({}); + + await expect(ProfileApplication.readProfileSeed({ pubky })).resolves.toBeNull(); + expect(HomeserverService.request).not.toHaveBeenCalled(); + }); + + it('never rejects: a failed read moves on to the next source', async () => { + vi.mocked(HomeserverService.exists).mockImplementation(async (url: string) => { + if (url === pubkyAppUrl) throw new Error('network'); + return url === bitkitUrl; + }); + vi.mocked(HomeserverService.request).mockResolvedValue({ display_name: 'After Failure' } as never); + const { Logger: loadedLogger } = await import('@/libs/logger/logger'); + const warnSpy = vi.spyOn(loadedLogger, 'warn').mockImplementation(() => {}); + + await expect(ProfileApplication.readProfileSeed({ pubky })).resolves.toMatchObject({ name: 'After Failure' }); + expect(warnSpy).toHaveBeenCalledWith(expect.any(String), { + path: '/pub/pubky.app/profile.json', + code: 'unknown', + }); + }); + }); }); diff --git a/src/core/application/profile/profile.ts b/src/core/application/profile/profile.ts index 1c8fa90d0e..189eef76a9 100644 --- a/src/core/application/profile/profile.ts +++ b/src/core/application/profile/profile.ts @@ -1,15 +1,22 @@ import JSZip from 'jszip'; import { baseUriBuilder } from 'pubky-app-specs'; +import { + BITKIT_PROFILE_PATHS, + parseBitkitProfileSeed, + parsePubkyAppProfileSeed, + PUBKY_APP_PROFILE_PATH, +} from '@/application/profile/profile.seed'; import type { TApplicationCommitUpdateDetailsParams, TCreateProfileInput, TDeleteAccountParams, TDownloadDataParams, + TProfileSeed, } from '@/application/profile/profile.types'; import { ClientErrorCode } from '@/libs/error/error.codes'; import { Err } from '@/libs/error/error.factories'; import { ErrorService } from '@/libs/error/error.types'; -import { hasHttpStatus } from '@/libs/error/error.utils'; +import { hasHttpStatus, isAppError } from '@/libs/error/error.utils'; import { HttpMethod, HttpStatusCode } from '@/libs/http/http.types'; import { Logger } from '@/libs/logger/logger'; import { sleep } from '@/libs/utils/utils'; @@ -49,6 +56,32 @@ export class ProfileApplication { } } + /** + * Prefill for Create profile from a profile the account already published: + * the Pubky App `profile.json` first, then Bitkit's profile. Best-effort: + * never rejects, and returns null when no source has a usable field. + */ + static async readProfileSeed({ pubky }: { pubky: Pubky }): Promise { + const sources = [ + { path: PUBKY_APP_PROFILE_PATH, parse: parsePubkyAppProfileSeed }, + ...BITKIT_PROFILE_PATHS.map((path) => ({ path, parse: parseBitkitProfileSeed })), + ]; + for (const { path, parse } of sources) { + const url = `pubky://${pubky}${path}`; + try { + if (!(await HomeserverService.exists(url))) continue; + const seed = parse(await HomeserverService.request({ method: HttpMethod.GET, url })); + if (seed) return seed; + } catch (error) { + Logger.warn('[ProfileApplication] Existing profile could not be read for prefill', { + path, + code: isAppError(error) ? error.code : 'unknown', + }); + } + } + return null; + } + /** * Updates full user profile in both homeserver and local database. * Follows local-first pattern: updates homeserver first, then local DB. diff --git a/src/core/application/profile/profile.types.ts b/src/core/application/profile/profile.types.ts index aaba52089f..eaf4c86aa7 100644 --- a/src/core/application/profile/profile.types.ts +++ b/src/core/application/profile/profile.types.ts @@ -23,6 +23,13 @@ export type TDownloadDataParams = { setProgress?: (progress: number) => void; }; +/** Text fields an existing profile can prefill into Create profile. */ +export type TProfileSeed = { + name: string; + bio: string; + links: NexusUserLink[]; +}; + export type TApplicationCommitUpdateDetailsParams = { pubky: Pubky; name: string; diff --git a/src/core/controllers/auth/auth.single-approval.test.ts b/src/core/controllers/auth/auth.single-approval.test.ts index c77cc7c916..5b57bda70a 100644 --- a/src/core/controllers/auth/auth.single-approval.test.ts +++ b/src/core/controllers/auth/auth.single-approval.test.ts @@ -580,3 +580,111 @@ describe('AuthController single-approval ceremony', () => { }); }); }); + +describe('AuthController Ring and Bitkit QRs side by side', () => { + const grantSession = asOpaque({ + info: { publicKey: { z32: () => 'test-pubky' } }, + grant: {}, + }); + + function mockGrantFlow(awaitApproval: Promise, cancelAuthFlow = vi.fn()) { + vi.spyOn(AuthApplication, 'generateGrantAuthUrl').mockResolvedValue({ + authorizationUrl: 'pubkyauth://signin_grant?caps=x&relay=r&secret=s&cid=shop.pubky.app&cpk=k', + awaitApproval, + cancelAuthFlow, + }); + return cancelAuthFlow; + } + + beforeEach(() => { + vi.restoreAllMocks(); + resetAuthFinalizationLockForTests(); + mockClearDatabase.mockReset(); + mockClearDatabase.mockResolvedValue(undefined); + AuthController.resetSignInCeremonyGuard(); + AuthController.resetCleanupLocalStateGuard(); + AuthController.cancelAllAuthFlows(); + vi.spyOn(BootstrapApplication, 'cancelModerationFollow').mockImplementation(() => {}); + vi.spyOn(useMigrationStore, 'getState').mockReturnValue(mockMigrationStore({ reset: vi.fn() })); + }); + + it('starting the Bitkit QR keeps the Ring ceremony live', async () => { + const ringCancel = vi.fn(); + mockDirectSignInFlow({ awaitToken: () => new Promise(() => {}), cancelAuthFlow: ringCancel }); + const ring = await AuthController.getAuthUrl(); + ring.awaitApproval.catch(() => {}); + + mockGrantFlow(new Promise(() => {})); + await AuthController.getGrantAuthUrl(); + + expect(ringCancel).not.toHaveBeenCalled(); + // The Ring ceremony guard is still held: a second getAuthUrl joins it. + const joined = await AuthController.getAuthUrl(); + expect(joined.authorizationUrl).toBe(ring.authorizationUrl); + }); + + it('starting the Ring QR keeps the Bitkit flow live', async () => { + const grantCancel = mockGrantFlow(new Promise(() => {})); + await AuthController.getGrantAuthUrl(); + + mockDirectSignInFlow({ awaitToken: () => new Promise(() => {}) }); + const ring = await AuthController.getAuthUrl(); + ring.awaitApproval.catch(() => {}); + + expect(grantCancel).not.toHaveBeenCalled(); + }); + + it('a completed sign-in cancels both QRs', async () => { + const ringCancel = vi.fn(); + mockDirectSignInFlow({ awaitToken: () => new Promise(() => {}), cancelAuthFlow: ringCancel }); + const ring = await AuthController.getAuthUrl(); + ring.awaitApproval.catch(() => {}); + const grantCancel = mockGrantFlow(new Promise(() => {})); + await AuthController.getGrantAuthUrl(); + + AuthController.cancelAllAuthFlows(); + + expect(ringCancel).toHaveBeenCalled(); + expect(grantCancel).toHaveBeenCalled(); + }); + + it('a Bitkit approval that settles after another sign-in won is signed out, not returned', async () => { + let approve!: (session: Session) => void; + mockGrantFlow( + new Promise((resolve) => { + approve = resolve; + }), + ); + const logoutSpy = vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + const { awaitApproval } = await AuthController.getGrantAuthUrl(); + + AuthController.cancelAllAuthFlows(); + approve(grantSession); + + await expect(awaitApproval).rejects.toMatchObject({ name: 'AuthFlowCanceled' }); + expect(logoutSpy).toHaveBeenCalledWith({ session: grantSession }); + }); + + it('a Bitkit approval with no competing sign-in is returned', async () => { + mockGrantFlow(Promise.resolve(grantSession)); + const logoutSpy = vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + const { awaitApproval } = await AuthController.getGrantAuthUrl(); + + await expect(awaitApproval).resolves.toBe(grantSession); + expect(logoutSpy).not.toHaveBeenCalled(); + }); + + it('releasing the Bitkit handle cancels only the Bitkit flow', async () => { + const ringCancel = vi.fn(); + mockDirectSignInFlow({ awaitToken: () => new Promise(() => {}), cancelAuthFlow: ringCancel }); + const ring = await AuthController.getAuthUrl(); + ring.awaitApproval.catch(() => {}); + const grantCancel = mockGrantFlow(new Promise(() => {})); + const grant = await AuthController.getGrantAuthUrl(); + + AuthController.releaseAuthFlow(grant.cancelAuthFlow); + + expect(grantCancel).toHaveBeenCalled(); + expect(ringCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/src/core/controllers/auth/auth.test.ts b/src/core/controllers/auth/auth.test.ts index d11513c19b..8e3067659c 100644 --- a/src/core/controllers/auth/auth.test.ts +++ b/src/core/controllers/auth/auth.test.ts @@ -1,4 +1,4 @@ -import type { Session } from '@synonymdev/pubky'; +import type { AuthToken, Session } from '@synonymdev/pubky'; import { LastReadResult } from 'pubky-app-specs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthApplication } from '@/application/auth/auth'; @@ -2478,6 +2478,139 @@ describe('AuthController', () => { expect(saveSpy).not.toHaveBeenCalled(); }); + describe('first approval wins between the Ring and Bitkit QRs', () => { + const marketplaceBearer = { + pubky: TEST_PUBKY as Pubky, + capabilities: '', + expiresAt: '2026-10-01T00:00:00.000Z', + issuedAt: '2026-09-24T00:00:00.000Z', + }; + + function mockRingCeremony(ceremony: Promise<{ session: Session; marketplace: typeof marketplaceBearer | null }>) { + vi.spyOn(AuthApplication, 'startDirectSignInFlow').mockReturnValue({ + authorizationUrl: 'pubkyauth://signin?caps=x&relay=r&secret=s', + awaitToken: async () => asOpaque({}), + cancelAuthFlow: vi.fn(), + }); + vi.spyOn(AuthApplication, 'completeSingleApprovalCeremony').mockReturnValue( + ceremony.then((result) => ({ ...result, marketplaceError: null })), + ); + } + + beforeEach(() => { + AuthController.resetSignInCeremonyGuard(); + AuthController.cancelAllAuthFlows(); + }); + + it('a late Ring approval for the same identity cannot replace a Bitkit sign-in that won', async () => { + const grant = grantSession(); + const ring = buildMockSession(); + const authStore = grantAuthStore(); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(authStore); + vi.spyOn(AuthApplication, 'saveGrantSession').mockResolvedValue('rec-1'); + const removeSpy = vi.spyOn(AuthApplication, 'removeGrantSession').mockResolvedValue(undefined); + const logoutSpy = vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + const clearSpy = vi.spyOn(CommerceController, 'clearMarketplaceSession'); + mockRingCeremony(Promise.resolve({ session: ring, marketplace: marketplaceBearer })); + + // Both approvals are in hand before either completes. + const ringApproval = await (await AuthController.getAuthUrl()).awaitApproval; + const grantApproval = await approveGrantSignIn(grant); + + await AuthController.initializeAuthenticatedSession({ session: grantApproval }); + await expect(AuthController.initializeAuthenticatedSession({ session: ringApproval })).rejects.toMatchObject({ + name: 'AuthFlowCanceled', + }); + + expect(authStore.init).toHaveBeenCalledTimes(1); + expect(authStore.init).toHaveBeenCalledWith( + expect.objectContaining({ session: grant, grantSessionRecordId: 'rec-1' }), + ); + expect(authStore.reset).not.toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + expect(logoutSpy).toHaveBeenCalledWith({ session: ring }); + expect(logoutSpy).not.toHaveBeenCalledWith({ session: grant }); + expect(clearSpy).toHaveBeenCalledTimes(1); + }); + + it('a late Ring approval for another identity is signed out without touching the Bitkit sign-in', async () => { + const grant = grantSession(); + const ring = buildMockSession(); + vi.spyOn(Identity, 'z32FromSession').mockImplementation( + ({ session }) => (session === ring ? 'other-pubky' : TEST_PUBKY) as Pubky, + ); + const authStore = grantAuthStore(); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(authStore); + vi.spyOn(AuthApplication, 'saveGrantSession').mockResolvedValue('rec-1'); + const logoutSpy = vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + mockRingCeremony(Promise.resolve({ session: ring, marketplace: null })); + + const ringApproval = await (await AuthController.getAuthUrl()).awaitApproval; + await AuthController.initializeAuthenticatedSession({ session: await approveGrantSignIn(grant) }); + await expect(AuthController.initializeAuthenticatedSession({ session: ringApproval })).rejects.toMatchObject({ + name: 'AuthFlowCanceled', + }); + + expect(authStore.init).toHaveBeenCalledTimes(1); + expect(authStore.reset).not.toHaveBeenCalled(); + expect(logoutSpy).toHaveBeenCalledWith({ session: ring }); + }); + + it('a Ring approval still inside its dual POST when Bitkit wins never writes its bearer', async () => { + const grant = grantSession(); + const ring = buildMockSession(); + const authStore = grantAuthStore(); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(authStore); + vi.spyOn(AuthApplication, 'saveGrantSession').mockResolvedValue('rec-1'); + const logoutSpy = vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + const writeSpy = vi.spyOn(CommerceController, 'writeMarketplaceSessionStore'); + const clearSpy = vi.spyOn(CommerceController, 'clearMarketplaceSession'); + let finishDualPost!: (result: { session: Session; marketplace: typeof marketplaceBearer }) => void; + mockRingCeremony( + new Promise((resolve) => { + finishDualPost = resolve; + }), + ); + + const { awaitApproval: ringApproval } = await AuthController.getAuthUrl(); + await vi.waitFor(() => expect(AuthApplication.completeSingleApprovalCeremony).toHaveBeenCalled()); + await AuthController.initializeAuthenticatedSession({ session: await approveGrantSignIn(grant) }); + finishDualPost({ session: ring, marketplace: marketplaceBearer }); + + await expect(ringApproval).rejects.toMatchObject({ name: 'AuthFlowCanceled' }); + expect(writeSpy).not.toHaveBeenCalled(); + expect(clearSpy).toHaveBeenCalledTimes(1); + expect(logoutSpy).toHaveBeenCalledWith({ session: ring }); + expect(authStore.init).toHaveBeenCalledTimes(1); + }); + + it('a late Bitkit approval cannot replace a Ring sign-in that won, and keeps the Ring bearer', async () => { + const grant = grantSession(); + const ring = buildMockSession(); + const authStore = grantAuthStore(); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(authStore); + const saveSpy = vi.spyOn(AuthApplication, 'saveGrantSession').mockResolvedValue('rec-1'); + const logoutSpy = vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + const clearSpy = vi.spyOn(CommerceController, 'clearMarketplaceSession'); + mockRingCeremony(Promise.resolve({ session: ring, marketplace: marketplaceBearer })); + + const ringApproval = await (await AuthController.getAuthUrl()).awaitApproval; + const grantApproval = await approveGrantSignIn(grant); + + await AuthController.initializeAuthenticatedSession({ session: ringApproval }); + await expect(AuthController.initializeAuthenticatedSession({ session: grantApproval })).rejects.toMatchObject({ + name: 'AuthFlowCanceled', + }); + + expect(authStore.init).toHaveBeenCalledTimes(1); + expect(authStore.init).toHaveBeenCalledWith(expect.objectContaining({ session: ring })); + expect(saveSpy).not.toHaveBeenCalled(); + expect(logoutSpy).toHaveBeenCalledWith({ session: grant }); + expect(logoutSpy).not.toHaveBeenCalledWith({ session: ring }); + expect(clearSpy).not.toHaveBeenCalled(); + }); + }); + it('signout calls signout then clearAll under lock', async () => { const order: string[] = []; const session = grantSession(); diff --git a/src/core/controllers/auth/auth.ts b/src/core/controllers/auth/auth.ts index f7582eeec7..bfb2d1e3b3 100644 --- a/src/core/controllers/auth/auth.ts +++ b/src/core/controllers/auth/auth.ts @@ -76,6 +76,20 @@ export class AuthController { private static activeAuthFlow: { token: symbol; cancel: (() => void) | null } | null = null; + /** + * The Bitkit grant QR lives beside the Ring QR on /sign-in, so it has its + * own slot: starting one never cancels the other. Both are cancelled when + * any sign-in completes, on local-state cleanup, and on cross-tab sign-out. + */ + private static activeGrantFlow: { token: symbol; cancel: (() => void) | null } | null = null; + + /** + * Bumped whenever every auth flow is cancelled. A grant approval that + * settles after a bump lost to another sign-in (or a sign-out) and is + * signed out instead of initialized. + */ + private static authFlowGeneration = 0; + /** * Covers QR wait AND both POSTs. wrapAuthFlow is not this lifetime: it * clears when awaitApproval/awaitToken settles, which is when the POSTs begin. @@ -111,6 +125,9 @@ export class AuthController { /** `authEpoch` read when each grant sign-in QR started, keyed by its approved session. */ private static grantEpochAtStart = new WeakMap(); + /** `authFlowGeneration` when each sign-in QR (Ring or Bitkit) started, keyed by its approved session. */ + private static sessionFlowGeneration = new WeakMap(); + /** * Single-run guard for cleanupLocalState: concurrent Controller invocations * (e.g. a logout racing an in-flight restore) share one run, and once a run @@ -157,6 +174,10 @@ export class AuthController { this.cancelActiveAuthFlow(); return; } + if (this.activeGrantFlow && this.activeGrantFlow.cancel === cancelAuthFlow) { + this.cancelActiveGrantFlow(); + return; + } cancelAuthFlow(); } @@ -189,6 +210,42 @@ export class AuthController { cancel?.(); } + private static cancelActiveGrantFlow() { + const cancel = this.activeGrantFlow?.cancel; + this.activeGrantFlow = null; + cancel?.(); + } + + /** Ring and Bitkit flows alike: a completed sign-in or a sign-out ends both QRs. */ + static cancelAllAuthFlows() { + this.authFlowGeneration += 1; + this.cancelActiveAuthFlow(); + this.cancelActiveGrantFlow(); + } + + /** A QR approval whose flow started before the latest cancelAllAuthFlows lost to another sign-in or a sign-out. */ + private static lostToAnotherSignIn(session: Session): boolean { + const startedAt = this.sessionFlowGeneration.get(session); + return startedAt !== undefined && startedAt !== this.authFlowGeneration; + } + + /** + * First approval wins. The losing session is signed out and never + * initialized; a losing Ring completion also drops the marketplace bearer + * its dual POST minted (a grant approval mints none, so a losing Bitkit + * approval never touches the winner's bearer). The winner's session and + * grant record are left alone. + */ + private static async discardLosingApproval(session: Session): Promise { + if (!AuthApplication.isGrantSession(session)) { + CommerceController.clearMarketplaceSession(); + } + await AuthApplication.logout({ session }).catch(() => { + Logger.warn('Failed to sign out an approval that lost to another sign-in'); + }); + throw createCanceledError(); + } + /** * Restores a persisted session from the auth store. * @returns Discriminated restore outcome for callers (logout vs route restore) @@ -424,6 +481,9 @@ export class AuthController { } private static async runInitializeAuthenticatedSession({ session }: THomeserverSessionResult) { + if (this.lostToAnotherSignIn(session)) { + return await this.discardLosingApproval(session); + } try { try { await AuthApplication.assertUserHomeserverAllowed({ publicKey: session.info.publicKey }); @@ -438,6 +498,9 @@ export class AuthController { } await this.completeAuthenticatedSession({ session }); } catch (error) { + // A completion that lost to another sign-in was already cleaned up by + // discardLosingApproval; the bearer at rest now belongs to the winner. + if (this.lostToAnotherSignIn(session)) throw error; // The marketplace half of the ceremony may already have minted (and // persisted) a bearer for this approval; a sign-in that does NOT commit // — refused by the environment check OR failed anywhere in the @@ -452,6 +515,12 @@ export class AuthController { * guard already passed for this session. */ private static async completeAuthenticatedSession({ session }: THomeserverSessionResult) { + // Checked and claimed with no await in between: of two approvals that both + // reach here, the first bumps the generation (cancelAllAuthFlows below) + // and the second is discarded before it can touch any store. + if (this.lostToAnotherSignIn(session)) { + return await this.discardLosingApproval(session); + } const signInStore = useSignInStore.getState(); signInStore.reset(); // Reset for fresh sign-in signInStore.setAuthUrlResolved(true); // Step 1 complete (20%) @@ -460,7 +529,9 @@ export class AuthController { let persistAborted = false; try { - this.cancelActiveAuthFlow(); + this.cancelAllAuthFlows(); + // This approval won; only the approvals it just cancelled count as lost. + this.sessionFlowGeneration.delete(session); const pubky = Identity.z32FromSession({ session }); // Identity persist takes the finalization lock so a visitor tab's @@ -754,8 +825,7 @@ export class AuthController { // Clear in-memory feed stream queues postStreamQueue.clear(); - // Cancel active auth flows - this.cancelActiveAuthFlow(); + this.cancelAllAuthFlows(); // Cancel and clear all query clients (nexus, homegate, exchangerate, and any future ones) clearAllQueryClients(); @@ -807,19 +877,46 @@ export class AuthController { /** * Bitkit sign-in: a grant QR (`pubkyauth://signin_grant`) beside the Ring - * cookie QR. The approved grant session skips the marketplace redeem (it - * carries no AuthToken) and is saved to BrowserSessionStore at completion. + * cookie QR, in its own slot so both stay scannable. The approved grant + * session skips the marketplace redeem (it carries no AuthToken) and is + * saved to BrowserSessionStore at completion. */ static async getGrantAuthUrl(): Promise { const epochAtStart = readAuthEpoch(); - const result = await this.wrapAuthFlow(() => AuthApplication.generateGrantAuthUrl()); - return { - ...result, - awaitApproval: result.awaitApproval.then((session) => { + BootstrapApplication.cancelModerationFollow(); + const captured = this.captureAuthIdentity(); + if (!(await this.takeLocalStateForCapturedIdentity(captured))) { + throw createCanceledError(); + } + const token = Symbol('grant-flow'); + this.cancelActiveGrantFlow(); + this.activeGrantFlow = { token, cancel: null }; + const generationAtStart = this.authFlowGeneration; + const { authorizationUrl, awaitApproval, cancelAuthFlow } = await AuthApplication.generateGrantAuthUrl(); + + if (!this.activeGrantFlow || this.activeGrantFlow.token !== token) { + cancelAuthFlow(); + return { authorizationUrl, awaitApproval, cancelAuthFlow }; + } + this.activeGrantFlow.cancel = cancelAuthFlow; + + const wrappedAwaitApproval = awaitApproval + .finally(() => { + if (this.activeGrantFlow?.token === token) { + this.activeGrantFlow = null; + } + cancelAuthFlow(); + }) + .then(async (session) => { + this.sessionFlowGeneration.set(session, generationAtStart); + if (this.lostToAnotherSignIn(session)) { + return await this.discardLosingApproval(session); + } this.grantEpochAtStart.set(session, epochAtStart); return session; - }), - }; + }); + + return { authorizationUrl, awaitApproval: wrappedAwaitApproval, cancelAuthFlow }; } static isGrantSignInAvailable(): boolean { @@ -995,6 +1092,7 @@ export class AuthController { } this.activeAuthFlow = { token, cancel: null }; + const generationAtStart = this.authFlowGeneration; const flow = AuthApplication.startDirectSignInFlow(); if (!this.activeAuthFlow || this.activeAuthFlow.token !== token) { flow.cancelAuthFlow(); @@ -1014,6 +1112,14 @@ export class AuthController { onHomeserverSession: async (session) => this.assertStepUpSessionMatchesSignedInUser({ session }), }) : await AuthApplication.completeSingleApprovalCeremony(authToken); + if (!preserveLocalState) { + // A Bitkit sign-in (or a sign-out) may have won while this + // approval was inside its dual POST. + this.sessionFlowGeneration.set(result.session, generationAtStart); + if (this.lostToAnotherSignIn(result.session)) { + return await this.discardLosingApproval(result.session); + } + } if (result.marketplace) { CommerceController.writeMarketplaceSessionStore(result.marketplace); } @@ -1258,7 +1364,7 @@ export class AuthController { await AuthApplication.logout({ session }).catch((error) => { Logger.warn('Cross-tab grant sign-out could not reach the homeserver', { error }); }); - this.cancelActiveAuthFlow(); + this.cancelAllAuthFlows(); if (grantSessionRecordId) { try { // Only this tab's record: a newer sign-in in another tab keeps its key. diff --git a/src/core/controllers/profile/profile.ts b/src/core/controllers/profile/profile.ts index c6e0a736ee..97352786de 100644 --- a/src/core/controllers/profile/profile.ts +++ b/src/core/controllers/profile/profile.ts @@ -36,6 +36,14 @@ export class ProfileController { await ProfileApplication.commitCreate({ profile: user, url: meta.url, pubky }); } + /** + * Name, bio and links from a profile the account already published (Pubky + * App or Bitkit), for prefilling Create profile. Null when there is none. + */ + static async readProfileSeed({ pubky }: { pubky: Pubky }) { + return await ProfileApplication.readProfileSeed({ pubky }); + } + /** * Commits the update status operation to the homeserver and local database. * @param pubky - The public key of the user diff --git a/src/core/services/homeserver/error.utils.write-path.test.ts b/src/core/services/homeserver/error.utils.write-path.test.ts new file mode 100644 index 0000000000..510139d538 --- /dev/null +++ b/src/core/services/homeserver/error.utils.write-path.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest'; +import { AppError } from '@/libs/error/error'; +import { AuthErrorCode } from '@/libs/error/error.codes'; +import { isAuthError, isWritePathNotAllowedError, requiresLogin } from '@/libs/error/error.utils'; +import { Logger } from '@/libs/logger/logger'; +import { handleError } from './error.utils'; + +// Captured 2026-09-24 from @synonymdev/pubky 0.11.0 `session.storage.putJson` on +// homeserver.pubky.app for a Homegate IP-signup account (grant and root cookie +// sessions alike): PUT /pub/pubky.app/profile.json. +const writePathForbidden = Object.assign( + new Error('Request failed: Server responded with an error: 403 Forbidden - Write to this path is not allowed'), + { name: 'RequestError', data: { statusCode: 403 } }, +); + +// pubky-homeserver `authorization.rs` 403 for a session whose capabilities do not cover the path. +const capabilityForbidden = Object.assign( + new Error( + 'Request failed: Server responded with an error: 403 Forbidden - Session does not have write access to path', + ), + { name: 'RequestError', data: { statusCode: 403 } }, +); + +function mapped(error: unknown): AppError { + vi.spyOn(Logger, 'error').mockImplementation(() => {}); + try { + handleError({ error, additionalContext: { url: 'pubky://u/pub/pubky.app/profile.json', method: 'PUT' } }); + } catch (appError) { + return appError as AppError; + } + throw new Error('handleError did not throw'); +} + +describe('homeserver write-path 403', () => { + it('the account allow-list 403 maps to an auth error the profile form can name', () => { + const error = mapped(writePathForbidden); + + expect(isAuthError(error)).toBe(true); + expect(error.code).toBe(AuthErrorCode.FORBIDDEN); + expect(requiresLogin(error)).toBe(false); + expect(isWritePathNotAllowedError(error)).toBe(true); + }); + + it('a capability 403 is not reported as an account restriction', () => { + const error = mapped(capabilityForbidden); + + expect(isAuthError(error)).toBe(true); + expect(isWritePathNotAllowedError(error)).toBe(false); + }); +}); diff --git a/src/hooks/useAuthUrl/useAuthUrl.test.tsx b/src/hooks/useAuthUrl/useAuthUrl.test.tsx index 77392a3532..8f01f22b50 100644 --- a/src/hooks/useAuthUrl/useAuthUrl.test.tsx +++ b/src/hooks/useAuthUrl/useAuthUrl.test.tsx @@ -308,6 +308,25 @@ describe('useAuthUrl', () => { expect(mockLoggerError).not.toHaveBeenCalled(); }); + it('stays silent when the approval lost to another sign-in', async () => { + mockGetAuthUrl.mockResolvedValue({ + authorizationUrl: 'pubkyring://authorize?token=lost', + awaitApproval: Promise.resolve(mockSession()), + cancelAuthFlow: createCancelAuthFlow(), + }); + mockInitializeAuthenticatedSession.mockRejectedValue( + Object.assign(new Error('Auth flow canceled'), { name: 'AuthFlowCanceled' }), + ); + + const { result } = renderHook(() => useAuthUrl()); + + await waitFor(() => expect(mockInitializeAuthenticatedSession).toHaveBeenCalled()); + await act(async () => {}); + expect(mockToast).not.toHaveBeenCalled(); + expect(mockLoggerError).not.toHaveBeenCalled(); + expect(result.current.isExpired).toBe(false); + }); + it('expires the Ring URL without double-logging when session initialization rejects the environment', async () => { const session = mockSession(); diff --git a/src/hooks/useAuthUrl/useAuthUrl.tsx b/src/hooks/useAuthUrl/useAuthUrl.tsx index 9e80fa1c6e..e6092e2ff3 100644 --- a/src/hooks/useAuthUrl/useAuthUrl.tsx +++ b/src/hooks/useAuthUrl/useAuthUrl.tsx @@ -24,6 +24,12 @@ const isAuthFlowExpiredError = (error: unknown): boolean => { return isAuthError(error) && error.code === AuthErrorCode.SESSION_EXPIRED; }; +const isAuthFlowCanceled = (error: unknown): boolean => + typeof error === 'object' && + error !== null && + 'name' in error && + (error as { name?: unknown }).name === AUTH_FLOW_CANCELED_ERROR_NAME; + /** * Manages the authentication URL lifecycle for Pubky Ring authorization. * @param options - Configuration for auth URL generation (autoFetch, type, inviteCode for signup) @@ -61,6 +67,8 @@ export function useAuthUrl(options: UseAuthUrlOptions = {}): UseAuthUrlReturn { try { await AuthController.initializeAuthenticatedSession({ session }); } catch (error) { + // This approval lost to another sign-in; the winner owns the page. + if (isAuthFlowCanceled(error)) return; const isWrongEnvironment = isWrongEnvironmentHomeserverError(error); if (!isWrongEnvironment && !isAppError(error)) { Logger.error('Failed to persist session and check profile:', error); @@ -76,14 +84,7 @@ export function useAuthUrl(options: UseAuthUrlOptions = {}): UseAuthUrlReturn { } }) .catch((error: unknown) => { - if ( - typeof error === 'object' && - error !== null && - 'name' in error && - (error as { name?: unknown }).name === AUTH_FLOW_CANCELED_ERROR_NAME - ) { - return; - } + if (isAuthFlowCanceled(error)) return; Logger.error('Authorization promise rejected:', error); if (!isMountedRef.current) return; diff --git a/src/hooks/useProfileForm/useProfileForm.test.tsx b/src/hooks/useProfileForm/useProfileForm.test.tsx index 42acb2cfea..317bc7623f 100644 --- a/src/hooks/useProfileForm/useProfileForm.test.tsx +++ b/src/hooks/useProfileForm/useProfileForm.test.tsx @@ -1,8 +1,14 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ProfileController } from '@/controllers/profile/profile'; +import { AppError } from '@/libs/error/error'; +import { AuthErrorCode } from '@/libs/error/error.codes'; +import { ErrorCategory, ErrorService } from '@/libs/error/error.types'; import type { NexusUserDetails } from '@/services/nexus/nexus.types'; import { useProfileForm } from './useProfileForm'; +import { WRITE_PATH_NOT_ALLOWED_MESSAGE } from './useProfileForm.types'; + +const { mockToast } = vi.hoisted(() => ({ mockToast: vi.fn() })); vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), back: vi.fn() }), @@ -17,11 +23,11 @@ vi.mock('@/controllers/file/file', () => ({ })); vi.mock('@/controllers/profile/profile', () => ({ - ProfileController: { commitCreate: vi.fn(), commitUpdate: vi.fn() }, + ProfileController: { commitCreate: vi.fn(), commitUpdate: vi.fn(), readProfileSeed: vi.fn(async () => null) }, })); vi.mock('@/molecules/Toaster/use-toast', () => ({ - useToast: () => ({ toast: vi.fn() }), + useToast: () => ({ toast: mockToast }), })); vi.mock('@/stores/localFiles/localFiles.store', () => ({ @@ -81,3 +87,103 @@ describe('useProfileForm profile link safety', () => { expect(ProfileController.commitUpdate).not.toHaveBeenCalled(); }); }); + +describe('useProfileForm create mode prefill', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ProfileController.readProfileSeed).mockResolvedValue(null); + }); + + const renderCreate = () => renderHook(() => useProfileForm({ mode: 'create', pubky, setShowWelcomeDialog: vi.fn() })); + + it('does not invent a name when the account has no profile', async () => { + const { result } = renderCreate(); + + await waitFor(() => expect(ProfileController.readProfileSeed).toHaveBeenCalledWith({ pubky })); + expect(result.current.state.name).toBe(''); + expect(result.current.isSubmitDisabled).toBe(true); + }); + + it('prefills name, bio and links from an existing profile', async () => { + vi.mocked(ProfileController.readProfileSeed).mockResolvedValue({ + name: 'Dusty Dolphin', + bio: 'Sells coffee', + links: [{ title: 'Website', url: 'https://example.com' }], + }); + const { result } = renderCreate(); + + await waitFor(() => expect(result.current.state.name).toBe('Dusty Dolphin')); + expect(result.current.state.bio).toBe('Sells coffee'); + expect(result.current.state.links).toEqual([{ label: 'WEBSITE', url: 'https://example.com' }]); + }); + + it('never overwrites a name typed before the profile arrives', async () => { + let resolveSeed!: (seed: { name: string; bio: string; links: [] }) => void; + vi.mocked(ProfileController.readProfileSeed).mockReturnValue( + new Promise((resolve) => { + resolveSeed = resolve; + }), + ); + const { result } = renderCreate(); + act(() => result.current.handlers.setName('Typed Name')); + + await act(async () => resolveSeed({ name: 'Seed Name', bio: 'Seed bio', links: [] })); + + expect(result.current.state.name).toBe('Typed Name'); + expect(result.current.state.bio).toBe('Seed bio'); + }); + + it('edit mode never reads a prefill', () => { + renderHook(() => + useProfileForm({ + mode: 'edit', + pubky, + userDetails: { id: pubky, name: 'A', bio: '', links: [], status: null, image: null, indexed_at: 1 }, + }), + ); + + expect(ProfileController.readProfileSeed).not.toHaveBeenCalled(); + }); + + it('names the homeserver account restriction when the profile write is refused by path', async () => { + vi.mocked(ProfileController.commitCreate).mockRejectedValue( + new AppError({ + category: ErrorCategory.Auth, + code: AuthErrorCode.FORBIDDEN, + message: 'Request failed: Server responded with an error: 403 Forbidden - Write to this path is not allowed', + service: ErrorService.Homeserver, + operation: 'request', + }), + ); + const { result } = renderCreate(); + act(() => result.current.handlers.setName('Valid User')); + + await act(async () => { + await result.current.handlers.handleSubmit(); + }); + + expect(mockToast).toHaveBeenCalledWith({ variant: 'error', description: WRITE_PATH_NOT_ALLOWED_MESSAGE }); + expect(mockToast).not.toHaveBeenCalledWith(expect.objectContaining({ description: 'Could not save profile' })); + }); + + it('keeps the generic copy for other homeserver 403s', async () => { + vi.mocked(ProfileController.commitCreate).mockRejectedValue( + new AppError({ + category: ErrorCategory.Auth, + code: AuthErrorCode.FORBIDDEN, + message: + 'Request failed: Server responded with an error: 403 Forbidden - Session does not have write access to path', + service: ErrorService.Homeserver, + operation: 'request', + }), + ); + const { result } = renderCreate(); + act(() => result.current.handlers.setName('Valid User')); + + await act(async () => { + await result.current.handlers.handleSubmit(); + }); + + expect(mockToast).toHaveBeenCalledWith({ variant: 'error', description: 'Could not save profile' }); + }); +}); diff --git a/src/hooks/useProfileForm/useProfileForm.tsx b/src/hooks/useProfileForm/useProfileForm.tsx index 1d08eb5b6e..a14ff4a091 100644 --- a/src/hooks/useProfileForm/useProfileForm.tsx +++ b/src/hooks/useProfileForm/useProfileForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { z } from 'zod'; import { HOME_ROUTES, PROFILE_ROUTES, SETTINGS_ROUTES } from '@/app/routes'; @@ -9,11 +9,10 @@ import { AuthController } from '@/controllers/auth/auth'; import { FileController } from '@/controllers/file/file'; import { ProfileController } from '@/controllers/profile/profile'; import { AppError } from '@/libs/error/error'; -import { isAuthError, requiresLogin } from '@/libs/error/error.utils'; +import { isAuthError, isWritePathNotAllowedError, requiresLogin } from '@/libs/error/error.utils'; import { getImageUploadSizeLimitToastMessage } from '@/libs/image/imageUploadSizeLimit'; import { Logger } from '@/libs/logger/logger'; import { safeExternalUrlSchema } from '@/libs/utils/safeExternalUrl'; -import { generateRandomUsername } from '@/libs/utils/utils'; import { useToast } from '@/molecules/Toaster/use-toast'; import { UserValidator } from '@/pipes/user/user.validator'; import { useLocalFilesStore } from '@/stores/localFiles/localFiles.store'; @@ -23,6 +22,7 @@ import { type SubmitText, type UseProfileFormProps, type UseProfileFormReturn, + WRITE_PATH_NOT_ALLOWED_MESSAGE, } from './useProfileForm.types'; const DEFAULT_LINKS: ProfileLink[] = [ @@ -50,11 +50,8 @@ export function useProfileForm(props: UseProfileFormProps): UseProfileFormReturn const { toast } = useToast(); const fileInputRef = useRef(null); - // Generate a stable initial username for create mode (only generated once) - const initialUsername = useMemo(() => (mode === 'create' ? generateRandomUsername() : ''), [mode]); - // Form state - const [name, setName] = useState(initialUsername); + const [name, setName] = useState(''); const [bio, setBio] = useState(''); const [links, setLinks] = useState(DEFAULT_LINKS); const [avatarFile, setAvatarFile] = useState(null); @@ -112,6 +109,28 @@ export function useProfileForm(props: UseProfileFormProps): UseProfileFormReturn } }, [mode, userDetails, pubky]); + // Create mode: prefill from a profile the account already published (Pubky + // App or Bitkit). Never overwrites a field the user has already typed in. + useEffect(() => { + if (mode !== 'create' || !pubky) return; + let cancelled = false; + void ProfileController.readProfileSeed({ pubky }).then((seed) => { + if (cancelled || !seed) return; + setName((current) => (current === '' ? seed.name : current)); + setBio((current) => (current === '' ? seed.bio : current)); + if (seed.links.length > 0) { + setLinks((current) => + current.every((link) => link.url === '') + ? seed.links.map((link) => ({ label: link.title.toUpperCase(), url: link.url })) + : current, + ); + } + }); + return () => { + cancelled = true; + }; + }, [mode, pubky]); + // Cleanup blob URLs on unmount useEffect(() => { return () => { @@ -377,6 +396,17 @@ export function useProfileForm(props: UseProfileFormProps): UseProfileFormReturn return; } + // The account's homeserver storage does not allow Pubky App data + if (isWritePathNotAllowedError(error)) { + Logger.error('Homeserver account does not allow saving the profile', { code: error.code }); + setSubmitText(PROFILE_SUBMIT_TEXT.tryAgain); + toast({ + variant: 'error', + description: WRITE_PATH_NOT_ALLOWED_MESSAGE, + }); + return; + } + // Handle auth errors from homeserver if (isAuthError(error)) { Logger.error('Failed to save profile in Homeserver', error); diff --git a/src/hooks/useProfileForm/useProfileForm.types.ts b/src/hooks/useProfileForm/useProfileForm.types.ts index 3de9450bbc..40c6652f6c 100644 --- a/src/hooks/useProfileForm/useProfileForm.types.ts +++ b/src/hooks/useProfileForm/useProfileForm.types.ts @@ -9,6 +9,10 @@ export interface ProfileLink { url: string; } +/** Shown when the homeserver's per-account allow-list refuses Pubky App writes (Bitkit's Homegate signups). */ +export const WRITE_PATH_NOT_ALLOWED_MESSAGE = + "Your homeserver account doesn't allow saving a Pubky profile yet. Signing in again won't change this."; + /** Submit-button copy for each phase of the profile save flow. */ export const PROFILE_SUBMIT_TEXT = { saveProfile: 'Save Profile', diff --git a/src/libs/error/error.utils.ts b/src/libs/error/error.utils.ts index f6756be55d..4c954795e7 100644 --- a/src/libs/error/error.utils.ts +++ b/src/libs/error/error.utils.ts @@ -29,6 +29,20 @@ export const isAuthError = (e: AppError): boolean => e.category === ErrorCategor /** Check if error is a rate limit error (429) */ export const isRateLimitError = (e: AppError): boolean => e.category === ErrorCategory.RateLimit; +/** The homeserver's 403 body when the account's storage allow-list excludes the path (pubky-homeserver `http_error.rs`). */ +const HOMESERVER_WRITE_PATH_NOT_ALLOWED = 'Write to this path is not allowed'; + +/** + * The homeserver refused a write because this account may not store data at + * that path, whatever the session's capabilities. Signing in again cannot + * fix it: accounts created through Homegate's IP signup only allow + * `/pub/paykit/` and `/pub/bitkit.to/`. + */ +export const isWritePathNotAllowedError = (e: AppError): boolean => + e.category === ErrorCategory.Auth && + e.code === AuthErrorCode.FORBIDDEN && + e.message.includes(HOMESERVER_WRITE_PATH_NOT_ALLOWED); + /** Check if error is a validation error (local) */ export const isValidationError = (e: AppError): boolean => e.category === ErrorCategory.Validation; diff --git a/src/libs/utils/utils.test.ts b/src/libs/utils/utils.test.ts index 4e27068242..7bde3abe5d 100644 --- a/src/libs/utils/utils.test.ts +++ b/src/libs/utils/utils.test.ts @@ -18,7 +18,6 @@ import { formatPublicKey, formatUSDate, generateRandomColor, - generateRandomUsername, getCharacterCount, getDisplayTags, getValidAuthorPubkyFromPostCompositeId, @@ -1467,47 +1466,6 @@ describe('Utils', () => { }); }); - describe('generateRandomUsername', () => { - it('should return a string in Adjective-Noun-Noun format', () => { - const username = generateRandomUsername(); - const parts = username.split('-'); - expect(parts).toHaveLength(3); - // Each part should start with uppercase and contain only letters - parts.forEach((part) => { - expect(part).toMatch(/^[A-Z][a-z]+$/); - }); - }); - - it('should generate different usernames on multiple calls', () => { - const usernames = new Set(); - // Generate 20 usernames - with 30 adjectives and 40 nouns, collisions should be rare - for (let i = 0; i < 20; i++) { - usernames.add(generateRandomUsername()); - } - // At least 10 should be unique (allowing for some randomness) - expect(usernames.size).toBeGreaterThanOrEqual(10); - }); - - it('should not have the same noun repeated twice', () => { - // Run multiple times to increase confidence - for (let i = 0; i < 50; i++) { - const username = generateRandomUsername(); - const parts = username.split('-'); - expect(parts[1]).not.toBe(parts[2]); - } - }); - - it('should generate usernames with reasonable length', () => { - for (let i = 0; i < 20; i++) { - const username = generateRandomUsername(); - // Minimum: 3 chars + hyphen + 3 chars + hyphen + 3 chars = 11 chars - // Maximum: 7 chars + hyphen + 7 chars + hyphen + 7 chars = 23 chars (based on word lists) - expect(username.length).toBeGreaterThanOrEqual(11); - expect(username.length).toBeLessThanOrEqual(25); - } - }); - }); - describe('focusAdjacentGridItem', () => { it('focuses the next article sibling when present', () => { document.body.innerHTML = ` diff --git a/src/libs/utils/utils.ts b/src/libs/utils/utils.ts index 780b0f949d..ab1ccaa123 100644 --- a/src/libs/utils/utils.ts +++ b/src/libs/utils/utils.ts @@ -662,110 +662,6 @@ export function formatUSDate(date: Date = new Date()): string { }); } -/** - * Word lists for random username generation - * Format: Adjective-Noun-Noun (e.g., "Blue-Rabbit-Hat") - */ -const USERNAME_ADJECTIVES = [ - 'Blue', - 'Red', - 'Green', - 'Golden', - 'Silver', - 'Purple', - 'Orange', - 'Pink', - 'Cosmic', - 'Bright', - 'Swift', - 'Noble', - 'Brave', - 'Calm', - 'Bold', - 'Wild', - 'Wise', - 'Lucky', - 'Happy', - 'Sunny', - 'Misty', - 'Rusty', - 'Dusty', - 'Frosty', - 'Mighty', - 'Gentle', - 'Clever', - 'Silent', - 'Ancient', - 'Mystic', -]; - -const USERNAME_NOUNS = [ - 'Rabbit', - 'Fox', - 'Wolf', - 'Bear', - 'Eagle', - 'Hawk', - 'Owl', - 'Tiger', - 'Lion', - 'Panda', - 'Koala', - 'Dolphin', - 'Falcon', - 'Phoenix', - 'Dragon', - 'Raven', - 'Sparrow', - 'Otter', - 'Badger', - 'Lynx', - 'Hat', - 'Star', - 'Moon', - 'Sun', - 'Cloud', - 'Storm', - 'Wave', - 'Stone', - 'Crystal', - 'Flame', - 'Frost', - 'Wind', - 'Thunder', - 'Shadow', - 'Light', - 'Blade', - 'Shield', - 'Crown', - 'Tower', - 'Garden', -]; - -/** - * Generates a random username in the format "Adjective-Noun-Noun" - * Creates unique, memorable usernames like "Blue-Rabbit-Hat" or "Golden-Eagle-Star" - * - * @returns A random username string - * - * @example - * generateRandomUsername() // "Blue-Rabbit-Hat" - * generateRandomUsername() // "Golden-Eagle-Star" - * generateRandomUsername() // "Swift-Fox-Moon" - */ -export function generateRandomUsername(): string { - const randomAdjective = USERNAME_ADJECTIVES[Math.floor(Math.random() * USERNAME_ADJECTIVES.length)]; - const randomNoun1 = USERNAME_NOUNS[Math.floor(Math.random() * USERNAME_NOUNS.length)]; - - // Ensure second noun is different from the first - let randomNoun2 = USERNAME_NOUNS[Math.floor(Math.random() * USERNAME_NOUNS.length)]; - while (randomNoun2 === randomNoun1) { - randomNoun2 = USERNAME_NOUNS[Math.floor(Math.random() * USERNAME_NOUNS.length)]; - } - - return `${randomAdjective}-${randomNoun1}-${randomNoun2}`; -} - const FOCUSABLE_SELECTOR = 'button,a[href],[tabindex]:not([tabindex="-1"])'; // Feed cards (`[role="article"]` in grid/list layouts) and visual-mosaic cells // (`[data-grid-item]` — the tiles are `role="button"`, not articles). diff --git a/src/test/vrt/onboarding/Profile.vrt.test.tsx b/src/test/vrt/onboarding/Profile.vrt.test.tsx index 8e70545230..16ccb22e4b 100644 --- a/src/test/vrt/onboarding/Profile.vrt.test.tsx +++ b/src/test/vrt/onboarding/Profile.vrt.test.tsx @@ -21,10 +21,17 @@ function ProfileWithHeader() { ); } -// `CreateProfileForm` runs `useProfileForm({ mode: 'create' })`, whose load -// effect is edit-mode-only — so the create-mode render is the empty form with a +// `CreateProfileForm` runs `useProfileForm({ mode: 'create' })`. Its prefill +// read finds no existing profile here, so the render is the empty form with a // pubky-derived FacehashAvatar (deterministic under the seeded Math.random in -// `renderForVRT`). No hook mock needed; only the stores it reads. +// `renderForVRT`). +vi.mock('@/controllers/profile/profile', () => ({ + ProfileController: { + readProfileSeed: vi.fn(async () => null), + commitCreate: vi.fn(), + commitUpdate: vi.fn(), + }, +})); vi.mock('next/navigation', () => { const router = { push: vi.fn(), diff --git a/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-desktop-chromium-linux.png b/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-desktop-chromium-linux.png index fcc370d6e5..5dc400536b 100644 Binary files a/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-desktop-chromium-linux.png and b/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-desktop-chromium-linux.png differ diff --git a/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-desktop-firefox-linux.png b/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-desktop-firefox-linux.png index a270b95ba1..5305be37e0 100644 Binary files a/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-desktop-firefox-linux.png and b/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-desktop-firefox-linux.png differ diff --git a/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-desktop-webkit-linux.png b/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-desktop-webkit-linux.png index 92fabda115..b4cd018ea6 100644 Binary files a/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-desktop-webkit-linux.png and b/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-desktop-webkit-linux.png differ diff --git a/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-mobile-chromium-linux.png b/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-mobile-chromium-linux.png index e3b6295771..7099e53319 100644 Binary files a/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-mobile-chromium-linux.png and b/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-mobile-chromium-linux.png differ diff --git a/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-mobile-firefox-linux.png b/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-mobile-firefox-linux.png index 3722b9fadf..cd8e45c503 100644 Binary files a/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-mobile-firefox-linux.png and b/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-mobile-firefox-linux.png differ diff --git a/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-mobile-webkit-linux.png b/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-mobile-webkit-linux.png index a1ab7cc4c8..da4afbd955 100644 Binary files a/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-mobile-webkit-linux.png and b/src/test/vrt/onboarding/__screenshots__/Profile.vrt.test.tsx/onboarding-profile-mobile-webkit-linux.png differ