fix(msa): 浏览器登录按钮在生产路径真的置位 browser_first(复审 r1 收口)
复审 r1 的 P1-1:「用浏览器登录」这颗按钮在生产二进制里是哑的——`start_browser_login` 用的是
共享的 `MicrosoftAuth::from_env()`(`browser_first=false`),于是 `browser_then_device_login`
里整段预检 / 回退 / 说明编排不执行,按钮静默退化成设备码,任务③(authcode 可选加速路)
实际未交付。
- P1-1:入口改成 `msa_auth(env, MsaEntry::{DeviceCode,Browser})`,浏览器入口走
`.with_browser_first(true)`。守卫钉两段:①调用点(按 `LoginJob` 分桶记下生产入口实际请求的
模式,避开同进程并发用例串台)②模式真的落到 `MicrosoftAuth::browser_first`。变异实测:
把浏览器入口改回 `DeviceCode` ⇒ 该用例红。核心侧另有报文级证据:浏览器优先路必然对
`/authorize` 发一次预检(`unregistered_redirect_falls_back_to_the_device_code` 断言 GET 计数 == 1)。
- P2-1:`listeners_opened` 改成**绑定现场**计数——`CallbackServer::bind_counting` 在
`TcpListener::bind` 前 +1,`login_counting` 把 per-login 的 `AtomicUsize` 传下去;
删掉「进了授权码分支就记 1」的代理指标。复做变异(预检之前偷绑一个监听口)⇒ 3 条
msalogin 用例红,包括两条 `listeners_opened == 0`。两处 stale 文档(引用不存在的
`callback::bind_attempts()`)改成引用 `listeners_opened` / `bind_counting`。
- P2-2:预检加整体 deadline(10s,`PREFLIGHT_TIMEOUT`),超时归 `PreflightUnavailable` 并
回退设备码;新增「接了连接却不出响应」的假服务器单测(200ms deadline 内必须收场)。
(下载引擎的 HTTP 客户端只设了 connect_timeout,这条兜住「header 之后停住」。)
- P2-3:client_id 守卫加严到三种拼法(`concat!` / 跨行两段 / `format!` 逐段拼),做法是
「先收字符串字面量再拼」+「连续纯 hex 字面量累计 ≥32 且 ≥2 段」,并跳过注释(否则本模块
文档里的示例会自指假红);每条一个自检向量,且不误杀 `session.rs` 的 profile UUID。
- P2-4:页面级断言补中文(`Lang::ZhCn` 走 `Page::build`),并把回退说明的文案出口收敛成
视图自己的 `login::browser_note_text`(视图与取样共用,不再测试另写一份映射)。
- P2-5:主按钮文案改成「使用 Microsoft 账户登录」/「Sign in with your Microsoft account」,
下面补一行「浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。」。
门禁:`cargo fmt --check`、`cargo clippy --workspace --all-targets -- -D warnings`、
`cargo test --workspace --all-targets`(51 个 test 目标全绿)通过;`client_id.rs` 的判据拆到
`client_id/tests.rs` 守 400 行红线。截图三张(入口 / 设备码 / 回退)已重出。
This commit is contained in:
1 parent
f82bd395f8
commit
a40b3a5940
15 files changed
+745
-294
No files matched your search
@@ -216,6 +216,11 @@ pub(super) fn force_account_state(app: &mut App) {
|
||||
"closed" => {
|
||||
let _ = app.on_account(crate::pages::account::Message::Close);
|
||||
}
|
||||
// M178:登录入口面板(两个按钮 + 一行「没有浏览器时用这个」)。复审 r1 P2-5 的文案改动
|
||||
// 要有图可看:`device_login` 是 Idle ⇒ 面板落在空闲态(主按钮=设备码,次按钮=浏览器)。
|
||||
"msa-entry" => {
|
||||
app.account_form.panel = crate::pages::account::Panel::Microsoft;
|
||||
}
|
||||
// 有账号时「Add an account」展开态(P2-4 的收敛后形态)
|
||||
"add" => {
|
||||
app.account_form.section_open = true;
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
//!
|
||||
//! ## 为什么不是「一个转圈」
|
||||
//!
|
||||
//! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
|
||||
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、
|
||||
//! 倒计时(`15:00 left`)与过期后的「已过期,重试」。授权码流同理,另外必须能**取消**。
|
||||
//! 设备码的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
|
||||
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码、链接、倒计时与「已过期,重试」。
|
||||
//!
|
||||
//! ## 文案一律**借用模型里的串**(M178)
|
||||
//!
|
||||
@@ -60,12 +59,15 @@ pub fn panel<'a>(
|
||||
/// 空闲:两条流的入口。**主按钮是设备码**(M178):它不依赖应用注册里的任何一项,是官方
|
||||
/// 产物上唯一开箱可用的路;浏览器流是右手的次要按钮(要注册 `http://localhost/auth`,
|
||||
/// 没登记时在预检处直接回退设备码)。
|
||||
///
|
||||
/// 文案(复审 r1 P2-5):主按钮写「使用 Microsoft 账户登录」而不是「改用设备码」;
|
||||
/// 「没有浏览器时用这个」放在按钮下面一行说明里。
|
||||
fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
let body = column![row![
|
||||
buttons::filled(
|
||||
ui,
|
||||
Button::new(
|
||||
catalog.text(Key::AccountDeviceUseCode),
|
||||
catalog.text(Key::AccountDeviceSignIn),
|
||||
Account::StartDevice.into_page(),
|
||||
)
|
||||
),
|
||||
@@ -86,7 +88,14 @@ fn idle<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
]
|
||||
.spacing(ui.space(Space::X2))
|
||||
.align_y(Alignment::Center),]
|
||||
.spacing(ui.space(Space::X3));
|
||||
.spacing(ui.space(Space::X3))
|
||||
.push(frame::text_role(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(Key::AccountDeviceFallbackHint),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
));
|
||||
frame::card(ui, body)
|
||||
}
|
||||
|
||||
@@ -115,7 +124,7 @@ fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||||
frame::card(ui, body)
|
||||
}
|
||||
|
||||
/// 设备码等待态:**码 + 链接 + 倒计时**(`note` = 浏览器登录没走成时的回退说明)。
|
||||
/// 设备码等待态:码 + 链接 + 倒计时(`note` = 回退说明)。
|
||||
fn device_code<'a>(
|
||||
ui: &'a Ui,
|
||||
code: &'a str,
|
||||
@@ -257,9 +266,7 @@ fn browser<'a>(
|
||||
frame::card(ui, body)
|
||||
}
|
||||
|
||||
/// 倒计时 / 过期提示(**这一行是「不得只转圈」的落点**)。
|
||||
///
|
||||
/// 文案由模型拼好(`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, text, ui.color(Role::Error));
|
||||
@@ -273,6 +280,11 @@ fn countdown_line<'a>(ui: &'a Ui, text: &'a str, expired: bool) -> Element<'a, P
|
||||
)
|
||||
}
|
||||
|
||||
/// 回退说明的**唯一文案出口**(视图与页面级断言共用:断言取样走这里,才是「视图会画的那句」)。
|
||||
pub(super) fn browser_note_text(note: BrowserLoginNote, catalog: Catalog) -> &'static str {
|
||||
catalog.text(super::model::note_key(note))
|
||||
}
|
||||
|
||||
/// 回退说明那一行(`None` = 没有这回事)。
|
||||
fn note_text<'a>(
|
||||
ui: &'a Ui,
|
||||
@@ -285,20 +297,17 @@ fn note_text<'a>(
|
||||
ui,
|
||||
TypeRole::BodySmall,
|
||||
false,
|
||||
catalog.text(super::model::note_key(note)),
|
||||
browser_note_text(note, catalog),
|
||||
ui.color(Role::OnSurfaceVariant),
|
||||
))
|
||||
}
|
||||
|
||||
/// 失败态:**M3 内联 alert**(左侧 `ErrorContainer` 图标块 + 标题/正文/Code + 重试/关闭)。
|
||||
///
|
||||
/// 四点修订叠在这里:
|
||||
/// - **语言泄漏(r1 p1)**:只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
|
||||
/// - **不许通栏实心红(tower r2)**:面板底走 `SurfaceContainerHigh` + 描边,
|
||||
/// `ErrorContainer`/`OnErrorContainer` 只作左侧图标块的强调(角色不变,面积收小);
|
||||
/// - **可操作的失败要有可读说明(M132)**:缺 client id 换专属标题 + 「要设哪个环境变量」的
|
||||
/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错;
|
||||
/// - **M178**:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。
|
||||
/// - 语言泄漏(r1 p1):只画原因码对应的 i18n + 一行 `Code: …`,后端中文原文不上屏;
|
||||
/// - 不许通栏实心红(tower r2):面板底 `SurfaceContainerHigh` + 描边,`ErrorContainer` 只做图标块强调;
|
||||
/// - 可操作的失败要有可读说明(M132):缺 client id 换专属标题 + 「要设哪个环境变量」,且不给「重试」;
|
||||
/// - M178:`note`(浏览器登录没走成)另起一行画在正文下面,两者都能看见。
|
||||
fn failed<'a>(
|
||||
ui: &'a Ui,
|
||||
failure: LoginFailure,
|
||||
@@ -308,7 +317,7 @@ fn failed<'a>(
|
||||
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)));
|
||||
body.push_str(browser_note_text(note, catalog));
|
||||
}
|
||||
let code = catalog.render(Key::AccountFailureCode, &[("code", failure.code())]);
|
||||
let mut actions: Vec<Element<'a, PageMessage>> = Vec::new();
|
||||
|
||||
@@ -4,15 +4,23 @@
|
||||
//! i18n 那句「为什么 + 怎么修」,而不是 AAD 的原始报文。core 那一半(真的回退、不白开监听口)
|
||||
//! 在 `crates/core/tests/auth/microsoft_msalogin.rs`。
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
use unlucky_presenter::dto::{
|
||||
AppEvent, BrowserLoginNote, CoreEvent, DeviceLoginView, LoginFailure,
|
||||
};
|
||||
use unlucky_presenter::state::AppState;
|
||||
|
||||
use super::model::{DevicePanel, Form};
|
||||
use super::avatar::Cache;
|
||||
use super::login::browser_note_text;
|
||||
use super::model::{DevicePanel, Form, Page};
|
||||
use super::tests::build;
|
||||
use super::tests_text::{has_cjk, on_screen};
|
||||
|
||||
/// 中文界面的同一页(`build` 钉死英文,这里要中文那一套)。
|
||||
fn build_zh(state: &AppState, form: &Form) -> Page {
|
||||
Page::build(state, form, &mut Cache::default(), Lang::ZhCn, 1180.0)
|
||||
}
|
||||
|
||||
/// **M178 守卫(页面这一半)**:浏览器登录没走成、已回退设备码时,页面上要有一句**可读的**
|
||||
/// 中文/英文说明(含「怎么修」),且**不是** AAD 的原始报文。
|
||||
///
|
||||
@@ -60,6 +68,37 @@ application."
|
||||
);
|
||||
assert!(!text.contains("AADSTS"), "原始报文不许上屏:{text}");
|
||||
assert!(!text.contains("is not valid"), "原始报文不许上屏:{text}");
|
||||
|
||||
// 3. 中文界面同样走一遍(复审 r1 P2-4):文案来自**视图自己的出口**
|
||||
// `login::browser_note_text`,中文要有、可照抄的修法要有、AAD 原始码不许有。
|
||||
let zh = build_zh(&state, &Form::new());
|
||||
let zh_text = on_screen(&zh);
|
||||
assert!(has_cjk(&zh_text), "中文界面要真有中文:{zh_text}");
|
||||
let note_text = browser_note_text(
|
||||
BrowserLoginNote::RedirectNotRegistered,
|
||||
Lang::ZhCn.catalog(),
|
||||
);
|
||||
assert!(
|
||||
note_text.contains("http://localhost/auth"),
|
||||
"中文文案也要给出可照抄的重定向 URI:{note_text}"
|
||||
);
|
||||
assert!(note_text.contains("重定向 URI"), "{note_text}");
|
||||
assert!(note_text.contains("设备码"), "{note_text}");
|
||||
for forbidden in ["invalid_request", "AADSTS", "is not valid", "{"] {
|
||||
assert!(
|
||||
!zh_text.contains(forbidden),
|
||||
"中文界面混进了原始报文:{zh_text}"
|
||||
);
|
||||
assert!(
|
||||
!note_text.contains(forbidden),
|
||||
"词条混进了原始报文:{note_text}"
|
||||
);
|
||||
}
|
||||
// 视图那一行的来源就是这一条文案:取样函数与视图共用同一个出口(不是测试另写映射)。
|
||||
assert_eq!(
|
||||
browser_note_text(BrowserLoginNote::PreflightUnavailable, Lang::En.catalog()),
|
||||
"Could not confirm that browser sign-in is usable, so it switched to a device code."
|
||||
);
|
||||
}
|
||||
|
||||
/// 回退之后设备码又失败:失败面板上**两者都要在**(失败原因 + 为什么没走浏览器)。
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
use unlucky_presenter::dto::AccountNoticeCode;
|
||||
|
||||
use super::login::{failure_body_key, failure_title_key};
|
||||
use super::login::{browser_note_text, failure_body_key, failure_title_key};
|
||||
use super::model::{AuthlibPanel, DevicePanel, Hint, Page};
|
||||
|
||||
/// 页面**会画什么**:模型里的数据 + 视图层对码的 i18n 查表结果。
|
||||
@@ -68,9 +68,10 @@ pub(super) fn on_screen(page: &Page) -> String {
|
||||
out.push_str(link);
|
||||
// 倒计时文案由模型拼好(M178:owned 文字路径画不出中文)。
|
||||
out.push_str(countdown);
|
||||
// M178:浏览器登录没走成时那一句「为什么 + 怎么修」也在这条取样线上。
|
||||
// M178:回退说明走**视图自己的**文案出口(`login::browser_note_text`),
|
||||
// 不是测试另写一份映射——否则视图哪天不画这一行,取样仍会绿。
|
||||
if let Some(note) = note {
|
||||
out.push_str(catalog.text(super::model::note_key(*note)));
|
||||
out.push_str(browser_note_text(*note, catalog));
|
||||
}
|
||||
}
|
||||
DevicePanel::Browser {
|
||||
@@ -84,7 +85,7 @@ pub(super) fn on_screen(page: &Page) -> String {
|
||||
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)));
|
||||
out.push_str(browser_note_text(*note, catalog));
|
||||
}
|
||||
}
|
||||
DevicePanel::Succeeded { name } => {
|
||||
|
||||
Reference in new issue
Block a user