diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 46bae71..8e8863d 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -53,7 +53,9 @@ func (a *App) login(noBrowser bool, region string, progress progressEmitter) (ma } timeout = time.Duration(parsed) * time.Millisecond } - callback, err := waitForOAuthCallback(state, timeout) + callback, err := waitForOAuthCallbackWithPage(state, timeout, callbackPageConfig{ + Region: loginRegion, + }) if err != nil { return nil, err } @@ -284,6 +286,10 @@ type callbackPayload struct { } func waitForOAuthCallback(expectedState string, timeout time.Duration) (*callbackServer, error) { + return waitForOAuthCallbackWithPage(expectedState, timeout, callbackPageConfig{}) +} + +func waitForOAuthCallbackWithPage(expectedState string, timeout time.Duration, page callbackPageConfig) (*callbackServer, error) { wait := make(chan callbackPayload, 1) errs := make(chan error, 1) mux := http.NewServeMux() @@ -313,18 +319,22 @@ func waitForOAuthCallback(expectedState string, timeout time.Duration) (*callbac code := r.URL.Query().Get("code") state := r.URL.Query().Get("state") oauthErr := r.URL.Query().Get("error") + w.Header().Set("Content-Type", "text/html; charset=utf-8") switch { case oauthErr != "": - http.Error(w, "Agora CLI login failed. Return to the terminal for details.", http.StatusBadRequest) + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, renderOAuthCallbackErrorPage(page, "Agora CLI login failed", "Return to the terminal for details.")) errs <- fmt.Errorf("OAuth authorization failed: %s", oauthErr) case code == "" || state == "": - http.Error(w, "Agora CLI login callback was missing required fields.", http.StatusBadRequest) + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, renderOAuthCallbackErrorPage(page, "Missing callback fields", "The OAuth callback did not include the required code and state fields.")) case state != expectedState: - http.Error(w, "Agora CLI login state mismatch.", http.StatusBadRequest) + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, renderOAuthCallbackErrorPage(page, "Login state mismatch", "Return to the terminal and restart login to protect this session.")) errs <- errors.New("OAuth state mismatch.") default: w.WriteHeader(http.StatusOK) - _, _ = io.WriteString(w, `Agora CLI Login Complete

Agora CLI login complete

You can close this browser window and return to your terminal.

`) + _, _ = io.WriteString(w, renderOAuthCallbackSuccessPage(page)) wait <- callbackPayload{Code: code, State: state} } }) diff --git a/internal/cli/oauth_pages.go b/internal/cli/oauth_pages.go new file mode 100644 index 0000000..e60ea3d --- /dev/null +++ b/internal/cli/oauth_pages.go @@ -0,0 +1,157 @@ +package cli + +import ( + "html" + "strings" +) + +// callbackPageConfig carries the login region used to brand the browser callback page. +type callbackPageConfig struct { + Region string +} + +// loginPageContent contains the region-specific copy rendered on the OAuth callback page. +type loginPageContent struct { + PageClass string + Title string + Message string + ActionLabel string + PrimaryAction string + Safety string +} + +// renderOAuthCallbackSuccessPage renders the browser page shown after a successful OAuth callback. +func renderOAuthCallbackSuccessPage(config callbackPageConfig) string { + content := loginPageContentForRegion(config.Region) + return renderOAuthCallbackPage(content, false, "", "") +} + +// renderOAuthCallbackErrorPage renders a branded browser page for OAuth callback errors. +func renderOAuthCallbackErrorPage(config callbackPageConfig, title, message string) string { + content := loginPageContentForRegion(config.Region) + return renderOAuthCallbackPage(content, true, title, message) +} + +// loginPageContentForRegion returns the final login-page copy for the active control-plane region. +func loginPageContentForRegion(region string) loginPageContent { + if normalizeContextRegion(region) == regionCN { + return loginPageContent{ + PageClass: "shengwang-page", + Title: "你已成功登录声网 CLI", + Message: "此浏览器步骤已完成。CLI 现在可以将此账号作为当前本地配置继续使用。", + ActionLabel: "回到终端后确认当前登录状态。", + PrimaryAction: "agora auth status", + Safety: "你可以关闭此页面,回到终端继续操作。", + } + } + + return loginPageContent{ + PageClass: "agora-page", + Title: "You are now authenticated with Agora CLI", + Message: "This browser step completed successfully. The CLI can now use this account as your active local configuration.", + ActionLabel: "Return to your terminal and confirm the active account.", + PrimaryAction: "agora auth status", + Safety: "You can close this window and return to your terminal.", + } +} + +// renderOAuthCallbackPage builds the complete OAuth callback HTML document. +func renderOAuthCallbackPage(content loginPageContent, isError bool, errorTitle, errorMessage string) string { + title := content.Title + message := content.Message + if isError { + title = valueOrDefault(errorTitle, "Login could not be completed") + message = valueOrDefault(errorMessage, "Return to your terminal for details.") + } + + var b strings.Builder + b.WriteString(`Agora CLI Login
`) + b.WriteString(brandLogoHTML(content.PageClass)) + b.WriteString(`CLI

