feat: wire mobile git auth action

This commit is contained in:
lucaronin
2026-05-05 13:21:08 +02:00
parent 68723c0e96
commit 6540d70f34
11 changed files with 359 additions and 13 deletions

View File

@@ -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() {
<View style={styles.tabletShell}>
<SidebarPanel />
<NoteListPanel
gitSyncPlan={gitSyncPlan}
gitSyncPlan={gitSyncFlow.gitSyncPlan}
notes={availableNotes}
selectedNoteId={compactNavigation.selectedNoteId}
createNoteFailed={createFlow.failed}
@@ -158,6 +162,7 @@ export function MobileApp() {
onCancelCreateNote={createFlow.cancel}
onChangeCreateNoteTitle={createFlow.setTitle}
onOpenCreateNote={createFlow.open}
onGitSyncAction={gitSyncFlow.authenticate}
onRetryRuntimeLoad={runtimeLoader.retry}
onSubmitCreateNote={createFlow.submit}
onSelectNote={selectNote}
@@ -181,7 +186,7 @@ export function MobileApp() {
<CompactShell
activePanel={compactNavigation.panel}
note={selectedNote}
gitSyncPlan={gitSyncPlan}
gitSyncPlan={gitSyncFlow.gitSyncPlan}
notes={availableNotes}
saveState={selectedSaveState}
selectedNoteId={compactNavigation.selectedNoteId}
@@ -196,6 +201,7 @@ export function MobileApp() {
onCancelCreateNote={createFlow.cancel}
onChangeCreateNoteTitle={createFlow.setTitle}
onOpenCreateNote={createFlow.open}
onGitSyncAction={gitSyncFlow.authenticate}
onRetryRuntimeLoad={runtimeLoader.retry}
onSubmitCreateNote={createFlow.submit}
onSelectNote={selectNote}
@@ -226,6 +232,7 @@ function CompactShell({
onCancelCreateNote,
onChangeCreateNoteTitle,
onOpenCreateNote,
onGitSyncAction,
onChangeProperties,
onRetryRuntimeLoad,
onSubmitCreateNote,
@@ -250,6 +257,7 @@ function CompactShell({
onCancelCreateNote: () => 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({
<View style={styles.toolbarSpacer} />
<IconButton icon={<MagnifyingGlass size={23} color={colors.textSoft} />} />
</Toolbar>
<MobileGitSyncStatusCard plan={gitSyncPlan} />
<MobileGitSyncStatusCard plan={gitSyncPlan} onPrimaryAction={onGitSyncAction} />
{runtimeLoadFailed ? <VaultLoadErrorNotice onRetry={onRetryRuntimeLoad} /> : null}
<FlatList
data={notes}

View File

@@ -1,11 +1,17 @@
import { GitBranch, WarningCircle } from 'phosphor-react-native'
import { Text, View } from 'react-native'
import { Pressable, Text, View } from 'react-native'
import { mobileGitSyncStatusView, type MobileGitSyncStatusTone } from './mobileGitSyncStatus'
import type { MobileGitSyncPlan } from './mobileGitSyncPlan'
import { styles } from './styles'
import { colors } from './theme'
export function MobileGitSyncStatusCard({ plan }: { plan: MobileGitSyncPlan }) {
export function MobileGitSyncStatusCard({
onPrimaryAction,
plan,
}: {
onPrimaryAction?: () => void
plan: MobileGitSyncPlan
}) {
const status = mobileGitSyncStatusView(plan)
if (!status) {
return null
@@ -20,7 +26,15 @@ export function MobileGitSyncStatusCard({ plan }: { plan: MobileGitSyncPlan }) {
<Text style={styles.gitSyncStatusLabel}>{status.label}</Text>
<Text style={styles.gitSyncStatusDetail}>{status.detail}</Text>
</View>
{status.actionLabel ? <Text style={styles.gitSyncStatusAction}>{status.actionLabel}</Text> : null}
{status.actionLabel ? (
<Pressable
accessibilityLabel={status.actionLabel}
onPress={onPrimaryAction}
style={({ pressed }) => pressed ? styles.pressed : null}
>
<Text style={styles.gitSyncStatusAction}>{status.actionLabel}</Text>
</Pressable>
) : null}
</View>
)
}

View File

@@ -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')
},
}
}

View File

@@ -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<MobileGitAuthenticationResult> {
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<MobileGitSyncPlan, { state: 'authRequired' | 'failed' }>) {
return plan.state === 'authRequired' ? plan.authStrategy : plan.remote.authStrategy
}

View File

@@ -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<string, ReturnType<typeof createMobileGitCredentialRecord>>()
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 () => {},
}
}

View File

@@ -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<MobileGitCredentialState> {
const result = createMobileVaultConfig(vault)
if (!result.ok || result.config.sync.state === 'localOnly') {
return { state: 'missing' }
}
return credentialStorage.loadState(result.config.sync.authRequirement)
}

View File

@@ -0,0 +1,18 @@
import { createNativeMobileGitHubOAuthSession } from './mobileNativeGitHubOAuthSession'
import type { MobileGitHubOAuthSession } from './mobileGitHubOAuthFlow'
declare const process: { env?: Record<string, string | undefined> } | 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 })
}

View File

@@ -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,
})
}

View File

@@ -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<string | null>(null)
const [credentials, setCredentials] = useState<MobileGitCredentialState>({ 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,
}
}

View File

@@ -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

View File

@@ -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.