@airalogy/airalogy-bd 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  版本统一使用 `x.y.z`,公开版本记录从 1.0.0 开始。本文件记录版本内容;安装包、镜像和线上服务的发布状态分别确认。
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ## [1.2.0] - 2026-10-26
8
+
9
+ - 邀请成员时邮箱改为选填,由成员激活时自行补充;保留内部身份、原有权限及历史,拒绝占用其他成员的邮箱。
10
+ - 增加可选邮箱归属验证,默认关闭;通过 SMTP 发送短期一次性验证码,保留密码与验证器登录,提供重试、限速及服务端授权校验。
11
+ - 成员管理区分角色和事项访问范围,明确新增事项的授权规则,支持搜索、批量选择和保存前权限预览。
12
+ - 显示邀请到期时间和过期状态,支持重新邀请并废止旧链接;修复未填写邮箱的待激活成员重新邀请时的异常。
13
+ - 已设置的用户名显示为锁定状态;精简软件版本说明。
14
+
15
+ ## [1.1.2] - 2026-10-26
16
+
17
+ - 源码开发直接使用 Next.js Fast Refresh,保留开发编译缓存;页面、样式和接口代码修改无需重启,安装工作区和生产构建继续使用独立运行目录。
18
+ - GitHub 正式 Release 自动构建并测试 Linux amd64 / arm64 镜像,使用临时 GitHub 凭据将同一测试归档发布到 GHCR,与 npm 保持同一版本。
19
+ - 增加镜像版本、提交、完整性及匿名拉取校验,拒绝覆盖不同内容的版本;自动提供锁定镜像摘要的部署包及校验文件。
20
+ - 团队部署默认使用预构建镜像,源码构建通过单独的 Compose 配置显式启用。
21
+
5
22
  ## [1.1.1] - 2026-10-26
6
23
 
7
24
  - 增加 GitHub Actions 可信发布流程:正式 Release 触发完整测试与安装包验证,通过 npm OIDC 发布同一归档;手动运行只检查,不发布。
package/README.md CHANGED
@@ -14,7 +14,7 @@ Airalogy BD 是一套支持本地使用与团队服务器部署的 BD 管理工
14
14
 
15
15
  支持邀请制账号、密码与动态验证码登录、管理员/编辑/只读角色、事项授权、录入账号追踪、服务器容器部署和私有 OSS 自动备份。本地模式无需登录。部署方式、权限范围、备份恢复和版本更新步骤见 [团队部署说明](docs/server-deployment.md)。
16
16
 
17
- 管理员可直接创建成员并复制一次性邀请链接,由本人设置用户名、密码和动态验证码;支持用户名或邮箱登录。默认使用 Airalogy Logo,自行部署可配置站点名称及自有 Logo,详见[账号邀请与站点品牌](docs/accounts-and-branding.md)。
17
+ 管理员填写姓名并分配权限即可创建成员,邮箱选填;复制一次性邀请链接交给本人,由本人补充邮箱、设置用户名、密码和动态验证码。支持用户名或邮箱登录。邮箱归属验证可选、默认关闭,无需配置邮件服务即可使用;管理员配置 SMTP 后可为整站开启。默认使用 Airalogy Logo,自行部署可配置站点名称及自有 Logo,详见[账号邀请与站点品牌](docs/accounts-and-branding.md)。
18
18
 
19
19
  ## 可选 AI 助手
20
20
 
@@ -27,6 +27,15 @@ Airalogy BD 是一套支持本地使用与团队服务器部署的 BD 管理工
27
27
  - uv
28
28
  - Git
29
29
 
30
+ ## 安装方式
31
+
32
+ - 团队成员:打开公司部署的网址,无需安装。
33
+ - 公司服务器:优先下载 GitHub Release 的预构建镜像部署包,按[团队部署说明](docs/server-deployment.md)使用固定版本镜像;不需要在服务器安装开发依赖或构建程序。
34
+ - 本地工作台:用 pnpm 安装固定版本的 npm 包,步骤见下文。
35
+ - 开发与定制:检出 GitHub 源码。
36
+
37
+ 容器镜像与 npm、GitHub Release 使用同一 `x.y.z`,支持 Linux amd64 / arm64。以 Release 中的部署附件和工作流成功状态确认可用性,不根据 npm 版本推断镜像已经发布。
38
+
30
39
  ## 安装与初始化
31
40
 
32
41
  在独立的业务工作区安装指定版本。以下以 `1.1.1` 为例,需要该版本已发布到 npm:
@@ -113,6 +122,12 @@ pnpm dev
113
122
 
114
123
  `fixtures/demo/content/` 只包含虚构数据,用于平台回归测试。
115
124
 
125
+ 日常开发直接运行 `pnpm dev`,不构建 Docker。此命令通过 `bd dev --source` 直接运行 `dashboard/` 源码,由 Next.js 原生 Fast Refresh 处理组件、样式及接口代码更新,不复制源码或额外实现同步监听器。编译缓存保留在 `dashboard/.next/`,重启开发服务可继续复用;默认使用 Webpack,兼容链接依赖。业务资料变更仍通过数据服务自动刷新。
126
+
127
+ 源码开发可用 `bd dev --source --content /指定工作区/content` 选择资料目录;同一个源码目录同时运行一个开发服务。修改 CLI、依赖或启动环境变量后需重启,依赖变化还需重新执行 `pnpm install`。普通安装用户继续使用 `bd dev` 或双击启动文件,保留各自独立运行目录。
128
+
129
+ 按改动运行针对性测试;`pnpm test:dev` 在临时源码副本和虚构工作区中验证真实开发服务的热更新。发版前再执行完整测试。Docker 构建和双架构容器演练用于正式发版,以及 Dockerfile、系统依赖或容器运行配置的修改,不是每次开发都必须执行的步骤。
130
+
116
131
  需要在独立工作区调试源码时,可执行 `node /path/to/airalogy-bd/bin/bd.mjs init --source`,再安装工作区依赖。`--source` 仅为新依赖使用本地链接;初始化不会覆盖已有依赖版本或自定义命令。npm 安装包的验证和发布见[发布说明](docs/npm-publishing.md)。
117
132
 
118
133
  ### 目录与文件命名
@@ -276,9 +291,9 @@ bd dev --content /path/to/new-workspace/content
276
291
 
277
292
  页脚显示实际运行的软件版本,点击可查看构建时间、构建标识及本地代码版本。从源码构建时显示“构建来源:本地源码”;版本来自工作台软件自身,不读取公司工作区的包版本。构建标识随程序内容变化,用于区分同一版本号下的本地更新。
278
293
 
279
- 版本信息在 `bd build` / `bd dev` 准备运行目录时固定,`bd start` 沿用已有构建的信息;修改或升级源码后需重新构建并启动才能加载。界面仅展示当前运行版本,不自动查询远程发布,发布状态由发布标签与分发产物确定。
294
+ 版本信息在 `bd build` / `bd dev` 启动时固定,`bd start` 沿用已有构建的信息。源码开发的页面代码可热更新,版本卡片中的构建标识仍对应启动时的源码快照;重启开发服务可刷新此标识。正式运行时,修改或升级源码后需重新构建并启动才能加载。界面仅展示当前运行版本,不自动查询远程发布,发布状态由发布标签与分发产物确定。
280
295
 
