Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d6036c7
feat(db): add attempts column to magic_link_tokens
iscekic Jul 8, 2026
9b5c751
refactor(web): extract apple JWKS verify and email sign-in eligibilit…
iscekic Jul 8, 2026
1b3a396
refactor(web): use typed AppleJwtClientError instead of error-message…
iscekic Jul 8, 2026
20e12bc
feat(web): email sign-in code issuance for native auth
iscekic Jul 8, 2026
651916a
fix(web): re-check sign-in code attempt budget atomically in consume …
iscekic Jul 8, 2026
5a0eaec
feat(web): native auth token exchange endpoint
iscekic Jul 8, 2026
ff5729a
fix(web): distinguish provider outages from invalid native tokens
iscekic Jul 8, 2026
48fed8f
feat(mobile): add google-signin SDK and optional google client-id config
iscekic Jul 8, 2026
1acf5b4
feat(mobile): native Apple/Google/email sign-in on login screen
iscekic Jul 8, 2026
c17aba9
fix(mobile): surface toast when signIn fails during email code verify
iscekic Jul 8, 2026
a257b1f
fix(web): enforce SSO/blacklist on native Apple/Google sign-in
iscekic Jul 8, 2026
0d4e636
chore(mobile): drop unused export on AUTH_ERROR_MESSAGES
iscekic Jul 8, 2026
5ea8053
fix(mobile): add modular_headers for GoogleSignin transitive pods
iscekic Jul 9, 2026
f6af69b
fix(mobile): avoid NativeWind textAlign crash on OTP code input
iscekic Jul 9, 2026
22e5c30
refactor(mobile): move Restore Purchases below Notifications on Profile
iscekic Jul 9, 2026
946f585
docs(mobile/e2e): document Android emulator + Maestro setup
iscekic Jul 9, 2026
b0222e2
fix(web): hardcode native Apple client ID
iscekic Jul 9, 2026
e75ce54
feat(mobile): branded Google button, profile action grid, Play Servic…
iscekic Jul 9, 2026
a2bec83
fix(mobile): match Google button label size to native Apple button
iscekic Jul 9, 2026
ef791a1
fix(auth): harden native token exchange and OTP flow
iscekic Jul 9, 2026
745450a
fix(db): keep native auth migration additive
iscekic Jul 9, 2026
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
6 changes: 6 additions & 0 deletions apps/mobile/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ KILO_CHAT_URL=http://localhost:8808
EVENT_SERVICE_URL=ws://localhost:8809
NOTIFICATIONS_URL=http://localhost:8804
POSTHOG_API_KEY=phc_GK2Pxl0HPj5ZPfwhLRjXrtdz8eD7e9MKnXiFrOqnB6z

# Optional — native Google sign-in. Leave blank until the Google OAuth clients
# exist; the sign-in button hides itself when unset. Same values as the
# backend's GOOGLE_CLIENT_ID / GOOGLE_IOS_CLIENT_ID.
GOOGLE_WEB_CLIENT_ID=
GOOGLE_IOS_CLIENT_ID=
28 changes: 27 additions & 1 deletion apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ExpoConfig } from 'expo/config';
import { ENV_KEYS } from './src/lib/env-keys';
import { ENV_KEYS, OPTIONAL_ENV_KEYS } from './src/lib/env-keys';

const missing = Object.values(ENV_KEYS).filter(key => !process.env[key]);
if (missing.length > 0) {
Expand All @@ -11,6 +11,16 @@ if (missing.length > 0) {
}
}

// ponytail: Google OAuth client IDs aren't created yet — plugin/config below tolerate absence
// so the app still builds; the native Google button (Task 6) hides itself when undefined.
const googleIosClientId = process.env[OPTIONAL_ENV_KEYS.googleIosClientId];
const googleIosUrlScheme = googleIosClientId
? `com.googleusercontent.apps.${googleIosClientId.replace(/\.apps\.googleusercontent\.com$/, '')}`
: undefined;
const googleSignInPlugins: NonNullable<ExpoConfig['plugins']> = googleIosUrlScheme
? [['@react-native-google-signin/google-signin', { iosUrlScheme: googleIosUrlScheme }]]
: [];

