From 6db10c6acaee4882c8cf9cf85ed780b0058f3003 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 14 Apr 2026 11:34:46 -0600 Subject: [PATCH] feat(mobile): add QR/code pairing flow with security hardening Add mobile pairing UI (QR scan + paste code) and auth persistence via secure storage. Includes SSRF protection on relay URLs, TLS enforcement in release builds, QR scanner de-duplication, re-entrancy guard, and an offline retry screen for network errors at startup. Desktop side adds a pairing card in settings that generates QR codes for mobile to scan. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/package.json | 1 + desktop/pnpm-lock.yaml | 12 + .../settings/ui/MobilePairingCard.tsx | 237 ++++++++++++++ .../features/settings/ui/SettingsPanels.tsx | 10 + mobile/ios/Flutter/Debug.xcconfig | 1 + mobile/ios/Flutter/Release.xcconfig | 1 + mobile/ios/Podfile | 42 +++ mobile/ios/Podfile.lock | 100 ++++++ mobile/ios/Runner.xcodeproj/project.pbxproj | 136 +++++++- .../contents.xcworkspacedata | 3 + mobile/lib/app.dart | 67 +++- mobile/lib/features/pairing/pairing_page.dart | 210 ++++++++++++ .../features/pairing/pairing_provider.dart | 167 ++++++++++ .../lib/features/settings/settings_page.dart | 104 +++--- mobile/lib/shared/auth/auth.dart | 2 + mobile/lib/shared/auth/auth_provider.dart | 88 ++++++ .../lib/shared/auth/credential_storage.dart | 47 +++ mobile/lib/shared/relay/relay_client.dart | 2 +- mobile/pubspec.lock | 207 +++++++++++- mobile/pubspec.yaml | 2 + .../features/pairing/pairing_page_test.dart | 132 ++++++++ .../pairing/pairing_provider_test.dart | 299 ++++++++++++++++++ mobile/test/widget_test.dart | 24 +- 23 files changed, 1832 insertions(+), 62 deletions(-) create mode 100644 desktop/src/features/settings/ui/MobilePairingCard.tsx create mode 100644 mobile/ios/Podfile create mode 100644 mobile/ios/Podfile.lock create mode 100644 mobile/lib/features/pairing/pairing_page.dart create mode 100644 mobile/lib/features/pairing/pairing_provider.dart create mode 100644 mobile/lib/shared/auth/auth.dart create mode 100644 mobile/lib/shared/auth/auth_provider.dart create mode 100644 mobile/lib/shared/auth/credential_storage.dart create mode 100644 mobile/test/features/pairing/pairing_page_test.dart create mode 100644 mobile/test/features/pairing/pairing_provider_test.dart diff --git a/desktop/package.json b/desktop/package.json index 25848ae1b24..578ddd7074d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -47,6 +47,7 @@ "emoji-mart": "^5.6.0", "jdenticon": "^3.3.0", "lucide-react": "^0.577.0", + "qrcode.react": "^4.2.0", "react": "^19.1.0", "react-diff-view": "^3.3.2", "react-dom": "^19.1.0", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 51b5e5d478a..3b8b8b26e5b 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.5) + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.5) react: specifier: ^19.1.0 version: 19.2.5 @@ -1963,6 +1966,11 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4158,6 +4166,10 @@ snapshots: property-information@7.1.0: {} + qrcode.react@4.2.0(react@19.2.5): + dependencies: + react: 19.2.5 + queue-microtask@1.2.3: {} react-diff-view@3.3.2(react@19.2.5): diff --git a/desktop/src/features/settings/ui/MobilePairingCard.tsx b/desktop/src/features/settings/ui/MobilePairingCard.tsx new file mode 100644 index 00000000000..d2b7a1bf78d --- /dev/null +++ b/desktop/src/features/settings/ui/MobilePairingCard.tsx @@ -0,0 +1,237 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { QRCodeSVG } from "qrcode.react"; +import { Check, Copy, Loader2, Smartphone, TriangleAlert } from "lucide-react"; + +import { useMintTokenMutation } from "@/features/tokens/hooks"; +import { getRelayHttpUrl } from "@/shared/api/tauri"; +import type { TokenScope } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +const MOBILE_SCOPES: TokenScope[] = [ + "messages:read", + "messages:write", + "channels:read", + "users:read", + "files:read", +]; +const EXPIRES_IN_DAYS = 90; + +function toBase64Url(str: string): string { + return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +type PairingPayload = { + relayUrl: string; + token: string; + pubkey: string; +}; + +function PairingDialog({ + open, + onOpenChange, + currentPubkey, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + currentPubkey: string; +}) { + const mintMutation = useMintTokenMutation(); + const mintRef = useRef(mintMutation.mutateAsync); + mintRef.current = mintMutation.mutateAsync; + + const [pairingUri, setPairingUri] = useState(null); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + const generate = useCallback(async (pubkey: string) => { + const [tokenResult, relayUrl] = await Promise.all([ + mintRef.current({ + name: `mobile-${Date.now()}`, + scopes: [...MOBILE_SCOPES], + expiresInDays: EXPIRES_IN_DAYS, + }), + getRelayHttpUrl(), + ]); + + const payload: PairingPayload = { + relayUrl, + token: tokenResult.token, + pubkey, + }; + return `sprout://${toBase64Url(JSON.stringify(payload))}`; + }, []); + + useEffect(() => { + if (!open) return; + + setPairingUri(null); + setError(null); + setCopied(false); + + let cancelled = false; + + generate(currentPubkey).then( + (uri) => { + if (!cancelled) setPairingUri(uri); + }, + (err) => { + if (!cancelled) { + setError( + err instanceof Error + ? err.message + : "Failed to generate pairing code", + ); + } + }, + ); + + return () => { + cancelled = true; + }; + }, [open, currentPubkey, generate]); + + async function handleCopy() { + if (!pairingUri) return; + await navigator.clipboard.writeText(pairingUri); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + + +
+ + Pair Mobile Device + + Scan this QR code with the Sprout mobile app, or copy the pairing + code for manual setup. + + + +
+ {error ? ( +
+ + {error} +
+ ) : !pairingUri ? ( +
+ +

+ Generating pairing code… +

+
+ ) : ( +
+
+ +
+ +
+

+ Pairing code +

+
+ + {pairingUri} + + +
+
+ +

+ This token expires in {EXPIRES_IN_DAYS} days. You can revoke + it from the Tokens settings at any time. +

+
+ )} +
+ +
+ +
+
+
+
+ ); +} + +export function MobilePairingCard({ + currentPubkey, +}: { + currentPubkey?: string; +}) { + const [dialogOpen, setDialogOpen] = useState(false); + + return ( +
+
+

Mobile

+

+ Connect the Sprout mobile app to this relay by scanning a QR code or + pasting a pairing code. +

+
+ +
+ +
+

Pair Mobile Device

+

+ Generate a one-time pairing code for the mobile app +

+
+ +
+ + {currentPubkey && ( + + )} +
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 20ef82332c0..869b2ea747e 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -6,6 +6,7 @@ import { MonitorCog, Moon, Search, + Smartphone, Stethoscope, Sun, UserRound, @@ -20,6 +21,7 @@ import { cn } from "@/shared/lib/cn"; import { ACCENT_COLORS, useTheme } from "@/shared/theme/ThemeProvider"; import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader"; import { DoctorSettingsPanel } from "./DoctorSettingsPanel"; +import { MobilePairingCard } from "./MobilePairingCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { ProfileSettingsCard } from "./ProfileSettingsCard"; @@ -28,6 +30,7 @@ export type SettingsSection = | "notifications" | "appearance" | "tokens" + | "mobile" | "doctor"; export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile"; @@ -72,6 +75,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ label: "Tokens", icon: KeyRound, }, + { + value: "mobile", + label: "Mobile", + icon: Smartphone, + }, { value: "doctor", label: "Doctor", @@ -228,6 +236,8 @@ export function renderSettingsSection( return ; case "tokens": return ; + case "mobile": + return ; case "doctor": return ; default: { diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig index 592ceee85b8..ec97fc6f302 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig index 592ceee85b8..c4855bfe200 100644 --- a/mobile/ios/Flutter/Release.xcconfig +++ b/mobile/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile new file mode 100644 index 00000000000..8eaadb05782 --- /dev/null +++ b/mobile/ios/Podfile @@ -0,0 +1,42 @@ +platform :ios, '16.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock new file mode 100644 index 00000000000..6b85699de66 --- /dev/null +++ b/mobile/ios/Podfile.lock @@ -0,0 +1,100 @@ +PODS: + - Flutter (1.0.0) + - flutter_secure_storage (6.0.0): + - Flutter + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleMLKit/BarcodeScanning (7.0.0): + - GoogleMLKit/MLKitCore + - MLKitBarcodeScanning (~> 6.0.0) + - GoogleMLKit/MLKitCore (7.0.0): + - MLKitCommon (~> 12.0.0) + - GoogleToolboxForMac/Defines (4.2.1) + - GoogleToolboxForMac/Logger (4.2.1): + - GoogleToolboxForMac/Defines (= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (4.2.1)": + - GoogleToolboxForMac/Defines (= 4.2.1) + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMSessionFetcher/Core (3.5.0) + - MLImage (1.0.0-beta6) + - MLKitBarcodeScanning (6.0.0): + - MLKitCommon (~> 12.0) + - MLKitVision (~> 8.0) + - MLKitCommon (12.0.0): + - GoogleDataTransport (~> 10.0) + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GoogleUtilities/Logger (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLKitVision (8.0.0): + - GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1) + - "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)" + - GTMSessionFetcher/Core (< 4.0, >= 3.3.2) + - MLImage (= 1.0.0-beta6) + - MLKitCommon (~> 12.0) + - mobile_scanner (6.0.2): + - Flutter + - GoogleMLKit/BarcodeScanning (~> 7.0.0) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - PromisesObjC (2.4.0) + +DEPENDENCIES: + - Flutter (from `Flutter`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`) + +SPEC REPOS: + trunk: + - GoogleDataTransport + - GoogleMLKit + - GoogleToolboxForMac + - GoogleUtilities + - GTMSessionFetcher + - MLImage + - MLKitBarcodeScanning + - MLKitCommon + - MLKitVision + - nanopb + - PromisesObjC + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" + mobile_scanner: + :path: ".symlinks/plugins/mobile_scanner/ios" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318 + GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 + MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56 + MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2 + MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d + MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e + mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + +PODFILE CHECKSUM: bd29822c3d5baf6b44b726f00ea3293a19339ef2 + +COCOAPODS: 1.16.2 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index cf0b964a554..ead3b073986 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -9,7 +9,9 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 42C129326CE4E1B8E617B9CD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -43,13 +45,20 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 42F36AA401015EC51F984FEA /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 529B9B84AD8748153EB60BCF /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 57A155722F02B92C397E5AE2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 7CF2415588E96D5723581BA9 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 8EB101B7046447A0502FE6C9 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -57,13 +66,23 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 3C28C6B702C81085E6F96F2A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 42C129326CE4E1B8E617B9CD /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -78,6 +97,20 @@ path = RunnerTests; sourceTree = ""; }; + 746EA2D40EB8F35E67C48311 /* Pods */ = { + isa = PBXGroup; + children = ( + 57A155722F02B92C397E5AE2 /* Pods-Runner.debug.xcconfig */, + 30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */, + 8EB101B7046447A0502FE6C9 /* Pods-Runner.profile.xcconfig */, + 42F36AA401015EC51F984FEA /* Pods-RunnerTests.debug.xcconfig */, + 7CF2415588E96D5723581BA9 /* Pods-RunnerTests.release.xcconfig */, + 529B9B84AD8748153EB60BCF /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -96,6 +129,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 746EA2D40EB8F35E67C48311 /* Pods */, + D12A84F9A6072498822784CC /* Frameworks */, ); sourceTree = ""; }; @@ -124,6 +159,15 @@ path = Runner; sourceTree = ""; }; + D12A84F9A6072498822784CC /* Frameworks */ = { + isa = PBXGroup; + children = ( + 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */, + CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -131,8 +175,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 29C475F38B9F5B6D27BD900B /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 3C28C6B702C81085E6F96F2A /* Frameworks */, ); buildRules = ( ); @@ -148,12 +194,15 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 1D327B920BBA7EF4545E2A92 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */, + 0CF701FE301CC7B2D25E86E6 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -225,6 +274,67 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 0CF701FE301CC7B2D25E86E6 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 1D327B920BBA7EF4545E2A92 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 29C475F38B9F5B6D27BD900B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -256,6 +366,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -350,7 +477,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -383,6 +510,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 42F36AA401015EC51F984FEA /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -400,6 +528,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 7CF2415588E96D5723581BA9 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -415,6 +544,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 529B9B84AD8748153EB60BCF /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -477,7 +607,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -528,7 +658,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a16ed0..21a3cc14c74 100644 --- a/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 4b48f31e20b..e1c0fa68b2f 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'features/home/home_page.dart'; +import 'features/pairing/pairing_page.dart'; +import 'shared/auth/auth.dart'; import 'shared/theme/theme.dart'; class App extends HookConsumerWidget { @@ -10,13 +13,75 @@ class App extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final themeMode = ref.watch(themeProvider); + final authState = ref.watch(authProvider); return MaterialApp( title: 'Sprout', theme: AppTheme.lightTheme, darkTheme: AppTheme.darkTheme, themeMode: themeMode, - home: const HomePage(), + home: authState.when( + loading: () => const _SplashScreen(), + error: (_, _) => const PairingPage(), + data: (state) => switch (state.status) { + AuthStatus.authenticated => const HomePage(), + AuthStatus.offline => const _OfflineScreen(), + _ => const PairingPage(), + }, + ), + ); + } +} + +class _SplashScreen extends StatelessWidget { + const _SplashScreen(); + + @override + Widget build(BuildContext context) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } +} + +class _OfflineScreen extends ConsumerWidget { + const _OfflineScreen(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.wifiOff, + size: 48, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(height: Grid.xs), + Text( + 'Unable to reach relay', + style: context.textTheme.titleMedium, + ), + const SizedBox(height: Grid.xxs), + Text( + 'Your pairing is saved — check your connection and try again.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.sm), + FilledButton.icon( + onPressed: () => ref.read(authProvider.notifier).retry(), + icon: const Icon(LucideIcons.refreshCw), + label: const Text('Retry'), + ), + ], + ), + ), + ), ); } } diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart new file mode 100644 index 00000000000..571884da113 --- /dev/null +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +import '../../shared/theme/theme.dart'; +import 'pairing_provider.dart'; + +class PairingPage extends HookConsumerWidget { + const PairingPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pairingState = ref.watch(pairingProvider); + final codeController = useTextEditingController(); + final isConnecting = pairingState.status == PairingStatus.connecting; + + return Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.sm), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(flex: 2), + + // Branding + Icon(LucideIcons.sprout, size: 64, color: context.colors.primary), + const SizedBox(height: Grid.xs), + Text('Welcome to Sprout', style: context.textTheme.headlineSmall), + const SizedBox(height: Grid.xxs), + Text( + 'Scan the QR code from your desktop app\nor paste a pairing code to connect.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + + const SizedBox(height: Grid.lg), + + // Scan QR button + FilledButton.icon( + onPressed: isConnecting + ? null + : () => _openScanner(context, ref), + icon: const Icon(LucideIcons.scanLine), + label: const Text('Scan QR Code'), + ), + + const SizedBox(height: Grid.sm), + + // Divider + Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + ), + child: Text( + 'or paste pairing code', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + const Expanded(child: Divider()), + ], + ), + + const SizedBox(height: Grid.sm), + + // Paste field + TextField( + controller: codeController, + decoration: const InputDecoration( + hintText: 'sprout://...', + prefixIcon: Icon(LucideIcons.link), + isDense: true, + ), + autocorrect: false, + enableSuggestions: false, + enabled: !isConnecting, + contextMenuBuilder: (context, editableTextState) { + return AdaptiveTextSelectionToolbar.editableText( + editableTextState: editableTextState, + ); + }, + ), + + const SizedBox(height: Grid.twelve), + + // Connect button + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: isConnecting + ? null + : () { + final code = codeController.text.trim(); + if (code.isNotEmpty) { + ref.read(pairingProvider.notifier).pair(code); + } + }, + child: isConnecting + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Connect'), + ), + ), + + // Error message + if (pairingState.status == PairingStatus.error && + pairingState.errorMessage != null) ...[ + const SizedBox(height: Grid.twelve), + Container( + padding: const EdgeInsets.all(Grid.twelve), + decoration: BoxDecoration( + color: context.colors.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + LucideIcons.triangleAlert, + size: 16, + color: context.colors.onErrorContainer, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Text( + pairingState.errorMessage!, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onErrorContainer, + ), + ), + ), + ], + ), + ), + ], + + const Spacer(flex: 3), + ], + ), + ), + ), + ); + } + + void _openScanner(BuildContext context, WidgetRef ref) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _ScannerPage( + onScanned: (code) { + Navigator.of(context).pop(); + ref.read(pairingProvider.notifier).pair(code); + }, + ), + ), + ); + } +} + +class _ScannerPage extends StatefulWidget { + final void Function(String code) onScanned; + + const _ScannerPage({required this.onScanned}); + + @override + State<_ScannerPage> createState() => _ScannerPageState(); +} + +class _ScannerPageState extends State<_ScannerPage> { + bool _handled = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Scan QR Code'), + leading: IconButton( + icon: const Icon(LucideIcons.arrowLeft), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: MobileScanner( + onDetect: (capture) { + if (_handled) return; + final barcodes = capture.barcodes; + if (barcodes.isNotEmpty) { + final value = barcodes.first.rawValue; + if (value != null && value.isNotEmpty) { + _handled = true; + widget.onScanned(value); + } + } + }, + ), + ); + } +} diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart new file mode 100644 index 00000000000..e4f20be7a12 --- /dev/null +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; + +import '../../shared/auth/auth.dart'; +import '../../shared/relay/relay.dart'; + +/// HTTP client used by [PairingNotifier] for the validation request. +/// Override in tests to inject a mock client. +final pairingHttpClientProvider = Provider((ref) { + final client = http.Client(); + ref.onDispose(client.close); + return client; +}); + +enum PairingStatus { idle, connecting, success, error } + +class PairingState { + final PairingStatus status; + final String? errorMessage; + + const PairingState({this.status = PairingStatus.idle, this.errorMessage}); + + PairingState copyWith({PairingStatus? status, String? errorMessage}) => + PairingState( + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + ); +} + +class PairingNotifier extends Notifier { + @override + PairingState build() => const PairingState(); + + Future pair(String rawInput) async { + if (state.status == PairingStatus.connecting) return; + + state = const PairingState(status: PairingStatus.connecting); + + try { + final creds = _parseInput(rawInput); + + // Test the connection before storing. + final client = RelayClient( + baseUrl: creds.relayUrl, + apiToken: creds.token, + httpClient: ref.read(pairingHttpClientProvider), + ); + try { + await client.get('/api/users/me/profile'); + } finally { + client.dispose(); + } + + await ref.read(authProvider.notifier).authenticate(creds); + state = const PairingState(status: PairingStatus.success); + } on FormatException catch (e) { + state = PairingState( + status: PairingStatus.error, + errorMessage: 'Invalid pairing code: ${e.message}', + ); + } on RelayException catch (e) { + state = PairingState( + status: PairingStatus.error, + errorMessage: + 'Could not connect to relay (${e.statusCode}). ' + 'Check that the pairing code is valid.', + ); + } catch (e) { + state = PairingState( + status: PairingStatus.error, + errorMessage: + 'Connection failed. Make sure your device can reach the ' + 'relay server.', + ); + } + } + + void reset() { + state = const PairingState(); + } + + StoredCredentials _parseInput(String raw) { + var payload = raw.trim(); + + if (payload.startsWith('sprout://')) { + payload = payload.substring('sprout://'.length); + } + + // base64url decode. + final normalized = base64Url.normalize(payload); + final jsonStr = utf8.decode(base64Url.decode(normalized)); + final decoded = jsonDecode(jsonStr); + if (decoded is! Map) { + throw const FormatException('Pairing payload is not a JSON object'); + } + + final relayUrl = decoded['relayUrl'] as String?; + final token = decoded['token'] as String?; + if (relayUrl == null || token == null) { + throw const FormatException('Missing relayUrl or token'); + } + + _validateRelayUrl(relayUrl); + + return StoredCredentials( + relayUrl: relayUrl, + token: token, + pubkey: decoded['pubkey'] as String?, + ); + } + + /// Reject relay URLs that aren't HTTPS (unless debug mode) or target + /// private/link-local addresses. + void _validateRelayUrl(String url) { + final uri = Uri.parse(url); + + if (!kDebugMode && uri.scheme != 'https') { + throw const FormatException('Relay URL must use HTTPS'); + } + if (uri.scheme != 'http' && uri.scheme != 'https') { + throw FormatException('Invalid URL scheme: ${uri.scheme}'); + } + + final host = uri.host.toLowerCase(); + if (host == 'localhost' || host == '127.0.0.1' || host == '::1') { + if (!kDebugMode) { + throw const FormatException('Relay URL cannot target localhost'); + } + return; + } + + // Block private and link-local IPs. + final ip = Uri.tryParse('http://$host')?.host ?? host; + if (_isPrivateHost(ip)) { + throw const FormatException( + 'Relay URL cannot target private network addresses', + ); + } + } + + static bool _isPrivateHost(String host) { + final parts = host.split('.'); + if (parts.length != 4) return false; + final octets = parts.map(int.tryParse).toList(); + if (octets.any((o) => o == null)) return false; + + final a = octets[0]!; + final b = octets[1]!; + + // 10.0.0.0/8 + if (a == 10) return true; + // 172.16.0.0/12 + if (a == 172 && b >= 16 && b <= 31) return true; + // 192.168.0.0/16 + if (a == 192 && b == 168) return true; + // 169.254.0.0/16 (link-local / cloud metadata) + if (a == 169 && b == 254) return true; + return false; + } +} + +final pairingProvider = NotifierProvider( + PairingNotifier.new, +); diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 289fa6039ad..87bcdb37158 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/auth/auth.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; @@ -12,71 +12,42 @@ class SettingsPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final config = ref.watch(relayConfigProvider); - final urlController = useTextEditingController(text: config.baseUrl); - final tokenController = useTextEditingController( - text: config.apiToken ?? '', - ); - final pubkeyController = useTextEditingController( - text: config.devPubkey ?? '', - ); return Scaffold( appBar: AppBar(title: const Text('Settings')), body: ListView( padding: const EdgeInsets.all(Grid.xs), children: [ - Text('Relay Connection', style: context.textTheme.titleMedium), + // Connection info + Text('Connection', style: context.textTheme.titleMedium), const SizedBox(height: Grid.twelve), - TextField( - controller: urlController, - decoration: const InputDecoration( - labelText: 'Relay URL', - hintText: 'http://localhost:3000', - prefixIcon: Icon(LucideIcons.server), + ListTile( + leading: const Icon(LucideIcons.server), + title: const Text('Connected to'), + subtitle: Text( + config.baseUrl, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), ), - keyboardType: TextInputType.url, - autocorrect: false, - ), - const SizedBox(height: Grid.twelve), - TextField( - controller: tokenController, - decoration: const InputDecoration( - labelText: 'API Token (optional)', - hintText: 'sprout_...', - prefixIcon: Icon(LucideIcons.key), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: context.colors.outlineVariant), ), - obscureText: true, - autocorrect: false, ), const SizedBox(height: Grid.twelve), - TextField( - controller: pubkeyController, - decoration: InputDecoration( - labelText: 'Dev Pubkey (hex, for local relay)', - hintText: '3bf0c63...', - prefixIcon: const Icon(LucideIcons.userRound), + OutlinedButton.icon( + onPressed: () => _confirmSignOut(context, ref), + icon: const Icon(LucideIcons.logOut), + label: const Text('Sign Out'), + style: OutlinedButton.styleFrom( + foregroundColor: context.colors.error, ), - autocorrect: false, - ), - const SizedBox(height: Grid.xs), - FilledButton( - onPressed: () { - final token = tokenController.text.trim(); - final pubkey = pubkeyController.text.trim(); - ref - .read(relayConfigProvider.notifier) - .update( - baseUrl: urlController.text.trim(), - apiToken: token.isEmpty ? null : token, - devPubkey: pubkey.isEmpty ? null : pubkey, - ); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Relay config updated')), - ); - }, - child: const Text('Save'), ), + const SizedBox(height: Grid.sm), + + // Appearance Text('Appearance', style: context.textTheme.titleMedium), const SizedBox(height: Grid.twelve), SegmentedButton( @@ -106,4 +77,33 @@ class SettingsPage extends HookConsumerWidget { ), ); } + + void _confirmSignOut(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Sign Out'), + content: const Text( + 'You will need to scan a new pairing code from your ' + 'desktop app to reconnect.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(ctx).pop(); + ref.read(authProvider.notifier).signOut(); + }, + style: FilledButton.styleFrom( + backgroundColor: context.colors.error, + ), + child: const Text('Sign Out'), + ), + ], + ), + ); + } } diff --git a/mobile/lib/shared/auth/auth.dart b/mobile/lib/shared/auth/auth.dart new file mode 100644 index 00000000000..e412d8b13a6 --- /dev/null +++ b/mobile/lib/shared/auth/auth.dart @@ -0,0 +1,2 @@ +export 'auth_provider.dart'; +export 'credential_storage.dart'; diff --git a/mobile/lib/shared/auth/auth_provider.dart b/mobile/lib/shared/auth/auth_provider.dart new file mode 100644 index 00000000000..a9bb9ccdeee --- /dev/null +++ b/mobile/lib/shared/auth/auth_provider.dart @@ -0,0 +1,88 @@ +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../relay/relay.dart'; +import 'credential_storage.dart'; + +enum AuthStatus { unknown, unauthenticated, authenticated, offline } + +class AuthState { + final AuthStatus status; + final StoredCredentials? credentials; + + const AuthState({required this.status, this.credentials}); +} + +class AuthNotifier extends AsyncNotifier { + @override + Future build() async { + // If a token is provided via dart-define in debug mode, treat as + // pre-authenticated. + if (kDebugMode && Env.apiToken.isNotEmpty) { + return const AuthState(status: AuthStatus.authenticated); + } + + final storage = CredentialStorage(); + final creds = await storage.load(); + if (creds == null) { + return const AuthState(status: AuthStatus.unauthenticated); + } + + // Validate stored credentials against the relay. + final client = RelayClient(baseUrl: creds.relayUrl, apiToken: creds.token); + try { + await client.get('/api/users/me/profile'); + // Credentials are valid — propagate to relay config. + ref + .read(relayConfigProvider.notifier) + .update( + baseUrl: creds.relayUrl, + apiToken: creds.token, + devPubkey: null, + ); + return AuthState(status: AuthStatus.authenticated, credentials: creds); + } on RelayException { + // Token is invalid or expired — clear and require re-pairing. + await storage.clear(); + return const AuthState(status: AuthStatus.unauthenticated); + } catch (_) { + // Network error — keep credentials and let the user retry. + return AuthState(status: AuthStatus.offline, credentials: creds); + } finally { + client.dispose(); + } + } + + /// Retry credential validation (e.g. after a network error). + Future retry() async { + ref.invalidateSelf(); + await future; + } + + Future authenticate(StoredCredentials creds) async { + final storage = CredentialStorage(); + await storage.save(creds); + + ref + .read(relayConfigProvider.notifier) + .update( + baseUrl: creds.relayUrl, + apiToken: creds.token, + devPubkey: null, + ); + + state = AsyncData( + AuthState(status: AuthStatus.authenticated, credentials: creds), + ); + } + + Future signOut() async { + final storage = CredentialStorage(); + await storage.clear(); + state = const AsyncData(AuthState(status: AuthStatus.unauthenticated)); + } +} + +final authProvider = AsyncNotifierProvider( + AuthNotifier.new, +); diff --git a/mobile/lib/shared/auth/credential_storage.dart b/mobile/lib/shared/auth/credential_storage.dart new file mode 100644 index 00000000000..35a5a160ce8 --- /dev/null +++ b/mobile/lib/shared/auth/credential_storage.dart @@ -0,0 +1,47 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class StoredCredentials { + final String relayUrl; + final String token; + final String? pubkey; + + const StoredCredentials({ + required this.relayUrl, + required this.token, + this.pubkey, + }); +} + +class CredentialStorage { + static const _keyRelayUrl = 'sprout_relay_url'; + static const _keyToken = 'sprout_token'; + static const _keyPubkey = 'sprout_pubkey'; + + final FlutterSecureStorage _storage; + + CredentialStorage([FlutterSecureStorage? storage]) + : _storage = storage ?? const FlutterSecureStorage(); + + Future load() async { + final relayUrl = await _storage.read(key: _keyRelayUrl); + final token = await _storage.read(key: _keyToken); + if (relayUrl == null || token == null) return null; + + final pubkey = await _storage.read(key: _keyPubkey); + return StoredCredentials(relayUrl: relayUrl, token: token, pubkey: pubkey); + } + + Future save(StoredCredentials credentials) async { + await _storage.write(key: _keyRelayUrl, value: credentials.relayUrl); + await _storage.write(key: _keyToken, value: credentials.token); + if (credentials.pubkey != null) { + await _storage.write(key: _keyPubkey, value: credentials.pubkey); + } + } + + Future clear() async { + await _storage.delete(key: _keyRelayUrl); + await _storage.delete(key: _keyToken); + await _storage.delete(key: _keyPubkey); + } +} diff --git a/mobile/lib/shared/relay/relay_client.dart b/mobile/lib/shared/relay/relay_client.dart index 363ccc631da..7fa83ae85c1 100644 --- a/mobile/lib/shared/relay/relay_client.dart +++ b/mobile/lib/shared/relay/relay_client.dart @@ -73,5 +73,5 @@ class RelayException implements Exception { RelayException(this.statusCode, this.body); @override - String toString() => 'RelayException($statusCode): $body'; + String toString() => 'RelayException($statusCode)'; } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index d09e4981f0f..5746be37c9c 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -121,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -193,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file: dependency: transitive description: @@ -238,11 +254,64 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" freezed_annotation: dependency: transitive description: @@ -267,6 +336,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" hooks_riverpod: dependency: "direct main" description: @@ -307,6 +384,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" json_annotation: dependency: transitive description: @@ -395,6 +496,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: "0b466a0a8a211b366c2e87f3345715faef9b6011c7147556ad22f37de6ba3173" + url: "https://pub.dev" + source: hosted + version: "6.0.11" mocktail: dependency: "direct dev" description: @@ -403,6 +512,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" node_preamble: dependency: transitive description: @@ -411,6 +528,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" package_config: dependency: transitive description: @@ -427,6 +552,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" pool: dependency: transitive description: @@ -688,6 +877,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" yaml: dependency: transitive description: @@ -706,4 +911,4 @@ packages: version: "2.2.4" sdks: dart: ">=3.11.4 <4.0.0" - flutter: ">=3.32.0" + flutter: ">=3.38.4" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 0c3c7f30ae6..c0b5ed487bc 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -13,6 +13,8 @@ dependencies: flutter_hooks: ^0.21.3 lucide_icons_flutter: ^3.1.0 http: ^1.4.0 + flutter_secure_storage: ^9.2.4 + mobile_scanner: ^6.0.2 dev_dependencies: flutter_test: diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart new file mode 100644 index 00000000000..52c37f503b9 --- /dev/null +++ b/mobile/test/features/pairing/pairing_page_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sprout_mobile/features/pairing/pairing_page.dart'; +import 'package:sprout_mobile/features/pairing/pairing_provider.dart'; + +import '../../helpers/widget_helpers.dart'; + +void main() { + group('PairingPage', () { + testWidgets('renders branding, scan button, divider, and text field', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable(child: const PairingPage()), + ); + + expect(find.text('Welcome to Sprout'), findsOneWidget); + expect(find.text('Scan QR Code'), findsOneWidget); + expect(find.text('or paste pairing code'), findsOneWidget); + expect(find.text('Connect'), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets('connect button is below text field, not beside it', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable(child: const PairingPage()), + ); + + final textField = tester.getBottomLeft(find.byType(TextField)); + final connectButton = tester.getTopLeft( + find.widgetWithText(FilledButton, 'Connect'), + ); + + // The connect button should be below the text field. + expect(connectButton.dy, greaterThan(textField.dy)); + }); + + testWidgets('connect button is full width', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable(child: const PairingPage()), + ); + + final connectButton = tester.getSize( + find.widgetWithText(FilledButton, 'Connect'), + ); + final textField = tester.getSize(find.byType(TextField)); + + // Button width should be close to the text field width (both full-width). + expect(connectButton.width, closeTo(textField.width, 2.0)); + }); + + testWidgets('shows error container when pairing fails', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + pairingProvider.overrideWith( + () => _ErrorPairingNotifier('Invalid pairing code: bad input'), + ), + ], + child: const PairingPage(), + ), + ); + await tester.pump(); + + expect(find.text('Invalid pairing code: bad input'), findsOneWidget); + }); + + testWidgets('shows spinner when connecting', (tester) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + pairingProvider.overrideWith(() => _ConnectingPairingNotifier()), + ], + child: const PairingPage(), + ), + ); + await tester.pump(); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + // Connect text should be replaced by spinner. + expect(find.text('Connect'), findsNothing); + }); + + testWidgets('text field and buttons disabled when connecting', ( + tester, + ) async { + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + pairingProvider.overrideWith(() => _ConnectingPairingNotifier()), + ], + child: const PairingPage(), + ), + ); + await tester.pump(); + + final textField = tester.widget(find.byType(TextField)); + expect(textField.enabled, isFalse); + }); + }); +} + +class _ErrorPairingNotifier extends Notifier + implements PairingNotifier { + final String error; + _ErrorPairingNotifier(this.error); + + @override + PairingState build() => + PairingState(status: PairingStatus.error, errorMessage: error); + + @override + Future pair(String rawInput) async {} + + @override + void reset() {} +} + +class _ConnectingPairingNotifier extends Notifier + implements PairingNotifier { + @override + PairingState build() => const PairingState(status: PairingStatus.connecting); + + @override + Future pair(String rawInput) async {} + + @override + void reset() {} +} diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart new file mode 100644 index 00000000000..6ca86d707b1 --- /dev/null +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -0,0 +1,299 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart' as http_testing; +import 'package:sprout_mobile/features/pairing/pairing_provider.dart'; +import 'package:sprout_mobile/shared/auth/auth.dart'; + +/// Encode a credentials payload the same way the desktop app would. +String _encodePairingCode({ + String relayUrl = 'http://test:3000', + String token = 'sprout_test_token', + String? pubkey, +}) { + final json = { + 'relayUrl': relayUrl, + 'token': token, + // ignore: use_null_aware_elements + if (pubkey != null) 'pubkey': pubkey, + }; + return base64Url.encode(utf8.encode(jsonEncode(json))); +} + +/// A fake [AuthNotifier] that records calls instead of touching secure storage. +class FakeAuthNotifier extends AsyncNotifier + implements AuthNotifier { + StoredCredentials? lastCredentials; + bool signedOut = false; + + @override + Future build() async => + const AuthState(status: AuthStatus.unauthenticated); + + @override + Future authenticate(StoredCredentials creds) async { + lastCredentials = creds; + state = AsyncData( + AuthState(status: AuthStatus.authenticated, credentials: creds), + ); + } + + @override + Future signOut() async { + signedOut = true; + state = const AsyncData(AuthState(status: AuthStatus.unauthenticated)); + } + + @override + Future retry() async {} +} + +void main() { + group('PairingNotifier', () { + late ProviderContainer container; + late FakeAuthNotifier fakeAuth; + + /// Creates a container with the HTTP client wired to [mockClient]. + ProviderContainer createContainer(http_testing.MockClient mockClient) { + fakeAuth = FakeAuthNotifier(); + return ProviderContainer( + overrides: [ + authProvider.overrideWith(() => fakeAuth), + pairingHttpClientProvider.overrideWithValue(mockClient), + ], + ); + } + + tearDown(() => container.dispose()); + + test('starts in idle state', () { + final mock = http_testing.MockClient( + (_) async => http.Response('{}', 200), + ); + container = createContainer(mock); + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.idle); + expect(state.errorMessage, isNull); + }); + + test('successful pairing with raw base64 code', () async { + final mock = http_testing.MockClient((request) async { + expect(request.url.path, '/api/users/me/profile'); + return http.Response(jsonEncode({'id': '1', 'name': 'Wes'}), 200); + }); + container = createContainer(mock); + + final code = _encodePairingCode(); + await container.read(pairingProvider.notifier).pair(code); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.success); + expect(fakeAuth.lastCredentials?.relayUrl, 'http://test:3000'); + expect(fakeAuth.lastCredentials?.token, 'sprout_test_token'); + }); + + test('successful pairing with sprout:// prefix', () async { + final mock = http_testing.MockClient( + (_) async => http.Response(jsonEncode({'id': '1'}), 200), + ); + container = createContainer(mock); + + final code = 'sprout://${_encodePairingCode()}'; + await container.read(pairingProvider.notifier).pair(code); + + expect(container.read(pairingProvider).status, PairingStatus.success); + }); + + test('successful pairing with pubkey', () async { + final mock = http_testing.MockClient( + (_) async => http.Response(jsonEncode({'id': '1'}), 200), + ); + container = createContainer(mock); + + final code = _encodePairingCode(pubkey: 'abc123'); + await container.read(pairingProvider.notifier).pair(code); + + expect(container.read(pairingProvider).status, PairingStatus.success); + expect(fakeAuth.lastCredentials?.pubkey, 'abc123'); + }); + + test('relay 401 sets error state', () async { + final mock = http_testing.MockClient( + (_) async => http.Response('{"error": "unauthorized"}', 401), + ); + container = createContainer(mock); + + final code = _encodePairingCode(); + await container.read(pairingProvider.notifier).pair(code); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.error); + expect(state.errorMessage, contains('Could not connect to relay')); + expect(state.errorMessage, contains('401')); + }); + + test('relay 500 sets error state', () async { + final mock = http_testing.MockClient( + (_) async => http.Response('internal error', 500), + ); + container = createContainer(mock); + + final code = _encodePairingCode(); + await container.read(pairingProvider.notifier).pair(code); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.error); + expect(state.errorMessage, contains('500')); + }); + + test('network error sets generic error', () async { + final mock = http_testing.MockClient( + (_) => throw Exception('no internet'), + ); + container = createContainer(mock); + + final code = _encodePairingCode(); + await container.read(pairingProvider.notifier).pair(code); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.error); + expect(state.errorMessage, contains('Connection failed')); + }); + + test('invalid base64 sets format error', () async { + final mock = http_testing.MockClient( + (_) async => http.Response('{}', 200), + ); + container = createContainer(mock); + + await container.read(pairingProvider.notifier).pair('not-valid!!!'); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.error); + expect(state.errorMessage, contains('Invalid pairing code')); + }); + + test('base64 with valid JSON but missing fields errors', () async { + final mock = http_testing.MockClient( + (_) async => http.Response('{}', 200), + ); + container = createContainer(mock); + + // Valid base64 JSON, but no relayUrl/token keys. + final code = base64Url.encode(utf8.encode(jsonEncode({'foo': 'bar'}))); + await container.read(pairingProvider.notifier).pair(code); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.error); + expect(state.errorMessage, contains('Missing relayUrl or token')); + }); + + test('empty input errors', () async { + final mock = http_testing.MockClient( + (_) async => http.Response('{}', 200), + ); + container = createContainer(mock); + + await container.read(pairingProvider.notifier).pair(''); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.error); + }); + + test('whitespace-padded input is trimmed', () async { + final mock = http_testing.MockClient( + (_) async => http.Response(jsonEncode({'id': '1'}), 200), + ); + container = createContainer(mock); + + final code = ' ${_encodePairingCode()} \n'; + await container.read(pairingProvider.notifier).pair(code); + + expect(container.read(pairingProvider).status, PairingStatus.success); + }); + + test('rejects private IP relay URLs (SSRF)', () async { + final mock = http_testing.MockClient( + (_) async => http.Response('{}', 200), + ); + container = createContainer(mock); + + for (final ip in [ + '10.0.0.1', + '172.16.0.1', + '192.168.1.1', + '169.254.169.254', + ]) { + final code = _encodePairingCode(relayUrl: 'http://$ip:3000'); + await container.read(pairingProvider.notifier).pair(code); + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.error, reason: 'should reject $ip'); + expect(state.errorMessage, contains('private network')); + container.read(pairingProvider.notifier).reset(); + } + }); + + test('rejects non-http/https schemes', () async { + final mock = http_testing.MockClient( + (_) async => http.Response('{}', 200), + ); + container = createContainer(mock); + + final code = _encodePairingCode(relayUrl: 'file:///etc/passwd'); + await container.read(pairingProvider.notifier).pair(code); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.error); + expect(state.errorMessage, contains('Invalid pairing code')); + }); + + test('rejects JSON array payload', () async { + final mock = http_testing.MockClient( + (_) async => http.Response('{}', 200), + ); + container = createContainer(mock); + + final code = base64Url.encode(utf8.encode(jsonEncode([1, 2, 3]))); + await container.read(pairingProvider.notifier).pair(code); + + final state = container.read(pairingProvider); + expect(state.status, PairingStatus.error); + expect(state.errorMessage, contains('not a JSON object')); + }); + + test('ignores duplicate pair() calls while connecting', () async { + var requestCount = 0; + final mock = http_testing.MockClient((_) async { + requestCount++; + // Simulate slow network. + await Future.delayed(const Duration(milliseconds: 50)); + return http.Response(jsonEncode({'id': '1'}), 200); + }); + container = createContainer(mock); + + final code = _encodePairingCode(); + // Fire two calls without awaiting the first. + final f1 = container.read(pairingProvider.notifier).pair(code); + final f2 = container.read(pairingProvider.notifier).pair(code); + await Future.wait([f1, f2]); + + // Only one network request should have been made. + expect(requestCount, 1); + }); + + test('reset returns to idle', () async { + final mock = http_testing.MockClient( + (_) async => http.Response(jsonEncode({'id': '1'}), 200), + ); + container = createContainer(mock); + + await container.read(pairingProvider.notifier).pair(_encodePairingCode()); + expect(container.read(pairingProvider).status, PairingStatus.success); + + container.read(pairingProvider.notifier).reset(); + expect(container.read(pairingProvider).status, PairingStatus.idle); + }); + }); +} diff --git a/mobile/test/widget_test.dart b/mobile/test/widget_test.dart index 412969ff258..7328e5cce7b 100644 --- a/mobile/test/widget_test.dart +++ b/mobile/test/widget_test.dart @@ -1,10 +1,26 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:sprout_mobile/app.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sprout_mobile/app.dart'; +import 'package:sprout_mobile/shared/auth/auth.dart'; void main() { - testWidgets('App renders without crashing', (WidgetTester tester) async { - await tester.pumpWidget(const ProviderScope(child: App())); - expect(find.text('Channels'), findsWidgets); + testWidgets('App renders pairing page when unauthenticated', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [authProvider.overrideWith(() => _FakeAuthNotifier())], + child: const App(), + ), + ); + await tester.pump(); + expect(find.text('Welcome to Sprout'), findsOneWidget); }); } + +class _FakeAuthNotifier extends AuthNotifier { + @override + Future build() async { + return const AuthState(status: AuthStatus.unauthenticated); + } +}