281
- `pnpm version:check` 检查软件版本与更新记录是否一致,已纳入 `pnpm test`。代码提交和推送不自动发版或部署。正式发布 GitHub Release 后,已配置的[可信发布流程](docs/npm-publishing.md)会验证并发布 npm 安装包;服务器部署仍单独执行。
296
+ `pnpm version:check` 检查软件版本与更新记录是否一致,已纳入 `pnpm test`。代码提交和推送不自动发版或部署。正式发布 GitHub Release 后,已配置的[可信发布流程](docs/npm-publishing.md)会验证并发布 npm 安装包、同版本容器镜像和部署附件;服务器部署仍单独执行。
282
297
 
283
298
  公开前运行 `pnpm publication:check`,并按 [公开代码检查说明](docs/publication-check.md) 复核源码及实际分发文件。
284
299
 
@@ -19,7 +19,7 @@ The declared dependency inventory is recorded in [docs/dependency-licenses.json]
19
19
 
20
20
  ## Inventory coverage
21
21
 
22
- The inventory covers metadata for 602 locked npm package/version pairs (549 installed manifests and 53 official registry manifests for optional/platform packages), plus 17 locked Python package/version pairs. Its inspection date and lockfile hashes are recorded in the inventory. Each entry includes a declared license. Most dependencies use permissive licenses; the exceptions above must not be treated as Apache-2.0.
22
+ The inventory covers metadata for 604 locked npm package/version pairs (551 installed manifests and 53 official registry manifests for optional/platform packages), plus 17 locked Python package/version pairs. Its inspection date and lockfile hashes are recorded in the inventory. Each entry includes a declared license. Most dependencies use permissive licenses; the exceptions above must not be treated as Apache-2.0.
23
23
 
24
24
  The source repository does not vendor the installed dependencies. Package metadata does not exhaustively describe every library, font or dataset embedded inside a native binary or framework bundle. The inventory is therefore not a completed binary/image distribution compliance report. Before distributing an installer, browser bundle or container, inspect the exact artifact, include its required notices and source access, and account for Node.js, Python, uv, Git, system libraries and the base image's terms. Artifact publication requires a separate distribution review.
25
25
 
package/bin/bd.mjs CHANGED
@@ -139,25 +139,34 @@ function workspaceRequest(contentDirectory, request) {
139
139
  }
140
140
 
