From c2fc63f5f15244ae07cd3a33441161d6192ed1ba Mon Sep 17 00:00:00 2001 From: Aaron <530816249@qq.com> Date: Sun, 19 Apr 2026 23:15:04 +0800 Subject: [PATCH] feat: add bilingual portal signin flow --- README.md | 37 ++- protal/README.md | 52 +++-- protal/main.js | 564 ++++++++++++++++++++++++++++++++++------------ protal/styles.css | 56 ++++- 4 files changed, 532 insertions(+), 177 deletions(-) diff --git a/README.md b/README.md index df85249..b11d5c7 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,26 @@ ## Aurask -Aurask 当前是一个按产品边界拆分的首版实现,根目录保持为四个主要目录: +Aurask 当前按产品边界拆分为四个主要目录: -- `api/`:后端网关、会话登录、配额/TBU、工作流、知识库桥接 +- `api/`:后端 API、会话登录、套餐 TBU、工作流与知识库桥接 - `protal/`:用户门户,保留既定目录拼写 - `manager/`:管理员面板 - `deploy/`:k3s 与 DevCloud 部署配置 -当前版本已经补齐了用户门户登录闭环: +## 当前实现范围 -- `/signin` 风格化登录页 -- Google 首次注册/登录后自动创建独立 workspace -- 登录后双标签页工作台: +- `/signin` 登录页 +- Google 首次注册 / 登录后自动创建独立 workspace +- 登录后双标签工作台: - `Workflows` 内嵌 `Langflow` - `Knowledge Base` 内嵌 `AnythingLLM` -- 右上角个人中心与登出 +- 右上角个人中心与退出登录 +- 门户双语支持: + - `/signin` 提供 `EN / 中文` 语言切换 + - `/app` 工作台壳层跟随已保存语言 + - 首次访问默认读取浏览器语言 + - 用户选择写入 `localStorage` +- Google 登录按钮默认灰色禁用,只有在配置完成且 Google SDK 就绪后才替换为真实按钮 ## 本地运行 @@ -25,14 +31,14 @@ $env:PYTHONPATH='api' py -3 -m aurask serve --reset --host 127.0.0.1 --port 8080 ``` -运行演示: +运行演示数据: ```powershell $env:PYTHONPATH='api' py -3 -m aurask demo --reset ``` -门户静态文件位于 `protal/`,部署到静态 Web 服务后即可使用。开发时可直接由本地静态服务器打开 `protal/index.html`。 +门户静态文件位于 `protal/`,部署到静态 Web 服务后即可使用。开发时可直接通过本地静态服务器打开 `protal/index.html`。 ## 当前接口 @@ -61,9 +67,9 @@ py -3 -m aurask demo --reset - `GET /workflow-runs/{run_id}` - `GET /admin/bridge-status` -Aurask 现在同时支持两类 Bearer Token: +Aurask 目前同时支持两类 Bearer Token: -- API Key:用于原有 API/集成访问 +- API Key:用于原生 API / 集成访问 - Session Token:用于门户登录态 ## 关键环境变量 @@ -80,6 +86,12 @@ AURASK_GOOGLE_CLIENT_ID= AURASK_SESSION_TTL_DAYS=7 ``` +说明: + +- 未设置 `AURASK_GOOGLE_CLIENT_ID` 时,`/signin` 会显示灰色禁用的 Google 按钮 +- 当 `auth.google.enabled` 为真、`client_id` 存在且 Google SDK 加载完成后,前端才会挂载真实按钮 +- 语言偏好保存在浏览器 `localStorage` 的 `aurask.portal.locale` + 运行时桥接: ```text @@ -115,11 +127,12 @@ $env:PYTHONPATH='api' cmd /c "py -3 -m unittest discover -s tests -v" ``` -当前已覆盖: +当前覆盖: - MVP 业务闭环 - PostgreSQL / PGVector / Redis / AnythingLLM / Langflow 桥接契约 - Google 登录与 session +- 门户双语登录与工作台壳层 ## 相关文档 diff --git a/protal/README.md b/protal/README.md index 084cd61..90d7264 100644 --- a/protal/README.md +++ b/protal/README.md @@ -1,34 +1,50 @@ # Aurask Protal -`protal/` 是用户门户目录,目录名按既定要求保留为 `protal`。 +`protal/` 是 Aurask 用户门户目录,目录名按既定要求保留为 `protal`。 -## 当前页面能力 +## 当前能力 -- 未登录时渲染 `/signin` 风格登录页 -- 支持 Google 登录入口 -- 新用户首次登录自动创建独立 workspace -- 登录后显示: - - `Workflows`:内嵌 Langflow - - `Knowledge Base`:内嵌 AnythingLLM -- 右上角个人中心展示用户、租户、workspace 与基础额度信息 +- 未登录用户访问 `/signin` 时展示 Aurask 自有登录页 +- 登录卡片右上角支持 `EN / 中文` 语言切换 +- 首次访问默认读取浏览器语言: + - `navigator.language` 以 `zh` 开头时默认中文 + - 其他情况默认英文 +- 用户手动切换语言后会写入 `localStorage` +- 语言范围覆盖 Aurask 自有门户界面: + - `/signin` + - 登录后的 `/app` 工作台壳层 +- 嵌入的 `Langflow` / `AnythingLLM` 保持其自身语言,不做翻译 + +## Google 登录行为 + +- 门户继续使用 `GET /auth/config` 返回的 Google 配置 +- 只有同时满足以下条件时才会挂载真实 Google 按钮: + - `auth.google.enabled === true` + - `auth.google.client_id` 有效 + - Google GSI SDK 已加载完成 +- 在配置缺失或 SDK 未就绪前,页面始终展示灰色禁用按钮 +- 禁用按钮不可点击,并通过辅助文案提示当前状态 +- 新用户首次成功登录后,后端仍会自动创建独立 workspace + +## 本地存储键 + +- `aurask.portal.session`:登录会话令牌 +- `aurask.portal.activeTab`:当前工作台标签 +- `aurask.portal.locale`:门户语言选择 ## 依赖接口 -门户依赖以下 API: - - `GET /auth/config` - `POST /auth/google/login` - `GET /auth/session` - `POST /auth/logout` -## 部署说明 +## 部署假设 -门户默认假设: - -- 生产环境通过 `https://aurask.xyz` -- API 通过 `https://aurask.xyz/api` -- Langflow iframe 通过 `https://aurask.xyz/runtime/langflow/` -- AnythingLLM iframe 通过 `https://aurask.xyz/runtime/anythingllm/` +- 生产门户地址:`https://aurask.xyz` +- 生产 API 地址:`https://aurask.xyz/api` +- Langflow iframe:`https://aurask.xyz/runtime/langflow/` +- AnythingLLM iframe:`https://aurask.xyz/runtime/anythingllm/` 本地调试时会自动回退到: diff --git a/protal/main.js b/protal/main.js index 71c1c28..5364e05 100644 --- a/protal/main.js +++ b/protal/main.js @@ -1,5 +1,140 @@ const STORAGE_KEY = "aurask.portal.session"; const TAB_KEY = "aurask.portal.activeTab"; +const LOCALE_KEY = "aurask.portal.locale"; +const SUPPORTED_LOCALES = ["en", "zh"]; + +const COPY = { + en: { + pageTitleSignin: "Aurask | Sign In", + pageTitleDashboard: "Aurask | Workspace", + status: { + api: "API", + web: "Web", + devcloud: "DevCloud API Image", + }, + signin: { + brand: "Aurask", + welcomeBack: "Welcome back", + title: "Sign in to Aurask", + subtitle: "Continue with Google to access your workspace or create a new one on first sign-in.", + continueWithGoogle: "Continue with Google", + helperReady: "Google sign-in is ready. First-time users get a dedicated Aurask workspace automatically.", + helperLoading: "Google sign-in is loading. Please wait a moment.", + helperUnavailable: "Google sign-in is unavailable until `AURASK_GOOGLE_CLIENT_ID` is configured.", + needWorkspace: "Need a first workspace?", + workspaceAuto: "Aurask provisions one automatically after your first successful sign-in.", + languageLabel: "Language", + heroTitle: "Ship AI workflows with private knowledge, safer by default.", + heroCopy: "Sign in to open your personal workspace, manage Langflow workflows, and use AnythingLLM knowledge bases from one portal.", + }, + dashboard: { + workspaceKicker: "Aurask Workspace", + workspaceSections: "Workspace sections", + tabs: { + workflows: "Workflows", + knowledge: "Knowledge Base", + }, + profileLabel: "Profile", + signOut: "Sign out", + personalCenter: "Personal Center", + userFallback: "Aurask User", + deployDefaults: "Deploy Defaults", + tenant: "Tenant", + workspace: "Workspace", + plan: "Plan", + tbu: "TBU", + knowledgeBases: "Knowledge Bases", + tenantId: "Tenant ID", + workspaceId: "Workspace ID", + apiImage: "API Image", + webImage: "Web Image", + apiGateway: "API Gateway", + langflowEmbed: "Langflow Embed", + anythingllmEmbed: "AnythingLLM Embed", + workflowStudio: "Workflow Studio", + langflow: "Langflow", + knowledgeBaseTitle: "Knowledge Base", + anythingllm: "AnythingLLM", + openNewTab: "Open in new tab", + plans: { + free_trial: "Free Trial", + basic_monthly: "Basic", + dedicated_space: "Dedicated Space", + }, + }, + messages: { + loading: "Loading Aurask...", + sessionExpired: "Your Aurask session has expired. Please sign in again.", + googleSignInFailed: "Google sign-in failed", + configLoadFailed: "Failed to load Aurask configuration", + }, + }, + zh: { + pageTitleSignin: "Aurask | 登录", + pageTitleDashboard: "Aurask | 工作台", + status: { + api: "接口", + web: "站点", + devcloud: "DevCloud API 镜像", + }, + signin: { + brand: "Aurask", + welcomeBack: "欢迎回来", + title: "登录 Aurask", + subtitle: "使用 Google 登录访问你的工作空间;首次登录时会自动创建专属 workspace。", + continueWithGoogle: "使用 Google 继续", + helperReady: "Google 登录已就绪,首次登录的用户会自动开通独立 Aurask workspace。", + helperLoading: "Google 登录组件正在加载,请稍候。", + helperUnavailable: "未配置 `AURASK_GOOGLE_CLIENT_ID` 前,Google 登录按钮会保持灰色禁用状态。", + needWorkspace: "还没有 workspace?", + workspaceAuto: "首次成功登录后,Aurask 会自动为你创建一个。", + languageLabel: "语言", + heroTitle: "以更稳妥的方式交付连接私有知识的 AI 工作流。", + heroCopy: "登录后即可进入你的个人 workspace,在同一门户中管理 Langflow 工作流并使用 AnythingLLM 知识库。", + }, + dashboard: { + workspaceKicker: "Aurask 工作台", + workspaceSections: "工作区导航", + tabs: { + workflows: "工作流", + knowledge: "知识库", + }, + profileLabel: "个人中心", + signOut: "退出登录", + personalCenter: "个人中心", + userFallback: "Aurask 用户", + deployDefaults: "部署信息", + tenant: "租户", + workspace: "工作空间", + plan: "套餐", + tbu: "TBU", + knowledgeBases: "知识库数量", + tenantId: "租户 ID", + workspaceId: "工作空间 ID", + apiImage: "API 镜像", + webImage: "Web 镜像", + apiGateway: "API 网关", + langflowEmbed: "Langflow 嵌入地址", + anythingllmEmbed: "AnythingLLM 嵌入地址", + workflowStudio: "工作流工作台", + langflow: "Langflow", + knowledgeBaseTitle: "知识库", + anythingllm: "AnythingLLM", + openNewTab: "新标签页打开", + plans: { + free_trial: "免费试用", + basic_monthly: "基础版", + dedicated_space: "独享空间", + }, + }, + messages: { + loading: "Aurask 加载中...", + sessionExpired: "Aurask 会话已过期,请重新登录。", + googleSignInFailed: "Google 登录失败", + configLoadFailed: "Aurask 配置加载失败", + }, + }, +}; const app = document.querySelector("#app"); @@ -8,10 +143,70 @@ const state = { sessionToken: window.localStorage.getItem(STORAGE_KEY) || "", profile: null, activeTab: window.localStorage.getItem(TAB_KEY) || "workflows", + locale: detectInitialLocale(), + googleSdkReady: Boolean(window.google?.accounts?.id), loading: true, - error: "", + errorKey: "", + errorDetail: "", }; +applyDocumentLanguage(); + +function normalizeLocale(locale) { + return SUPPORTED_LOCALES.includes(locale) ? locale : null; +} + +function detectInitialLocale() { + const storedLocale = normalizeLocale(window.localStorage.getItem(LOCALE_KEY)); + if (storedLocale) return storedLocale; + return String(window.navigator.language || "").toLowerCase().startsWith("zh") ? "zh" : "en"; +} + +function persistLocale(locale) { + state.locale = normalizeLocale(locale) || "en"; + window.localStorage.setItem(LOCALE_KEY, state.locale); + applyDocumentLanguage(); +} + +function applyDocumentLanguage() { + document.documentElement.lang = state.locale === "zh" ? "zh-CN" : "en"; +} + +function copy(path) { + return path.split(".").reduce((value, part) => value?.[part], COPY[state.locale]) ?? path; +} + +function setDocumentTitle() { + document.title = state.profile ? copy("pageTitleDashboard") : copy("pageTitleSignin"); +} + +function setLocale(locale) { + const nextLocale = normalizeLocale(locale); + if (!nextLocale || nextLocale === state.locale) return; + persistLocale(nextLocale); + render(); + if (!state.profile) { + mountGoogleButton(); + } +} + +function setError(key, detail = "") { + state.errorKey = key; + state.errorDetail = detail || ""; +} + +function clearError() { + state.errorKey = ""; + state.errorDetail = ""; +} + +function renderError() { + if (!state.errorKey) return ""; + const message = copy(state.errorKey); + if (!state.errorDetail) return escapeHtml(message); + return `${escapeHtml(message)}: ${escapeHtml(state.errorDetail)}`; +} + function detectApiBase() { if (window.location.protocol === "file:") return "http://127.0.0.1:8080"; if (["127.0.0.1", "localhost"].includes(window.location.hostname)) return "http://127.0.0.1:8080"; @@ -27,6 +222,7 @@ async function request(path, options = {}) { headers.set("Accept", "application/json"); if (options.body !== undefined) headers.set("Content-Type", "application/json"); if (state.sessionToken) headers.set("Authorization", `Bearer ${state.sessionToken}`); + const response = await fetch(`${normalizeApiBase(state.config?.public_api_base_url)}${path}`, { ...options, headers, @@ -99,126 +295,95 @@ function ensureDashboardRoute() { } } -async function bootstrap() { - state.loading = true; - render(); - try { - const activeApiBase = detectApiBase(); - const localHost = window.location.protocol === "file:" || ["127.0.0.1", "localhost"].includes(window.location.hostname); - state.config = await fetch(`${activeApiBase}/auth/config`).then((response) => response.json()); - state.config.public_api_base_url = normalizeApiBase(localHost ? activeApiBase : state.config.public_api_base_url || activeApiBase); - if (state.sessionToken) { - try { - state.profile = await request("/auth/session"); - ensureDashboardRoute(); - } catch (error) { - persistSession(""); - state.profile = null; - } - } - if (!state.profile) ensureSigninRoute(); - } catch (error) { - state.error = error.message || "Failed to load Aurask configuration"; - } finally { - state.loading = false; - render(); - mountGoogleButton(); - } -} - -async function signInWithGoogle(idToken) { - state.error = ""; - render(); - try { - const payload = await request("/auth/google/login", { - method: "POST", - body: { id_token: idToken }, - }); - persistSession(payload.token); - state.profile = payload; - ensureDashboardRoute(); - } catch (error) { - state.error = error.message || "Google sign-in failed"; - render(); - } -} - -async function logout() { - try { - await request("/auth/logout", { method: "POST" }); - } catch (error) { - console.warn("Logout request failed", error); - } - persistSession(""); - state.profile = null; - ensureSigninRoute(); - render(); - mountGoogleButton(); -} - -function mountGoogleButton() { - const googleBox = document.querySelector("#googleButton"); - if (!googleBox || !state.config?.auth?.google?.enabled || !state.config?.auth?.google?.client_id) return; - if (!window.google?.accounts?.id) return; - googleBox.innerHTML = ""; - window.google.accounts.id.initialize({ - client_id: state.config.auth.google.client_id, - callback: ({ credential }) => signInWithGoogle(credential), - }); - window.google.accounts.id.renderButton(googleBox, { - theme: "outline", - size: "large", - shape: "pill", - text: "signup_with", - width: 320, - }); +function googleStatus() { + const googleConfig = state.config?.auth?.google || {}; + if (!googleConfig.enabled || !googleConfig.client_id) return "unavailable"; + if (!state.googleSdkReady) return "loading"; + return "ready"; } function renderStatusPills() { if (!state.config) return ""; return `
- API: ${escapeHtml(state.config.public_api_base_url)} - Web: ${escapeHtml(state.config.public_base_url)} - DevCloud API Image Ready + ${escapeHtml(copy("status.api"))}: ${escapeHtml(state.config.public_api_base_url)} + ${escapeHtml(copy("status.web"))}: ${escapeHtml(state.config.public_base_url)} + ${escapeHtml(copy("status.devcloud"))}
`; } +function renderLocaleSwitcher() { + return ` +
+ + +
+ `; +} + +function renderSigninGoogleArea() { + if (googleStatus() === "ready") { + return ` +
+
+
+ `; + } + + return ` +
+ +
+ `; +} + +function renderSigninHelper() { + const status = googleStatus(); + if (status === "unavailable") return copy("signin.helperUnavailable"); + if (status === "loading") return copy("signin.helperLoading"); + return copy("signin.helperReady"); +} + +function formatPlanName(planCode) { + return copy(`dashboard.plans.${planCode}`) === `dashboard.plans.${planCode}` + ? planCode || "" + : copy(`dashboard.plans.${planCode}`); +} + function renderSignin() { - const googleConfig = state.config?.auth?.google || {}; - const googleEnabled = Boolean(googleConfig.enabled && googleConfig.client_id); app.innerHTML = `
`; + + document.querySelectorAll("[data-locale]").forEach((button) => { + button.addEventListener("click", () => setLocale(button.dataset.locale)); + }); } function renderDashboard() { @@ -229,25 +394,30 @@ function renderDashboard() { const config = state.profile?.config || state.config || {}; const langflowUrl = withEmbedContext(config.embeds?.langflow_url); const anythingllmUrl = withEmbedContext(config.embeds?.anythingllm_url); + app.innerHTML = `
A
-

