Skip to content

feat(ios): iOS OAuth improvements with native Keychain and SPM - #245

Merged
2witstudios merged 14 commits into
masterfrom
feat/ios-oauth-improvements
Jan 26, 2026
Merged

2witstudios merged 14 commits into
masterfrom
feat/ios-oauth-improvements

Conversation

@2witstudios

@2witstudios 2witstudios commented Jan 25, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Migrate from CocoaPods to Swift Package Manager for simpler iOS builds and better Xcode integration
  • Add custom PageSpaceKeychain plugin replacing third-party secure storage with a native Swift implementation
  • Fix Google Sign-In issues: force account picker display and fix post-login navigation in Capacitor WebView
  • Update app configuration for native auth flow

Changes

1. Swift Package Manager Migration

  • Remove Podfile and Podfile.lock
  • Add CapApp-SPM package for Capacitor dependencies
  • Update project.pbxproj for SPM references

2. Custom PageSpaceKeychain Plugin

  • Add native Swift plugin for iOS Keychain access
  • Add TypeScript interfaces for iOS and web platforms
  • Remove @aparajita/capacitor-secure-storage dependency

3. Google Sign-In Fixes

  • Add forcePrompt: true to always show account picker (prevents auto-login with cached credentials)
  • Use window.location.href for navigation (fixes router.replace failure in Capacitor WebView)
  • Skip GoogleOneTap on native app (use native SDK instead)
  • Add iOS Keychain session management in auth store

4. Configuration Updates

  • Load /dashboard directly in iOS app
  • Add iOS OAuth client ID to Dockerfile build args

Test plan

  • Delete iOS app from simulator (clears cached Google session)
  • Rebuild web: pnpm --filter web build
  • Rebuild iOS app in Xcode
  • Open iOS app → signin → tap "Continue with Google"
  • Verify Google account picker appears
  • Verify navigation to dashboard after account selection
  • Verify logout clears Keychain session

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Keychain-backed session storage for iOS with a native get/set/remove plugin.
    • Native iOS auth now returns user info from native sign‑in.
  • Changes

    • Improved iOS auth flow and logout to clear Keychain sessions.
    • App initial native load now targets the dashboard path.
    • iOS project moved to Swift Package Manager; iOS dependencies updated.
  • Removed

    • Automatic landing‑page redirect for native app users.
  • Chores

    • Build/runtime wiring for iOS OAuth client ID.

✏️ Tip: You can customize this high-level summary in your review settings.

2witstudios and others added 4 commits January 25, 2026 17:40
Replace CocoaPods dependency management with native Swift Package Manager
for simpler builds and better Xcode integration.

- Remove Podfile and Podfile.lock
- Remove xcworkspace (SPM uses xcodeproj directly)
- Add CapApp-SPM package for Capacitor dependencies
- Add debug.xcconfig for build configuration
- Update project.pbxproj for SPM package references

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Replace @aparajita/capacitor-secure-storage with a custom native Swift
plugin for more reliable iOS Keychain access.

- Add PageSpaceKeychainPlugin.swift native implementation
- Add TypeScript interfaces for iOS and web platforms
- Update auth-bridge.ts to use new plugin API
- Remove @aparajita/capacitor-secure-storage dependency

The custom plugin provides simpler get/set/remove operations without
the complexity of third-party abstractions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Address two issues with iOS OAuth:

1. Force account picker display:
   - Add forcePrompt: true to SocialLogin.login() options
   - Prevents auto-selection of cached Google credentials

2. Fix post-login navigation:
   - Use window.location.href instead of router.replace()
   - Resolves navigation failure in Capacitor WebView

Additional improvements:
- Skip GoogleOneTap on Capacitor native apps (use native SDK instead)
- Add iOS Keychain session clearing on logout
- Add iOS session token retrieval in auth store
- Remove obsolete NativeAppRedirect component
- Add clearStoredSession and getSessionToken helpers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Update capacitor.config.ts to load /dashboard directly (skip landing)
- Update Main.storyboard to use custom MyViewController
- Add NEXT_PUBLIC_GOOGLE_OAUTH_IOS_CLIENT_ID to Dockerfile build args

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@coderabbitai

coderabbitai Bot commented Jan 25, 2026 •

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Migrate iOS from CocoaPods to a local Swift Package (CapApp‑SPM); add a PageSpaceKeychain iOS Capacitor plugin (Swift) and TypeScript bridge; replace SecureStorage with PageSpaceKeychain across iOS/web auth flows; update Xcode/project configs, package manifests, and navigation/link handling for Capacitor contexts.

Changes

