mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-20 06:51:38 +00:00
feat(macos): redesign device-code sign-in (#104766)
* feat(macos): redesign device-code sign-in * chore(i18n): refresh native string inventory * fix(macos): localize device-code completion action * fix(auth): preserve device-code fallback copy * fix(xai): report browser launch accurately
This commit is contained in:
committed by
GitHub
parent
0ea7b2a543
commit
847460600f
@@ -19451,7 +19451,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 509,
|
||||
"line": 518,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Open sign-in page…",
|
||||
"surface": "apple",
|
||||
@@ -19459,7 +19459,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 516,
|
||||
"line": 525,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Starting secure sign-in…",
|
||||
"surface": "apple",
|
||||
@@ -19467,7 +19467,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 522,
|
||||
"line": 531,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Sign-in didn’t complete",
|
||||
"surface": "apple",
|
||||
@@ -19475,7 +19475,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 532,
|
||||
"line": 541,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Cancel",
|
||||
"surface": "apple",
|
||||
@@ -19483,7 +19483,39 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 572,
|
||||
"line": 564,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Finish in your browser",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.012f9f442abb9624"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 580,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Copy",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.d9f2c3523534fd83"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 591,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Expires in \\(minutes) minutes",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.acb7041e820bfb87"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 598,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Open sign-in page",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.3e537aee648f41a5"
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 626,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Option",
|
||||
"surface": "apple",
|
||||
@@ -19491,15 +19523,23 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 578,
|
||||
"line": 632,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Confirm",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.675c1390266b5ccb"
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 641,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "I've signed in",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.707648a54749ed5b"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 586,
|
||||
"line": 643,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Continue",
|
||||
"surface": "apple",
|
||||
@@ -19507,7 +19547,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 586,
|
||||
"line": 643,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Submit",
|
||||
"surface": "apple",
|
||||
@@ -19515,7 +19555,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 591,
|
||||
"line": 648,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Connect with an API key or token",
|
||||
"surface": "apple",
|
||||
@@ -19523,7 +19563,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 594,
|
||||
"line": 651,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Provider",
|
||||
"surface": "apple",
|
||||
@@ -19531,7 +19571,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 602,
|
||||
"line": 659,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "API key or token",
|
||||
"surface": "apple",
|
||||
@@ -19539,7 +19579,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 614,
|
||||
"line": 671,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Connect",
|
||||
"surface": "apple",
|
||||
@@ -19547,7 +19587,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-named-argument",
|
||||
"line": 627,
|
||||
"line": 684,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "That key didn’t work",
|
||||
"surface": "apple",
|
||||
@@ -19555,7 +19595,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 653,
|
||||
"line": 710,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Crestodian — setup helper",
|
||||
"surface": "apple",
|
||||
@@ -19563,7 +19603,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 656,
|
||||
"line": 713,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Done",
|
||||
"surface": "apple",
|
||||
@@ -19571,7 +19611,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 717,
|
||||
"line": 774,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Open help…",
|
||||
"surface": "apple",
|
||||
@@ -19579,7 +19619,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 756,
|
||||
"line": 813,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Hide details",
|
||||
"surface": "apple",
|
||||
@@ -19587,7 +19627,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 756,
|
||||
"line": 813,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Show details",
|
||||
"surface": "apple",
|
||||
@@ -19595,7 +19635,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-call",
|
||||
"line": 779,
|
||||
"line": 836,
|
||||
"path": "apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift",
|
||||
"source": "Copy error",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -494,10 +494,17 @@ struct OnboardingAISetupView: View {
|
||||
}
|
||||
|
||||
if let step = self.model.authStep {
|
||||
if let title = step.title, !title.isEmpty {
|
||||
let deviceCode = parseWizardDeviceCode(step.devicecode)
|
||||
if deviceCode == nil,
|
||||
let title = step.title,
|
||||
!title.isEmpty,
|
||||
title != self.model.activeAuthOption?.label
|
||||
{
|
||||
Text(title).font(.headline)
|
||||
}
|
||||
if let message = step.message, !message.isEmpty {
|
||||
if let deviceCode {
|
||||
self.deviceCodeStep(deviceCode)
|
||||
} else if let message = step.message, !message.isEmpty {
|
||||
ScrollView {
|
||||
Text(message)
|
||||
.textSelection(.enabled)
|
||||
@@ -505,7 +512,9 @@ struct OnboardingAISetupView: View {
|
||||
}
|
||||
.frame(maxHeight: 190)
|
||||
}
|
||||
if let url = OnboardingProviderAuthLink.safeURL(step.externalurl) {
|
||||
if deviceCode == nil,
|
||||
let url = OnboardingProviderAuthLink.safeURL(step.externalurl)
|
||||
{
|
||||
Link("Open sign-in page…", destination: url)
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
@@ -549,6 +558,51 @@ struct OnboardingAISetupView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func deviceCodeStep(_ deviceCode: WizardDeviceCodePresentation) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Finish in your browser")
|
||||
.font(.headline)
|
||||
Text(deviceCode.message ?? "Enter this one-time code on the provider's sign-in page.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text(deviceCode.code)
|
||||
.font(.system(.title2, design: .monospaced).weight(.semibold))
|
||||
.textSelection(.enabled)
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(deviceCode.code, forType: .string)
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color(NSColor.controlBackgroundColor)))
|
||||
|
||||
HStack(spacing: 12) {
|
||||
if let minutes = deviceCode.expiresInMinutes {
|
||||
Label("Expires in \(minutes) minutes", systemImage: "clock")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if let url = OnboardingProviderAuthLink.safeURL(self.model.authStep?.externalurl) {
|
||||
Link(destination: url) {
|
||||
Label("Open sign-in page", systemImage: "arrow.up.right.square")
|
||||
}
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openProviderAuthURLIfNeeded(_ rawURL: String?) {
|
||||
guard let url = OnboardingProviderAuthLink.safeURL(rawURL),
|
||||
url != openedProviderAuthURL
|
||||
@@ -583,6 +637,9 @@ struct OnboardingAISetupView: View {
|
||||
|
||||
private var authContinueTitle: String {
|
||||
guard let step = model.authStep else { return "Continue" }
|
||||
if parseWizardDeviceCode(step.devicecode) != nil {
|
||||
return String(localized: "I've signed in")
|
||||
}
|
||||
return wizardStepType(step) == "note" ? "Continue" : "Submit"
|
||||
}
|
||||
|
||||
|
||||
@@ -588,6 +588,23 @@ struct OnboardingAISetupTests {
|
||||
#expect(OnboardingProviderIcon.resourceURL(for: "gemini-cli") == nil)
|
||||
}
|
||||
|
||||
@Test func `device code presentation decodes structured wizard metadata`() throws {
|
||||
let presentation = try #require(parseWizardDeviceCode([
|
||||
"code": AnyCodable("ABCD-1234"),
|
||||
"expiresInMinutes": AnyCodable(15),
|
||||
"message": AnyCodable("Enter this code in your browser."),
|
||||
]))
|
||||
|
||||
#expect(presentation.code == "ABCD-1234")
|
||||
#expect(presentation.expiresInMinutes == 15)
|
||||
#expect(presentation.message == "Enter this code in your browser.")
|
||||
#expect(parseWizardDeviceCode(["code": AnyCodable("")]) == nil)
|
||||
#expect(parseWizardDeviceCode([
|
||||
"code": AnyCodable("ABCD-1234"),
|
||||
"expiresInMinutes": AnyCodable(1e100),
|
||||
])?.expiresInMinutes == nil)
|
||||
}
|
||||
|
||||
@Test func `provider auth transport outlives device code windows`() {
|
||||
#expect(OnboardingAISetupModel.providerAuthRequestTimeoutMs > 15 * 60 * 1000)
|
||||
}
|
||||
|
||||
@@ -5343,6 +5343,7 @@ public struct WizardStep: Codable, Sendable {
|
||||
public let sensitive: Bool?
|
||||
public let executor: AnyCodable?
|
||||
public let externalurl: String?
|
||||
public let devicecode: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
@@ -5355,7 +5356,8 @@ public struct WizardStep: Codable, Sendable {
|
||||
placeholder: String? = nil,
|
||||
sensitive: Bool? = nil,
|
||||
executor: AnyCodable? = nil,
|
||||
externalurl: String? = nil)
|
||||
externalurl: String? = nil,
|
||||
devicecode: [String: AnyCodable]? = nil)
|
||||
{
|
||||
self.id = id
|
||||
self.type = type
|
||||
@@ -5368,6 +5370,7 @@ public struct WizardStep: Codable, Sendable {
|
||||
self.sensitive = sensitive
|
||||
self.executor = executor
|
||||
self.externalurl = externalurl
|
||||
self.devicecode = devicecode
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -5382,6 +5385,7 @@ public struct WizardStep: Codable, Sendable {
|
||||
case sensitive
|
||||
case executor
|
||||
case externalurl = "externalUrl"
|
||||
case devicecode = "deviceCode"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,33 @@ public struct WizardOption: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct WizardDeviceCodePresentation: Sendable {
|
||||
public let code: String
|
||||
public let expiresInMinutes: Int?
|
||||
public let message: String?
|
||||
}
|
||||
|
||||
public func parseWizardDeviceCode(
|
||||
_ raw: [String: AnyCodable]?) -> WizardDeviceCodePresentation?
|
||||
{
|
||||
guard let code = raw?["code"]?.value as? String, !code.isEmpty else { return nil }
|
||||
let allowedMinutes = 1...1440
|
||||
let expiresInMinutes: Int? = switch raw?["expiresInMinutes"]?.value {
|
||||
case let value as Int where allowedMinutes.contains(value): value
|
||||
case let value as Double:
|
||||
if let exact = Int(exactly: value), allowedMinutes.contains(exact) {
|
||||
exact
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
default: nil
|
||||
}
|
||||
return WizardDeviceCodePresentation(
|
||||
code: code,
|
||||
expiresInMinutes: expiresInMinutes,
|
||||
message: raw?["message"]?.value as? String)
|
||||
}
|
||||
|
||||
public func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? {
|
||||
guard let raw else { return nil }
|
||||
do {
|
||||
|
||||
@@ -457,7 +457,7 @@ describe("xAI OAuth", () => {
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchImpl);
|
||||
const note = vi.fn<(message: string, title?: string) => Promise<void>>(async () => {});
|
||||
const deviceCode = vi.fn(async () => {});
|
||||
const openUrl = vi.fn(async () => {});
|
||||
const log = vi.fn();
|
||||
const runtime = { ...createRuntimeEnv(), log };
|
||||
@@ -467,7 +467,7 @@ describe("xAI OAuth", () => {
|
||||
openUrl,
|
||||
prompter: createTestWizardPrompter({
|
||||
progress: vi.fn(() => progress),
|
||||
note,
|
||||
deviceCode,
|
||||
}),
|
||||
runtime,
|
||||
oauth: {
|
||||
@@ -480,7 +480,15 @@ describe("xAI OAuth", () => {
|
||||
const result = await loginXaiDeviceCode(ctx);
|
||||
|
||||
expect(openUrl).toHaveBeenCalledWith("https://accounts.x.ai/oauth2/device?user_code=ABCD-1234");
|
||||
expect(note).toHaveBeenCalledWith(expect.stringContaining("ABCD-1234"), "xAI OAuth");
|
||||
expect(deviceCode).toHaveBeenCalledWith({
|
||||
title: "xAI OAuth",
|
||||
code: "ABCD-1234",
|
||||
expiresInMinutes: 15,
|
||||
message: "Enter this one-time code on the xAI sign-in page.",
|
||||
});
|
||||
expect(openUrl.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
deviceCode.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER,
|
||||
);
|
||||
const remoteLog = log.mock.calls[0]?.[0];
|
||||
expect(remoteLog).toContain("https://accounts.x.ai/oauth2/device");
|
||||
expect(remoteLog).not.toContain("ABCD-1234");
|
||||
|
||||
@@ -590,6 +590,15 @@ async function noteXaiDeviceCode(
|
||||
deviceCode: XaiDeviceCodeResponse,
|
||||
): Promise<void> {
|
||||
const expiresInMinutes = Math.max(1, Math.round(deviceCode.expiresInMs / 60_000));
|
||||
if (ctx.prompter.deviceCode) {
|
||||
await ctx.prompter.deviceCode({
|
||||
title: "xAI OAuth",
|
||||
code: deviceCode.userCode,
|
||||
expiresInMinutes,
|
||||
message: "Enter this one-time code on the xAI sign-in page.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
await ctx.prompter.note(
|
||||
[
|
||||
ctx.isRemote
|
||||
@@ -615,20 +624,19 @@ export async function loginXaiDeviceCode(ctx: ProviderAuthContext): Promise<Prov
|
||||
...(ctx.signal ? { signal: ctx.signal } : {}),
|
||||
});
|
||||
const browserUrl = deviceCode.verificationUriComplete ?? deviceCode.verificationUri;
|
||||
if (ctx.isRemote) {
|
||||
let openedBrowser = false;
|
||||
try {
|
||||
await ctx.openUrl(browserUrl);
|
||||
openedBrowser = true;
|
||||
} catch {
|
||||
ctx.runtime.log(`Open manually: ${deviceCode.verificationUri}`);
|
||||
}
|
||||
await noteXaiDeviceCode(ctx, deviceCode);
|
||||
const logUrl = deviceCode.verificationUri;
|
||||
if (ctx.isRemote) {
|
||||
ctx.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${logUrl}\n`);
|
||||
} else {
|
||||
try {
|
||||
await ctx.openUrl(browserUrl);
|
||||
ctx.runtime.log(`Open: ${logUrl}`);
|
||||
} catch {
|
||||
ctx.runtime.log(`Open manually: ${logUrl}`);
|
||||
}
|
||||
} else if (openedBrowser) {
|
||||
ctx.runtime.log(`Open: ${logUrl}`);
|
||||
}
|
||||
|
||||
progress.update("Waiting for xAI device authorization...");
|
||||
|
||||
@@ -61,6 +61,15 @@ export const WizardStepOptionSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const WizardDeviceCodeSchema = Type.Object(
|
||||
{
|
||||
code: NonEmptyString,
|
||||
expiresInMinutes: Type.Optional(Type.Integer({ minimum: 1, maximum: 1440 })),
|
||||
message: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
/** UI contract for one wizard step rendered by gateway clients. */
|
||||
export const WizardStepSchema = Type.Object(
|
||||
{
|
||||
@@ -83,6 +92,7 @@ export const WizardStepSchema = Type.Object(
|
||||
sensitive: Type.Optional(Type.Boolean()),
|
||||
executor: Type.Optional(Type.Union([Type.Literal("gateway"), Type.Literal("client")])),
|
||||
externalUrl: Type.Optional(Type.String()),
|
||||
deviceCode: Type.Optional(WizardDeviceCodeSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -49,10 +49,19 @@ export type WizardProgress = {
|
||||
stop: (message?: string) => void;
|
||||
};
|
||||
|
||||
export type WizardDeviceCodeParams = {
|
||||
title: string;
|
||||
code: string;
|
||||
expiresInMinutes?: number;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type WizardPrompter = {
|
||||
intro: (title: string) => Promise<void>;
|
||||
outro: (message: string) => Promise<void>;
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
/** Present a browser device code as structured UI when the client supports it. */
|
||||
deviceCode?: (params: WizardDeviceCodeParams) => Promise<void>;
|
||||
plain?: (message: string) => Promise<void>;
|
||||
select: <T>(params: WizardSelectParams<T>) => Promise<T>;
|
||||
multiselect: <T>(params: WizardMultiSelectParams<T>) => Promise<T[]>;
|
||||
|
||||
@@ -81,6 +81,32 @@ describe("WizardSession", () => {
|
||||
expect((await session.next()).status).toBe("done");
|
||||
});
|
||||
|
||||
test("carries device-code presentation without parsing provider prose", async () => {
|
||||
const session = new WizardSession(async (prompter) => {
|
||||
await prompter.openUrl?.("https://provider.example/device");
|
||||
await prompter.deviceCode?.({
|
||||
title: "Provider sign-in",
|
||||
code: "ABCD-1234",
|
||||
expiresInMinutes: 15,
|
||||
message: "Enter this one-time code in your browser.",
|
||||
});
|
||||
});
|
||||
|
||||
const first = await session.next();
|
||||
expect(first.step).toMatchObject({
|
||||
type: "note",
|
||||
title: "Provider sign-in",
|
||||
message:
|
||||
"Enter this one-time code in your browser.\nCode: ABCD-1234\nCode expires in 15 minutes. Never share it.",
|
||||
externalUrl: "https://provider.example/device",
|
||||
deviceCode: {
|
||||
code: "ABCD-1234",
|
||||
expiresInMinutes: 15,
|
||||
message: "Enter this one-time code in your browser.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid answers throw", async () => {
|
||||
const session = noteRunner();
|
||||
const first = await session.next();
|
||||
|
||||
@@ -23,6 +23,11 @@ export type WizardStep = {
|
||||
sensitive?: boolean;
|
||||
executor?: "gateway" | "client";
|
||||
externalUrl?: string;
|
||||
deviceCode?: {
|
||||
code: string;
|
||||
expiresInMinutes?: number;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type WizardSessionStatus = "running" | "done" | "cancelled" | "error";
|
||||
@@ -72,6 +77,32 @@ class WizardSessionPrompter implements WizardPrompter {
|
||||
await this.prompt({ type: "note", title, message, executor: "client" });
|
||||
}
|
||||
|
||||
async deviceCode(params: {
|
||||
title: string;
|
||||
code: string;
|
||||
expiresInMinutes?: number;
|
||||
message?: string;
|
||||
}): Promise<void> {
|
||||
const fallbackMessage = [
|
||||
params.message ?? "Enter this one-time code on the provider's sign-in page.",
|
||||
`Code: ${params.code}`,
|
||||
...(params.expiresInMinutes
|
||||
? [`Code expires in ${params.expiresInMinutes} minutes. Never share it.`]
|
||||
: []),
|
||||
].join("\n");
|
||||
await this.prompt({
|
||||
type: "note",
|
||||
title: params.title,
|
||||
message: fallbackMessage,
|
||||
deviceCode: {
|
||||
code: params.code,
|
||||
...(params.expiresInMinutes ? { expiresInMinutes: params.expiresInMinutes } : {}),
|
||||
...(params.message ? { message: params.message } : {}),
|
||||
},
|
||||
executor: "client",
|
||||
});
|
||||
}
|
||||
|
||||
async plain(message: string): Promise<void> {
|
||||
await this.prompt({ type: "note", message, format: "plain", executor: "client" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user