feat: api token login

This commit is contained in:
Dr-Blank 2024-09-06 15:10:00 -04:00
parent 880960c745
commit 682631fb8e
No known key found for this signature in database
GPG key ID: 7452CC63F210A266
17 changed files with 993 additions and 254 deletions

View file

@ -1,15 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:vaani/api/api_provider.dart';
import 'package:vaani/api/authenticated_user_provider.dart';
import 'package:vaani/api/server_provider.dart';
import 'package:vaani/features/onboarding/view/user_login.dart';
import 'package:vaani/router/router.dart';
import 'package:vaani/settings/api_settings_provider.dart';
import 'package:vaani/settings/models/models.dart' as model;
import 'package:vaani/shared/utils.dart';
import 'package:vaani/shared/widgets/add_new_server.dart';
@ -26,80 +21,22 @@ class OnboardingSinglePage extends HookConsumerWidget {
);
var audiobookshelfUri = makeBaseUrl(serverUriController.text);
final api = ref.watch(audiobookshelfApiProvider(audiobookshelfUri));
final canUserLogin = useState(apiSettings.activeServer != null);
final isUserLoginAvailable = useState(apiSettings.activeServer != null);
final usernameController = useTextEditingController();
final passwordController = useTextEditingController();
void addServer() {
var newServer = serverUriController.text.isEmpty
? null
: model.AudiobookShelfServer(
serverUrl: Uri.parse(serverUriController.text),
);
try {
// add the server to the list of servers
if (newServer != null) {
ref.read(audiobookShelfServerProvider.notifier).addServer(
newServer,
);
}
// else remove the server from the list of servers
else if (apiSettings.activeServer != null) {
ref
.read(audiobookShelfServerProvider.notifier)
.removeServer(apiSettings.activeServer!);
}
} on ServerAlreadyExistsException catch (e) {
newServer = e.server;
} finally {
ref.read(apiSettingsProvider.notifier).updateState(
apiSettings.copyWith(
activeServer: newServer,
),
);
}
}
/// Login to the server and save the user
Future<void> loginAndSave() async {
final username = usernameController.text;
final password = passwordController.text;
final success = await api.login(username: username, password: password);
if (success != null) {
// save the server
addServer();
var authenticatedUser = model.AuthenticatedUser(
server: model.AudiobookShelfServer(
serverUrl: Uri.parse(serverUriController.text),
),
id: success.user.id,
password: password,
username: username,
authToken: api.token!,
);
// add the user to the list of users
ref.read(authenticatedUserProvider.notifier).addUser(authenticatedUser);
// set the active user
ref.read(apiSettingsProvider.notifier).updateState(
apiSettings.copyWith(
activeUser: authenticatedUser,
),
);
// redirect to the library page
GoRouter.of(context).goNamed(Routes.home.name);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Login failed. Please check your credentials.'),
),
);
// give focus back to the username field
}
fadeSlideTransitionBuilder(
Widget child,
Animation<double> animation,
) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.3),
end: const Offset(0, 0),
).animate(animation),
child: child,
),
);
}
return Scaffold(
@ -118,9 +55,20 @@ class OnboardingSinglePage extends HookConsumerWidget {
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'Please enter the URL of your AudiobookShelf Server',
style: Theme.of(context).textTheme.bodyMedium,
child: AnimatedSwitcher(
duration: 500.ms,
transitionBuilder: fadeSlideTransitionBuilder,
child: canUserLogin.value
? Text(
'Server connected, please login',
key: const ValueKey('connected'),
style: Theme.of(context).textTheme.bodyMedium,
)
: Text(
'Please enter the URL of your AudiobookShelf Server',
key: const ValueKey('not_connected'),
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
Padding(
@ -129,30 +77,16 @@ class OnboardingSinglePage extends HookConsumerWidget {
controller: serverUriController,
allowEmpty: true,
onPressed: () {
isUserLoginAvailable.value =
serverUriController.text.isNotEmpty;
canUserLogin.value = serverUriController.text.isNotEmpty;
},
),
),
AnimatedSwitcher(
duration: 500.ms,
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.3),
end: const Offset(0, 0),
).animate(animation),
child: child,
),
);
},
child: isUserLoginAvailable.value
? UserLogin(
passwordController: passwordController,
usernameController: usernameController,
onPressed: loginAndSave,
transitionBuilder: fadeSlideTransitionBuilder,
child: canUserLogin.value
? UserLoginWidget(
server: audiobookshelfUri,
)
// ).animate().fade(duration: 600.ms).slideY(begin: 0.3, end: 0)
: const RedirectToABS().animate().fadeIn().slideY(

View file

@ -1,30 +1,343 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lottie/lottie.dart';
import 'package:shelfsdk/audiobookshelf_api.dart';
import 'package:vaani/api/api_provider.dart';
import 'package:vaani/api/authenticated_user_provider.dart';
import 'package:vaani/api/server_provider.dart';
import 'package:vaani/hacks/fix_autofill_losing_focus.dart';
import 'package:vaani/models/error_response.dart';
import 'package:vaani/router/router.dart';
import 'package:vaani/settings/api_settings_provider.dart';
import 'package:vaani/settings/models/models.dart' as model;
class UserLogin extends HookConsumerWidget {
UserLogin({
class UserLoginWidget extends HookConsumerWidget {
UserLoginWidget({
super.key,
this.usernameController,
this.passwordController,
required this.server,
});
final Uri server;
final serverStatusError = ErrorResponse();
@override
Widget build(BuildContext context, WidgetRef ref) {
final serverStatus =
ref.watch(serverStatusProvider(server, serverStatusError.storeError));
final api = ref.watch(audiobookshelfApiProvider(server));
return serverStatus.when(
data: (value) {
if (value == null) {
// check the error message
return Text(serverStatusError.response.body);
}
// check available authentication methods and return the correct widget
return UserLoginMultipleAuth(
server: server,
localAvailable:
value.authMethods?.contains(AuthMethod.local) ?? false,
openidAvailable:
value.authMethods?.contains(AuthMethod.openid) ?? false,
);
},
loading: () {
return const Center(
child: CircularProgressIndicator(),
);
},
error: (error, _) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Server is not reachable: $error'),
ElevatedButton(
onPressed: () {
ref.invalidate(
serverStatusProvider(
server,
serverStatusError.storeError,
),
);
},
child: const Text('Try again'),
),
],
),
);
},
);
}
}
enum AuthMethodChoice {
local,
openid,
authToken,
}
class UserLoginMultipleAuth extends HookConsumerWidget {
const UserLoginMultipleAuth({
super.key,
required this.server,
this.localAvailable = false,
this.openidAvailable = false,
this.onPressed,
});
TextEditingController? usernameController;
TextEditingController? passwordController;
final Uri server;
final bool localAvailable;
final bool openidAvailable;
final void Function()? onPressed;
@override
Widget build(BuildContext context, WidgetRef ref) {
usernameController ??= useTextEditingController();
passwordController ??= useTextEditingController();
// will show choice chips for the available authentication methods
// authToken method is always available
final methodChoice = useState<AuthMethodChoice>(
localAvailable ? AuthMethodChoice.local : AuthMethodChoice.authToken,
);
final apiSettings = ref.watch(apiSettingsProvider);
model.AudiobookShelfServer addServer() {
var newServer = model.AudiobookShelfServer(
serverUrl: server,
);
try {
// add the server to the list of servers
ref.read(audiobookShelfServerProvider.notifier).addServer(
newServer,
);
} on ServerAlreadyExistsException catch (e) {
newServer = e.server;
} finally {
ref.read(apiSettingsProvider.notifier).updateState(
apiSettings.copyWith(
activeServer: newServer,
),
);
}
return newServer;
}
return Center(
child: InactiveFocusScopeObserver(
child: AutofillGroup(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Wrap(
// mainAxisAlignment: MainAxisAlignment.center,
spacing: 10,
runAlignment: WrapAlignment.center,
runSpacing: 10,
alignment: WrapAlignment.center,
children: [
// a small label to show the user what to do
if (localAvailable)
ChoiceChip(
label: const Text('Local'),
selected: methodChoice.value == AuthMethodChoice.local,
onSelected: (selected) {
if (selected) {
methodChoice.value = AuthMethodChoice.local;
}
},
),
if (openidAvailable)
ChoiceChip(
label: const Text('OpenID'),
selected: methodChoice.value == AuthMethodChoice.openid,
onSelected: (selected) {
if (selected) {
methodChoice.value = AuthMethodChoice.openid;
}
},
),
ChoiceChip(
label: const Text('Token'),
selected:
methodChoice.value == AuthMethodChoice.authToken,
onSelected: (selected) {
if (selected) {
methodChoice.value = AuthMethodChoice.authToken;
}
},
),
],
),
const SizedBox.square(
dimension: 8,
),
switch (methodChoice.value) {
AuthMethodChoice.local => UserLoginWithPassword(
server: server,
addServer: addServer,
),
AuthMethodChoice.openid => _UserLoginWithOpenID(
server: server,
addServer: addServer,
),
AuthMethodChoice.authToken => UserLoginWithToken(
server: server,
addServer: addServer,
),
},
],
),
),
),
),
);
}
}
class _UserLoginWithOpenID extends HookConsumerWidget {
const _UserLoginWithOpenID({
super.key,
required this.server,
required this.addServer,
});
final Uri server;
final model.AudiobookShelfServer Function() addServer;
@override
Widget build(BuildContext context, WidgetRef ref) {
// TODO: implement build
return const Text('OpenID');
}
}
class UserLoginWithToken extends HookConsumerWidget {
UserLoginWithToken({
super.key,
required this.server,
required this.addServer,
});
final Uri server;
final model.AudiobookShelfServer Function() addServer;
final serverErrorResponse = ErrorResponse();
@override
Widget build(BuildContext context, WidgetRef ref) {
final authTokensController = useTextEditingController();
final api = ref.watch(audiobookshelfApiProvider(server));
Future<void> loginAndSave() async {
api.token = authTokensController.text;
model.AuthenticatedUser? authenticatedUser;
LoginResponse? success;
try {
success = await api.misc.authorize(
responseErrorHandler: serverErrorResponse.storeError,
);
if (success == null) {
throw StateError('No response from server');
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Login failed. Got response: ${serverErrorResponse.response.body} (${serverErrorResponse.response.statusCode})',
),
action: SnackBarAction(
label: 'See Error',
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Error'),
content: Text(e.toString()),
),
);
},
),
),
);
return;
}
authenticatedUser = model.AuthenticatedUser(
server: addServer(),
id: success.user.id,
username: success.user.username,
authToken: api.token!,
);
ref
.read(authenticatedUserProvider.notifier)
.addUser(authenticatedUser, setActive: true);
context.goNamed(Routes.home.name);
}
return Form(
child: Column(
children: [
TextFormField(
controller: authTokensController,
autofocus: true,
textInputAction: TextInputAction.done,
maxLines: 10,
minLines: 1,
decoration: InputDecoration(
labelText: 'API Token',
labelStyle: TextStyle(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
border: const OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an API token';
}
return null;
},
onFieldSubmitted: (_) async {
await loginAndSave();
},
),
const SizedBox(height: 10),
ElevatedButton(
onPressed: loginAndSave,
child: const Text('Login'),
),
],
),
);
}
}
// class _UserLoginWithToken extends HookConsumerWidget {
class UserLoginWithPassword extends HookConsumerWidget {
UserLoginWithPassword({
super.key,
required this.server,
required this.addServer,
});
final Uri server;
final model.AudiobookShelfServer Function() addServer;
final serverErrorResponse = ErrorResponse();
@override
Widget build(BuildContext context, WidgetRef ref) {
final usernameController = useTextEditingController();
final passwordController = useTextEditingController();
final isPasswordVisibleAnimationController = useAnimationController(
duration: const Duration(milliseconds: 500),
);
var isPasswordVisible = useState(false);
final api = ref.watch(audiobookshelfApiProvider(server));
// forward animation when the password visibility changes
useEffect(
@ -39,6 +352,61 @@ class UserLogin extends HookConsumerWidget {
[isPasswordVisible.value],
);
/// Login to the server and save the user
Future<void> loginAndSave() async {
final username = usernameController.text;
final password = passwordController.text;
LoginResponse? success;
try {
success = await api.login(
username: username,
password: password,
responseErrorHandler: serverErrorResponse.storeError,
);
if (success == null) {
throw StateError('No response from server');
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Login failed. Got response: ${serverErrorResponse.response.body} (${serverErrorResponse.response.statusCode})',
),
action: SnackBarAction(
label: 'See Error',
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Error'),
content: Text(e.toString()),
),
);
},
),
),
);
return;
}
// save the server
final authenticatedUser = model.AuthenticatedUser(
server: addServer(),
id: success.user.id,
password: password,
username: username,
authToken: api.token!,
);
// add the user to the list of users
ref
.read(authenticatedUserProvider.notifier)
.addUser(authenticatedUser, setActive: true);
// redirect to the library page
GoRouter.of(context).goNamed(Routes.home.name);
}
return Center(
child: InactiveFocusScopeObserver(
child: AutofillGroup(
@ -69,11 +437,9 @@ class UserLogin extends HookConsumerWidget {
autofillHints: const [AutofillHints.password],
textInputAction: TextInputAction.done,
obscureText: !isPasswordVisible.value,
onFieldSubmitted: onPressed != null
? (_) {
onPressed!();
}
: null,
onFieldSubmitted: (_) {
loginAndSave();
},
decoration: InputDecoration(
labelText: 'Password',
labelStyle: TextStyle(
@ -109,7 +475,7 @@ class UserLogin extends HookConsumerWidget {
),
const SizedBox(height: 30),
ElevatedButton(
onPressed: onPressed,
onPressed: loginAndSave,
child: const Text('Login'),
),
],

View file

@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:vaani/api/api_provider.dart';
import 'package:vaani/api/authenticated_user_provider.dart';
import 'package:vaani/api/server_provider.dart';
import 'package:vaani/models/error_response.dart';
import 'package:vaani/router/router.dart';
import 'package:vaani/settings/api_settings_provider.dart';
import 'package:vaani/settings/models/models.dart' as model;
@ -227,7 +228,7 @@ class ServerManagerPage extends HookConsumerWidget {
if (formKey.currentState!.validate()) {
try {
final newServer = model.AudiobookShelfServer(
serverUrl: Uri.parse(serverURIController.text),
serverUrl: makeBaseUrl(serverURIController.text),
);
ref
.read(audiobookShelfServerProvider.notifier)
@ -285,12 +286,16 @@ class _AddUserDialog extends HookConsumerWidget {
final formKey = GlobalKey<FormState>();
final serverErrorResponse = ErrorResponse();
/// Login to the server and save the user
Future<model.AuthenticatedUser?> loginAndSave() async {
model.AuthenticatedUser? authenticatedUser;
if (isMethodAuth.value) {
api.token = authTokensController.text;
final success = await api.misc.authorize();
final success = await api.misc.authorize(
responseErrorHandler: serverErrorResponse.storeError,
);
if (success != null) {
authenticatedUser = model.AuthenticatedUser(
server: server,
@ -302,7 +307,11 @@ class _AddUserDialog extends HookConsumerWidget {
} else {
final username = usernameController.text;
final password = passwordController.text;
final success = await api.login(username: username, password: password);
final success = await api.login(
username: username,
password: password,
responseErrorHandler: serverErrorResponse.storeError,
);
if (success != null) {
authenticatedUser = model.AuthenticatedUser(
server: server,
@ -317,8 +326,10 @@ class _AddUserDialog extends HookConsumerWidget {
ref.read(authenticatedUserProvider.notifier).addUser(authenticatedUser);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Login failed. Please check your credentials.'),
SnackBar(
content: Text(
'Login failed. Got response: ${serverErrorResponse.response.body} (${serverErrorResponse.response.statusCode})',
),
),
);
}
@ -326,7 +337,23 @@ class _AddUserDialog extends HookConsumerWidget {
}
return AlertDialog(
title: const Text('Add User'),
// title: Text('Add User for ${server.serverUrl}'),
title: Text.rich(
TextSpan(
children: [
TextSpan(
text: 'Add User for ',
style: Theme.of(context).textTheme.labelLarge,
),
TextSpan(
text: server.serverUrl.toString(),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
content: Form(
key: formKey,
child: Column(