Cohort / File(s) Summary
iOS SPM & project config
apps/ios/ios/App/App.xcodeproj/project.pbxproj, apps/ios/ios/App/App.xcworkspace/xcshareddata/swiftpm/Package.resolved, apps/ios/ios/App/App.xcworkspace/contents.xcworkspacedata, apps/ios/ios/Podfile, apps/ios/ios/debug.xcconfig, apps/ios/ios/release.xcconfig, apps/ios/package.json, apps/ios/capacitor.config.ts
Remove CocoaPods/workspace references; add local Swift package CapApp‑SPM and Package.resolved; wire package into project/product dependencies; add debug/release xcconfigs; update server.url to https://pagespace.ai/dashboard; update iOS npm deps.
iOS app VC & plugin registration
apps/ios/ios/App/App/Base.lproj/Main.storyboard, apps/ios/ios/App/App/MyViewController.swift
Replace CAPBridgeViewController reference with MyViewController and register PageSpaceKeychainPlugin on capacitorDidLoad.
iOS Keychain plugin (Swift)
apps/ios/ios/App/App/Plugins/PageSpaceKeychainPlugin.swift
New CAPBridged plugin PageSpaceKeychainPlugin exposing promise-based get/set/remove backed by iOS Keychain (service ai.pagespace.app).
Local SPM package files
apps/ios/ios/App/CapApp-SPM/Package.swift, apps/ios/ios/App/CapApp-SPM/Sources/.../CapApp-SPM.swift, apps/ios/ios/App/CapApp-SPM/.gitignore, apps/ios/ios/App/CapApp-SPM/README.md
Add CapApp‑SPM manifest, minimal source (exported isCapacitorApp = true), README, and .gitignore; declare Capacitor-related SPM dependencies and products.
Remove Podfile
apps/ios/ios/Podfile
Podfile deleted (CocoaPods setup and helper functions removed).
iOS TS plugin bridge & auth migration
apps/ios/src/keychain-plugin.ts, apps/ios/src/auth-bridge.ts
Add TypeScript plugin interface/registration; migrate SecureStorage usage to PageSpaceKeychain for session/CSRF storage and migration paths.
Web keychain bridge & iOS auth helpers
apps/web/src/lib/keychain-plugin.ts, apps/web/src/lib/ios-google-auth.ts, apps/web/src/stores/useAuthStore.ts, apps/web/src/hooks/useAuth.ts, apps/web/src/app/auth/signin/page.tsx
Add plugin typings; replace SecureStorage with PageSpaceKeychain; add getStoredSession/getSessionToken/clearStoredSession; integrate token retrieval at startup and logout cleanup; update native sign-in flow and router.replace navigation; add forcePrompt to Google sign-in.
Web navigation & router-aware UI
apps/web/src/lib/navigation/app-navigation.ts, apps/web/src/components/ui/AppLink.tsx, apps/web/src/lib/editor/tiptap-mention-config.tsx, apps/web/src/components/ai/shared/chat/StreamingMarkdown.tsx, apps/web/src/components/messages/MessagePartRenderer.tsx, apps/web/src/components/editors/RichEditor.tsx, apps/web/src/components/layout/.../CanvasPageView.tsx, apps/web/src/components/ui/sheet.tsx
Add cross-platform navigation helpers, AppLink, event-based internal navigation for mentions, router-aware anchors/components, navigation event subscription in editors, safer external URL handling, and safe-area adjustments for sheets.
Remove Native redirect & OneTap guard
apps/web/src/components/shared/NativeAppRedirect.tsx, apps/web/src/app/page.tsx, apps/web/src/components/auth/GoogleOneTap.tsx
Remove NativeAppRedirect and its use on Home; add guard in GoogleOneTap to skip initialization inside Capacitor native apps.
Package/build updates
apps/web/Dockerfile, apps/web/package.json, apps/ios/package.json
Add NEXT_PUBLIC_GOOGLE_OAUTH_IOS_CLIENT_ID build arg/env; remove @aparajita/capacitor-secure-storage; add/update Capacitor deps and @capgo/capacitor-social-login; small package.json adjustments.
Workspace/IDE files
apps/ios/ios/App/App.xcworkspace/contents.xcworkspacedata, apps/ios/ios/.gitignore
Remove workspace contents referencing Pods; add local.xcconfig to .gitignore.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant WebApp as Web App (TS)
    participant CapacitorBridge as Capacitor Bridge
    participant iOSPlugin as PageSpaceKeychain Plugin (Swift)
    participant Keychain as iOS Keychain
    participant Server

    User->>WebApp: completes Google sign-in (native)
    WebApp->>CapacitorBridge: PageSpaceKeychain.set(sessionKey, sessionData)
    CapacitorBridge->>iOSPlugin: invoke set(...)
    iOSPlugin->>Keychain: SecItemAdd / SecItemDelete -> store data
    Keychain-->>iOSPlugin: success
    iOSPlugin-->>CapacitorBridge: promise resolved
    CapacitorBridge-->>WebApp: success

    alt app start / token retrieval
        WebApp->>CapacitorBridge: PageSpaceKeychain.get(sessionKey)
        CapacitorBridge->>iOSPlugin: invoke get(...)
        iOSPlugin->>Keychain: SecItemCopyMatching -> retrieve data
        Keychain-->>iOSPlugin: value
        iOSPlugin-->>CapacitorBridge: return value
        CapacitorBridge-->>WebApp: token returned
        WebApp->>Server: use Bearer token for API calls
    end

    User->>WebApp: logout
    WebApp->>CapacitorBridge: PageSpaceKeychain.remove(sessionKey)
    CapacitorBridge->>iOSPlugin: invoke remove(...)
    iOSPlugin->>Keychain: SecItemDelete
    Keychain-->>iOSPlugin: success
    iOSPlugin-->>CapacitorBridge: resolved
    CapacitorBridge-->>WebApp: removal confirmed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped from Pods into SPM bright,

