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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user