From 26cbe83d663d572d56ebc88fdafb5a4132e9f115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Fri, 23 Jan 2026 23:39:59 +0100 Subject: [PATCH] Add JoinPage feature with invite token handling and update routing --- b0esche_cloud/lib/main.dart | 6 + b0esche_cloud/lib/pages/join_page.dart | 137 ++++++++++++++++++ b0esche_cloud/lib/theme/app_theme.dart | 1 + .../widgets/organization_settings_dialog.dart | 48 +++++- b0esche_cloud/pubspec.lock | 16 ++ b0esche_cloud/pubspec.yaml | 3 + go_cloud/internal/http/routes.go | 28 ++++ 7 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 b0esche_cloud/lib/pages/join_page.dart diff --git a/b0esche_cloud/lib/main.dart b/b0esche_cloud/lib/main.dart index a6785b9..ba2961d 100644 --- a/b0esche_cloud/lib/main.dart +++ b/b0esche_cloud/lib/main.dart @@ -11,6 +11,7 @@ import 'pages/home_page.dart'; import 'pages/file_explorer.dart'; import 'pages/document_viewer.dart'; import 'pages/editor_page.dart'; +import 'pages/join_page.dart'; import 'theme/app_theme.dart'; import 'injection.dart'; @@ -36,6 +37,11 @@ final GoRouter _router = GoRouter( builder: (context, state) => FileExplorer(orgId: state.pathParameters['orgId']!), ), + GoRoute( + path: '/join', + builder: (context, state) => + JoinPage(token: state.uri.queryParameters['token'] ?? ''), + ), ], ); diff --git a/b0esche_cloud/lib/pages/join_page.dart b/b0esche_cloud/lib/pages/join_page.dart new file mode 100644 index 0000000..c7b46b2 --- /dev/null +++ b/b0esche_cloud/lib/pages/join_page.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import '../blocs/organization/organization_state.dart'; +import '../services/org_api.dart'; +import '../services/api_client.dart'; +import '../theme/app_theme.dart'; +import '../theme/modern_glass_button.dart'; +import 'package:get_it/get_it.dart'; + +class JoinPage extends StatefulWidget { + final String token; + + const JoinPage({super.key, required this.token}); + + @override + State createState() => _JoinPageState(); +} + +class _JoinPageState extends State { + Organization? _org; + bool _isLoading = true; + String? _error; + bool _isJoining = false; + + @override + void initState() { + super.initState(); + _fetchOrg(); + } + + Future _fetchOrg() async { + if (widget.token.isEmpty) { + setState(() { + _error = 'Invalid invite link'; + _isLoading = false; + }); + return; + } + + try { + // Assume we have a method to get org by token + // For now, since not implemented, use ApiClient directly + final apiClient = GetIt.I(); + final result = await apiClient.get( + '/join?token=${widget.token}', + fromJson: (data) => Organization.fromJson(data), + ); + setState(() { + _org = result; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = 'Invalid or expired invite link'; + _isLoading = false; + }); + } + } + + Future _joinOrg() async { + if (_org == null) return; + + setState(() => _isJoining = true); + try { + final orgApi = GetIt.I(); + await orgApi.createJoinRequest(_org!.id, inviteToken: widget.token); + // Navigate to home or show success + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Join request sent successfully')), + ); + Navigator.of(context).pushReplacementNamed('/'); // Assuming home is / + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to join: $e'))); + } + } finally { + setState(() => _isJoining = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.primaryBackground, + appBar: AppBar( + title: const Text('Join Organization'), + backgroundColor: AppTheme.secondaryBackground, + ), + body: Center( + child: _isLoading + ? const CircularProgressIndicator() + : _error != null + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_error!, style: TextStyle(color: AppTheme.errorColor)), + const SizedBox(height: 16), + ModernGlassButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Go Back'), + ), + ], + ) + : _org != null + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Join ${_org!.name}?', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Text( + 'You have been invited to join this organization.', + style: TextStyle(color: AppTheme.secondaryText), + ), + const SizedBox(height: 32), + _isJoining + ? const CircularProgressIndicator() + : ModernGlassButton( + onPressed: _joinOrg, + child: const Text('Join Organization'), + ), + ], + ) + : const Text('Organization not found'), + ), + ); + } +} diff --git a/b0esche_cloud/lib/theme/app_theme.dart b/b0esche_cloud/lib/theme/app_theme.dart index f85d491..abdf688 100644 --- a/b0esche_cloud/lib/theme/app_theme.dart +++ b/b0esche_cloud/lib/theme/app_theme.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; class AppTheme { static const Color primaryBackground = Colors.black; + static const Color secondaryBackground = Colors.grey; static const Color accentColor = Color.fromARGB(255, 100, 200, 255); static const Color secondaryText = Colors.white70; static const Color primaryText = Colors.white; diff --git a/b0esche_cloud/lib/widgets/organization_settings_dialog.dart b/b0esche_cloud/lib/widgets/organization_settings_dialog.dart index 3460548..77cabbb 100644 --- a/b0esche_cloud/lib/widgets/organization_settings_dialog.dart +++ b/b0esche_cloud/lib/widgets/organization_settings_dialog.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import '../blocs/organization/organization_state.dart'; import '../blocs/permission/permission_state.dart'; import '../models/organization.dart'; +import '../models/user.dart'; import '../services/org_api.dart'; import '../theme/app_theme.dart'; import '../theme/modern_glass_button.dart'; @@ -33,6 +34,7 @@ class _OrganizationSettingsDialogState extends State String? _inviteLink; bool _isLoading = false; String? _error; + List _userSuggestions = []; @override void initState() { @@ -180,7 +182,9 @@ class _OrganizationSettingsDialogState extends State void _copyInviteLink() { if (_inviteLink != null) { - Clipboard.setData(ClipboardData(text: _inviteLink!)); + Clipboard.setData( + ClipboardData(text: 'https://b0esche.cloud/join?token=$_inviteLink'), + ); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Invite link copied to clipboard')), ); @@ -381,8 +385,45 @@ class _OrganizationSettingsDialogState extends State border: OutlineInputBorder(), ), style: TextStyle(color: AppTheme.primaryText), + onChanged: (value) async { + if (value.length > 2) { + try { + _userSuggestions = await widget.orgApi.searchUsers( + widget.organization.id, + value, + ); + } catch (e) { + _userSuggestions = []; + } + setState(() {}); + } else { + _userSuggestions = []; + setState(() {}); + } + }, ), const SizedBox(height: 8), + if (_userSuggestions.isNotEmpty) ...[ + SizedBox( + height: 100, + child: ListView.builder( + itemCount: _userSuggestions.length, + itemBuilder: (context, index) { + final user = _userSuggestions[index]; + return ListTile( + title: Text( + user.displayName ?? user.username, + style: TextStyle(color: AppTheme.primaryText), + ), + onTap: () { + usernameController.text = user.username; + setState(() => _userSuggestions = []); + }, + ); + }, + ), + ), + ], DropdownButtonFormField( initialValue: selectedRole, items: ['admin', 'member'].map((role) { @@ -459,7 +500,10 @@ class _OrganizationSettingsDialogState extends State ), ), const SizedBox(height: 8), - Text(_inviteLink!, style: TextStyle(color: AppTheme.secondaryText)), + Text( + 'https://b0esche.cloud/join?token=${_inviteLink ?? ''}', + style: TextStyle(color: AppTheme.secondaryText), + ), const SizedBox(height: 16), Row( children: [ diff --git a/b0esche_cloud/pubspec.lock b/b0esche_cloud/pubspec.lock index fb989b4..60f2505 100644 --- a/b0esche_cloud/pubspec.lock +++ b/b0esche_cloud/pubspec.lock @@ -278,6 +278,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -491,6 +499,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.16" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" logger: dependency: "direct main" description: diff --git a/b0esche_cloud/pubspec.yaml b/b0esche_cloud/pubspec.yaml index a122b37..39bd656 100644 --- a/b0esche_cloud/pubspec.yaml +++ b/b0esche_cloud/pubspec.yaml @@ -66,6 +66,9 @@ dependencies: just_audio_web: ^0.4.16 just_audio: ^0.10.5 +dev_dependencies: + flutter_lints: ^5.0.0 + flutter: uses-material-design: true diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index 089feea..e86a643 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -110,6 +110,11 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut // Health check r.Get("/health", healthHandler) + // Join org by invite token (public) + r.Get("/join", func(w http.ResponseWriter, req *http.Request) { + getOrgByInviteTokenHandler(w, req, db) + }) + // WOPI routes (public, token validation done per endpoint) r.Route("/wopi", func(r chi.Router) { r.Route("/files/{fileId}", func(r chi.Router) { @@ -317,6 +322,29 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut return r } +func getOrgByInviteTokenHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { + token := r.URL.Query().Get("token") + if token == "" { + errors.WriteError(w, errors.CodeInvalidArgument, "Token required", http.StatusBadRequest) + return + } + + var org database.Organization + err := db.QueryRowContext(r.Context(), ` + SELECT id, owner_id, name, slug, created_at + FROM organizations + WHERE invite_link_token = $1 + `, token).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt) + if err != nil { + errors.LogError(r, err, "Invalid invite token") + errors.WriteError(w, errors.CodeNotFound, "Invalid token", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(org) +} + func healthHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("OK"))