feat: added status bar for auth states (#954)
This commit is contained in:
parent
97d4477c69
commit
6ee7bdb443
17 changed files with 617 additions and 78 deletions
5
.changeset/invalid_refresh_token_prompt.md
Normal file
5
.changeset/invalid_refresh_token_prompt.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
default: patch
|
||||
---
|
||||
|
||||
# Added status bar showing up if a player is logged out or he has a stale refresh token
|
||||
|
|
@ -91,7 +91,7 @@ pub use markdown::{Markdown, MarkdownStyle};
|
|||
pub use memory_field::memory_field;
|
||||
pub use microsoft_java_prompt::MicrosoftJavaPromptOverlay;
|
||||
pub(crate) use microsoft_login::login_dialog;
|
||||
pub use microsoft_login::use_microsoft_login;
|
||||
pub use microsoft_login::{MicrosoftLogin, use_microsoft_login};
|
||||
pub use modpack_confirm_popup::ModpackConfirmPopup;
|
||||
pub(crate) use navbar::window_controls;
|
||||
pub use navbar::{AppNavbar, OnboardingNavbar};
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@ use std::collections::HashSet;
|
|||
|
||||
use freya::animation::{AnimNum, Ease, Function, OnCreation, use_animation};
|
||||
use freya::prelude::*;
|
||||
use freya::router::RouterContext;
|
||||
use oneclient_net::status::{self, ServiceStatus};
|
||||
|
||||
use crate::components::{Icon, IconType};
|
||||
use crate::hooks::{try_default_account, use_current_account};
|
||||
use crate::routes::Route;
|
||||
use crate::theme::colors;
|
||||
|
||||
const BAR_HEIGHT: f32 = 34.;
|
||||
|
|
@ -18,32 +21,48 @@ const BAR_LAYER: u8 = 14;
|
|||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
enum Issue {
|
||||
NoInternet,
|
||||
SignedOut,
|
||||
McAuthDown,
|
||||
PolyfrostDown,
|
||||
}
|
||||
|
||||
/// What the bar reacts to the network probe plus whether the active account
|
||||
/// lost its sign-in
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct Conditions {
|
||||
status: ServiceStatus,
|
||||
signed_out_as: Option<String>,
|
||||
}
|
||||
|
||||
impl Issue {
|
||||
fn is_active(self, s: &ServiceStatus) -> bool {
|
||||
fn is_active(self, c: &Conditions) -> bool {
|
||||
let s = &c.status;
|
||||
match self {
|
||||
Self::NoInternet => !s.online,
|
||||
Self::SignedOut => c.signed_out_as.is_some(),
|
||||
Self::McAuthDown => s.online && !s.mc_auth_up,
|
||||
Self::PolyfrostDown => s.online && !s.polyfrost_up,
|
||||
}
|
||||
}
|
||||
|
||||
fn message(self) -> &'static str {
|
||||
fn message(self, c: &Conditions) -> String {
|
||||
match self {
|
||||
Self::NoInternet => "No internet connection.",
|
||||
Self::NoInternet => "No internet connection.".to_string(),
|
||||
Self::SignedOut => format!(
|
||||
"You have been signed out of {}. Sign in again to keep playing.",
|
||||
c.signed_out_as.as_deref().unwrap_or("your account")
|
||||
),
|
||||
Self::McAuthDown => {
|
||||
"Minecraft authentication servers are unreachable. Logging in may fail."
|
||||
"Minecraft authentication servers are unreachable. Logging in may fail.".to_string()
|
||||
}
|
||||
Self::PolyfrostDown => "Polyfrost services are experiencing issues.",
|
||||
Self::PolyfrostDown => "Polyfrost services are experiencing issues.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn icon(self) -> IconType {
|
||||
match self {
|
||||
Self::NoInternet => IconType::Globe01,
|
||||
Self::SignedOut => IconType::Users01,
|
||||
Self::McAuthDown => IconType::AlertTriangle,
|
||||
Self::PolyfrostDown => IconType::AlertCircle,
|
||||
}
|
||||
|
|
@ -51,7 +70,7 @@ impl Issue {
|
|||
|
||||
fn background(self) -> Color {
|
||||
match self {
|
||||
Self::NoInternet => colors::danger(),
|
||||
Self::NoInternet | Self::SignedOut => colors::danger(),
|
||||
Self::McAuthDown | Self::PolyfrostDown => AMBER,
|
||||
}
|
||||
}
|
||||
|
|
@ -59,13 +78,23 @@ impl Issue {
|
|||
fn closeable(self) -> bool {
|
||||
!matches!(self, Self::NoInternet)
|
||||
}
|
||||
|
||||
/// Where pressing the message takes the user if anywhere
|
||||
fn route(self) -> Option<Route> {
|
||||
matches!(self, Self::SignedOut).then_some(Route::SettingsAccounts {})
|
||||
}
|
||||
}
|
||||
|
||||
fn active_issues(s: &ServiceStatus) -> Vec<Issue> {
|
||||
[Issue::NoInternet, Issue::McAuthDown, Issue::PolyfrostDown]
|
||||
.into_iter()
|
||||
.filter(|i| i.is_active(s))
|
||||
.collect()
|
||||
fn active_issues(c: &Conditions) -> Vec<Issue> {
|
||||
[
|
||||
Issue::NoInternet,
|
||||
Issue::SignedOut,
|
||||
Issue::McAuthDown,
|
||||
Issue::PolyfrostDown,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|i| i.is_active(c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
|
|
@ -75,6 +104,7 @@ impl Component for StatusBar {
|
|||
fn render(&self) -> impl IntoElement {
|
||||
let mut status = use_state(status::current);
|
||||
let mut dismissed = use_state(HashSet::<Issue>::new);
|
||||
let current_account = use_current_account();
|
||||
|
||||
use_hook(move || {
|
||||
status::request_recheck();
|
||||
|
|
@ -86,21 +116,35 @@ impl Component for StatusBar {
|
|||
});
|
||||
});
|
||||
|
||||
let conditions = move || Conditions {
|
||||
status: *status.read(),
|
||||
signed_out_as: try_default_account(¤t_account)
|
||||
.filter(|a| a.needs_sign_in())
|
||||
.map(|a| a.username),
|
||||
};
|
||||
|
||||
// A dismissal lasts only while its issue does so the next sign-out
|
||||
// shows the bar again
|
||||
use_side_effect(move || {
|
||||
let s = *status.read();
|
||||
let c = conditions();
|
||||
let cur = dismissed.peek().clone();
|
||||
let next: HashSet<Issue> = cur.iter().copied().filter(|i| i.is_active(&s)).collect();
|
||||
let next: HashSet<Issue> = cur.iter().copied().filter(|i| i.is_active(&c)).collect();
|
||||
if next != cur {
|
||||
dismissed.set(next);
|
||||
}
|
||||
});
|
||||
|
||||
let s = *status.read();
|
||||
let c = conditions();
|
||||
let dset = dismissed.read();
|
||||
let visible = active_issues(&s).into_iter().find(|i| !dset.contains(i));
|
||||
let visible = active_issues(&c).into_iter().find(|i| !dset.contains(i));
|
||||
|
||||
match visible {
|
||||
Some(issue) => StatusBanner { issue, dismissed }.into_element(),
|
||||
Some(issue) => StatusBanner {
|
||||
issue,
|
||||
text: issue.message(&c),
|
||||
dismissed,
|
||||
}
|
||||
.into_element(),
|
||||
None => rect().into_element(),
|
||||
}
|
||||
}
|
||||
|
|
@ -109,6 +153,7 @@ impl Component for StatusBar {
|
|||
#[derive(PartialEq)]
|
||||
struct StatusBanner {
|
||||
issue: Issue,
|
||||
text: String,
|
||||
dismissed: State<HashSet<Issue>>,
|
||||
}
|
||||
|
||||
|
|
@ -153,6 +198,35 @@ impl Component for StatusBanner {
|
|||
// to the top of the content box inside the bar's padding
|
||||
let leading = rect().width(Size::flex(1.0)).height(Size::fill());
|
||||
|
||||
let mut action_hover = use_state(|| false);
|
||||
|
||||
let action = issue.route().map(|route| {
|
||||
rect()
|
||||
.center()
|
||||
.height(Size::px(22.))
|
||||
.padding(Gaps::new_symmetric(0., 8.))
|
||||
.corner_radius(CornerRadius::new_all(6.))
|
||||
.background(if *action_hover.read() {
|
||||
Color::WHITE.with_a(46)
|
||||
} else {
|
||||
Color::WHITE.with_a(26)
|
||||
})
|
||||
.cursor(CursorIcon::Pointer)
|
||||
.on_pointer_enter(move |_| action_hover.set(true))
|
||||
.on_pointer_leave(move |_| action_hover.set(false))
|
||||
.on_press(move |_| {
|
||||
let _ = RouterContext::get().push(route.clone());
|
||||
})
|
||||
.child(
|
||||
label()
|
||||
.text("Sign in")
|
||||
.font_size(12.)
|
||||
.font_weight(FontWeight::SEMI_BOLD)
|
||||
.color(Color::WHITE),
|
||||
)
|
||||
.into_element()
|
||||
});
|
||||
|
||||
let message = rect()
|
||||
.horizontal()
|
||||
.height(Size::fill())
|
||||
|
|
@ -162,12 +236,13 @@ impl Component for StatusBanner {
|
|||
.child(Icon::new(issue.icon()).size(15.).color(Color::WHITE))
|
||||
.child(
|
||||
label()
|
||||
.text(issue.message())
|
||||
.text(self.text.clone())
|
||||
.font_size(12.)
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.max_lines(1)
|
||||
.color(Color::WHITE),
|
||||
);
|
||||
)
|
||||
.maybe_child(action);
|
||||
|
||||
let trailing = rect()
|
||||
.width(Size::flex(1.0))
|
||||
|
|
|
|||
|
|
@ -1503,6 +1503,24 @@ impl Actions {
|
|||
|
||||
/// `syncing_bundles` gates every launch button so the readiness check must
|
||||
/// happen before the flag is raised not inside the task
|
||||
/// Lets the signed-out bar show at startup instead of on the first launch
|
||||
pub fn check_sessions() {
|
||||
let state = match launcher::state() {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
tracing::error!("session check skipped, launcher not ready: {err:#}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
spawn_forever(async move {
|
||||
let signed_out = off_ui(async move { state.auth.check_expired_sessions().await }).await;
|
||||
if signed_out {
|
||||
super::invalidate_auth_queries(None).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn sync_bundles(&self) {
|
||||
let state = match launcher::state() {
|
||||
Ok(state) => state,
|
||||
|
|
@ -2078,6 +2096,12 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) {
|
|||
return;
|
||||
};
|
||||
|
||||
// The renewal may have just marked the account signed out the accounts
|
||||
// page and the status bar must pick that up while the game still starts
|
||||
if account.needs_sign_in() {
|
||||
super::invalidate_auth_queries(None).await;
|
||||
}
|
||||
|
||||
crate::microsoft_java::offer_for_pinned_cluster(actions, cluster_id).await;
|
||||
|
||||
let content = state.services.content();
|
||||
|
|
|
|||
|
|
@ -43,35 +43,35 @@ pub use queries::{
|
|||
accounts_have_microsoft, available_bundles, bundle_overrides_map, bundles_with_status_items,
|
||||
category_list, changelog_entries, changelog_error, changelog_is_loading, cluster_art_url,
|
||||
cluster_content_items, content_type_for_slug, disable_warnings, game_versions,
|
||||
has_migration_data, invalidate_cluster_content_queries, invalidate_cluster_queries,
|
||||
invalidate_java_queries, invalidate_leftovers_queries, invalidate_logs_queries,
|
||||
invalidate_profile_queries, invalidate_screenshots_queries, invalidate_storage_queries,
|
||||
java_majors, java_runtimes, latest_changelog_version, loaded_image, loader_versions,
|
||||
login_code_already_handled, migration_detection, mutation_error, mutation_is_pending,
|
||||
mutation_is_running, mutation_ok, onboarding_bundles_items, package_meta_batch,
|
||||
package_updates, pick_version_metadata, project_detail, provider_versions, query_error,
|
||||
query_is_busy, query_is_loading, refresh_version_art_gallery, reset_login_code_dedup,
|
||||
resolve_art_url, search_items, search_pending, search_total, settled_or_loading, stale_hashes,
|
||||
terms_document, terms_error, terms_is_loading, try_account, try_accounts,
|
||||
try_cluster_analytics, try_cluster_logs, try_cluster_screenshots, try_default_account,
|
||||
try_game_profile, try_global_analytics, try_leftovers, try_log_content, try_storage_report,
|
||||
use_account, use_accounts, use_add_microsoft_account, use_add_offline_account,
|
||||
use_available_bundles, use_begin_microsoft_login, use_bundle_overrides, use_bundle_updates,
|
||||
use_bundles_with_status, use_cached_image, use_cancel_microsoft_login, use_changelog,
|
||||
use_cluster, use_cluster_analytics, use_cluster_content, use_cluster_logs,
|
||||
use_cluster_mutation, use_cluster_profile, use_cluster_screenshots, use_cluster_settings,
|
||||
use_clusters, use_current_account, use_default_account, use_disable_warnings,
|
||||
use_discard_leftovers, use_finish_microsoft_login, use_game_profile, use_game_versions,
|
||||
use_global_analytics, use_java_majors, use_java_runtimes, use_leftovers, use_loader_versions,
|
||||
use_local_image, use_log_action, use_log_content, use_migratable_routes, use_migration,
|
||||
use_mods_folder_sync, use_named_profiles, use_onboarding_bundles, use_package_categories,
|
||||
use_package_meta_batch, use_package_project, use_package_search, use_package_updates,
|
||||
use_package_versions_when, use_player_profile, use_player_skin, use_provider_versions,
|
||||
use_refresh_account, use_refresh_all_accounts, use_remove_account, use_screenshot_action,
|
||||
use_screenshot_folder_watch, use_set_default_account, use_shadowed_mods, use_storage_action,
|
||||
use_storage_report, use_terms, use_upload_log, use_version_art, use_version_art_gallery,
|
||||
use_version_loaders, use_version_metadata, use_versions, version_list, versions_metadata,
|
||||
versions_total,
|
||||
has_migration_data, invalidate_auth_queries, invalidate_cluster_content_queries,
|
||||
invalidate_cluster_queries, invalidate_java_queries, invalidate_leftovers_queries,
|
||||
invalidate_logs_queries, invalidate_profile_queries, invalidate_screenshots_queries,
|
||||
invalidate_storage_queries, java_majors, java_runtimes, latest_changelog_version, loaded_image,
|
||||
loader_versions, login_code_already_handled, migration_detection, mutation_error,
|
||||
mutation_is_pending, mutation_is_running, mutation_ok, onboarding_bundles_items,
|
||||
package_meta_batch, package_updates, pick_version_metadata, project_detail, provider_versions,
|
||||
query_error, query_is_busy, query_is_loading, refresh_version_art_gallery,
|
||||
reset_login_code_dedup, resolve_art_url, search_items, search_pending, search_total,
|
||||
settled_or_loading, stale_hashes, terms_document, terms_error, terms_is_loading, try_account,
|
||||
try_accounts, try_cluster_analytics, try_cluster_logs, try_cluster_screenshots,
|
||||
try_default_account, try_game_profile, try_global_analytics, try_leftovers, try_log_content,
|
||||
try_storage_report, use_account, use_accounts, use_add_microsoft_account,
|
||||
use_add_offline_account, use_available_bundles, use_begin_microsoft_login,
|
||||
use_bundle_overrides, use_bundle_updates, use_bundles_with_status, use_cached_image,
|
||||
use_cancel_microsoft_login, use_changelog, use_cluster, use_cluster_analytics,
|
||||
use_cluster_content, use_cluster_logs, use_cluster_mutation, use_cluster_profile,
|
||||
use_cluster_screenshots, use_cluster_settings, use_clusters, use_current_account,
|
||||
use_default_account, use_disable_warnings, use_discard_leftovers, use_finish_microsoft_login,
|
||||
use_game_profile, use_game_versions, use_global_analytics, use_java_majors, use_java_runtimes,
|
||||
use_leftovers, use_loader_versions, use_local_image, use_log_action, use_log_content,
|
||||
use_migratable_routes, use_migration, use_mods_folder_sync, use_named_profiles,
|
||||
use_onboarding_bundles, use_package_categories, use_package_meta_batch, use_package_project,
|
||||
use_package_search, use_package_updates, use_package_versions_when, use_player_profile,
|
||||
use_player_skin, use_provider_versions, use_refresh_account, use_refresh_all_accounts,
|
||||
use_remove_account, use_screenshot_action, use_screenshot_folder_watch,
|
||||
use_set_default_account, use_shadowed_mods, use_storage_action, use_storage_report, use_terms,
|
||||
use_upload_log, use_version_art, use_version_art_gallery, use_version_loaders,
|
||||
use_version_metadata, use_versions, version_list, versions_metadata, versions_total,
|
||||
};
|
||||
pub use queries::{
|
||||
add_world_datapacks, delete_world, delete_world_datapack, invalidate_world_contents,
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ pub fn try_account(query: &UseQuery<AccountQuery>) -> Option<MinecraftAccount> {
|
|||
super::state::settled_or_loading(query).flatten()
|
||||
}
|
||||
|
||||
async fn invalidate_auth_queries(account_id: Option<Uuid>) {
|
||||
pub async fn invalidate_auth_queries(account_id: Option<Uuid>) {
|
||||
QueriesStorage::<ListAccountsQuery>::invalidate_matching(ListAccountsKeys).await;
|
||||
for fallback in [false, true] {
|
||||
QueriesStorage::<DefaultAccountQuery>::invalidate_matching(DefaultAccountKeys { fallback })
|
||||
|
|
@ -307,10 +307,10 @@ impl MutationCapability for RefreshAccountMutation {
|
|||
.await?)
|
||||
}
|
||||
|
||||
async fn on_settled(&self, keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
|
||||
if result.is_ok() {
|
||||
invalidate_auth_queries(Some(keys.id)).await;
|
||||
}
|
||||
/// A failure can still change the account a rejected token marks it
|
||||
/// signed out
|
||||
async fn on_settled(&self, keys: &Self::Keys, _result: &Result<Self::Ok, Self::Err>) {
|
||||
invalidate_auth_queries(Some(keys.id)).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -329,10 +329,8 @@ impl MutationCapability for RefreshAllAccountsMutation {
|
|||
.await?)
|
||||
}
|
||||
|
||||
async fn on_settled(&self, _keys: &Self::Keys, result: &Result<Self::Ok, Self::Err>) {
|
||||
if result.is_ok() {
|
||||
invalidate_auth_queries(None).await;
|
||||
}
|
||||
async fn on_settled(&self, _keys: &Self::Keys, _result: &Result<Self::Ok, Self::Err>) {
|
||||
invalidate_auth_queries(None).await;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -42,12 +42,13 @@ pub use auth::{
|
|||
AddOfflineAccountKeys, BeginMicrosoftLoginMutation, CancelMicrosoftLoginKeys,
|
||||
CancelMicrosoftLoginMutation, FinishMicrosoftLoginMutation, RefreshAccountKeys,
|
||||
RemoveAccountKeys, SetDefaultAccountKeys, UseRefreshAccount, UseRemoveAccount,
|
||||
UseSetDefaultAccount, accounts_have_microsoft, login_code_already_handled, mutation_error,
|
||||
mutation_is_pending, mutation_is_running, mutation_ok, reset_login_code_dedup, try_account,
|
||||
try_accounts, try_default_account, use_account, use_accounts, use_add_microsoft_account,
|
||||
use_add_offline_account, use_begin_microsoft_login, use_cancel_microsoft_login,
|
||||
use_current_account, use_default_account, use_finish_microsoft_login, use_refresh_account,
|
||||
use_refresh_all_accounts, use_remove_account, use_set_default_account,
|
||||
UseSetDefaultAccount, accounts_have_microsoft, invalidate_auth_queries,
|
||||
login_code_already_handled, mutation_error, mutation_is_pending, mutation_is_running,
|
||||
mutation_ok, reset_login_code_dedup, try_account, try_accounts, try_default_account,
|
||||
use_account, use_accounts, use_add_microsoft_account, use_add_offline_account,
|
||||
use_begin_microsoft_login, use_cancel_microsoft_login, use_current_account,
|
||||
use_default_account, use_finish_microsoft_login, use_refresh_account, use_refresh_all_accounts,
|
||||
use_remove_account, use_set_default_account,
|
||||
};
|
||||
pub use bundles::{
|
||||
AvailableBundlesQuery, ClusterBundles, OnboardingBundlesQuery, available_bundles,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ impl App for OneClientApp {
|
|||
// it early leaves `syncing_bundles` stuck disabling every launch button
|
||||
Ok(()) => {
|
||||
startup.sync_bundles();
|
||||
Actions::check_sessions();
|
||||
microsoft_java::spawn_auto_install();
|
||||
}
|
||||
Err(err) => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ use oneclient_net::status::{self, ServiceStatus};
|
|||
|
||||
use crate::Actions;
|
||||
use crate::components::{Button, Dropdown, Icon, IconType, TextInput, login_dialog, toggle};
|
||||
use crate::hooks::{settled_or_loading, use_active_cluster_id, use_clusters, use_dispatch};
|
||||
use crate::hooks::{
|
||||
invalidate_auth_queries, settled_or_loading, try_default_account, use_active_cluster_id,
|
||||
use_clusters, use_current_account, use_dispatch,
|
||||
};
|
||||
use crate::notifications::{
|
||||
ClusterUpdateItem, ClusterUpdateSummary, NotificationAction, NotificationActionKind,
|
||||
OptionalModsGroup,
|
||||
|
|
@ -97,6 +100,11 @@ impl Component for Debug {
|
|||
vec![AuthGuidancePreview.into_element()],
|
||||
))
|
||||
.child(divider())
|
||||
.child(section(
|
||||
"Session Expiry",
|
||||
vec![SessionExpirySimulator.into_element()],
|
||||
))
|
||||
.child(divider())
|
||||
.child(section(
|
||||
"Corruption Simulator",
|
||||
vec![CorruptionSimulator.into_element()],
|
||||
|
|
@ -936,6 +944,121 @@ impl Component for AuthGuidancePreview {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
struct SessionExpirySimulator;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ExpiryKind {
|
||||
/// Skips the network and flips the account straight to signed out
|
||||
SignedOut,
|
||||
/// Leaves detection to Microsoft on the next launch
|
||||
RejectedToken,
|
||||
}
|
||||
|
||||
impl Component for SessionExpirySimulator {
|
||||
fn render(&self) -> impl IntoElement {
|
||||
let dispatch = use_dispatch();
|
||||
let current = use_current_account();
|
||||
|
||||
let account = try_default_account(¤t).filter(|a| a.is_microsoft());
|
||||
let target = account.as_ref().map(|a| (a.id, a.username.clone()));
|
||||
let usable = account.as_ref().is_some_and(|a| !a.needs_sign_in());
|
||||
|
||||
let description = match &account {
|
||||
Some(a) if a.needs_sign_in() => format!("{} is already signed out.", a.username),
|
||||
Some(a) => format!("Acts on the active account, {}.", a.username),
|
||||
None => "The active account must be a Microsoft account.".to_string(),
|
||||
};
|
||||
|
||||
let button = |text: &'static str, icon: IconType, kind: ExpiryKind| {
|
||||
let dispatch = dispatch.clone();
|
||||
let target = target.clone();
|
||||
Button::new()
|
||||
.danger()
|
||||
.enabled(usable)
|
||||
.child(Icon::new(icon).size(16.))
|
||||
.text(text)
|
||||
.on_press(move |_| {
|
||||
if let Some((id, username)) = target.clone() {
|
||||
run_expiry(&dispatch, kind, id, username);
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::fill())
|
||||
.spacing(10.)
|
||||
.child(
|
||||
label()
|
||||
.text(description)
|
||||
.font_size(13.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
.width(Size::fill())
|
||||
.spacing(8.)
|
||||
.child(button(
|
||||
"Force signed out",
|
||||
IconType::AlertTriangle,
|
||||
ExpiryKind::SignedOut,
|
||||
))
|
||||
.child(button(
|
||||
"Invalidate refresh token",
|
||||
IconType::RefreshCw01,
|
||||
ExpiryKind::RejectedToken,
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
label()
|
||||
.text("Invalidate keeps the account looking fine until the next launch, where Microsoft answers invalid_grant.")
|
||||
.font_size(11.)
|
||||
.color(colors::fg_secondary()),
|
||||
)
|
||||
.into_element()
|
||||
}
|
||||
}
|
||||
|
||||
fn run_expiry(dispatch: &crate::Actions, kind: ExpiryKind, id: uuid::Uuid, username: String) {
|
||||
let dispatch = dispatch.clone();
|
||||
spawn(async move {
|
||||
let result = match crate::launcher::state() {
|
||||
Ok(state) => match kind {
|
||||
ExpiryKind::SignedOut => state.auth.mark_signed_out(id).await,
|
||||
ExpiryKind::RejectedToken => state.auth.corrupt_refresh_token(id).await,
|
||||
}
|
||||
.map_err(|err| err.to_string()),
|
||||
Err(err) => Err(err.to_string()),
|
||||
};
|
||||
invalidate_auth_queries(Some(id)).await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let body = match kind {
|
||||
ExpiryKind::SignedOut => format!("{username} is now signed out"),
|
||||
ExpiryKind::RejectedToken => {
|
||||
format!("{username} will be rejected on the next launch")
|
||||
}
|
||||
};
|
||||
dispatch
|
||||
.notify("Session expiry simulated")
|
||||
.body(body)
|
||||
.icon(IconType::AlertTriangle)
|
||||
.send();
|
||||
}
|
||||
Err(err) => {
|
||||
dispatch
|
||||
.notify("Simulation failed")
|
||||
.body(err)
|
||||
.error()
|
||||
.send();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Presets are grouped by which layer should catch them same-length corruption only the hashing pass finds truncation the launch-time size check
|
||||
#[derive(PartialEq)]
|
||||
struct CorruptionSimulator;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ use uuid::Uuid;
|
|||
|
||||
use super::{section_header, settings_page};
|
||||
use crate::components::{
|
||||
Avatar, Button, Icon, IconType, OverlayPopup, PlayerModel, TextInput, use_microsoft_login,
|
||||
Avatar, Button, Icon, IconType, MicrosoftLogin, OverlayPopup, PlayerModel, TextInput,
|
||||
use_microsoft_login,
|
||||
};
|
||||
use crate::hooks::{
|
||||
AddOfflineAccountKeys, RefreshAccountKeys, RemoveAccountKeys, SetDefaultAccountKeys,
|
||||
|
|
@ -87,10 +88,12 @@ impl Component for SettingsAccounts {
|
|||
username: account.username.clone(),
|
||||
kind: account.kind,
|
||||
expires: account.expires,
|
||||
signed_out: account.needs_sign_in(),
|
||||
is_default: Some(account.id) == default_id,
|
||||
set_default,
|
||||
remove,
|
||||
refresh,
|
||||
sign_in: msa.clone(),
|
||||
}
|
||||
.into_element()
|
||||
})
|
||||
|
|
@ -136,6 +139,10 @@ fn hero(
|
|||
on_add_microsoft: impl FnMut(Event<PressEventData>) + 'static,
|
||||
) -> impl IntoElement {
|
||||
let (name, subtitle) = match &account {
|
||||
Some(account) if account.needs_sign_in() => (
|
||||
account.username.clone(),
|
||||
"Signed out. Sign in again to play",
|
||||
),
|
||||
Some(account) => (account.username.clone(), kind_label(account.kind)),
|
||||
None => (
|
||||
"No active account".to_string(),
|
||||
|
|
@ -396,15 +403,20 @@ const REFRESH_SPIN_TIME: u64 = 800;
|
|||
|
||||
const REFRESHING_OPACITY: f32 = 0.7;
|
||||
|
||||
/// Only the identity half fades the sign-in and remove buttons stay crisp
|
||||
const SIGNED_OUT_OPACITY: f32 = 0.45;
|
||||
|
||||
struct AccountRow {
|
||||
id: Uuid,
|
||||
username: String,
|
||||
kind: AccountKind,
|
||||
expires: DateTime<Utc>,
|
||||
signed_out: bool,
|
||||
is_default: bool,
|
||||
set_default: crate::hooks::UseSetDefaultAccount,
|
||||
remove: crate::hooks::UseRemoveAccount,
|
||||
refresh: crate::hooks::UseRefreshAccount,
|
||||
sign_in: MicrosoftLogin,
|
||||
}
|
||||
|
||||
impl PartialEq for AccountRow {
|
||||
|
|
@ -413,7 +425,9 @@ impl PartialEq for AccountRow {
|
|||
&& self.username == other.username
|
||||
&& self.kind == other.kind
|
||||
&& self.expires == other.expires
|
||||
&& self.signed_out == other.signed_out
|
||||
&& self.is_default == other.is_default
|
||||
&& self.sign_in.pending == other.sign_in.pending
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -430,7 +444,11 @@ impl Component for AccountRow {
|
|||
let refresh = self.refresh;
|
||||
|
||||
let is_microsoft = self.kind == AccountKind::Microsoft;
|
||||
let expired = is_microsoft && self.expires <= Utc::now();
|
||||
let signed_out = is_microsoft && self.signed_out;
|
||||
// A signed-out entry has no token left to call expired
|
||||
let expired = is_microsoft && !signed_out && self.expires <= Utc::now();
|
||||
let identity_opacity = if signed_out { SIGNED_OUT_OPACITY } else { 1. };
|
||||
let sign_in = self.sign_in.clone();
|
||||
|
||||
let mut refreshing = use_state(|| false);
|
||||
let is_refreshing = *refreshing.read();
|
||||
|
|
@ -457,7 +475,9 @@ impl Component for AccountRow {
|
|||
0.
|
||||
};
|
||||
|
||||
let border_color = if expired {
|
||||
let border_color = if signed_out {
|
||||
colors::component_border()
|
||||
} else if expired {
|
||||
colors::danger()
|
||||
} else if is_default {
|
||||
colors::brand()
|
||||
|
|
@ -481,19 +501,22 @@ impl Component for AccountRow {
|
|||
1.
|
||||
})
|
||||
.a11y_role(AccessibilityRole::Button)
|
||||
.maybe(!is_default, |el| {
|
||||
.maybe(!is_default && !signed_out, |el| {
|
||||
el.on_press(move |_| set_default.mutate(SetDefaultAccountKeys { id: Some(id) }))
|
||||
})
|
||||
.child(
|
||||
Avatar::new(id.to_string())
|
||||
.width(Size::px(AVATAR_SIZE_PX))
|
||||
.height(Size::px(AVATAR_SIZE_PX)),
|
||||
rect().opacity(identity_opacity).child(
|
||||
Avatar::new(id.to_string())
|
||||
.width(Size::px(AVATAR_SIZE_PX))
|
||||
.height(Size::px(AVATAR_SIZE_PX)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
rect()
|
||||
.vertical()
|
||||
.width(Size::flex(1.0))
|
||||
.spacing(4.)
|
||||
.opacity(identity_opacity)
|
||||
.child(
|
||||
rect()
|
||||
.horizontal()
|
||||
|
|
@ -508,18 +531,37 @@ impl Component for AccountRow {
|
|||
.color(colors::fg_primary()),
|
||||
)
|
||||
.maybe_child(is_default.then(default_badge))
|
||||
.maybe_child(expired.then(expired_badge)),
|
||||
.maybe_child(expired.then(expired_badge))
|
||||
.maybe_child(signed_out.then(signed_out_badge)),
|
||||
)
|
||||
// The kind rides with the id a third badge and a full uuid do not both fit
|
||||
.child(
|
||||
label()
|
||||
.text(format!("{} · {id}", kind_label(self.kind)))
|
||||
.text(if signed_out {
|
||||
"Session expired. Sign in again to use this account".to_string()
|
||||
} else {
|
||||
format!("{} · {id}", kind_label(self.kind))
|
||||
})
|
||||
.font_size(11.)
|
||||
.max_lines(1)
|
||||
.color(colors::fg_secondary()),
|
||||
),
|
||||
)
|
||||
.maybe_child(is_microsoft.then(|| {
|
||||
.maybe_child(signed_out.then(|| {
|
||||
let pending = sign_in.pending;
|
||||
Button::new()
|
||||
.secondary()
|
||||
.enabled(!pending)
|
||||
.tooltip("Sign in to this Microsoft account again")
|
||||
.on_press(move |e: Event<PressEventData>| {
|
||||
e.stop_propagation();
|
||||
sign_in.start();
|
||||
})
|
||||
.child(Icon::new(IconType::Globe01).size(16.))
|
||||
.text(if pending { "Signing in..." } else { "Sign in" })
|
||||
.into_element()
|
||||
}))
|
||||
.maybe_child((is_microsoft && !signed_out).then(|| {
|
||||
Button::new()
|
||||
.ghost()
|
||||
.icon()
|
||||
|
|
@ -610,6 +652,18 @@ fn expired_badge() -> impl IntoElement {
|
|||
)
|
||||
}
|
||||
|
||||
fn signed_out_badge() -> impl IntoElement {
|
||||
badge(
|
||||
Icon::new(IconType::AlertTriangle)
|
||||
.size(12.)
|
||||
.color(colors::fg_secondary())
|
||||
.into_element(),
|
||||
"Signed out".to_string(),
|
||||
colors::component_border(),
|
||||
colors::fg_secondary(),
|
||||
)
|
||||
}
|
||||
|
||||
fn badge(icon: impl IntoElement, text: String, border: Color, fg: Color) -> impl IntoElement {
|
||||
rect()
|
||||
.horizontal()
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@ pub struct MinecraftAccount {
|
|||
pub expires: DateTime<Utc>,
|
||||
#[serde(default = "default_account_kind")]
|
||||
pub kind: AccountKind,
|
||||
/// Microsoft rejected the refresh token the entry stays listed but cannot
|
||||
/// launch until the user signs in to it again
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub signed_out: bool,
|
||||
}
|
||||
|
||||
fn default_account_kind() -> AccountKind {
|
||||
|
|
@ -33,6 +37,10 @@ impl MinecraftAccount {
|
|||
self.kind == AccountKind::Offline
|
||||
}
|
||||
|
||||
pub fn needs_sign_in(&self) -> bool {
|
||||
self.is_microsoft() && self.signed_out
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expires <= Utc::now() + chrono::TimeDelta::seconds(60)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ pub enum MinecraftAuthError {
|
|||
message: String,
|
||||
redirect: Option<String>,
|
||||
},
|
||||
/// Retrying cannot fix this only a fresh interactive sign-in can
|
||||
#[error("Microsoft rejected the saved sign-in ({error})")]
|
||||
RefreshTokenRevoked { error: String },
|
||||
#[error(
|
||||
"The sign-in service returned an error (HTTP {status_code}) during step {step:?}. Please wait a moment and try again."
|
||||
)]
|
||||
|
|
@ -131,6 +134,11 @@ pub enum AuthError {
|
|||
#[error("invalid offline username: {reason}")]
|
||||
InvalidOfflineUsername { reason: String },
|
||||
|
||||
#[error(
|
||||
"Your Microsoft sign-in for {username} has expired. Sign in to it again in Settings > Accounts."
|
||||
)]
|
||||
SessionExpired { username: String },
|
||||
|
||||
#[error("account {0} is not registered")]
|
||||
AccountNotFound(uuid::Uuid),
|
||||
|
||||
|
|
|
|||
|
|
@ -482,9 +482,30 @@ async fn refresh_msa_token(
|
|||
source,
|
||||
})?;
|
||||
|
||||
parse_json_response(res, MinecraftAuthStep::RefreshToken)
|
||||
let status = res.status();
|
||||
let text = res
|
||||
.text()
|
||||
.await
|
||||
.map(MsaToken::from_response)
|
||||
.map_err(|source| MinecraftAuthError::RequestError {
|
||||
step: MinecraftAuthStep::RefreshToken,
|
||||
source,
|
||||
})?;
|
||||
|
||||
if status.is_client_error()
|
||||
&& let Ok(err) = serde_json::from_str::<OAuthErrorResponse>(&text)
|
||||
&& is_revoked_refresh_error(&err.error)
|
||||
{
|
||||
tracing::warn!(error = %err.error, description = ?err.error_description, "Microsoft rejected the refresh token");
|
||||
return Err(MinecraftAuthError::RefreshTokenRevoked { error: err.error });
|
||||
}
|
||||
|
||||
parse_json_text(status, text, MinecraftAuthStep::RefreshToken).map(MsaToken::from_response)
|
||||
}
|
||||
|
||||
/// `invalid_grant` covers expired revoked and password-changed tokens
|
||||
/// `interaction_required` is MFA or consent neither can be answered silently
|
||||
fn is_revoked_refresh_error(error: &str) -> bool {
|
||||
matches!(error, "invalid_grant" | "interaction_required")
|
||||
}
|
||||
|
||||
struct MsaToken {
|
||||
|
|
@ -541,6 +562,7 @@ async fn account_from_msa_token(
|
|||
#[allow(clippy::cast_possible_wrap)]
|
||||
expires: msa.obtained_at + chrono::TimeDelta::seconds(msa.expires_in as i64),
|
||||
kind: AccountKind::Microsoft,
|
||||
signed_out: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -779,6 +801,14 @@ async fn parse_json_response<T: for<'de> Deserialize<'de>>(
|
|||
.await
|
||||
.map_err(|source| MinecraftAuthError::RequestError { step, source })?;
|
||||
|
||||
parse_json_text(status, text, step)
|
||||
}
|
||||
|
||||
fn parse_json_text<T: for<'de> Deserialize<'de>>(
|
||||
status: reqwest::StatusCode,
|
||||
text: String,
|
||||
step: MinecraftAuthStep,
|
||||
) -> Result<T, MinecraftAuthError> {
|
||||
if !status.is_success() {
|
||||
tracing::error!(sentry = false, ?step, %status, body = %text, "MSA endpoint returned a non-success status");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,5 +48,6 @@ pub fn offline_account(username: String) -> MinecraftAccount {
|
|||
refresh_token: String::new(),
|
||||
expires: Utc::now() + Duration::days(3650),
|
||||
kind: AccountKind::Offline,
|
||||
signed_out: false,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,6 +222,23 @@ impl AuthService {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Debug aid puts the account straight into the state a rejected refresh
|
||||
/// token leaves behind
|
||||
#[tracing::instrument(skip(self), fields(%id))]
|
||||
pub async fn mark_signed_out(&self, id: Uuid) -> AuthResult<()> {
|
||||
let guard = self.refresh_guard(id);
|
||||
let _serialised = guard.lock().await;
|
||||
self.store.lock().await.mark_signed_out(id).await
|
||||
}
|
||||
|
||||
/// Debug aid see [`CredentialsStore::corrupt_refresh_token`]
|
||||
#[tracing::instrument(skip(self), fields(%id))]
|
||||
pub async fn corrupt_refresh_token(&self, id: Uuid) -> AuthResult<()> {
|
||||
let guard = self.refresh_guard(id);
|
||||
let _serialised = guard.lock().await;
|
||||
self.store.lock().await.corrupt_refresh_token(id).await
|
||||
}
|
||||
|
||||
pub async fn has_microsoft_account(&self) -> bool {
|
||||
self.store.lock().await.has_microsoft_account()
|
||||
}
|
||||
|
|
@ -250,6 +267,11 @@ impl AuthService {
|
|||
#[tracing::instrument(level = "debug", skip(self), fields(%id))]
|
||||
async fn renew_token(&self, id: Uuid, force: bool) -> AuthResult<MinecraftAccount> {
|
||||
let existing = self.account_snapshot(id).await?;
|
||||
if existing.needs_sign_in() {
|
||||
return Err(AuthError::SessionExpired {
|
||||
username: existing.username,
|
||||
});
|
||||
}
|
||||
if !existing.is_microsoft() || (!force && !existing.is_expired()) {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
|
@ -259,6 +281,11 @@ impl AuthService {
|
|||
|
||||
// Re-read under the guard whoever held it may have just refreshed
|
||||
let existing = self.account_snapshot(id).await?;
|
||||
if existing.needs_sign_in() {
|
||||
return Err(AuthError::SessionExpired {
|
||||
username: existing.username,
|
||||
});
|
||||
}
|
||||
if !existing.is_microsoft() || (!force && !existing.is_expired()) {
|
||||
return Ok(existing);
|
||||
}
|
||||
|
|
@ -273,6 +300,13 @@ impl AuthService {
|
|||
.await?;
|
||||
Ok(refreshed)
|
||||
}
|
||||
Err(MinecraftAuthError::RefreshTokenRevoked { .. }) => {
|
||||
tracing::warn!(username = %existing.username, "refresh token rejected, marking account signed out");
|
||||
self.store.lock().await.mark_signed_out(id).await?;
|
||||
Err(AuthError::SessionExpired {
|
||||
username: existing.username,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
let err = AuthError::from(err);
|
||||
if store::is_transient_auth_error(&err) {
|
||||
|
|
@ -290,21 +324,64 @@ impl AuthService {
|
|||
self.renew_token(id, true).await
|
||||
}
|
||||
|
||||
/// A signed-out account is skipped rather than failing the whole batch it
|
||||
/// already shows as needing a sign-in
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
pub async fn refresh_all_accounts(&self) -> AuthResult<Vec<MinecraftAccount>> {
|
||||
let ids: Vec<Uuid> = self.store.lock().await.users.keys().copied().collect();
|
||||
let mut refreshed = Vec::with_capacity(ids.len());
|
||||
|
||||
for id in ids {
|
||||
refreshed.push(self.renew_token(id, true).await?);
|
||||
match self.renew_token(id, true).await {
|
||||
Ok(account) => refreshed.push(account),
|
||||
Err(AuthError::SessionExpired { .. }) => {}
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(refreshed)
|
||||
}
|
||||
|
||||
/// A revoked refresh token is only discovered by trying it doing so at
|
||||
/// startup surfaces the sign-out before the user presses play
|
||||
/// Only lapsed tokens are tried a live one needs no renewal yet
|
||||
/// Returns whether any account was newly marked signed out
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
pub async fn check_expired_sessions(&self) -> bool {
|
||||
let ids: Vec<Uuid> = self
|
||||
.store
|
||||
.lock()
|
||||
.await
|
||||
.users
|
||||
.values()
|
||||
.filter(|a| a.is_microsoft() && !a.needs_sign_in() && a.is_expired())
|
||||
.map(|a| a.id)
|
||||
.collect();
|
||||
|
||||
let mut signed_out = false;
|
||||
for id in ids {
|
||||
match self.renew_token(id, false).await {
|
||||
Ok(_) => {}
|
||||
Err(AuthError::SessionExpired { .. }) => signed_out = true,
|
||||
Err(err) => tracing::warn!(%id, "startup session check failed: {err}"),
|
||||
}
|
||||
}
|
||||
signed_out
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(self), fields(%id))]
|
||||
pub async fn account_for_launch(&self, id: Uuid) -> AuthResult<MinecraftAccount> {
|
||||
let account = self.renew_token(id, false).await?;
|
||||
let account = match self.renew_token(id, false).await {
|
||||
Ok(account) => account,
|
||||
// A signed-out account still plays under its saved name and uuid
|
||||
// with an empty token like an offline account singleplayer works
|
||||
// and the UI keeps asking for a sign-in
|
||||
Err(AuthError::SessionExpired { username }) => {
|
||||
tracing::info!(%username, "launching signed-out account without a token");
|
||||
self.account_snapshot(id).await?
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
if account.is_offline() && !self.has_microsoft_account().await {
|
||||
return Err(AuthError::OfflineRequiresMicrosoft);
|
||||
|
|
@ -429,4 +506,67 @@ mod tests {
|
|||
))
|
||||
));
|
||||
}
|
||||
|
||||
fn signed_out_service() -> (AuthService, Uuid) {
|
||||
let mut account = crate::offline::offline_account("MsaUser".to_string());
|
||||
account.kind = crate::data::AccountKind::Microsoft;
|
||||
account.signed_out = true;
|
||||
let id = account.id;
|
||||
|
||||
let mut store = CredentialsStore::default();
|
||||
store.users.insert(id, account);
|
||||
store.default_user = Some(id);
|
||||
|
||||
let (events, _rx) = EventBus::channel();
|
||||
let net = RequestClient::new(NetConfig::default()).expect("net client");
|
||||
(AuthService::with_store(store, net, events), id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_signed_out_account_still_launches_without_touching_the_network() {
|
||||
let (service, id) = signed_out_service();
|
||||
|
||||
let account = service
|
||||
.default_account_for_launch()
|
||||
.await
|
||||
.expect("a signed-out account must still launch")
|
||||
.expect("the only account is the default");
|
||||
assert_eq!(account.id, id);
|
||||
assert!(account.needs_sign_in());
|
||||
assert!(account.access_token.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_all_skips_signed_out_accounts() {
|
||||
let (service, id) = signed_out_service();
|
||||
|
||||
let refreshed = service.refresh_all_accounts().await.unwrap();
|
||||
assert!(refreshed.is_empty());
|
||||
assert!(
|
||||
service.get_account(id).await.is_some(),
|
||||
"the entry must stay listed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_startup_check_leaves_live_and_signed_out_accounts_alone() {
|
||||
let (service, signed_out_id) = signed_out_service();
|
||||
|
||||
let mut live = crate::offline::offline_account("LiveUser".to_string());
|
||||
live.kind = crate::data::AccountKind::Microsoft;
|
||||
live.expires = chrono::Utc::now() + chrono::TimeDelta::hours(12);
|
||||
let live_id = live.id;
|
||||
service.store.lock().await.users.insert(live_id, live);
|
||||
|
||||
// Neither account qualifies so this must return without a request
|
||||
assert!(!service.check_expired_sessions().await);
|
||||
assert!(!service.get_account(live_id).await.unwrap().needs_sign_in());
|
||||
assert!(
|
||||
service
|
||||
.get_account(signed_out_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.needs_sign_in()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,35 @@ impl CredentialsStore {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Drops the dead tokens but keeps the entry and its default slot so the UI
|
||||
/// can offer a sign-in for this exact account
|
||||
#[tracing::instrument(level = "debug", skip(self), fields(%id))]
|
||||
pub async fn mark_signed_out(&mut self, id: Uuid) -> AuthResult<()> {
|
||||
let Some(account) = self.users.get_mut(&id) else {
|
||||
return Err(AuthError::AccountNotFound(id));
|
||||
};
|
||||
|
||||
account.signed_out = true;
|
||||
account.access_token.clear();
|
||||
account.refresh_token.clear();
|
||||
self.save().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Debug aid swaps in a token Microsoft will reject and backdates expiry so
|
||||
/// the next launch walks the real `invalid_grant` path
|
||||
#[tracing::instrument(level = "debug", skip(self), fields(%id))]
|
||||
pub async fn corrupt_refresh_token(&mut self, id: Uuid) -> AuthResult<()> {
|
||||
let Some(account) = self.users.get_mut(&id) else {
|
||||
return Err(AuthError::AccountNotFound(id));
|
||||
};
|
||||
|
||||
account.refresh_token = "oneclient-debug-invalid-refresh-token".to_string();
|
||||
account.expires = chrono::DateTime::UNIX_EPOCH;
|
||||
self.save().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(self), fields(%id))]
|
||||
pub async fn remove_account(&mut self, id: Uuid) -> AuthResult<Option<MinecraftAccount>> {
|
||||
let removed = self.users.remove(&id);
|
||||
|
|
|
|||
|
|
@ -105,3 +105,45 @@ async fn default_account_returns_expired_token_without_refreshing() {
|
|||
assert_eq!(account.id, msa_id);
|
||||
assert!(account.is_expired());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn signed_out_account_stays_listed_and_default() {
|
||||
isolate_launcher_dir();
|
||||
|
||||
let mut store = CredentialsStore::default();
|
||||
let msa = fake_microsoft_account("MsaUser");
|
||||
let msa_id = msa.id;
|
||||
store.users.insert(msa_id, msa);
|
||||
store.default_user = Some(msa_id);
|
||||
|
||||
store.mark_signed_out(msa_id).await.unwrap();
|
||||
|
||||
let reloaded = CredentialsStore::load().await.unwrap();
|
||||
let account = reloaded.get_account(msa_id).expect("entry must survive");
|
||||
assert!(account.needs_sign_in());
|
||||
assert!(
|
||||
account.refresh_token.is_empty(),
|
||||
"the dead token must not be kept"
|
||||
);
|
||||
assert!(account.access_token.is_empty());
|
||||
assert_eq!(reloaded.default_user, Some(msa_id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounts_saved_before_the_flag_existed_load_as_signed_in() {
|
||||
let account: oneclient_auth::MinecraftAccount = serde_json::from_str(&format!(
|
||||
r#"{{"id":"{}","username":"MsaUser","access_token":"a","refresh_token":"r","expires":"2026-01-01T00:00:00Z","kind":"microsoft"}}"#,
|
||||
Uuid::new_v4()
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(!account.needs_sign_in());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_accounts_never_need_sign_in() {
|
||||
let mut account = offline_account("Steve".into());
|
||||
account.signed_out = true;
|
||||
|
||||
assert!(!account.needs_sign_in());
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue