diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index def6c49..5beaaac 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -4,7 +4,8 @@ @@ -17,34 +18,56 @@ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize"> - + + + + + + + + + + + + + + + + - + - + + + + + + + + + + \ No newline at end of file diff --git a/mobile/lib/controllers/auth.dart b/mobile/lib/controllers/auth.dart index 49ec2d1..fd113ae 100644 --- a/mobile/lib/controllers/auth.dart +++ b/mobile/lib/controllers/auth.dart @@ -1,11 +1,17 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:dio/dio.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:app_links/app_links.dart'; import 'package:mobile/models/user.dart'; +import '../core/constants.dart'; import '../services/auth.dart'; class AuthState extends ChangeNotifier { final AuthService _authService = AuthService(); + final AppLinks _appLinks = AppLinks(); + StreamSubscription? _linkSubscription; String? _token; bool _isLoading = false; @@ -23,6 +29,33 @@ class AuthState extends ChangeNotifier { AuthState() { onGlobalUnauthorized = logoutSilently; + _initDeepLinks(); + } + + Future _initDeepLinks() async { + try { + final initialUri = await _appLinks.getInitialLink(); + if (initialUri != null) { + _handleIncomingUri(initialUri); + } + } catch (e) { + debugPrint("Error reading initial deep link: $e"); + } + + _linkSubscription = _appLinks.uriLinkStream.listen( + (Uri uri) { + _handleIncomingUri(uri); + }, + onError: (err) { + debugPrint("Deep link stream error: $err"); + }, + ); + } + + void _handleIncomingUri(Uri uri) { + if (uri.scheme == 'elephant' && uri.host == 'oauth-callback') { + handleOAuthCallback(uri); + } } Future loadUserProfile() async { @@ -147,6 +180,64 @@ class AuthState extends ChangeNotifier { return false; } + Future handleOAuthLogin(String provider) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + final String oAuthUrl = "${Env.httpBaseUrl}/auth/$provider"; + final Uri uri = Uri.parse(oAuthUrl); + + final bool launched = await launchUrl( + uri, + mode: LaunchMode.externalApplication, + ); + + if (!launched) { + _errorMessage = + "Could not launch web browser for $provider authentication."; + } + } catch (e) { + _errorMessage = "OAuth launch error: $e"; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future handleOAuthCallback(Uri uri) async { + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + final accessToken = + uri.queryParameters['access_token'] ?? uri.queryParameters['token']; + final refreshToken = uri.queryParameters['refresh_token']; + + if (accessToken != null) { + _token = accessToken; + await _authService.saveTokens(accessToken, refreshToken ?? accessToken); + await loadUserProfile(); + + _isLoading = false; + notifyListeners(); + return true; + } else if (uri.queryParameters.containsKey('error')) { + _errorMessage = uri.queryParameters['error']; + } else { + _errorMessage = "OAuth callback received no valid token payload."; + } + } catch (e) { + _errorMessage = "Failed to parse authentication callback data."; + } + + _isLoading = false; + notifyListeners(); + return false; + } + Future logout() async { _token = null; _currentUser = null; @@ -162,4 +253,10 @@ class AuthState extends ChangeNotifier { notifyListeners(); } } + + @override + void dispose() { + _linkSubscription?.cancel(); + super.dispose(); + } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 0258584..3ce98cd 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -12,13 +12,14 @@ import 'pages/home_page.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); - await Future.wait([Env.init(), AuthService().initTokens()]); + await Env.init(); + await AuthService().initTokens(); final themeProvider = ThemeProvider(); - // while (!themeProvider.isInitialized) { - // await Future.delayed(const Duration(milliseconds: 10)); - // } + while (!themeProvider.isInitialized) { + await Future.delayed(const Duration(milliseconds: 10)); + } runApp( MultiProvider( @@ -55,23 +56,29 @@ class SessionGateway extends StatefulWidget { State createState() => _SessionGatewayState(); } -class _SessionGatewayState extends State { +class _SessionGatewayState extends State + with WidgetsBindingObserver { bool _hasCheckedAutoLogin = false; String? _lastInitializedToken; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _performInitialAutoLoginCheck(); } + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + void _performInitialAutoLoginCheck() async { final auth = context.read(); final token = await auth.checkAutoLogin(); - if (!mounted) return; - - if (token != null) { + if (token != null && mounted) { _lastInitializedToken = token; context.read().initSession(); } @@ -91,7 +98,6 @@ class _SessionGatewayState extends State { body: Center( child: CircularProgressIndicator( color: Theme.of(context).colorScheme.primary, - strokeWidth: 3, ), ), ); diff --git a/mobile/lib/pages/login_page.dart b/mobile/lib/pages/login_page.dart index 60f7cb8..9d4020d 100644 --- a/mobile/lib/pages/login_page.dart +++ b/mobile/lib/pages/login_page.dart @@ -55,8 +55,8 @@ class _LoginPageState extends State title: Row( children: [ Icon(Icons.dns, color: colorScheme.primary), - SizedBox(width: 8), - Text( + const SizedBox(width: 8), + const Text( "Server Settings", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), @@ -224,11 +224,15 @@ class _LoginPageState extends State } } - Widget _buildSocialButton({required String label, required IconData icon}) { + Widget _buildSocialButton({ + required String label, + required IconData icon, + required VoidCallback onPressed, + }) { final colorScheme = Theme.of(context).colorScheme; return Expanded( child: OutlinedButton.icon( - onPressed: () {}, + onPressed: onPressed, icon: Icon(icon, color: colorScheme.onSurface, size: 18), label: Text( label, @@ -292,7 +296,6 @@ class _LoginPageState extends State end: const Offset(1.4, 1.4), ), ), - Positioned( bottom: -200, right: -100, @@ -325,7 +328,6 @@ class _LoginPageState extends State end: const Offset(1.2, 0.8), ), ), - Positioned( top: MediaQuery.of(context).size.height * 0.2, left: MediaQuery.of(context).size.width * 0.1, @@ -366,7 +368,6 @@ class _LoginPageState extends State ], ), ), - Positioned.fill( child: RepaintBoundary( child: Center( @@ -376,7 +377,7 @@ class _LoginPageState extends State vertical: 40, ), child: ClipRRect( - borderRadius: BorderRadiusGeometry.circular(24), + borderRadius: BorderRadius.circular(24), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), child: Container( @@ -408,7 +409,6 @@ class _LoginPageState extends State ), constraints: const BoxConstraints(maxWidth: 420), padding: const EdgeInsets.all(32), - child: Form( key: _formKey, child: Column( @@ -438,7 +438,6 @@ class _LoginPageState extends State ], ), const SizedBox(height: 24), - Column( key: ValueKey(_tabController.index), children: [ @@ -471,7 +470,6 @@ class _LoginPageState extends State ], ), const SizedBox(height: 28), - Container( height: 52, decoration: BoxDecoration( @@ -575,7 +573,6 @@ class _LoginPageState extends State ), ), const SizedBox(height: 24), - if (authState.errorMessage != null) ...[ Container( padding: const EdgeInsets.symmetric( @@ -653,9 +650,7 @@ class _LoginPageState extends State ? 'Required field' : null, ), - const SizedBox(height: 16), - AnimatedSize( duration: const Duration(milliseconds: 500), curve: Curves.fastLinearToSlowEaseIn, @@ -736,7 +731,6 @@ class _LoginPageState extends State .slideX(begin: -0.05) : const SizedBox.shrink(), ), - Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -810,7 +804,6 @@ class _LoginPageState extends State : null, ), const SizedBox(height: 24), - ElevatedButton( onPressed: authState.isLoading ? null : _submit, style: ElevatedButton.styleFrom( @@ -853,7 +846,6 @@ class _LoginPageState extends State ), ), const SizedBox(height: 24), - Row( children: [ Expanded( @@ -893,16 +885,25 @@ class _LoginPageState extends State _buildSocialButton( label: "Google", icon: Icons.g_mobiledata, + onPressed: authState.isLoading + ? () {} + : () => context + .read() + .handleOAuthLogin("google"), ), const SizedBox(width: 12), _buildSocialButton( label: "GitHub", icon: Icons.code, + onPressed: authState.isLoading + ? () {} + : () => context + .read() + .handleOAuthLogin("github"), ), ], ), const SizedBox(height: 28), - Center( child: Container( padding: const EdgeInsets.symmetric( @@ -946,7 +947,6 @@ class _LoginPageState extends State ), ), const SizedBox(height: 20), - Center( child: TextButton.icon( onPressed: _openServerConfigDialog, @@ -979,7 +979,6 @@ class _LoginPageState extends State ), ), ), - Positioned( top: MediaQuery.of(context).padding.top + 16, right: 24, diff --git a/mobile/lib/services/auth.dart b/mobile/lib/services/auth.dart index 5e8cc1a..5a0fd0c 100644 --- a/mobile/lib/services/auth.dart +++ b/mobile/lib/services/auth.dart @@ -39,6 +39,7 @@ class AuthService { } Future login(String username, String password) async { + _dio.options.baseUrl = Env.httpBaseUrl; return await _dio.post( "/auth/login", data: {"username": username, "password": password}, @@ -50,6 +51,7 @@ class AuthService { String displayName, String password, ) async { + _dio.options.baseUrl = Env.httpBaseUrl; return await _dio.post( "/auth/register", data: { @@ -61,6 +63,7 @@ class AuthService { } Future refreshAccessToken(String refreshToken) async { + _dio.options.baseUrl = Env.httpBaseUrl; return await _dio.post( "/auth/refresh", data: {"refresh_token": refreshToken}, @@ -68,10 +71,10 @@ class AuthService { } Future getCurrentUser(String token) async { + _dio.options.baseUrl = Env.httpBaseUrl; return await _dio.get( '/users/me', options: Options(headers: {'Authorization': 'Bearer $token'}), ); } - } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 2937f71..3df5aa7 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1,6 +1,38 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + app_links: + dependency: "direct main" + description: + name: app_links + sha256: f8db46d2ea9ff6f3a37191a7fd5b7813da1253ace8c32c1b9eadace41d1188ea + url: "https://pub.dev" + source: hosted + version: "7.2.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: transitive description: @@ -352,6 +384,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.0" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" hooks: dependency: transitive description: @@ -942,7 +982,7 @@ packages: source: hosted version: "1.4.0" url_launcher: - dependency: transitive + dependency: "direct main" description: name: url_launcher sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index d468e84..ce6858a 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -53,7 +53,9 @@ dependencies: qr_flutter: ^4.1.0 scrollable_positioned_list: ^0.3.8 flutter_animate: ^4.5.2 - + url_launcher: ^6.3.2 + app_links: ^7.2.1 + launcher_name: default: "Elephant" diff --git a/server/controllers/auth.go b/server/controllers/auth.go index 8fd3e08..e78b7ad 100644 --- a/server/controllers/auth.go +++ b/server/controllers/auth.go @@ -6,6 +6,7 @@ import ( "fmt" "math/big" "net/http" + "net/url" "regexp" "strings" "time" @@ -146,38 +147,36 @@ func HandleOAuthRedirect(w http.ResponseWriter, r *http.Request) { } http.SetCookie(w, cookie) - var url string + var urlStr string switch provider { case "google": - url = config.GoogleConfig.AuthCodeURL(state) + urlStr = config.GoogleConfig.AuthCodeURL(state) case "github": - url = config.GithubConfig.AuthCodeURL(state) + urlStr = config.GithubConfig.AuthCodeURL(state) default: + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Unsupported OAuth provider context"}) return } - http.Redirect(w, r, url, http.StatusTemporaryRedirect) + http.Redirect(w, r, urlStr, http.StatusTemporaryRedirect) } func HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") provider := chi.URLParam(r, "provider") code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") cookie, err := r.Cookie("oauth_state") if err != nil || cookie.Value != state { - w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "State token match validation failed"}) + renderHandoffError(w, "State token match validation failed.") return } profile, err := services.ProcessCallback(r.Context(), provider, code) if err != nil { - w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: err.Error()}) + renderHandoffError(w, fmt.Sprintf("OAuth processing error: %s", err.Error())) return } @@ -204,8 +203,7 @@ func HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { u, err = userRepo.CreateOAuthUser(r.Context(), username, profile.Email, profile.Name, provider, profile.ID) if err != nil { - w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(models.JSONResponse{Success: false, Error: "Failed to allocate user credentials"}) + renderHandoffError(w, "Failed to allocate user account credentials.") return } } @@ -213,7 +211,7 @@ func HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { tokens, err := services.GenerateTokenPair(u.ID) if err != nil { - w.WriteHeader(http.StatusInternalServerError) + renderHandoffError(w, "Token generation failure.") return } @@ -222,13 +220,158 @@ func HandleOAuthCallback(w http.ResponseWriter, r *http.Request) { expiry := time.Now().Add(7 * 24 * time.Hour) _ = tokenRepo.StoreToken(r.Context(), u.ID, hashedRt, expiry) - _ = json.NewEncoder(w).Encode(models.JSONResponse{ - Success: true, - Data: map[string]interface{}{ - "user": u, - "tokens": tokens, - }, - }) + customSchemeURL := fmt.Sprintf( + "elephant://oauth-callback?access_token=%s&refresh_token=%s", + url.QueryEscape(tokens.AccessToken), + url.QueryEscape(tokens.RefreshToken), + ) + + intentURL := fmt.Sprintf( + "intent://oauth-callback?access_token=%s&refresh_token=%s#Intent;scheme=elephant;package=in.commandlinecoding.elephant;end;", + url.QueryEscape(tokens.AccessToken), + url.QueryEscape(tokens.RefreshToken), + ) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + htmlPage := fmt.Sprintf(` + + + + + Elephant Authentication + + + +
+
🐘
+

Sign In Successful

+

Redirecting back to the Elephant application...

+ Open Elephant App +
+ + +`, customSchemeURL, intentURL, customSchemeURL) + + _, _ = w.Write([]byte(htmlPage)) +} + +func renderHandoffError(w http.ResponseWriter, message string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusUnauthorized) + + htmlError := fmt.Sprintf(` + + + + + Authentication Error + + + +
+

Authentication Failed

+

%s

+
+ +`, message) + + _, _ = w.Write([]byte(htmlError)) } func cleanAlphanumeric(s string) string {