Aurask Workspace

+

${escapeHtml(copy("dashboard.workspaceKicker"))}

${escapeHtml(tenant.name || "Aurask")}

-
@@ -278,34 +448,34 @@ function renderDashboard() {
@@ -313,26 +483,26 @@ function renderDashboard() {
-

Workflow Studio

-

Langflow

+

${escapeHtml(copy("dashboard.workflowStudio"))}

+

${escapeHtml(copy("dashboard.langflow"))}

- Open in new tab + ${escapeHtml(copy("dashboard.openNewTab"))}
- +
-

Knowledge Base

-

AnythingLLM

+

${escapeHtml(copy("dashboard.knowledgeBaseTitle"))}

+

${escapeHtml(copy("dashboard.anythingllm"))}

- Open in new tab + ${escapeHtml(copy("dashboard.openNewTab"))}
- +
- ${state.error ? `
${escapeHtml(state.error)}
` : ""} + ${state.errorKey ? `
${renderError()}
` : ""}
`; @@ -346,47 +516,149 @@ function renderLoading() { app.innerHTML = `
-

Loading Aurask...

+

${escapeHtml(copy("messages.loading"))}

`; } function render() { + applyDocumentLanguage(); + setDocumentTitle(); + if (state.loading) { renderLoading(); return; } + if (state.profile) { renderDashboard(); return; } + renderSignin(); } +async function bootstrap() { + state.loading = true; + clearError(); + render(); + + try { + const activeApiBase = detectApiBase(); + const localHost = + window.location.protocol === "file:" || ["127.0.0.1", "localhost"].includes(window.location.hostname); + + state.config = await fetch(`${activeApiBase}/auth/config`).then((response) => response.json()); + state.config.public_api_base_url = normalizeApiBase( + localHost ? activeApiBase : state.config.public_api_base_url || activeApiBase, + ); + state.googleSdkReady = state.googleSdkReady || Boolean(window.google?.accounts?.id); + + if (state.sessionToken) { + try { + state.profile = await request("/auth/session"); + ensureDashboardRoute(); + } catch (_error) { + persistSession(""); + state.profile = null; + } + } + + if (!state.profile) { + ensureSigninRoute(); + } + } catch (error) { + setError("messages.configLoadFailed", error.message || ""); + } finally { + state.loading = false; + render(); + mountGoogleButton(); + } +} + +async function signInWithGoogle(idToken) { + clearError(); + render(); + + try { + const payload = await request("/auth/google/login", { + method: "POST", + body: { id_token: idToken }, + }); + persistSession(payload.token); + state.profile = payload; + ensureDashboardRoute(); + render(); + } catch (error) { + setError("messages.googleSignInFailed", error.message || ""); + render(); + mountGoogleButton(); + } +} + +async function logout() { + try { + await request("/auth/logout", { method: "POST" }); + } catch (error) { + console.warn("Logout request failed", error); + } + + persistSession(""); + state.profile = null; + ensureSigninRoute(); + render(); + mountGoogleButton(); +} + +function mountGoogleButton() { + const googleBox = document.querySelector("#googleButton"); + if (!googleBox || googleStatus() !== "ready" || !window.google?.accounts?.id) return; + + googleBox.innerHTML = ""; + window.google.accounts.id.initialize({ + client_id: state.config.auth.google.client_id, + callback: ({ credential }) => signInWithGoogle(credential), + }); + + const width = Math.max(280, Math.floor(googleBox.getBoundingClientRect().width || 320)); + window.google.accounts.id.renderButton(googleBox, { + theme: "outline", + size: "large", + shape: "pill", + text: "continue_with", + width, + }); +} + window.addEventListener("popstate", render); -if (state.sessionToken) { - document.addEventListener("visibilitychange", async () => { - if (document.visibilityState !== "visible" || !state.profile) return; - try { - state.profile = await request("/auth/session"); - render(); - } catch (error) { - persistSession(""); - state.profile = null; - state.error = "Your Aurask session has expired. Please sign in again."; - ensureSigninRoute(); - render(); - mountGoogleButton(); - } - }); -} +document.addEventListener("visibilitychange", async () => { + if (document.visibilityState !== "visible" || !state.profile || !state.sessionToken) return; + + try { + state.profile = await request("/auth/session"); + render(); + } catch (_error) { + persistSession(""); + state.profile = null; + setError("messages.sessionExpired"); + ensureSigninRoute(); + render(); + mountGoogleButton(); + } +}); const googleScript = document.createElement("script"); googleScript.src = "https://accounts.google.com/gsi/client"; googleScript.async = true; googleScript.defer = true; -googleScript.onload = mountGoogleButton; +googleScript.onload = () => { + state.googleSdkReady = Boolean(window.google?.accounts?.id); + if (!state.profile && !state.loading) { + render(); + mountGoogleButton(); + } +}; document.head.appendChild(googleScript); bootstrap(); diff --git a/protal/styles.css b/protal/styles.css index 5ec89d7..d2acef7 100644 --- a/protal/styles.css +++ b/protal/styles.css @@ -139,6 +139,13 @@ button { backdrop-filter: blur(16px); } +.signin-header-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + .signin-header h2 { margin: 8px 0 8px; color: #0f172a; @@ -159,6 +166,40 @@ button { margin-top: 26px; } +.google-slot { + display: grid; + gap: 14px; + margin-top: 26px; +} + +.locale-switcher { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + border: 1px solid #dbe2f0; + border-radius: 999px; + background: #f8fafc; +} + +.locale-btn { + min-width: 56px; + min-height: 32px; + padding: 0 10px; + color: #64748b; + border: 0; + border-radius: 999px; + background: transparent; + font-size: 13px; + font-weight: 800; +} + +.locale-btn.is-active { + color: #ffffff; + background: linear-gradient(135deg, #2563eb, #0ea5e9); + box-shadow: 0 10px 22px rgba(37, 99, 235, 0.18); +} + .auth-btn { display: inline-flex; align-items: center; @@ -196,11 +237,19 @@ button { border: 1px solid #dbe7ff; } +.auth-btn-fallback { + color: #64748b; + border-color: #d7dee9; + background: linear-gradient(180deg, #f3f6fb, #e9eef6); + box-shadow: none; + opacity: 1; +} + .google-box { display: flex; justify-content: center; + width: 100%; min-height: 48px; - margin-top: 14px; } .ly-form label { @@ -567,6 +616,11 @@ iframe { flex-direction: column-reverse; } + .signin-header-top { + align-items: flex-start; + flex-direction: column; + } + .tabbar, .profile-menu summary, .profile-panel {