Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(shorebird_cli): Add account create command #334

Merged
merged 18 commits into from
Apr 20, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion packages/shorebird_cli/lib/src/auth/auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ class Auth {
}

Future<void> login(void Function(String) prompt) async {
if (_credentials != null) return;
if (_credentials != null) {
throw UserAlreadyLoggedInException(email: _credentials!.email!);
}

final client = http.Client();
try {
Expand All @@ -126,6 +128,9 @@ class Auth {
final codePushClient = _buildCodePushClient(httpClient: this.client);

final user = await codePushClient.getCurrentUser();
if (user == null) {
throw UserNotFoundException(email: _credentials!.email!);
}

_email = user.email;
_flushCredentials(_credentials!);
Expand All @@ -136,6 +141,42 @@ class Auth {

void logout() => _clearCredentials();

Future<User> signUp({
required void Function(String) authPrompt,
required String Function() namePrompt,
}) async {
if (_credentials != null) {
throw UserAlreadyLoggedInException(email: _credentials!.email!);
}

final client = http.Client();
final User newUser;
try {
_credentials = await _obtainAccessCredentials(
_clientId,
_scopes,
client,
authPrompt,
);

final codePushClient = _buildCodePushClient(httpClient: this.client);

final existingUser = await codePushClient.getCurrentUser();
if (existingUser != null) {
throw UserAlreadyExistsException(existingUser);
}

newUser = await codePushClient.createUser(name: namePrompt());

_email = newUser.email;
_flushCredentials(_credentials!);
} finally {
client.close();
}

return newUser;
}

oauth2.AccessCredentials? _credentials;

String? _email;
Expand Down Expand Up @@ -192,3 +233,36 @@ extension on oauth2.AccessCredentials {
return claims['email'] as String?;
}
}

/// Thrown when an already authenticated user attempts to log in or sign up.
class UserAlreadyLoggedInException implements Exception {
/// {@macro user_already_logged_in_exception}
UserAlreadyLoggedInException({required this.email});

/// The email of the already authenticated user, as derived from the stored
/// auth credentials.
final String email;
}

/// {@template user_not_found_exception}
/// Thrown when an attempt to fetch a User object results in a 404.
/// {@endtemplate}
class UserNotFoundException implements Exception {
/// {@macro user_not_found_exception}
UserNotFoundException({required this.email});

/// The email used to locate the user, as derived from the stored auth
/// credentials.
final String email;
}

/// {@template user_already_exists_exception}
/// Thrown when an attempt to create a User object results in a 409.
/// {@endtemplate}
class UserAlreadyExistsException implements Exception {
/// {@macro user_already_exists_exception}
UserAlreadyExistsException(this.user);

/// The existing user.
final User user;
}
2 changes: 2 additions & 0 deletions packages/shorebird_cli/lib/src/commands/account/account.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export 'account_command.dart';
export 'create_account_command.dart';
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/commands/commands.dart';

/// {@template account_command}
/// `shorebird account`
/// Manage your Shorebird account.
/// {@endtemplate}
class AccountCommand extends ShorebirdCommand {
/// {@macro account_command}
AccountCommand({required super.logger, super.auth}) {
addSubcommand(CreateAccountCommand(logger: logger));
}

@override
String get name => 'account';

@override
String get description => 'Manage your Shorebird account.';
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import 'dart:async';

import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/shorebird_config_mixin.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';

/// {@template create_account_command}
/// `shorebird account create`
/// Create a new Shorebird account.
/// {@endtemplate}
class CreateAccountCommand extends ShorebirdCommand with ShorebirdConfigMixin {
/// {@macro create_account_command}
CreateAccountCommand({required super.logger, super.auth});

@override
String get description => 'Create a new Shorebird account.';

@override
String get name => 'create';

@override
Future<int> run() async {
final User newUser;
try {
newUser = await auth.signUp(
authPrompt: authPrompt,
namePrompt: namePrompt,
);
} on UserAlreadyLoggedInException catch (error) {
logger
..info('You are already logged in as <${error.email}>.')
..info("Run 'shorebird logout' to log out and try again.");
bryanoltman marked this conversation as resolved.
Show resolved Hide resolved
return ExitCode.success.code;
} on UserAlreadyExistsException catch (error) {
// TODO(bryanoltman): change this message based on the user's subscription
// status.
logger.info('''
You already have an account, ${error.user.displayName}!
To subscribe, run ${green.wrap('shorebird account subscribe')}.
bryanoltman marked this conversation as resolved.
Show resolved Hide resolved
''');
return ExitCode.success.code;
} catch (error) {
logger.err(error.toString());
return ExitCode.software.code;
}

logger.info(
'''

🎉 ${lightGreen.wrap('Welcome to Shorebird, ${newUser.displayName}!')}
🔑 Credentials are stored in ${lightCyan.wrap(auth.credentialsFilePath)}.
🚪 To logout, use: "${lightCyan.wrap('shorebird logout')}".
⬆️ To upgrade your account, use: "${lightCyan.wrap('shorebird account subscribe')}".

Enjoy! Please let us know via Discord if we can help.''',
);
return ExitCode.success.code;
}

void authPrompt(String url) {
logger.info('''
Shorebird currently requires a Google account for authentication. If you'd like to use a different kind of auth, please let us know: ${lightCyan.wrap('https://github.com/shorebirdtech/shorebird/issues/335')}.

Follow the link below to authenticate:

${styleBold.wrap(styleUnderlined.wrap(lightCyan.wrap(url)))}

Waiting for your authorization...''');
}

String namePrompt() => logger.prompt('''
Tell us your name to finish creating your account:''');
}
29 changes: 0 additions & 29 deletions packages/shorebird_cli/lib/src/commands/account_command.dart

This file was deleted.

2 changes: 1 addition & 1 deletion packages/shorebird_cli/lib/src/commands/commands.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export 'account_command.dart';
export 'account/account.dart';
export 'apps/apps.dart';
export 'build/build_command.dart';
export 'cache/cache.dart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import 'package:shorebird_cli/src/validators/validators.dart';
import 'package:shorebird_cli/src/version.dart';

/// {@template doctor_command}
///
/// `shorebird doctor`
/// A command that checks for potential issues with the current shorebird
/// environment.
Expand Down
31 changes: 20 additions & 11 deletions packages/shorebird_cli/lib/src/commands/login_command.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/command.dart';

/// {@template login_command}
///
/// `shorebird login`
/// Login as a new Shorebird user.
/// {@endtemplate}
Expand All @@ -18,26 +18,35 @@ class LoginCommand extends ShorebirdCommand {

@override
Future<int> run() async {
if (auth.isAuthenticated) {
try {
await auth.login(prompt);
} on UserAlreadyLoggedInException catch (error) {
logger
..info('You are already logged in as <${auth.email}>.')
..info('You are already logged in as <${error.email}>.')
..info("Run 'shorebird logout' to log out and try again.");
bryanoltman marked this conversation as resolved.
Show resolved Hide resolved
return ExitCode.success.code;
} on UserNotFoundException catch (error) {
logger
..err(
'''
We could not find a Shorebird account for ${error.email}.''',
)
..info(
"""If you have not yet created an account, you can do so by running "${green.wrap('shorebird account create')}". If you believe this is an error, please reach out to us via Discord, we're happy to help!""",
bryanoltman marked this conversation as resolved.
Show resolved Hide resolved
);
return ExitCode.software.code;
} catch (error) {
logger.err(error.toString());
return ExitCode.software.code;
}

try {
await auth.login(prompt);
logger.info('''
logger.info('''

🎉 ${lightGreen.wrap('Welcome to Shorebird! You are now logged in as <${auth.email}>.')}

🔑 Credentials are stored in ${lightCyan.wrap(auth.credentialsFilePath)}.
🚪 To logout use: "${lightCyan.wrap('shorebird logout')}".''');
return ExitCode.success.code;
} catch (error) {
logger.err(error.toString());
return ExitCode.software.code;
}
return ExitCode.success.code;
}

void prompt(String url) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class CancelSubscriptionCommand extends ShorebirdCommand

final User user;
try {
user = await client.getCurrentUser();
user = (await client.getCurrentUser())!;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're already logged in we could cache the user and avoid having to make this extra request

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow – we won't be running login or signUp in this command, and we'll definitely want to get the latest subscription info for this user before dealing with subscription info.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh sorry I think I misread this. We might want to remove the auth.isAuthenticated check here and instead handle the case where getCurrentUser is null rather than force unwrapping though 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should still fail in the same way as before – if the user is null or if getCurrentUser fails in some other way, we log the error and exit. What's your concern?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My concern is mainly that the error from force unwrapping a null value won't be helpful to the end user

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah! That makes sense. Updated.

} catch (error) {
logger.err(error.toString());
return ExitCode.software.code;
Expand Down
65 changes: 62 additions & 3 deletions packages/shorebird_cli/test/src/auth/auth_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ void main() {
const idToken =
'''eyJhbGciOiJSUzI1NiIsImN0eSI6IkpXVCJ9.eyJlbWFpbCI6InRlc3RAZW1haWwuY29tIn0.pD47BhF3MBLyIpfsgWCzP9twzC1HJxGukpcR36DqT6yfiOMHTLcjDbCjRLAnklWEHiT0BQTKTfhs8IousU90Fm5bVKObudfKu8pP5iZZ6Ls4ohDjTrXky9j3eZpZjwv8CnttBVgRfMJG-7YASTFRYFcOLUpnb4Zm5R6QdoCDUYg''';
const email = 'test@email.com';
const name = 'Jane Doe';
const user = User(id: 42, email: email);
const refreshToken = '';
const scopes = <String>[];
Expand Down Expand Up @@ -177,9 +178,67 @@ void main() {
});

test('should not set the email when user does not exist', () async {
const exception = CodePushException(message: 'oops');
when(() => codePushClient.getCurrentUser()).thenThrow(exception);
await expectLater(auth.login((_) {}), throwsA(exception));
when(() => codePushClient.getCurrentUser())
.thenAnswer((_) async => null);
await expectLater(
auth.login((_) {}),
throwsA(isA<UserNotFoundException>()),
);
expect(auth.email, isNull);
expect(auth.isAuthenticated, isFalse);
});
});

group('signUp', () {
test(
'should set the email when claims are valid and user is successfully '
'created', () async {
when(() => codePushClient.getCurrentUser())
.thenAnswer((_) async => null);
when(() => codePushClient.createUser(name: any(named: 'name')))
.thenAnswer((_) async => user);

final newUser = await auth.signUp(
authPrompt: (_) {},
namePrompt: () => name,
);
expect(user, newUser);
expect(auth.email, email);
expect(auth.isAuthenticated, isTrue);
expect(buildAuth().email, email);
expect(buildAuth().isAuthenticated, isTrue);
});

test('throws UserAlreadyExistsException if user already exists',
() async {
when(() => codePushClient.getCurrentUser())
.thenAnswer((_) async => user);

await expectLater(
auth.signUp(
authPrompt: (_) {},
namePrompt: () => name,
),
throwsA(isA<UserAlreadyExistsException>()),
);
verifyNever(() => codePushClient.createUser(name: any(named: 'name')));
expect(auth.email, isNull);
expect(auth.isAuthenticated, isFalse);
});

test('throws exception if createUser fails', () async {
when(() => codePushClient.getCurrentUser())
.thenAnswer((_) async => null);
when(() => codePushClient.createUser(name: any(named: 'name')))
.thenThrow(Exception('oh no!'));

await expectLater(
auth.signUp(
authPrompt: (_) {},
namePrompt: () => name,
),
throwsA(isA<Exception>()),
);
expect(auth.email, isNull);
expect(auth.isAuthenticated, isFalse);
});
Expand Down
Loading