Improve handling of logout and token expiration
This commit is contained in:
parent
730de37b78
commit
5561f18a2c
6 changed files with 142 additions and 136 deletions
|
@ -14,8 +14,9 @@ class ClientHolder extends InheritedWidget {
|
||||||
super.key,
|
super.key,
|
||||||
required AuthenticationData authenticationData,
|
required AuthenticationData authenticationData,
|
||||||
required this.settingsClient,
|
required this.settingsClient,
|
||||||
required super.child
|
required super.child,
|
||||||
}) : apiClient = ApiClient(authenticationData: authenticationData);
|
required Function() onLogout,
|
||||||
|
}) : apiClient = ApiClient(authenticationData: authenticationData, onLogout: onLogout);
|
||||||
|
|
||||||
static ClientHolder? maybeOf(BuildContext context) {
|
static ClientHolder? maybeOf(BuildContext context) {
|
||||||
return context.dependOnInheritedWidgetOfExactType<ClientHolder>();
|
return context.dependOnInheritedWidgetOfExactType<ClientHolder>();
|
||||||
|
|
|
@ -18,10 +18,12 @@ class ApiClient {
|
||||||
static const String tokenKey = "token";
|
static const String tokenKey = "token";
|
||||||
static const String passwordKey = "password";
|
static const String passwordKey = "password";
|
||||||
|
|
||||||
ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData;
|
ApiClient({required AuthenticationData authenticationData, required this.onLogout}) : _authenticationData = authenticationData;
|
||||||
|
|
||||||
final AuthenticationData _authenticationData;
|
final AuthenticationData _authenticationData;
|
||||||
final Logger _logger = Logger("API");
|
final Logger _logger = Logger("API");
|
||||||
|
// Saving the context here feels kinda cringe ngl
|
||||||
|
final Function() onLogout;
|
||||||
|
|
||||||
AuthenticationData get authenticationData => _authenticationData;
|
AuthenticationData get authenticationData => _authenticationData;
|
||||||
String get userId => _authenticationData.userId;
|
String get userId => _authenticationData.userId;
|
||||||
|
@ -103,7 +105,7 @@ class ApiClient {
|
||||||
return AuthenticationData.unauthenticated();
|
return AuthenticationData.unauthenticated();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout(BuildContext context) async {
|
Future<void> logout() async {
|
||||||
const FlutterSecureStorage storage = FlutterSecureStorage(
|
const FlutterSecureStorage storage = FlutterSecureStorage(
|
||||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||||
);
|
);
|
||||||
|
@ -111,9 +113,7 @@ class ApiClient {
|
||||||
await storage.delete(key: machineIdKey);
|
await storage.delete(key: machineIdKey);
|
||||||
await storage.delete(key: tokenKey);
|
await storage.delete(key: tokenKey);
|
||||||
await storage.delete(key: passwordKey);
|
await storage.delete(key: passwordKey);
|
||||||
if (context.mounted) {
|
onLogout();
|
||||||
Phoenix.rebirth(context);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> extendSession() async {
|
Future<void> extendSession() async {
|
||||||
|
@ -127,7 +127,7 @@ class ApiClient {
|
||||||
if (response.statusCode == 403) {
|
if (response.statusCode == 403) {
|
||||||
tryCachedLogin().then((value) {
|
tryCachedLogin().then((value) {
|
||||||
if (!value.isAuthenticated) {
|
if (!value.isAuthenticated) {
|
||||||
// TODO: Turn api-client into a change notifier to present login screen when logged out
|
onLogout();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -138,7 +138,7 @@ class ApiClient {
|
||||||
if (response.statusCode < 300) return;
|
if (response.statusCode < 300) return;
|
||||||
|
|
||||||
final error = "${switch (response.statusCode) {
|
final error = "${switch (response.statusCode) {
|
||||||
429 => "Sorry, you are being rate limited.",
|
429 => "You are being rate limited.",
|
||||||
403 => "You are not authorized to do that.",
|
403 => "You are not authorized to do that.",
|
||||||
404 => "Resource not found.",
|
404 => "Resource not found.",
|
||||||
500 => "Internal server error.",
|
500 => "Internal server error.",
|
||||||
|
|
|
@ -15,7 +15,6 @@ class SettingsClient {
|
||||||
final data = await _storage.read(key: _settingsKey);
|
final data = await _storage.read(key: _settingsKey);
|
||||||
if (data == null) return;
|
if (data == null) return;
|
||||||
_currentSettings = Settings.fromMap(jsonDecode(data));
|
_currentSettings = Settings.fromMap(jsonDecode(data));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> changeSettings(Settings newSettings) async {
|
Future<void> changeSettings(Settings newSettings) async {
|
||||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:developer';
|
||||||
|
|
||||||
import 'package:contacts_plus_plus/apis/github_api.dart';
|
import 'package:contacts_plus_plus/apis/github_api.dart';
|
||||||
import 'package:contacts_plus_plus/client_holder.dart';
|
import 'package:contacts_plus_plus/client_holder.dart';
|
||||||
|
import 'package:contacts_plus_plus/clients/api_client.dart';
|
||||||
import 'package:contacts_plus_plus/clients/messaging_client.dart';
|
import 'package:contacts_plus_plus/clients/messaging_client.dart';
|
||||||
import 'package:contacts_plus_plus/clients/settings_client.dart';
|
import 'package:contacts_plus_plus/clients/settings_client.dart';
|
||||||
import 'package:contacts_plus_plus/models/sem_ver.dart';
|
import 'package:contacts_plus_plus/models/sem_ver.dart';
|
||||||
|
@ -26,14 +27,20 @@ void main() async {
|
||||||
Logger.root.onRecord.listen((event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time));
|
Logger.root.onRecord.listen((event) => log("${dateFormat.format(event.time)}: ${event.message}", name: event.loggerName, time: event.time));
|
||||||
final settingsClient = SettingsClient();
|
final settingsClient = SettingsClient();
|
||||||
await settingsClient.loadSettings();
|
await settingsClient.loadSettings();
|
||||||
await settingsClient.changeSettings(settingsClient.currentSettings); // Save generated defaults to disk
|
final newSettings = settingsClient.currentSettings.copyWith(machineId: settingsClient.currentSettings.machineId.valueOrDefault);
|
||||||
runApp(Phoenix(child: ContactsPlusPlus(settingsClient: settingsClient,)));
|
await settingsClient.changeSettings(newSettings); // Save generated machineId to disk
|
||||||
|
AuthenticationData cachedAuth = AuthenticationData.unauthenticated();
|
||||||
|
try {
|
||||||
|
cachedAuth = await ApiClient.tryCachedLogin();
|
||||||
|
} catch (_) {}
|
||||||
|
runApp(ContactsPlusPlus(settingsClient: settingsClient, cachedAuthentication: cachedAuth));
|
||||||
}
|
}
|
||||||
|
|
||||||
class ContactsPlusPlus extends StatefulWidget {
|
class ContactsPlusPlus extends StatefulWidget {
|
||||||
const ContactsPlusPlus({required this.settingsClient, super.key});
|
const ContactsPlusPlus({required this.settingsClient, required this.cachedAuthentication, super.key});
|
||||||
|
|
||||||
final SettingsClient settingsClient;
|
final SettingsClient settingsClient;
|
||||||
|
final AuthenticationData cachedAuthentication;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ContactsPlusPlus> createState() => _ContactsPlusPlusState();
|
State<ContactsPlusPlus> createState() => _ContactsPlusPlusState();
|
||||||
|
@ -41,7 +48,7 @@ class ContactsPlusPlus extends StatefulWidget {
|
||||||
|
|
||||||
class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
|
class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
|
||||||
final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
|
final Typography _typography = Typography.material2021(platform: TargetPlatform.android);
|
||||||
AuthenticationData _authData = AuthenticationData.unauthenticated();
|
late AuthenticationData _authData = widget.cachedAuthentication;
|
||||||
bool _checkedForUpdate = false;
|
bool _checkedForUpdate = false;
|
||||||
|
|
||||||
void showUpdateDialogOnFirstBuild(BuildContext context) {
|
void showUpdateDialogOnFirstBuild(BuildContext context) {
|
||||||
|
@ -95,43 +102,55 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ClientHolder(
|
return Phoenix(
|
||||||
settingsClient: widget.settingsClient,
|
child: Builder(
|
||||||
authenticationData: _authData,
|
builder: (context) {
|
||||||
child: DynamicColorBuilder(
|
return ClientHolder(
|
||||||
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp(
|
settingsClient: widget.settingsClient,
|
||||||
debugShowCheckedModeBanner: false,
|
authenticationData: _authData,
|
||||||
title: 'Contacts++',
|
onLogout: () {
|
||||||
theme: ThemeData(
|
setState(() {
|
||||||
useMaterial3: true,
|
_authData = AuthenticationData.unauthenticated();
|
||||||
textTheme: _typography.white,
|
});
|
||||||
colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark),
|
Phoenix.rebirth(context);
|
||||||
),
|
},
|
||||||
home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder
|
child: DynamicColorBuilder(
|
||||||
builder: (context) {
|
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) => MaterialApp(
|
||||||
showUpdateDialogOnFirstBuild(context);
|
debugShowCheckedModeBanner: false,
|
||||||
final clientHolder = ClientHolder.of(context);
|
title: 'Contacts++',
|
||||||
return _authData.isAuthenticated ?
|
theme: ThemeData(
|
||||||
ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime.
|
useMaterial3: true,
|
||||||
create: (context) =>
|
textTheme: _typography.white,
|
||||||
MessagingClient(
|
colorScheme: darkDynamic ?? ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark),
|
||||||
apiClient: clientHolder.apiClient,
|
),
|
||||||
notificationClient: clientHolder.notificationClient,
|
home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder
|
||||||
),
|
builder: (context) {
|
||||||
child: const FriendsList(),
|
showUpdateDialogOnFirstBuild(context);
|
||||||
) :
|
final clientHolder = ClientHolder.of(context);
|
||||||
LoginScreen(
|
return _authData.isAuthenticated ?
|
||||||
onLoginSuccessful: (AuthenticationData authData) async {
|
ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime.
|
||||||
if (authData.isAuthenticated) {
|
create: (context) =>
|
||||||
setState(() {
|
MessagingClient(
|
||||||
_authData = authData;
|
apiClient: clientHolder.apiClient,
|
||||||
});
|
notificationClient: clientHolder.notificationClient,
|
||||||
|
),
|
||||||
|
child: const FriendsList(),
|
||||||
|
) :
|
||||||
|
LoginScreen(
|
||||||
|
onLoginSuccessful: (AuthenticationData authData) async {
|
||||||
|
if (authData.isAuthenticated) {
|
||||||
|
setState(() {
|
||||||
|
_authData = authData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
)
|
||||||
);
|
),
|
||||||
}
|
),
|
||||||
)
|
);
|
||||||
),
|
}
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,12 +19,7 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||||
final TextEditingController _passwordController = TextEditingController();
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
final TextEditingController _totpController = TextEditingController();
|
final TextEditingController _totpController = TextEditingController();
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
late final Future<AuthenticationData> _cachedLoginFuture = ApiClient.tryCachedLogin().then((value) async {
|
|
||||||
if (value.isAuthenticated) {
|
|
||||||
await loginSuccessful(value);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
late final FocusNode _passwordFocusNode;
|
late final FocusNode _passwordFocusNode;
|
||||||
late final FocusNode _totpFocusNode;
|
late final FocusNode _totpFocusNode;
|
||||||
|
|
||||||
|
@ -150,102 +145,94 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text("Contacts++"),
|
title: const Text("Contacts++"),
|
||||||
),
|
),
|
||||||
body: FutureBuilder(
|
body: Builder(
|
||||||
future: _cachedLoginFuture,
|
builder: (context) {
|
||||||
builder: (context, snapshot) {
|
return ListView(
|
||||||
if (snapshot.hasData || snapshot.hasError) {
|
controller: _scrollController,
|
||||||
final authData = snapshot.data;
|
children: [
|
||||||
if (authData?.isAuthenticated ?? false) {
|
Padding(
|
||||||
return const SizedBox.shrink();
|
padding: const EdgeInsets.symmetric(vertical: 64),
|
||||||
}
|
child: Center(
|
||||||
return ListView(
|
child: Text("Sign In", style: Theme
|
||||||
controller: _scrollController,
|
.of(context)
|
||||||
children: [
|
.textTheme
|
||||||
Padding(
|
.headlineMedium),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 64),
|
),
|
||||||
child: Center(
|
),
|
||||||
child: Text("Sign In", style: Theme
|
Padding(
|
||||||
.of(context)
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
|
||||||
.textTheme
|
child: TextField(
|
||||||
.headlineMedium),
|
autofocus: true,
|
||||||
|
controller: _usernameController,
|
||||||
|
onEditingComplete: () => _passwordFocusNode.requestFocus(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(32),
|
||||||
|
),
|
||||||
|
labelText: 'Username',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
|
||||||
|
child: TextField(
|
||||||
|
controller: _passwordController,
|
||||||
|
focusNode: _passwordFocusNode,
|
||||||
|
onEditingComplete: submit,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(32)
|
||||||
|
),
|
||||||
|
labelText: 'Password',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_needsTotp)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
autofocus: true,
|
controller: _totpController,
|
||||||
controller: _usernameController,
|
focusNode: _totpFocusNode,
|
||||||
onEditingComplete: () => _passwordFocusNode.requestFocus(),
|
onEditingComplete: submit,
|
||||||
|
obscureText: false,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
|
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(32),
|
borderRadius: BorderRadius.circular(32),
|
||||||
),
|
),
|
||||||
labelText: 'Username',
|
labelText: '2FA Code',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
|
padding: const EdgeInsets.only(top: 16),
|
||||||
child: TextField(
|
child: _isLoading ?
|
||||||
controller: _passwordController,
|
const Center(child: CircularProgressIndicator()) :
|
||||||
focusNode: _passwordFocusNode,
|
TextButton.icon(
|
||||||
onEditingComplete: submit,
|
onPressed: submit,
|
||||||
obscureText: true,
|
icon: const Icon(Icons.login),
|
||||||
decoration: InputDecoration(
|
label: const Text("Login"),
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(32)
|
|
||||||
),
|
|
||||||
labelText: 'Password',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (_needsTotp)
|
),
|
||||||
Padding(
|
Center(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _errorOpacity,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
|
||||||
child: TextField(
|
child: Text(_error, style: Theme
|
||||||
controller: _totpController,
|
.of(context)
|
||||||
focusNode: _totpFocusNode,
|
.textTheme
|
||||||
onEditingComplete: submit,
|
.labelMedium
|
||||||
obscureText: false,
|
?.copyWith(color: Colors.red)),
|
||||||
decoration: InputDecoration(
|
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 24),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(32),
|
|
||||||
),
|
|
||||||
labelText: '2FA Code',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 16),
|
|
||||||
child: _isLoading ?
|
|
||||||
const Center(child: CircularProgressIndicator()) :
|
|
||||||
TextButton.icon(
|
|
||||||
onPressed: submit,
|
|
||||||
icon: const Icon(Icons.login),
|
|
||||||
label: const Text("Login"),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Center(
|
)
|
||||||
child: AnimatedOpacity(
|
],
|
||||||
opacity: _errorOpacity,
|
);
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 64),
|
|
||||||
child: Text(_error, style: Theme
|
|
||||||
.of(context)
|
|
||||||
.textTheme
|
|
||||||
.labelMedium
|
|
||||||
?.copyWith(color: Colors.red)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const LinearProgressIndicator();
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -44,7 +44,7 @@ class SettingsPage extends StatelessWidget {
|
||||||
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text("No")),
|
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text("No")),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await ClientHolder.of(context).apiClient.logout(context);
|
await ClientHolder.of(context).apiClient.logout();
|
||||||
},
|
},
|
||||||
child: const Text("Yes"),
|
child: const Text("Yes"),
|
||||||
),
|
),
|
||||||
|
|
Loading…
Reference in a new issue