fix(msa): 微软登录默认走设备码,浏览器流降级为可选加速路
官方产物上点「微软登录」必失败:GUI 只接了授权码流(随机端口回环回调),而 Azure 应用 注册当初是为 device-code 建的,重定向 URI 一项都没登记,于是授权端点当场回 `invalid_request: ... 'redirect_uri' is not valid`,浏览器里只剩一张 AAD 原始错误页, 启动器干等 5 分钟超时。设备码流本身是好的(CLI `accounts add msa` 一直能用)。 - core:新增 `preflight`(回环重定向 URI 注册预检)与 `login`(浏览器优先 + 自动回退设备码)。 预检拿「即将使用的 redirect_uri」(端口不参与匹配,微软官方文档 *Localhost exceptions* 原文已引进模块文档)先问一次授权端点;没登记 / 问不出来就地跳过,**一个监听口都不开**。 预检通过才真绑端口走授权码;授权码失败(超时/被拒/绑不上)自动回退设备码并带上结构化 原因码;用户取消(`Canceled`)原样上抛,不偷偷换流。设备码是默认,零监听口。 - presenter:`start_login` = 设备码(默认);`start_browser_login` = 浏览器优先 + 回退, 回退原因码写进 `DeviceLoginView.browser_note`(原文只进日志)。 - i18n:三条回退文案(含「怎么修:把 http://localhost/auth 加进重定向 URI,端口不参与匹配」)。 - app-iced:登录面板主按钮改成设备码、浏览器降级为次按钮;重登也走设备码;设备码面板与失败 面板都能画回退说明。倒计时文案改由视图模型持有(借用渲染),顺带修掉中文倒计时整行不显示。 - 守卫(都能变红):①授权端点回 invalid_request 时确实回退设备码且原因是结构化码; ②未登记回环 / 默认设备码路 `listeners_opened == 0`;③回环登记了仍能走授权码(不焊死加速路); ④用户取消不被偷偷换流;⑤client id 仍只能构建期注入(扫 `auth/microsoft/` 源码无 UUID 字面量)。
This commit is contained in:
1 parent
329c529bea
commit
f82bd395f8
23 files changed
+1373
-131
No files matched your search
@@ -95,7 +95,7 @@ impl App {
|
||||
self.dispatch(AppCommand::AddAuthlibAccount(request));
|
||||
}
|
||||
}
|
||||
Panel::Microsoft => return self.on_account(Account::StartBrowser),
|
||||
Panel::Microsoft => return self.on_account(Account::StartDevice),
|
||||
Panel::None => {}
|
||||
},
|
||||
Account::StartDevice => self.dispatch(AppCommand::StartDeviceLogin),
|
||||
@@ -136,7 +136,8 @@ impl App {
|
||||
match account.kind {
|
||||
AccountKind::Microsoft => {
|
||||
self.account_form.panel = Panel::Microsoft;
|
||||
self.dispatch(AppCommand::StartBrowserLogin);
|
||||
// M178:重登也走设备码(不依赖任何应用注册项)。想试浏览器再点面板里的次按钮。
|
||||
self.dispatch(AppCommand::StartDeviceLogin);
|
||||
}
|
||||
AccountKind::Authlib => {
|
||||
self.account_form.open(Track::Authlib);
|
||||
@@ -173,6 +174,23 @@ pub(super) fn force_account_state(app: &mut App) {
|
||||
app.state.device_login_started =
|
||||
Some(std::time::Instant::now() - std::time::Duration::from_secs(3_600));
|
||||
}
|
||||
// M178:浏览器登录没走成、已回退设备码的那一态(设备码 + 一句「为什么 + 怎么修」)。
|
||||
//
|
||||
// 为什么在 app 层造:真走到这一步要一次真网络请求(预检),截图通道要的是**确定性**。
|
||||
// 这里只替换登录视图本身——面板的渲染路径与真 host 完全同一条
|
||||
// (`apply_device_login` → `pages::account::panels::device_panel` → `login::panel`)。
|
||||
"msa-fallback" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Microsoft;
|
||||
app.state.apply_device_login(
|
||||
unlucky_presenter::dto::DeviceLoginView::waiting(
|
||||
"ABCD-EFGH",
|
||||
"https://www.microsoft.com/link",
|
||||
900,
|
||||
"",
|
||||
)
|
||||
.with_browser_note(unlucky_presenter::dto::BrowserLoginNote::RedirectNotRegistered),
|
||||
);
|
||||
}
|
||||
// 第三方站**失败**态:面板打开 + mock 那边给出的 `AuthlibLoginChanged(Failed)`
|
||||
"authlib-failed" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Authlib;
|
||||
|
||||
@@ -125,10 +125,10 @@ fn the_device_code_flow_is_started_visible_and_cancellable() {
|
||||
let _ = app.on_account(Account::StartDevice);
|
||||
match &app.account.device {
|
||||
DevicePanel::Code {
|
||||
code, remaining, ..
|
||||
code, countdown, ..
|
||||
} => {
|
||||
assert_eq!(code, "ABCD-EFGH", "用户码要上屏");
|
||||
assert!(remaining.is_some_and(|left| left > 0), "倒计时在跑");
|
||||
assert!(!countdown.is_empty(), "倒计时在跑(文案由模型拼好)");
|
||||
}
|
||||
other => panic!("进行中状态必须可见,实际 {other:?}"),
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ pub mod widgets;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
#[path = "account/tests_msa.rs"]
|
||||
mod tests_msa;
|
||||
#[cfg(test)]
|
||||
#[path = "account/tests_text.rs"]
|
||||
mod tests_text;
|
||||
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
//!
|
||||
//! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
|
||||
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、
|
||||
//! 倒计时(`{secs}s left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。
|
||||
//! 倒计时(`15:00 left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。
|
||||
//!
|
||||
//! ## 文案一律**借用模型里的串**(M178)
|
||||
//!
|
||||
//! 倒计时/回退说明这类运行时格式化出来的句子由 [`super::panels`] 在模型层拼好(带 i18n),
|
||||
//! 视图只拿 `&'a str` 画。为什么不在这里 `format!` 后交给 `frame::text_role_owned`:
|
||||
//! 那条 owned 路径上的**中文画不出来**(实测:同一位置 ASCII 能画、`剩 15:00` 一片空白),
|
||||
//! 而借用路径正常。模型持有字符串、视图借用,两个问题一起消掉。
|
||||
|
||||
use iced::widget::{column, row};
|
||||
use iced::{Alignment, Element};
|
||||
@@ -13,7 +20,7 @@ use unlucky_design::color::Role;
|
||||
use unlucky_design::spacing::Space;
|
||||
use unlucky_design::typography::TypeRole;
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::LoginFailure;
|
||||
use unlucky_presenter::dto::{BrowserLoginNote, LoginFailure};
|
||||
|
||||
use crate::app::components::buttons::{self, Button};
|
||||
use crate::pages::frame;
|
||||
@@ -36,34 +43,37 @@ pub fn panel<'a>(
|
||||
DevicePanel::Code {
|
||||
code,
|
||||
link,
|
||||
remaining,
|
||||
countdown,
|
||||
expired,
|
||||
} => device_code(ui, code, link, *remaining, *expired, catalog),
|
||||
note,
|
||||
} => device_code(ui, code, link, countdown, *expired, *note, catalog),
|
||||
DevicePanel::Browser {
|
||||
link,
|
||||
remaining,
|
||||
countdown,
|
||||
expired,
|
||||
} => browser(ui, link, *remaining, *expired, catalog),
|
||||
DevicePanel::Failed { failure } => failed(ui, *failure, catalog),
|
||||
} => browser(ui, link, countdown, *expired, catalog),
|
||||
DevicePanel::Failed { failure, note } => failed(ui, *failure, *note, catalog),
|
||||
DevicePanel::Succeeded { name } => succeeded(ui, name, catalog),
|
||||
}
|
||||
}
|
||||
|
||||
/// 空闲:给出两条流的入口(GUI 默认授权码流,设备码是备选)。
|
||||
/// 空闲:两条流的入口。**主按钮是设备码**(M178):它不依赖应用注册里的任何一项,是官方
|
||||
/// 产物上唯一开箱可用的路;浏览器流是右手的次要按钮(要注册 `http://localhost/auth`,
|
||||
/// 没登记时在预检处直接回退设备码)。
|
||||
fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
let body = column![row![
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::AccountDeviceBrowser),
|
||||
Account::StartBrowser.into_page(),
|
||||
catalog.text(Key::AccountDeviceUseCode),
|
||||
Account::StartDevice.into_page(),
|
||||
)
|
||||
),
|
||||
buttons::outlined(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::AccountDeviceUseCode),
|
||||
Account::StartDevice.into_page(),
|
||||
catalog.text(Key::AccountDeviceBrowser),
|
||||
Account::StartBrowser.into_page(),
|
||||
)
|
||||
),
|
||||
buttons::text(
|
||||
@@ -105,13 +115,14 @@ fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
frame::card(ui, body)
|
||||
}
|
||||
|
||||
/// 设备码等待态:**码 + 链接 + 倒计时**。
|
||||
/// 设备码等待态:**码 + 链接 + 倒计时**(`note` = 浏览器登录没走成时的回退说明)。
|
||||
fn device_code<'a>(
|
||||
ui: &'a Ui,
|
||||
code: &'a str,
|
||||
link: &'a str,
|
||||
remaining: Option<u64>,
|
||||
countdown: &'a str,
|
||||
expired: bool,
|
||||
note: Option<BrowserLoginNote>,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let mut actions = row![
|
||||
@@ -150,9 +161,7 @@ fn device_code<'a>(
|
||||
),
|
||||
));
|
||||
|
||||
let countdown = countdown_line(ui, remaining, expired, catalog);
|
||||
|
||||
let body = column![
|
||||
let mut body = column![
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
@@ -174,7 +183,7 @@ fn device_code<'a>(
|
||||
link,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
countdown,
|
||||
countdown_line(ui, countdown, expired),
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
@@ -182,17 +191,21 @@ fn device_code<'a>(
|
||||
catalog.text(Key::AccountDeviceHint),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
actions,
|
||||
]
|
||||
.spacing(ui.space(Space::X3));
|
||||
frame::card(ui, body)
|
||||
// 回退说明放在**动作行之前**:它解释的是「这个面板为什么是设备码」,
|
||||
// 用户读完再决定复制码/取消(放在按钮下面会像脚注,读不到)。
|
||||
if let Some(text) = note_text(ui, note, catalog) {
|
||||
body = body.push(text);
|
||||
}
|
||||
frame::card(ui, body.push(actions))
|
||||
}
|
||||
|
||||
/// 授权码等待态:浏览器已拉起,链接可复制;过期可重试。
|
||||
fn browser<'a>(
|
||||
ui: &'a Ui,
|
||||
link: &'a str,
|
||||
remaining: Option<u64>,
|
||||
countdown: &'a str,
|
||||
expired: bool,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, PageMessage> {
|
||||
@@ -237,7 +250,7 @@ fn browser<'a>(
|
||||
link,
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
),
|
||||
countdown_line(ui, remaining, expired, catalog),
|
||||
countdown_line(ui, countdown, expired),
|
||||
actions,
|
||||
]
|
||||
.spacing(ui.space(Space::X3));
|
||||
@@ -245,29 +258,13 @@ fn browser<'a>(
|
||||
}
|
||||
|
||||
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。
|
||||
fn countdown_line<'a>(
|
||||
ui: &'a Ui,
|
||||
remaining: Option<u64>,
|
||||
expired: bool,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, PageMessage> {
|
||||
///
|
||||
/// 文案由模型拼好(`panels::device_panel` 用 catalog 渲染 `{time}`),这里只决定颜色与强调。
|
||||
fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, PageMessage> {
|
||||
if expired {
|
||||
return frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
true,
|
||||
catalog.text(Key::AccountDeviceExpired),
|
||||
ui.color(Role::Error),
|
||||
);
|
||||
return frame::text_role(ui, TypeRole::LabelMedium, true, text, ui.color(Role::Error));
|
||||
}
|
||||
let text = remaining.map_or_else(String::new, |secs| {
|
||||
// mm:ss(`15:00 left`)而不是 `900s left`:人读的是分秒,而且不随语言换格式
|
||||
// (数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog)。
|
||||
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
|
||||
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
|
||||
});
|
||||
// 运行时格式化的串走 `text_role_owned`(`text_role` 只借 `&'a str`)
|
||||
frame::text_role_owned(
|
||||
frame::text_role(
|
||||
ui,
|
||||
TypeRole::LabelMedium,
|
||||
false,
|
||||
@@ -276,16 +273,43 @@ fn countdown_line<'a>(
|
||||
)
|
||||
}
|
||||
|
||||
/// 回退说明那一行(`None` = 没有这回事)。
|
||||
fn note_text<'a>(
|
||||
ui: &'a Ui,
|
||||
note: Option<BrowserLoginNote>,
|
||||
catalog: Catalog,
|
||||
) -> Option<Element<'a, PageMessage>> {
|
||||
let note = note?;
|
||||
// 用正文色而不是 `Error`:这不是失败态(设备码接着跑),画成红色会让人以为登录已经挂了。
|
||||
Some(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(super::model::note_key(note)),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
))
|
||||
}
|
||||
|
||||
/// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。
|
||||
///
|
||||
/// 三点修订叠在这里:
|
||||
/// 四点修订叠在这里:
|
||||
/// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
|
||||
/// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边,
|
||||
/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小);
|
||||
/// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的
|
||||
/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错。
|
||||
fn failed<'a>(ui: &'a Ui, failure: LoginFailure, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
let body = catalog.text(failure_body_key(failure)).to_string();
|
||||
/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错;
|
||||
/// - **M178**:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。
|
||||
fn failed<'a>(
|
||||
ui: &'a Ui,
|
||||
failure: LoginFailure,
|
||||
note: Option<BrowserLoginNote>,
|
||||
catalog: Catalog,
|
||||
) -> Element<'a, PageMessage> {
|
||||
let mut body = catalog.text(failure_body_key(failure)).to_string();
|
||||
if let Some(note) = note {
|
||||
body.push_str("\n\n");
|
||||
body.push_str(catalog.text(super::model::note_key(note)));
|
||||
}
|
||||
let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]);
|
||||
let mut actions: Vec<Element<'a, PageMessage>> = Vec::new();
|
||||
if !matches!(failure, LoginFailure::MsaNotConfigured) {
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
use iced::widget::image;
|
||||
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
use unlucky_presenter::dto::{AccountKind, AccountNotice, AccountNoticeCode, AccountView};
|
||||
use unlucky_presenter::dto::{
|
||||
AccountKind, AccountNotice, AccountNoticeCode, AccountView, BrowserLoginNote,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use super::avatar::{Avatar, Cache};
|
||||
@@ -164,6 +166,18 @@ impl Form {
|
||||
}
|
||||
}
|
||||
|
||||
/// 回退说明码 → i18n key(**唯一的「码→文案」映射点**;原文永远不上屏)。
|
||||
///
|
||||
/// 与 [`Track::title_key`] 同一种写法:页面只认 key,后端只给码,两边独立演进。
|
||||
#[must_use]
|
||||
pub const fn note_key(note: BrowserLoginNote) -> Key {
|
||||
match note {
|
||||
BrowserLoginNote::RedirectNotRegistered => Key::AccountBrowserFallbackNotRegistered,
|
||||
BrowserLoginNote::PreflightUnavailable => Key::AccountBrowserFallbackUnverified,
|
||||
BrowserLoginNote::AttemptFailed => Key::AccountBrowserFallbackFailed,
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表行 / 当前账户卡片用的数据。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Row {
|
||||
@@ -253,7 +267,8 @@ impl Page {
|
||||
.map(|account| row_of(account, avatars, lang))
|
||||
.collect();
|
||||
let active = rows.iter().position(|row| row.active);
|
||||
let device = device_panel(&state.device_login, state.device_code_remaining());
|
||||
let remaining = state.device_code_remaining();
|
||||
let device = device_panel(&state.device_login, remaining, lang.catalog());
|
||||
// 登录在进行(或刚失败/刚成功)时,面板**必须**是微软那条轨:否则用户点了登录、
|
||||
// 后台已经在等设备码,页面上却只剩三张入口卡——那正是「只转圈」的另一种坏法。
|
||||
let panel = if matches!(device, DevicePanel::Hidden) {
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
//! 现在直接把字段删掉:视图**没有材料可画**,想画原文是编译错误——比任何测试都硬。
|
||||
//! 原文仍在 presenter 的 `DeviceLoginView/AuthlibLoginView::message` 里(只进 `eprintln!`)。
|
||||
|
||||
use unlucky_presenter::dto::{AuthlibLoginView, DeviceLoginPhase, DeviceLoginView, LoginFailure};
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
AuthlibLoginView, BrowserLoginNote, DeviceLoginPhase, DeviceLoginView, LoginFailure,
|
||||
};
|
||||
|
||||
/// 微软登录面板要画什么(每态都不同,**不允许只有一个转圈**)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -25,17 +28,23 @@ pub enum DevicePanel {
|
||||
code: String,
|
||||
/// 验证链接
|
||||
link: String,
|
||||
/// 剩余秒数
|
||||
remaining: Option<u64>,
|
||||
/// 是否已过期
|
||||
/// 倒计时那行**已经渲染好的文案**(`15:00 left` / `剩 15:00`;过期时是「已过期」)
|
||||
///
|
||||
/// 为什么文案在模型层拼(M178):`frame::text_role_owned` 那条 owned 文字路径
|
||||
/// **画不出中文**(ASCII 正常),倒计时在中文界面会整行消失。模型持有字符串、
|
||||
/// 视图用 `&'a str` 借用,两边都不依赖那条路径。
|
||||
countdown: String,
|
||||
/// 是否已过期(决定颜色与「重试」按钮)
|
||||
expired: bool,
|
||||
/// 浏览器登录没走成、已回退到设备码的说明(M178;`None` = 没这回事)
|
||||
note: Option<BrowserLoginNote>,
|
||||
},
|
||||
/// 授权码流:等浏览器回调
|
||||
Browser {
|
||||
/// 授权链接(可复制)
|
||||
link: String,
|
||||
/// 剩余秒数
|
||||
remaining: Option<u64>,
|
||||
/// 倒计时那行已渲染好的文案(同上)
|
||||
countdown: String,
|
||||
/// 是否已过期
|
||||
expired: bool,
|
||||
},
|
||||
@@ -43,6 +52,8 @@ pub enum DevicePanel {
|
||||
Failed {
|
||||
/// 原因码(页面据此选 i18n)
|
||||
failure: LoginFailure,
|
||||
/// 回退说明(浏览器登录没走成之后设备码也失败了;M178)
|
||||
note: Option<BrowserLoginNote>,
|
||||
},
|
||||
/// 成功
|
||||
Succeeded {
|
||||
@@ -76,8 +87,13 @@ pub enum AuthlibPanel {
|
||||
}
|
||||
|
||||
/// 微软登录状态 → 面板态(倒计时与「已过期」都在这里定性,UI 只画)。
|
||||
pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> DevicePanel {
|
||||
pub(super) fn device_panel(
|
||||
view: &DeviceLoginView,
|
||||
remaining: Option<u64>,
|
||||
catalog: Catalog,
|
||||
) -> DevicePanel {
|
||||
let expired = remaining == Some(0);
|
||||
let countdown = countdown_text(remaining, expired, catalog);
|
||||
match view.phase {
|
||||
DeviceLoginPhase::Idle => DevicePanel::Hidden,
|
||||
DeviceLoginPhase::Requesting => DevicePanel::Requesting,
|
||||
@@ -85,20 +101,22 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> De
|
||||
if view.user_code.trim().is_empty() {
|
||||
DevicePanel::Browser {
|
||||
link: view.verification_uri.clone(),
|
||||
remaining,
|
||||
countdown,
|
||||
expired,
|
||||
}
|
||||
} else {
|
||||
DevicePanel::Code {
|
||||
code: view.user_code.clone(),
|
||||
link: view.qr_link.clone(),
|
||||
remaining,
|
||||
countdown,
|
||||
expired,
|
||||
note: view.browser_note,
|
||||
}
|
||||
}
|
||||
}
|
||||
DeviceLoginPhase::Failed => DevicePanel::Failed {
|
||||
failure: view.failure.unwrap_or(LoginFailure::Other),
|
||||
note: view.browser_note,
|
||||
},
|
||||
DeviceLoginPhase::Succeeded => DevicePanel::Succeeded {
|
||||
name: view.account_name.clone(),
|
||||
@@ -106,6 +124,19 @@ pub(super) fn device_panel(view: &DeviceLoginView, remaining: Option<u64>) -> De
|
||||
}
|
||||
}
|
||||
|
||||
/// 倒计时那一行的文案(`mm:ss` + i18n;过期/不知道有效期的两种情况都在这里收口)。
|
||||
///
|
||||
/// `mm:ss` 而不是 `900s`:人读的是分秒;数字与冒号在两种语言里同形,只有 `left`/`剩` 走 catalog。
|
||||
fn countdown_text(remaining: Option<u64>, expired: bool, catalog: Catalog) -> String {
|
||||
if expired {
|
||||
return catalog.text(Key::AccountDeviceExpired).to_string();
|
||||
}
|
||||
remaining.map_or_else(String::new, |secs| {
|
||||
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
|
||||
catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)])
|
||||
})
|
||||
}
|
||||
|
||||
/// 第三方轨状态 → 面板态(顺带取出「校验通过的服务器名」)。
|
||||
pub(super) fn authlib_panel(view: &AuthlibLoginView) -> (AuthlibPanel, String) {
|
||||
match view.phase {
|
||||
|
||||
@@ -33,7 +33,7 @@ fn account(name: &str, kind: AccountKind, active: bool) -> AccountView {
|
||||
}
|
||||
}
|
||||
|
||||
fn build(state: &AppState, form: &Form) -> Page {
|
||||
pub(super) fn build(state: &AppState, form: &Form) -> Page {
|
||||
// 断言口径是英文界面(app 钉死 `Lang::En`):英文目录下「视图里有中文」=「后端原文上屏了」。
|
||||
// 宽度取验收窗口 1180(r2 起 `Page` 记中栏可用宽)。
|
||||
Page::build(state, form, &mut Cache::default(), Lang::En, 1180.0)
|
||||
@@ -212,12 +212,15 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
|
||||
match &page.device {
|
||||
DevicePanel::Code {
|
||||
code,
|
||||
remaining,
|
||||
countdown,
|
||||
expired,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(code, "ABCD-EFGH");
|
||||
assert_eq!(*remaining, Some(900));
|
||||
assert!(
|
||||
countdown.contains("15:00"),
|
||||
"倒计时要画成 mm:ss:{countdown}"
|
||||
);
|
||||
assert!(!expired);
|
||||
}
|
||||
other => panic!("等待态应该是设备码面板,实际 {other:?}"),
|
||||
@@ -232,10 +235,10 @@ fn the_device_code_panel_shows_a_countdown_and_expires() {
|
||||
let page = build(&state, &Form::new());
|
||||
match &page.device {
|
||||
DevicePanel::Code {
|
||||
remaining, expired, ..
|
||||
countdown, expired, ..
|
||||
} => {
|
||||
assert_eq!(*remaining, Some(0));
|
||||
assert!(*expired, "过期必须被标出来(面板要画「已过期,重试」)");
|
||||
assert!(!countdown.is_empty(), "过期也要有一句人话,不是空行");
|
||||
}
|
||||
other => panic!("过期态仍是设备码面板,实际 {other:?}"),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! M178 的登录回退判据(从 `tests.rs` 拆出来守 400 行硬线)。
|
||||
//!
|
||||
//! 这两条钉的是**页面这一半**:浏览器登录没走成、已回退设备码时,用户看到的是
|
||||
//! i18n 那句「为什么 + 怎么修」,而不是 AAD 的原始报文。core 那一半(真的回退、不白开监听口)
|
||||
//! 在 `crates/core/tests/auth/microsoft_msalogin.rs`。
|
||||
|
||||
use unlucky_presenter::dto::{
|
||||
AppEvent, BrowserLoginNote, CoreEvent, DeviceLoginView, LoginFailure,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use super::model::{DevicePanel, Form};
|
||||
use super::tests::build;
|
||||
use super::tests_text::{has_cjk, on_screen};
|
||||
|
||||
/// **M178 守卫(页面这一半)**:浏览器登录没走成、已回退设备码时,页面上要有一句**可读的**
|
||||
/// 中文/英文说明(含「怎么修」),且**不是** AAD 的原始报文。
|
||||
///
|
||||
/// 抓得住:`browser_note` 在 `device_panel` 里被丢掉(面板就只剩用户码,用户不知道为什么
|
||||
/// 变成了设备码)、或有人把 presenter 的原始诊断串(`invalid_request ...`)画上屏。
|
||||
/// 抓不住:这句提示在 1180 宽下会不会换行难看(那靠出图给 tower 看)。
|
||||
#[test]
|
||||
fn a_browser_fallback_shows_a_readable_note_not_the_raw_aad_error() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
|
||||
DeviceLoginView {
|
||||
message: "invalid_request: The provided value for the input parameter 'redirect_uri' \
|
||||
is not valid. The expected value is a URI which matches a redirect URI registered for this client \
|
||||
application."
|
||||
.to_string(),
|
||||
..DeviceLoginView::waiting("ABCD-EFGH", "https://www.microsoft.com/link", 900, "")
|
||||
}
|
||||
.with_browser_note(BrowserLoginNote::RedirectNotRegistered),
|
||||
)));
|
||||
let page = build(&state, &Form::new());
|
||||
|
||||
// 1. 面板态带着说明码(不是只剩一个用户码)
|
||||
match &page.device {
|
||||
DevicePanel::Code { note, code, .. } => {
|
||||
assert_eq!(*note, Some(BrowserLoginNote::RedirectNotRegistered));
|
||||
assert_eq!(code, "ABCD-EFGH");
|
||||
}
|
||||
other => panic!("回退后仍是设备码面板,实际 {other:?}"),
|
||||
}
|
||||
|
||||
// 2. 英文界面上画出来的那一段:是 i18n 文案、含可照抄的修法,且没有 AAD 原始报文/中文
|
||||
let text = on_screen(&page);
|
||||
assert!(!has_cjk(&text), "英文界面不许出现中文:{text}");
|
||||
assert!(
|
||||
text.contains("http://localhost/auth"),
|
||||
"提示里要给出可照抄的重定向 URI:{text}"
|
||||
);
|
||||
assert!(
|
||||
text.to_lowercase().contains("redirect uri"),
|
||||
"提示里要点名「重定向 URI」这一项:{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("invalid_request"),
|
||||
"原始报文不许上屏:{text}"
|
||||
);
|
||||
assert!(!text.contains("AADSTS"), "原始报文不许上屏:{text}");
|
||||
assert!(!text.contains("is not valid"), "原始报文不许上屏:{text}");
|
||||
}
|
||||
|
||||
/// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。
|
||||
#[test]
|
||||
fn a_failed_device_login_keeps_the_fallback_note() {
|
||||
let mut state = AppState::new();
|
||||
state.apply(AppEvent::Core(CoreEvent::DeviceLoginChanged(
|
||||
DeviceLoginView::failed_as(
|
||||
LoginFailure::Unreachable,
|
||||
"连不上 login.microsoftonline.com",
|
||||
)
|
||||
.with_browser_note(BrowserLoginNote::PreflightUnavailable),
|
||||
)));
|
||||
let page = build(&state, &Form::new());
|
||||
match &page.device {
|
||||
DevicePanel::Failed { failure, note } => {
|
||||
assert_eq!(*failure, LoginFailure::Unreachable);
|
||||
assert_eq!(*note, Some(BrowserLoginNote::PreflightUnavailable));
|
||||
}
|
||||
other => panic!("应是失败面板,实际 {other:?}"),
|
||||
}
|
||||
let text = on_screen(&page);
|
||||
assert!(!has_cjk(&text), "{text}");
|
||||
assert!(text.contains("unreachable"), "失败码要画出来:{text}");
|
||||
}
|
||||
@@ -60,23 +60,32 @@ pub(super) fn on_screen(page: &Page) -> String {
|
||||
DevicePanel::Code {
|
||||
code,
|
||||
link,
|
||||
remaining,
|
||||
expired,
|
||||
countdown,
|
||||
note,
|
||||
..
|
||||
} => {
|
||||
out.push_str(code);
|
||||
out.push_str(link);
|
||||
if *expired {
|
||||
out.push_str(catalog.text(Key::AccountDeviceExpired));
|
||||
} else if let Some(secs) = remaining {
|
||||
let clock = format!("{:02}:{:02}", secs / 60, secs % 60);
|
||||
out.push_str(&catalog.render(Key::AccountDeviceCountdown, &[("time", &clock)]));
|
||||
// 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。
|
||||
out.push_str(countdown);
|
||||
// M178:浏览器登录没走成时那一句「为什么 + 怎么修」也在这条取样线上。
|
||||
if let Some(note) = note {
|
||||
out.push_str(catalog.text(super::model::note_key(*note)));
|
||||
}
|
||||
}
|
||||
DevicePanel::Browser { link, .. } => out.push_str(link),
|
||||
DevicePanel::Failed { failure } => {
|
||||
DevicePanel::Browser {
|
||||
link, countdown, ..
|
||||
} => {
|
||||
out.push_str(link);
|
||||
out.push_str(countdown);
|
||||
}
|
||||
DevicePanel::Failed { failure, note } => {
|
||||
out.push_str(catalog.text(failure_title_key(*failure)));
|
||||
out.push_str(catalog.text(failure_body_key(*failure)));
|
||||
out.push_str(&catalog.render(Key::AccountFailureCode, &[("code", failure.code())]));
|
||||
if let Some(note) = note {
|
||||
out.push_str(catalog.text(super::model::note_key(*note)));
|
||||
}
|
||||
}
|
||||
DevicePanel::Succeeded { name } => {
|
||||
out.push_str(&catalog.render(Key::AccountLoginSucceeded, &[("name", name)]));
|
||||
|
||||
Reference in new issue
Block a user