141
141
  function runNext(command, contentDirectory, forwardedArguments) {
142
+ const sourceDevelopment = command === "dev" && forwardedArguments.includes("--source");
143
+ if (sourceDevelopment) forwardedArguments = forwardedArguments.filter(argument => argument !== "--source");
142
144
  const serverMode = process.env.BD_MODE === "server";
143
145
  if (process.env.BD_MODE && !["local","server"].includes(process.env.BD_MODE)) fail("BD_MODE 只支持 local 或 server。");
144
146
  if (serverMode && command !== "build") {
145
147
  deploymentOrigin();
148
+ const emailVerification = process.env.BD_REQUIRE_EMAIL_VERIFICATION || "0";
149
+ if (!["0","1"].includes(emailVerification)) fail("BD_REQUIRE_EMAIL_VERIFICATION 只支持 0 或 1。");
150
+ if (emailVerification === "1" && ["BD_SMTP_HOST","BD_SMTP_USER","BD_SMTP_PASSWORD","BD_MAIL_FROM"].some(key => !process.env[key]?.trim())) fail("开启邮箱验证前,请先配置发信服务器、用户名、密码和发件邮箱;暂不启用时将 BD_REQUIRE_EMAIL_VERIFICATION 设为 0。");
146
151
  if (!process.env.BETTER_AUTH_SECRET || process.env.BETTER_AUTH_SECRET.length < 32) fail("请配置至少 32 字符的 BETTER_AUTH_SECRET。");
147
152
  if (command !== "start") fail("服务器模式请使用 bd build 构建后运行 bd start。");
148
153
  if (!existsSync(path.join(path.dirname(contentDirectory), ".bd-auth", "accounts.sqlite3")) || !workspaceRequest(contentDirectory,{action:"account_status"}).initialized) fail("请先使用 bd account-init 初始化管理员。");
149
154
  }
150
155
  if (command !== "build") workspaceRequest(contentDirectory, {action:"checkpoint",origin:"checkpoint",label:"启动工作台时留存资料",force:true});
151
156
  const dataFile = command === "build" ? generateData(contentDirectory) : path.join(contentDirectory,"generated","dashboard.json");
152
- const dashboardDirectory = runtimeDashboardDirectory({prepare:command !== "start"});
157
+ // Let Next.js watch the real source tree and manage Fast Refresh itself.
158
+ // Installed workspaces and production builds retain their isolated runtime.
159
+ const dashboardDirectory = sourceDevelopment ? dashboardSourceDirectory : runtimeDashboardDirectory({prepare:command !== "start"});
153
160
  const nextBinary = require.resolve("next/dist/bin/next");
154
- const env = {...process.env, BD_DATA_FILE:dataFile, BD_CONTENT_DIR:contentDirectory, BD_SERVICE_FILE:path.join(scriptsDirectory,"workspace_service.py"), BD_CACHE_DIR:cacheDirectory, BD_CSRF_TOKEN:randomBytes(32).toString("hex")};
155
- if (command === "build") {run(process.execPath,[nextBinary,command,dashboardDirectory,...(forwardedArguments.some(x=>["--webpack","--turbopack","--turbo"].includes(x))?[]:["--webpack"]),...forwardedArguments],{env});return;}
161
+ const env = {...process.env, BD_DATA_FILE:dataFile, BD_CONTENT_DIR:contentDirectory, BD_SERVICE_FILE:path.join(scriptsDirectory,"workspace_service.py"), BD_CACHE_DIR:cacheDirectory, BD_CSRF_TOKEN:randomBytes(32).toString("hex"), NEXT_PUBLIC_BD_DEV_BUILD_INFO:sourceDevelopment ? JSON.stringify(createBuildInfo(packageRoot)) : ""};
162
+ const bundler = command !== "start" && !forwardedArguments.some(x=>["--webpack","--turbopack","--turbo"].includes(x)) ? ["--webpack"] : [];
163
+ if (command === "build") {run(process.execPath,[nextBinary,command,dashboardDirectory,...bundler,...forwardedArguments],{env});return;}
156
164
  const hosts = forwardedArguments.flatMap((arg,index) => ["--hostname","-H"].includes(arg) ? [forwardedArguments[index+1]] : arg.startsWith("--hostname=") ? [arg.slice(11)] : arg.startsWith("-H") ? [arg.slice(2)] : []);
157
165
  const suppliedHost = hosts.length > 0;
158
166
  if (!serverMode && hosts.some(host => !["localhost","127.0.0.1","::1"].includes(host))) fail("本地模式只允许绑定本机地址;对外服务请配置服务器模式和登录账号。");
167
+ if (sourceDevelopment) console.log("源码开发模式:由 Next.js 自动刷新,编译缓存保存在 dashboard/.next。");
159
168
  const watcher = spawn(process.execPath,[path.join(scriptsDirectory,"watch-content.mjs")],{env,stdio:"inherit"});
160
- const server = spawn(process.execPath,[nextBinary,command,dashboardDirectory,...(!suppliedHost ? ["--hostname","127.0.0.1"] : []),...forwardedArguments],{env,stdio:"inherit"});
169
+ const server = spawn(process.execPath,[nextBinary,command,dashboardDirectory,...bundler,...(!suppliedHost ? ["--hostname","127.0.0.1"] : []),...forwardedArguments],{env,stdio:"inherit"});
161
170
  const stop = signal => {watcher.kill(signal);server.kill(signal);};
162
171
  process.once("SIGINT",() => stop("SIGINT"));
163
172
  process.once("SIGTERM",() => stop("SIGTERM"));
@@ -182,7 +191,7 @@ function printHelp() {
182
191
  bd launch
183
192
  bd validate [--content <dir>]
184
193
  bd data [--content <dir>]
185
- bd dev [--content <dir>] [Next.js options]
194
+ bd dev [--source] [--content <dir>] [Next.js options]
186
195
  bd build [--content <dir>] [Next.js options]
187
196
  bd start [--content <dir>] [Next.js options]
188
197
  bd lint
@@ -7,7 +7,7 @@ import path from "node:path";
7
7
  export function createBuildInfo(packageRoot) {
8
8
  const manifest = JSON.parse(readFileSync(path.join(packageRoot, "package.json"), "utf8"));
9
9
  const hash = createHash("sha256");
10
- const ignored = new Set(["node_modules", ".next", "__pycache__", ".pytest_cache", ".DS_Store", "tsconfig.tsbuildinfo"]);
10
+ const ignored = new Set(["node_modules", ".next", "__pycache__", ".pytest_cache", ".DS_Store", "tsconfig.tsbuildinfo", "next-env.d.ts"]);
11
11
  const visit = relative => {
12
12
  const absolute = path.join(packageRoot, relative);
13
13
  if (!existsSync(absolute)) return;
@@ -13,7 +13,9 @@ export default function AccountSettings({ user }: { user: TeamUser }) {
13
13
  const [busy, setBusy] = useState(false);
14
14
  const [error, setError] = useState("");
15
15
  async function save(event: React.FormEvent) {
16
- event.preventDefault(); setBusy(true); setError("");
16
+ event.preventDefault();
17
+ if (saved || busy) return;
18
+ setBusy(true); setError("");
17
19
  try {
18
20
  const result = await teamRequest<{ username: string }>("auth_username", { username }, user.csrf);
19
21
  setUsername(result.username); setSaved(true); router.refresh();
@@ -22,12 +24,12 @@ export default function AccountSettings({ user }: { user: TeamUser }) {
22
24
  }
23
25
  return <><AccountBar user={user} management /><main className="page-shell account-settings">
24
26
  <BrandIdentity /><h1>我的账号</h1>
25
- <section className="panel"><p>姓名:{user.name}</p><p>绑定邮箱:{user.email}</p>
27
+ <section className="panel"><p>姓名:{user.name}</p><p>账号邮箱:{user.email}({user.email_verified?"已验证":"未验证"})</p>
26
28
  <p>用户名和邮箱均可登录,姓名用于展示。设置用户名不会改变你的事项权限或历史记录。</p>
27
29
  {error && <p role="alert" className="admin-alert">{error}</p>}
28
- <form className="team-form" onSubmit={save}><label>用户名<input required autoComplete="username" autoCapitalize="none" spellCheck={false} pattern={usernamePattern} minLength={3} maxLength={30} readOnly={saved} value={username} onChange={e => setUsername(e.target.value)} /></label>
29
- <p className="admin-help">{usernameHelp}</p>
30
- {saved ? <p className="admin-notice" role="status">已设置,可使用此用户名登录。</p> : <button className="admin-button admin-primary" disabled={busy}>{busy ? "正在保存…" : "设置用户名"}</button>}
30
+ <form className="team-form" onSubmit={save}><label>用户名<input required autoComplete="username" autoCapitalize="none" spellCheck={false} pattern={usernamePattern} minLength={3} maxLength={30} readOnly={saved} aria-describedby="account-username-help" value={username} onChange={e => setUsername(e.target.value)} /></label>
31
+ <p id="account-username-help" className={saved ? "admin-notice" : "admin-help"} role={saved ? "status" : undefined}>{saved ? "已锁定,不能修改。可使用此用户名登录。" : usernameHelp}</p>
32
+ {!saved && <button className="admin-button admin-primary" disabled={busy}>{busy ? "正在保存…" : "设置用户名"}</button>}
31
33
  </form><p className="admin-help">忘记密码或无法取得动态验证码时,请联系管理员重新签发登录邀请。</p>
32
34
  </section>
33
35
  </main></>;
@@ -23,7 +23,7 @@ export default function AdminCenter({ user, projects, timezone, unavailable, ini
23
23
  {!user && <p className="admin-notice">当前为本机单人模式,由本机使用者管理设置。团队模式启用后,仅管理员账号可以进入本页面。</p>}
24
24
  {unavailable && <p className="admin-alert" role="alert">工作区资料暂时不可用,事项列表未能加载,请先检查工作区。</p>}
25
25
  <nav className="admin-center-nav" aria-label="管理中心功能">{Object.entries(sections).map(([key, label]) => <Link key={key} href={`/admin?section=${key}`} aria-current={section === key ? "page" : undefined}>{label}</Link>)}</nav>
26
- {section === "members" && (user ? <TeamManagement user={user} projects={projects} /> : <section className="panel admin-center-panel"><h2>成员与权限</h2><p>成员邀请、停用账号和事项授权在团队模式中提供。本机单人模式无需创建账号。</p></section>)}
26
+ {section === "members" && (user ? <TeamManagement user={user} projects={projects} timezone={timezone} /> : <section className="panel admin-center-panel"><h2>成员与权限</h2><p>成员邀请、停用账号和事项授权在团队模式中提供。本机单人模式无需创建账号。</p></section>)}
27
27
  {section === "ai" && <AiSettings />}
28
28
  {section === "usage" && <section className="panel admin-center-panel"><h2>AI 用量与费用</h2><AiUsage initialFrom={dates.from} initialTo={dates.to} /></section>}
29
29
  {section === "backup" && <BackupSettings timezone={timezone} onChanged={() => {}} />}
@@ -6,6 +6,7 @@ import {BrandIdentity} from "./Branding";
6
6
  import {usernamePattern,usernameHelp} from "./lib/username-policy";
7
7
 
8
8
  type Setup={secret:string;otp_uri:string};
9
+ type LoginStep={step:"email"|"setup"|"verify";email_sent?:boolean};
9
10
  export default function LoginForm({join=false}:{join?:boolean}) {
10
11
  const router=useRouter();
11
12
  const token=useRef("");
@@ -13,9 +14,12 @@ export default function LoginForm({join=false}:{join?:boolean}) {
13
14
  const [username,setUsername]=useState("");
14
15
  const [password,setPassword]=useState("");
15
16
  const [code,setCode]=useState("");
16
- const [invitation,setInvitation]=useState<{email:string;name:string;username?:string|null}|null>(null);
17
+ const [emailCode,setEmailCode]=useState("");
18
+ const [accepted,setAccepted]=useState(false);
19
+ const [emailSent,setEmailSent]=useState(false);
20
+ const [invitation,setInvitation]=useState<{email:string|null;name:string;username?:string|null}|null>(null);
17
21
  const [setup,setSetup]=useState<Setup|null>(null);
18
- const [step,setStep]=useState<"password"|"setup"|"verify">("password");
22
+ const [step,setStep]=useState<"password"|"email"|"setup"|"verify">("password");
19
23
  const [error,setError]=useState("");
20
24
  const [busy,setBusy]=useState(false);
21
25
  useEffect(()=>{
@@ -24,17 +28,34 @@ export default function LoginForm({join=false}:{join?:boolean}) {
24
28
  // Keep invitations out of URLs, access logs and referrers after opening the page.
25
29
  window.history.replaceState(null,"",window.location.pathname);
26
30
  let active=true;
27
- teamRequest<{email:string;name:string;username?:string|null}>("auth_invite_info",{token:token.current}).then(data=>{if(active){setInvitation(data);setEmail(data.email);setUsername(data.username||"");}}).catch(err=>{if(active)setError(err.message);});
31
+ teamRequest<{email:string|null;name:string;username?:string|null}>("auth_invite_info",{token:token.current}).then(data=>{if(active){setInvitation(data);setEmail(data.email||"");setUsername(data.username||"");}}).catch(err=>{if(active)setError(err.message);});
28
32
  return()=>{active=false;};
29
33
  },[join]);
34
+ async function passwordLogin() {
35
+ const response=await teamRequest<LoginStep>(join&&!accepted?"auth_accept":"auth_login",{identifier:email,email,username,password,token:token.current});
36
+ if(join)setAccepted(true);
37
+ setStep(response.step);setEmailSent(response.email_sent===true);
38
+ if(response.step==="email")return;
39
+ if(response.step==="verify"){setPassword("");return;}
40
+ const enrollment=await teamRequest<Setup>("auth_setup",{password});
41
+ setSetup(enrollment);setPassword("");setStep("verify");
42
+ }
43
+ async function resendEmail() {
44
+ setBusy(true);setError("");
45
+ try {await passwordLogin();setEmailCode("");}
46
+ catch(err){setError((err as Error).message);}
47
+ finally{setBusy(false);}
48
+ }
30
49
  async function submit(event:React.FormEvent) {
31
50
  event.preventDefault();setBusy(true);setError("");
32
51
  try {
33
52
  if(step==="verify") {await teamRequest("auth_verify",{code});router.replace("/");router.refresh();return;}
53
+ if(step==="email") {
54
+ await teamRequest("auth_email_verify",{identifier:email,code:emailCode});
55
+ setEmailCode("");await passwordLogin();return;
56
+ }
34
57
  if(step==="password") {
35
- const response=await teamRequest<{step:"setup"|"verify"}>(join?"auth_accept":"auth_login",{identifier:email,username,password,token:token.current});
36
- setStep(response.step);
37
- if(response.step==="verify"){setPassword("");return;}
58
+ await passwordLogin();return;
38
59
  }
39
60
  const enrollment=await teamRequest<Setup>("auth_setup",{password});
40
61
  setSetup(enrollment);setPassword("");setStep("verify");
@@ -45,12 +66,13 @@ export default function LoginForm({join=false}:{join?:boolean}) {
45
66
  {join && !invitation && !error && <p role="status">正在检查邀请…</p>}
46
67
  {error && <p className="admin-alert" role="alert">{error}</p>}
47
68
  {(!join || invitation) && <form onSubmit={submit}>
48
- {step==="password"&&<>{join&&<p>受邀成员:{invitation?.name}</p>}<label>{join?"绑定邮箱":"用户名或邮箱"}<input required type={join?"email":"text"} autoComplete={join?"email":"username"} autoCapitalize="none" spellCheck={false} value={email} onChange={e=>setEmail(e.target.value)} readOnly={join}/></label>
69
+ {step==="password"&&<>{join&&<p>受邀成员:{invitation?.name}</p>}<label>{join?"账号邮箱":"用户名或邮箱"}<input required type={join?"email":"text"} autoComplete={join?"email":"username"} autoCapitalize="none" spellCheck={false} value={email} onChange={e=>setEmail(e.target.value)} readOnly={join&&!!invitation?.email}/>{join&&<span className="admin-help">{invitation?.email?"邀请指定的账号邮箱。":"请填写你自己的邮箱,用于账号资料,也可以用于登录。"}</span>}</label>
49
70
  {join&&<label>用户名<input required autoComplete="username" autoCapitalize="none" spellCheck={false} pattern={usernamePattern} minLength={3} maxLength={30} value={username} onChange={e=>setUsername(e.target.value)} readOnly={!!invitation?.username}/><span className="admin-help">{invitation?.username?"账号恢复保留原用户名、事项权限和历史记录。":usernameHelp}</span></label>}</>}
50
- {step!=="verify"&&<label>{join?"设置密码(至少 12 个字符)":"密码"}<input required type="password" minLength={12} maxLength={256} autoComplete={join?"new-password":"current-password"} value={password} onChange={e=>setPassword(e.target.value)}/></label>}
71
+ {(step==="password"||step==="setup")&&<label>{join&&!accepted?"设置密码(至少 12 个字符)":"密码"}<input required type="password" minLength={12} maxLength={256} autoComplete={join&&!accepted?"new-password":"current-password"} value={password} onChange={e=>setPassword(e.target.value)}/></label>}
72
+ {step==="email"&&<><h2>验证账号邮箱</h2><p role="status">{emailSent?"验证码已发送至账号邮箱,10 分钟内有效。这是邮箱收到的验证码,身份验证器的动态验证码将在下一步输入。":"账号已保留,但验证邮件暂未发送成功。请稍后重新发送,或联系管理员检查发信配置。"}</p><label>邮箱验证码<input required inputMode="numeric" pattern="[0-9]{6}" maxLength={6} autoComplete="one-time-code" value={emailCode} onChange={e=>setEmailCode(e.target.value)}/></label><button className="admin-button" type="button" onClick={resendEmail} disabled={busy}>重新发送验证码</button><p className="admin-help">每分钟最多发送一次;重新发送后请使用最新验证码。也可以返回登录页稍后继续。</p><a href="/login">返回登录</a></>}
51
73
  {setup && <div className="auth-setup"><h2>绑定身份验证器</h2><p>在你的身份验证器中添加账号,选择“输入设置密钥”,填入以下密钥。请勿分享此密钥。</p><code>{setup.secret}</code><p><a href={setup.otp_uri}>在支持的身份验证器中打开</a></p></div>}
52
74
  {step==="verify"&&<label>六位动态验证码<input required inputMode="numeric" pattern="[0-9]{6}" maxLength={6} autoComplete="one-time-code" value={code} onChange={e=>setCode(e.target.value)}/></label>}
53
- <button className="admin-button admin-primary" type="submit" disabled={busy}>{busy?"正在验证…":step==="verify"?(setup?"完成绑定":"登录"):step==="setup"?"继续绑定":join?"设置密码并继续":"下一步"}</button>
75
+ <button className="admin-button admin-primary" type="submit" disabled={busy}>{busy?"正在验证…":step==="email"?"验证邮箱并继续":step==="verify"?(setup?"完成绑定":"登录"):step==="setup"?"继续绑定":join&&!accepted?"设置密码并继续":"下一步"}</button>
54
76
  </form>}
55
77
  <p className="admin-help">仅限受邀成员。忘记密码或无法取得验证码时,请联系管理员重新签发邀请。设置中断后,可从登录页面继续绑定。</p>
56
78
  {join&&<p><a href="/login">返回登录</a></p>}
@@ -1,4 +1,4 @@
1
- import buildInfo from "./lib/build-info.json";
1
+ import buildInfo from "./lib/software-build";
2
2
  import { productInfo } from "./lib/product-info";
3
3
 
4
4
  export default function SoftwareVersion({ locale, timezone }: { locale: string; timezone: string }) {
@@ -18,7 +18,6 @@ export default function SoftwareVersion({ locale, timezone }: { locale: string;
18
18
  {local && <div><dt>代码版本</dt><dd>{buildInfo.revision || "尚未提交"}</dd></div>}
19
19
  </dl>
20
20
  {local && <p>本地修改以构建标识区分;更新后可在这里核对。</p>}
21
- <p>显示当前运行的软件版本,未检查远程更新。</p>
22
21
  </div>
23
22
  </details>;
24
23
  }
@@ -8,8 +8,9 @@ import type {WorkspaceResponse} from "./lib/workspace-types";
8
8
  type Audit={id:number;at:number;actor_name:string;action:string;target:string;outcome:string};
9
9
  const actionNames:Record<string,string>={"account.invite":"邀请成员","account.activate":"激活账号","account.login":"登录","account.permissions":"调整权限","account.reset":"重置登录",save:"保存资料",file_save:"更新附件",restore:"恢复资料",backup_config:"调整备份",backup_now:"立即备份",file_backup:"导出完整备份"};
10
10
  const roleNames={admin:"管理员",editor:"编辑成员",viewer:"只读成员"};
11
- const statusNames={active:"已启用",setup:"待绑定验证器",invited:"待接受邀请",expired:"邀请已过期",disabled:"已停用"};
12
- export default function TeamManagement({user,projects}:{user:TeamUser;projects:Pick<Project,"id"|"name">[]}) {
11
+ const roleHelp={admin:"管理全部资料、成员权限和系统设置。",editor:"修改获授权事项和附件、补充进展。",viewer:"查看和导出获授权资料,不能修改。"};
12
+ const statusNames={active:"已启用",email:"待验证邮箱",setup:"待绑定验证器",invited:"待接受邀请",expired:"邀请已过期",disabled:"已停用"};
13
+ export default function TeamManagement({user,projects,timezone}:{user:TeamUser;projects:Pick<Project,"id"|"name">[];timezone:string}) {
13
14
  const router=useRouter();
14
15
  const [members,setMembers]=useState<TeamUser[]>([]);
15
16
  const [projectChoices,setProjectChoices]=useState(projects);
@@ -17,32 +18,51 @@ export default function TeamManagement({user,projects}:{user:TeamUser;projects:P
17
18
  const [email,setEmail]=useState("");const [name,setName]=useState("");
18
19
  const [role,setRole]=useState<TeamUser["role"]>("viewer");
19
20
  const [all,setAll]=useState(false);const [ids,setIds]=useState<string[]>([]);
21
+ const [projectSearch,setProjectSearch]=useState("");
20
22
  const [active,setActive]=useState(true);const [busy,setBusy]=useState(false);
21
23
  const [error,setError]=useState("");const [notice,setNotice]=useState("");const [link,setLink]=useState("");
22
24
  const [audit,setAudit]=useState<Audit[]>([]);
25
+ const [linkExpires,setLinkExpires]=useState<number|null>(null);
26
+ const pendingInvitations=members.some(member=>member.status==="invited");
27
+ const expiryText=(seconds:number)=>new Intl.DateTimeFormat("zh-CN",{timeZone:timezone,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",hourCycle:"h23"}).format(new Date(seconds*1000));
28
+ const visibleProjects=projectChoices.filter(project=>project.name.toLocaleLowerCase().includes(projectSearch.trim().toLocaleLowerCase()));
29
+ const fullAccess=role==="admin"||all;
23
30
  const refresh=useCallback(async()=>{const result=await teamRequest<{users:TeamUser[]}>("team_list",{},user.csrf);setMembers(result.users);},[user.csrf]);
24
31
  useEffect(()=>{let cancelled=false;teamRequest<{users:TeamUser[]}>("team_list",{},user.csrf).then(result=>{if(!cancelled)setMembers(result.users);}).catch(err=>{if(!cancelled)setError(err.message);});return()=>{cancelled=true;};},[user.csrf]);
25
32
  useEffect(()=>{const controller=new AbortController();workspaceRequest<WorkspaceResponse>("workspace",{},false,controller.signal).then(result=>{if(result.data)setProjectChoices(result.data.projects);}).catch(err=>{if(!controller.signal.aborted)setError(err.message);});return()=>controller.abort();},[]);
26
- function select(member:TeamUser|null){setSelected(member);setEmail(member?.email||"");setName(member?.name||"");setRole(member?.role||"viewer");setAll(member?.all_projects||false);setIds(member?.project_ids||[]);setActive(member?!!member.active:true);setLink("");setError("");setNotice("");}
27
- function showLink(result:{token:string}){setLink(`${window.location.origin}/join#${result.token}`);setNotice("请将邀请链接交给该成员。链接 48 小时内有效,仅可使用一次。");}
33
+ useEffect(()=>{if(!pendingInvitations)return;const timer=window.setInterval(()=>{void refresh().catch(err=>setError(err.message));},60000);return()=>window.clearInterval(timer);},[pendingInvitations,refresh]);
34
+ function select(member:TeamUser|null){setSelected(member);setEmail(member?.email||"");setName(member?.name||"");setRole(member?.role||"viewer");setAll(member?.all_projects||false);setIds(member?.project_ids||[]);setProjectSearch("");setActive(member?!!member.active:true);setLink("");setLinkExpires(null);setError("");setNotice("");}
35
+ function showLink(result:{token:string;expires:number}){setLinkExpires(result.expires);setLink(`${window.location.origin}/join#${result.token}`);setNotice("请将邀请链接交给该成员。链接 48 小时内有效,仅可使用一次。");}
28
36
  async function save(event:React.FormEvent){event.preventDefault();setBusy(true);setError("");setNotice("");try{
29
37
  const payload={email,name,role,all_projects:all,project_ids:ids,active,id:selected?.id,revision:selected?.revision};
30
38
  if(selected){await teamRequest("team_update",payload,user.csrf);setNotice(active?"权限已更新,该成员需要重新登录。":"权限已更新,账号已停用。如需重新邀请,请生成新的登录邀请。");if(selected.id===user.id){router.replace("/login");router.refresh();return;}setSelected(null);}
31
39
  else showLink(await teamRequest("team_invite",payload,user.csrf));
32
40
  await refresh();
33
41
  }catch(err){setError((err as Error).message);}finally{setBusy(false);}}
34
- async function reset(member:TeamUser){if(!window.confirm(`重新签发 ${member.name} 的登录邀请?现有登录会立即失效,原密码和验证器将需要重新设置。`))return;setBusy(true);setError("");try{showLink(await teamRequest("team_reset",{id:member.id},user.csrf));await refresh();}catch(err){setError((err as Error).message);}finally{setBusy(false);}}
35
- async function copyLink(){try{await navigator.clipboard.writeText(link);setNotice("邀请链接已复制。请通过微信、邮件等渠道单独交给该成员,由本人设置用户名、密码和验证器。");}catch{setNotice("无法自动复制,请选中邀请链接后手动复制。");}}
42
+ async function reset(member:TeamUser){const pending=member.status==="invited"||member.status==="expired";if(!window.confirm(pending?`重新邀请 ${member.name}?旧链接将立即失效,新链接 48 小时内有效,原有身份和权限保留。`:`重新签发 ${member.name} 的登录邀请?现有登录会立即失效,原密码和验证器将需要重新设置。`))return;setBusy(true);setError("");try{showLink(await teamRequest("team_reset",{id:member.id},user.csrf));await refresh();}catch(err){setError((err as Error).message);}finally{setBusy(false);}}
43
+ async function copyLink(){try{await navigator.clipboard.writeText(link);setNotice("邀请链接已复制。请通过微信、邮件等渠道单独交给该成员,由本人填写邮箱、设置用户名、密码和验证器。");}catch{setNotice("无法自动复制,请选中邀请链接后手动复制。");}}
36
44
  async function loadAudit(more=false){try{const result=await teamRequest<{entries:Audit[]}>("team_audit",more&&audit.length?{before:audit.at(-1)!.id}:{},user.csrf);setAudit(old=>more?[...old,...result.entries]:result.entries);}catch(err){setError((err as Error).message);}}
37
- return <section className="team-management"><h2>成员与权限</h2><p>管理员可管理全部资料。编辑成员可修改获授权的事项和附件、补充进展;只读成员可浏览及导出获授权资料。人员总库、事项创建、全局历史和备份由管理员管理。</p><p>移除成员时停用其账号,立即撤销登录访问,保留已有业务记录和操作历史。</p>
45
+ return <section className="team-management"><h2>成员与权限</h2><p>角色决定能做什么,访问范围决定能看到哪些事项。人员总库的维护、事项创建、全局历史和备份由管理员管理。</p><p>移除成员时停用其账号,立即撤销登录访问,保留已有业务记录和操作历史。</p>
38
46
  {error&&<p className="admin-alert" role="alert">{error}</p>}{notice&&<p className="admin-notice" role="status">{notice}</p>}
39
- {link&&<div className="invitation-result"><label>一次性邀请链接<input readOnly value={link} onFocus={e=>e.target.select()}/></label><button type="button" className="admin-button" onClick={()=>void copyLink()}>复制邀请链接</button><p className="admin-help">链接 48 小时内有效,仅可使用一次。系统不会自动发送邮件;无需提供临时密码,请由成员本人完成设置。</p></div>}
40
- <div className="team-grid"><section className="panel"><h2>成员</h2><button className="admin-button" onClick={()=>select(null)}>邀请新成员</button>{members.map(member=><article className="team-member" key={member.id}><strong>{member.name}</strong><span>{member.username?`用户名:${member.username} · `:""}{member.email} · {roleNames[member.role]} · {member.status?statusNames[member.status]:member.active?"已启用":"已停用"}</span><div><button className="admin-button" onClick={()=>select(member)}>调整权限</button>{member.id!==user.id&&<button disabled={busy} className="admin-button" onClick={()=>void reset(member)}>{member.status==="invited"||member.status==="expired"?"重新生成邀请链接":"重新签发登录邀请"}</button>}</div></article>)}</section>
41
- <section className="panel"><h2>{selected?`调整 ${selected.name} 的权限`:"新建成员并邀请"}</h2>{!selected&&<p>填写成员资料并分配权限,再把生成的链接交给本人。对方无需提前注册。</p>}<form className="team-form" onSubmit={save}><fieldset disabled={busy}>
42
- {!selected&&<><label>姓名<input required maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></label><label>邮箱<input required type="email" value={email} onChange={e=>setEmail(e.target.value)}/></label></>}
43
- <label>角色<select value={role} onChange={e=>setRole(e.target.value as TeamUser["role"])}>{Object.entries(roleNames).map(([value,label])=><option key={value} value={value}>{label}</option>)}</select></label>
44
- {role!=="admin"&&<><label className="team-check"><input type="checkbox" checked={all} onChange={e=>setAll(e.target.checked)}/>全部事项(包括未来新增事项)</label>{!all&&<fieldset><legend>可访问的事项</legend>{projectChoices.map(project=><label className="team-check" key={project.id}><input type="checkbox" checked={ids.includes(project.id)} onChange={e=>setIds(e.target.checked?[...ids,project.id]:ids.filter(id=>id!==project.id))}/>{project.name}</label>)}<p>未选择事项时,该成员不能查看业务事项。跨事项附件需要同时拥有全部关联事项的权限。</p></fieldset>}</>}
47
+ {link&&<div className="invitation-result"><label>一次性邀请链接<input readOnly value={link} onFocus={e=>e.target.select()}/></label><button type="button" className="admin-button" onClick={()=>void copyLink()}>复制邀请链接</button><p className="admin-help">{linkExpires&&<>到期时间:<time dateTime={new Date(linkExpires*1000).toISOString()}>{expiryText(linkExpires)}</time>({timezone})。</>}链接 48 小时内有效,仅可使用一次。邀请链接不会自动通过邮件发送;无需提供临时密码,请由成员本人完成设置。</p></div>}
48
+ <div className="team-grid"><section className="panel"><h2>成员</h2><button className="admin-button" disabled={busy} onClick={()=>select(null)}>邀请新成员</button>{members.map(member=><article className="team-member" key={member.id}><strong>{member.name}</strong><span>{member.username?`用户名:${member.username} · `:""}{member.email||"邮箱待本人填写"} · {roleNames[member.role]} · <span className={member.status==="expired"?"team-invitation-expired":undefined}>{member.status?statusNames[member.status]:member.active?"已启用":"已停用"}</span></span>{member.invitation_expires&&<span className="team-member-scope">{member.status==="expired"?"已于":"邀请将于"} <time dateTime={new Date(member.invitation_expires*1000).toISOString()}>{expiryText(member.invitation_expires)}</time> 到期({timezone})</span>}<span className="team-member-scope">{member.role==="admin"||member.all_projects?"全部事项 · 自动包含新增事项":member.project_ids.length?`指定事项 · ${member.project_ids.length} 项 · 新增事项需另行授权`:"尚未分配事项"}</span><div><button className="admin-button" disabled={busy} onClick={()=>select(member)}>调整权限</button>{member.id!==user.id&&<button disabled={busy} className="admin-button" onClick={()=>void reset(member)}>{member.status==="invited"||member.status==="expired"?"重新邀请":"重新签发登录邀请"}</button>}</div></article>)}</section>
49
+ <section className="panel"><h2>{selected?`调整 ${selected.name} 的权限`:"新建成员并邀请"}</h2>{!selected&&<p>填写姓名并分配权限,再把生成的链接交给本人。邮箱可以不填,由对方激活时补充。</p>}<form className="team-form" onSubmit={save}><fieldset disabled={busy}>
50
+ {!selected&&<><label>姓名<input required maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></label><label>邮箱(选填)<input type="email" maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/><span className="admin-help">不知道对方邮箱可以留空;填写后,该邀请将使用指定邮箱。</span></label></>}
51
+ <label>成员角色<select aria-describedby="team-role-help" value={role} onChange={e=>setRole(e.target.value as TeamUser["role"])}>{Object.entries(roleNames).map(([value,label])=><option key={value} value={value}>{label}</option>)}</select><span id="team-role-help" className="admin-help">{roleHelp[role]}</span></label>
52
+ {role!=="admin"?<>
53
+ <fieldset className="team-scope-options"><legend>事项访问范围</legend>
54
+ <label className={`team-scope-option${!all?" is-selected":""}`}><input type="radio" name="matter-scope" checked={!all} onChange={()=>setAll(false)}/><span><strong>指定事项</strong><span>仅限勾选的事项。以后新增事项需另行授权。</span></span></label>
55
+ <label className={`team-scope-option${all?" is-selected":""}`}><input type="radio" name="matter-scope" checked={all} onChange={()=>setAll(true)}/><span><strong>全部事项</strong><span>自动包含当前和以后新增的事项,无需逐项授权。</span></span></label>
56
+ </fieldset>
57
+ {all?<p className="admin-help">同时可查看公司动态及共享的人员、对接方资料。</p>:<fieldset className="team-project-picker"><legend>选择可访问的事项</legend>
58
+ <label className="team-search-label">搜索事项<input type="search" placeholder="按事项名称搜索" value={projectSearch} onChange={e=>setProjectSearch(e.target.value)}/></label>
59
+ <div className="team-selection-toolbar"><span role="status">已选 {ids.length} 项</span><div><button type="button" className="admin-button" disabled={!visibleProjects.length} onClick={()=>setIds(current=>[...new Set([...current,...visibleProjects.map(project=>project.id)])])}>{projectSearch.trim()?"选中搜索结果":"选中当前全部"}</button><button type="button" className="admin-button" disabled={!ids.length} onClick={()=>setIds([])}>清空选择</button></div></div>
60
+ <div className="team-project-list">{visibleProjects.map(project=><label className="team-check" key={project.id}><input type="checkbox" checked={ids.includes(project.id)} onChange={e=>setIds(e.target.checked?[...ids,project.id]:ids.filter(id=>id!==project.id))}/>{project.name}</label>)}{!visibleProjects.length&&<p className="admin-help">{projectChoices.length?"没有找到匹配的事项。":"暂无可选事项;新增事项后可在此分配。"}</p>}</div>
61
+ <p className="admin-help">“选中当前全部”只勾选现有事项,以后新增的事项仍需另行授权。跨事项附件需要具备其全部关联事项的权限。</p>
62
+ </fieldset>}
63
+ </>:<p className="admin-notice">管理员始终管理全部事项,自动包含以后新增的事项。</p>}
45
64
  {selected&&<label className="team-check"><input type="checkbox" checked={active} onChange={e=>setActive(e.target.checked)}/>启用账号</label>}
65
+ <section className="team-permission-summary" aria-labelledby="team-permission-summary-title"><h3 id="team-permission-summary-title">权限预览</h3>{selected&&!active?<p>保存后,账号将处于停用状态,不能访问工作台;业务记录和历史仍会保留。</p>:<><dl><div><dt>可操作</dt><dd>{roleHelp[role]}</dd></div><div><dt>可访问</dt><dd>{fullAccess?"全部事项":ids.length?`已选的 ${ids.length} 个事项`:"尚未分配事项"}</dd></div><div><dt>以后新增事项</dt><dd>{fullAccess?"自动获得访问权限":"需要管理员另行授权"}</dd></div></dl>{!fullAccess&&!ids.length&&<p className="admin-help">可以完成账号激活,但暂时不能查看业务事项。</p>}</>}</section>
46
66
  <button className="admin-button admin-primary" type="submit">{busy?"正在保存…":selected?"保存权限":"生成邀请链接"}</button>
47
67
  </fieldset></form></section></div>
48
68
  <section className="panel"><h2>操作记录</h2><button className="admin-button" onClick={()=>void loadAudit()}>查看 / 刷新</button><p>操作账号记录实际录入者,与业务记录中的会议参与人分别维护。“开始”后没有完成记录的操作,需要检查执行结果。</p><div className="team-audit">{audit.map(item=><p key={item.id}><time>{new Date(item.at*1000).toLocaleString("zh-CN")}</time> · {item.actor_name} · {actionNames[item.action]||"账号操作"} · {{started:"开始",success:"完成",failed:"失败"}[item.outcome]||item.outcome} {item.target}</p>)}</div>{audit.length>=100&&<button className="admin-button" onClick={()=>void loadAudit(true)}>更早记录</button>}</section>
@@ -6,7 +6,7 @@ import { normalizeUsername } from "../../lib/username-policy";
6
6
 
7
7
  export const runtime="nodejs";
8
8
  export const dynamic="force-dynamic";
9
- const publicActions=["auth_login","auth_invite_info","auth_accept","auth_setup","auth_verify"];
9
+ const publicActions=["auth_login","auth_invite_info","auth_accept","auth_setup","auth_verify","auth_email_verify"];
10
10
 
11
11
  function updatedCookies(request:Request,response:Response) {
12
12
  const jar=new Map((request.headers.get("cookie")||"").split(";").filter(Boolean).map(value=>{
@@ -20,23 +20,30 @@ function updatedCookies(request:Request,response:Response) {
20
20
  }
21
21
  async function authentication(input:Record<string,unknown>,request:Request) {
22
22
  const provider=await getAuthProvider();
23
- let route="",body:Record<string,unknown>={};
23
+ let route="",body:Record<string,unknown>={},loginIdentifier="";
24
24
  if(input.action==="auth_accept") {
25
- const created=await provider.accept(input.token,input.password,input.username);
25
+ const created=await provider.accept(input.token,input.password,input.username,input.email);
26
+ loginIdentifier=created.email;
26
27
  route="/sign-in/email";body={email:created.email,password:input.password,rememberMe:true};
27
28
  } else if(input.action==="auth_login") {
28
29
  const identifier=String(input.identifier ?? input.email ?? "").trim().toLowerCase();
30
+ loginIdentifier=identifier;
29
31
  const email=identifier.includes("@");
30
32
  route=email?"/sign-in/email":"/sign-in/username";
31
33
  body={...(email?{email:identifier}:{username:normalizeUsername(identifier)}),password:input.password,rememberMe:true};
32
34
  } else if(input.action==="auth_username") {
33
35
  return Response.json({ok:true,result:await provider.setUsername(request,input.username)},{headers:privateHeaders});
36
+ } else if(input.action==="auth_email_verify") {
37
+ if(!provider.emailRequired)return denied();
38
+ const identifier=String(input.identifier||"").trim().toLowerCase();
39
+ const identity=provider.db.prepare("SELECT b.email FROM ba_user b JOIN users u ON u.id=b.memberId WHERE (b.email=? OR u.username=?) AND u.active=1 AND b.credentialRevision=u.credential_revision").get(identifier,identifier) as {email:string}|undefined;
40
+ route="/email-otp/verify-email";body={email:identity?.email||"unknown@example.invalid",otp:input.code};
34
41
  } else if(input.action==="auth_verify") {
35
42
  route="/two-factor/verify-totp";body={code:input.code,trustDevice:false};
36
43
  } else if(input.action==="auth_setup") {
37
44
  const session=await provider.providerSession(requestSession(request));
38
45
  const member=session && provider.member(session.user.memberId);
39
- if(!session || !member?.active || member.credential_revision!==session.user.credentialRevision || member.revision!==session.session.permissionRevision || session.user.twoFactorEnabled) return denied("请重新登录后设置验证器。",401);
46
+ if(!session || !member?.active || member.credential_revision!==session.user.credentialRevision || member.revision!==session.session.permissionRevision || session.user.twoFactorEnabled || (provider.emailRequired && !session.user.emailVerified)) return denied("请重新登录后设置验证器。",401);
40
47
  const pending=provider.db.prepare("SELECT 1 FROM ba_two_factor WHERE userId=?").get(session.user.id);
41
48
  route=pending?"/two-factor/get-totp-uri":"/two-factor/enable";
42
49
  body={password:input.password,method:"totp"};
@@ -44,11 +51,23 @@ async function authentication(input:Record<string,unknown>,request:Request) {
44
51
  else return denied();
45
52
  const response=await provider.handle(route,body,request);
46
53
  const data=await response.json();
54
+ if(["auth_login","auth_accept"].includes(String(input.action)) && provider.emailRequired && data.code==="EMAIL_NOT_VERIFIED") {
55
+ // The official password endpoint has authenticated the password before returning this code.
56
+ // Email sending is not exposed as an anonymous endpoint.
57
+ const identity=provider.db.prepare("SELECT b.email FROM ba_user b JOIN users u ON u.id=b.memberId WHERE (b.email=? OR u.username=?) AND u.active=1 AND b.credentialRevision=u.credential_revision").get(loginIdentifier,loginIdentifier) as {email:string}|undefined;
58
+ if(!identity)return denied("账号信息已失效,请重新登录。",401);
59
+ const delivery=await provider.handle("/email-otp/send-verification-otp",{email:identity.email,type:"email-verification"},request);
60
+ if(delivery.status===429 && input.action!=="auth_accept")return Response.json({ok:false,error:"发送次数过多,请一分钟后重试。"},{status:429,headers:privateHeaders});
61
+ return Response.json({ok:true,result:{step:"email",email_sent:delivery.ok}},{headers:privateHeaders});
62
+ }
47
63
  if(!response.ok) return Response.json({ok:false,code:response.status===429?"rate_limited":"unauthenticated",error:response.status===429?"尝试次数过多,请稍后重试。":"账号信息或验证码不正确;邀请失效时请联系管理员。"},{status:response.status,headers:privateHeaders});
48
64
  const headers=new Headers(privateHeaders);
49
65
  for(const value of response.headers.getSetCookie()) headers.append("Set-Cookie",value);
50
66
  let result:Record<string,unknown>;
51
- if(input.action==="auth_verify") {
67
+ if(input.action==="auth_email_verify") {
68
+ // Email ownership alone never signs a user in or returns provider identity/session data.
69
+ return Response.json({ok:true,result:{email_verified:true}},{headers:privateHeaders});
70
+ } else if(input.action==="auth_verify") {
52
71
  const cookie=updatedCookies(request,response);
53
72
  const verified=await invokeWorkspace({action:"auth_session"},cookie);
54
73
  if(!verified.ok)return serviceResponse(verified);
@@ -1,7 +1,7 @@
1
1
  import { boundedBody, privateHeaders, serverMode, trustedRequest } from "../../lib/server-auth";
2
2
  import { invokeRemote } from "../../lib/workspace-server";
3
3
  import { remoteTools } from "../../lib/remote-tools";
4
- import buildInfo from "../../lib/build-info.json";
4
+ import buildInfo from "../../lib/software-build";
5
5
 
6
6
  export const runtime = "nodejs";
7
7
  export const dynamic = "force-dynamic";
@@ -3046,7 +3046,7 @@ select,
3046
3046
  .auth-card{width:min(100%,460px);padding:32px;border:1px solid #dfe5ed;border-radius:16px;background:white;box-shadow:0 12px 40px #152a4610}
3047
3047
  .auth-card h1{margin:0 0 12px}.auth-card form,.team-form fieldset{display:grid;gap:18px}
3048
3048
  .auth-card label,.team-form label,.team-management>label{display:grid;gap:8px;font-weight:600}
3049
- .auth-card input,.team-form input:not([type=checkbox]),.team-form select,.team-management>label input{width:100%;padding:10px 12px;border:1px solid #cbd5e1;border-radius:8px;background:white;color:#152a46}
3049
+ .auth-card input,.team-form input:not([type=checkbox]):not([type=radio]),.team-form select,.team-management>label input{width:100%;padding:10px 12px;border:1px solid #cbd5e1;border-radius:8px;background:white;color:#152a46}
3050
3050
  .auth-setup{padding:14px;background:#f1f5f9;border-radius:8px}.auth-setup h2{font-size:16px}.auth-setup code{overflow-wrap:anywhere;user-select:all}
3051
3051
  .team-bar{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:10px max(20px,calc((100vw - 1440px)/2));border-bottom:1px solid #e2e8f0;background:white}.team-bar>div{display:flex;gap:8px}
3052
3052
  .team-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin:24px 0}.team-management .panel{padding:24px}.team-member{display:grid;gap:8px;padding:16px 0;border-bottom:1px solid #e2e8f0}.team-member>div{display:flex;gap:8px;flex-wrap:wrap}.team-form fieldset{border:0;padding:0}.team-form label.team-check{display:flex;align-items:center;font-weight:400}.team-form input[type=checkbox]{width:18px;height:18px}.team-audit{overflow-wrap:anywhere;font-size:13px}
@@ -3057,6 +3057,38 @@ select,
3057
3057
  .auth-card h1 .site-brand-logo { height: 44px; max-width: 176px; }
3058
3058
  .auth-card label .admin-help { display: block; font-weight: 400; }
3059
3059
  .account-settings { max-width: 800px; }
3060
+ .team-grid > .panel { min-width: 0; align-self: start; }
3061
+ .team-invitation-expired { color: #b45309; font-weight: 600; }
3062
+ .team-member-scope { font-size: 13px; color: #475569; }
3063
+ .team-form .admin-help { font-weight: 400; margin: 0; }
3064
+ .team-form legend { padding: 0; margin-bottom: 10px; font-weight: 600; }
3065
+ .team-form .team-scope-options { gap: 10px; }
3066
+ .team-form label.team-scope-option { display: flex; align-items: flex-start; gap: 10px; padding: 14px; border: 1px solid #cbd5e1; border-radius: 10px; cursor: pointer; }
3067
+ .team-form label.team-scope-option.is-selected { background: #eff6ff; border-color: #2563eb; }
3068
+ .team-scope-option input[type=radio] { flex: 0 0 auto; width: 18px; height: 18px; margin-top: 2px; accent-color: #2563eb; }
3069
+ .team-scope-option > span { display: grid; gap: 4px; }
3070
+ .team-scope-option > span > span { font-size: 13px; line-height: 1.5; color: #475569; font-weight: 400; }
3071
+ .team-scope-option:focus-within { outline: 2px solid #2563eb; outline-offset: 2px; }
3072
+ .team-form .team-project-picker { gap: 12px; }
3073
+ .team-selection-toolbar, .team-selection-toolbar > div { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
3074
+ .team-selection-toolbar { justify-content: space-between; font-size: 13px; }
3075
+ .team-project-list { display: grid; gap: 10px; max-height: 260px; overflow-y: auto; padding: 12px; border: 1px solid #e2e8f0; border-radius: 8px; }
3076
+ .team-project-list label { overflow-wrap: anywhere; }
3077
+ .team-project-list input[type=checkbox] { flex: 0 0 auto; }
3078
+ .team-permission-summary { padding: 16px; border: 1px solid #dbe4ee; border-radius: 10px; background: #f8fafc; }
3079
+ .team-permission-summary h3 { margin: 0 0 12px; font-size: 15px; }
3080
+ .team-permission-summary dl { display: grid; gap: 10px; margin: 0; }
3081
+ .team-permission-summary dl > div { display: grid; grid-template-columns: 7em minmax(0, 1fr); gap: 12px; font-size: 13px; }
3082
+ .team-permission-summary dt { color: #64748b; }
3083
+ .team-permission-summary dd { margin: 0; }
3084
+ .team-permission-summary .admin-help { margin-top: 12px; }
3085
+ .account-settings .team-form input[readonly] {
3086
+ background: #f1f5f9;
3087
+ color: #475569;
3088
+ border-color: #cbd5e1;
3089
+ cursor: default;
3090
+ caret-color: transparent;
3091
+ }
3060
3092
  .invitation-result { padding: 16px; margin: 16px 0; border: 1px solid var(--line); border-radius: 12px; }
3061
3093
  .invitation-result label { display: grid; gap: 8px; margin-bottom: 12px; }
3062
3094
  .invitation-result input { width: 100%; padding: 10px; }