官方产物上点「微软登录」必失败: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 字面量)。
392 lines
13 KiB
Rust
392 lines
13 KiB
Rust
//! 微软登录面板:设备码(用户码 + 链接 + **倒计时** + 过期重试)与授权码(浏览器 + 可复制链接 + 取消)。
|
||
//!
|
||
//! ## 为什么不是「一个转圈」
|
||
//!
|
||
//! 设备码流的等待期可以长到 15 分钟。只画转圈的话,用户既不知道要做什么、也不知道还剩多久,
|
||
//! 更不知道已经过期了——M114 把这三件事都做成可见状态:用户码(可复制)、链接(可复制/打开)、
|
||
//! 倒计时(`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};
|
||
|
||
use unlucky_design::color::Role;
|
||
use unlucky_design::spacing::Space;
|
||
use unlucky_design::typography::TypeRole;
|
||
use unlucky_i18n::{Catalog, Key};
|
||
use unlucky_presenter::dto::{BrowserLoginNote, LoginFailure};
|
||
|
||
use crate::app::components::buttons::{self, Button};
|
||
use crate::pages::frame;
|
||
use crate::theme::Ui;
|
||
|
||
use crate::pages::account::model::DevicePanel;
|
||
use crate::pages::Message as PageMessage;
|
||
|
||
use super::Message as Account;
|
||
|
||
/// 微软登录面板。
|
||
pub fn panel<'a>(
|
||
ui: &'a Ui,
|
||
device: &'a DevicePanel,
|
||
catalog: Catalog,
|
||
) -> Element<'a, PageMessage> {
|
||
match device {
|
||
DevicePanel::Hidden => idle(ui, catalog),
|
||
DevicePanel::Requesting => requesting(ui, catalog),
|
||
DevicePanel::Code {
|
||
code,
|
||
link,
|
||
countdown,
|
||
expired,
|
||
note,
|
||
} => device_code(ui, code, link, countdown, *expired, *note, catalog),
|
||
DevicePanel::Browser {
|
||
link,
|
||
countdown,
|
||
expired,
|
||
} => browser(ui, link, countdown, *expired, catalog),
|
||
DevicePanel::Failed { failure, note } => failed(ui, *failure, *note, catalog),
|
||
DevicePanel::Succeeded { name } => succeeded(ui, name, catalog),
|
||
}
|
||
}
|
||
|
||
/// 空闲:两条流的入口。**主按钮是设备码**(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::AccountDeviceUseCode),
|
||
Account::StartDevice.into_page(),
|
||
)
|
||
),
|
||
buttons::outlined(
|
||
ui,
|
||
Button::new(
|
||
catalog.text(Key::AccountDeviceBrowser),
|
||
Account::StartBrowser.into_page(),
|
||
)
|
||
),
|
||
buttons::text(
|
||
ui,
|
||
Button::new(
|
||
catalog.text(Key::AccountDeviceCancel),
|
||
Account::Close.into_page()
|
||
)
|
||
),
|
||
]
|
||
.spacing(ui.space(Space::X2))
|
||
.align_y(Alignment::Center),]
|
||
.spacing(ui.space(Space::X3));
|
||
frame::card(ui, body)
|
||
}
|
||
|
||
/// 「正在申请」:一句 i18n 状态 + 取消。
|
||
///
|
||
/// presenter 那边的进度串是中文诊断(只进日志),面板态里**根本没有**这个字段可画。
|
||
fn requesting<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> {
|
||
let body = column![
|
||
frame::text_role(
|
||
ui,
|
||
TypeRole::BodyMedium,
|
||
false,
|
||
catalog.text(Key::AccountDeviceRequesting),
|
||
ui.color(Role::OnSurfaceVariant),
|
||
),
|
||
row![buttons::text(
|
||
ui,
|
||
Button::new(
|
||
catalog.text(Key::AccountDeviceCancel),
|
||
Account::Cancel.into_page()
|
||
)
|
||
)]
|
||
.align_y(Alignment::Center),
|
||
]
|
||
.spacing(ui.space(Space::X3));
|
||
frame::card(ui, body)
|
||
}
|
||
|
||
/// 设备码等待态:**码 + 链接 + 倒计时**(`note` = 浏览器登录没走成时的回退说明)。
|
||
fn device_code<'a>(
|
||
ui: &'a Ui,
|
||
code: &'a str,
|
||
link: &'a str,
|
||
countdown: &'a str,
|
||
expired: bool,
|
||
note: Option<BrowserLoginNote>,
|
||
catalog: Catalog,
|
||
) -> Element<'a, PageMessage> {
|
||
let mut actions = row![
|
||
buttons::tonal(
|
||
ui,
|
||
Button::new(
|
||
catalog.text(Key::AccountDeviceCodeCopy),
|
||
Account::Copy(code.to_string()).into_page(),
|
||
)
|
||
),
|
||
buttons::outlined(
|
||
ui,
|
||
Button::new(
|
||
catalog.text(Key::AccountDeviceLinkCopy),
|
||
Account::Copy(link.to_string()).into_page(),
|
||
)
|
||
),
|
||
]
|
||
.spacing(ui.space(Space::X2))
|
||
.align_y(Alignment::Center);
|
||
|
||
if expired {
|
||
actions = actions.push(buttons::filled(
|
||
ui,
|
||
Button::new(
|
||
catalog.text(Key::AccountDeviceRetry),
|
||
Account::StartDevice.into_page(),
|
||
),
|
||
));
|
||
}
|
||
actions = actions.push(buttons::text(
|
||
ui,
|
||
Button::new(
|
||
catalog.text(Key::AccountDeviceCancel),
|
||
Account::Cancel.into_page(),
|
||
),
|
||
));
|
||
|
||
let mut body = column![
|
||
frame::text_role(
|
||
ui,
|
||
TypeRole::LabelMedium,
|
||
false,
|
||
catalog.text(Key::AccountDeviceCodeLabel),
|
||
ui.color(Role::OnSurfaceVariant),
|
||
),
|
||
frame::text_role(
|
||
ui,
|
||
TypeRole::HeadlineSmall,
|
||
true,
|
||
code,
|
||
ui.color(Role::OnSurface),
|
||
),
|
||
frame::text_role(
|
||
ui,
|
||
TypeRole::BodySmall,
|
||
false,
|
||
link,
|
||
ui.color(Role::OnSurfaceVariant),
|
||
),
|
||
countdown_line(ui, countdown, expired),
|
||
frame::text_role(
|
||
ui,
|
||
TypeRole::BodySmall,
|
||
false,
|
||
catalog.text(Key::AccountDeviceHint),
|
||
ui.color(Role::OnSurfaceVariant),
|
||
),
|
||
]
|
||
.spacing(ui.space(Space::X3));
|
||
// 回退说明放在**动作行之前**:它解释的是「这个面板为什么是设备码」,
|
||
// 用户读完再决定复制码/取消(放在按钮下面会像脚注,读不到)。
|
||
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,
|
||
countdown: &'a str,
|
||
expired: bool,
|
||
catalog: Catalog,
|
||
) -> Element<'a, PageMessage> {
|
||
let mut actions = row![buttons::outlined(
|
||
ui,
|
||
Button::new(
|
||
catalog.text(Key::AccountDeviceLinkCopy),
|
||
Account::Copy(link.to_string()).into_page(),
|
||
)
|
||
)]
|
||
.spacing(ui.space(Space::X2))
|
||
.align_y(Alignment::Center);
|
||
if expired {
|
||
actions = actions.push(buttons::filled(
|
||
ui,
|
||
Button::new(
|
||
catalog.text(Key::AccountDeviceRetry),
|
||
Account::StartBrowser.into_page(),
|
||
),
|
||
));
|
||
}
|
||
actions = actions.push(buttons::text(
|
||
ui,
|
||
Button::new(
|
||
catalog.text(Key::AccountDeviceCancel),
|
||
Account::Cancel.into_page(),
|
||
),
|
||
));
|
||
|
||
let body = column![
|
||
frame::text_role(
|
||
ui,
|
||
TypeRole::BodyMedium,
|
||
false,
|
||
catalog.text(Key::AccountDeviceBrowserHint),
|
||
ui.color(Role::OnSurface),
|
||
),
|
||
frame::text_role(
|
||
ui,
|
||
TypeRole::BodySmall,
|
||
false,
|
||
link,
|
||
ui.color(Role::OnSurfaceVariant),
|
||
),
|
||
countdown_line(ui, countdown, expired),
|
||
actions,
|
||
]
|
||
.spacing(ui.space(Space::X3));
|
||
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));
|
||
}
|
||
frame::text_role(
|
||
ui,
|
||
TypeRole::LabelMedium,
|
||
false,
|
||
text,
|
||
ui.color(Role::OnSurfaceVariant),
|
||
)
|
||
}
|
||
|
||
/// 回退说明那一行(`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 换专属标题 + 「要设哪个环境变量」的
|
||
/// 正文,且**不给「重试」**——进程内的环境变量改不了,重试必然还是同一个错;
|
||
/// - **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) {
|
||
actions.push(buttons::tonal(
|
||
ui,
|
||
Button::new(
|
||
catalog.text(Key::AccountDeviceRetry),
|
||
Account::StartDevice.into_page(),
|
||
),
|
||
));
|
||
}
|
||
actions.push(buttons::text(
|
||
ui,
|
||
Button::new(
|
||
catalog.text(Key::AccountDialogClose),
|
||
Account::Close.into_page(),
|
||
),
|
||
));
|
||
super::widgets::alert(
|
||
ui,
|
||
super::widgets::Tone::Error,
|
||
catalog.text(failure_title_key(failure)),
|
||
body,
|
||
Some(code),
|
||
actions,
|
||
)
|
||
}
|
||
|
||
/// 成功态。
|
||
fn succeeded<'a>(ui: &'a Ui, name: &'a str, catalog: Catalog) -> Element<'a, PageMessage> {
|
||
// 成功也走 key + 参数(名字是 `{name}` 参数)——presenter 不再拼任何人类可读句子
|
||
// (复审 r1 P1-1 那条「报告与代码不符」的收口:`已登录:{name}` 这类拼接全部消失)。
|
||
let text = catalog.render(Key::AccountLoginSucceeded, &[("name", name)]);
|
||
let body = column![
|
||
frame::text_role_owned(
|
||
ui,
|
||
TypeRole::BodyMedium,
|
||
true,
|
||
text,
|
||
ui.color(Role::OnSurface),
|
||
),
|
||
row![buttons::text(
|
||
ui,
|
||
Button::new(
|
||
catalog.text(Key::AccountDialogClose),
|
||
Account::Close.into_page()
|
||
)
|
||
)]
|
||
.align_y(Alignment::Center),
|
||
]
|
||
.spacing(ui.space(Space::X3));
|
||
frame::card(ui, body)
|
||
}
|
||
|
||
/// 原因码 → i18n 正文 key(页面里唯一的「码→文案」映射点)。
|
||
pub(super) fn failure_body_key(failure: LoginFailure) -> Key {
|
||
match failure {
|
||
LoginFailure::Denied => Key::AccountFailureDenied,
|
||
LoginFailure::NotWhitelisted => Key::AccountFailureWhitelist,
|
||
LoginFailure::Unreachable => Key::AccountFailureUnreachable,
|
||
LoginFailure::NotAuthlibServer => Key::AccountFailureNotAuthlib,
|
||
LoginFailure::BadCredentials => Key::AccountFailureBadCredentials,
|
||
LoginFailure::NoProfile => Key::AccountFailureNoProfile,
|
||
LoginFailure::StoreFailed => Key::AccountFailureStore,
|
||
LoginFailure::MsaNotConfigured => Key::AccountFailureMsaNotConfigured,
|
||
LoginFailure::Other => Key::AccountFailureOther,
|
||
}
|
||
}
|
||
|
||
/// 原因码 → i18n **标题** key。
|
||
///
|
||
/// 只有「缺 client id」有专属标题(M132):标题写着 `Sign-in failed` 的话,用户会以为是自己
|
||
/// 的账号/网络出了问题,而不会被指向那一项要补的配置。
|
||
pub(super) fn failure_title_key(failure: LoginFailure) -> Key {
|
||
match failure {
|
||
LoginFailure::MsaNotConfigured => Key::AccountLoginNotConfigured,
|
||
_ => Key::AccountLoginFailed,
|
||
}
|
||
}
|