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/file_explorer.dart';
|
||||||
import 'pages/document_viewer.dart';
|
import 'pages/document_viewer.dart';
|
||||||
import 'pages/editor_page.dart';
|
import 'pages/editor_page.dart';
|
||||||
|
import 'pages/join_page.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'injection.dart';
|
import 'injection.dart';
|
||||||
|
|
||||||
@@ -36,6 +37,11 @@ final GoRouter _router = GoRouter(
|
|||||||
builder: (context, state) =>
|
builder: (context, state) =>
|
||||||
FileExplorer(orgId: state.pathParameters['orgId']!),
|
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 {
|
class AppTheme {
|
||||||
static const Color primaryBackground = Colors.black;
|
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 accentColor = Color.fromARGB(255, 100, 200, 255);
|
||||||
static const Color secondaryText = Colors.white70;
|
static const Color secondaryText = Colors.white70;
|
||||||
static const Color primaryText = Colors.white;
|
static const Color primaryText = Colors.white;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
|||||||
import '../blocs/organization/organization_state.dart';
|
import '../blocs/organization/organization_state.dart';
|
||||||
import '../blocs/permission/permission_state.dart';
|
import '../blocs/permission/permission_state.dart';
|
||||||
import '../models/organization.dart';
|
import '../models/organization.dart';
|
||||||
|
import '../models/user.dart';
|
||||||
import '../services/org_api.dart';
|
import '../services/org_api.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../theme/modern_glass_button.dart';
|
import '../theme/modern_glass_button.dart';
|
||||||
@@ -33,6 +34,7 @@ class _OrganizationSettingsDialogState extends State<OrganizationSettingsDialog>
|
|||||||
String? _inviteLink;
|
String? _inviteLink;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
List<User> _userSuggestions = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -180,7 +182,9 @@ class _OrganizationSettingsDialogState extends State<OrganizationSettingsDialog>
|
|||||||
|
|
||||||
void _copyInviteLink() {
|
void _copyInviteLink() {
|
||||||
if (_inviteLink != null) {
|
if (_inviteLink != null) {
|
||||||
Clipboard.setData(ClipboardData(text: _inviteLink!));
|
Clipboard.setData(
|
||||||
|
ClipboardData(text: 'https://b0esche.cloud/join?token=$_inviteLink'),
|
||||||
|
);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Invite link copied to clipboard')),
|
const SnackBar(content: Text('Invite link copied to clipboard')),
|
||||||
);
|
);
|
||||||
@@ -381,8 +385,45 @@ class _OrganizationSettingsDialogState extends State<OrganizationSettingsDialog>
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
style: TextStyle(color: AppTheme.primaryText),
|
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),
|
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>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: selectedRole,
|
initialValue: selectedRole,
|
||||||
items: ['admin', 'member'].map((role) {
|
items: ['admin', 'member'].map((role) {
|
||||||
@@ -459,7 +500,10 @@ class _OrganizationSettingsDialogState extends State<OrganizationSettingsDialog>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -278,6 +278,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.2.0"
|
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:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -491,6 +499,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.4.16"
|
version: "0.4.16"
|
||||||
|
lints:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "5.1.1"
|
||||||
logger:
|
logger:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ dependencies:
|
|||||||
just_audio_web: ^0.4.16
|
just_audio_web: ^0.4.16
|
||||||
just_audio: ^0.10.5
|
just_audio: ^0.10.5
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_lints: ^5.0.0
|
||||||
|
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
|
|||||||
// Health check
|
// Health check
|
||||||
r.Get("/health", healthHandler)
|
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)
|
// WOPI routes (public, token validation done per endpoint)
|
||||||
r.Route("/wopi", func(r chi.Router) {
|
r.Route("/wopi", func(r chi.Router) {
|
||||||
r.Route("/files/{fileId}", 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
|
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) {
|
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
w.Write([]byte("OK"))
|
w.Write([]byte("OK"))
|
||||||
|
|||||||
Reference in New Issue
Block a user