`) + b.WriteString(escapeText(title)) + b.WriteString(`

`) + b.WriteString(escapeText(message)) + b.WriteString(`

`) + if !isError { + b.WriteString(``) + } + b.WriteString(`

`) + b.WriteString(escapeText(content.Safety)) + b.WriteString(`

`) + + return b.String() +} + +// loginPageCSS returns shared page CSS plus the theme for the active login region. +func loginPageCSS(pageClass string) string { + var b strings.Builder + b.WriteString(` +*{box-sizing:border-box} +body{margin:0;min-height:100vh;font-family:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;-webkit-font-smoothing:antialiased} +.page{min-height:100vh;display:grid;place-items:center;padding:64px 24px;color:var(--ink);background:var(--bg)} +.card{width:min(760px,100%);padding:40px;border:1px solid var(--line);border-radius:20px;background:var(--panel);box-shadow:var(--shadow)} +.brand{display:inline-flex;align-items:center;gap:10px;color:var(--ink);font-size:22px;font-weight:800;letter-spacing:-.01em} +.brand-logo{display:inline-flex;align-items:center;flex:0 0 auto} +.brand-logo svg{display:block;width:100%;height:100%} +.brand-logo img{display:block;width:100%;height:auto} +.brand-logo-agora{width:82px;max-height:32px} +.hero{margin-top:36px} +h1{margin:0;max-width:700px;font-size:clamp(26px,3vw,31px);line-height:1.22;letter-spacing:-.03em} +.message{max-width:680px;margin:18px 0 0;color:var(--muted);font-size:16px;line-height:1.65} +.next{display:grid;grid-template-columns:1fr auto;gap:18px;align-items:center;margin-top:28px;padding:14px 16px;border:1px solid var(--next-line);border-radius:12px;background:var(--next-bg)} +.next p{margin:0;color:var(--next-text);font-size:14px;font-weight:600} +code{padding:5px 9px;border:1px solid var(--code-line);border-radius:8px;background:var(--code-bg);color:var(--primary);font:700 14px ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap} +.safety{margin:20px 0 0;color:var(--muted);font-size:13px;line-height:1.6} +.is-error .next{display:none} +@media (max-width:900px){ + .page{padding:32px 18px} + .card{padding:28px} + .next{grid-template-columns:1fr} + code{white-space:normal;word-break:break-word} +} +`) + + if pageClass == "shengwang-page" { + b.WriteString(` +.shengwang-page{--ink:#152033;--muted:#617083;--primary:#0b9dfd;--brand:#0b9dfd;--line:#dce7f5;--panel:#fff;--shadow:0 20px 55px rgba(21,45,85,.1);--next-line:#dce7f5;--next-bg:#f7fbff;--next-text:#46576c;--code-line:#cfe0f8;--code-bg:#eef6ff;--bg:linear-gradient(180deg,#f6faff 0%,#fff 58%,#f9fbfd 100%)} +.brand-logo-cn{width:58px;height:auto;color:var(--brand)} +.brand-logo-cn svg{width:58px;height:auto} +`) + return b.String() + } + + b.WriteString(` +.agora-page{--ink:#172033;--muted:#647084;--primary:#09976f;--line:#dfe5ee;--panel:#fff;--shadow:0 20px 55px rgba(22,33,55,.1);--next-line:#dfe5ee;--next-bg:#f8fafc;--next-text:#4c596c;--code-line:#d8e2ee;--code-bg:#f3f6fa;--bg:linear-gradient(180deg,#f8fafc 0%,#fff 58%,#f6f8fb 100%)} +`) + return b.String() +} + +// brandLogoHTML returns the official region-specific brand mark used in the callback page. +func brandLogoHTML(pageClass string) string { + if pageClass == "shengwang-page" { + return `` + } + + return `` +} + +// escapeText escapes user-visible text before it is inserted into HTML. +func escapeText(value string) string { + return html.EscapeString(value) +} + +// escapeAttr escapes attribute values before they are inserted into HTML. +func escapeAttr(value string) string { + return html.EscapeString(value) +} diff --git a/internal/cli/oauth_pages_test.go b/internal/cli/oauth_pages_test.go new file mode 100644 index 0000000..ec7eab8 --- /dev/null +++ b/internal/cli/oauth_pages_test.go @@ -0,0 +1,90 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestOAuthCallbackSuccessPageUsesFinalGlobalDesign(t *testing.T) { + html := renderOAuthCallbackSuccessPage(callbackPageConfig{Region: regionGlobal}) + + for _, want := range []string{ + "agora-page", + "Agora%20Logo%20Crisp.webp", + "You are now authenticated with Agora CLI", + "This browser step completed successfully. The CLI can now use this account as your active local configuration.", + "agora auth status", + "You can close this window and return to your terminal.", + } { + if !strings.Contains(html, want) { + t.Fatalf("expected global login page to contain %q", want) + } + } + + for _, forbidden := range []string{"agora whoami", "global-minimal", "global-deck", "shengwang", "声网", "brand-logo-cn"} { + if strings.Contains(html, forbidden) { + t.Fatalf("global login page should not contain legacy variant content %q", forbidden) + } + } +} + +func TestOAuthCallbackSuccessPageDefaultsToGlobalBranding(t *testing.T) { + html := renderOAuthCallbackSuccessPage(callbackPageConfig{Region: "test"}) + + for _, want := range []string{ + "agora-page", + "Agora%20Logo%20Crisp.webp", + "You are now authenticated with Agora CLI", + } { + if !strings.Contains(html, want) { + t.Fatalf("expected fallback login page to contain %q", want) + } + } + + for _, forbidden := range []string{"shengwang", "声网", "brand-logo-cn"} { + if strings.Contains(html, forbidden) { + t.Fatalf("fallback login page should not contain China branding %q", forbidden) + } + } +} + +func TestOAuthCallbackSuccessPageUsesFinalChinaDesign(t *testing.T) { + html := renderOAuthCallbackSuccessPage(callbackPageConfig{Region: regionCN}) + + for _, want := range []string{ + "shengwang-page", + "brand-logo-cn", + "你已成功登录声网 CLI", + "此浏览器步骤已完成。CLI 现在可以将此账号作为当前本地配置继续使用。", + "agora auth status", + "你可以关闭此页面,回到终端继续操作。", + } { + if !strings.Contains(html, want) { + t.Fatalf("expected China login page to contain %q", want) + } + } + + for _, forbidden := range []string{"agora whoami", "cn-console", "cn-silk"} { + if strings.Contains(html, forbidden) { + t.Fatalf("China login page should not contain legacy variant content %q", forbidden) + } + } +} + +func TestOAuthCallbackErrorPageKeepsRegionBranding(t *testing.T) { + html := renderOAuthCallbackErrorPage(callbackPageConfig{Region: regionCN}, "Login state mismatch", "Return to the terminal and restart login.") + + for _, want := range []string{ + "shengwang-page is-error", + "Login state mismatch", + "Return to the terminal and restart login.", + } { + if !strings.Contains(html, want) { + t.Fatalf("expected error login page to contain %q", want) + } + } + + if strings.Contains(html, "agora auth status") { + t.Fatal("error login page should not show success action command") + } +}