diff --git a/README.md b/README.md index 5cd788da4e..8b534358b7 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,21 @@ community. See [issue #15][]. In this early prototype, we don't yet have a UI for logging into a Zulip server. Instead, you supply Zulip credentials at build time. -To do this, log into the Zulip web app for the test account you want -to use, and [download a `.zuliprc` file][download-zuliprc]. Then -create a file `lib/credential_fixture.dart` in this worktree with the -following form: +To do this, log into the Zulip web app for the test account +you want to use, and gather two kinds of information: +* [Download a `.zuliprc` file][download-zuliprc]. + This will contain a realm URL, email, and API key. +* Find the account's user ID. You can do this by visiting your + DMs with yourself, and looking at the URL; + it's the number after `pm-with/` or `dm-with/`. + +Then create a file `lib/credential_fixture.dart` in this worktree +with the following form, and fill in the gathered information: ```dart const String realmUrl = '…'; const String email = '…'; const String apiKey = '…'; +const int userId = /* … */ -1; ``` Now build and run the app (see "Flutter help" above), and things diff --git a/lib/api/core.dart b/lib/api/core.dart index 852a548b09..6da65a95a6 100644 --- a/lib/api/core.dart +++ b/lib/api/core.dart @@ -3,12 +3,12 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; -abstract class Auth { - String get realmUrl; +class Auth { + const Auth({required this.realmUrl, required this.email, required this.apiKey}); - String get email; - - String get apiKey; + final String realmUrl; + final String email; + final String apiKey; } /// A value for an API request parameter, to use directly without JSON encoding. diff --git a/lib/api/route/account.dart b/lib/api/route/account.dart index a5f552d6bd..d8949edfa2 100644 --- a/lib/api/route/account.dart +++ b/lib/api/route/account.dart @@ -33,8 +33,13 @@ Future fetchApiKey({ class FetchApiKeyResult { final String apiKey; final String email; + final int? userId; // TODO(server-7) - FetchApiKeyResult({required this.apiKey, required this.email}); + FetchApiKeyResult({ + required this.apiKey, + required this.email, + this.userId, + }); factory FetchApiKeyResult.fromJson(Map json) => _$FetchApiKeyResultFromJson(json); diff --git a/lib/api/route/account.g.dart b/lib/api/route/account.g.dart index 7673770bbe..95fc88d2a2 100644 --- a/lib/api/route/account.g.dart +++ b/lib/api/route/account.g.dart @@ -10,10 +10,12 @@ FetchApiKeyResult _$FetchApiKeyResultFromJson(Map json) => FetchApiKeyResult( apiKey: json['api_key'] as String, email: json['email'] as String, + userId: json['user_id'] as int?, ); Map _$FetchApiKeyResultToJson(FetchApiKeyResult instance) => { 'api_key': instance.apiKey, 'email': instance.email, + 'user_id': instance.userId, }; diff --git a/lib/api/route/realm.dart b/lib/api/route/realm.dart new file mode 100644 index 0000000000..9ea99aaaf7 --- /dev/null +++ b/lib/api/route/realm.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:json_annotation/json_annotation.dart'; + +part 'realm.g.dart'; + +/// https://zulip.com/api/get-server-settings +/// +/// Despite the name, this is really a home for certain realm-specific +/// settings, as well as some information about the server as a whole. +/// +/// The Zulip server offers this endpoint at the root domain of a server, +/// even when there is no Zulip realm at that domain. This binding, however, +/// only operates on an actual Zulip realm. +// TODO(#35): Perhaps detect realmless root domain, for more specific onboarding feedback. +// See thread, and the zulip-mobile code and chat thread it links to: +// https://github.com/zulip/zulip-flutter/pull/55#discussion_r1160267577 +Future getServerSettings({ + required String realmUrl, +}) async { + // TODO dedupe this part with LiveApiConnection; make this function testable + final response = await http.get( + Uri.parse("$realmUrl/api/v1/server_settings")); + if (response.statusCode != 200) { + throw Exception('error on GET server_settings: status ${response.statusCode}'); + } + final data = utf8.decode(response.bodyBytes); + + final json = jsonDecode(data); + return GetServerSettingsResult.fromJson(json); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class GetServerSettingsResult { + final Map authenticationMethods; + // final List external_authentication_methods; // TODO handle + + final int zulipFeatureLevel; + final String zulipVersion; + final String? zulipMergeBase; // TODO(server-5) + + final bool pushNotificationsEnabled; + final bool isIncompatible; + + final bool emailAuthEnabled; + final bool requireEmailFormatUsernames; + final String realmUri; + final String realmName; + final String realmIcon; + final String realmDescription; + final bool? realmWebPublicAccessEnabled; // TODO(server-5) + + GetServerSettingsResult({ + required this.authenticationMethods, + required this.zulipFeatureLevel, + required this.zulipVersion, + this.zulipMergeBase, + required this.pushNotificationsEnabled, + required this.isIncompatible, + required this.emailAuthEnabled, + required this.requireEmailFormatUsernames, + required this.realmUri, + required this.realmName, + required this.realmIcon, + required this.realmDescription, + this.realmWebPublicAccessEnabled, + }); + + factory GetServerSettingsResult.fromJson(Map json) => + _$GetServerSettingsResultFromJson(json); + + Map toJson() => _$GetServerSettingsResultToJson(this); +} diff --git a/lib/api/route/realm.g.dart b/lib/api/route/realm.g.dart new file mode 100644 index 0000000000..f085f61d9c --- /dev/null +++ b/lib/api/route/realm.g.dart @@ -0,0 +1,46 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'realm.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GetServerSettingsResult _$GetServerSettingsResultFromJson( + Map json) => + GetServerSettingsResult( + authenticationMethods: + Map.from(json['authentication_methods'] as Map), + zulipFeatureLevel: json['zulip_feature_level'] as int, + zulipVersion: json['zulip_version'] as String, + zulipMergeBase: json['zulip_merge_base'] as String?, + pushNotificationsEnabled: json['push_notifications_enabled'] as bool, + isIncompatible: json['is_incompatible'] as bool, + emailAuthEnabled: json['email_auth_enabled'] as bool, + requireEmailFormatUsernames: + json['require_email_format_usernames'] as bool, + realmUri: json['realm_uri'] as String, + realmName: json['realm_name'] as String, + realmIcon: json['realm_icon'] as String, + realmDescription: json['realm_description'] as String, + realmWebPublicAccessEnabled: + json['realm_web_public_access_enabled'] as bool?, + ); + +Map _$GetServerSettingsResultToJson( + GetServerSettingsResult instance) => + { + 'authentication_methods': instance.authenticationMethods, + 'zulip_feature_level': instance.zulipFeatureLevel, + 'zulip_version': instance.zulipVersion, + 'zulip_merge_base': instance.zulipMergeBase, + 'push_notifications_enabled': instance.pushNotificationsEnabled, + 'is_incompatible': instance.isIncompatible, + 'email_auth_enabled': instance.emailAuthEnabled, + 'require_email_format_usernames': instance.requireEmailFormatUsernames, + 'realm_uri': instance.realmUri, + 'realm_name': instance.realmName, + 'realm_icon': instance.realmIcon, + 'realm_description': instance.realmDescription, + 'realm_web_public_access_enabled': instance.realmWebPublicAccessEnabled, + }; diff --git a/lib/api/route/users.dart b/lib/api/route/users.dart new file mode 100644 index 0000000000..a5a94d93ee --- /dev/null +++ b/lib/api/route/users.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; + +import 'package:json_annotation/json_annotation.dart'; + +import '../core.dart'; + +part 'users.g.dart'; + +/// https://zulip.com/api/get-own-user, abridged +/// +/// This route's return type is simplified because we use it only +/// as a workaround on old servers. +Future getOwnUser(ApiConnection connection) async { + final data = await connection.get('users/me', {}); + return GetOwnUserResult.fromJson(jsonDecode(data)); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class GetOwnUserResult { + final int userId; + + // There are many more properties in this route's result. + // But we use this route only as a workaround on old servers: + // https://github.com/zulip/zulip/issues/24980 + // https://chat.zulip.org/#narrow/stream/378-api-design/topic/user.20ID.20in.20fetch-api-key/near/1540592 + // for which `userId` is the only property we need. + // TODO(server-7): Drop getOwnUser entirely, relying on userId from fetchApiKey. + + GetOwnUserResult({ + required this.userId, + }); + + factory GetOwnUserResult.fromJson(Map json) => + _$GetOwnUserResultFromJson(json); + + Map toJson() => _$GetOwnUserResultToJson(this); +} diff --git a/lib/api/route/users.g.dart b/lib/api/route/users.g.dart new file mode 100644 index 0000000000..d32a458a7d --- /dev/null +++ b/lib/api/route/users.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'users.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GetOwnUserResult _$GetOwnUserResultFromJson(Map json) => + GetOwnUserResult( + userId: json['user_id'] as int, + ); + +Map _$GetOwnUserResultToJson(GetOwnUserResult instance) => + { + 'user_id': instance.userId, + }; diff --git a/lib/model/store.dart b/lib/model/store.dart index 9766ddddc2..3feec9f1ca 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -201,16 +201,21 @@ class PerAccountStore extends ChangeNotifier { } @immutable -class Account implements Auth { - const Account( - {required this.realmUrl, required this.email, required this.apiKey}); - - @override - final String realmUrl; - @override - final String email; - @override - final String apiKey; +class Account extends Auth { + const Account({ + required super.realmUrl, + required super.email, + required super.apiKey, + required this.userId, + required this.zulipFeatureLevel, + required this.zulipVersion, + required this.zulipMergeBase, + }); + + final int userId; + final int zulipFeatureLevel; + final String zulipVersion; + final String? zulipMergeBase; } class LiveGlobalStore extends GlobalStore { @@ -240,6 +245,10 @@ const Account _fixtureAccount = Account( realmUrl: credentials.realmUrl, email: credentials.email, apiKey: credentials.apiKey, + userId: credentials.userId, + zulipFeatureLevel: 169, + zulipVersion: '6.0-1235-g061f1dc43b', + zulipMergeBase: '6.0-1235-g061f1dc43b', ); /// A [PerAccountStore] which polls an event queue to stay up to date. diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart index ff29b98baa..31d175d3b6 100644 --- a/lib/widgets/login.dart +++ b/lib/widgets/login.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; +import '../api/core.dart'; import '../api/route/account.dart'; +import '../api/route/realm.dart'; +import '../api/route/users.dart'; import '../model/store.dart'; import 'app.dart'; import 'store.dart'; @@ -32,17 +35,27 @@ class _AddAccountPageState extends State { super.dispose(); } - void _onSubmitted(BuildContext context, String value) { + Future _onSubmitted(BuildContext context, String value) async { final Uri? url = Uri.tryParse(value); switch (url) { case Uri(scheme: 'https' || 'http'): // TODO(#35): validate realm URL further? - // TODO(#36): support login methods beyond email/password - Navigator.push(context, - EmailPasswordLoginPage.buildRoute(realmUrl: url)); + break; default: // TODO(#35): give feedback to user on bad realm URL + return; } + + // TODO(#35): show feedback that we're working, while fetching server settings + final serverSettings = await getServerSettings(realmUrl: url.toString()); + if (context.mounted) {} // https://github.com/dart-lang/linter/issues/4007 + else { + return; + } + + // TODO(#36): support login methods beyond email/password + Navigator.push(context, + EmailPasswordLoginPage.buildRoute(realmUrl: url, serverSettings: serverSettings)); } @override @@ -69,13 +82,16 @@ class _AddAccountPageState extends State { } class EmailPasswordLoginPage extends StatefulWidget { - const EmailPasswordLoginPage({super.key, required this.realmUrl}); + const EmailPasswordLoginPage({ + super.key, required this.realmUrl, required this.serverSettings}); final Uri realmUrl; + final GetServerSettingsResult serverSettings; - static Route buildRoute({required Uri realmUrl}) { + static Route buildRoute({ + required Uri realmUrl, required GetServerSettingsResult serverSettings}) { return _LoginSequenceRoute(builder: (context) => - EmailPasswordLoginPage(realmUrl: realmUrl)); + EmailPasswordLoginPage(realmUrl: realmUrl, serverSettings: serverSettings)); } @override @@ -86,6 +102,14 @@ class _EmailPasswordLoginPageState extends State { final GlobalKey> _emailKey = GlobalKey(); final GlobalKey> _passwordKey = GlobalKey(); + Future _getUserId(FetchApiKeyResult fetchApiKeyResult) async { + final FetchApiKeyResult(:email, :apiKey) = fetchApiKeyResult; + final auth = Auth( + realmUrl: widget.realmUrl.toString(), email: email, apiKey: apiKey); + final connection = LiveApiConnection(auth: auth); // TODO make this widget testable + return (await getOwnUser(connection)).userId; + } + void _submit() async { final context = _emailKey.currentContext!; final realmUrl = widget.realmUrl; @@ -106,13 +130,23 @@ class _EmailPasswordLoginPageState extends State { debugPrint(e.toString()); return; } + + // TODO(server-7): Rely on user_id from fetchApiKey. + final int userId = result.userId ?? await _getUserId(result); if (context.mounted) {} // https://github.com/dart-lang/linter/issues/4007 else { return; } final account = Account( - realmUrl: realmUrl.toString(), email: result.email, apiKey: result.apiKey); + realmUrl: realmUrl.toString(), + email: result.email, + apiKey: result.apiKey, + userId: userId, + zulipFeatureLevel: widget.serverSettings.zulipFeatureLevel, + zulipVersion: widget.serverSettings.zulipVersion, + zulipMergeBase: widget.serverSettings.zulipMergeBase, + ); final globalStore = GlobalStoreWidget.of(context); final accountId = await globalStore.insertAccount(account); if (context.mounted) {} // https://github.com/dart-lang/linter/issues/4007 diff --git a/test/api/fake_api.dart b/test/api/fake_api.dart index b49aa86752..4fbcd7394a 100644 --- a/test/api/fake_api.dart +++ b/test/api/fake_api.dart @@ -4,7 +4,7 @@ import 'package:zulip/model/store.dart'; /// An [ApiConnection] that accepts and replays canned responses, for testing. class FakeApiConnection extends ApiConnection { FakeApiConnection({required String realmUrl, required String email}) - : super(auth: Account( + : super(auth: Auth( realmUrl: realmUrl, email: email, apiKey: _fakeApiKey)); FakeApiConnection.fromAccount(Account account) diff --git a/test/example_data.dart b/test/example_data.dart index 1ed93f9fde..2c0376aade 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -7,10 +7,25 @@ const String realmUrl = 'https://chat.example/'; const String recentZulipVersion = '6.1'; const int recentZulipFeatureLevel = 164; -const Account selfAccount = - Account(realmUrl: realmUrl, email: 'self@example', apiKey: 'asdfqwer'); -const Account otherAccount = - Account(realmUrl: realmUrl, email: 'other@example', apiKey: 'sdfgwert'); +const Account selfAccount = Account( + realmUrl: realmUrl, + email: 'self@example', + apiKey: 'asdfqwer', + userId: 123, + zulipFeatureLevel: recentZulipFeatureLevel, + zulipVersion: recentZulipVersion, + zulipMergeBase: recentZulipVersion, +); + +const Account otherAccount = Account( + realmUrl: realmUrl, + email: 'other@example', + apiKey: 'sdfgwert', + userId: 234, + zulipFeatureLevel: recentZulipFeatureLevel, + zulipVersion: recentZulipVersion, + zulipMergeBase: recentZulipVersion, +); final _messagePropertiesBase = { 'is_me_message': false,