diff --git a/.gitignore b/.gitignore index 40201a8d9..115cc540f 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,5 @@ frontend/*.local # Build caching apparently? *.tsbuildinfo + +**/.claude/settings.local.json diff --git a/frontend/bun.lock b/frontend/bun.lock index c193cedb9..5ebd74298 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -4,7 +4,7 @@ "": { "name": "maple", "dependencies": { - "@opensecret/react": "1.2.0", + "@opensecret/react": "1.3.1", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", @@ -35,6 +35,7 @@ "remark-math": "^6.0.0", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "uuid": "^11.1.0", }, "devDependencies": { "@eslint/js": "^9.14.0", @@ -47,6 +48,7 @@ "@types/node": "^22.3.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "eslint": "^9.8.0", @@ -212,7 +214,7 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opensecret/react": ["@opensecret/react@1.2.0", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-s2dKjznj2oamW6MQVED/g7bxBHdYpm7DGdMrJrC+R6YmwAtbwM3rRwkOM4pbCoiHUUo8NzrXFZWwm/yh8IIfeQ=="], + "@opensecret/react": ["@opensecret/react@1.3.1", "", { "dependencies": { "@peculiar/x509": "^1.12.2", "@stablelib/base64": "^2.0.0", "@stablelib/chacha20poly1305": "^2.0.0", "@stablelib/random": "^2.0.0", "cbor2": "^1.7.0", "tweetnacl": "^1.0.3", "zod": "^3.23.8" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-C53cinMhkn2HNJ2LJxY5+xhVcyT2Y6p7z9nsAXCMkBQl677VteMGaDPifxsJpEIX1bBp5ZEsKIRX1md2zta3yw=="], "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.3.15", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.15", "@peculiar/asn1-x509": "^2.3.15", "@peculiar/asn1-x509-attr": "^2.3.15", "asn1js": "^3.0.5", "tslib": "^2.8.1" } }, "sha512-B+DoudF+TCrxoJSTjjcY8Mmu+lbv8e7pXGWrhNp2/EGJp9EEcpzjBCar7puU57sGifyzaRVM03oD5L7t7PghQg=="], @@ -460,6 +462,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="], + "@types/ws": ["@types/ws@8.5.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.24.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.24.0", "@typescript-eslint/type-utils": "8.24.0", "@typescript-eslint/utils": "8.24.0", "@typescript-eslint/visitor-keys": "8.24.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^2.0.1" }, "peerDependencies": { "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.8.0" } }, "sha512-aFcXEJJCI4gUdXgoo/j9udUYIHgF23MFkg09LFz2dzEmU0+1Plk4rQWv/IYKvPHAtlkkGoB3m5e6oUp+JPsNaQ=="], @@ -1156,6 +1160,8 @@ "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], diff --git a/frontend/package.json b/frontend/package.json index 3e9edc0fc..04b43d736 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "mdast-util-gfm-autolink-literal": "2.0.0" }, "dependencies": { - "@opensecret/react": "1.2.0", + "@opensecret/react": "1.3.1", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", @@ -46,7 +46,8 @@ "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "tailwind-merge": "^2.6.0", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "uuid": "^11.1.0" }, "devDependencies": { "@eslint/js": "^9.14.0", @@ -59,6 +60,7 @@ "@types/node": "^22.3.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "eslint": "^9.8.0", diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 3922909a4..5635879b3 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -2244,6 +2244,7 @@ dependencies = [ "tauri-plugin-log", "tauri-plugin-opener", "tauri-plugin-os", + "tauri-plugin-sign-in-with-apple", "tauri-plugin-updater", "tokio", ] @@ -4408,6 +4409,18 @@ dependencies = [ "thiserror 2.0.11", ] +[[package]] +name = "tauri-plugin-sign-in-with-apple" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9402eb8a70fdff5127fccc1f1666ebb06b5efc10e0eb656d8a70c798e26142e" +dependencies = [ + "serde", + "tauri", + "tauri-plugin", + "thiserror 2.0.11", +] + [[package]] name = "tauri-plugin-updater" version = "2.7.0" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 6d5f6d359..58137c0d7 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -29,5 +29,6 @@ tauri-plugin = "2.1.1" tauri-plugin-deep-link = "2" tauri-plugin-opener = "2" tauri-plugin-os = "2" +tauri-plugin-sign-in-with-apple = "1.0.2" tokio = { version = "1.0", features = ["time"] } once_cell = "1.18.0" diff --git a/frontend/src-tauri/apple-sign-in-info.md b/frontend/src-tauri/apple-sign-in-info.md new file mode 100644 index 000000000..30fcc26d2 --- /dev/null +++ b/frontend/src-tauri/apple-sign-in-info.md @@ -0,0 +1,102 @@ +# Sign in with Apple Integration + +## Overview +This document provides a comprehensive guide to the Sign in with Apple integration for Maple, supporting both native iOS authentication and web-based OAuth. + +## Integration Types + +### 1. Native iOS Authentication +- Uses the iOS native Sign In with Apple dialog +- Implemented via `tauri-plugin-sign-in-with-apple` (version 1.0.0) +- Returns user credentials directly to the app +- Provides access to user identifiers, email, and name (only on first sign-in) + +### 2. Web-based OAuth Flow +- Similar to GitHub and Google OAuth flow +- Redirects users to Apple's authentication page +- Supports both web and desktop (non-iOS) platforms +- Handles callback with auth code and state verification + +## Configuration + +### iOS Native Auth +1. Required entitlements have been added in `maple_iOS.entitlements` +2. The capability is registered in `capabilities/default.json` and `capabilities/mobile-ios.json` +3. Plugin is configured in Cargo.toml and registered in the app + +### OAuth Configuration +1. Set up these parameters in your OpenSecret project settings: + - **Client ID**: Your Apple Services ID (e.g., com.example.web) + - **Client Secret**: The base64-encoded contents of your Apple private key (p8 file) + - **Redirect URI**: Configure as `https://api.opensecret.cloud/auth/apple/callback` + +## Implementation Details + +### Frontend Integration + +#### iOS Native Flow +```typescript +// iOS native authentication +const result = await invoke("plugin:sign-in-with-apple|get_apple_id_credential", { + payload: { + scope: ["email", "fullName"], + state: "apple-auth-state", + options: { debug: true } + } +}); + +// Format and send to backend +const appleUser = { + user_identifier: result.user, + identity_token: result.identityToken, + email: result.email, + given_name: result.fullName?.givenName, + family_name: result.fullName?.familyName +}; + +// Call OpenSecret SDK +await os.handleAppleNativeSignIn(appleUser, inviteCode); +``` + +#### Web OAuth Flow +```typescript +// Web OAuth authentication +const { auth_url } = await os.initiateAppleAuth(inviteCode); +window.location.href = auth_url; + +// Callback handling (in separate component) +await handleAppleCallback(code, state, inviteCode); +``` + +### Platform Detection +The app automatically determines the appropriate flow: +1. Checks if the app is running on iOS and uses native flow +2. Checks if running in a Tauri environment (desktop) and uses the desktop auth flow +3. Uses the web OAuth flow for all other cases + +## Callback Handling +For the web OAuth flow, callbacks are handled in `auth.$provider.callback.tsx`: +1. Extracts code and state from URL parameters +2. Verifies auth state to prevent CSRF attacks +3. Processes the authentication with the backend +4. Redirects to appropriate page after successful authentication + +## Debugging +If you experience issues with Sign in with Apple: +1. Check debug logs (enabled by default in both flows) +2. Verify that iOS entitlements are properly configured (for native flow) +3. For OAuth flow, check if Apple Developer account is properly set up +4. Verify that the OpenSecret project settings are correctly configured + +## Apple Developer Setup +To use Sign in with Apple, you need: +1. An Apple Developer account +2. An App ID with "Sign In with Apple" capability +3. A Services ID for web authentication +4. A Bundle ID for iOS apps +5. A private key for JWT token signing + +## Resources +- [Apple Developer Documentation](https://developer.apple.com/documentation/sign_in_with_apple) +- [OpenSecret Apple Auth API](https://docs.opensecret.cloud/docs/guides/authentication) +- [tauri-plugin-sign-in-with-apple](https://crates.io/crates/tauri-plugin-sign-in-with-apple) \ No newline at end of file diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 12db43e64..1c519f1f5 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -18,6 +18,7 @@ "url": "https://trymaple.ai/*" } ] - } + }, + "sign-in-with-apple:default" ] } diff --git a/frontend/src-tauri/capabilities/mobile-ios.json b/frontend/src-tauri/capabilities/mobile-ios.json new file mode 100644 index 000000000..188ce5fa9 --- /dev/null +++ b/frontend/src-tauri/capabilities/mobile-ios.json @@ -0,0 +1,25 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "mobile-ios", + "description": "Capabilities for iOS", + "windows": [ + "main" + ], + "platforms": ["iOS"], + "permissions": [ + "core:default", + "updater:default", + { + "identifier": "opener:allow-open-url", + "allow": [ + { + "url": "http://localhost:5173/*" + }, + { + "url": "https://trymaple.ai/*" + } + ] + }, + "sign-in-with-apple:default" + ] +} \ No newline at end of file diff --git a/frontend/src-tauri/gen/apple/maple_iOS/Info.plist b/frontend/src-tauri/gen/apple/maple_iOS/Info.plist index 2e8e14840..d38ce3269 100644 --- a/frontend/src-tauri/gen/apple/maple_iOS/Info.plist +++ b/frontend/src-tauri/gen/apple/maple_iOS/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.4 + 1.0.5 CFBundleVersion - 1.0.4 + 1.0.5 LSRequiresIPhoneOS UILaunchStoryboardName diff --git a/frontend/src-tauri/gen/apple/maple_iOS/maple_iOS.entitlements b/frontend/src-tauri/gen/apple/maple_iOS/maple_iOS.entitlements index b026fc176..869918254 100644 --- a/frontend/src-tauri/gen/apple/maple_iOS/maple_iOS.entitlements +++ b/frontend/src-tauri/gen/apple/maple_iOS/maple_iOS.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.applesignin + + Default + com.apple.developer.associated-domains applinks:trymaple.ai diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 3655ec4a8..52777924e 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ use tauri::Emitter; use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_opener; +use tauri_plugin_sign_in_with_apple; // This handles incoming deep links fn handle_deep_link_event(url: &str, app: &tauri::AppHandle) { @@ -184,7 +185,7 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()); #[cfg(not(desktop))] - let app = tauri::Builder::default() + let mut builder = tauri::Builder::default() .plugin( tauri_plugin_log::Builder::default() .level(log::LevelFilter::Info) @@ -192,7 +193,16 @@ pub fn run() { ) .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_os::init()); + + // Only add the Apple Sign In plugin on iOS + #[cfg(all(not(desktop), target_os = "ios"))] + { + builder = builder.plugin(tauri_plugin_sign_in_with_apple::init()); + } + + #[cfg(not(desktop))] + let app = builder .setup(|app| { // Set up the deep link handler for mobile let app_handle = app.handle().clone(); diff --git a/frontend/src/components/Marketing.tsx b/frontend/src/components/Marketing.tsx index c060db583..ad3c8ab25 100644 --- a/frontend/src/components/Marketing.tsx +++ b/frontend/src/components/Marketing.tsx @@ -200,8 +200,18 @@ export function Marketing() { useEffect(() => { const checkPlatform = async () => { try { - const platform = await type(); - setIsIOS(platform === "ios"); + // First check if we're in a Tauri environment + const isTauriEnv = await import("@tauri-apps/api/core") + .then((m) => m.isTauri()) + .catch(() => false); + + if (isTauriEnv) { + // Only check platform type if we're in a Tauri environment + const platform = await type(); + setIsIOS(platform === "ios"); + } else { + setIsIOS(false); + } } catch (error) { console.error("Error checking platform:", error); setIsIOS(false); diff --git a/frontend/src/components/icons/Apple.tsx b/frontend/src/components/icons/Apple.tsx new file mode 100644 index 000000000..ac813c774 --- /dev/null +++ b/frontend/src/components/icons/Apple.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +export function Apple(props: React.SVGProps) { + return ( + + + + ); +} diff --git a/frontend/src/routes/auth.$provider.callback.tsx b/frontend/src/routes/auth.$provider.callback.tsx index ef043ccba..6ca4e2931 100644 --- a/frontend/src/routes/auth.$provider.callback.tsx +++ b/frontend/src/routes/auth.$provider.callback.tsx @@ -29,6 +29,58 @@ function OAuthCallback() { const { handleGitHubCallback, handleGoogleCallback } = useOpenSecret(); const processedRef = useRef(false); + // Helper functions for the callback process + const handleSuccessfulAuth = () => { + // Check if this is a Tauri app auth flow (desktop or mobile) + const isTauriAuth = localStorage.getItem("redirect-to-native") === "true"; + + // Clear the flag + localStorage.removeItem("redirect-to-native"); + + if (isTauriAuth) { + // Handle Tauri redirect + const accessToken = localStorage.getItem("access_token") || ""; + const refreshToken = localStorage.getItem("refresh_token"); + + let deepLinkUrl = `cloud.opensecret.maple://auth?access_token=${encodeURIComponent(accessToken)}`; + + if (refreshToken) { + deepLinkUrl += `&refresh_token=${encodeURIComponent(refreshToken)}`; + } + + setTimeout(() => { + window.location.href = deepLinkUrl; + }, 1000); + + return; + } + + // Handle web redirect + const selectedPlan = sessionStorage.getItem("selected_plan"); + sessionStorage.removeItem("selected_plan"); + + setTimeout(() => { + if (selectedPlan) { + navigate({ + to: "/pricing", + search: { selected_plan: selectedPlan } + }); + } else { + navigate({ to: "/" }); + } + }, 2000); + }; + + const handleAuthError = (error: unknown) => { + console.error(`Authentication callback error:`, error); + if (error instanceof Error) { + setError(error.message); + } else { + setError("Unknown error"); + } + setIsProcessing(false); + }; + const { provider } = Route.useParams(); const formattedProvider = formatProviderName(provider); // Format the provider name @@ -37,68 +89,27 @@ function OAuthCallback() { if (processedRef.current) return; processedRef.current = true; + // Get URL parameters for all OAuth providers const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get("code"); const state = urlParams.get("state"); if (code && state) { try { - // Get the auth token from localStorage - await (provider === "github" - ? handleGitHubCallback(code, state, "") - : handleGoogleCallback(code, state, "")); - - // Check if this is a Tauri app auth flow (desktop or mobile) - const isTauriAuth = localStorage.getItem("redirect-to-native") === "true"; - - // Clear the flag - localStorage.removeItem("redirect-to-native"); - - if (isTauriAuth) { - // This is a Tauri app auth flow - redirect back to the app with tokens - // Get tokens from localStorage where they're stored after auth - const accessToken = localStorage.getItem("access_token") || ""; - const refreshToken = localStorage.getItem("refresh_token"); - - // Construct the deep link URL using the consistent token names - let deepLinkUrl = `cloud.opensecret.maple://auth?access_token=${encodeURIComponent(accessToken)}`; - - if (refreshToken) { - deepLinkUrl += `&refresh_token=${encodeURIComponent(refreshToken)}`; - } - - // Redirect to the deep link - setTimeout(() => { - window.location.href = deepLinkUrl; - }, 1000); - - return; // Stop further processing + // Get the auth token from localStorage based on the provider + if (provider === "github") { + await handleGitHubCallback(code, state, ""); + } else if (provider === "google") { + await handleGoogleCallback(code, state, ""); + } else { + throw new Error(`Unsupported provider: ${provider}`); } - // Regular web flow - unchanged - const selectedPlan = sessionStorage.getItem("selected_plan"); - sessionStorage.removeItem("selected_plan"); - - // If successful, redirect after a short delay - setTimeout(() => { - if (selectedPlan) { - // If there was a selected plan, go to pricing - navigate({ - to: "/pricing", - search: { selected_plan: selectedPlan } - }); - } else { - // Otherwise go home (original behavior) - navigate({ to: "/" }); - } - }, 2000); + // Handle the successful authentication (redirect) + handleSuccessfulAuth(); } catch (error) { - console.error(`${provider} callback error:`, error); - if (error instanceof Error) { - setError(error.message); - } else { - setError("Unknown error"); - } + // Handle authentication error + handleAuthError(error); } finally { setIsProcessing(false); } diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index ffab59628..527fd2043 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -7,8 +7,14 @@ import { Label } from "@/components/ui/label"; import { AlertDestructive } from "@/components/AlertDestructive"; import { Loader2, Github, Mail } from "lucide-react"; import { Google } from "@/components/icons/Google"; +import { Apple } from "@/components/icons/Apple"; import { AuthMain } from "@/components/AuthMain"; import { isTauri, invoke } from "@tauri-apps/api/core"; +import { type } from "@tauri-apps/plugin-os"; +import { v4 as uuidv4 } from "uuid"; +import type { AppleCredential } from "@/types/apple-sign-in"; +import { sha256 } from "@noble/hashes/sha256"; +import { bytesToHex } from "@noble/hashes/utils"; type LoginSearchParams = { next?: string; @@ -23,7 +29,7 @@ export const Route = createFileRoute("/login")({ }) }); -type LoginMethod = "email" | "github" | "google" | null; +type LoginMethod = "email" | "github" | "google" | "apple" | null; function LoginPage() { const navigate = useNavigate(); @@ -32,6 +38,37 @@ function LoginPage() { const [loginMethod, setLoginMethod] = useState(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [isIOS, setIsIOS] = useState(false); + + // Check if running on iOS + useEffect(() => { + const checkPlatform = async () => { + try { + // First check if we're in a Tauri environment + let isTauriEnv = false; + try { + isTauriEnv = await isTauri(); + } catch { + // Not in Tauri environment + isTauriEnv = false; + } + + // Only check platform type if we're in a Tauri environment + if (isTauriEnv) { + const platform = await type(); + setIsIOS(platform === "ios"); + } else { + // Not in Tauri environment, definitely not iOS + setIsIOS(false); + } + } catch (error) { + console.error("Error checking platform:", error); + setIsIOS(false); + } + }; + + checkPlatform(); + }, []); // Redirect if already logged in useEffect(() => { @@ -150,9 +187,88 @@ function LoginPage() { } }; + const handleAppleLogin = async () => { + try { + // Only iOS supports native Apple Sign In + console.log("[OAuth] Initiating native Sign in with Apple for iOS"); + + try { + // Generate random UUIDs for state and nonce + const state = uuidv4(); + const rawNonce = uuidv4(); + + // SHA-256 hash the nonce before sending to Apple + // Apple requires the nonce to be hashed with SHA-256 + const hashedNonce = bytesToHex(sha256(new TextEncoder().encode(rawNonce))); + + + // Invoke the Apple Sign in plugin + // This will show the native Apple authentication UI + const result = await invoke( + "plugin:sign-in-with-apple|get_apple_id_credential", + { + payload: { + scope: ["email", "fullName"], + state, + nonce: hashedNonce, // Send the hashed nonce to Apple + // Disable debug mode in production + options: { + debug: false + } + } + } + ); + + console.log("[OAuth] Apple Sign-In result:", result); + + // Format the response for the API + const appleUser = { + user_identifier: result.user, + identity_token: result.identityToken, + email: result.email, + given_name: result.fullName?.givenName, + family_name: result.fullName?.familyName, + nonce: rawNonce // Pass the original raw nonce to backend + }; + + // Send to backend via SDK + try { + await os.handleAppleNativeSignIn(appleUser, ""); + // Redirect after successful login + if (selected_plan) { + navigate({ + to: "/pricing", + search: { selected_plan } + }); + } else { + navigate({ to: next || "/" }); + } + } catch (backendError) { + console.error("[OAuth] Backend processing failed:", backendError); + setError( + backendError instanceof Error + ? backendError.message + : "Failed to process Apple authentication" + ); + } + } catch (error) { + console.error("[OAuth] Failed to authenticate with Apple:", error); + const errorMessage = + error instanceof Error + ? `Apple Sign In error: ${error.message}` + : "Failed to authenticate with Apple. Please try again."; + setError(errorMessage); + } + } catch (error) { + console.error("Failed to initiate Apple login:", error); + setError("Failed to initiate Apple login. Please try again."); + } + }; + if (!loginMethod) { return ( + {error && } + {isIOS && ( + + )}
Need an account?{" "} diff --git a/frontend/src/routes/pricing.tsx b/frontend/src/routes/pricing.tsx index 88142c8be..4702a76ac 100644 --- a/frontend/src/routes/pricing.tsx +++ b/frontend/src/routes/pricing.tsx @@ -152,8 +152,18 @@ function PricingPage() { useEffect(() => { const checkPlatform = async () => { try { - const platform = await type(); - setIsIOS(platform === "ios"); + // First check if we're in a Tauri environment + const isTauriEnv = await import("@tauri-apps/api/core") + .then((m) => m.isTauri()) + .catch(() => false); + + if (isTauriEnv) { + // Only check platform type if we're in a Tauri environment + const platform = await type(); + setIsIOS(platform === "ios"); + } else { + setIsIOS(false); + } } catch (error) { console.error("Error checking platform:", error); setIsIOS(false); diff --git a/frontend/src/routes/signup.tsx b/frontend/src/routes/signup.tsx index 5c11c514d..26dfb6b61 100644 --- a/frontend/src/routes/signup.tsx +++ b/frontend/src/routes/signup.tsx @@ -7,8 +7,14 @@ import { Label } from "@/components/ui/label"; import { AlertDestructive } from "@/components/AlertDestructive"; import { Loader2, Github, Mail } from "lucide-react"; import { Google } from "@/components/icons/Google"; +import { Apple } from "@/components/icons/Apple"; import { AuthMain } from "@/components/AuthMain"; import { isTauri, invoke } from "@tauri-apps/api/core"; +import { type } from "@tauri-apps/plugin-os"; +import { v4 as uuidv4 } from "uuid"; +import type { AppleCredential } from "@/types/apple-sign-in"; +import { sha256 } from "@noble/hashes/sha256"; +import { bytesToHex } from "@noble/hashes/utils"; type SignupSearchParams = { next?: string; @@ -23,7 +29,7 @@ export const Route = createFileRoute("/signup")({ }) }); -type SignUpMethod = "email" | "github" | "google" | null; +type SignUpMethod = "email" | "github" | "google" | "apple" | null; function SignupPage() { const navigate = useNavigate(); @@ -32,6 +38,37 @@ function SignupPage() { const [signUpMethod, setSignUpMethod] = useState(null); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [isIOS, setIsIOS] = useState(false); + + // Check if running on iOS + useEffect(() => { + const checkPlatform = async () => { + try { + // First check if we're in a Tauri environment + let isTauriEnv = false; + try { + isTauriEnv = await isTauri(); + } catch { + // Not in Tauri environment + isTauriEnv = false; + } + + // Only check platform type if we're in a Tauri environment + if (isTauriEnv) { + const platform = await type(); + setIsIOS(platform === "ios"); + } else { + // Not in Tauri environment, definitely not iOS + setIsIOS(false); + } + } catch (error) { + console.error("Error checking platform:", error); + setIsIOS(false); + } + }; + + checkPlatform(); + }, []); // Redirect if already logged in useEffect(() => { @@ -150,9 +187,88 @@ function SignupPage() { } }; + const handleAppleSignup = async () => { + try { + // Only iOS supports native Apple Sign In + console.log("[OAuth] Initiating native Sign in with Apple for iOS"); + + try { + // Generate random UUIDs for state and nonce + const state = uuidv4(); + const rawNonce = uuidv4(); + + // SHA-256 hash the nonce before sending to Apple + // Apple requires the nonce to be hashed with SHA-256 + const hashedNonce = bytesToHex(sha256(new TextEncoder().encode(rawNonce))); + + + // Invoke the Apple Sign in plugin + // This will show the native Apple authentication UI + const result = await invoke( + "plugin:sign-in-with-apple|get_apple_id_credential", + { + payload: { + scope: ["email", "fullName"], + state, + nonce: hashedNonce, // Send the hashed nonce to Apple + // Disable debug mode in production + options: { + debug: false + } + } + } + ); + + console.log("[OAuth] Apple Sign-In result:", result); + + // Format the response for the API + const appleUser = { + user_identifier: result.user, + identity_token: result.identityToken, + email: result.email, + given_name: result.fullName?.givenName, + family_name: result.fullName?.familyName, + nonce: rawNonce // Pass the original raw nonce to backend + }; + + // Send to backend via SDK + try { + await os.handleAppleNativeSignIn(appleUser, ""); + // Redirect after successful signup + if (selected_plan) { + navigate({ + to: "/pricing", + search: { selected_plan } + }); + } else { + navigate({ to: next || "/" }); + } + } catch (backendError) { + console.error("[OAuth] Backend processing failed:", backendError); + setError( + backendError instanceof Error + ? backendError.message + : "Failed to process Apple authentication" + ); + } + } catch (error) { + console.error("[OAuth] Failed to authenticate with Apple:", error); + const errorMessage = + error instanceof Error + ? `Apple Sign In error: ${error.message}` + : "Failed to authenticate with Apple. Please try again."; + setError(errorMessage); + } + } catch (error) { + console.error("Failed to initiate Apple signup:", error); + setError("Failed to initiate Apple signup. Please try again."); + } + }; + if (!signUpMethod) { return ( + {error && } + {isIOS && ( + + )}
Already have an account?{" "} diff --git a/frontend/src/types/apple-sign-in.d.ts b/frontend/src/types/apple-sign-in.d.ts new file mode 100644 index 000000000..406949156 --- /dev/null +++ b/frontend/src/types/apple-sign-in.d.ts @@ -0,0 +1,13 @@ +// Type definitions for native iOS Apple Sign In plugin + +// For native Apple Sign In plugin +export interface AppleCredential { + user: string; + identityToken: string; + email?: string; + fullName?: { + givenName?: string; + familyName?: string; + }; + state?: string; +}