aurask/protal/main.js
Aaron c44746a5a8
All checks were successful
aurask-release / build-and-deploy (push) Successful in 2m11s
Add portal sign-in flow and DevCloud deployment defaults
2026-04-19 20:44:53 +08:00

459 lines
16 KiB
JavaScript

const STORAGE_KEY = "aurask.portal.session";
const TAB_KEY = "aurask.portal.activeTab";
const app = document.querySelector("#app");
const state = {
config: null,
sessionToken: window.localStorage.getItem(STORAGE_KEY) || "",
profile: null,
activeTab: window.localStorage.getItem(TAB_KEY) || "workflows",
lyFormOpen: false,
loading: true,
error: "",
};
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");
}
}
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 submitLySso(event) {
event.preventDefault();
state.error = "";
render();
const form = new FormData(event.currentTarget);
try {
const payload = await request("/auth/ly-sso/login", {
method: "POST",
body: {
username: String(form.get("username") || "").trim(),
password: String(form.get("password") || ""),
},
});
persistSession(payload.token);
state.profile = payload;
state.lyFormOpen = false;
ensureDashboardRoute();
} catch (error) {
state.error = error.message || "LY SSO sign-in failed";
} finally {
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;
state.lyFormOpen = false;
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 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>
</div>
`;
}
function renderSignin() {
const lyConfig = state.config?.auth?.ly_sso || {};
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>
${renderStatusPills()}
</section>
<section class="signin-card">
<div class="signin-header">
<p class="section-kicker">Welcome back</p>
<h2>Sign in to Aurask</h2>
<p>Use your organization account first, or enable Google one-tap registration for self-serve users.</p>
</div>
<div class="signin-actions">
<button id="lyButton" class="auth-btn auth-btn-primary" type="button">Use LY SSO</button>
<button id="googleFallbackButton" class="auth-btn auth-btn-secondary" type="button" ${googleEnabled ? "" : "disabled"}>
Continue with Google
</button>
</div>
<div id="googleButton" class="google-box ${googleEnabled ? "" : "is-hidden"}"></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.lyFormOpen
? `
<form id="lyForm" class="ly-form">
<label>
Username
<input name="username" value="${escapeHtml(lyConfig.username_hint || "ly-xujian1")}" autocomplete="username" />
</label>
<label>
Password
<input name="password" type="password" autocomplete="current-password" placeholder="Enter LY SSO password" />
</label>
<button class="auth-btn auth-btn-primary" type="submit">Sign in with LY SSO</button>
</form>
`
: ""
}
${state.error ? `<p class="message error">${escapeHtml(state.error)}</p>` : ""}
<div class="signin-footer">
<span>Need a first workspace?</span>
<span>Aurask creates one automatically after your first successful login.</span>
</div>
</section>
</main>
`;
document.querySelector("#lyButton")?.addEventListener("click", () => {
if (lyConfig.login_url) {
window.location.href = lyConfig.login_url;
return;
}
state.lyFormOpen = !state.lyFormOpen;
render();
mountGoogleButton();
});
document.querySelector("#lyForm")?.addEventListener("submit", submitLySso);
document.querySelector("#googleFallbackButton")?.addEventListener("click", () => {
document.querySelector("#googleButton")?.scrollIntoView({ behavior: "smooth", block: "center" });
});
}
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">Aurask Workspace</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>
<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>
</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>Tenant</span>
<strong>${escapeHtml(tenant.name || "")}</strong>
</div>
<div class="profile-block">
<span>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>
<strong>${escapeHtml(String(quota.available_tbu ?? 0))}</strong>
<span>Knowledge Bases</span>
<strong>${escapeHtml(String(quota.knowledge_bases ?? 0))}</strong>
</div>
<button id="logoutButton" class="auth-btn auth-btn-secondary" type="button">Sign out</button>
</div>
</details>
</div>
</header>
<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>${escapeHtml(user.email || "")}</p>
<div class="stat-list">
<div class="stat-item">
<span>Tenant ID</span>
<strong>${escapeHtml(tenant.id || "")}</strong>
</div>
<div class="stat-item">
<span>Workspace ID</span>
<strong>${escapeHtml(workspace.id || "")}</strong>
</div>
<div class="stat-item">
<span>API Image</span>
<strong>${escapeHtml(config.devcloud?.api_image || "")}</strong>
</div>
<div class="stat-item">
<span>Web Image</span>
<strong>${escapeHtml(config.devcloud?.web_image || "")}</strong>
</div>
</div>
</section>
<section class="info-card">
<p class="section-kicker">Deploy Defaults</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>
</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">Workflow Studio</p>
<h2>Langflow</h2>
</div>
<a href="${escapeHtml(langflowUrl)}" target="_blank" rel="noreferrer">Open in new tab</a>
</div>
<iframe title="Aurask 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>
</div>
<a href="${escapeHtml(anythingllmUrl)}" target="_blank" rel="noreferrer">Open in new tab</a>
</div>
<iframe title="Aurask AnythingLLM" src="${escapeHtml(anythingllmUrl)}"></iframe>
</div>
</section>
</section>
${state.error ? `<div class="toast error">${escapeHtml(state.error)}</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>Loading Aurask...</p>
</main>
`;
}
function render() {
if (state.loading) {
renderLoading();
return;
}
if (state.profile) {
renderDashboard();
return;
}
renderSignin();
}
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();
}
});
}
const googleScript = document.createElement("script");
googleScript.src = "https://accounts.google.com/gsi/client";
googleScript.async = true;
googleScript.defer = true;
googleScript.onload = mountGoogleButton;
document.head.appendChild(googleScript);
bootstrap();