Tucked tokens snug in Keychain's night,
Web and iOS now share one key,
Set, get, remove — secure and free,
A tiny hop, a coder's delight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(ios): iOS OAuth improvements with native Keychain and SPM' accurately summarizes the main changes: adding native Keychain support and migrating to Swift Package Manager for iOS.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/ios/src/auth-bridge.ts (1)

34-59: Persist migration failure flag to avoid repeated retries on next launch.
In the catch path you only set the in-memory migrationComplete. MIGRATED_KEY stays unset, so the migration will rerun after app restart and can spam logs if Keychain access is permanently failing. Consider persisting the flag (or a separate failure flag) in the catch path.

💡 Suggested fix
   } catch (error) {
     // Log error but don't crash - user can still log in fresh
     console.error('[PageSpace iOS] Keychain migration failed:', error);
     // Mark as migrated to prevent retry loop
-    migrationComplete = true;
+    try {
+      await Preferences.set({ key: MIGRATED_KEY, value: 'true' });
+    } catch (prefError) {
+      console.error('[PageSpace iOS] Failed to persist migration flag:', prefError);
+    }
+    migrationComplete = true;
   }
🤖 Fix all issues with AI agents
In `@apps/ios/ios/App/App.xcodeproj/project.pbxproj`:
- Line 319: The project currently hardcodes DEVELOPMENT_TEAM = M96WTV3CKX in
project.pbxproj which will break builds for other contributors; remove the
hardcoded DEVELOPMENT_TEAM entries and move the setting into an xcconfig (e.g.,
add DEVELOPMENT_TEAM = $(DEVELOPMENT_TEAM) or set the actual team in a new
local.xcconfig or extend debug.xcconfig), update the Xcode project
target/project configuration to use that xcconfig, add the local xcconfig to
.gitignore, and update README or docs to instruct developers to create their own
local.xcconfig with their Apple team ID if needed; refer to the DEVELOPMENT_TEAM
key and the project configuration in project.pbxproj when making the changes.

In `@apps/ios/ios/App/App/Plugins/PageSpaceKeychainPlugin.swift`:
- Around line 39-66: The set(_ call: CAPPluginCall) method force-unwraps
value.data(using: .utf8) which can crash; replace the force unwrap with a safe
optional binding (e.g., guard let data = value.data(using: .utf8) else {
call.reject("Failed to encode value as UTF-8"); return }) and use that data
variable when building addQuery so the call gracefully rejects instead of
crashing if encoding fails; update the references to data in SecItemAdd
accordingly.

In `@apps/ios/ios/App/CapApp-SPM/README.md`:
- Line 3: Fix the grammar typo in the README sentence "This SPM is used to host
SPM dependencies for you Capacitor project" by replacing "you" with "your" so it
reads "This SPM is used to host SPM dependencies for your Capacitor project"
(update the README.md content where that sentence appears).

In `@apps/ios/package.json`:
- Around line 15-25: Update the dependency version constraints in
apps/ios/package.json so they require newer secure minima: bump all `@capacitor/`*
packages from ^7.0.0 to at least ^7.0.3 (or a specific patched release like
^7.4.5 for `@capacitor/core`) and set `@capgo/capacitor-social-login` to ^7.20.0;
after changing the version strings for `@capacitor/app`, `@capacitor/browser`,
`@capacitor/core`, `@capacitor/ios`, `@capacitor/keyboard`, `@capacitor/preferences`,
`@capacitor/splash-screen`, `@capacitor/status-bar` and
`@capgo/capacitor-social-login`, run your package manager (npm/pnpm) to update the
lockfile, run npm audit / pnpm audit and resolve any remaining vulnerabilities,
and then run the iOS build/tests to verify no breaking changes.

In `@apps/web/src/lib/ios-google-auth.ts`:
- Around line 165-176: clearStoredSession only removes pagespace_session from
the iOS Keychain which leaves pagespace_csrf stale; update the function
(clearStoredSession) to also remove the pagespace_csrf entry via
PageSpaceKeychain.remove (call remove for both 'pagespace_session' and
'pagespace_csrf', e.g., sequentially or with Promise.all) so it matches
apps/ios/src/auth-bridge.ts behavior and still wraps the calls in the existing
try/catch.
🧹 Nitpick comments (1)
apps/web/src/app/auth/signin/page.tsx (1)

