[object Object]

← back to CelebritySignatures

celeb mobile: add Sign in with Apple button to AccountScreen — appleSignIn() runs ASWebAuthenticationSession (expo-web-browser) then replays /auth/handoff so cs_sess lands in the native cookie jar; button hidden until /api/auth-config reports apple:true (v1.1, staged — not submitted)

5c6df61c0be2c96e63b0d732b3d8eede704fd1b1 · 2026-08-07 10:08:25 -0700 · Steve

Files touched

Diff

commit 5c6df61c0be2c96e63b0d732b3d8eede704fd1b1
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 10:08:25 2026 -0700

    celeb mobile: add Sign in with Apple button to AccountScreen — appleSignIn() runs ASWebAuthenticationSession (expo-web-browser) then replays /auth/handoff so cs_sess lands in the native cookie jar; button hidden until /api/auth-config reports apple:true (v1.1, staged — not submitted)
---
 apps/mobile/api.ts                    | 29 +++++++++++++++++++
 apps/mobile/app.json                  |  3 +-
 apps/mobile/package-lock.json         | 11 +++++++
 apps/mobile/package.json              |  1 +
 apps/mobile/screens/AccountScreen.tsx | 54 ++++++++++++++++++++++++++++++++++-
 5 files changed, 96 insertions(+), 2 deletions(-)

diff --git a/apps/mobile/api.ts b/apps/mobile/api.ts
index b38d85f..5b795e0 100644
--- a/apps/mobile/api.ts
+++ b/apps/mobile/api.ts
@@ -3,10 +3,37 @@
 // own cookie jar, so the cs_sess auth cookie set by /api/auth/* is retained
 // across fetches automatically (no CORS constraints apply to a native client).
 import Constants from 'expo-constants';
+import * as WebBrowser from 'expo-web-browser';
 
 export const WEB_BASE: string =
   (Constants.expoConfig?.extra as any)?.webBase ?? 'https://celebsignatures.com';
 
+/**
+ * Sign in with Apple (native). Apple/Google reject OAuth inside an embedded
+ * WebView, so we run the web sign-in flow in ASWebAuthenticationSession, then
+ * replay the one-time handoff token from THIS native fetch — so the cs_sess
+ * cookie lands in RN's shared cookie jar (the same jar every api call + the
+ * checkout WebView use). Returns {ok:false, error:'canceled'} on user cancel.
+ */
+export async function appleSignIn(): Promise<{ ok: boolean; error?: string }> {
+  try {
+    const result = await WebBrowser.openAuthSessionAsync(
+      `${WEB_BASE}/auth/apple/login?app=1`,
+      'celebsignatures://auth',
+    );
+    if (result.type === 'cancel' || result.type === 'dismiss') return { ok: false, error: 'canceled' };
+    if (result.type !== 'success' || !result.url) return { ok: false, error: 'Sign in was not completed.' };
+    const token = new URL(result.url).searchParams.get('token');
+    if (!token) return { ok: false, error: 'No sign-in token returned.' };
+    // Replay the handoff (302 + Set-Cookie); RN's fetch stores the cookie from
+    // the redirect chain into the shared native jar.
+    await fetch(`${WEB_BASE}/auth/handoff?token=${encodeURIComponent(token)}`);
+    return { ok: true };
+  } catch (e) {
+    return { ok: false, error: e instanceof Error ? e.message : String(e) };
+  }
+}
+
 export type Signature = {
   category: string;
   rank?: number;
@@ -86,6 +113,8 @@ export const api = {
   leaderboardSubmit: (game: string, diff: string, score: number, name: string) =>
     postJSON<{ ok: boolean; rank?: number; top?: LeaderRow[] }>('/api/leaderboard', { game, diff, score, name }),
 
+  // Which sign-in methods are live server-side (Apple button hides until configured).
+  authConfig: () => getJSON<{ ok: boolean; apple: boolean }>('/api/auth-config'),
   me: () => getJSON<{ ok: boolean; user: User }>('/api/auth/me'),
   login: (email: string, password: string) =>
     postJSON<{ ok: boolean; user?: User; error?: string }>('/api/auth/login', { email, password }),
diff --git a/apps/mobile/app.json b/apps/mobile/app.json
index 7fb671f..d8dd7fd 100644
--- a/apps/mobile/app.json
+++ b/apps/mobile/app.json
@@ -32,7 +32,8 @@
           "imageWidth": 220,
           "resizeMode": "contain"
         }
-      ]
+      ],
+      "expo-web-browser"
     ],
     "extra": {
       "webBase": "https://celebsignatures.com",
diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json
index f07f411..615777a 100644
--- a/apps/mobile/package-lock.json
+++ b/apps/mobile/package-lock.json
@@ -16,6 +16,7 @@
         "expo-linear-gradient": "~56.0.4",
         "expo-splash-screen": "~56.0.14",
         "expo-status-bar": "~56.0.4",
+        "expo-web-browser": "~56.0.6",
         "react": "19.2.3",
         "react-native": "0.85.3",
         "react-native-webview": "13.16.1"
@@ -2810,6 +2811,16 @@
         "react-native": "*"
       }
     },
