Skip to content

Commit b20a0f2

Browse files
dfallingclaude
andcommitted
Add authentication with token rotation and App Links
- Login screen using GraphQL mutation against the post-overhaul auth API (accessToken + refreshToken + expiresAt). - Token store backed by EncryptedSharedPreferences via react-native-encrypted-storage; survives app restarts. - Apollo auth link attaches Bearer access token, proactively refreshes within 60s of expiry, single-flight mutex on renewal so concurrent requests don't trip TOKEN_REUSE_DETECTED. - Apollo error link branches on extensions.code: refresh + replay on TOKEN_EXPIRED/UNAUTHENTICATED, clear tokens on REVOKED/INVALID/ UNCONFIRMED, raise a security warning on REUSE_DETECTED. - Logout calls the server with the refresh token before clearing local tokens and the Apollo cache. - App Links: manifest intent filters for /users/confirm/* and /users/reset_password/*, plus a JS-side deep link receiver that captures the token into a reactive var for future confirm/reset screens to consume. - Fix dev API: localhost -> 10.0.2.2 (emulator's host loopback), path /api/graphql -> /graphql/v1. - Drop stale ping.graphql (referenced a removed schema field). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0dd689e commit b20a0f2

15 files changed

Lines changed: 1345 additions & 54 deletions

File tree

App.tsx

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
11
/**
2-
* Sample React Native App
3-
* https://github.com/facebook/react-native
2+
* Culpeos React Native app entry.
43
*
54
* @format
65
*/
76

87
import {ApolloProvider} from '@apollo/client';
98
import {NewAppScreen} from '@react-native/new-app-screen';
10-
import {StatusBar, StyleSheet, useColorScheme, View} from 'react-native';
9+
import {useEffect} from 'react';
10+
import {
11+
ActivityIndicator,
12+
Pressable,
13+
StatusBar,
14+
StyleSheet,
15+
Text,
16+
useColorScheme,
17+
View,
18+
} from 'react-native';
1119
import {
1220
SafeAreaProvider,
1321
useSafeAreaInsets,
1422
} from 'react-native-safe-area-context';
15-
import {apolloClient} from './src/graphql/client';
23+
import {apolloClient, logout} from './src/auth/authClient';
24+
import {useDeepLinkListener} from './src/auth/deepLinks';
25+
import {LoginScreen} from './src/auth/LoginScreen';
26+
import {tokenStore, useAuth, useAuthHydrated} from './src/auth/tokenStore';
1627