103-121: Prefer clearing authFailedPermanently via the store API instead of manual localStorage edits.
This avoids tight coupling to the persisted JSON shape and keeps persistence logic centralized.

♻️ Proposed refactor
-import { useAuth } from "@/hooks/useAuth";
+import { useAuth } from "@/hooks/useAuth";
+import { useAuthStore } from "@/stores/useAuthStore";
@@
-          // Clear authFailedPermanently flag directly in localStorage
-          // This prevents dashboard from skipping auth validation
-          try {
-            const stored = localStorage.getItem('auth-storage');
-            if (stored) {
-              const parsed = JSON.parse(stored);
-              if (parsed.state?.authFailedPermanently) {
-                parsed.state.authFailedPermanently = false;
-                localStorage.setItem('auth-storage', JSON.stringify(parsed));
-              }
-            }
-          } catch {
-            // Ignore localStorage errors
-          }
+          // Clear authFailedPermanently via the store (persists safely)
+          try {
+            useAuthStore.setState({ authFailedPermanently: false });
+          } catch {
+            // Ignore storage errors
+          }

Comment thread apps/ios/ios/App/App.xcodeproj/project.pbxproj Outdated
Comment thread apps/ios/ios/App/App/Plugins/PageSpaceKeychainPlugin.swift
Comment thread apps/ios/ios/App/CapApp-SPM/README.md Outdated
Comment thread apps/ios/package.json
Comment thread apps/web/src/lib/ios-google-auth.ts
Relative URLs with window.location.href don't navigate reliably in
Capacitor iOS WKWebView. Using window.location.origin ensures explicit
navigation to the dashboard after Google Sign-In.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@2witstudios

Copy link
Copy Markdown
Owner Author

Code review

Found 1 issue:

  1. Direct localStorage manipulation bypasses Zustand store API (established pattern in useAuth.ts:70 uses useAuthStore.getState().setAuthFailedPermanently(false))

