Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/mobile/app/(tabs)/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ function BuildFooter() {
const MENU_ITEMS = [
{ label: 'Reading Stats', icon: 'stats-chart-outline' as const, route: '/stats/' },
{ label: 'Highlights', icon: 'color-wand-outline' as const, route: '/highlights/' },
// The key is account-only, and the screen says so itself rather than being hidden — a guest who
// never sees the row never learns the capability exists.
{ label: 'Connect an assistant', icon: 'key-outline' as const, route: '/connect' },
]


Expand Down
1 change: 1 addition & 0 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ function AppContent() {
<Stack.Screen name="privacy" />
<Stack.Screen name="terms" />
<Stack.Screen name="contact" />
<Stack.Screen name="connect" />
<Stack.Screen name="books" />
<Stack.Screen name="authors" />
</Stack>
Expand Down
227 changes: 227 additions & 0 deletions apps/mobile/app/connect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
import { useCallback, useEffect, useState } from 'react'
import { ScrollView, View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'
import { Stack, router } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
import * as Clipboard from 'expo-clipboard'
import {
mcpKeysApi,
claudeDesktopConfig,
defaultKeyName,
liveKeys,
MCP_ENDPOINT,
type McpKey,
type CreatedMcpKey,
} from '@textstack/shared'
import { useTheme } from '../src/context/ThemeContext'
import { useLanguage } from '../src/context/LanguageContext'
import { useAuth } from '../src/context/AuthContext'
import { useToast } from '../src/context/ToastContext'
import { capabilitiesFor } from '../src/lib/capabilities'
import { EmptyState } from '../src/components/ui/EmptyState'
import { fonts } from '../src/theme/typography'

/**
* Create and revoke the connect keys that let an outside assistant reach this reader's books.
*
* <p>The phone is where the reading happens, so it is where the key should be obtainable. The key is
* account-level, so one minted here also works in Claude Desktop — which is why this screen is worth
* having even if the mobile assistant apps turn out not to accept custom connectors.</p>
*
* <p>The key is shown once, in a panel that stays put. The server keeps only a SHA-256 of it, so
* "copy it now" is a statement of fact rather than a nudge — a toast would take the value away with
* it. The toast is used for the copy confirmation only.</p>
*/
export default function ConnectScreen() {
const { colors } = useTheme()
const { t } = useLanguage()
const { user } = useAuth()
const { show: showToast } = useToast()
const { canConnectAssistant } = capabilitiesFor(user)

const [keys, setKeys] = useState<McpKey[]>([])
const [loading, setLoading] = useState(false)
const [creating, setCreating] = useState(false)
const [created, setCreated] = useState<CreatedMcpKey | null>(null)
const [error, setError] = useState<string | null>(null)

const screen = (
<Stack.Screen options={{
title: t('connect.title'),
headerShown: true,
headerStyle: { backgroundColor: colors.background },
headerTintColor: colors.text,
headerTitleStyle: { fontFamily: fonts.sansMedium, fontSize: 16 },
headerShadowVisible: false,
}} />
)

// Deliberately does not clear `error` on success: the mount refresh and a user action overlap, so a
// create that failed at 200ms would have its message wiped by a list that succeeded at 300ms —
// leaving a button that visibly did nothing. Each action clears the error it is about to replace.
const refresh = useCallback(async () => {
setLoading(true)
try {
setKeys(await mcpKeysApi.listMcpKeys())
} catch {
setError(t('connect.loadFailed'))
} finally {
setLoading(false)
}
}, [t])

useEffect(() => {
if (canConnectAssistant) void refresh()
}, [canConnectAssistant, refresh])

if (!canConnectAssistant) {
return (
<>
{screen}
<View style={{ flex: 1, backgroundColor: colors.background }}>
<EmptyState
icon="key-outline"
title={t('connect.signIn.title')}
subtitle={t('connect.signIn.subtitle')}
buttonLabel={t('connect.signIn.cta')}
onButtonPress={() => router.push('/(auth)/login')}
/>
</View>
</>
)
}

const create = async () => {
setCreating(true)
setError(null)
try {
const key = await mcpKeysApi.createMcpKey(defaultKeyName(new Date()))
setCreated(key)
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : t('connect.createFailed'))
} finally {
setCreating(false)
}
}

const revoke = async (id: string) => {
setError(null)
try {
await mcpKeysApi.revokeMcpKey(id)
// A revoked key authenticates nothing; leaving it on screen invites pasting a dead string.
if (created && created.id === id) setCreated(null)
await refresh()
} catch {
setError(t('connect.revokeFailed'))
}
}

const copy = async (text: string) => {
await Clipboard.setStringAsync(text)
// 12 rather than the default: this screen has no tab bar under it.
showToast({ message: t('connect.copied'), variant: 'success', bottomOffset: 12 })
}

const live = liveKeys(keys)

return (
<>
{screen}
<ScrollView style={[styles.container, { backgroundColor: colors.background }]}>
<Text style={[styles.lead, { color: colors.textSecondary }]}>{t('connect.lead')}</Text>

{error && <Text style={[styles.error, { color: colors.error }]}>{error}</Text>}

{created && (
<View style={[styles.fresh, { borderColor: colors.primary, backgroundColor: colors.surface }]}>
<Text style={[styles.once, { color: colors.text }]}>{t('connect.shownOnce')}</Text>

<Text selectable style={[styles.key, { color: colors.text, backgroundColor: colors.background }]}>
{created.key}
</Text>
<TouchableOpacity
style={[styles.btn, { backgroundColor: colors.primary }]}
onPress={() => copy(created.key)}
accessibilityRole="button"
>
<Ionicons name="copy-outline" size={16} color="#fff" />
<Text style={[styles.btnText, { color: '#fff' }]}>{t('connect.copyKey')}</Text>
</TouchableOpacity>

<Text style={[styles.configLabel, { color: colors.textSecondary }]}>{t('connect.configLabel')}</Text>
<TouchableOpacity
style={[styles.btn, { backgroundColor: colors.surface, borderColor: colors.border, borderWidth: 1 }]}
onPress={() => copy(claudeDesktopConfig(created.key))}
accessibilityRole="button"
>
<Ionicons name="copy-outline" size={16} color={colors.text} />
<Text style={[styles.btnText, { color: colors.text }]}>{t('connect.copyConfig')}</Text>
</TouchableOpacity>
</View>
)}

<TouchableOpacity
style={[styles.btn, styles.create, { backgroundColor: colors.primary, opacity: creating ? 0.6 : 1 }]}
onPress={create}
disabled={creating}
accessibilityRole="button"
>
{creating
? <ActivityIndicator size="small" color="#fff" />
: <Ionicons name="key-outline" size={16} color="#fff" />}
<Text style={[styles.btnText, { color: '#fff' }]}>
{creating ? t('connect.creating') : t('connect.createCta')}
</Text>
</TouchableOpacity>

<Text style={[styles.endpointLabel, { color: colors.textSecondary }]}>{t('connect.endpointLabel')}</Text>
<Text selectable style={[styles.endpoint, { color: colors.text, backgroundColor: colors.surface }]}>
{MCP_ENDPOINT}
</Text>

{loading && live.length === 0 ? null : live.length === 0 ? (
<Text style={[styles.empty, { color: colors.textSecondary }]}>{t('connect.empty')}</Text>
) : (
live.map(k => (
<View key={k.id} style={[styles.row, { borderBottomColor: colors.border }]}>
<View style={{ flex: 1 }}>
<Text style={[styles.rowName, { color: colors.text }]}>{k.name}</Text>
<Text style={[styles.rowMeta, { color: colors.textSecondary }]}>
{k.prefix}… · {k.lastUsedAt ? t('connect.usedRecently') : t('connect.neverUsed')}
</Text>
</View>
<TouchableOpacity onPress={() => revoke(k.id)} accessibilityRole="button">
<Text style={[styles.revoke, { color: colors.error }]}>{t('connect.revoke')}</Text>
</TouchableOpacity>
</View>
))
)}

<View style={{ height: 40 }} />
</ScrollView>
</>
)
}

const styles = StyleSheet.create({
container: { flex: 1, padding: 16 },
lead: { fontSize: 14, lineHeight: 21, fontFamily: fonts.sans, marginBottom: 16 },
error: { fontSize: 13, fontFamily: fonts.sans, marginBottom: 12 },
fresh: { borderWidth: 1, borderRadius: 10, padding: 14, marginBottom: 16 },
once: { fontSize: 13, fontFamily: fonts.sansMedium, marginBottom: 10, lineHeight: 19 },
key: { fontSize: 12, fontFamily: 'Courier', padding: 10, borderRadius: 6, marginBottom: 10 },
configLabel: { fontSize: 12, fontFamily: fonts.sans, marginTop: 14, marginBottom: 6 },
btn: {
flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8,
paddingVertical: 11, paddingHorizontal: 16, borderRadius: 8,
},
btnText: { fontSize: 14, fontFamily: fonts.sansMedium },
create: { marginBottom: 20 },
endpointLabel: { fontSize: 12, fontFamily: fonts.sans, marginBottom: 6 },
endpoint: { fontSize: 12, fontFamily: 'Courier', padding: 10, borderRadius: 6, marginBottom: 20 },
empty: { fontSize: 13, fontFamily: fonts.sans },
row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 12, borderBottomWidth: 1 },
rowName: { fontSize: 14, fontFamily: fonts.sansMedium },
rowMeta: { fontSize: 12, fontFamily: fonts.sans, marginTop: 2 },
revoke: { fontSize: 13, fontFamily: fonts.sansMedium },
})
22 changes: 21 additions & 1 deletion apps/mobile/src/lib/__fixtures__/shared-catalog.golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -508,5 +508,25 @@
"vocabulary.weeklyBudget.backToReading": "Back to reading",
"vocabulary.weeklyBudget.emptyStateSubtitle": "You've hit your weekly review target. Read — new reviews unlock as old ones age out.",
"vocabulary.weeklyBudget.label": "This week",
"vocabulary.weeklyBudget.progress": "{used} / {budget}"
"vocabulary.weeklyBudget.progress": "{used} / {budget}",
"connect.title": "Connect an assistant",
"connect.lead": "Create a key and paste it into Claude or ChatGPT once. It can then read the books you upload and write conclusions back into them. The key does not expire — revoke it here whenever you like.",
"connect.createCta": "Create a key",
"connect.creating": "Creating…",
"connect.shownOnce": "Copy this now — it is shown once and cannot be recovered. We store only a hash of it.",
"connect.copyKey": "Copy key",
"connect.copyConfig": "Copy config",
"connect.configLabel": "Claude Desktop config, with your key already in it",
"connect.endpointLabel": "Server URL, if your client asks for one",
"connect.copied": "Copied",
"connect.empty": "No keys yet.",
"connect.revoke": "Revoke",
"connect.usedRecently": "used recently",
"connect.neverUsed": "never used",
"connect.loadFailed": "Could not load your keys.",
"connect.createFailed": "Could not create a key.",
"connect.revokeFailed": "Could not revoke that key.",
"connect.signIn.title": "Sign in to connect an assistant",
"connect.signIn.subtitle": "A key reaches your whole library and lasts until you revoke it, so it belongs to an account rather than a guest session.",
"connect.signIn.cta": "Sign in"
}
3 changes: 3 additions & 0 deletions apps/mobile/src/lib/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ describe('capabilitiesFor — the whole table', () => {
canEditIdentity: false,
canDeleteAccount: false,
canSyncAcrossDevices: false,
canConnectAssistant: false,
// Vacuous: nothing to sign out of, so nothing to warn about.
canSignOutSilently: true,
})
Expand All @@ -59,6 +60,7 @@ describe('capabilitiesFor — the whole table', () => {
canEditIdentity: false,
canDeleteAccount: false,
canSyncAcrossDevices: false,
canConnectAssistant: false,
canSignOutSilently: false,
})
})
Expand All @@ -73,6 +75,7 @@ describe('capabilitiesFor — the whole table', () => {
canEditIdentity: true,
canDeleteAccount: true,
canSyncAcrossDevices: true,
canConnectAssistant: true,
canSignOutSilently: true,
})
})
Expand Down
14 changes: 14 additions & 0 deletions apps/mobile/src/lib/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,16 @@ export interface Capabilities {
* hidden from one.
*/
canSyncAcrossDevices: boolean
/**
* Mint a connect key for an outside assistant (Claude, ChatGPT).
*
* Account-only, and for a sharper reason than the others here: a key reaches
* the reader's whole library from outside the app and lives until revoked. A
* guest's identity is three SecureStore keys that vanish with the app, so a
* key minted by one would outlive every means of revoking it — a durable
* credential hanging off a session that cannot be recovered.
*/
canConnectAssistant: boolean
/**
* Sign out without a confirmation step.
*
Expand Down Expand Up @@ -157,6 +167,10 @@ export function capabilitiesFor(user: UserDto | null): Capabilities {
canEditIdentity: isAccount,
canDeleteAccount: isAccount,
canSyncAcrossDevices: isAccount,
// A connect key reaches the reader's whole library from outside the app and never expires until
// revoked. A guest's identity is three SecureStore keys that vanish with the app, so a key
// minted by one would outlive any way of revoking it.
canConnectAssistant: isAccount,
// Note the asymmetry: `!isGuest`, not `isAccount`. Signed out is already
// signed out; only a guest has something irrecoverable to lose.
canSignOutSilently: !isGuest,
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/__tests__/noSharedApiOnWeb.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

/**
* The web app must not call `@textstack/shared`'s API clients.
*
* `packages/shared/src/api/*` routes every request through its `authFetch`, which reads base URL and
* token from `initApi()`. **Only the mobile app calls `initApi`** — the web app has its own
* `authFetch` because its token lives in a cookie (`credentials: 'include'`), not a header. A shared
* API call made from the web therefore throws "API not initialized" before it reaches the network.
*
* Not hypothetical. `BookInsightsSection` imported `insightsApi` from the shared package and
* swallowed the rejection with `.catch(() => {})` — a reasonable posture for a supplementary panel —
* so the конспект section, the destination of the entire assistant handoff, never rendered on the web
* at all. Its own unit test stayed green throughout, because it mocked `@textstack/shared` and so
* replaced the broken dependency with a working one. A test that mocks the thing that is broken
* cannot see it; this file looks at the imports instead.
*
* Types, pure helpers and constants from the shared package are fine and used everywhere. Only the
* api clients are the problem. Add one to this list the day it is written, not the day it breaks.
*/
const API_CLIENTS = [
'insightsApi', 'vocabularyApi', 'readingProgressApi', 'libraryApi',
'highlightsApi', 'userBooksApi', 'authApi', 'createBooksApi',
]

function* sourceFiles(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
if (entry === 'node_modules' || entry === 'dist' || entry === '__tests__') continue
const full = join(dir, entry)
if (statSync(full).isDirectory()) yield* sourceFiles(full)
else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) yield full
}
}

describe('web never calls the shared API clients', () => {
it('because the web app does not initialise them', () => {
// dirname(fileURLToPath(...)) rather than URL.pathname: the latter drops the leading
// path on some platforms and resolved to a bare '/src' here.
const root = dirname(dirname(fileURLToPath(import.meta.url)))
const offenders: string[] = []

for (const file of sourceFiles(root)) {
const src = readFileSync(file, 'utf8')
// Only imports FROM the shared package matter; a local symbol of the same name is fine.
const imports = src.match(/import\s+\{[^}]*\}\s+from\s+'@textstack\/shared'/g) ?? []
for (const stmt of imports) {
for (const client of API_CLIENTS) {
if (new RegExp(`\\b${client}\\b`).test(stmt)) {
offenders.push(`${relative(root, file)} imports ${client}`)
}
}
}
}

expect(offenders, offenders.join('\n')).toEqual([])
})
})
Loading
Loading