1728
function App() {
1829
const isDarkMode = useColorScheme() === 'dark';
@@ -28,14 +39,49 @@ function App() {
2839
}
2940

3041
function AppContent() {
42+
const hydrated = useAuthHydrated();
43+
const auth = useAuth();
3144
const safeAreaInsets = useSafeAreaInsets();
3245

46+
useDeepLinkListener();
47+
48+
useEffect(() => {
49+
tokenStore.hydrate();
50+
}, []);
51+
52+
if (!hydrated) {
53+
return (
54+
<View style={[styles.container, styles.splash]}>
55+
<ActivityIndicator />
56+
</View>
57+
);
58+
}
59+
60+
if (!auth) {
61+
return <LoginScreen />;
62+
}
63+
3364
return (
3465
<View style={styles.container}>
3566
<NewAppScreen
3667
templateFileName="App.tsx"
3768
safeAreaInsets={safeAreaInsets}
3869
/>
70+
<View
71+
style={[styles.logoutBar, {paddingBottom: safeAreaInsets.bottom + 12}]}>
72+
<Text style={styles.signedInAs}>Signed in as {auth.user.email}</Text>
73+
<Pressable
74+
accessibilityRole="button"
75+
onPress={() => {
76+
logout();
77+
}}
78+
style={({pressed}) => [
79+
styles.logoutButton,
80+
pressed && styles.logoutPressed,
81+
]}>
82+
<Text style={styles.logoutText}>Log out</Text>
83+
</Pressable>
84+
</View>
3985
</View>
4086
);
4187
}
@@ -44,6 +90,41 @@ const styles = StyleSheet.create({
4490
container: {
4591
flex: 1,
4692
},
93+
splash: {
94+
alignItems: 'center',
95+
justifyContent: 'center',
96+
},
97+
logoutBar: {
98+
position: 'absolute',
99+
left: 0,
100+
right: 0,
101+
bottom: 0,
102+
paddingHorizontal: 16,
103+
paddingTop: 12,
104+
backgroundColor: 'rgba(255,255,255,0.95)',
105+
borderTopWidth: StyleSheet.hairlineWidth,
106+
borderTopColor: '#ccc',
107+
flexDirection: 'row',
108+
alignItems: 'center',
109+
justifyContent: 'space-between',
110+
},
111+
signedInAs: {
112+
fontSize: 12,
113+
color: '#444',
114+
flex: 1,
115+
marginRight: 12,
116+
},
117+
logoutButton: {
118+
paddingHorizontal: 12,
119+
paddingVertical: 8,
120+
borderRadius: 6,
121+
backgroundColor: '#eee',
122+
},
123+
logoutPressed: {opacity: 0.7},
124+
logoutText: {
125+
fontSize: 14,
126+
color: '#222',
127+
},
47128
});
48129

49130
export default App;

android/app/src/main/AndroidManifest.xml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,20 @@
2222
<action android:name="android.intent.action.MAIN" />
2323
<category android:name="android.intent.category.LAUNCHER" />
2424
</intent-filter>
25+
<!-- App Links: open culpeos.com confirm/reset emails in this app.
26+
Requires /.well-known/assetlinks.json on the server to list the
27+
signing keystore's SHA-256 fingerprint for com.culpeos.app. -->
28+
<intent-filter android:autoVerify="true">
29+
<action android:name="android.intent.action.VIEW" />
30+
<category android:name="android.intent.category.DEFAULT" />
31+
<category android:name="android.intent.category.BROWSABLE" />
32+
<data android:scheme="https"
33+
android:host="www.culpeos.com"
34+
android:pathPrefix="/users/confirm/" />
35+
<data android:scheme="https"
36+
android:host="www.culpeos.com"
37+
android:pathPrefix="/users/reset_password/" />
38+
</intent-filter>
2539
</activity>
2640
</application>
2741
</manifest>

bun.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@
1212
"codegen": "graphql-codegen --config codegen.ts"
1313
},
1414
"dependencies": {
15+
"@apollo/client": "^3.11.0",
16+
"@react-native/new-app-screen": "0.85.3",
17+
"graphql": "^16.9.0",
1518
"react": "19.2.3",
1619
"react-native": "0.85.3",
17-
"@react-native/new-app-screen": "0.85.3",
18-
"react-native-safe-area-context": "^5.5.2",
19-
"@apollo/client": "^3.11.0",
20-
"graphql": "^16.9.0"
20+
"react-native-encrypted-storage": "^4.0.3",
21+
"react-native-safe-area-context": "^5.5.2"
2122
},
2223
"devDependencies": {
2324
"@babel/core": "^7.25.2",

src/auth/LoginScreen.tsx

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import {useState} from 'react';
2+
import {
3+
ActivityIndicator,
4+
KeyboardAvoidingView,
5+
Platform,
6+
Pressable,
7+
StyleSheet,
8+
Text,
9+
TextInput,
10+
View,
11+
} from 'react-native';
12+
import {useLoginMutation} from '../graphql/__generated__/types';
13+
import {clearSecurityWarning, useSecurityWarning} from './authClient';
14+
import {tokenStore} from './tokenStore';
15+
16+
function deviceLabel(): string {
17+
// The guide recommends device model + Android version; we don't have a
18+
// device-info module installed, so fall back to the OS version. Users can
19+
// still identify the entry in the session list.
20+
return `Android ${Platform.Version}`;
21+
}
22+
23+
function errorCode(err: unknown): string | undefined {
24+
const arr =
25+
err && typeof err === 'object' && 'graphQLErrors' in err
26+
? (err as {graphQLErrors?: ReadonlyArray<{extensions?: {code?: string}}>})
27+
.graphQLErrors
28+
: undefined;
29+
return arr?.[0]?.extensions?.code;
30+
}
31+
32+
function messageForCode(code: string | undefined): string {
33+
switch (code) {
34+
case 'INVALID_CREDENTIALS':
35+
return 'Invalid email or password.';
36+
case 'ACCOUNT_UNCONFIRMED':
37+
return 'Please confirm your email address before logging in.';
38+
case 'RATE_LIMITED':
39+
return 'Too many attempts. Please wait a minute and try again.';
40+
default:
41+
return 'Login failed. Please try again.';
42+
}
43+
}
44+
45+
export function LoginScreen() {
46+
const [email, setEmail] = useState('');
47+
const [password, setPassword] = useState('');
48+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
49+
const [login, {loading}] = useLoginMutation();
50+
const securityWarning = useSecurityWarning();
51+
52+
const canSubmit = email.length > 0 && password.length > 0 && !loading;
53+
54+
async function onSubmit() {
55+
setErrorMessage(null);
56+
clearSecurityWarning();
57+
try {
58+
const {data} = await login({
59+
variables: {input: {email, password, deviceLabel: deviceLabel()}},
60+
});
61+
if (!data?.login) {
62+
setErrorMessage('Login failed. Please try again.');
63+
return;
64+
}
65+
tokenStore.set({
66+
accessToken: data.login.accessToken,
67+
refreshToken: data.login.refreshToken,
68+
expiresAtMs: new Date(data.login.expiresAt).getTime(),
69+
user: data.login.user,
70+
});
71+
} catch (err) {
72+
setErrorMessage(messageForCode(errorCode(err)));
73+
}
74+
}
75+
76+
return (
77+
<KeyboardAvoidingView
78+
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
79+
style={styles.flex}>
80+
<View style={styles.container}>
81+
<Text style={styles.title}>Sign in to Culpeos</Text>
82+
83+
<TextInput
84+
style={styles.input}
85+
placeholder="Email"
86+
autoCapitalize="none"
87+
autoCorrect={false}
88+
autoComplete="email"
89+
keyboardType="email-address"
90+
value={email}
91+
onChangeText={setEmail}
92+
editable={!loading}
93+
/>
94+
<TextInput
95+
style={styles.input}
96+
placeholder="Password"
97+
autoCapitalize="none"
98+
autoCorrect={false}
99+
autoComplete="password"
100+
secureTextEntry
101+
value={password}
102+
onChangeText={setPassword}
103+
editable={!loading}
104+
onSubmitEditing={canSubmit ? onSubmit : undefined}
105+
/>
106+
107+
{securityWarning ? (
108+
<Text style={styles.warning}>{securityWarning}</Text>
109+
) : null}
110+
{errorMessage ? <Text style={styles.error}>{errorMessage}</Text> : null}
111+
112+
<Pressable
113+
accessibilityRole="button"
114+
disabled={!canSubmit}
115+
onPress={onSubmit}
116+
style={({pressed}) => [
117+
styles.button,
118+
!canSubmit && styles.buttonDisabled,
119+
pressed && canSubmit && styles.buttonPressed,
120+
]}>
121+
{loading ? (
122+
<ActivityIndicator color="#fff" />
123+
) : (
124+
<Text style={styles.buttonText}>Log in</Text>
125+
)}
126+
</Pressable>
127+
</View>
128+
</KeyboardAvoidingView>
129+
);
130+
}
131+
132+
const styles = StyleSheet.create({
133+
flex: {flex: 1},
134+
container: {
135+
flex: 1,
136+
padding: 24,
137+
justifyContent: 'center',
138+
gap: 12,
139+
},
140+
title: {
141+
fontSize: 24,
142+
fontWeight: '600',
143+
marginBottom: 16,
144+
textAlign: 'center',
145+
},
146+
input: {
147+
borderWidth: 1,
148+
borderColor: '#ccc',
149+
borderRadius: 8,
150+
paddingHorizontal: 12,
151+
paddingVertical: 12,
152+
fontSize: 16,
153+
},
154+
error: {
155+
color: '#c00',
156+
fontSize: 14,
157+
textAlign: 'center',
158+
},
159+
warning: {
160+
color: '#a55',
161+
fontSize: 13,
162+
textAlign: 'center',
163+
fontStyle: 'italic',
164+
},
165+
button: {
166+
backgroundColor: '#0a7ea4',
167+
borderRadius: 8,
168+
paddingVertical: 14,
169+
alignItems: 'center',
170+
marginTop: 8,
171+
},
172+
buttonDisabled: {opacity: 0.5},
173+
buttonPressed: {opacity: 0.85},
174+
buttonText: {
175+
color: '#fff',
176+
fontSize: 16,
177+
fontWeight: '600',
178+
},
179+
});

0 commit comments

Comments
 (0)