if (result.success) {
// Clear authFailedPermanently flag directly in localStorage
// This prevents dashboard from skipping auth validation
try {
const stored = localStorage.getItem('auth-storage');
if (stored) {
const parsed = JSON.parse(stored);
if (parsed.state?.authFailedPermanently) {
parsed.state.authFailedPermanently = false;
localStorage.setItem('auth-storage', JSON.stringify(parsed));
}
}
} catch {
// Ignore localStorage errors
}

The authFailedPermanently flag should be cleared using the store's API method rather than directly manipulating localStorage. This bypasses Zustand's state management and can cause race conditions during store hydration. The proper pattern is used in useAuth.ts:70:

useAuthStore.getState().setAuthFailedPermanently(false);

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

2witstudios and others added 4 commits January 25, 2026 23:31
The native Google OAuth route was returning the session token as JSON
but not setting it as a cookie. This caused the middleware to redirect
to /auth/signin because it couldn't find the session cookie.

Changes:
- Set session cookie via appendSessionCookie in native OAuth response
- Return user info from native OAuth for immediate auth state update
- Use router.replace() instead of window.location.href for navigation
- Set auth store state directly to prevent race conditions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…e manipulation

Replace direct localStorage parsing/writing with the existing
setAuthFailedPermanently() store action for clearing the auth
failure flag after successful native Google Sign-In.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Clear CSRF token on logout in ios-google-auth.ts
- Fix force unwrap crash in PageSpaceKeychainPlugin.swift
- Persist migration failure flag to prevent retry loops
- Update Capacitor dependencies to fix vulnerabilities
- Move DEVELOPMENT_TEAM to xcconfig for contributor builds
- Fix typo in CapApp-SPM README

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/ios/ios/App/App/Plugins/PageSpaceKeychainPlugin.swift`:
- Around line 55-62: The keychain entry currently uses
kSecAttrAccessibleAfterFirstUnlock which allows backup/restore; update the
addQuery dictionary to use a device-only accessibility constant (e.g., set
kSecAttrAccessible to kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly) for
auth/session tokens, or choose kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly
if you want to restrict to devices with a passcode; change the value in the
addQuery assignment where kSecAttrAccessible is set so the item is not
recoverable via iCloud backups.
♻️ Duplicate comments (1)
apps/ios/ios/debug.xcconfig (1)

1-5: Hardcoded DEVELOPMENT_TEAM still risks contributor build failures.
The comment suggests a local override, but local.xcconfig isn’t included, so the shared team ID remains the default. Consider making the local override optional and removing the hardcoded ID.

🛠️ Suggested tweak
+#include? "local.xcconfig"
 CAPACITOR_DEBUG = true
 
 // Development team - contributors should override this in their local environment
 // or create a local.xcconfig file that is git-ignored
-DEVELOPMENT_TEAM = M96WTV3CKX
+DEVELOPMENT_TEAM =
🧹 Nitpick comments (2)
apps/web/src/lib/ios-google-auth.ts (2)

96-110: Avoid implicit any from response.json(); type the payload before use.
This aligns with the “no any” guideline and makes field access safer.

♻️ Proposed typing tweak
 export interface GoogleAuthResult {
   success: boolean;
   error?: string;
   isNewUser?: boolean;
   user?: {
     id: string;
     name: string | null;
     email: string | null;
     image?: string | null;
   };
 }
+
+type GoogleNativeAuthResponse = {
+  sessionToken?: string;
+  csrfToken?: string | null;
+  isNewUser?: boolean;
+  user?: GoogleAuthResult['user'];
+};

@@
-    const { sessionToken, csrfToken, isNewUser, user } = await response.json();
+    const payload = (await response.json()) as GoogleNativeAuthResponse;
+    const { sessionToken, csrfToken, isNewUser, user } = payload;

143-169: Validate parsed Keychain payload to avoid implicit any and malformed sessions.
JSON.parse currently returns any; a small guard keeps the return type reliable.

♻️ Proposed safe parse
-export async function getStoredSession(): Promise<{
-  sessionToken: string;
-  csrfToken: string | null;
-  deviceId: string;
-} | null> {
+type StoredSession = {
+  sessionToken: string;
+  csrfToken: string | null;
+  deviceId: string;
+};
+
+export async function getStoredSession(): Promise<StoredSession | null> {
@@
-    return JSON.parse(value);
+    const parsed = JSON.parse(value) as Partial<StoredSession>;
+    if (typeof parsed.sessionToken !== 'string' || typeof parsed.deviceId !== 'string') {
+      return null;
+    }
+    return {
+      sessionToken: parsed.sessionToken,
+      csrfToken: parsed.csrfToken ?? null,
+      deviceId: parsed.deviceId,
+    };

Comment thread apps/ios/ios/App/App/Plugins/PageSpaceKeychainPlugin.swift
2witstudios and others added 3 commits January 26, 2026 09:06
- Use kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly for device-bound auth tokens
- Add GoogleNativeAuthResponse type for response.json() to avoid implicit any
- Add StoredSession type and runtime validation in getStoredSession()
- Use optional include for local.xcconfig and clear hardcoded DEVELOPMENT_TEAM
- Add local.xcconfig to .gitignore for contributor flexibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…for external

Internal @mention links and other internal navigation now use router.push()
to stay within the Capacitor WebView instead of opening Safari externally.
External links use Capacitor's Browser plugin which opens Safari View
Controller (an in-app browser) keeping users in the app context.

Changes:
- Add navigation utilities (app-navigation.ts) for platform-aware navigation
- Add AppLink component for mobile-aware link handling
- Update StreamingMarkdown to use router.push() for mention links
- Update MessagePartRenderer to use router.push() for mentions
- Remove target="_blank" from TipTap mentions, use navigation events
- Update CanvasPageView to use openExternalUrl() for external links
- Add safe area insets to Sheet component for iOS notch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…for external

Internal @mention links and other internal navigation now use router.push()
to stay within the Capacitor WebView instead of opening Safari externally.
External links use Capacitor's Browser plugin which opens Safari View
Controller (an in-app browser) keeping users in the app context.

Changes:
- Add navigation utilities (app-navigation.ts) for platform-aware navigation
- Add AppLink component for mobile-aware link handling
- Update StreamingMarkdown to use router.push() for mention links
- Update MessagePartRenderer to use router.push() for mentions
- Remove target="_blank" from TipTap mentions, use navigation events
- Update CanvasPageView to use openExternalUrl() for external links
- Add safe area insets to Sheet component for iOS notch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/web/src/lib/editor/tiptap-mention-config.tsx`:
- Around line 176-183: The comment claims a fallback to window.location exists,
but the click handler only calls dispatchInternalNavigation(href) and does not
set window.location; update the comment above dom.addEventListener(...) to
remove or correct the "Falls back to window.location for non-React contexts"
text, leaving a concise note that the handler dispatches an internal navigation
event (via dispatchInternalNavigation) for parent components/routers to handle
and does not perform a direct navigation itself.
🧹 Nitpick comments (7)
apps/web/src/lib/ios-google-auth.ts (1)

109-128: Consider validating the user object from the backend response.

The sessionToken is validated (line 112), but the user object is returned directly without structure validation. If the backend returns a malformed user object, it could propagate unexpected data types to consumers.

This is a minor robustness concern since the backend is trusted, but adding defensive validation would align with the pattern used in getStoredSession.

♻️ Optional: Add user validation
     const { sessionToken, csrfToken, isNewUser, user } =
       (await response.json()) as GoogleNativeAuthResponse;

     if (!sessionToken) {
       throw new Error('No session token received from server');
     }

+    // Validate user object structure if present
+    const validatedUser = user && typeof user.id === 'string' ? user : undefined;
+
     // Store tokens in iOS Keychain via PageSpaceKeychain plugin
     await PageSpaceKeychain.set({
       key: 'pagespace_session',
       value: JSON.stringify({
         sessionToken,
         csrfToken: csrfToken || null,
         deviceId,
       }),
     });

     console.log('[iOS Google Auth] Sign-in successful, tokens stored');

-    return { success: true, isNewUser, user };
+    return { success: true, isNewUser, user: validatedUser };
apps/web/src/lib/navigation/app-navigation.ts (2)

37-51: Consider logging errors in the catch block for debugging.

The silent catch hides potential issues that could help debug problems in Capacitor environments (e.g., plugin misconfiguration). Even with a fallback, knowing why it failed is valuable.

🔧 Suggested improvement
     } catch {
       // Fallback if Browser plugin isn't available
+      console.warn('[openExternalUrl] Capacitor Browser plugin unavailable, falling back to window.open');
       window.open(url, '_blank', 'noopener,noreferrer');
     }

