Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tag-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ jobs:

deploy:
needs: validate
if: startsWith(github.ref, 'refs/tags/v')
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
uses: FastR-D/FastDeploy/.github/workflows/deploy.yml@9a2c51dcd63f47a0e6c8f6bb07bd22ea589abbd2
with:
app: fastcas
Expand Down
10 changes: 7 additions & 3 deletions internal/httpapi/auth_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import (
)

type authAssets struct {
Script string
Styles []string
Bootstrap string
Script string
Styles []string
}

// Read the Vite manifest at startup. Authentication pages consume the same
Expand Down Expand Up @@ -76,6 +77,9 @@ func readAuthAssets(dir string) (authAssets, error) {
return result, err
}
result.Script, err = asset(manifest["src/auth.ts"].File, ".js")
if err == nil {
result.Bootstrap, err = asset("assets/theme-init.js", ".js")
}
if err == nil && len(result.Styles) == 0 {
return result, fmt.Errorf("console auth styles missing")
}
Expand All @@ -85,7 +89,7 @@ func readAuthAssets(dir string) (authAssets, error) {
func authTemplate(name, body string) *template.Template {
return template.Must(template.New(name).Parse(`
{{define "auth-head"}}<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{.Title}} · FastCAS</title>
{{range .Assets.Styles}}<link rel="stylesheet" href="{{.}}">{{end}}{{if .Assets.Script}}<script type="module" src="{{.Assets.Script}}"></script>{{end}}{{end}}
{{if .Assets.Bootstrap}}<script src="{{.Assets.Bootstrap}}"></script>{{end}}{{range .Assets.Styles}}<link rel="stylesheet" href="{{.}}">{{end}}{{if .Assets.Script}}<script type="module" src="{{.Assets.Script}}"></script>{{end}}{{end}}
{{define "auth-toolbar"}}<div class="auth-toolbar"><span class="auth-brand">FASTR-D / IDENTITY</span>{{if .Assets.Script}}<select class="fastui-select fastui-select--sm" data-theme-select aria-label="界面主题"><option value="system">跟随系统</option><option value="light">浅色主题</option><option value="dark">深色主题</option></select>{{end}}</div>{{end}}
{{define "auth-footer"}}<p class="auth-note">FastCAS · 可选认证,独立账号<br>各项目的账号、内容和权限由项目独立管理。</p>{{end}}
` + body))
Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/console_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func TestConsoleAuthManifestValidation(t *testing.T) {
t.Fatal(err)
}
}
for _, path := range []string{"index.html", "assets/auth.js", "assets/foundation.js", "assets/auth.css", "assets/foundation.css"} {
for _, path := range []string{"index.html", "assets/theme-init.js", "assets/auth.js", "assets/foundation.js", "assets/auth.css", "assets/foundation.css"} {
if err := os.WriteFile(filepath.Join(dir, path), []byte("fixture"), 0600); err != nil {
t.Fatal(err)
}
Expand Down
9 changes: 9 additions & 0 deletions web/FASTUI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# FastUI console

`@fastrd/ui-react` 与直接使用的 `@fastrd/ui-foundation` 精确固定消费官方 npm `0.1.2`,锁文件保留制品完整性。安装后 `pnpm run build` 将 CSS/JS 打入本地控制台输出,运行时不访问包下载站点。

保留 main 的桌面 Shell、Security、Admin、Audit、应用配置与安全确认对话框。基础表单实际使用 npm `TextField` / `TextArea` / `Select` / `Button`,加载和成功/错误反馈使用 `Loading` / `Alert`;宿主 CSS 只维护布局、密度及语义 token,没有复制浅深色板。

`src/theme-init.ts` 通过 Vite 编译为同源、parser-blocking 的 `assets/theme-init.js`,在页面内容前创建唯一 foundation controller。Shell 与原生 Auth/Device 的主题选择器复用 `window.fastCASColorMode`;保留 main 的 `fastcas.color-mode` key,默认为 system。切换主题不重挂载 React 树,不清除表单草稿/焦点。没有再次运行包的自动初始化脚本。

Auth/Device 页面继续使用 main 的 Vite manifest CSS 与原生 Go 表单校验,包括禁用 JavaScript 的登录;业务请求、一次性凭证、CSRF、近期 MFA、角色权限及确认取消行为由原有业务组件和服务端管理。
4 changes: 2 additions & 2 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pnpm run build

审计支持操作名称、执行者 ID、目标 ID 的精确匹配和开始/结束时间组合筛选;分页保留已应用条件,重置与刷新从首屏重新查询。输入时间使用浏览器时区,发送时转为 UTC;开始时间包含、结束时间排除。后台迁移 `015_audit_filters.sql` 为检索与分页增加索引。

主题支持浅色、深色和跟随系统,使用 foundation 的颜色模式控制器;仅 `fastcas.color-mode` 偏好写入 localStorage。一次性凭证、客户端密钥、恢复码和认证器密钥只保存在当前页面内存中;离开对应页面会卸载组件并清空显示,管理页之间不会复用密钥。查询参数中的邀请/恢复凭证在 React 发出请求前从地址栏清除。凭证须通过可信渠道私密交付。
主题支持浅色、深色和跟随系统,使用 foundation 的颜色模式控制器;仅 `fastcas.color-mode` 偏好写入 localStorage。同源经典脚本 `assets/theme-init.js` 在 head 阻塞初始化首屏主题,Shell 和原生认证页复用同一 controller,系统或跨标签主题变化保持表单 DOM、草稿和焦点。一次性凭证、客户端密钥、恢复码和认证器密钥只保存在当前页面内存中;离开对应页面会卸载组件并清空显示,管理页之间不会复用密钥。查询参数中的邀请/恢复凭证在 React 发出请求前从地址栏清除。凭证须通过可信渠道私密交付。

公开表单入口为 `/console/?flow=invite`、`?flow=recovery`、`?flow=mfa_reset`,支持手动凭证或 `token` 查询参数。切换流程清空原凭证与密码。成功后提示独立登录,各项目账号不会自动创建或合并。

Expand All @@ -29,7 +29,7 @@ FASTCAS_BROWSER_CONTRACT=1 \
go test ./internal/httpapi -run '^TestConsoleAgainstRealBrowser$' -count=1 -v
```

该契约使用隔离 schema、真实 HTTP 和构建产物,覆盖注册、登录、改密、恢复、MFA、分页、审计筛选、应用配置保存/失败重试/取消、焦点恢复、密钥轮换与清除、服务生命周期和设备确认。也验证禁用 JavaScript 时的原生登录、桌面深浅主题和主题持久化;截图保存在 `.local/ui-verification/`,只含隔离测试数据。`TestAuditFiltersAndPagination` 验证服务端时间边界、组合筛选、分页、非法条件与权限;`TestConsoleAuthManifestValidation` 验证产物完整性与路径约束。
该契约使用隔离 schema、真实 HTTP 和构建产物,覆盖注册、登录、改密、恢复、MFA、分页、审计筛选、应用配置保存/失败重试/取消、焦点恢复、密钥轮换与清除、服务生命周期和设备确认。也验证禁用 JavaScript 时的原生登录、桌面深浅主题和主题持久化;延迟应用模块检查 console/login 的保存偏好、系统默认、无效值和存储拒绝首屏,以及首屏 controller 同一性和安全表单跨主题保持。截图和 `integration-results.json` 保存在 `.local/ui-verification/`,只含隔离测试数据。`TestAuditFiltersAndPagination` 验证服务端时间边界、组合筛选、分页、非法条件与权限;`TestConsoleAuthManifestValidation` 验证产物完整性与路径约束。

这轮完成控制台基础重构与审计检索,不代表全部商业化规划完成。真实域名部署、监控告警、备份恢复和跨项目容量/SLO 验收仍需按项目交付清单推进。

Expand Down
2 changes: 1 addition & 1 deletion web/src/Guest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export function Guest({ initial }: { initial: InitialCredential }) {
</label>
)}
{error && <Alert tone="danger">{error}</Alert>}
<Button type="submit" variant="primary" disabled={busy}>
<Button type="submit" variant="primary" loading={busy}>
{busy
? "正在提交…"
: flow === "invite"
Expand Down
5 changes: 2 additions & 3 deletions web/src/Shell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState, type ReactNode } from "react";
import { Button, Select, Badge } from "@fastrd/ui-react";
import { initColorMode, type ColorMode } from "@fastrd/ui-foundation";
import type { ColorMode } from "@fastrd/ui-foundation";
import {
ShieldCheck,
Link2,
Expand All @@ -23,8 +23,7 @@ import {
} from "lucide-react";
import { Icon } from "./Icon";

// Initialize before React mounts; only the non-sensitive theme preference is persisted.
const theme = initColorMode({ storageKey: "fastcas.color-mode" });
const theme = window.fastCASColorMode;
const groups: { label: string; items: [string, string, LucideIcon][] }[] = [
{
label: "个人账号",
Expand Down
3 changes: 1 addition & 2 deletions web/src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { initColorMode } from "@fastrd/ui-foundation";
import "@fastrd/ui-react/styles.css";
import "./auth.css";

// Authentication remains a native, server-validated form, including without JavaScript.
const theme = initColorMode({ storageKey: "fastcas.color-mode" });
const theme = window.fastCASColorMode;
const select = document.querySelector<HTMLSelectElement>("[data-theme-select]");
if (select) {
select.value = theme.mode;
Expand Down
4 changes: 2 additions & 2 deletions web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ function App() {
{page === "integration" && me.user.role === "admin" && (
<Integration api={api} busy={busy} run={run} />
)}
{page === "exchange-policies" && (
{page === "exchange-policies" && me.user.role === "admin" && (
<Delegations key="admin" api={api} busy={busy} run={run} admin />
)}
{page === "sessions" && (
Expand Down Expand Up @@ -386,7 +386,7 @@ function App() {
].includes(page) && (
<Admin key={page} page={page} api={api} busy={busy} run={run} />
)}
{page === "outbox" && (
{page === "outbox" && me.user.role === "admin" && (
<section>
<h2>失败投递</h2>
<p>
Expand Down
10 changes: 10 additions & 0 deletions web/src/theme-init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { initColorMode, type ColorModeController } from "@fastrd/ui-foundation";

declare global {
interface Window {
fastCASColorMode: ColorModeController;
}
}

// Built as a blocking classic script; console and native auth reuse this handle.
window.fastCASColorMode = initColorMode({ storageKey: "fastcas.color-mode" });
94 changes: 93 additions & 1 deletion web/tests/console_contract.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { mkdir } from "node:fs/promises";
import { mkdir, writeFile } from "node:fs/promises";
import { createHmac } from "node:crypto";
import { chromium } from "playwright-core";

Expand All @@ -20,6 +20,7 @@ const context = await browser.newContext({
});
const page = await context.newPage();
const uiErrors = [];
const integrationChecks = [];
page.on("pageerror", (error) => uiErrors.push(error.message));
await mkdir("../.local/ui-verification", { recursive: true });
const screenshot = async (name) => {
Expand All @@ -46,6 +47,85 @@ const acceptConfirmation = async () =>
.getByRole("dialog", { name: "确认操作" })
.getByRole("button", { name: "确认并继续" })
.click();
// Delay only the application module: inspect the actual server-delivered page
// after the blocking bootstrap, before either React or auth controls execute.
async function firstPaint(path, stored, system, expected, denyStorage = false) {
const isolated = await browser.newContext({ colorScheme: system });
try {
await isolated.addInitScript(({ stored, denyStorage }) => {
if (stored !== null) localStorage.setItem("fastcas.color-mode", stored);
if (denyStorage) {
Storage.prototype.getItem = Storage.prototype.setItem = () => {
throw new DOMException("Storage denied", "SecurityError");
};
}
}, { stored, denyStorage });
const target = await isolated.newPage();
const errors = [];
target.on("pageerror", (error) => errors.push(error.message));
let release;
const held = new Promise((resolve) => { release = resolve; });
await target.route(/\/assets\/(console|auth)-.*\.js$/, async (route) => {
await held;
await route.continue();
});
try {
await target.goto(origin + path, { waitUntil: "commit" });
await target.waitForFunction(() => window.fastCASColorMode, { timeout: 5000 });
const controller = await target.evaluateHandle(() => window.fastCASColorMode);
assert.equal(await target.locator("html").getAttribute("data-fastui-theme"), expected);
release();
await target.getByLabel("界面主题").waitFor();
assert.equal(await target.evaluate((initial) => window.fastCASColorMode === initial, controller), true,
"application replaced the first-paint controller");
if (path === "/console/") {
await target.getByLabel("一次性凭证").fill("unsubmitted-draft-token");
await target.getByLabel("姓名").fill("主题草稿");
await target.getByLabel("新密码", { exact: true }).fill("draft-password-123");
await target.getByLabel("确认新密码").fill("different-password-456");
await target.getByRole("button", { name: "创建 FastCAS 账号" }).click();
await target.getByRole("alert").getByText("两次输入的密码不一致").waitFor();
} else {
await target.locator("input[name=email]").fill("draft@example.test");
}
await target.getByLabel("界面主题").selectOption(expected === "dark" ? "light" : "dark");
assert.equal(await target.locator("html").getAttribute("data-fastui-theme"), expected === "dark" ? "light" : "dark");
assert.deepEqual(errors, []);
integrationChecks.push({ path, stored, system, denyStorage, firstPaint: expected, sameController: true });
} finally {
release();
}
} finally {
await isolated.close();
}
}

async function preserveSecurityDraft() {
await page.getByLabel("界面主题").selectOption("system");
const input = page.getByLabel("新 FastCAS 密码", { exact: true });
await input.fill("preserved-password-draft-123");
await input.focus();
const original = await input.elementHandle();
const assertDraft = async () => {
assert.equal(await input.inputValue(), "preserved-password-draft-123");
assert.equal(await input.evaluate((element, initial) => element === initial && element === document.activeElement, original), true,
"theme change remounted or blurred the security form");
};
await page.emulateMedia({ colorScheme: "dark" });
await page.waitForFunction(() => document.documentElement.dataset.fastuiTheme === "dark");
await assertDraft();
const other = await context.newPage();
try {
await other.goto(origin + "/console/");
await other.getByLabel("界面主题").selectOption("light");
await page.waitForFunction(() => document.documentElement.dataset.fastuiTheme === "light");
await assertDraft();
} finally {
await other.close();
}
await input.fill("");
integrationChecks.push({ securityDraft: "system-and-cross-tab", sameInput: true, focus: true });
}
page.setDefaultTimeout(5000);
function totp(uri) {
const secret = new URL(uri).searchParams.get("secret");
Expand Down Expand Up @@ -78,6 +158,13 @@ function totp(uri) {
return String(number % 1000000).padStart(6, "0");
}
try {
for (const path of ["/console/", "/login"]) {
await firstPaint(path, "dark", "light", "dark");
await firstPaint(path, "light", "dark", "light");
await firstPaint(path, "invalid", "dark", "dark");
await firstPaint(path, null, "light", "light");
await firstPaint(path, null, "dark", "dark", true);
}
await page.goto(
`${origin}/console/?flow=invite&token=${encodeURIComponent(invite)}`,
{ waitUntil: "networkidle" },
Expand Down Expand Up @@ -131,6 +218,10 @@ try {
.fill("invited-reader-password-123");
await page.getByRole("button", { name: "登录", exact: true }).click();
await page.getByRole("heading", { name: "你好,Invited Reader" }).waitFor();
assert.equal(await page.getByRole("button", { name: "应用管理", exact: true }).count(), 0);
assert.equal(await page.getByRole("button", { name: "调用策略", exact: true }).count(), 0);
assert.equal(await page.getByRole("button", { name: "失败投递", exact: true }).count(), 0);
await preserveSecurityDraft();
await page.getByLabel("原 FastCAS 密码").fill("invited-reader-password-123");
await page
.getByLabel("新 FastCAS 密码", { exact: true })
Expand Down Expand Up @@ -586,6 +677,7 @@ try {
"administrator retained management access after MFA reset",
);
assert.deepEqual(uiErrors, [], "browser JavaScript errors");
await writeFile("../.local/ui-verification/integration-results.json", JSON.stringify({ checks: integrationChecks, browserErrors: uiErrors, consoleContract: "passed" }, null, 2));
console.log(
"FastCAS browser contract passed: invite, login, logout, password and MFA recovery, MFA enrollment and recovery-code rotation, audit filtering and pagination, FastUI light/dark desktop themes, accessible application configuration dialogs, cancelled rotation, one-time secret isolation, delivery history, developer integration guide, application registration, service lifecycle, secret rotation and invitation",
);
Expand Down
22 changes: 11 additions & 11 deletions web/tests/fasttask_contract.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict';
import {chromium} from 'playwright-core';
import {mkdir,writeFile} from 'node:fs/promises';
import {assertTaskStorage,captureTaskStorage,verifyTaskStorageBoundary} from './fasttask_storage_contract.mjs';

const {FASTCAS_CONTRACT_TASK_ORIGIN: origin, FASTCAS_CONTRACT_ISSUER: issuer} = process.env;
if(!origin||!issuer)throw Error('FastTask browser contract configuration missing');
Expand Down Expand Up @@ -55,16 +57,10 @@ try{
assert.equal(cas.role,local.role,'FastCAS login changed project role');
await openAccount(login);
await login.getByText('已完成 FastCAS 认证').waitFor();
const storage=await login.evaluate(()=>({
keys:Object.keys(localStorage).sort(),
offlineUser:localStorage.getItem('fasttask_offline_user'),
sessionKeys:Object.keys(sessionStorage),
}));
assert.deepEqual(storage.keys,['fasttask_offline_user','fasttask_refresh'],'unexpected browser storage');
assert.deepEqual(storage.sessionKeys,[],'unexpected session storage');
const offlineUser=JSON.parse(storage.offlineUser);
assert.deepEqual(Object.keys(offlineUser).sort(),['$schema','created_at','display_name','id','identifier','locale','revision','role','status','timezone','updated_at'],'offline user cache contains unexpected fields');
assert.deepEqual(offlineUser,cas,'offline user cache differs from project profile');
await login.getByRole('separator',{name:'调整研究助手宽度'}).press('End');
assert.equal((await captureTaskStorage(login)).local['fasttask.research-assistant.width'],'800','assistant End did not persist its maximum width');
assertTaskStorage(await captureTaskStorage(login),cas);
const storageCases=await verifyTaskStorageBoundary(login,cas);
await fillMDUI(page,'确认本地 FastTask 密码','password-for-tests');
await page.getByText('解除 FastCAS 认证',{exact:true}).click();
await page.getByText('当前账号未认证').waitFor();
Expand All @@ -73,8 +69,12 @@ try{
const restoredMe=meResponse(page);
await page.reload({waitUntil:'networkidle'});
assert.equal((await(await restoredMe).json()).id,local.id,'local session did not survive unlink');
await mkdir(new URL('../../.local/fasttask-contract/',import.meta.url),{recursive:true});
await writeFile(new URL('../../.local/fasttask-contract/results.json',import.meta.url),JSON.stringify({
storageCases,profileExact:true,unlinkCompleted:true,casSessionRevoked:true,localSessionPreserved:true,stableUserAndRole:true,
},null,2));
}finally{await second.close()}
console.log('FastTask browser contract passed: local administrator, explicit link, FastCAS login, revoke isolation, stable user and role');
console.log('FastTask browser contract passed: local administrator, explicit link, FastCAS login, exact storage/profile and rejection cases, browser unlink, CAS session revoked, local session preserved, stable user and role');
}catch(error){
console.error('browser state:',page.url(),(await page.locator('body').innerText().catch(()=>'' )).slice(0,700));
throw error;
Expand Down
Loading
Loading