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:
AskaEth committed 2026-11-08 20:23:42 +08:00
1 parent 329c529bea
commit f82bd395f8
23 files changed
+1373 -131

No files matched your search

+20 -2
View File
@@ -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:?}"),
}
+3
View File
@@ -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;
+71 -47
View File
@@ -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) {
+17 -2
View File
@@ -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) {
+40 -9
View File
@@ -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 {
+8 -5
View File
@@ -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)]));