aurask/protal/main.js
Aaron c2fc63f5f1
All checks were successful
aurask-release / build-and-deploy (push) Successful in 1m56s
feat: add bilingual portal signin flow
2026-04-19 23:15:04 +08:00

665 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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");
const state = {
config: null,
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,
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";
return `${window.location.origin}/api`;
}
function normalizeApiBase(url) {
return (url || detectApiBase()).replace(/\/$/, "");
}
async function request(path, options = {}) {
const headers = new Headers(options.headers || {});
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,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message = payload?.error?.message || payload?.message || "Request failed";
const error = new Error(message);
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
function persistSession(token) {
state.sessionToken = token || "";
if (state.sessionToken) {
window.localStorage.setItem(STORAGE_KEY, state.sessionToken);
} else {
window.localStorage.removeItem(STORAGE_KEY);
}
}
function setTab(tab) {
state.activeTab = tab;
window.localStorage.setItem(TAB_KEY, tab);
render();
}
function initials(label) {
return (label || "AU")
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("");
}
function escapeHtml(value) {
return String(value || "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function withEmbedContext(baseUrl) {
if (!baseUrl) return "";
const url = new URL(baseUrl, window.location.origin);
if (state.profile?.tenant?.id) url.searchParams.set("tenant_id", state.profile.tenant.id);
if (state.profile?.workspace?.id) url.searchParams.set("workspace_id", state.profile.workspace.id);
url.searchParams.set("source", "aurask-protal");
return url.toString();
}
function ensureSigninRoute() {
if (state.profile) return;
if (window.location.pathname !== "/signin") {
window.history.replaceState({}, "", "/signin");
}
}
function ensureDashboardRoute() {
if (!state.profile) return;
if (!window.location.pathname.startsWith("/app")) {
window.history.replaceState({}, "", "/app");
}
}
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">${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() {
app.innerHTML = `
<main class="signin-layout">
<section class="signin-brand">
<div class="brand-mark">A</div>
<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">
<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>
${renderSigninGoogleArea()}
<p class="signin-tip">${escapeHtml(renderSigninHelper())}</p>
${state.errorKey ? `<p class="message error">${renderError()}</p>` : ""}
<div class="signin-footer">
<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() {
const user = state.profile?.user || {};
const tenant = state.profile?.tenant || {};
const workspace = state.profile?.workspace || {};
const quota = state.profile?.quota || {};
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">${escapeHtml(copy("dashboard.workspaceKicker"))}</p>
<h1>${escapeHtml(tenant.name || "Aurask")}</h1>
</div>
</div>
<div class="topbar-right">
<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 || 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">
<strong>${escapeHtml(user.display_name || user.email || "")}</strong>
<span>${escapeHtml(user.email || "")}</span>
</div>
<div class="profile-block">
<span>${escapeHtml(copy("dashboard.tenant"))}</span>
<strong>${escapeHtml(tenant.name || "")}</strong>
</div>
<div class="profile-block">
<span>${escapeHtml(copy("dashboard.workspace"))}</span>
<strong>${escapeHtml(workspace.name || "")}</strong>
</div>
<div class="profile-block profile-grid">
<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>${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">${escapeHtml(copy("dashboard.signOut"))}</button>
</div>
</details>
</div>
</header>
<section class="dashboard-grid">
<aside class="side-panel">
<section class="info-card">
<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>${escapeHtml(copy("dashboard.tenantId"))}</span>
<strong>${escapeHtml(tenant.id || "")}</strong>
</div>
<div class="stat-item">
<span>${escapeHtml(copy("dashboard.workspaceId"))}</span>
<strong>${escapeHtml(workspace.id || "")}</strong>
</div>
<div class="stat-item">
<span>${escapeHtml(copy("dashboard.apiImage"))}</span>
<strong>${escapeHtml(config.devcloud?.api_image || "")}</strong>
</div>
<div class="stat-item">
<span>${escapeHtml(copy("dashboard.webImage"))}</span>
<strong>${escapeHtml(config.devcloud?.web_image || "")}</strong>
</div>
</div>
</section>
<section class="info-card">
<p class="section-kicker">${escapeHtml(copy("dashboard.deployDefaults"))}</p>
<ul class="plain-list">
<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>
<section class="workspace-panel">
<div class="embed-card ${state.activeTab === "workflows" ? "" : "is-hidden"}">
<div class="embed-header">
<div>
<p class="section-kicker">${escapeHtml(copy("dashboard.workflowStudio"))}</p>
<h2>${escapeHtml(copy("dashboard.langflow"))}</h2>
</div>
<a href="${escapeHtml(langflowUrl)}" target="_blank" rel="noreferrer">${escapeHtml(copy("dashboard.openNewTab"))}</a>
</div>
<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">${escapeHtml(copy("dashboard.knowledgeBaseTitle"))}</p>
<h2>${escapeHtml(copy("dashboard.anythingllm"))}</h2>
</div>
<a href="${escapeHtml(anythingllmUrl)}" target="_blank" rel="noreferrer">${escapeHtml(copy("dashboard.openNewTab"))}</a>
</div>
<iframe title="${escapeHtml(copy("dashboard.anythingllm"))}" src="${escapeHtml(anythingllmUrl)}"></iframe>
</div>
</section>
</section>
${state.errorKey ? `<div class="toast error">${renderError()}</div>` : ""}
</main>
`;
document.querySelectorAll("[data-tab]").forEach((button) => {
button.addEventListener("click", () => setTab(button.dataset.tab));
});
document.querySelector("#logoutButton")?.addEventListener("click", logout);
}
function renderLoading() {
app.innerHTML = `
<main class="loading-shell">
<div class="loader"></div>
<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);
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 = () => {
state.googleSdkReady = Boolean(window.google?.accounts?.id);
if (!state.profile && !state.loading) {
render();
mountGoogleButton();
}
};
document.head.appendChild(googleScript);
bootstrap();