Add JoinPage feature with invite token handling and update routing
This commit is contained in:
@@ -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'] ?? ''),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
137
b0esche_cloud/lib/pages/join_page.dart
Normal file
137
b0esche_cloud/lib/pages/join_page.dart
Normal file
@@ -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<JoinPage> createState() => _JoinPageState();
|
||||
}
|
||||
|
||||
class _JoinPageState extends State<JoinPage> {
|
||||
Organization? _org;
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
bool _isJoining = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchOrg();
|
||||
}
|
||||
|
||||
Future<void> _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<ApiClient>();
|
||||
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<void> _joinOrg() async {
|
||||
if (_org == null) return;
|
||||
|
||||
setState(() => _isJoining = true);
|
||||
try {
|
||||
final orgApi = GetIt.I<OrgApi>();
|
||||
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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<OrganizationSettingsDialog>
|
||||
String? _inviteLink;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
List<User> _userSuggestions = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -180,7 +182,9 @@ class _OrganizationSettingsDialogState extends State<OrganizationSettingsDialog>
|
||||
|
||||
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<OrganizationSettingsDialog>
|
||||
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<String>(
|
||||
initialValue: selectedRole,
|
||||
items: ['admin', 'member'].map((role) {
|
||||
@@ -459,7 +500,10 @@ class _OrganizationSettingsDialogState extends State<OrganizationSettingsDialog>
|
||||
),
|
||||
),
|
||||
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: [
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user