From 6540d70f340b5d02f83ef0e69b0afb1b4eb297e8 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 5 May 2026 13:21:08 +0200 Subject: [PATCH] feat: wire mobile git auth action --- apps/mobile/src/MobileApp.tsx | 27 ++++-- apps/mobile/src/MobileGitSyncStatusCard.tsx | 20 ++++- .../src/mobileGitAuthentication.test.ts | 85 +++++++++++++++++++ apps/mobile/src/mobileGitAuthentication.ts | 55 ++++++++++++ .../mobileGitCredentialStateForVault.test.ts | 58 +++++++++++++ .../src/mobileGitCredentialStateForVault.ts | 19 +++++ .../src/mobileGitHubOAuthEnvironment.ts | 18 ++++ apps/mobile/src/mobileGitSyncRuntimePlan.ts | 13 ++- apps/mobile/src/useMobileGitSyncFlow.ts | 69 +++++++++++++++ docs/MOBILE_PROGRESS.md | 7 +- docs/MOBILE_STRATEGY.md | 1 + 11 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/src/mobileGitAuthentication.test.ts create mode 100644 apps/mobile/src/mobileGitAuthentication.ts create mode 100644 apps/mobile/src/mobileGitCredentialStateForVault.test.ts create mode 100644 apps/mobile/src/mobileGitCredentialStateForVault.ts create mode 100644 apps/mobile/src/mobileGitHubOAuthEnvironment.ts create mode 100644 apps/mobile/src/useMobileGitSyncFlow.ts diff --git a/apps/mobile/src/MobileApp.tsx b/apps/mobile/src/MobileApp.tsx index 7b85b88e..de4dd8f3 100644 --- a/apps/mobile/src/MobileApp.tsx +++ b/apps/mobile/src/MobileApp.tsx @@ -52,18 +52,21 @@ import { useMobileNoteDeleteFlow } from './useMobileNoteDeleteFlow' import { useMobileNotePropertiesFlow } from './useMobileNotePropertiesFlow' import { createNativeMobileAppStateStorage } from './mobileNativeAppStateStorage' import { createNativeMobileVaultMetadataStorage } from './mobileNativeVaultMetadataStorage' -import { createMobileGitSyncPlanForVault } from './mobileGitSyncRuntimePlan' +import { createNativeMobileGitCredentialStorage } from './mobileNativeGitCredentialStorage' +import { createNativeMobileGitHubOAuthSessionFromEnvironment } from './mobileGitHubOAuthEnvironment' import type { MobileGitSyncPlan } from './mobileGitSyncPlan' import { defaultMobileVaultMetadata } from './mobileVaultMetadata' import type { MobileVaultRuntime } from './mobileVaultRuntime' import { useMobileVaultRuntimeLoader } from './useMobileVaultRuntimeLoader' import type { MobileNotePropertyPatch } from './mobileNoteProperties' +import { useMobileGitSyncFlow } from './useMobileGitSyncFlow' export function MobileApp() { const { width } = useWindowDimensions() const isTablet = width >= 820 const showsProperties = width >= 1000 const appStateStorage = useMemo(() => createNativeMobileAppStateStorage(), []) + const gitCredentialStorage = useMemo(() => createNativeMobileGitCredentialStorage(), []) const vaultMetadataStorage = useMemo(() => createNativeMobileVaultMetadataStorage(), []) const [activeVaultMetadata, setActiveVaultMetadata] = useState(defaultMobileVaultMetadata) const [availableNotes, setAvailableNotes] = useState(fallbackNotes) @@ -74,10 +77,11 @@ export function MobileApp() { [availableNotes, compactNavigation.selectedNoteId], ) const selectedSaveState = saveStateByNoteId[selectedNote.id] ?? idleMobileEditorSaveState - const gitSyncPlan = useMemo( - () => createMobileGitSyncPlanForVault({ vault: activeVaultMetadata }), - [activeVaultMetadata], - ) + const gitSyncFlow = useMobileGitSyncFlow({ + createGitHubOAuthSession: createNativeMobileGitHubOAuthSessionFromEnvironment, + credentialStorage: gitCredentialStorage, + vault: activeVaultMetadata, + }) const autosaveQueue = useMemo( () => createMobileAutosaveQueue({ @@ -147,7 +151,7 @@ export function MobileApp() { void onChangeCreateNoteTitle: (title: string) => void onOpenCreateNote: () => void + onGitSyncAction: () => void onChangeProperties: (patch: MobileNotePropertyPatch) => void onRetryRuntimeLoad: () => void onSubmitCreateNote: () => void @@ -308,6 +316,7 @@ function CompactShell({ runtimeLoadFailed={runtimeLoadFailed} onCancelCreateNote={onCancelCreateNote} onChangeCreateNoteTitle={onChangeCreateNoteTitle} + onGitSyncAction={onGitSyncAction} onOpenCreateNote={onOpenCreateNote} onOpenSidebar={() => onNavigate({ type: 'openSidebar' })} onRetryRuntimeLoad={onRetryRuntimeLoad} @@ -361,6 +370,7 @@ function NoteListPanel({ runtimeLoadFailed, onCancelCreateNote, onChangeCreateNoteTitle, + onGitSyncAction, onOpenCreateNote, onOpenSidebar, onRetryRuntimeLoad, @@ -377,6 +387,7 @@ function NoteListPanel({ runtimeLoadFailed: boolean onCancelCreateNote: () => void onChangeCreateNoteTitle: (title: string) => void + onGitSyncAction: () => void onOpenCreateNote: () => void onOpenSidebar?: () => void onRetryRuntimeLoad: () => void @@ -392,7 +403,7 @@ function NoteListPanel({ } /> - + {runtimeLoadFailed ? : null} void + plan: MobileGitSyncPlan +}) { const status = mobileGitSyncStatusView(plan) if (!status) { return null @@ -20,7 +26,15 @@ export function MobileGitSyncStatusCard({ plan }: { plan: MobileGitSyncPlan }) { {status.label} {status.detail} - {status.actionLabel ? {status.actionLabel} : null} + {status.actionLabel ? ( + pressed ? styles.pressed : null} + > + {status.actionLabel} + + ) : null} ) } diff --git a/apps/mobile/src/mobileGitAuthentication.test.ts b/apps/mobile/src/mobileGitAuthentication.test.ts new file mode 100644 index 00000000..bd08f49a --- /dev/null +++ b/apps/mobile/src/mobileGitAuthentication.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest' +import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage' +import { authenticateMobileGitSyncPlan } from './mobileGitAuthentication' + +describe('authenticateMobileGitSyncPlan', () => { + it('ignores plans that do not need authentication', async () => { + await expect(authenticateMobileGitSyncPlan({ + credentialStorage: noopCredentialStorage(), + createGitHubOAuthSession: failingSession, + now: () => '2026-05-05T12:00:00.000Z', + plan: { primaryAction: null, state: 'localOnly' }, + })).resolves.toEqual({ state: 'ignored' }) + }) + + it('connects GitHub auth-required plans through the OAuth session', async () => { + const credentialStorage = memoryCredentialStorage() + + await expect(authenticateMobileGitSyncPlan({ + credentialStorage, + createGitHubOAuthSession: () => ({ + authorize: async () => ({ + state: 'succeeded', + token: { accessToken: 'token', tokenType: 'bearer' }, + }), + }), + now: () => '2026-05-05T12:00:00.000Z', + plan: { + authStrategy: 'githubOAuth', + host: 'github.com', + primaryAction: 'authenticate', + state: 'authRequired', + }, + })).resolves.toEqual({ state: 'connected' }) + + await expect(credentialStorage.loadState({ host: 'github.com', strategy: 'githubOAuth' })) + .resolves.toEqual({ state: 'available' }) + }) + + it('fails unsupported SSH authentication without starting OAuth', async () => { + await expect(authenticateMobileGitSyncPlan({ + credentialStorage: noopCredentialStorage(), + createGitHubOAuthSession: failingSession, + now: () => '2026-05-05T12:00:00.000Z', + plan: { + authStrategy: 'sshKey', + host: 'git.example.com', + primaryAction: 'authenticate', + state: 'authRequired', + }, + })).resolves.toEqual({ + message: 'SSH credential setup is not available yet.', + state: 'failed', + }) + }) +}) + +function memoryCredentialStorage(): MobileGitCredentialStorage { + let isAvailable = false + + return { + loadState: async () => isAvailable ? { state: 'available' } : { state: 'missing' }, + remove: async () => { + isAvailable = false + }, + saveRecord: async () => { + isAvailable = true + }, + } +} + +function noopCredentialStorage(): MobileGitCredentialStorage { + return { + loadState: async () => ({ state: 'missing' }), + remove: async () => {}, + saveRecord: async () => {}, + } +} + +function failingSession() { + return { + authorize: async () => { + throw new Error('should not start OAuth') + }, + } +} diff --git a/apps/mobile/src/mobileGitAuthentication.ts b/apps/mobile/src/mobileGitAuthentication.ts new file mode 100644 index 00000000..3dda76db --- /dev/null +++ b/apps/mobile/src/mobileGitAuthentication.ts @@ -0,0 +1,55 @@ +import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage' +import type { MobileGitSyncPlan } from './mobileGitSyncPlan' +import { connectMobileGitHubOAuth, type MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow' + +export type MobileGitAuthenticationResult = + | { + state: 'connected' + } + | { + state: 'cancelled' + } + | { + message: string + state: 'failed' + } + | { + state: 'ignored' + } + +export async function authenticateMobileGitSyncPlan({ + credentialStorage, + createGitHubOAuthSession, + now, + plan, +}: { + credentialStorage: MobileGitCredentialStorage + createGitHubOAuthSession: () => MobileGitHubOAuthSession + now: () => string + plan: MobileGitSyncPlan +}): Promise { + if (!canAuthenticate(plan)) { + return { state: 'ignored' } + } + + if (authStrategy(plan) !== 'githubOAuth') { + return { + message: 'SSH credential setup is not available yet.', + state: 'failed', + } + } + + return connectMobileGitHubOAuth({ + credentialStorage, + now, + session: createGitHubOAuthSession(), + }) +} + +function canAuthenticate(plan: MobileGitSyncPlan) { + return plan.state === 'authRequired' || plan.state === 'failed' +} + +function authStrategy(plan: Extract) { + return plan.state === 'authRequired' ? plan.authStrategy : plan.remote.authStrategy +} diff --git a/apps/mobile/src/mobileGitCredentialStateForVault.test.ts b/apps/mobile/src/mobileGitCredentialStateForVault.test.ts new file mode 100644 index 00000000..abfd0eda --- /dev/null +++ b/apps/mobile/src/mobileGitCredentialStateForVault.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { + createMobileGitCredentialRecord, + type MobileGitCredentialStorage, +} from './mobileGitCredentialStorage' +import { loadMobileGitCredentialStateForVault } from './mobileGitCredentialStateForVault' + +describe('loadMobileGitCredentialStateForVault', () => { + it('keeps local-only vaults credential-missing without hitting secure storage', async () => { + await expect(loadMobileGitCredentialStateForVault({ + credentialStorage: failingCredentialStorage(), + vault: { id: 'personal', name: 'Personal Journal' }, + })).resolves.toEqual({ state: 'missing' }) + }) + + it('loads credential state for a remote-backed vault auth requirement', async () => { + const credentialStorage = memoryCredentialStorage() + await credentialStorage.saveRecord(createMobileGitCredentialRecord({ + requirement: { host: 'github.com', strategy: 'githubOAuth' }, + storedAt: '2026-05-05T12:00:00.000Z', + })) + + await expect(loadMobileGitCredentialStateForVault({ + credentialStorage, + vault: { + id: 'tolaria', + name: 'Tolaria', + remoteUrl: 'https://github.com/refactoringhq/tolaria.git', + }, + })).resolves.toEqual({ state: 'available' }) + }) +}) + +function memoryCredentialStorage(): MobileGitCredentialStorage { + const records = new Map>() + + return { + loadState: async (requirement) => records.has(`${requirement.strategy}:${requirement.host}`) + ? { state: 'available' } + : { state: 'missing' }, + remove: async (requirement) => { + records.delete(`${requirement.strategy}:${requirement.host}`) + }, + saveRecord: async (record) => { + records.set(`${record.strategy}:${record.host}`, record) + }, + } +} + +function failingCredentialStorage(): MobileGitCredentialStorage { + return { + loadState: async () => { + throw new Error('should not load credentials') + }, + remove: async () => {}, + saveRecord: async () => {}, + } +} diff --git a/apps/mobile/src/mobileGitCredentialStateForVault.ts b/apps/mobile/src/mobileGitCredentialStateForVault.ts new file mode 100644 index 00000000..1bb9c7ed --- /dev/null +++ b/apps/mobile/src/mobileGitCredentialStateForVault.ts @@ -0,0 +1,19 @@ +import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage' +import type { MobileGitCredentialState } from './mobileGitSyncPlan' +import { createMobileVaultConfig } from './mobileVaultConfig' +import type { MobileVaultMetadata } from './mobileVaultMetadata' + +export async function loadMobileGitCredentialStateForVault({ + credentialStorage, + vault, +}: { + credentialStorage: MobileGitCredentialStorage + vault: MobileVaultMetadata +}): Promise { + const result = createMobileVaultConfig(vault) + if (!result.ok || result.config.sync.state === 'localOnly') { + return { state: 'missing' } + } + + return credentialStorage.loadState(result.config.sync.authRequirement) +} diff --git a/apps/mobile/src/mobileGitHubOAuthEnvironment.ts b/apps/mobile/src/mobileGitHubOAuthEnvironment.ts new file mode 100644 index 00000000..067626da --- /dev/null +++ b/apps/mobile/src/mobileGitHubOAuthEnvironment.ts @@ -0,0 +1,18 @@ +import { createNativeMobileGitHubOAuthSession } from './mobileNativeGitHubOAuthSession' +import type { MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow' + +declare const process: { env?: Record } | undefined + +export function createNativeMobileGitHubOAuthSessionFromEnvironment(): MobileGitHubOAuthSession { + const clientId = process?.env?.EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID?.trim() ?? '' + if (!clientId) { + return { + authorize: async () => ({ + message: 'GitHub OAuth client ID is not configured.', + state: 'failed', + }), + } + } + + return createNativeMobileGitHubOAuthSession({ clientId }) +} diff --git a/apps/mobile/src/mobileGitSyncRuntimePlan.ts b/apps/mobile/src/mobileGitSyncRuntimePlan.ts index f4c6c2bf..106c1cff 100644 --- a/apps/mobile/src/mobileGitSyncRuntimePlan.ts +++ b/apps/mobile/src/mobileGitSyncRuntimePlan.ts @@ -1,12 +1,21 @@ -import { createMobileGitSyncPlan, type MobileGitCredentialState, type MobileGitSyncPlan } from './mobileGitSyncPlan' +import { + createMobileGitSyncPlan, + type MobileGitCredentialState, + type MobileGitOperation, + type MobileGitSyncPlan, +} from './mobileGitSyncPlan' import { createMobileVaultConfig } from './mobileVaultConfig' import type { MobileVaultMetadata } from './mobileVaultMetadata' export function createMobileGitSyncPlanForVault({ credentials = { state: 'missing' }, + failure, + operation, vault, }: { credentials?: MobileGitCredentialState + failure?: { message: string; operation: MobileGitOperation } + operation?: MobileGitOperation vault: MobileVaultMetadata }): MobileGitSyncPlan { const result = createMobileVaultConfig(vault) @@ -16,6 +25,8 @@ export function createMobileGitSyncPlanForVault({ return createMobileGitSyncPlan({ credentials, + failure, + operation, sync: result.config.sync, }) } diff --git a/apps/mobile/src/useMobileGitSyncFlow.ts b/apps/mobile/src/useMobileGitSyncFlow.ts new file mode 100644 index 00000000..be47ab53 --- /dev/null +++ b/apps/mobile/src/useMobileGitSyncFlow.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { MobileGitCredentialStorage } from './mobileGitCredentialStorage' +import { loadMobileGitCredentialStateForVault } from './mobileGitCredentialStateForVault' +import type { MobileGitCredentialState } from './mobileGitSyncPlan' +import { createMobileGitSyncPlanForVault } from './mobileGitSyncRuntimePlan' +import { authenticateMobileGitSyncPlan } from './mobileGitAuthentication' +import type { MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow' +import type { MobileVaultMetadata } from './mobileVaultMetadata' + +export function useMobileGitSyncFlow({ + createGitHubOAuthSession, + credentialStorage, + vault, +}: { + createGitHubOAuthSession: () => MobileGitHubOAuthSession + credentialStorage: MobileGitCredentialStorage + vault: MobileVaultMetadata +}) { + const [authFailureMessage, setAuthFailureMessage] = useState(null) + const [credentials, setCredentials] = useState({ state: 'missing' }) + const [isAuthenticating, setIsAuthenticating] = useState(false) + const refreshCredentials = useCallback(() => { + void loadMobileGitCredentialStateForVault({ credentialStorage, vault }) + .then(setCredentials) + .catch(() => setCredentials({ state: 'missing' })) + }, [credentialStorage, vault]) + const gitSyncPlan = useMemo( + () => createMobileGitSyncPlanForVault({ + credentials, + ...(authFailureMessage ? { failure: { message: authFailureMessage, operation: 'clone' } } : {}), + ...(isAuthenticating ? { operation: 'clone' } : {}), + vault, + }), + [authFailureMessage, credentials, isAuthenticating, vault], + ) + const authenticate = useCallback(() => { + if (isAuthenticating) { + return + } + + setAuthFailureMessage(null) + setIsAuthenticating(true) + void authenticateMobileGitSyncPlan({ + credentialStorage, + createGitHubOAuthSession, + now: () => new Date().toISOString(), + plan: gitSyncPlan, + }) + .then((result) => { + if (result.state === 'connected') { + refreshCredentials() + return + } + + if (result.state === 'failed') { + setAuthFailureMessage(result.message) + } + }) + .catch(() => setAuthFailureMessage('GitHub authentication failed.')) + .finally(() => setIsAuthenticating(false)) + }, [createGitHubOAuthSession, credentialStorage, gitSyncPlan, isAuthenticating, refreshCredentials]) + + useEffect(refreshCredentials, [refreshCredentials]) + + return { + authenticate, + gitSyncPlan, + } +} diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index c3c01a08..d2dd8c7b 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -92,12 +92,13 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Created [ADR-0113](./adr/0113-expo-secure-store-for-mobile-git-credentials.md) for the mobile secure credential storage dependency. - Added the mobile GitHub OAuth session boundary with Expo AuthSession/WebBrowser, PKCE request shaping, code exchange, SecureStore handoff, and the `tolaria://oauth/github` redirect scheme. - Created [ADR-0114](./adr/0114-expo-auth-session-for-mobile-github-oauth.md) for the mobile GitHub OAuth native dependency and redirect scheme. +- Wired remote-backed mobile sync status actions to credential loading and the GitHub OAuth flow, including visible syncing/failed status states when Connect is tapped. ## Next Action Continue Phase 4 with editor durability: -1. Wire remote-backed vault status actions to the GitHub OAuth flow so tapping Connect can populate credential state, still behind model-only Git operations until the native git adapter is selected. +1. Add a mobile vault-management path for entering remote URLs and exposing the configured GitHub OAuth client ID requirement during setup. 2. Continue TenTap Markdown serialization coverage for any editor output observed in simulator QA. 3. Retry the iOS development-client build after installing an iOS 26.2 simulator runtime in Xcode. @@ -341,6 +342,10 @@ Continue Phase 4 with editor durability: - `pnpm --filter @tolaria/mobile typecheck` passed after mobile GitHub OAuth boundary. - CodeScene after mobile GitHub OAuth boundary: `apps/mobile/src/mobileGitCredentialStorage.ts`, `apps/mobile/src/mobileGitCredentialStorage.test.ts`, `apps/mobile/src/mobileGitHubOAuth.ts`, `apps/mobile/src/mobileGitHubOAuth.test.ts`, `apps/mobile/src/mobileGitHubOAuthFlow.ts`, `apps/mobile/src/mobileGitHubOAuthFlow.test.ts`, and `apps/mobile/src/mobileNativeGitHubOAuthSession.ts` scored `10`. - `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after mobile GitHub OAuth boundary. +- `pnpm --filter @tolaria/mobile test -- src/mobileGitAuthentication.test.ts src/mobileGitCredentialStateForVault.test.ts src/mobileGitSyncRuntimePlan.test.ts src/mobileGitHubOAuthFlow.test.ts src/mobileGitHubOAuth.test.ts` passed after wiring Connect to mobile auth: 37 files / 126 tests. +- `pnpm --filter @tolaria/mobile typecheck` passed after wiring Connect to mobile auth. +- CodeScene after wiring Connect to mobile auth: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/MobileGitSyncStatusCard.tsx`, `apps/mobile/src/useMobileGitSyncFlow.ts`, `apps/mobile/src/mobileGitAuthentication.ts`, `apps/mobile/src/mobileGitAuthentication.test.ts`, `apps/mobile/src/mobileGitCredentialStateForVault.ts`, `apps/mobile/src/mobileGitCredentialStateForVault.test.ts`, and `apps/mobile/src/mobileGitHubOAuthEnvironment.ts` scored `10`; `apps/mobile/src/mobileGitSyncRuntimePlan.ts` returned no scorable code and no findings. +- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after wiring Connect to mobile auth. ## Risks / Watch Items diff --git a/docs/MOBILE_STRATEGY.md b/docs/MOBILE_STRATEGY.md index 56eb4a38..883d94d3 100644 --- a/docs/MOBILE_STRATEGY.md +++ b/docs/MOBILE_STRATEGY.md @@ -732,6 +732,7 @@ Rules: - Store tokens and private keys only in Keychain / Android Keystore. - Keep the JavaScript-facing credential contract at `available` / `missing` plus provider metadata; raw tokens and SSH material stay behind the secure storage and native Git credential callback boundary. - Use the `tolaria://oauth/github` redirect scheme for development builds; production redirect registration must match the final app scheme/bundle identity. +- Read the OAuth App client ID from `EXPO_PUBLIC_GITHUB_OAUTH_CLIENT_ID`; if it is absent, the Connect action must fail visibly and avoid writing placeholder credentials. - Do not persist credentials inside Git remote URLs. - Use credential callbacks at operation time. - Redact credentials from logs, analytics, crash reports, and support bundles.