+    "node_modules/expo-web-browser": {
+      "version": "56.0.6",
+      "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-56.0.6.tgz",
+      "integrity": "sha512-+5Zg0JHETAB1+vNu0Bb4+WiUB50MViwcaJKt16sz5GJTrnutcJvwFeXr6oJ7Pw73sp+DjxkIDGSHDq8SAmFRSg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "expo": "*",
+        "react-native": "*"
+      }
+    },
     "node_modules/expo/node_modules/@expo/cli": {
       "version": "56.1.22",
       "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.22.tgz",
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index ee4b2cc..eb71b6b 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -17,6 +17,7 @@
     "expo-linear-gradient": "~56.0.4",
     "expo-splash-screen": "~56.0.14",
     "expo-status-bar": "~56.0.4",
+    "expo-web-browser": "~56.0.6",
     "react": "19.2.3",
     "react-native": "0.85.3",
     "react-native-webview": "13.16.1"
diff --git a/apps/mobile/screens/AccountScreen.tsx b/apps/mobile/screens/AccountScreen.tsx
index ba89f62..135d640 100644
--- a/apps/mobile/screens/AccountScreen.tsx
+++ b/apps/mobile/screens/AccountScreen.tsx
@@ -21,7 +21,7 @@ import {
 } from 'react-native';
 import * as Haptics from 'expo-haptics';
 
-import { api, User } from '../api';
+import { api, User, appleSignIn } from '../api';
 import { GLASS, GlassButton, GlassPanel } from '../ui/glass';
 
 // ---------------------------------------------------------------------------
@@ -121,10 +121,40 @@ function AuthView({ onSuccess }: { onSuccess: (user: User) => void }) {
   const [name, setName] = useState('');
   const [busy, setBusy] = useState(false);
   const [error, setError] = useState<string | null>(null);
+  const [appleBusy, setAppleBusy] = useState(false);
+  const [appleAvailable, setAppleAvailable] = useState(false);
 
   const passwordRef = useRef<TextInput>(null);
   const nameRef = useRef<TextInput>(null);
 
+  // Show the Apple button only once its creds are configured server-side.
+  useEffect(() => {
+    api.authConfig().then((c) => setAppleAvailable(!!c.apple)).catch(() => setAppleAvailable(false));
+  }, []);
+
+  const handleApple = useCallback(async () => {
+    setAppleBusy(true);
+    setError(null);
+    try {
+      const r = await appleSignIn();
+      if (r.ok) {
+        const me = await api.me();
+        if (me.ok && me.user) {
+          Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+          onSuccess(me.user);
+          return;
+        }
+        setError('Signed in, but could not load your account. Please try again.');
+      } else if (r.error && r.error !== 'canceled') {
+        setError(r.error);
+      }
+    } catch {
+      setError('Network error. Please check your connection.');
+    } finally {
+      setAppleBusy(false);
+    }
+  }, [onSuccess]);
+
   // Reset error when switching mode
   const handleModeChange = useCallback((m: AuthMode) => {
     setMode(m);
@@ -225,6 +255,18 @@ function AuthView({ onSuccess }: { onSuccess: (user: User) => void }) {
           disabled={busy}
         />
       </View>
+
+      {appleAvailable && (
+        <View style={styles.appleWrap}>
+          <Text style={styles.orText}>or</Text>
+          <GlassButton
+            label={'  Sign in with Apple'}
+            onPress={handleApple}
+            busy={appleBusy}
+            disabled={appleBusy || busy}
+          />
+        </View>
+      )}
     </View>
   );
 }
@@ -505,6 +547,16 @@ const styles = StyleSheet.create({
   submitBtn: {
     marginTop: 8,
   },
+  appleWrap: {
+    marginTop: 14,
+    gap: 8,
+  },
+  orText: {
+    textAlign: 'center',
+    color: GLASS.textDim,
+    fontSize: 13,
+    letterSpacing: 0.3,
+  },
 
   // Signed-in view
   signedInRoot: {

← ee2b355 celeb: add Sign in with Apple backend (web OAuth + WebView h  ·  back to CelebritySignatures  ·  celeb: allow /.well-known/ through the sensitive-path guard 7f435c2 →