Add JoinPage feature with invite token handling and update routing

This commit is contained in:
Leon Bösche
2026-01-23 23:39:59 +01:00
parent 98e7bbdb9e
commit 26cbe83d66
7 changed files with 237 additions and 2 deletions

View File

@@ -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'] ?? ''),
),
],
);

View 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'),
),
);
}
}

View File

@@ -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;

View File

@@ -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: [

View File

@@ -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:

View File

@@ -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

View File

@@ -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"))