Skip to content

Commit fdfa055

Browse files
authored
fix(mobile): gate iOS browser entrypoints (#3693)
* fix(mobile): gate iOS browser entrypoints * fix(mobile): simplify denied auth copy * fix(mobile): simplify iOS KiloClaw access state * fix(auth): derive device auth app mode * fix(web): reuse privacy policy content * Revert "fix(web): reuse privacy policy content" This reverts commit 841e26c. * fix(web): add privacy policy fallback * fix(web): avoid privacy fallback email literal
1 parent a097a66 commit fdfa055

15 files changed

Lines changed: 419 additions & 48 deletions

apps/mobile/src/components/consent/consent-card.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,12 @@ import { type ConsentMode, getConsentActions } from '@/components/consent/consen
1010
import { Button } from '@/components/ui/button';
1111
import { Text } from '@/components/ui/text';
1212
import { useAuth } from '@/lib/auth/auth-context';
13+
import { WEB_BASE_URL } from '@/lib/config';
1314
import { acceptConsent, revokeConsent } from '@/lib/consent';
1415
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
1516
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
1617

17-
const PRIVACY_URL = 'https://kilo.ai/privacy';
18+
const PRIVACY_URL = `${WEB_BASE_URL}/privacy-app`;
1819

1920
type ConsentCardProps = {
2021
readonly mode?: ConsentMode;

apps/mobile/src/components/consent/consent-details.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import { Section } from '@/components/consent/section';
77
import { ScreenHeader } from '@/components/screen-header';
88
import { Button } from '@/components/ui/button';
99
import { Text } from '@/components/ui/text';
10+
import { WEB_BASE_URL } from '@/lib/config';
1011

11-
const PRIVACY_URL = 'https://kilo.ai/privacy';
12+
const PRIVACY_URL = `${WEB_BASE_URL}/privacy-app`;
1213

1314
export function ConsentDetails() {
1415
const router = useRouter();

apps/mobile/src/components/kiloclaw/access-required-screen.tsx

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
ShieldAlert,
99
} from 'lucide-react-native';
1010
import { useEffect, useRef } from 'react';
11-
import { Linking, View } from 'react-native';
11+
import { Linking, Platform, View } from 'react-native';
1212

1313
import { Button, type ButtonProps } from '@/components/ui/button';
1414
import { Text } from '@/components/ui/text';
@@ -22,67 +22,67 @@ import { WEB_BASE_URL } from '@/lib/config';
2222
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
2323
import { cn } from '@/lib/utils';
2424

25-
type CtaVariant = Extract<ButtonProps['variant'], 'default' | 'outline'>;
26-
2725
export type { AccessRequiredSubcase };
2826

27+
type CtaVariant = Extract<ButtonProps['variant'], 'default' | 'outline'>;
28+
2929
type SubcaseContent = {
30-
icon: LucideIcon;
31-
tone: ToneKey;
32-
title: string;
3330
body: string;
3431
ctaLabel: string;
3532
ctaVariant: CtaVariant;
33+
icon: LucideIcon;
34+
title: string;
35+
tone: ToneKey;
3636
};
3737

3838
const SUBCASE_CONTENT: Record<AccessRequiredSubcase, SubcaseContent> = {
3939
trial_expired: {
40-
icon: Clock,
41-
tone: 'warn',
42-
title: 'Subscribe on the web',
4340
body: "To keep using KiloClaw, go to kilo.ai/claw from your browser. You can't subscribe in the app.",
4441
ctaLabel: 'Open kilo.ai/claw',
4542
ctaVariant: 'default',
43+
icon: Clock,
44+
title: 'Subscribe on the web',
45+
tone: 'warn',
4646
},
4747
subscription_canceled: {
48-
icon: PauseCircle,
49-
tone: 'warn',
50-
title: 'Subscribe on the web',
5148
body: "To use KiloClaw, go to kilo.ai/claw from your browser. You can't subscribe in the app.",
5249
ctaLabel: 'Open kilo.ai/claw',
5350
ctaVariant: 'default',
51+
icon: PauseCircle,
52+
title: 'Subscribe on the web',
53+
tone: 'warn',
5454
},
5555
subscription_past_due: {
56-
icon: AlertTriangle,
57-
tone: 'danger',
58-
title: 'Update payment on the web',
5956
body: "We had trouble with your most recent payment. Go to kilo.ai/claw from your browser to update it. You can't manage billing in the app.",
6057
ctaLabel: 'Open kilo.ai/claw',
6158
ctaVariant: 'default',
59+
icon: AlertTriangle,
60+
title: 'Update payment on the web',
61+
tone: 'danger',
6262
},
6363
quarantined: {
64-
icon: ShieldAlert,
65-
tone: 'danger',
66-
title: 'Instance needs remediation',
6764
body: "Your KiloClaw instance is in a quarantined state and can't be used right now. Our team needs to help restore it.",
6865
ctaLabel: 'Continue on kilo.ai',
6966
ctaVariant: 'outline',
67+
icon: ShieldAlert,
68+
title: 'Instance needs remediation',
69+
tone: 'danger',
7070
},
7171
multiple_current_conflict: {
72-
icon: AlertTriangle,
73-
tone: 'warn',
74-
title: 'Account needs review',
7572
body: "We found more than one active subscription on your account, so we've paused things to avoid double-billing you.",
7673
ctaLabel: 'Continue on kilo.ai',
7774
ctaVariant: 'outline',
75+
icon: AlertTriangle,
76+
title: 'Account needs review',
77+
tone: 'warn',
7878
},
7979
non_canonical_earlybird: {
80-
icon: LifeBuoy,
81-
tone: 'warn',
82-
title: 'Legacy plan detected',
8380
body: 'Your early-access plan needs a manual review before it can be used on mobile.',
8481
ctaLabel: 'Continue on kilo.ai',
8582
ctaVariant: 'outline',
83+
icon: LifeBuoy,
84+
title: 'Legacy plan detected',
85+
tone: 'warn',
8686
},
8787
};
8888

@@ -119,6 +119,31 @@ export function AccessRequiredScreen({ subcase }: Readonly<AccessRequiredScreenP
119119
void Linking.openURL(target);
120120
};
121121

122+
if (Platform.OS === 'ios') {
123+
const iosTint = toneColor('warn');
124+
const iosIconColor = colors[iosTint.hueThemeKey];
125+
126+
return (
127+
<View className="w-full flex-1 items-center justify-center gap-6 px-6">
128+
<View
129+
className={cn(
130+
'h-24 w-24 items-center justify-center rounded-3xl border',
131+
iosTint.tileBgClass,
132+
iosTint.tileBorderClass
133+
)}
134+
>
135+
<AlertTriangle size={40} color={iosIconColor} />
136+
</View>
137+
<View className="items-center gap-2">
138+
<Text className="text-center text-2xl font-semibold">KiloClaw unavailable in iOS</Text>
139+
<Text variant="muted" className="text-center text-base">
140+
KiloClaw access is managed outside the iOS app for this account.
141+
</Text>
142+
</View>
143+
</View>
144+
);
145+
}
146+
122147
return (
123148
<View className="w-full flex-1 items-center justify-center gap-6 px-6">
124149
<View

apps/mobile/src/components/login-screen.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ function errorMessage(status: string, fallback: string | undefined) {
1919
return 'Your sign-in code has expired. Please try again.';
2020
}
2121
case 'denied': {
22-
return 'Access was denied. Please contact your administrator.';
22+
return 'Access was denied.';
2323
}
2424
default: {
2525
return fallback ?? 'Something went wrong. Please try again.';

apps/mobile/src/lib/auth/use-device-auth.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ export function useDeviceAuth(): DeviceAuthResult {
127127
});
128128

129129
try {
130-
const response = await fetch(`${API_BASE_URL}/api/device-auth/codes`, {
130+
const response = await fetch(`${API_BASE_URL}/api/device-auth/codes?app=1`, {
131131
method: 'POST',
132132
headers: { 'Content-Type': 'application/json' },
133133
});
@@ -156,7 +156,7 @@ export function useDeviceAuth(): DeviceAuthResult {
156156
const browserUrl =
157157
mode === 'signup'
158158
? `${WEB_BASE_URL}/users/sign_in?${new URLSearchParams({
159-
callbackPath: `/device-auth?code=${data.code}`,
159+
callbackPath: `/device-auth?code=${data.code}&app=1`,
160160
signup: 'true',
161161
}).toString()}`
162162
: data.verificationUrl;

apps/mobile/src/lib/kilo-pass/subscription-card-state.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,46 @@ describe('getKiloPassSubscriptionCardState', () => {
2424
});
2525
});
2626

27+
it('keeps Stripe-managed Kilo Pass inert on iOS', () => {
28+
expect(
29+
getKiloPassSubscriptionCardState(
30+
{
31+
cancelAtPeriodEnd: false,
32+
currentPeriodBaseCreditsUsd: 49,
33+
paymentProvider: 'stripe',
34+
refillAt: '2026-06-08T15:21:05.000Z',
35+
status: 'active',
36+
},
37+
{ platformOS: 'ios' }
38+
)
39+
).toEqual({
40+
action: 'none',
41+
actionLabel: null,
42+
description: '$49 monthly credits · This Kilo Pass is managed on web',
43+
title: 'Kilo Pass active',
44+
});
45+
});
46+
47+
it('keeps canceling Stripe-managed Kilo Pass inert on iOS', () => {
48+
expect(
49+
getKiloPassSubscriptionCardState(
50+
{
51+
cancelAtPeriodEnd: true,
52+
currentPeriodBaseCreditsUsd: 49,
53+
paymentProvider: 'stripe',
54+
refillAt: '2026-06-08T15:21:05.000Z',
55+
status: 'active',
56+
},
57+
{ platformOS: 'ios' }
58+
)
59+
).toEqual({
60+
action: 'none',
61+
actionLabel: null,
62+
description: '$49 monthly credits · Ends June 8, 2026 · This Kilo Pass is managed on web',
63+
title: 'Kilo Pass canceling',
64+
});
65+
});
66+
2767
it('keeps unsubscribed users on the App Store purchase path', () => {
2868
expect(getKiloPassSubscriptionCardState(null)).toEqual({
2969
action: 'open-store-sheet',

apps/mobile/src/lib/kilo-pass/subscription-card-state.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,17 @@ export function getKiloPassSubscriptionCardState(
239239
});
240240
}
241241

242+
if (options.platformOS === 'ios') {
243+
return {
244+
action: 'none',
245+
actionLabel: null,
246+
description: subscription.cancelAtPeriodEnd
247+
? `${credits} · Ends ${formatSubscriptionEndDate(subscription.refillAt)} · This Kilo Pass is managed on web`
248+
: `${credits} · This Kilo Pass is managed on web`,
249+
title: subscription.cancelAtPeriodEnd ? 'Kilo Pass canceling' : 'Kilo Pass active',
250+
};
251+
}
252+
242253
if (subscription.cancelAtPeriodEnd) {
243254
return {
244255
action: 'open-web-management',

apps/web/src/app/api/device-auth/codes/route.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ import { NextResponse } from 'next/server';
22
import { createDeviceAuthRequest } from '@/lib/device-auth/device-auth';
33
import { headers } from 'next/headers';
44
import { APP_URL } from '@/lib/constants';
5+
import {
6+
buildDeviceAuthVerificationUrl,
7+
getDeviceAuthAppModeFromRequestUrl,
8+
} from '@/app/device-auth/device-auth-url';
59

6-
export async function POST(_request: Request) {
10+
export async function POST(request: Request) {
711
const headersList = await headers();
812
const userAgent = headersList.get('user-agent') || undefined;
913
const ipAddress = headersList.get('x-forwarded-for') || undefined;
@@ -13,7 +17,9 @@ export async function POST(_request: Request) {
1317
ipAddress,
1418
});
1519

16-
const verificationUrl = `${APP_URL}/device-auth?code=${code}`;
20+
const verificationUrl = buildDeviceAuthVerificationUrl(APP_URL, code, {
21+
app: getDeviceAuthAppModeFromRequestUrl(request.url),
22+
});
1723

1824
return NextResponse.json({
1925
code,

apps/web/src/app/device-auth/DeviceAuthClient.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { describe, expect, test } from '@jest/globals';
22

3-
import { getDeviceAuthSignInUrl } from './DeviceAuthClient';
3+
import {
4+
buildDeviceAuthVerificationUrl,
5+
closeDeviceAuthWindowIfAppMode,
6+
getDeviceAuthAppModeFromRequestUrl,
7+
getDeviceAuthSignInUrl,
8+
getDeviceAuthShellClassName,
9+
} from './device-auth-url';
410

511
describe('getDeviceAuthSignInUrl', () => {
612
test('preserves the device auth code through sign in', () => {
@@ -14,4 +20,79 @@ describe('getDeviceAuthSignInUrl', () => {
1420
'/users/sign_in?callbackPath=%2Fdevice-auth%3Fcode%3Dabc%2B123'
1521
);
1622
});
23+
24+
test('preserves app mode through sign in', () => {
25+
expect(getDeviceAuthSignInUrl('ABC-123', { app: true })).toBe(
26+
'/users/sign_in?callbackPath=%2Fdevice-auth%3Fcode%3DABC-123%26app%3D1'
27+
);
28+
});
29+
});
30+
31+
describe('buildDeviceAuthVerificationUrl', () => {
32+
test('omits app mode by default for non-app callers', () => {
33+
expect(buildDeviceAuthVerificationUrl('https://app.kilo.ai', 'ABC-123')).toBe(
34+
'https://app.kilo.ai/device-auth?code=ABC-123'
35+
);
36+
});
37+
38+
test('adds the app mode query parameter for mobile browser launches', () => {
39+
expect(buildDeviceAuthVerificationUrl('https://app.kilo.ai', 'ABC-123', { app: true })).toBe(
40+
'https://app.kilo.ai/device-auth?code=ABC-123&app=1'
41+
);
42+
});
43+
});
44+
45+
describe('getDeviceAuthAppModeFromRequestUrl', () => {
46+
test('derives app mode from the API request URL', () => {
47+
expect(
48+
getDeviceAuthAppModeFromRequestUrl('https://app.kilo.ai/api/device-auth/codes?app=1')
49+
).toBe(true);
50+
});
51+
52+
test('leaves app mode off unless explicitly requested', () => {
53+
expect(getDeviceAuthAppModeFromRequestUrl('https://app.kilo.ai/api/device-auth/codes')).toBe(
54+
false
55+
);
56+
});
57+
});
58+
59+
describe('getDeviceAuthShellClassName', () => {
60+
test('uses page padding by default', () => {
61+
expect(getDeviceAuthShellClassName(false)).toContain('p-4');
62+
});
63+
64+
test('removes top and bottom page padding in app mode', () => {
65+
expect(getDeviceAuthShellClassName(true)).not.toContain('p-4');
66+
expect(getDeviceAuthShellClassName(true)).toContain('py-0');
67+
expect(getDeviceAuthShellClassName(true)).toContain('px-4');
68+
});
69+
70+
test('uses the dynamic viewport height to center app-mode authorization content', () => {
71+
expect(getDeviceAuthShellClassName(true)).toContain('h-dvh');
72+
expect(getDeviceAuthShellClassName(true)).toContain('w-full');
73+
expect(getDeviceAuthShellClassName(true)).toContain('items-center');
74+
expect(getDeviceAuthShellClassName(true)).toContain('justify-center');
75+
});
76+
});
77+
78+
describe('closeDeviceAuthWindowIfAppMode', () => {
79+
test('attempts to close the window in app mode', () => {
80+
let closeCount = 0;
81+
82+
closeDeviceAuthWindowIfAppMode(true, () => {
83+
closeCount++;
84+
});
85+
86+
expect(closeCount).toBe(1);
87+
});
88+
89+
test('does not close the window outside app mode', () => {
90+
let closeCount = 0;
91+
92+
closeDeviceAuthWindowIfAppMode(false, () => {
93+
closeCount++;
94+
});
95+
96+
expect(closeCount).toBe(0);
97+
});
1798
});

0 commit comments

Comments
 (0)