diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d65b57e..9dbd9a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,3 +57,17 @@ jobs: - name: Lint mtmharness package metadata run: pnpm dlx publint run "${{ steps.pack.outputs.tarball }}" --strict + + - name: Pack mtm-admin bundle + id: pack-admin + run: | + set -euo pipefail + pack_dir="$RUNNER_TEMP/mtm-admin" + mkdir -p "$pack_dir" + pnpm --filter mtm-admin pack --pack-destination "$pack_dir" + tarball="$(find "$pack_dir" -maxdepth 1 -type f -name 'mtm-admin-*.tgz' -print -quit)" + test -n "$tarball" + printf 'tarball=%s\n' "$tarball" >> "$GITHUB_OUTPUT" + + - name: Lint mtm-admin package metadata + run: pnpm dlx publint run "${{ steps.pack-admin.outputs.tarball }}" --strict diff --git a/.github/workflows/release-mtm-admin.yml b/.github/workflows/release-mtm-admin.yml new file mode 100644 index 0000000..1f6c0dd --- /dev/null +++ b/.github/workflows/release-mtm-admin.yml @@ -0,0 +1,135 @@ +name: release-mtm-admin +run-name: Release mtm-admin ${{ github.ref_name }} + +on: + push: + tags: + - 'mtm-admin-v[0-9]*.[0-9]*.[0-9]*' + +permissions: + contents: read + id-token: write + +concurrency: + group: mtm-admin-npm-publish + cancel-in-progress: false + +env: + PACKAGE_NAME: mtm-admin + PACKAGE_DIR: packages/mtm-admin + TAG_PREFIX: mtm-admin-v + +jobs: + publish: + name: Pack, publish, and read back mtm-admin + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout tagged main commit + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 11.7.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + cache: pnpm + + - name: Enable Corepack + run: corepack enable + + - name: Verify pnpm version + run: test "$(pnpm --version)" = 11.7.0 + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Validate tag and main ancestry + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + package_version="$(node -p "require('./${PACKAGE_DIR}/package.json').version")" + test "$RELEASE_TAG" = "${TAG_PREFIX}${package_version}" + git fetch --no-tags origin main + git merge-base --is-ancestor "$GITHUB_SHA" origin/main + printf 'PACKAGE_VERSION=%s\n' "$package_version" >> "$GITHUB_ENV" + + - name: Check browser extension and manifest pin + run: | + set -euo pipefail + pnpm --filter mtm-admin run check + package_version="$(node -p "require('./${PACKAGE_DIR}/package.json').version")" + manifest_version="$(sed -n '/id: "mtm-admin"/{n;s/.*version: "\([^\"]*\)".*/\1/p;}' packages/mtmharness/src/features/secondary/manifest.ts)" + expected_integrity="$(sed -n '/id: "mtm-admin"/{n;n;n;s/.*clientIntegrity: "\([^\"]*\)".*/\1/p;}' packages/mtmharness/src/features/secondary/manifest.ts)" + test "$manifest_version" = "$package_version" + local_integrity="sha256-$(openssl dgst -sha256 -binary packages/mtm-admin/lib/client.js | base64 -w0)" + test "$local_integrity" = "$expected_integrity" + printf 'EXPECTED_INTEGRITY=%s\n' "$expected_integrity" >> "$GITHUB_ENV" + + - name: Pack release tarball + id: pack + run: | + set -euo pipefail + pack_dir="$RUNNER_TEMP/mtm-admin" + mkdir -p "$pack_dir" + pnpm --filter mtm-admin pack --pack-destination "$pack_dir" + tarball="$(find "$pack_dir" -maxdepth 1 -type f -name "${PACKAGE_NAME}-*.tgz" -print -quit)" + test -n "$tarball" + local_integrity="sha512-$(openssl dgst -sha512 -binary "$tarball" | base64 -w0)" + printf 'tarball=%s\n' "$tarball" >> "$GITHUB_OUTPUT" + printf 'LOCAL_INTEGRITY=%s\n' "$local_integrity" >> "$GITHUB_ENV" + + - name: Lint package metadata + run: pnpm dlx publint run "${{ steps.pack.outputs.tarball }}" --strict + + - name: Preflight registry and token + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + test -n "${NODE_AUTH_TOKEN:-}" + npm whoami --registry=https://registry.npmjs.org >/dev/null + status="$(curl --silent --show-error --location --output /dev/null --write-out '%{http_code}' "https://registry.npmjs.org/${PACKAGE_NAME}/${PACKAGE_VERSION}")" + test "$status" = 404 + + - name: Publish with provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm publish "${{ steps.pack.outputs.tarball }}" --access public --provenance --ignore-scripts + + - name: Read back npm and CDN artifacts + run: | + set -euo pipefail + remote="$RUNNER_TEMP/mtm-admin-client.js" + for attempt in $(seq 1 12); do + published_integrity="$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" dist.integrity --json 2>/dev/null | jq -r . || true)" + published_version="$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version --json 2>/dev/null | jq -r . || true)" + curl --fail --silent --show-error --location --output "$remote" "https://unpkg.com/${PACKAGE_NAME}@${PACKAGE_VERSION}/lib/client.js" || true + remote_integrity="" + test -f "$remote" && remote_integrity="sha256-$(openssl dgst -sha256 -binary "$remote" | base64 -w0)" + if test "$published_integrity" = "$LOCAL_INTEGRITY" && test "$published_version" = "$PACKAGE_VERSION" && test "$remote_integrity" = "$EXPECTED_INTEGRITY" && cmp packages/mtm-admin/lib/client.js "$remote"; then + break + fi + if test "$attempt" -eq 12; then + echo "npm/CDN artifact read-back did not converge after ${attempt} attempts" >&2 + exit 1 + fi + sleep 5 + done + { + echo '### npm release evidence' + echo + echo "- package: ${PACKAGE_NAME}@${PACKAGE_VERSION}" + echo "- tag: ${GITHUB_REF_NAME}" + echo "- integrity: ${published_integrity}" + echo '- provenance: requested with npm publish --provenance' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-mtmharness.yml b/.github/workflows/release-mtmharness.yml index e1f6d1c..92bf060 100644 --- a/.github/workflows/release-mtmharness.yml +++ b/.github/workflows/release-mtmharness.yml @@ -69,6 +69,7 @@ jobs: pnpm --filter mtmharness run check pnpm --filter mtmcanvas run check pnpm --filter mtm-connect run check + pnpm --filter mtm-admin run check # The pinned secondary artifacts must already be published before mtmharness. canvas_version="$(node -p "require('./packages/mtmcanvas/package.json').version")" canvas_integrity="$(sed -n '/id: "mtmcanvas"/{n;n;n;s/.*clientIntegrity: "\([^\"]*\)".*/\1/p;}' packages/mtmharness/src/features/secondary/manifest.ts)" @@ -92,6 +93,16 @@ jobs: remote_integrity="sha256-$(openssl dgst -sha256 -binary "$remote" | base64 -w0)" test "$remote_integrity" = "$connect_integrity" cmp "packages/mtm-connect/lib/client.js" "$remote" + admin_version="$(node -p "require('./packages/mtm-admin/package.json').version")" + admin_integrity="$(sed -n '/id: "mtm-admin"/{n;n;n;s/.*clientIntegrity: "\([^\"]*\)".*/\1/p;}' packages/mtmharness/src/features/secondary/manifest.ts)" + local_integrity="sha256-$(openssl dgst -sha256 -binary packages/mtm-admin/lib/client.js | base64 -w0)" + test "$admin_integrity" = "$local_integrity" + test "$(npm view "mtm-admin@${admin_version}" version --json | jq -r .)" = "$admin_version" + remote="$RUNNER_TEMP/mtm-admin-client.js" + curl --fail --silent --show-error --output "$remote" "https://unpkg.com/mtm-admin@${admin_version}/lib/client.js" + remote_integrity="sha256-$(openssl dgst -sha256 -binary "$remote" | base64 -w0)" + test "$remote_integrity" = "$admin_integrity" + cmp "packages/mtm-admin/lib/client.js" "$remote" - name: Pack release tarball id: pack diff --git a/package.json b/package.json index 44e4e72..fe77187 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,9 @@ "packageManager": "pnpm@11.7.0", "engines": { "node": ">=22.19.0" }, "scripts": { - "build": "pnpm --filter mtmharness run build", + "build": "pnpm --filter mtmharness run build && pnpm --filter mtm-admin run build", "demo": "pnpm --filter mtmharness run dev", - "check": "pnpm --filter mtmharness run check && pnpm --filter mtmcanvas run check && pnpm --filter mtm-connect run check" + "check": "pnpm --filter mtmharness run check && pnpm --filter mtmcanvas run check && pnpm --filter mtm-connect run check && pnpm --filter mtm-admin run check" }, "devDependencies": { "typescript": "6.0.3" } } diff --git a/packages/mtm-admin/LICENSE b/packages/mtm-admin/LICENSE new file mode 100644 index 0000000..9ee7bee --- /dev/null +++ b/packages/mtm-admin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 codeh007 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/mtm-admin/README.md b/packages/mtm-admin/README.md new file mode 100644 index 0000000..2d44b44 --- /dev/null +++ b/packages/mtm-admin/README.md @@ -0,0 +1,49 @@ +# mtm-admin + +mtm-admin is the independent browser control-plane client for gomtm. It is public source and is not a standard DSH profile plugin. The package publishes a standalone static app, a programmatic embed, and a token-free mtmharness secondary launcher. + +## Static app + +Serve dist/standalone as the app root and provide a sibling config.js before the module script runs: + + window.__MTM_ADMIN_CONFIG__ = { + apiOrigin: "https://gomtm.example.test", + oauth: { + issuer: "https://gomtm.example.test", + clientId: "mtm-admin-web-v1", + redirectUri: "https://admin.example.test/", + resource: "https://gomtm.example.test/api/system", + scopes: ["openid", "profile", "email", "offline_access", "gomtm:admin"] + } + }; + +The OAuth client uses Authorization Code with PKCE S256. The exact redirect URI must be registered at the authority. Tokens stay in JavaScript memory; only the short-lived PKCE transaction uses sessionStorage. API requests send Authorization Bearer and credentials omit. No cookie session is used. + +## mtmharness launcher + +mtmharness owns the trusted secondary manifest and loads mtm-admin's lib/client.js after the user enables the feature. The launcher only opens the configured standalone app URL and never receives a token. The host must point the manifest at a deployment of the static app that has its own public OAuth configuration. + +The launcher uses the existing mount(context) -> cleanup contract. Its integrity pin identifies the reviewed artifact; it is not a JavaScript sandbox. + +## Embed + +The mtm-admin/embed export mounts the full React application into a caller-owned element: + + import { mount } from "mtm-admin/embed"; + const unmount = mount(document.querySelector("#admin"), { + apiOrigin: "https://gomtm.example.test", + oauth: { + issuer: "https://gomtm.example.test", + clientId: "mtm-admin-web-v1", + redirectUri: "https://host.example.test/admin/callback", + resource: "https://gomtm.example.test/api/system", + scopes: ["openid", "gomtm:admin"] + } + }); + +Use the standalone app or launcher for the high-privilege default. Inline embed is an explicit opt-in because it executes in the caller page. + +## Development + + pnpm --filter mtmharness run build + pnpm --filter mtm-admin run check diff --git a/packages/mtm-admin/index.html b/packages/mtm-admin/index.html new file mode 100644 index 0000000..7eb94db --- /dev/null +++ b/packages/mtm-admin/index.html @@ -0,0 +1,16 @@ + + + + + + + + + MTM Administrator + + + +
+ + + diff --git a/packages/mtm-admin/package.json b/packages/mtm-admin/package.json new file mode 100644 index 0000000..1ffa19b --- /dev/null +++ b/packages/mtm-admin/package.json @@ -0,0 +1,62 @@ +{ + "name": "mtm-admin", + "version": "0.1.0", + "description": "Independent OAuth control-plane client and mtmharness launcher for gomtm.", + "type": "module", + "engines": { "node": ">=22.19.0", "pnpm": ">=11.7.0" }, + "main": "./lib/client.js", + "types": "./lib/types/launcher.d.ts", + "exports": { + ".": { "types": "./lib/types/launcher.d.ts", "import": "./lib/client.js", "default": "./lib/client.js" }, + "./client": { "types": "./lib/types/launcher.d.ts", "import": "./lib/client.js", "default": "./lib/client.js" }, + "./embed": { "types": "./lib/types/embed.d.ts", "import": "./dist/embed/mtm-admin.js", "default": "./dist/embed/mtm-admin.js" }, + "./app": "./dist/standalone/index.html", + "./package.json": "./package.json" + }, + "mtmharness": { + "secondary": { "id": "mtm-admin", "apiVersion": 1, "client": "./lib/client.js" } + }, + "files": [ + "lib/client.js", + "lib/types/**/*.d.ts", + "dist/standalone", + "dist/embed", + "README.md", + "package.json", + "LICENSE" + ], + "scripts": { + "build": "node scripts/build.mjs", + "pretypecheck": "pnpm --filter mtmharness run build", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "check": "pnpm run typecheck && pnpm run test && pnpm run build", + "prepack": "pnpm run build" + }, + "license": "MIT", + "publishConfig": { "access": "public" }, + "repository": { "type": "git", "url": "git+https://github.com/codeh007/mtmdsh.git", "directory": "packages/mtm-admin" }, + "devDependencies": { + "@base-ui/react": "1.7.0", + "@tailwindcss/vite": "4.3.3", + "@types/node": "22.20.1", + "@types/react": "~18.3.1", + "@types/react-dom": "~18.3.0", + "@testing-library/react": "16.3.2", + "@vitejs/plugin-react": "6.0.5", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "esbuild": "0.28.2", + "jsdom": "30.0.1", + "lucide-react": "1.21.0", + "mtmharness": "workspace:*", + "react": "18.3.1", + "react-dom": "18.3.1", + "tailwind-merge": "3.6.0", + "tailwindcss": "4.3.0", + "tw-animate-css": "1.4.0", + "typescript": "6.0.3", + "vite": "8.2.2", + "vitest": "4.1.11" + } +} diff --git a/packages/mtm-admin/public/config.js b/packages/mtm-admin/public/config.js new file mode 100644 index 0000000..1493ded --- /dev/null +++ b/packages/mtm-admin/public/config.js @@ -0,0 +1 @@ +// Deployment-owned public configuration is injected here before the app module runs. diff --git a/packages/mtm-admin/scripts/build.mjs b/packages/mtm-admin/scripts/build.mjs new file mode 100644 index 0000000..3b33a43 --- /dev/null +++ b/packages/mtm-admin/scripts/build.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const libRoot = resolve(packageRoot, "lib"); +const distRoot = resolve(packageRoot, "dist"); +const tsc = resolve(packageRoot, "node_modules/.bin/tsc"); +const vite = resolve(packageRoot, "node_modules/.bin/vite"); + +rmSync(libRoot, { recursive: true, force: true }); +rmSync(distRoot, { recursive: true, force: true }); +mkdirSync(libRoot, { recursive: true }); +if (!existsSync(tsc) || !existsSync(vite)) throw new Error("mtm-admin build: local TypeScript and Vite executables are required"); + +execFileSync(tsc, ["--project", resolve(packageRoot, "tsconfig.json")], { cwd: packageRoot, stdio: "inherit" }); +await build({ + entryPoints: [resolve(packageRoot, "src/launcher.ts")], + outfile: resolve(libRoot, "client.js"), + bundle: true, + format: "esm", + platform: "browser", + target: "es2020", + legalComments: "none", + logLevel: "info", +}); +execFileSync(vite, ["build", "--config", resolve(packageRoot, "vite.config.ts")], { cwd: packageRoot, stdio: "inherit" }); +execFileSync(vite, ["build", "--config", resolve(packageRoot, "vite.embed.config.ts")], { cwd: packageRoot, stdio: "inherit" }); + +console.log("built mtm-admin standalone, embed, and launcher artifacts"); diff --git a/packages/mtm-admin/src/admin-auth.test.tsx b/packages/mtm-admin/src/admin-auth.test.tsx new file mode 100644 index 0000000..dc12e21 --- /dev/null +++ b/packages/mtm-admin/src/admin-auth.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AdminAuthClient, AdminAuthSnapshot } from "./config"; + +vi.mock("./components/admin/admin-control-plane-surface", () => ({ + AdminControlPlaneSurface: () =>
control plane
, +})); + +import { AdminAuthGate } from "./admin-auth"; + +function auth(snapshot: AdminAuthSnapshot): AdminAuthClient { + return { + getAccessToken: vi.fn(async () => "token"), + getAccountPartition: () => undefined, + subscribe: () => () => undefined, + clear: vi.fn(), + getSnapshot: () => snapshot, + discover: vi.fn(), + beginLogin: vi.fn(async () => "https://auth.example/authorize"), + consumeCallback: vi.fn(async () => false), + logout: vi.fn(async () => undefined), + switchAccount: vi.fn(async () => "https://auth.example/authorize"), + dispose: vi.fn(), + } as unknown as AdminAuthClient; +} + +afterEach(() => cleanup()); + +describe("AdminAuthGate", () => { + it("renders the control plane only after bearer authentication", () => { + render(); + expect(screen.getByText("control plane")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Sign in" })).toBeNull(); + }); + + it("renders a sign-in action while signed out", () => { + render(); + expect(screen.getByRole("button", { name: "Sign in" })).toBeTruthy(); + expect(screen.getByText("Administrator authentication is required.")).toBeTruthy(); + }); +}); diff --git a/packages/mtm-admin/src/admin-auth.tsx b/packages/mtm-admin/src/admin-auth.tsx new file mode 100644 index 0000000..ba1adf6 --- /dev/null +++ b/packages/mtm-admin/src/admin-auth.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { LoaderCircle, LogIn } from "lucide-react"; +import { useEffect, useState, useSyncExternalStore } from "react"; +import type { AdminAuthClient } from "./config"; +import { useTranslations } from "./i18n"; +import { AdminControlPlaneSurface } from "./components/admin/admin-control-plane-surface"; +import { Button } from "./components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./components/ui/card"; + +type AuthGateProps = { auth: AdminAuthClient }; + +export function AdminAuthGate({ auth }: AuthGateProps) { + const snapshot = useSyncExternalStore( + (listener) => auth.subscribe(() => listener()), + () => auth.getSnapshot(), + () => auth.getSnapshot(), + ); + const [signingIn, setSigningIn] = useState(false); + const [loginError, setLoginError] = useState(null); + const t = useTranslations("admin.auth"); + + useEffect(() => { + void auth.consumeCallback().catch(() => undefined); + }, [auth]); + + if (snapshot.status === "authenticated") return ; + + async function signIn(): Promise { + setSigningIn(true); + setLoginError(null); + try { + window.location.assign(await auth.beginLogin()); + } catch (error) { + setLoginError(error instanceof Error ? error.message : t("authenticationFailed")); + setSigningIn(false); + } + } + + const busy = signingIn || snapshot.status === "authorizing" || snapshot.status === "discovering"; + return ( +
+ + + {t("title")} + {t("description")} + + + {snapshot.status === "error" || loginError ? ( +

{loginError ?? snapshot.error ?? t("authenticationFailed")}

+ ) : ( +

{t("authenticationRequired")}

+ )} + +
+
+
+ ); +} diff --git a/packages/mtm-admin/src/admin-fetch.test.ts b/packages/mtm-admin/src/admin-fetch.test.ts new file mode 100644 index 0000000..deb59bb --- /dev/null +++ b/packages/mtm-admin/src/admin-fetch.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AdminAuthClient } from "./config"; +import { adminFetch, adminUrl, clearAdminApp, configureAdminApp } from "./admin-fetch"; + +const auth = { + getAccessToken: vi.fn(async () => "admin-access-token"), + getAccountPartition: () => undefined, + subscribe: () => () => undefined, + clear: vi.fn(), + getSnapshot: () => ({ status: "authenticated" as const }), + discover: vi.fn(), + beginLogin: vi.fn(), + consumeCallback: vi.fn(), + logout: vi.fn(), + switchAccount: vi.fn(), + dispose: vi.fn(), +} as unknown as AdminAuthClient; + +afterEach(() => { + clearAdminApp(auth); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + auth.getAccessToken.mockClear(); + auth.clear.mockClear(); +}); + +describe("mtm-admin API boundary", () => { + it("resolves requests against the configured API origin", () => { + configureAdminApp({ apiOrigin: "https://authority.example", auth }); + expect(adminUrl("/api/system/auth")).toBe("https://authority.example/api/system/auth"); + }); + + it("sends the OAuth bearer without cookies", async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + configureAdminApp({ apiOrigin: "https://authority.example", auth }); + + await adminFetch("/api/system/auth", { headers: { accept: "application/json" }, method: "PUT" }); + + const [input, init] = fetchMock.mock.calls[0] ?? []; + expect(input).toBe("https://authority.example/api/system/auth"); + expect(init?.credentials).toBe("omit"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer admin-access-token"); + expect(new Headers(init?.headers).get("accept")).toBe("application/json"); + }); + + it("clears the token source after an unauthorized response", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(null, { status: 401 }))); + configureAdminApp({ apiOrigin: "https://authority.example", auth }); + + await adminFetch("/api/system/auth"); + + expect(auth.clear).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/mtm-admin/src/admin-fetch.ts b/packages/mtm-admin/src/admin-fetch.ts new file mode 100644 index 0000000..8569203 --- /dev/null +++ b/packages/mtm-admin/src/admin-fetch.ts @@ -0,0 +1,46 @@ +import type { AdminAuthClient } from "./config"; +import { normalizeAdminOrigin } from "./config"; + +type AdminRuntime = { + apiOrigin: string; + auth: AdminAuthClient; + tokenSource: Pick; +}; + +let runtime: AdminRuntime | undefined; + +export function configureAdminApp(config: { apiOrigin: string; auth: AdminAuthClient; tokenSource?: Pick }): void { + runtime = { + apiOrigin: normalizeAdminOrigin(config.apiOrigin), + auth: config.auth, + tokenSource: config.tokenSource ?? config.auth, + }; +} + +export function clearAdminApp(auth: AdminAuthClient): void { + if (runtime?.auth === auth) runtime = undefined; +} + +export function adminAuth(): AdminAuthClient | undefined { + return runtime?.auth; +} + +export function adminOrigin(): string { + if (runtime === undefined) throw new Error("mtm-admin is not configured"); + return runtime.apiOrigin; +} + +export function adminUrl(path: string): string { + return new URL(path, adminOrigin()).toString(); +} + +export async function adminFetch(path: string, init: RequestInit = {}): Promise { + const current = runtime; + if (current === undefined) throw new Error("mtm-admin is not configured"); + const token = await current.tokenSource.getAccessToken(); + const headers = new Headers(init.headers); + headers.set("authorization", "Bearer " + token); + const response = await fetch(adminUrl(path), { ...init, credentials: "omit", headers }); + if (response.status === 401) current.tokenSource.clear(); + return response; +} diff --git a/packages/mtm-admin/src/components/admin/admin-control-plane-surface.test.tsx b/packages/mtm-admin/src/components/admin/admin-control-plane-surface.test.tsx new file mode 100644 index 0000000..c94a3ce --- /dev/null +++ b/packages/mtm-admin/src/components/admin/admin-control-plane-surface.test.tsx @@ -0,0 +1,77 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AdminAuthClient } from "../../config"; +import { clearAdminApp, configureAdminApp } from "../../admin-fetch"; +import { AdminControlPlaneSurface } from "./admin-control-plane-surface"; + +vi.mock("../system-config/system-config-surface", () => ({ + SystemConfigSurface: () =>
system config surface
, +})); + +vi.mock("./p2p-bootstrap-surface", () => ({ + P2PBootstrapSurface: () =>
p2p bootstrap surface
, +})); + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status }); +} + +const authConfig = { + emailVerificationAvailable: false, + emailVerificationEnabled: false, + github: { clientId: "", enabled: false, secretConfigured: false }, + memberSignupEnabled: false, +}; +const auth = { + getAccessToken: vi.fn(async () => "admin-access-token"), + clear: vi.fn(), +} as unknown as AdminAuthClient; + +beforeEach(() => { + configureAdminApp({ apiOrigin: "https://authority.example", auth }); +}); + +afterEach(() => { + cleanup(); + clearAdminApp(auth); + vi.unstubAllGlobals(); + document.documentElement.lang = ""; +}); + +describe("AdminControlPlaneSurface", () => { + it("loads authentication controls with the Admin bearer", async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + expect(new URL(String(input)).pathname).toBe("/api/system/auth"); + expect(init?.credentials).toBe("omit"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer admin-access-token"); + return jsonResponse({ config: authConfig }); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + expect(await screen.findByText("system config surface")).toBeTruthy(); + expect(screen.getByText("Authentication")).toBeTruthy(); + expect(screen.getByText("Member registration")).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("renders the selected locale for admin controls", async () => { + document.documentElement.lang = "zh-CN"; + vi.stubGlobal( + "fetch", + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + expect(init?.credentials).toBe("omit"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer admin-access-token"); + return jsonResponse({ config: authConfig }); + }), + ); + + render(); + + expect(await screen.findByText("认证")).toBeTruthy(); + expect(screen.getByText("成员注册")).toBeTruthy(); + }); +}); diff --git a/packages/mtm-admin/src/components/admin/admin-control-plane-surface.tsx b/packages/mtm-admin/src/components/admin/admin-control-plane-surface.tsx new file mode 100644 index 0000000..73f5695 --- /dev/null +++ b/packages/mtm-admin/src/components/admin/admin-control-plane-surface.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { adminAuth, adminFetch } from "../../admin-fetch"; +import { LoaderCircle, LogOut, Save, ShieldCheck } from "lucide-react"; +import { useTranslations } from "../../i18n"; +import { useEffect, useState } from "react"; +import { P2PBootstrapSurface } from "./p2p-bootstrap-surface"; +import { SystemConfigSurface } from "../system-config/system-config-surface"; +import { Button } from "../ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Switch } from "../ui/switch"; +import { translateApiError } from "../../lib/i18n/api-error"; + +type AuthConfig = { + emailVerificationAvailable: boolean; + emailVerificationEnabled: boolean; + github: { clientId: string; enabled: boolean; secretConfigured: boolean }; + memberSignupEnabled: boolean; +}; + +type Message = { kind: "error" | "success"; text: string }; + +type AuthConfigResponse = { + config?: AuthConfig; + error?: { code?: string; message?: string }; +}; + +const controlPlaneErrorKeys = { + auth_config_invalid: "authConfigInvalid", + auth_config_unavailable: "authConfigUnavailable", + auth_email_delivery_unavailable: "emailDeliveryUnavailable", + platform_admin_required: "platformAdminRequired", + system_config_auth_required: "authRequired", + system_config_auth_unavailable: "authUnavailable", + system_config_scope_required: "scopeRequired", +} as const; + +async function signOut() { + await adminAuth()?.logout(); +} + +export function AdminControlPlaneSurface() { + const [config, setConfig] = useState(null); + const [githubSecret, setGithubSecret] = useState(""); + const [busy, setBusy] = useState(true); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + const t = useTranslations("admin.controlPlane"); + const tErrors = useTranslations("admin.controlPlane.errors"); + + useEffect(() => { + let cancelled = false; + async function load() { + try { + const response = await adminFetch("/api/system/auth", { headers: { accept: "application/json" } }); + const body = (await response.json()) as AuthConfigResponse; + if (!response.ok || !body.config) + throw new Error(translateApiError(body, tErrors("authConfigUnavailable"), controlPlaneErrorKeys, tErrors)); + if (!cancelled) setConfig(body.config); + } catch (error) { + if (!cancelled) + setMessage({ + kind: "error", + text: error instanceof Error ? error.message : tErrors("adminConsoleUnavailable"), + }); + } finally { + if (!cancelled) setBusy(false); + } + } + void load(); + return () => { + cancelled = true; + }; + }, [tErrors]); + + async function save() { + if (!config) return; + setSaving(true); + setMessage(null); + try { + const github = { + clientId: config.github.clientId, + enabled: config.github.enabled, + ...(githubSecret.trim() ? { clientSecret: githubSecret.trim() } : {}), + }; + const response = await adminFetch("/api/system/auth", { + body: JSON.stringify({ + memberSignupEnabled: config.memberSignupEnabled, + emailVerificationEnabled: config.emailVerificationEnabled, + github, + }), + headers: { accept: "application/json", "content-type": "application/json" }, + method: "PUT", + }); + const body = (await response.json()) as AuthConfigResponse; + if (!response.ok || !body.config) + throw new Error(translateApiError(body, tErrors("authConfigUpdateFailed"), controlPlaneErrorKeys, tErrors)); + setConfig(body.config); + setGithubSecret(""); + setMessage({ kind: "success", text: t("authPolicySaved") }); + } catch (error) { + setMessage({ + kind: "error", + text: error instanceof Error ? error.message : tErrors("authConfigUpdateFailed"), + }); + } finally { + setSaving(false); + } + } + + if (busy) { + return ( +
+ + {t("loading")} +
+ ); + } + + return ( +
+
+
+
+
+ +
+
+

{t("product")}

+

{t("title")}

+
+
+ +
+ + {config ? ( + + + {t("authenticationTitle")} + {t("authenticationDescription")} + + +
+
+ +

{t("memberSignupDescription")}

+
+ setConfig({ ...config, memberSignupEnabled: checked })} + /> +
+
+
+ +

+ {config.emailVerificationAvailable + ? t("emailVerificationRequired") + : t("emailVerificationUnavailable")} +

+
+ setConfig({ ...config, emailVerificationEnabled: checked })} + /> +
+
+
+
+ +

{t("githubProviderDescription")}

+
+ + setConfig({ ...config, github: { ...config.github, enabled: checked } }) + } + /> +
+
+ + + setConfig({ ...config, github: { ...config.github, clientId: event.target.value } }) + } + /> +
+
+ + setGithubSecret(event.target.value)} + /> +
+
+
+ {message ? ( +

+ {message.text} +

+ ) : ( + + )} + +
+
+
+ ) : ( + + +

+ {message?.text ?? tErrors("authConfigUnavailable")} +

+
+
+ )} + + + +
+
+ ); +} diff --git a/packages/mtm-admin/src/components/admin/p2p-bootstrap-surface.test.tsx b/packages/mtm-admin/src/components/admin/p2p-bootstrap-surface.test.tsx new file mode 100644 index 0000000..bf63cd9 --- /dev/null +++ b/packages/mtm-admin/src/components/admin/p2p-bootstrap-surface.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AdminAuthClient } from "../../config"; +import { clearAdminApp, configureAdminApp } from "../../admin-fetch"; +import { P2PBootstrapSurface } from "./p2p-bootstrap-surface"; + +const initialSnapshot = { + node_id: "12D3KooWBootstrap", + revision: 1, + generation: 1, + capabilities: ["config.snapshot.v1"], + services: ["mock.execution-world"], + data: { mode: "mock", status: "ready" }, +}; +const auth = { + getAccessToken: vi.fn(async () => "admin-access-token"), + clear: vi.fn(), +} as unknown as AdminAuthClient; + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, status }); +} + +beforeEach(() => { + configureAdminApp({ apiOrigin: "https://authority.example", auth }); +}); + +afterEach(() => { + cleanup(); + clearAdminApp(auth); + vi.unstubAllGlobals(); + document.documentElement.lang = ""; +}); + +describe("P2PBootstrapSurface", () => { + it("loads the bootstrap address and saves an edited snapshot with a bearer", async () => { + const savedSnapshot = { + ...initialSnapshot, + capabilities: ["config.snapshot.v1", "config.snapshot.v2"], + revision: 2, + generation: 2, + }; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = new URL(String(input)).pathname; + if (path === "/p2p/bootstrap") { + return jsonResponse({ + peer_id: initialSnapshot.node_id, + multiaddr: "/dns4/gomtm-dev.yuepa8.com/tcp/443/wss/p2p/" + initialSnapshot.node_id, + protocols: ["/gomtm/config/1.0.0"], + connections: [], + snapshot: initialSnapshot, + websocket_mode: "standard", + }); + } + expect(path).toBe("/api/system/p2p/bootstrap/config"); + expect(init?.method).toBe("PUT"); + expect(init?.body).toContain("config.snapshot.v2"); + expect(new Headers(init?.headers).get("authorization")).toBe("Bearer admin-access-token"); + return jsonResponse(savedSnapshot); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + expect(await screen.findByText(initialSnapshot.node_id)).toBeTruthy(); + fireEvent.change(screen.getByLabelText("Capabilities (one per line)"), { + target: { value: "config.snapshot.v1\nconfig.snapshot.v2" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save snapshot" })); + + expect(await screen.findByText("Snapshot saved")).toBeTruthy(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/mtm-admin/src/components/admin/p2p-bootstrap-surface.tsx b/packages/mtm-admin/src/components/admin/p2p-bootstrap-surface.tsx new file mode 100644 index 0000000..5048c8d --- /dev/null +++ b/packages/mtm-admin/src/components/admin/p2p-bootstrap-surface.tsx @@ -0,0 +1,245 @@ +"use client"; + +import { adminFetch } from "../../admin-fetch"; +import { Check, Copy, LoaderCircle, Radio, RefreshCw, Save } from "lucide-react"; +import { useTranslations } from "../../i18n"; +import { useCallback, useEffect, useState } from "react"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; +import { Label } from "../ui/label"; +import { Textarea } from "../ui/textarea"; + +type Snapshot = { + node_id: string; + revision: number; + generation: number; + capabilities: string[]; + services: string[]; + data: Record; +}; + +type BootstrapStatus = { + peer_id: string; + multiaddr: string; + protocols: string[]; + connections: { peer_id: string; address: string; status: string }[]; + snapshot: Snapshot; + websocket_mode: string; +}; + +type SnapshotDraft = { + capabilities: string; + services: string; + data: string; +}; + +type Message = { kind: "error" | "success"; text: string }; + +export function P2PBootstrapSurface() { + const t = useTranslations("admin.p2p"); + const [status, setStatus] = useState(null); + const [draft, setDraft] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [copied, setCopied] = useState(false); + const [message, setMessage] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setMessage(null); + try { + const response = await adminFetch("/p2p/bootstrap", { headers: { accept: "application/json" } }); + const body = (await response.json()) as BootstrapStatus & { error?: string }; + if (!response.ok || !body.snapshot) throw new Error(body.error ?? t("errors.loadFailed")); + setStatus(body); + setDraft(toDraft(body.snapshot)); + } catch (error) { + setMessage({ kind: "error", text: error instanceof Error ? error.message : t("errors.loadFailed") }); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + async function copyAddress() { + if (!status) return; + try { + await navigator.clipboard.writeText(status.multiaddr); + setCopied(true); + setMessage({ kind: "success", text: t("addressCopied") }); + window.setTimeout(() => setCopied(false), 1600); + } catch { + setMessage({ kind: "error", text: t("errors.copyFailed") }); + } + } + + async function save() { + if (!draft) return; + setSaving(true); + setMessage(null); + try { + const data = JSON.parse(draft.data) as unknown; + if (!isStringMap(data)) throw new Error(t("errors.invalidData")); + const response = await adminFetch("/api/system/p2p/bootstrap/config", { + body: JSON.stringify({ + capabilities: splitLines(draft.capabilities), + services: splitLines(draft.services), + data, + }), + headers: { accept: "application/json", "content-type": "application/json" }, + method: "PUT", + }); + const body = (await response.json()) as Snapshot & { error?: string }; + if (!response.ok || !body.node_id) throw new Error(body.error ?? t("errors.saveFailed")); + setStatus((current) => (current ? { ...current, snapshot: body } : current)); + setDraft(toDraft(body)); + setMessage({ kind: "success", text: t("saved") }); + } catch (error) { + setMessage({ kind: "error", text: error instanceof Error ? error.message : t("errors.saveFailed") }); + } finally { + setSaving(false); + } + } + + const disabled = loading || saving || draft === null; + + return ( +
+ + +
+
+ +
+
+ {t("title")} + {t("description")} +
+
+
+ + +
+
+ + {loading ? ( +
+ + {t("loading")} +
+ ) : status && draft ? ( + <> +
+
+
{t("peerId")}
+
{status.peer_id}
+
+
+
{t("bootstrapAddress")}
+
{status.multiaddr}
+
+
+
{t("revision")}
+
+ {status.snapshot.revision} + + {t("generation", { value: status.snapshot.generation })} + +
+
+
+
{t("connections")}
+
{status.connections.length}
+
+
+
+
+ +