57-59: navigateInternal wrapper adds minimal value.

This function simply delegates to routerPush. Consider removing it or adding value (e.g., URL validation, analytics). Currently, callers could just use routerPush directly.

apps/web/src/components/messages/MessagePartRenderer.tsx (1)

48-58: Consider extracting the shared mention link rendering.

The mention link rendering (lines 48-58 and 102-112) is duplicated between text and rich-text cases. A shared helper could reduce duplication.

♻️ Possible extraction
const renderPageMentionLink = (key: string, id: string, label: string) => (
  <a
    key={key}
    href={`/p/${id}`}
    onClick={(e) => handleMentionClick(e, id)}
    className="bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary px-1 rounded hover:bg-primary/20 dark:hover:bg-primary/30 transition-colors no-underline"
  >
    @{label}
  </a>
);
apps/web/src/components/ai/shared/chat/StreamingMarkdown.tsx (2)

142-153: Minor: Props spread order could override onClick.

The {...props} spread is placed after onClick, which means if props contains an onClick, it would override your handler. Consider moving the spread before onClick to ensure your handler always takes precedence.

Suggested reorder
         return (
           <a
             href={pageHref}
+            {...props}
             onClick={(e) => {
               e.preventDefault();
               router.push(pageHref);
             }}
             className="inline-flex items-center px-2 py-0.5 rounded-md bg-primary/20 text-primary dark:bg-primary/30 dark:text-primary text-sm font-medium mx-1 hover:bg-primary/30 dark:hover:bg-primary/40 transition-colors cursor-pointer no-underline"
-            {...props}
           >
             @{label}
           </a>
         );

165-174: Minor: Same props spread order concern for regular links.

Similar to the page mention anchor, the {...props} spread after onClick could override the click handler if props contains one.

Suggested reorder
     return (
       <a
         href={href}
+        {...props}
         onClick={handleClick}
         className="min-w-0 max-w-full break-all [overflow-wrap:anywhere] inline-block"
-        {...props}
       >
         {children}
       </a>
     );
apps/ios/ios/App/App/Plugins/PageSpaceKeychainPlugin.swift (1)

16-37: Handle UTF‑8 decode failures explicitly.

This avoids reporting a “success” OSStatus as a generic failure if the stored value isn’t valid UTF‑8.

💡 Suggested tweak
-        if status == errSecSuccess, let data = result as? Data,
-           let value = String(data: data, encoding: .utf8) {
-            call.resolve(["value": value])
-        } else if status == errSecItemNotFound {
-            call.resolve(["value": NSNull()])
-        } else {
-            call.reject("Keychain read failed: \(status)")
-        }
+        if status == errSecSuccess, let data = result as? Data {
+            if let value = String(data: data, encoding: .utf8) {
+                call.resolve(["value": value])
+            } else {
+                call.reject("Keychain value is not valid UTF-8")
+            }
+        } else if status == errSecItemNotFound {
+            call.resolve(["value": NSNull()])
+        } else {
+            call.reject("Keychain read failed: \(status)")
+        }

Comment on lines +176 to 183
// Handle click events - dispatch navigation event for React router handling
// Falls back to window.location for non-React contexts
dom.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
window.open(href, '_blank', 'noopener,noreferrer');
// Dispatch event for parent component to handle with router.push()
dispatchInternalNavigation(href);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misleading comment: no fallback is implemented.

The comment on line 177 mentions "Falls back to window.location for non-React contexts" but no such fallback exists in the code. The event is dispatched and nothing else happens if no listener catches it.

🔧 Suggested fix - either add fallback or update comment

Option 1 - Update comment:

