mirror of
https://18126008609:longquanjian123@gitee.com/feigong123/aurask.git
synced 2026-04-19 20:03:53 +00:00
feat: add bilingual portal signin flow
All checks were successful
aurask-release / build-and-deploy (push) Successful in 1m56s
All checks were successful
aurask-release / build-and-deploy (push) Successful in 1m56s
This commit is contained in:
parent
4e2639ea43
commit
c2fc63f5f1
37
README.md
37
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=<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
|
||||
- 门户双语登录与工作台壳层
|
||||
|
||||
## 相关文档
|
||||
|
||||
|
||||
@ -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/`
|
||||
|
||||
本地调试时会自动回退到:
|
||||
|
||||
|
||||
564
protal/main.js
564
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 `
|
||||
<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();
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user