From 4e7b158ff930d2bc7ed65590409176d82471121c Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Sat, 18 Jul 2026 17:36:08 +0000 Subject: [PATCH] feat(hub): drop redundant /admin/org/:slug path + release v0.0.35 Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy /admin/org/:slug/* bookmarks redirect, login lands on /admin. --- .../src/lib/components/FolderTree.svelte | 2 +- hub/admin-web/src/lib/org.ts | 40 ++++++ hub/admin-web/src/lib/session.ts | 11 +- hub/admin-web/src/routes/+layout.svelte | 127 +++++++----------- hub/admin-web/src/routes/+page.svelte | 2 +- .../admin/{org/[slug] => }/+page.svelte | 10 +- .../[slug] => }/capabilities/+page.svelte | 5 +- .../{org/[slug] => }/capacity/+page.svelte | 5 +- .../{org/[slug] => }/feishu/+page.svelte | 5 +- .../{org/[slug] => }/members/+page.svelte | 5 +- .../routes/admin/org/[...path]/+page.svelte | 23 ++++ .../{org/[slug] => }/projects/+page.svelte | 9 +- .../projects/[projectId]/+page.svelte | 13 +- .../{org/[slug] => }/provider/+page.svelte | 5 +- .../admin/{org/[slug] => }/roles/+page.svelte | 5 +- .../sessions/[sessionId]/+page.svelte | 9 +- .../{org/[slug] => }/skills/+page.svelte | 5 +- .../admin/{org/[slug] => }/teams/+page.svelte | 5 +- .../admin/{org/[slug] => }/usage/+page.svelte | 7 +- hub/package-lock.json | 4 +- hub/package.json | 2 +- hub/src/admin/routes/authRoutes.ts | 23 +++- hub/test/integration/admin-auth.test.ts | 12 +- hub/test/unit/session.test.ts | 15 ++- hub/test/unit/silo-rate-limit.test.ts | 1 + 25 files changed, 221 insertions(+), 129 deletions(-) create mode 100644 hub/admin-web/src/lib/org.ts rename hub/admin-web/src/routes/admin/{org/[slug] => }/+page.svelte (95%) rename hub/admin-web/src/routes/admin/{org/[slug] => }/capabilities/+page.svelte (97%) rename hub/admin-web/src/routes/admin/{org/[slug] => }/capacity/+page.svelte (98%) rename hub/admin-web/src/routes/admin/{org/[slug] => }/feishu/+page.svelte (96%) rename hub/admin-web/src/routes/admin/{org/[slug] => }/members/+page.svelte (96%) create mode 100644 hub/admin-web/src/routes/admin/org/[...path]/+page.svelte rename hub/admin-web/src/routes/admin/{org/[slug] => }/projects/+page.svelte (95%) rename hub/admin-web/src/routes/admin/{org/[slug] => }/projects/[projectId]/+page.svelte (97%) rename hub/admin-web/src/routes/admin/{org/[slug] => }/provider/+page.svelte (96%) rename hub/admin-web/src/routes/admin/{org/[slug] => }/roles/+page.svelte (95%) rename hub/admin-web/src/routes/admin/{org/[slug] => }/sessions/[sessionId]/+page.svelte (95%) rename hub/admin-web/src/routes/admin/{org/[slug] => }/skills/+page.svelte (95%) rename hub/admin-web/src/routes/admin/{org/[slug] => }/teams/+page.svelte (97%) rename hub/admin-web/src/routes/admin/{org/[slug] => }/usage/+page.svelte (97%) diff --git a/hub/admin-web/src/lib/components/FolderTree.svelte b/hub/admin-web/src/lib/components/FolderTree.svelte index 8576128..5fd27db 100644 --- a/hub/admin-web/src/lib/components/FolderTree.svelte +++ b/hub/admin-web/src/lib/components/FolderTree.svelte @@ -33,7 +33,7 @@
{#each childProjects as p (p.id)} diff --git a/hub/admin-web/src/lib/org.ts b/hub/admin-web/src/lib/org.ts new file mode 100644 index 0000000..fd892ff --- /dev/null +++ b/hub/admin-web/src/lib/org.ts @@ -0,0 +1,40 @@ +import type { MeResponse, OrgMembership } from './api'; + +/** Alpha Silo host prefix: .educraft[.dev].… */ +export function hostOrgSlug(hostname: string = typeof window !== 'undefined' ? window.location.hostname : ''): string | null { + const host = hostname.toLowerCase(); + const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./); + return m?.[1] ?? null; +} + +export function isOrgAdmin(org: OrgMembership | null | undefined): boolean { + if (!org) return false; + const role = String(org.role ?? '').toUpperCase(); + return role === 'OWNER' || role === 'ADMIN'; +} + +/** + * Resolve the tenancy for this browser session. + * Prefers hostname slug (silo), then ?org=, then first admin membership, then first membership. + */ +export function resolveOrg(me: MeResponse | null | undefined, search: string = ''): OrgMembership | null { + if (!me || me.organizations.length === 0) return null; + const host = hostOrgSlug(); + if (host) { + const byHost = me.organizations.find((o) => o.slug === host); + if (byHost) return byHost; + } + const q = new URLSearchParams(search).get('org')?.trim(); + if (q) { + const byQuery = me.organizations.find((o) => o.slug === q); + if (byQuery) return byQuery; + } + const admin = me.organizations.find((o) => isOrgAdmin(o)); + return admin ?? me.organizations[0] ?? null; +} + +/** SPA paths no longer embed org slug (subdomain carries tenancy). */ +export function adminPath(rest: string = ''): string { + const cleaned = rest.replace(/^\/+/, ''); + return cleaned === '' ? '/admin' : `/admin/${cleaned}`; +} diff --git a/hub/admin-web/src/lib/session.ts b/hub/admin-web/src/lib/session.ts index 21cc2c3..2507957 100644 --- a/hub/admin-web/src/lib/session.ts +++ b/hub/admin-web/src/lib/session.ts @@ -35,12 +35,9 @@ export async function loadSession(): Promise { /** * Resolve org slug for org-scoped Feishu OAuth (`GET /auth/feishu/:orgSlug`). * Unscoped `/auth/feishu` is disabled unless allowLegacyFeishuOAuth is on. + * Path no longer carries tenancy: prefer hostname silo slug, then ?org=. */ export function resolveLoginOrgSlug(): string | null { - const path = window.location.pathname.split('/').filter(Boolean); - if (path[0] === 'admin' && path[1] === 'org' && path[2]) { - return decodeURIComponent(path[2]); - } const q = new URLSearchParams(window.location.search).get('org'); if (q && q.trim() !== '') return q.trim(); @@ -48,6 +45,12 @@ export function resolveLoginOrgSlug(): string | null { const host = window.location.hostname.toLowerCase(); const m = host.match(/^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\.educraft(?:-dev)?\./); if (m?.[1]) return m[1]; + + // Legacy path while old bookmarks still land briefly before redirect. + const path = window.location.pathname.split('/').filter(Boolean); + if (path[0] === 'admin' && path[1] === 'org' && path[2]) { + return decodeURIComponent(path[2]); + } return null; } diff --git a/hub/admin-web/src/routes/+layout.svelte b/hub/admin-web/src/routes/+layout.svelte index c78b8bf..2e123b9 100644 --- a/hub/admin-web/src/routes/+layout.svelte +++ b/hub/admin-web/src/routes/+layout.svelte @@ -5,6 +5,7 @@ import { page } from '$app/state'; import { session, loadSession, logout, redirectToLogin } from '$lib/session'; import type { OrgMembership } from '$lib/api'; + import { adminPath, isOrgAdmin, resolveOrg } from '$lib/org'; import { orgRoleLabel } from '$lib/format'; import Icon from '$lib/components/Icon.svelte'; import ToastHost from '$lib/components/ToastHost.svelte'; @@ -32,56 +33,36 @@ { key: 'capabilities', label: '能力', icon: 'provider' as const }, ]; - function isAdmin(org: OrgMembership): boolean { - const role = String(org.role ?? '').toUpperCase(); - return role === 'OWNER' || role === 'ADMIN'; - } - - function orgSlugFromPath(): string | null { - const parts = page.url.pathname.split('/').filter(Boolean); - if (parts[0] === 'admin' && parts[1] === 'org' && parts[2]) { - return decodeURIComponent(parts[2]); - } - return null; - } - - function isOnProjectRoute(): boolean { - const parts = page.url.pathname.split('/').filter(Boolean); - return parts[0] === 'admin' && parts[1] === 'org' && parts[3] === 'projects'; - } - function memberships(): OrgMembership[] { return $session.me?.organizations ?? []; } function adminOrgs(): OrgMembership[] { - return memberships().filter(isAdmin); + return memberships().filter((o) => isOrgAdmin(o)); } function currentOrg(): OrgMembership | null { - const slug = orgSlugFromPath(); - if (!slug) return null; - return memberships().find((o) => o.slug === slug) ?? null; + return resolveOrg($session.me, page.url.search); } - function pickHomeOrg(): OrgMembership | null { - const admin = adminOrgs()[0]; - if (admin) return admin; - return memberships()[0] ?? null; + function isOnProjectRoute(): boolean { + const parts = page.url.pathname.split('/').filter(Boolean); + // /admin/projects or /admin/projects/:id + return parts[0] === 'admin' && parts[1] === 'projects'; } function activeKey(): string { const parts = page.url.pathname.split('/').filter(Boolean); - if (parts[0] !== 'admin' || parts[1] !== 'org' || !parts[2]) return ''; - return parts[3] ?? 'overview'; + if (parts[0] !== 'admin') return ''; + // /admin → overview; /admin/usage → usage; /admin/projects/x → projects + return parts[1] ?? 'overview'; } function navHref(key: string): string { - const slug = currentOrg()?.slug ?? pickHomeOrg()?.slug; - if (!slug) return '/'; - if (key === 'overview') return `/admin/org/${slug}`; - return `/admin/org/${slug}/${key}`; + if (key === 'overview') return adminPath(); + return adminPath(key); } + function pageTitle(): string { const key = activeKey(); if (key === 'overview' || key === '') return '概览'; @@ -91,7 +72,10 @@ function switchOrg(nextSlug: string) { if (!nextSlug || nextSlug === currentOrg()?.slug) return; - void goto(`/admin/org/${nextSlug}`); + // Path is tenancy-free; keep optional ?org= for local multi-membership debugging. + const url = new URL(page.url.href); + url.searchParams.set('org', nextSlug); + void goto(`${url.pathname}${url.search}`, { replaceState: true }); } function handleLogout(e: Event) { @@ -114,33 +98,23 @@ $effect(() => { if ($session.loading || !$session.me) return; - const slug = orgSlugFromPath(); - const matched = slug ? memberships().find((o) => o.slug === slug) : null; + const path = page.url.pathname; + // Legacy /admin/org/:slug… is handled by admin/org/[...path] page. - // Org admins: route to their first admin org if none matched as admin. + const org = currentOrg(); const admins = adminOrgs(); - if (matched && isAdmin(matched)) { + + if (org && isOrgAdmin(org)) { redirecting = false; return; } - if (!matched && admins.length > 0) { - const target = `/admin/org/${admins[0].slug}`; - if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) { - redirecting = true; - void goto(target, { replaceState: true }); - } - return; - } - // Members (non-admin): project pages are open to project MANAGE holders; - // the org overview and other admin-only surfaces are not for them. - if (matched && !isAdmin(matched)) { + // Member only: allow project routes; bounce admin-only surfaces to projects. + if (org && !isOrgAdmin(org)) { redirecting = false; - const parts = page.url.pathname.split('/').filter(Boolean); - const onOverview = parts.length === 3; // /admin/org/:slug - if (onOverview) { - const target = `/admin/org/${matched.slug}/projects`; - if (page.url.pathname !== target) { + if (!isOnProjectRoute()) { + const target = adminPath('projects'); + if (path !== target) { redirecting = true; void goto(target, { replaceState: true }); } @@ -148,12 +122,11 @@ return; } - // No matched org and no admin orgs: route a member to their first org's - // projects page so they can reach project MANAGE surfaces. - if (!matched && memberships().length > 0) { - const home = memberships()[0]; - const target = `/admin/org/${home.slug}/projects`; - if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) { + // No org resolved but user has memberships → land on first org's projects/overview via resolveOrg next tick + if (!org && memberships().length > 0) { + const home = admins[0] ?? memberships()[0]!; + const target = isOrgAdmin(home) ? adminPath() : adminPath('projects'); + if (path !== target && !path.startsWith(`${target}/`)) { redirecting = true; void goto(target, { replaceState: true }); } @@ -174,14 +147,14 @@ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" > -

{redirecting ? '正在进入组织…' : '正在加载会话…'}

+

加载中…

{:else if $session.error}
!
@@ -194,7 +167,7 @@
CPH
@@ -203,7 +176,7 @@
-{:else if currentOrg() && isAdmin(currentOrg()!)} +{:else if currentOrg() && isOrgAdmin(currentOrg()!)} {@const org = currentOrg()!} {@const me = $session.me!}
@@ -228,17 +201,19 @@
组织后台
-
Curriculum Hub
+
{org.name}
-
-
- - 组织 + {#if me.organizations.length > 1} +
+
+ + 组织 +
+
- -
+ {/if}
-{:else if currentOrg() && !isAdmin(currentOrg()!) && isOnProjectRoute()} +{:else if currentOrg() && !isOrgAdmin(currentOrg()!) && isOnProjectRoute()} {@const org = currentOrg()!} {@const me = $session.me!}
- +
@@ -343,7 +318,7 @@
-{:else if currentOrg() && !isAdmin(currentOrg()!)} +{:else if currentOrg() && !isOrgAdmin(currentOrg()!)} {@const denied = currentOrg()!}
@@ -352,7 +327,7 @@ 组织 {denied.name}(/{denied.slug})中你的角色是 {orgRoleLabel(denied.role)}。普通成员仅可访问自己有授权的项目。

- 查看我的项目 + 查看我的项目 {#if memberships().length > 1}

切换到其他组织

@@ -363,14 +338,14 @@
{:else if memberships().length > 0} - {@const denied = pickHomeOrg()!} + {@const denied = resolveOrg($session.me) ?? memberships()[0]!}

正在跳转…

即将进入 {denied.name}(/{denied.slug})的项目。

- 立即进入 + 立即进入
diff --git a/hub/admin-web/src/routes/+page.svelte b/hub/admin-web/src/routes/+page.svelte index 5e72a6b..1cf2cd4 100644 --- a/hub/admin-web/src/routes/+page.svelte +++ b/hub/admin-web/src/routes/+page.svelte @@ -11,7 +11,7 @@ return r === 'OWNER' || r === 'ADMIN'; }); const target = admin ?? me.organizations[0]; - return target ? `/admin/org/${target.slug}` : null; + return target ? `/admin` : null; } onMount(() => { diff --git a/hub/admin-web/src/routes/admin/org/[slug]/+page.svelte b/hub/admin-web/src/routes/admin/+page.svelte similarity index 95% rename from hub/admin-web/src/routes/admin/org/[slug]/+page.svelte rename to hub/admin-web/src/routes/admin/+page.svelte index 30570e5..fd34f18 100644 --- a/hub/admin-web/src/routes/admin/org/[slug]/+page.svelte +++ b/hub/admin-web/src/routes/admin/+page.svelte @@ -2,6 +2,7 @@ import { page } from '$app/state'; import { api, type OrgMembership } from '$lib/api'; import { session } from '$lib/session'; + import { resolveOrg } from '$lib/org'; import { fmtCost, fmtNum, orgRoleLabel } from '$lib/format'; import PageHeader from '$lib/components/PageHeader.svelte'; import StatCard from '$lib/components/StatCard.svelte'; @@ -10,7 +11,8 @@ import SwitchControl from '$lib/components/SwitchControl.svelte'; import { toastError, toastSuccess } from '$lib/toast'; - let orgSlug = $derived(page.params.slug ?? ''); + const orgFromSession = $derived(resolveOrg($session.me, page.url.search)); + let orgSlug = $derived(orgFromSession?.slug ?? ''); let org = $derived($session.me?.organizations.find((o) => o.slug === orgSlug) as OrgMembership | undefined); let settings = $state<{ membersCanCreateProjects: boolean } | null>(null); @@ -96,7 +98,7 @@

用量概览

totals 含全部 UsageFact;分账明细见用量页。

- 完整用量报告 + 完整用量报告
@@ -115,7 +117,7 @@

消费来源(Top)

模型完成 vs 外部能力,按成本降序前 5

- 查看全部分账 + 查看全部分账
@@ -167,7 +169,7 @@ {#each usage.projects as p} diff --git a/hub/admin-web/src/routes/admin/org/[slug]/capabilities/+page.svelte b/hub/admin-web/src/routes/admin/capabilities/+page.svelte similarity index 97% rename from hub/admin-web/src/routes/admin/org/[slug]/capabilities/+page.svelte rename to hub/admin-web/src/routes/admin/capabilities/+page.svelte index dd97739..5a1573b 100644 --- a/hub/admin-web/src/routes/admin/org/[slug]/capabilities/+page.svelte +++ b/hub/admin-web/src/routes/admin/capabilities/+page.svelte @@ -1,6 +1,8 @@ + +
+

正在重定向到新地址…

+
diff --git a/hub/admin-web/src/routes/admin/org/[slug]/projects/+page.svelte b/hub/admin-web/src/routes/admin/projects/+page.svelte similarity index 95% rename from hub/admin-web/src/routes/admin/org/[slug]/projects/+page.svelte rename to hub/admin-web/src/routes/admin/projects/+page.svelte index b79e2f8..2daddf0 100644 --- a/hub/admin-web/src/routes/admin/org/[slug]/projects/+page.svelte +++ b/hub/admin-web/src/routes/admin/projects/+page.svelte @@ -2,6 +2,7 @@ import { page } from '$app/state'; import { api, type ExplorerData, type ExplorerFolder, type ExplorerProject, type OrgMembership } from '$lib/api'; import { session } from '$lib/session'; + import { resolveOrg } from '$lib/org'; import FolderTree from '$lib/components/FolderTree.svelte'; import PageHeader from '$lib/components/PageHeader.svelte'; import LoadingState from '$lib/components/LoadingState.svelte'; @@ -13,8 +14,8 @@ import { fmtDate } from '$lib/format'; import { toastError, toastSuccess } from '$lib/toast'; - const slug = $derived(page.params.slug ?? ''); - const org = $derived(($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null); + const org = $derived(resolveOrg($session.me, page.url.search)); + const slug = $derived(org?.slug ?? ''); const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN')); let data = $state(null); @@ -87,7 +88,7 @@ projectName = ''; projectFolder = ''; showProjectModal = false; - window.location.href = `/admin/org/${slug}/projects/${res.id}`; + window.location.href = `/admin/projects/${res.id}`; } catch (err) { toastError(err instanceof Error ? err.message : String(err)); } @@ -207,7 +208,7 @@ {#each myProjects as p} - (window.location.href = `/admin/org/${slug}/projects/${p.id}`)}> + (window.location.href = `/admin/projects/${p.id}`)}> diff --git a/hub/admin-web/src/routes/admin/org/[slug]/projects/[projectId]/+page.svelte b/hub/admin-web/src/routes/admin/projects/[projectId]/+page.svelte similarity index 97% rename from hub/admin-web/src/routes/admin/org/[slug]/projects/[projectId]/+page.svelte rename to hub/admin-web/src/routes/admin/projects/[projectId]/+page.svelte index 3cfb1aa..5c0dbc3 100644 --- a/hub/admin-web/src/routes/admin/org/[slug]/projects/[projectId]/+page.svelte +++ b/hub/admin-web/src/routes/admin/projects/[projectId]/+page.svelte @@ -9,6 +9,8 @@ type ExplorerData, type ProjectUsageReport, } from '$lib/api'; + import { session } from '$lib/session'; + import { resolveOrg } from '$lib/org'; import { fmtCost, fmtDate, @@ -27,7 +29,8 @@ import Icon from '$lib/components/Icon.svelte'; import { toastError, toastSuccess } from '$lib/toast'; - const slug = $derived(page.params.slug ?? ''); + const org = $derived(resolveOrg($session.me, page.url.search)); + const slug = $derived(org?.slug ?? ''); const projectId = $derived(page.params.projectId ?? ''); const roleItems = PERMISSION_ROLES.map((r) => ({ value: r, label: PERMISSION_ROLE_LABELS[r] })); const roleChain = `${PERMISSION_ROLE_LABELS.READ} ⊂ ${PERMISSION_ROLE_LABELS.EDIT} ⊂ ${PERMISSION_ROLE_LABELS.MANAGE}`; @@ -109,7 +112,7 @@ if (!confirm(`归档项目 ${proj?.name}?`)) return; try { await api.archiveProject(slug, projectId); - window.location.href = `/admin/org/${slug}/projects`; + window.location.href = `/admin/projects`; } catch (err) { toastError(err instanceof Error ? err.message : String(err)); } @@ -170,7 +173,7 @@ {:else if proj} - 组织报告 + 组织报告 {#if projectUsage.breakdown.length === 0}
尚无 UsageFact。
@@ -368,7 +371,7 @@
- + {p.projectName}
{p.name} {p.binding ? `群 ${p.binding.chatId}` : '—'} {fmtDate(p.createdAt)} 详情 diff --git a/hub/admin-web/src/routes/admin/org/[slug]/provider/+page.svelte b/hub/admin-web/src/routes/admin/provider/+page.svelte similarity index 96% rename from hub/admin-web/src/routes/admin/org/[slug]/provider/+page.svelte rename to hub/admin-web/src/routes/admin/provider/+page.svelte index b253162..383ecc7 100644 --- a/hub/admin-web/src/routes/admin/org/[slug]/provider/+page.svelte +++ b/hub/admin-web/src/routes/admin/provider/+page.svelte @@ -1,6 +1,8 @@