const config: ExpoConfig = {
name: 'Kilo',
owner: 'kilocode',
Expand Down Expand Up @@ -60,6 +70,16 @@ const config: ExpoConfig = {
android: {
enableProguardInReleaseBuilds: true,
},
ios: {
// GoogleSignIn is a Swift static lib that imports GoogleUtilities/RecaptchaInterop
// (pulled transitively alongside expo-iap's AppCheckCore); those pods don't define
// modules, so pod install fails unless we force module maps on them. Unconditional
// because the google-signin pod autolinks whether or not the OAuth client is set.
extraPods: [
{ name: 'GoogleUtilities', modular_headers: true },
{ name: 'RecaptchaInterop', modular_headers: true },
],
},
},
],
'expo-router',
Expand Down Expand Up @@ -112,13 +132,19 @@ const config: ExpoConfig = {
],
['react-native-appsflyer', {}],
'./plugins/withAndroidManifestFix',
// ponytail: only registered when GOOGLE_IOS_CLIENT_ID is set, so prebuild works before the
// Google OAuth clients exist.
...googleSignInPlugins,
],
experiments: {
typedRoutes: true,
reactCompiler: true,
},
extra: {
...Object.fromEntries(Object.entries(ENV_KEYS).map(([key, env]) => [key, process.env[env]])),
...Object.fromEntries(
Object.entries(OPTIONAL_ENV_KEYS).map(([key, env]) => [key, process.env[env]])
),
router: {},
eas: {
projectId: '2cf05e39-90b5-48a5-a8a5-e0b3423cf3f4',
Expand Down
40 changes: 38 additions & 2 deletions apps/mobile/e2e/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ pnpm dev:env:mobile # writes apps/mobile/.env.local with LAN-IP URLs for all l

Restart the app (or reload from the Expo dev server) after this so Expo picks up the env file.

## 2. App on the simulator, signed in
## 2. App on the device, signed in

- The app is the dev-build `com.kilocode.kiloapp` on an iOS simulator. Check booted simulators for it with `xcrun simctl listapps booted | grep kilocode`. If no simulator has the build, install one with `npx expo run:ios` from `apps/mobile/` (the `ios/` project is checked in).
- **iOS**: the app is the dev-build `com.kilocode.kiloapp` on an iOS simulator. Check booted simulators for it with `xcrun simctl listapps booted | grep kilocode`. If no simulator has the build, install one with `npx expo run:ios` from `apps/mobile/` (the `ios/` project is checked in).
- **Android**: see [§6 Android](#6-android-emulator) for the one-time toolchain setup and the emulator/dev-client workflow (`android/` is generated by prebuild and git-ignored — it is not checked in).
- Sign in with **fake-login**: the local sign-in page (reached via the app's normal sign-in flow) has a "Test Account" form — enter any email (an account is created on first login, e.g. `e2e-mobile@example.com`); appending `?fakeUser=<email>` to the sign-in URL auto-submits.
- Seed data with `pnpm dev:seed` (topics in `dev/seed/`): `app:user-id <email>` to look up a user id, `app:add-credits <userId> <usd>` if completions return 402. Local Postgres is `postgres://postgres:postgres@localhost:5432/postgres`.

Expand Down Expand Up @@ -90,11 +91,46 @@ Then register the MCP server with your harness: stdio transport, command `maestr

**Usage**: `list_devices` → pick the booted simulator's device id → `inspect_screen` before targeting any element → `run` with inline YAML. Flows declare `appId: com.kilocode.kiloapp` and start with `launchApp`. Use `cheat_sheet` for unfamiliar Maestro commands.

If the MCP driver can't connect (`Failed to connect to 127.0.0.1:22087`), fall back to the CLI: `maestro --device <udid|emulator-5554> test <flow.yaml>` (pass flow variables with `-e KEY=VAL`). Capture screenshots with `xcrun simctl io <udid> screenshot <path>` (iOS) or `adb exec-out screencap -p > <path>` (Android).

Gotchas:

- Cold relaunch (`simctl terminate` + `launch`, or `launchApp` with `clearState: false`) restores navigation state — the app reopens on the last screen. Navigate back explicitly before testing a flow that assumes a starting screen.
- `simctl io recordVideo` is flaky (1-frame videos after first use). For capturing transitions, loop `simctl io screenshot` in the background (~7.5 fps) and assemble with ffmpeg.
- Re-inspect the screen after every UI change; element ids/text from a stale inspect will miss.
- Elements are matched by `accessibilityLabel` via the `text:` selector (the app has no testIDs); `id:` does not match a11y labels.
- Attach screenshots from the e2e run to the PR description where applicable — e.g. the screen a UI change touches, or a key step of the flow being verified. They're the evidence the flow actually works and make review faster.

## 6. Android emulator

One-time toolchain setup (Homebrew):

```bash
brew install --cask android-commandlinetools
brew install --cask temurin@17 # RN/Gradle require JDK 17; newer JDKs fail with
# "JvmVendorSpec ... IBM_SEMERU" during the Gradle build
export ANDROID_HOME=/opt/homebrew/share/android-commandlinetools
export ANDROID_SDK_ROOT=$ANDROID_HOME
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator:$PATH"
yes | sdkmanager --licenses
sdkmanager "platform-tools" "emulator" "platforms;android-35" "system-images;android-35;google_apis;arm64-v8a"
echo no | avdmanager create avd -n kilo_pixel7 -k "system-images;android-35;google_apis;arm64-v8a" -d pixel_7
```

Boot + build + run (build with JDK 17 pinned):

```bash
export JAVA_HOME=$(/usr/libexec/java_home -v 17)
emulator -avd kilo_pixel7 -no-snapshot-save -no-boot-anim -gpu swiftshader_indirect &
adb wait-for-device
cd apps/mobile && set -a; source .env.local; set +a
npx expo run:android --no-install --no-bundler # reuses the Metro already on :8081
```

- Android maps `localhost` to the emulator itself, so route host services through adb: `adb reverse tcp:3000 tcp:3000 && adb reverse tcp:8081 tcp:8081` (Next + Metro). Re-run after any full app-data clear.
- The dev-client deep link (to re-point at localhost Metro) uses scheme `exp+kilo-app`, not the app's `kiloapp` scheme: `adb shell am start -a android.intent.action.VIEW -d "exp+kilo-app://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"`.
- Reset auth/state to the login screen with `adb shell pm clear com.kilocode.kiloapp` — this also wipes the dev-client's saved packager URL, so re-deep-link (above) and re-add the `adb reverse` maps afterward. First launch after a clear shows the Expo dev menu; dismiss it (tap **Continue**).
- The browser fallback ("More sign-in options") opens the system browser at `WEB_BASE_URL`. `.env.local` defaults it to a LAN IP; set it to a value the emulator can reach (or complete that leg on iOS) for full browser sign-in.

## 5. Cleaning up

Expand Down
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@kilocode/notifications": "workspace:*",
"@kilocode/trpc": "workspace:*",
"@react-native-community/netinfo": "11.5.2",
"@react-native-google-signin/google-signin": "^16.1.2",
"@rn-primitives/portal": "1.3.0",
"@rn-primitives/slot": "1.2.0",
"@sentry/react-native": "~7.11.0",
Expand Down
17 changes: 10 additions & 7 deletions apps/mobile/src/components/login-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import * as Clipboard from 'expo-clipboard';
import { ExternalLink } from 'lucide-react-native';
import { useEffect } from 'react';
import { ActivityIndicator, View } from 'react-native';
import { ActivityIndicator, ScrollView, View } from 'react-native';
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
import { toast } from 'sonner-native';

import logo from '@/../assets/images/logo.png';
import { IdleAuth } from '@/components/login/idle-auth';
import { Button } from '@/components/ui/button';
import { Image } from '@/components/ui/image';
import { Text } from '@/components/ui/text';
Expand Down Expand Up @@ -70,7 +71,12 @@ export function LoginScreen() {
}

return (
<View className="flex-1 items-center justify-center gap-6 bg-background px-6">
<ScrollView
className="flex-1 bg-background"
contentContainerClassName="flex-grow items-center justify-center gap-6 px-6"
automaticallyAdjustKeyboardInsets
keyboardShouldPersistTaps="handled"
>
<View className="items-center gap-2">
<Image source={logo} className="mb-1 h-16 w-16" accessibilityLabel="Kilo logo" />
<Text className="text-lg">Welcome to Kilo Code</Text>
Expand All @@ -83,10 +89,7 @@ export function LoginScreen() {
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
>
<AuthButtons start={start} />
<Text variant="muted" className="text-center text-xs">
You will be redirected to your browser
</Text>
<IdleAuth start={start} />
</Animated.View>
)}

Expand Down Expand Up @@ -163,6 +166,6 @@ export function LoginScreen() {
</Animated.View>
)}
</Animated.View>
</View>
</ScrollView>
);
}
78 changes: 78 additions & 0 deletions apps/mobile/src/components/login/email-otp-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { useRef, useState } from 'react';
import { ActivityIndicator, TextInput, View } from 'react-native';

import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
import { type useNativeAuth } from '@/lib/auth/use-native-auth';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { canSubmitEmailCode } from './email-otp-state';

export function EmailOtpForm({
email,
busy,
onVerify,
onResend,
onBack,
}: Readonly<{
email: string;
busy: ReturnType<typeof useNativeAuth>['busy'];
onVerify: (code: string) => void;
onResend: () => void;
onBack: () => void;
}>) {
const colors = useThemeColors();
const codeRef = useRef('');
const [hasCompleteCode, setHasCompleteCode] = useState(false);
const authBusy = busy !== undefined;

return (
<View className="gap-3">
<Text variant="muted" className="text-center text-sm">
Enter the code sent to {email}
</Text>
<TextInput
className="h-12 rounded-md border border-input bg-background px-3 text-lg leading-5 tracking-widest text-foreground"
// textAlign is applied inline, not via a `text-center` class: NativeWind maps
// textAlign to a native prop for TextInput and crashes on it in this version.
// eslint-disable-next-line react-native/no-inline-styles -- see comment above
style={{ textAlign: 'center' }}
placeholder="123456"
placeholderTextColor={colors.mutedForeground}
keyboardType="number-pad"
maxLength={6}
onChangeText={value => {
codeRef.current = value;
setHasCompleteCode(/^\d{6}$/.test(value));
}}
accessibilityLabel="Sign-in code"
/>
<Button
size="lg"
className="flex-row gap-2"
disabled={!hasCompleteCode || authBusy}
onPress={() => {
if (canSubmitEmailCode(codeRef.current, busy)) {
onVerify(codeRef.current);
}
}}
accessibilityLabel="Verify code"
>
{busy === 'otp-verify' ? <ActivityIndicator size="small" /> : null}
<Text>Verify code</Text>
</Button>
<Button
variant="outline"
className="flex-row gap-2"
disabled={authBusy}
onPress={onResend}
accessibilityLabel="Resend code"
>
{busy === 'otp-send' ? <ActivityIndicator size="small" /> : null}
<Text>Resend code</Text>
</Button>
<Button variant="ghost" disabled={authBusy} onPress={onBack} accessibilityLabel="Back">
<Text>Back</Text>
</Button>
</View>
);
}
14 changes: 14 additions & 0 deletions apps/mobile/src/components/login/email-otp-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { canSubmitEmailCode } from './email-otp-state';

describe('canSubmitEmailCode', () => {
it.each(['', '1', '12345', '12345a', '1234567'])('rejects %j', code => {
expect(canSubmitEmailCode(code)).toBe(false);
});

it('accepts exactly six digits when no auth action is busy', () => {
expect(canSubmitEmailCode('123456')).toBe(true);
expect(canSubmitEmailCode('123456', 'otp-send')).toBe(false);
expect(canSubmitEmailCode('123456', 'otp-verify')).toBe(false);
});
});
8 changes: 8 additions & 0 deletions apps/mobile/src/components/login/email-otp-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { type useNativeAuth } from '@/lib/auth/use-native-auth';

export function canSubmitEmailCode(
code: string,
busy?: ReturnType<typeof useNativeAuth>['busy']
): boolean {
return busy === undefined && /^\d{6}$/.test(code);
}
25 changes: 25 additions & 0 deletions apps/mobile/src/components/login/google-logo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Svg, { Path } from 'react-native-svg';

// Official Google "G" mark (4-color). Fixed brand colors — do not theme.
export function GoogleLogo({ size = 18 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 48 48">
<Path
fill="#EA4335"
d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"
/>
<Path
fill="#4285F4"
d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"
/>
<Path
fill="#FBBC05"
d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"
/>
<Path
fill="#34A853"
d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"
/>
</Svg>
);
}
Loading
Loading