feat: add bilingual portal signin flow
All checks were successful
aurask-release / build-and-deploy (push) Successful in 1m56s

This commit is contained in:
Aaron 2026-04-19 23:15:04 +08:00
parent 4e2639ea43
commit c2fc63f5f1
4 changed files with 532 additions and 177 deletions

View File

@ -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=<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
- 门户双语登录与工作台壳层
## 相关文档

View File

@ -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/`
本地调试时会自动回退到:

View File

@ -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 `
<div class="pill-row">
<span class="pill">API: ${escapeHtml(state.config.public_api_base_url)}</span>
<span class="pill">Web: ${escapeHtml(state.config.public_base_url)}</span>
<span class="pill">DevCloud API Image Ready</span>
<span class="pill">${escapeHtml(copy("status.api"))}: ${escapeHtml(state.config.public_api_base_url)}</span>
<span class="pill">${escapeHtml(copy("status.web"))}: ${escapeHtml(state.config.public_base_url)}</span>
<span class="pill">${escapeHtml(copy("status.devcloud"))}</span>
</div>
`;
}
function renderLocaleSwitcher() {
return `
<div class="locale-switcher" aria-label="${escapeHtml(copy("signin.languageLabel"))}">
<button class="locale-btn ${state.locale === "en" ? "is-active" : ""}" data-locale="en" type="button" lang="en" aria-pressed="${state.locale === "en"}">EN</button>
<button class="locale-btn ${state.locale === "zh" ? "is-active" : ""}" data-locale="zh" type="button" lang="zh-CN" aria-pressed="${state.locale === "zh"}">中文</button>
</div>
`;
}
function renderSigninGoogleArea() {
if (googleStatus() === "ready") {
return `
<div class="google-slot">
<div id="googleButton" class="google-box"></div>
</div>
`;
}
return `
<div class="google-slot">
<button class="auth-btn auth-btn-secondary auth-btn-fallback" type="button" disabled aria-disabled="true">${escapeHtml(copy("signin.continueWithGoogle"))}</button>
</div>
`;
}
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 = `
<main class="signin-layout">
<section class="signin-brand">
<div class="brand-mark">A</div>
<p class="brand-eyebrow">Aurask</p>
<h1>Ship AI workflows with private knowledge, safer by default.</h1>
<p class="brand-copy">
Sign in to open your personal workspace, manage Langflow workflows, and use AnythingLLM knowledge bases from one portal.
</p>
<p class="brand-eyebrow">${escapeHtml(copy("signin.brand"))}</p>
<h1>${escapeHtml(copy("signin.heroTitle"))}</h1>
<p class="brand-copy">${escapeHtml(copy("signin.heroCopy"))}</p>
${renderStatusPills()}
</section>
<section class="signin-card">
<div class="signin-header">
<p class="section-kicker">Welcome back</p>
<h2>Sign in to Aurask</h2>
<p>Continue with Google to access your workspace or create a new one on first sign-in.</p>
<div class="signin-header-top">
<p class="section-kicker">${escapeHtml(copy("signin.welcomeBack"))}</p>
${renderLocaleSwitcher()}
</div>
<h2>${escapeHtml(copy("signin.title"))}</h2>
<p>${escapeHtml(copy("signin.subtitle"))}</p>
</div>
${googleEnabled ? '<div id="googleButton" class="google-box"></div>' : '<div class="signin-actions"><button class="auth-btn auth-btn-secondary" type="button" disabled>Continue with Google</button></div>'}
${
googleEnabled
? '<p class="signin-tip">Google new-user login provisions a dedicated Aurask workspace on first sign-in.</p>'
: '<p class="signin-tip">Set `AURASK_GOOGLE_CLIENT_ID` to enable Google one-tap registration.</p>'
}
${state.error ? `<p class="message error">${escapeHtml(state.error)}</p>` : ""}
${renderSigninGoogleArea()}
<p class="signin-tip">${escapeHtml(renderSigninHelper())}</p>
${state.errorKey ? `<p class="message error">${renderError()}</p>` : ""}
<div class="signin-footer">
<span>Need a first workspace?</span>
<span>Aurask creates one automatically after your first successful login.</span>
<span>${escapeHtml(copy("signin.needWorkspace"))}</span>
<span>${escapeHtml(copy("signin.workspaceAuto"))}</span>
</div>
</section>
</main>
`;
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 = `
<main class="dashboard-shell">
<header class="topbar">
<div class="topbar-left">
<div class="brand-mark brand-mark-small">A</div>
<div>
<p class="section-kicker">Aurask Workspace</p>
<p class="section-kicker">${escapeHtml(copy("dashboard.workspaceKicker"))}</p>
<h1>${escapeHtml(tenant.name || "Aurask")}</h1>
</div>
</div>
<div class="topbar-right">
<nav class="tabbar" aria-label="Workspace sections">
<button class="tab ${state.activeTab === "workflows" ? "is-active" : ""}" data-tab="workflows" type="button">Workflows</button>
<button class="tab ${state.activeTab === "knowledge" ? "is-active" : ""}" data-tab="knowledge" type="button">Knowledge Base</button>
<nav class="tabbar" aria-label="${escapeHtml(copy("dashboard.workspaceSections"))}">
<button class="tab ${state.activeTab === "workflows" ? "is-active" : ""}" data-tab="workflows" type="button">${escapeHtml(copy("dashboard.tabs.workflows"))}</button>
<button class="tab ${state.activeTab === "knowledge" ? "is-active" : ""}" data-tab="knowledge" type="button">${escapeHtml(copy("dashboard.tabs.knowledge"))}</button>
</nav>
<details class="profile-menu">
<summary>
${user.avatar_url ? `<img class="avatar-image" src="${escapeHtml(user.avatar_url)}" alt="${escapeHtml(user.display_name || user.email)}" />` : `<span class="avatar">${escapeHtml(initials(user.display_name || user.email))}</span>`}
<span class="profile-name">${escapeHtml(user.display_name || user.email || "Profile")}</span>
${
user.avatar_url
? `<img class="avatar-image" src="${escapeHtml(user.avatar_url)}" alt="${escapeHtml(user.display_name || user.email || copy("dashboard.profileLabel"))}" />`
: `<span class="avatar">${escapeHtml(initials(user.display_name || user.email))}</span>`
}
<span class="profile-name">${escapeHtml(user.display_name || user.email || copy("dashboard.profileLabel"))}</span>
</summary>
<div class="profile-panel">
<div class="profile-block">
@ -255,22 +425,22 @@ function renderDashboard() {
<span>${escapeHtml(user.email || "")}</span>
</div>
<div class="profile-block">
<span>Tenant</span>
<span>${escapeHtml(copy("dashboard.tenant"))}</span>
<strong>${escapeHtml(tenant.name || "")}</strong>
</div>
<div class="profile-block">
<span>Workspace</span>
<span>${escapeHtml(copy("dashboard.workspace"))}</span>
<strong>${escapeHtml(workspace.name || "")}</strong>
</div>
<div class="profile-block profile-grid">
<span>Plan</span>
<strong>${escapeHtml(quota.plan_code || "")}</strong>
<span>TBU</span>
<span>${escapeHtml(copy("dashboard.plan"))}</span>
<strong>${escapeHtml(formatPlanName(quota.plan_code))}</strong>
<span>${escapeHtml(copy("dashboard.tbu"))}</span>
<strong>${escapeHtml(String(quota.available_tbu ?? 0))}</strong>
<span>Knowledge Bases</span>
<span>${escapeHtml(copy("dashboard.knowledgeBases"))}</span>
<strong>${escapeHtml(String(quota.knowledge_bases ?? 0))}</strong>
</div>
<button id="logoutButton" class="auth-btn auth-btn-secondary" type="button">Sign out</button>
<button id="logoutButton" class="auth-btn auth-btn-secondary" type="button">${escapeHtml(copy("dashboard.signOut"))}</button>
</div>
</details>
</div>
@ -278,34 +448,34 @@ function renderDashboard() {
<section class="dashboard-grid">
<aside class="side-panel">
<section class="info-card">
<p class="section-kicker">Personal Center</p>
<h2>${escapeHtml(user.display_name || user.email || "Aurask User")}</h2>
<p class="section-kicker">${escapeHtml(copy("dashboard.personalCenter"))}</p>
<h2>${escapeHtml(user.display_name || user.email || copy("dashboard.userFallback"))}</h2>
<p>${escapeHtml(user.email || "")}</p>
<div class="stat-list">
<div class="stat-item">
<span>Tenant ID</span>
<span>${escapeHtml(copy("dashboard.tenantId"))}</span>
<strong>${escapeHtml(tenant.id || "")}</strong>
</div>
<div class="stat-item">
<span>Workspace ID</span>
<span>${escapeHtml(copy("dashboard.workspaceId"))}</span>
<strong>${escapeHtml(workspace.id || "")}</strong>
</div>
<div class="stat-item">
<span>API Image</span>
<span>${escapeHtml(copy("dashboard.apiImage"))}</span>
<strong>${escapeHtml(config.devcloud?.api_image || "")}</strong>
</div>
<div class="stat-item">
<span>Web Image</span>
<span>${escapeHtml(copy("dashboard.webImage"))}</span>
<strong>${escapeHtml(config.devcloud?.web_image || "")}</strong>
</div>
</div>
</section>
<section class="info-card">
<p class="section-kicker">Deploy Defaults</p>
<p class="section-kicker">${escapeHtml(copy("dashboard.deployDefaults"))}</p>
<ul class="plain-list">
<li>API Gateway: ${escapeHtml(config.public_api_base_url || "")}</li>
<li>Langflow Embed: ${escapeHtml(config.embeds?.langflow_url || "")}</li>
<li>AnythingLLM Embed: ${escapeHtml(config.embeds?.anythingllm_url || "")}</li>
<li>${escapeHtml(copy("dashboard.apiGateway"))}: ${escapeHtml(config.public_api_base_url || "")}</li>
<li>${escapeHtml(copy("dashboard.langflowEmbed"))}: ${escapeHtml(config.embeds?.langflow_url || "")}</li>
<li>${escapeHtml(copy("dashboard.anythingllmEmbed"))}: ${escapeHtml(config.embeds?.anythingllm_url || "")}</li>
</ul>
</section>
</aside>
@ -313,26 +483,26 @@ function renderDashboard() {
<div class="embed-card ${state.activeTab === "workflows" ? "" : "is-hidden"}">
<div class="embed-header">
<div>
<p class="section-kicker">Workflow Studio</p>
<h2>Langflow</h2>
<p class="section-kicker">${escapeHtml(copy("dashboard.workflowStudio"))}</p>
<h2>${escapeHtml(copy("dashboard.langflow"))}</h2>
</div>
<a href="${escapeHtml(langflowUrl)}" target="_blank" rel="noreferrer">Open in new tab</a>
<a href="${escapeHtml(langflowUrl)}" target="_blank" rel="noreferrer">${escapeHtml(copy("dashboard.openNewTab"))}</a>
</div>
<iframe title="Aurask Langflow" src="${escapeHtml(langflowUrl)}"></iframe>
<iframe title="${escapeHtml(copy("dashboard.langflow"))}" src="${escapeHtml(langflowUrl)}"></iframe>
</div>
<div class="embed-card ${state.activeTab === "knowledge" ? "" : "is-hidden"}">
<div class="embed-header">
<div>
<p class="section-kicker">Knowledge Base</p>
<h2>AnythingLLM</h2>
<p class="section-kicker">${escapeHtml(copy("dashboard.knowledgeBaseTitle"))}</p>
<h2>${escapeHtml(copy("dashboard.anythingllm"))}</h2>
</div>
<a href="${escapeHtml(anythingllmUrl)}" target="_blank" rel="noreferrer">Open in new tab</a>
<a href="${escapeHtml(anythingllmUrl)}" target="_blank" rel="noreferrer">${escapeHtml(copy("dashboard.openNewTab"))}</a>
</div>
<iframe title="Aurask AnythingLLM" src="${escapeHtml(anythingllmUrl)}"></iframe>
<iframe title="${escapeHtml(copy("dashboard.anythingllm"))}" src="${escapeHtml(anythingllmUrl)}"></iframe>
</div>
</section>
</section>
${state.error ? `<div class="toast error">${escapeHtml(state.error)}</div>` : ""}
${state.errorKey ? `<div class="toast error">${renderError()}</div>` : ""}
</main>
`;
@ -346,47 +516,149 @@ function renderLoading() {
app.innerHTML = `
<main class="loading-shell">
<div class="loader"></div>
<p>Loading Aurask...</p>
<p>${escapeHtml(copy("messages.loading"))}</p>
</main>
`;
}
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();

View File

@ -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 {