[client] WIP: web oauth login

This commit is contained in:
Abhishek Shroff
2025-06-28 17:06:56 +05:30
parent 95a7e8fa19
commit d07c04ee60
11 changed files with 247 additions and 147 deletions
+2
View File
@@ -127,8 +127,10 @@ class PhylumRouteInformationParser extends RouteInformationParser<PhylumRoute> {
}
} else if (segments.length == 2) {
if (segments[0] == 'login' && segments[1] == 'oauth') {
print(uri.toString());
return SynchronousFuture(OAuthLoginRoute(
code: uri.queryParameters['code'] ?? '',
state: uri.queryParameters['state'] ?? '',
));
}
if (segments[0] == 'folder') {
+8 -4
View File
@@ -1,6 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:offtheline/offtheline.dart';
import 'package:phylum/libphylum/db/db.dart';
@@ -11,6 +10,7 @@ import 'package:phylum/libphylum/requests/shared_resources_request.dart';
import 'package:phylum/libphylum/responses/responses.dart';
import 'package:phylum/ui/layout/app_layout.dart';
import 'package:phylum/ui/login/login_page.dart';
import 'package:phylum/ui/login/oauth_login_page.dart';
import 'package:phylum/ui/login/reset_password_page.dart';
import 'package:provider/provider.dart';
@@ -24,15 +24,19 @@ sealed class PhylumRoute {
class OAuthLoginRoute extends PhylumRoute {
final String code;
final String state;
OAuthLoginRoute({required this.code});
OAuthLoginRoute({required this.code, required this.state});
@override
Uri get uri => Uri(path: '/login/oauth');
@override
Widget buildPage(BuildContext context) {
return const LoginPage();
return OAuthLoginPage(
code: code,
state: state,
);
}
}
@@ -71,7 +75,7 @@ sealed class ExplorerRoute extends PhylumRoute {
context.select<AccountManagerState<PhylumAccount>, PhylumAccount?>((state) => state.selectedAccount);
if (account == null) {
return kDebugMode ? const LoginPage() : LoginPage(fixedInstanceUrl: Uri());
return const LoginPage();
}
return AppLayout.create(account);
}
+2
View File
@@ -7,6 +7,8 @@ class InstanceConfig {
final bool magicLink;
final List<OpenIDProvider> openIDProviers;
bool get hasEmailLogin => password || magicLink;
InstanceConfig(
{required this.url,
required this.password,
+10 -14
View File
@@ -1,10 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:phylum/ui/login/instance_config.dart';
import 'package:phylum/util/dialogs.dart';
import 'package:uri/uri.dart';
import 'package:phylum/util/base_uri_stub.dart' if (dart.library.js_interop) 'package:phylum/util/base_uri_web.dart';
class InstanceUrlFragment extends StatefulWidget {
final Function(InstanceConfig) onInstanceSelected;
@@ -15,35 +16,30 @@ class InstanceUrlFragment extends StatefulWidget {
}
class _InstanceUrlFragmentState extends State<InstanceUrlFragment> {
Uri? _url;
Uri? _url = webAppBaseUri;
@override
Widget build(BuildContext context) {
return Column(
spacing: 12.0,
children: [
TextField(
TextFormField(
decoration: InputDecoration(
label: const Text('Instance URL'),
hintText: 'https://phylum.example.com',
),
initialValue: webAppBaseUri?.toString(),
autofocus: true,
autofillHints: const [AutofillHints.url],
keyboardType: TextInputType.url,
onChanged: (value) {
final Uri? url;
if (kIsWeb) {
url = value == '' ? Uri() : Uri.tryParse(value);
} else {
final tempUrl = Uri.tryParse(value);
if (tempUrl != null && (kIsWeb || tempUrl.hasAuthority && tempUrl.hasScheme)) {
url = tempUrl;
} else {
url = null;
}
var url = value == '' ? webAppBaseUri : Uri.tryParse(value);
if (url != null && (!url.hasAuthority || !url.hasScheme)) {
url = null;
}
setState(() => _url = url);
},
onSubmitted: _url == null ? null : (value) => checkConfig(_url!),
onFieldSubmitted: _url == null ? null : (value) => checkConfig(_url!),
),
ElevatedButton(onPressed: _url == null ? null : () => checkConfig(_url!), child: Text('Next'))
],
+2 -84
View File
@@ -1,27 +1,11 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:offtheline/offtheline.dart';
import 'package:phylum/libphylum/phylum_account.dart';
import 'package:phylum/libphylum/responses/responses.dart';
import 'package:phylum/ui/app/router.dart';
import 'package:phylum/ui/app/routes.dart';
import 'package:phylum/ui/common/logo_row.dart';
import 'package:phylum/ui/login/instance_config.dart';
import 'package:phylum/ui/login/instance_url_fragment.dart';
import 'package:phylum/ui/login/password_login_fragment.dart';
import 'package:phylum/util/dialogs.dart';
import 'package:phylum/util/logging.dart';
import 'package:provider/provider.dart';
import 'package:uri/uri.dart';
import 'request.dart';
class LoginPage extends StatefulWidget {
final Uri? fixedInstanceUrl;
const LoginPage({super.key, this.fixedInstanceUrl});
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
@@ -71,73 +55,7 @@ class _LoginPageState extends State<LoginPage> {
}
return PasswordLoginFragment(
onBackPressed: () => setState(() => _instanceConfig = null),
instanceUrl: instanceConfig.url,
requestLogin: instanceConfig.password
? (email, password) => _performLogin(context, instanceConfig.url, email, password)
: null,
requestPasswordReset:
instanceConfig.passwordReset ? (email) => _requestPasswordReset(context, instanceConfig.url, email) : null,
resetPassword: instanceConfig.passwordReset
? (email, token) => context
.read<PhylumRouterDelegate>()
.go(ResetPasswordRoute(instanceUri: instanceConfig.url, email: email, token: token))
: null,
openIDProviders: instanceConfig.openIDProviers,
instanceConfig: instanceConfig,
);
}
Future<void> _performLogin(BuildContext context, Uri instanceUri, String email, String password) async {
final builder = UriBuilder.fromUri(instanceUri);
builder.path = '${builder.path}/api/v1/auth/password';
final request = MultipartRequest('post', builder.build());
request.fields['email'] = email;
request.fields['password'] = password;
final responseString = await sendRequest(context, 'Logging In', request);
if (responseString == null) return;
if (context.mounted) {
final accountManager = context.read<AccountManager<PhylumAccount>>();
final navigator = Navigator.of(context);
showProgressDialog(context, barrierDismissible: false, message: 'Logging In');
try {
final response =
BootstrapLoginResponse.fromResponse((jsonDecode(responseString) as Map).cast<String, dynamic>());
final account = await PhylumAccount.create(
serverUrl: instanceUri,
accessToken: response.accessToken!,
user: response.user,
);
await response.process(account);
await accountManager.addAccount(account);
navigator.pop();
} catch (e, stack) {
navigator.pop();
if (context.mounted) {
showAlertDialog(
context,
barrierDismissible: false,
title: 'Error',
message: e.toString(),
);
}
logger.w('Login Error', error: e, stackTrace: stack);
}
}
}
Future<void> _requestPasswordReset(BuildContext context, Uri instanceUrl, String email) async {
final builder = UriBuilder.fromUri(instanceUrl);
builder.path = '${builder.path}/api/v1/auth/request-password-reset';
final request = MultipartRequest('post', builder.build());
request.fields['email'] = email;
final responseString = await sendRequest(context, 'Requesting Password Reset', request);
if (responseString == null) return;
if (context.mounted) {
await showAlertDialog(
context,
title: 'Password Reset Requested',
);
}
}
}
+113
View File
@@ -0,0 +1,113 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:offtheline/offtheline.dart';
import 'package:phylum/libphylum/phylum_account.dart';
import 'package:phylum/libphylum/responses/responses.dart';
import 'package:phylum/ui/app/router.dart';
import 'package:phylum/ui/app/routes.dart';
import 'package:provider/provider.dart';
import 'package:uri/uri.dart';
import 'package:phylum/ui/app/dialog_scaffold.dart';
class OAuthLoginPage extends StatefulWidget {
final String code;
final String state;
const OAuthLoginPage({
super.key,
required this.code,
required this.state,
});
@override
State<OAuthLoginPage> createState() => _OAuthLoginPageState();
}
class _OAuthLoginPageState extends State<OAuthLoginPage> {
String? error;
@override
void initState() {
super.initState();
performLogin(context);
}
@override
Widget build(BuildContext context) {
return PhylumDialogScaffold(
child: error == null
? const Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
spacing: 12.0,
children: [
CircularProgressIndicator(),
Text('Logging In'),
],
)
: Column(
mainAxisSize: MainAxisSize.min,
spacing: 6.0,
children: [
Text('Error: $error'),
ElevatedButton(
onPressed: () {
context.read<PhylumRouterDelegate>().go(ExplorerRouteHome());
},
child: Text('OK'))
],
),
);
}
void performLogin(BuildContext context) async {
final instanceUrl = Uri.parse(widget.state);
final builder = UriBuilder.fromUri(instanceUrl);
builder.path = '${builder.path}/api/v1/auth/oauth';
final request = MultipartRequest('post', builder.build());
request.fields['code'] = widget.code;
try {
final response = await Client().send(request);
final responseString = await response.stream.bytesToString();
final responseMap = (jsonDecode(responseString) as Map).cast<String, dynamic>();
if (response.statusCode >= 200 && response.statusCode < 300) {
if (context.mounted) {
final navigator = Navigator.of(context);
final routerDelegate = context.read<PhylumRouterDelegate>();
final accountManager = context.read<AccountManager<PhylumAccount>>();
final response = BootstrapLoginResponse.fromResponse(responseMap);
final account = await PhylumAccount.create(
serverUrl: instanceUrl,
accessToken: response.accessToken!,
user: response.user,
);
await response.process(account);
await accountManager.addAccount(account);
navigator.pop();
routerDelegate.go(ExplorerRouteHome());
}
} else {
if (context.mounted) {
setState(() => error = responseMap['msg']);
}
return null;
}
} on SocketException {
if (context.mounted) {
setState(() => error = 'unable to reach server');
}
return null;
} catch (e) {
if (context.mounted) {
setState(() => error = e.toString());
}
return null;
}
}
}
+3 -1
View File
@@ -1 +1,3 @@
void startOAuthFlow(Uri oAuthUrl) => throw UnimplementedError();
import 'package:uri/uri.dart';
void startOAuthFlow(UriBuilder oAuthUriBuilder, Uri instanceUrl) => throw UnimplementedError();
+8 -1
View File
@@ -1,3 +1,10 @@
import 'package:uri/uri.dart';
import 'package:url_launcher/url_launcher.dart';
void startOAuthFlow(Uri oAuthUrl) => launchUrl(oAuthUrl);
void startOAuthFlow(UriBuilder oAuthUriBuilder, Uri instanceUrl) {
final redirectBuilder = UriBuilder.fromUri(instanceUrl);
// This will redirect to the native scheme cloud.phylum.drive://
redirectBuilder.path = '/api/v1/auth/oauth_native_redirect';
oAuthUriBuilder.queryParameters['redirect_uri'] = redirectBuilder.toString();
launchUrl(oAuthUriBuilder.build());
}
+9 -1
View File
@@ -1,3 +1,11 @@
import 'package:phylum/util/base_uri_web.dart';
import 'package:uri/uri.dart';
import 'package:web/web.dart' as web;
void startOAuthFlow(Uri oAuthUrl) => web.window.location.href = oAuthUrl.toString();
void startOAuthFlow(UriBuilder oAuthUriBuilder, Uri instanceUrl) {
final redirectBuilder = UriBuilder.fromUri(webAppBaseUri);
redirectBuilder.path = '/login/oauth';
oAuthUriBuilder.queryParameters['redirect_uri'] = redirectBuilder.toString();
oAuthUriBuilder.queryParameters['state'] = instanceUrl.toString();
web.window.location.href = oAuthUriBuilder.toString();
}
@@ -1,27 +1,30 @@
import 'dart:convert';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:offtheline/offtheline.dart';
import 'package:phylum/libphylum/phylum_account.dart';
import 'package:phylum/libphylum/responses/responses.dart';
import 'package:phylum/ui/app/router.dart';
import 'package:phylum/ui/app/routes.dart';
import 'package:phylum/ui/login/instance_config.dart';
import 'package:phylum/ui/login/request.dart';
import 'package:phylum/util/dialogs.dart';
import 'package:phylum/util/logging.dart';
import 'package:provider/provider.dart';
import 'package:uri/uri.dart';
import 'oauth_stub.dart' if (dart.library.js_interop) 'oauth_web.dart' if (dart.library.ffi) 'oauth_vm.dart';
class PasswordLoginFragment extends StatefulWidget {
final InstanceConfig instanceConfig;
final Function()? onBackPressed;
final Uri instanceUrl;
final Function(String, String)? requestLogin;
final Function(String)? requestPasswordReset;
final Function(String, String)? resetPassword;
final List<OpenIDProvider> openIDProviders;
const PasswordLoginFragment({
super.key,
required this.instanceConfig,
required this.onBackPressed,
required this.instanceUrl,
required this.requestLogin,
required this.requestPasswordReset,
required this.resetPassword,
required this.openIDProviders,
});
@override
@@ -38,7 +41,11 @@ class _PasswordLoginFragmentState extends State<PasswordLoginFragment> {
child: Column(
spacing: 12.0,
children: [
if (widget.requestLogin != null)
ListTile(
title: Text(widget.instanceConfig.url.toString()),
trailing: IconButton(onPressed: widget.onBackPressed, icon: Icon(Icons.edit)),
),
if (widget.instanceConfig.hasEmailLogin)
TextField(
decoration: const InputDecoration(
label: Text('Email'),
@@ -47,7 +54,7 @@ class _PasswordLoginFragmentState extends State<PasswordLoginFragment> {
autofillHints: const [AutofillHints.email],
onChanged: (value) => setState(() => _email = value.trim()),
),
if (widget.requestLogin != null)
if (widget.instanceConfig.password)
TextField(
decoration: const InputDecoration(
label: Text('Password'),
@@ -56,9 +63,17 @@ class _PasswordLoginFragmentState extends State<PasswordLoginFragment> {
keyboardType: TextInputType.visiblePassword,
autofillHints: const [AutofillHints.password],
onChanged: (value) => setState(() => _password = value),
onSubmitted: _email.isEmpty ? null : (value) => widget.requestLogin!(_email, _password),
onSubmitted: _email.isEmpty
? null
: (value) => _performLogin(context, widget.instanceConfig.url, _email, _password),
),
if (widget.requestPasswordReset != null)
ElevatedButton(
onPressed: _email.isEmpty || _password.isEmpty
? null
: () => _performLogin(context, widget.instanceConfig.url, _email, _password),
child: Text('Login'),
),
if (widget.instanceConfig.passwordReset)
TextButton(
child: Text('Reset Password', style: TextStyle(color: Theme.of(context).colorScheme.primary)),
onPressed: () async {
@@ -84,7 +99,8 @@ class _PasswordLoginFragmentState extends State<PasswordLoginFragment> {
await showInputDialog(context, labelText: 'Reset Token', barrierDismissable: true);
if (token == null || !context.mounted) return;
Navigator.of(context).pop(false);
widget.resetPassword!(_email, token);
context.read<PhylumRouterDelegate>().go(ResetPasswordRoute(
instanceUri: widget.instanceConfig.url, email: _email, token: token));
},
),
],
@@ -101,43 +117,75 @@ class _PasswordLoginFragmentState extends State<PasswordLoginFragment> {
) ??
false;
if (!confirm || !context.mounted) return;
widget.requestPasswordReset!(_email);
_requestPasswordReset(context, widget.instanceConfig.url, _email);
},
),
...widget.openIDProviders.map((p) => ElevatedButton(
...widget.instanceConfig.openIDProviers.map((p) => ElevatedButton(
onPressed: () async {
final builder = UriBuilder.fromUri(Uri.parse(p.endpoint));
final redirectBuilder = UriBuilder.fromUri(widget.instanceUrl);
redirectBuilder.path = '/login/oauth';
builder.queryParameters['client_id'] = p.clientID;
builder.queryParameters['response_type'] = 'code';
builder.queryParameters['redirect_uri'] = redirectBuilder.toString();
builder.queryParameters['scope'] = 'openid email profile';
startOAuthFlow(builder.build());
startOAuthFlow(builder, widget.instanceConfig.url);
},
child: Text('Log In with ${p.name}'))),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.end,
spacing: 12.0,
children: [
if (widget.onBackPressed != null)
TextButton(
onPressed: widget.onBackPressed,
child: Text(
'Back',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
if (widget.requestLogin != null)
ElevatedButton(
onPressed: _email.isEmpty || _password.isEmpty ? null : () => widget.requestLogin!(_email, _password),
child: Text('Login'),
),
],
),
],
),
);
}
Future<void> _performLogin(BuildContext context, Uri instanceUri, String email, String password) async {
final builder = UriBuilder.fromUri(instanceUri);
builder.path = '${builder.path}/api/v1/auth/password';
final request = MultipartRequest('post', builder.build());
request.fields['email'] = email;
request.fields['password'] = password;
final responseString = await sendRequest(context, 'Logging In', request);
if (responseString == null) return;
if (context.mounted) {
final accountManager = context.read<AccountManager<PhylumAccount>>();
final navigator = Navigator.of(context);
showProgressDialog(context, barrierDismissible: false, message: 'Logging In');
try {
final response =
BootstrapLoginResponse.fromResponse((jsonDecode(responseString) as Map).cast<String, dynamic>());
final account = await PhylumAccount.create(
serverUrl: instanceUri,
accessToken: response.accessToken!,
user: response.user,
);
await response.process(account);
await accountManager.addAccount(account);
navigator.pop();
} catch (e, stack) {
navigator.pop();
if (context.mounted) {
showAlertDialog(
context,
barrierDismissible: false,
title: 'Error',
message: e.toString(),
);
}
logger.w('Login Error', error: e, stackTrace: stack);
}
}
}
Future<void> _requestPasswordReset(BuildContext context, Uri instanceUrl, String email) async {
final builder = UriBuilder.fromUri(instanceUrl);
builder.path = '${builder.path}/api/v1/auth/request-password-reset';
final request = MultipartRequest('post', builder.build());
request.fields['email'] = email;
final responseString = await sendRequest(context, 'Requesting Password Reset', request);
if (responseString == null) return;
if (context.mounted) {
await showAlertDialog(
context,
title: 'Password Reset Requested',
);
}
}
}
+1 -1
View File
@@ -1 +1 @@
Uri get webAppBaseUri => Uri();
Uri? get webAppBaseUri => null;