-      // Handle click events - dispatch navigation event for React router handling
-      // Falls back to window.location for non-React contexts
+      // Handle click events - dispatch navigation event for React router handling
+      // Parent components must subscribe to 'pagespace:navigate' events
       dom.addEventListener('click', (event) => {

Option 2 - Add actual fallback:

       dom.addEventListener('click', (event) => {
         event.preventDefault();
         event.stopPropagation();
         // Dispatch event for parent component to handle with router.push()
-        dispatchInternalNavigation(href);
+        const handled = dispatchInternalNavigation(href);
+        if (!handled) {
+          window.location.href = href;
+        }
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Handle click events - dispatch navigation event for React router handling
// Falls back to window.location for non-React contexts
dom.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
window.open(href, '_blank', 'noopener,noreferrer');
// Dispatch event for parent component to handle with router.push()
dispatchInternalNavigation(href);
});
// Handle click events - dispatch navigation event for React router handling
// Parent components must subscribe to 'pagespace:navigate' events
dom.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
// Dispatch event for parent component to handle with router.push()
dispatchInternalNavigation(href);
});
🤖 Prompt for AI Agents
In `@apps/web/src/lib/editor/tiptap-mention-config.tsx` around lines 176 - 183,
The comment claims a fallback to window.location exists, but the click handler
only calls dispatchInternalNavigation(href) and does not set window.location;
update the comment above dom.addEventListener(...) to remove or correct the
"Falls back to window.location for non-React contexts" text, leaving a concise
note that the handler dispatches an internal navigation event (via
dispatchInternalNavigation) for parent components/routers to handle and does not
perform a direct navigation itself.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/web/src/components/ai/shared/chat/StreamingMarkdown.tsx`:
- Around line 112-173: handleClick currently always calls e.preventDefault()
which blocks modifier/middle clicks and the inline onClick in the mention page
link also unconditionally prevents default; update both to only prevent default
and call router.push for primary left-clicks without modifiers (check e.button
=== 0 and !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) so
Cmd/Ctrl/Shift/middle-click still work and open-in-new-tab behavior is
preserved; also move {...props} before explicit onClick/className (or explicitly
compose handlers) so external props cannot override the component's onClick and
className—apply these changes to the handleClick function and the mention page
link's onClick handler.

Comment on lines +112 to +173
const handleClick = async (e: MouseEvent<HTMLAnchorElement>) => {
if (!href) return;

e.preventDefault();

if (isInternalUrl(href)) {
router.push(href);
} else {
await openExternalUrl(href);
}
};

// Check if this is a mention link
if (typeof href === 'string' && href.startsWith('mention://')) {
// Extract the label from children (format: "mention:Label")
const label = typeof children === 'string'
? children.replace(/^mention:/, '')
: Array.isArray(children) && typeof children[0] === 'string'
? children[0].replace(/^mention:/, '')
: children;

// Parse the mention URL: mention://id/type
const mentionPath = href.replace('mention://', '');
const [id, type] = mentionPath.split('/');

// Only page mentions should be clickable links
// User and other mention types render as non-clickable badges
if (type === 'page') {
const pageHref = `/p/${id}`;
return (
<a
href={pageHref}
onClick={(e) => {
e.preventDefault();
router.push(pageHref);
}}
className="inline-flex items-center px-2 py-0.5 rounded-md bg-primary/20 text-primary dark:bg-primary/30 dark:text-primary text-sm font-medium mx-1 hover:bg-primary/30 dark:hover:bg-primary/40 transition-colors cursor-pointer no-underline"
{...props}
>
@{label}
</a>
);
}

// Non-page mentions (user, agent, etc.) render as styled badges without links
return (
<span className="inline-flex items-center px-2 py-0.5 rounded-md bg-primary/20 text-primary dark:bg-primary/30 dark:text-primary text-sm font-medium mx-1">
@{label}
</span>
);
}

// Regular link with mobile-aware click handling
return (
<a
href={href}
onClick={handleClick}
className="min-w-0 max-w-full break-all [overflow-wrap:anywhere] inline-block"
{...props}
>
{children}
</a>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Preserve modifier/middle‑click behavior and avoid prop overrides.
preventDefault() runs for all clicks, which blocks Cmd/Ctrl/Shift/middle‑click and can break expected “open in new tab” behavior. Also, {...props} after onClick/className can override the handler and styles, bypassing the mobile‑aware routing.

🛠️ Suggested fix
 function createCustomAnchor(router: RouterLike) {
   return function CustomAnchor({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { children?: ReactNode }) {
+    const { className: classNameProp, onClick: onClickProp, target, ...rest } = props;
     const handleClick = async (e: MouseEvent<HTMLAnchorElement>) => {
       if (!href) return;
 
+      if (
+        e.defaultPrevented ||
+        e.button !== 0 ||
+        e.metaKey ||
+        e.ctrlKey ||
+        e.shiftKey ||
+        e.altKey ||
+        target === '_blank'
+      ) {
+        return;
+      }
+
       e.preventDefault();
 
       if (isInternalUrl(href)) {
         router.push(href);
       } else {
         await openExternalUrl(href);
       }
     };
@@
     return (
       <a
-        href={href}
-        onClick={handleClick}
-        className="min-w-0 max-w-full break-all [overflow-wrap:anywhere] inline-block"
-        {...props}
+        {...rest}
+        href={href}
+        target={target}
+        onClick={(e) => {
+          onClickProp?.(e);
+          if (!e.defaultPrevented) void handleClick(e);
+        }}
+        className={`min-w-0 max-w-full break-all [overflow-wrap:anywhere] inline-block ${classNameProp ?? ''}`}
       >
         {children}
       </a>
     );
   };
 }
🤖 Prompt for AI Agents
In `@apps/web/src/components/ai/shared/chat/StreamingMarkdown.tsx` around lines
112 - 173, handleClick currently always calls e.preventDefault() which blocks
modifier/middle clicks and the inline onClick in the mention page link also
unconditionally prevents default; update both to only prevent default and call
router.push for primary left-clicks without modifiers (check e.button === 0 and
!e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey) so
Cmd/Ctrl/Shift/middle-click still work and open-in-new-tab behavior is
preserved; also move {...props} before explicit onClick/className (or explicitly
compose handlers) so external props cannot override the component's onClick and
className—apply these changes to the handleClick function and the mention page
link's onClick handler.

…missions

- Fix @capacitor/browser import in app-navigation.ts by using a variable
  for the module path with @vite-ignore comment, preventing Vite's static
  import analysis from failing during tests
- Fix usePermissions.test.ts mock path from '../use-auth' to '@/hooks/useAuth'
- Fix usePermissions test timing by separating onSuccess callback invocation
  from SWR mock to respect useEffect execution order

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/web/src/lib/navigation/app-navigation.ts`:
- Around line 18-23: The isInternalUrl function currently uses
url.startsWith(window.location.origin) which is vulnerable to URL confusion;
update isInternalUrl to: treat empty/relative paths as before, then (after
confirming typeof window !== 'undefined') construct a URL object via new
URL(url, window.location.origin) inside a try/catch and compare the resulting
urlObj.origin strictly to window.location.origin (instead of startsWith); return
true only when origins match, and return false on URL constructor errors —
reference the isInternalUrl function and window.location.origin when making the
change.
🧹 Nitpick comments (2)
apps/web/src/hooks/__tests__/usePermissions.test.ts (1)

37-38: Consider scoping capturedConfig within each test.

The shared mutable variable at describe-level works in sequential execution since each mock overwrites it, but could lead to subtle issues if tests run in parallel or if a test fails before setting it. Moving the declaration inside each test improves isolation.

♻️ Suggested refactor
  describe('SWR editing protection', () => {
-   let capturedConfig: { isPaused?: () => boolean; onSuccess?: (data: unknown, key: string, config: never) => void };
-
    it('given user is editing a document, should pause permission revalidation', () => {
+     let capturedConfig: { isPaused?: () => boolean; onSuccess?: (data: unknown, key: string, config: never) => void };
      // Arrange: User is actively editing

Apply the same pattern to the other two tests.

apps/web/src/lib/navigation/app-navigation.ts (1)

37-52: Consider logging the error for debugging.

Silent error handling makes it harder to diagnose issues when the Capacitor Browser plugin fails unexpectedly. A debug log would help during development.

💡 Optional: Add error logging
     } catch {
       // Fallback if Browser plugin isn't available
+      console.debug('[openExternalUrl] Browser plugin unavailable, using window.open fallback');
       window.open(url, '_blank', 'noopener,noreferrer');
     }

Comment on lines +18 to +23
export function isInternalUrl(url: string): boolean {
if (!url) return false;
if (url.startsWith('/')) return true;
if (typeof window !== 'undefined' && url.startsWith(window.location.origin)) return true;
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

URL origin check is vulnerable to URL confusion attacks.

The check url.startsWith(window.location.origin) will incorrectly classify https://example.com.evil.com as internal when the origin is https://example.com. This could allow external URLs to bypass the intended navigation behavior.

🔒 Proposed fix to validate URL boundary
 export function isInternalUrl(url: string): boolean {
   if (!url) return false;
   if (url.startsWith('/')) return true;
-  if (typeof window !== 'undefined' && url.startsWith(window.location.origin)) return true;
+  if (typeof window !== 'undefined') {
+    const origin = window.location.origin;
+    if (url === origin || url.startsWith(origin + '/')) return true;
+  }
   return false;
 }
🤖 Prompt for AI Agents
In `@apps/web/src/lib/navigation/app-navigation.ts` around lines 18 - 23, The
isInternalUrl function currently uses url.startsWith(window.location.origin)
which is vulnerable to URL confusion; update isInternalUrl to: treat
empty/relative paths as before, then (after confirming typeof window !==
'undefined') construct a URL object via new URL(url, window.location.origin)
inside a try/catch and compare the resulting urlObj.origin strictly to
window.location.origin (instead of startsWith); return true only when origins
match, and return false on URL constructor errors — reference the isInternalUrl
function and window.location.origin when making the change.

@2witstudios
2witstudios merged commit 07f229a into master Jan 26, 2026
9 of 10 checks passed
@2witstudios
2witstudios deleted the feat/ios-oauth-improvements branch January 29, 2026 02:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant