@airalogy/airalogy-bd 1.1.2 → 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
@@ -4,6 +4,14 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.2.0] - 2026-10-26
8
+
9
+ - 邀请成员时邮箱改为选填,由成员激活时自行补充;保留内部身份、原有权限及历史,拒绝占用其他成员的邮箱。
10
+ - 增加可选邮箱归属验证,默认关闭;通过 SMTP 发送短期一次性验证码,保留密码与验证器登录,提供重试、限速及服务端授权校验。
11
+ - 成员管理区分角色和事项访问范围,明确新增事项的授权规则,支持搜索、批量选择和保存前权限预览。
12
+ - 显示邀请到期时间和过期状态,支持重新邀请并废止旧链接;修复未填写邮箱的待激活成员重新邀请时的异常。
13
+ - 已设置的用户名显示为锁定状态;精简软件版本说明。
14
+
7
15
  ## [1.1.2] - 2026-10-26
8
16
 
9
17
  - 源码开发直接使用 Next.js Fast Refresh,保留开发编译缓存;页面、样式和接口代码修改无需重启,安装工作区和生产构建继续使用独立运行目录。
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
 
@@ -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
@@ -145,6 +145,9 @@ function runNext(command, contentDirectory, forwardedArguments) {
145
145
  if (process.env.BD_MODE && !["local","server"].includes(process.env.BD_MODE)) fail("BD_MODE 只支持 local 或 server。");
146
146
  if (serverMode && command !== "build") {
147
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。");
148
151
  if (!process.env.BETTER_AUTH_SECRET || process.env.BETTER_AUTH_SECRET.length < 32) fail("请配置至少 32 字符的 BETTER_AUTH_SECRET。");
149
152
  if (command !== "start") fail("服务器模式请使用 bd build 构建后运行 bd start。");
150
153
  if (!existsSync(path.join(path.dirname(contentDirectory), ".bd-auth", "accounts.sqlite3")) || !workspaceRequest(contentDirectory,{action:"account_status"}).initialized) fail("请先使用 bd account-init 初始化管理员。");
@@ -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>}
@@ -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);
@@ -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; }
@@ -3,13 +3,15 @@ import { DatabaseSync } from "node:sqlite";
3
3
  import { chmodSync, lstatSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { createHash, randomBytes } from "node:crypto";
6
+ import { AsyncLocalStorage } from "node:async_hooks";
6
7
  import { betterAuth } from "better-auth";
7
- import { admin, twoFactor, username } from "better-auth/plugins";
8
+ import { admin, twoFactor, username, emailOTP } from "better-auth/plugins";
8
9
  import { APIError } from "better-auth/api";
9
10
  import { getMigrations } from "better-auth/db/migration";
10
11
  import { normalizeUsername, validUsername, usernameHelp } from "./username-policy";
12
+ import { requireEmailVerification, verificationMailer } from "./email-delivery";
11
13
 
12
- type Member = {id:string;email:string;name:string;username:string|null;active:number;revision:string;credential_revision:string};
14
+ type Member = {id:string;email:string|null;name:string;username:string|null;active:number;revision:string;credential_revision:string};
13
15
  type Invitation = Member & {digest:string;expires:number};
14
16
  const rejected = () => new APIError("UNAUTHORIZED", {message:"账号信息已失效,请重新登录或联系管理员。"});
15
17
  const digest = (token:string) => createHash("sha256").update(token).digest("hex");
@@ -18,6 +20,9 @@ async function initialize() {
18
20
  if(process.env.BD_MODE !== "server") throw new Error("认证组件仅用于服务器模式。");
19
21
  const secret=process.env.BETTER_AUTH_SECRET;
20
22
  if(!secret || secret.length<32) throw new Error("请配置至少 32 字符的 BETTER_AUTH_SECRET。");
23
+ const emailRequired=requireEmailVerification();
24
+ // Validate SMTP configuration before opening the account database.
25
+ const sendMail=emailRequired?verificationMailer():null;
21
26
  const origin=new URL(process.env.BD_PUBLIC_URL || "").origin;
22
27
  const directory=path.join(path.dirname(process.env.BD_CONTENT_DIR || ""),".bd-auth");
23
28
  const file=path.join(directory,"accounts.sqlite3");
@@ -26,10 +31,15 @@ async function initialize() {
26
31
  chmodSync(directory,0o700);chmodSync(file,0o600);
27
32
  db.exec("PRAGMA busy_timeout=30000; PRAGMA foreign_keys=ON;");
28
33
  const member=(id:string) => db.prepare("SELECT * FROM users WHERE id=?").get(id) as Member|undefined;
34
+ const mailAttempt=new AsyncLocalStorage<{failed:boolean}>();
35
+ const emailMember=(email:string) => db.prepare("SELECT u.* FROM users u JOIN ba_user b ON b.memberId=u.id WHERE b.email=? AND u.email=b.email AND u.active=1 AND b.credentialRevision=u.credential_revision").get(email) as Member|undefined;
29
36
  const auth=betterAuth({
30
37
  appName:"Airalogy BD", baseURL:origin, basePath:"/api/identity", secret, telemetry:{enabled:false},
31
38
  database:db, trustedOrigins:[origin],
32
- emailAndPassword:{enabled:true,disableSignUp:true,minPasswordLength:12,maxPasswordLength:256},
39
+ emailAndPassword:{enabled:true,disableSignUp:true,minPasswordLength:12,maxPasswordLength:256,requireEmailVerification:emailRequired},
40
+ emailVerification:{sendOnSignUp:false,sendOnSignIn:false,autoSignInAfterVerification:false,
41
+ beforeEmailVerification:async(user)=>{if(!emailRequired || !emailMember(user.email))throw rejected();},
42
+ afterEmailVerification:async(user)=>{const current=emailMember(user.email);if(current)audit(current.id,"account.email_verified");}},
33
43
  user:{modelName:"ba_user",additionalFields:{
34
44
  memberId:{type:"string",required:true,input:false},
35
45
  credentialRevision:{type:"string",required:true,input:false},
@@ -48,11 +58,40 @@ async function initialize() {
48
58
  "/sign-in/username":{window:900,max:20},
49
59
  "/two-factor/*":{window:60,max:10},
50
60
  }},
51
- plugins:[admin(),twoFactor({issuer:"Airalogy BD",twoFactorTable:"ba_two_factor"}),username({displayUsername:false,immutableUsername:true,usernameValidator:validUsername})],
61
+ plugins:[admin(),twoFactor({issuer:"Airalogy BD",twoFactorTable:"ba_two_factor"}),username({displayUsername:false,immutableUsername:true,usernameValidator:validUsername}),
62
+ emailOTP({disableSignUp:true,storeOTP:"hashed",expiresIn:600,allowedAttempts:5,
63
+ generateOTP:({email,type})=>{
64
+ if(!emailRequired || type!=="email-verification" || !emailMember(email))throw rejected();
65
+ // Per-account cooldown also covers username/email aliases and different IP addresses.
66
+ const identifier=`bd-email-send-${email}`;
67
+ db.exec("BEGIN IMMEDIATE");
68
+ try {
69
+ const last=db.prepare("SELECT 1 FROM ba_verification WHERE identifier=? AND expiresAt>?").get(identifier,new Date().toISOString());
70
+ if(last)throw new APIError("TOO_MANY_REQUESTS",{message:"请稍后再发送验证码。"});
71
+ db.prepare("DELETE FROM ba_verification WHERE identifier=?").run(identifier);
72
+ const now=new Date().toISOString();
73
+ db.prepare("INSERT INTO ba_verification(id,identifier,value,expiresAt,createdAt,updatedAt) VALUES(?,?,?,?,?,?)").run(randomBytes(16).toString("hex"),identifier,"cooldown",new Date(Date.now()+60000).toISOString(),now,now);
74
+ db.exec("COMMIT");
75
+ } catch(error){db.exec("ROLLBACK");throw error;}
76
+ // Better Auth generates, hashes, expires and atomically consumes the code.
77
+ return undefined;
78
+ },
79
+ sendVerificationOTP:async({email,otp,type})=>{
80
+ if(!sendMail || type!=="email-verification")throw rejected();
81
+ try {await sendMail(email,otp);}
82
+ catch {
83
+ // Better Auth intentionally absorbs background mail errors. Track the outcome
84
+ // within this request so the UI never claims that a failed delivery succeeded.
85
+ const attempt=mailAttempt.getStore();
86
+ if(attempt)attempt.failed=true;
87
+ else throw new Error("验证邮件发送失败。");
88
+ }
89
+ },
90
+ })],
52
91
  databaseHooks:{session:{create:{before:async(session,ctx)=>{
53
- const identity=db.prepare("SELECT memberId,credentialRevision FROM ba_user WHERE id=?").get(session.userId) as {memberId:string;credentialRevision:string}|undefined;
92
+ const identity=db.prepare("SELECT memberId,credentialRevision,emailVerified FROM ba_user WHERE id=?").get(session.userId) as {memberId:string;credentialRevision:string;emailVerified:number}|undefined;
54
93
  const current=identity && member(identity.memberId);
55
- if(!current?.active || current.credential_revision!==identity?.credentialRevision) throw rejected();
94
+ if(!current?.active || current.credential_revision!==identity?.credentialRevision || (emailRequired && !identity.emailVerified)) throw rejected();
56
95
  return {data:{...session,permissionRevision:current.revision,csrf:randomBytes(32).toString("hex"),
57
96
  // Only Better Auth's successful TOTP endpoint may produce a business session.
58
97
  mfaVerified:ctx?.path==="/two-factor/verify-totp"}};
@@ -64,7 +103,7 @@ async function initialize() {
64
103
  function invitation(token:unknown) {
65
104
  if(typeof token!=="string" || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) throw rejected();
66
105
  const row=db.prepare("SELECT u.*,i.digest,i.expires FROM invitations i JOIN users u ON u.id=i.user_id WHERE digest=? AND expires>?").get(digest(token),Math.floor(Date.now()/1000)) as Invitation|undefined;
67
- if(!row) throw rejected();
106
+ if(!row || row.active) throw rejected();
68
107
  return row;
69
108
  }
70
109
  // Serialize activation, including retries, within the single application instance.
@@ -77,26 +116,40 @@ async function initialize() {
77
116
  if(db.prepare("SELECT 1 FROM users WHERE username=? COLLATE NOCASE AND id!=?").get(name,current.id)) throw new APIError("CONFLICT",{message:"该用户名已被使用,请换一个。"});
78
117
  return name;
79
118
  }
80
- function accept(token:unknown,password:unknown,value:unknown) {
119
+ function chosenEmail(value:unknown,current:Member) {
120
+ const email=typeof value==="string"?value.trim().toLowerCase():current.email;
121
+ if(!email || email.length>254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))throw new APIError("BAD_REQUEST",{message:"请填写有效的账号邮箱。"});
122
+ if(current.email && current.email!==email)throw new APIError("BAD_REQUEST",{message:"请使用邀请指定的账号邮箱。"});
123
+ if(db.prepare("SELECT 1 FROM users WHERE email=? COLLATE NOCASE AND id!=?").get(email,current.id))throw new APIError("CONFLICT",{message:"该邮箱不能用于此邀请,请使用自己的其他邮箱或联系管理员。"});
124
+ const identity=db.prepare("SELECT memberId FROM ba_user WHERE email=? COLLATE NOCASE").get(email) as {memberId:string}|undefined;
125
+ if(identity && identity.memberId!==current.id)throw new APIError("CONFLICT",{message:"该邮箱不能用于此邀请,请使用自己的其他邮箱或联系管理员。"});
126
+ return email;
127
+ }
128
+ function accept(token:unknown,password:unknown,value:unknown,emailInput?:unknown) {
81
129
  const next=activation.catch(()=>{}).then(async()=>{
82
130
  if(typeof password!=="string" || password.length<12 || password.length>256) throw new APIError("BAD_REQUEST",{message:"密码须为 12–256 个字符。"});
83
131
  const invited=invitation(token);
84
132
  const chosen=chosenUsername(value ?? invited.username,invited);
133
+ const email=chosenEmail(emailInput,invited);
85
134
  const context=await auth.$context;
86
- const previous=await context.internalAdapter.findUserByEmail(invited.email);
87
- if(previous) await context.internalAdapter.deleteUser(previous.user.id);
135
+ db.prepare("DELETE FROM ba_verification WHERE identifier IN (?,?)").run(`email-verification-otp-${email}`,`bd-email-send-${email}`);
136
+ // Recovery and interrupted activation may replace only this invited member's
137
+ // identity. A user-supplied email must never select another member for deletion.
138
+ const previous=db.prepare("SELECT id FROM ba_user WHERE memberId=?").all(invited.id) as {id:string}[];
139
+ for(const identity of previous)await context.internalAdapter.deleteUser(identity.id);
88
140
  // This server-only call is never mounted as a public admin endpoint.
89
- const result=await auth.api.createUser({body:{email:invited.email,name:invited.name,password,
141
+ const result=await auth.api.createUser({body:{email,name:invited.name,password,
90
142
  data:{memberId:invited.id,credentialRevision:invited.credential_revision,username:chosen}}});
91
143
  db.exec("BEGIN IMMEDIATE");
92
144
  try {
93
145
  const fresh=invitation(token);
94
146
  if(fresh.credential_revision!==invited.credential_revision) throw rejected();
147
+ chosenEmail(email,fresh);
95
148
  db.prepare("DELETE FROM invitations WHERE digest=?").run(fresh.digest);
96
- db.prepare("UPDATE users SET active=1,username=? WHERE id=?").run(chosen,invited.id);
149
+ db.prepare("UPDATE users SET active=1,username=?,email=? WHERE id=?").run(chosen,email,invited.id);
97
150
  db.exec("COMMIT");
98
151
  } catch(error) {db.exec("ROLLBACK");await context.internalAdapter.deleteUser(result.user.id);throw error;}
99
- return {email:invited.email};
152
+ return {email};
100
153
  });
101
154
  activation=next;
102
155
  return next;
@@ -124,13 +177,17 @@ async function initialize() {
124
177
  // Do not forward attacker-selected identity, permission or internal-invitation headers.
125
178
  const headers=new Headers({"Content-Type":"application/json",Origin:origin,Cookie:request.headers.get("cookie")||"",
126
179
  "x-real-ip":request.headers.get("x-real-ip")||"127.0.0.1","User-Agent":request.headers.get("user-agent")||""});
127
- return auth.handler(new Request(origin+"/api/identity"+route,{method:"POST",headers,body:JSON.stringify(body)}));
180
+ return mailAttempt.run({failed:false},async()=>{
181
+ const response=await auth.handler(new Request(origin+"/api/identity"+route,{method:"POST",headers,body:JSON.stringify(body)}));
182
+ if(mailAttempt.getStore()?.failed)return Response.json({code:"BD_EMAIL_SEND_FAILED"},{status:503});
183
+ return response;
184
+ });
128
185
  }
129
186
  function audit(id:string,action:string) {
130
187
  const current=member(id);
131
188
  if(current) db.prepare("INSERT INTO audit(at,actor_id,actor_name,action,target,outcome) VALUES(?,?,?,?,?,?)").run(Math.floor(Date.now()/1000),id,current.name,action,id,"success");
132
189
  }
133
- return {auth,db,member,accept,setUsername,providerSession,handle,audit};
190
+ return {auth,db,member,accept,setUsername,providerSession,handle,audit,emailRequired};
134
191
  }
135
192
  // Next may bundle SSR and route handlers separately; share the connection/migration promise.
136
193
  const state=globalThis as typeof globalThis & {bdAuthProvider?:ReturnType<typeof initialize>};
@@ -141,5 +198,5 @@ export function getAuthProvider() {
141
198
  export async function verifiedSessionToken(cookie:string) {
142
199
  if(!cookie) return "";
143
200
  const session=await (await getAuthProvider()).providerSession(cookie);
144
- return session?.user.twoFactorEnabled && session.session.mfaVerified ? session.session.token : "";
201
+ return session?.user.twoFactorEnabled && session.session.mfaVerified && (!requireEmailVerification() || session.user.emailVerified) ? session.session.token : "";
145
202
  }
@@ -0,0 +1,41 @@
1
+ import nodemailer from "nodemailer";
2
+
3
+ export function requireEmailVerification(env: NodeJS.ProcessEnv = process.env) {
4
+ const value = env.BD_REQUIRE_EMAIL_VERIFICATION || "0";
5
+ if (!["0", "1"].includes(value)) throw new Error("BD_REQUIRE_EMAIL_VERIFICATION 只支持 0 或 1。");
6
+ return env.BD_MODE === "server" && value === "1";
7
+ }
8
+
9
+ export function smtpConfiguration(env: NodeJS.ProcessEnv = process.env) {
10
+ const host = (env.BD_SMTP_HOST || "").trim();
11
+ const port = Number(env.BD_SMTP_PORT || "465");
12
+ const user = env.BD_SMTP_USER || "";
13
+ const pass = env.BD_SMTP_PASSWORD || "";
14
+ const from = (env.BD_MAIL_FROM || "").trim();
15
+ if (!/^[a-z0-9.-]+$/i.test(host) || !Number.isInteger(port) || port < 1 || port > 65535 || !user || !pass ||
16
+ from.length > 254 || !/^[^\s<>@,;]+@[^\s<>@,;]+\.[^\s<>@,;]+$/.test(from)) {
17
+ throw new Error("开启邮箱验证前,请配置 BD_SMTP_HOST、BD_SMTP_PORT、BD_SMTP_USER、BD_SMTP_PASSWORD 和 BD_MAIL_FROM。");
18
+ }
19
+ return {from, options: {
20
+ host, port, secure: port === 465, requireTLS: port !== 465,
21
+ auth: {user, pass}, tls: {minVersion: "TLSv1.2" as const, rejectUnauthorized: true},
22
+ connectionTimeout: 10000, greetingTimeout: 10000, socketTimeout: 15000,
23
+ logger: false, debug: false, disableFileAccess: true, disableUrlAccess: true,
24
+ }};
25
+ }
26
+
27
+ export function verificationMailer(env: NodeJS.ProcessEnv = process.env) {
28
+ const {from, options} = smtpConfiguration(env);
29
+ const transport = nodemailer.createTransport(options);
30
+ return async (email: string, code: string) => {
31
+ try {
32
+ const sent = await transport.sendMail({from, to: email, subject: "验证你的 BD 工作台邮箱",
33
+ text: `你的邮箱验证码是:${code}\n\n验证码 10 分钟内有效,仅用于验证邮箱归属。请在发起验证的 BD 工作台页面输入,不要转发或分享。\n如果不是你本人操作,请忽略此邮件。`,
34
+ });
35
+ if (!sent.accepted?.length) throw new Error("not accepted");
36
+ } catch {
37
+ // Never propagate SMTP credentials, addresses, server responses or OTPs to logs/browser.
38
+ throw new Error("验证邮件发送失败,请稍后重试或联系管理员检查发信配置。");
39
+ }
40
+ };
41
+ }
@@ -3,7 +3,7 @@ import { timingSafeEqual } from "node:crypto";
3
3
  import { cookies, headers as pageHeaders } from "next/headers";
4
4
  import { invokeWorkspace, isLocalRequest } from "./workspace-server";
5
5
 
6
- export type TeamUser = {id:string;name:string;email:string;username?:string|null;status?:"active"|"setup"|"invited"|"expired"|"disabled";invitation_expires?:number|null;role:"admin"|"editor"|"viewer";all_projects:boolean;project_ids:string[];active:boolean;revision:string;csrf:string;expires:number};
6
+ export type TeamUser = {id:string;name:string;email:string|null;email_verified?:boolean;username?:string|null;status?:"active"|"email"|"setup"|"invited"|"expired"|"disabled";invitation_expires?:number|null;role:"admin"|"editor"|"viewer";all_projects:boolean;project_ids:string[];active:boolean;revision:string;csrf:string;expires:number};
7
7
  export const serverMode = () => {
8
8
  if(process.env.BD_MODE && !["local","server"].includes(process.env.BD_MODE)) throw new Error("工作台运行模式配置无效。");
9
9
  return process.env.BD_MODE === "server";
@@ -4,7 +4,7 @@ const nextConfig: NextConfig = {
4
4
  poweredByHeader: false,
5
5
  // Development must not generate instruction files inside the source checkout.
6
6
  agentRules: false,
7
- serverExternalPackages: ["better-auth"],
7
+ serverExternalPackages: ["better-auth", "nodemailer"],
8
8
  async headers() {
9
9
  return [{source:"/:path*",headers:[
10
10
  {key:"X-Content-Type-Options",value:"nosniff"},
@@ -2,21 +2,39 @@
2
2
 
3
3
  ## 管理员邀请成员
4
4
 
5
- 进入“管理中心 → 成员与权限 → 邀请新成员”,填写姓名、邮箱及事项权限,点击“生成邀请链接”。系统创建待激活成员并显示一次性链接,不开放自行注册。
5
+ 进入“管理中心 → 成员与权限 → 邀请新成员”,填写姓名、选择角色及事项权限,点击“生成邀请链接”。**邮箱选填**:不知道对方邮箱时可以留空,由受邀者激活时补充。系统创建待激活成员并显示一次性链接,不开放自行注册。
6
6
 
7
- 点击“复制邀请链接”,通过可信渠道单独交给本人。链接有效期为 48 小时,只能使用一次;系统不会自动发送邮件,也不生成需要转交的临时密码。成员打开链接后设置用户名、密码,再绑定身份验证器,即可进入获授权的事项。
7
+ 点击“复制邀请链接”,通过微信、邮件等可信渠道单独交给本人。链接有效期为 48 小时,只能使用一次;邀请链接不会自动通过邮件发送,也不生成需要转交的临时密码。成员打开链接后填写自己的邮箱、设置用户名和密码,再绑定身份验证器,即可进入获授权的事项。如管理员开启了邮箱验证,还须先输入账号邮箱收到的验证码。
8
8
 
9
- 成员列表区分待接受邀请、邀请已过期、待绑定验证器、已启用及已停用。重新签发邀请会废止旧链接及登录状态,原密码和验证器需要重新设置。停用成员保留其业务记录和操作历史。
9
+ 管理员已填写邮箱的邀请会固定使用该邮箱;账号恢复也保留原邮箱和用户名。自行填写的邮箱不能占用其他成员的账号。待激活成员即使没有邮箱,也有独立的内部 ID 和已分配权限;同名成员不会合并,激活后仍沿用这个 ID、权限和历史。页面会显示“邮箱待本人填写”,无需编造占位邮箱。
10
+
11
+ 分配权限时,先选择成员角色,再选择访问范围:“全部事项”自动包含以后新增事项;“指定事项”仅开放勾选的事项,新增事项需另行授权。指定事项支持搜索、批量选择及清空;“选中当前全部”只选择现有名单。保存前的“权限预览”会说明可操作内容、访问范围及新事项如何生效,成员列表也显示每个人的授权范围。
12
+
13
+ 成员列表区分待接受邀请、邀请已过期、待绑定验证器、已启用及已停用,并按工作区时区显示邀请到期时间;存在待接受邀请时每分钟刷新状态。未激活成员可点击“重新邀请”,原链接立即失效,新链接重新计算 48 小时有效期,原有身份和权限保留。重新签发邀请会废止旧链接及登录状态,原密码和验证器需要重新设置。停用成员保留其业务记录和操作历史。
10
14
 
11
15
  ## 用户名与身份
12
16
 
13
- - 姓名用于展示,可以重名;邮箱仍为必填资料,可继续用于登录。
17
+ - 姓名用于展示,可以重名;邮箱在本人激活时必填,也可用于登录。内部用户 ID 自动生成,不需要管理员填写。
14
18
  - 用户名为 3–30 位字母、数字、点或下划线,以字母或数字开头,按小写保存,唯一且不区分大小写。
15
19
  - 已有账号继续使用邮箱登录,在“我的账号”中设置用户名;用户名设置后保留不变,账号恢复也使用原用户名。
16
20
  - 权限、历史和账号恢复始终关联内部用户 ID,不因增加用户名而改变归属。密码、动态验证码和登录防护继续由 Better Auth 提供,用户名登录使用其官方插件。
17
21
 
18
22
  更新前保存完整备份。账号字段采用增量迁移,支持恢复 1.0.x 的账号备份;更新后新备份包含用户名。回滚时同时恢复更新前的数据库,不用旧程序直接打开新账号备份。
19
23
 
24
+ ## 可选的邮箱验证
25
+
26
+ 邀请时邮箱可留空,正式激活时仍需本人补齐,验证邮箱归属则**默认关闭**。不开启时无需邮件服务,邀请、用户名或邮箱登录、密码及身份验证器保持可用。邮箱验证不替代身份验证器,也不开放公开注册或邮件免密登录。
27
+
28
+ 管理员可在服务器环境文件中设置 `BD_REQUIRE_EMAIL_VERIFICATION=1`,同时配置 `BD_SMTP_HOST`、`BD_SMTP_PORT`(默认 465)、`BD_SMTP_USER`、`BD_SMTP_PASSWORD` 和 `BD_MAIL_FROM`(单个发件邮箱地址),重启服务后生效。该开关属于整站部署配置,普通成员不能跳过验证。SMTP 支持阿里云邮件推送或公司现有邮件服务;465 端口直接使用 TLS,其他端口要求 STARTTLS,始终检查服务器证书。凭据只保存在受限的服务器环境文件中,不放入代码、浏览器或业务备份。
29
+
30
+ 开启前先在测试实例验证邮件可以实际收到,再为团队开启;配置字段完整不代表邮件已能送达。尚未验证的现有账号(包括管理员)也须验证邮箱,已有网页会话和远程凭据不能绕过。不要把“填写过邮箱”自动视为已验证。邮件服务故障时,由服务器管理员修复配置,或将开关改回 `0` 并重启;邀请制、密码、身份验证器和事项权限继续生效。
31
+
32
+ 开启后,正确输入密码才会触发验证邮件。使用 Better Auth 官方 Email OTP 插件生成和校验六位验证码,10 分钟有效、最多尝试 5 次、成功后失效;同一账号一分钟最多发送一次,新验证码替代旧验证码。页面区分邮箱验证码和身份验证器动态验证码,验证邮箱后仍需完成密码和身份验证器登录。发信失败时账号会保留:首次邀请虽然已接受,仍可从登录页重试。重新签发登录邀请会废止之前的邮箱验证码并重新验证。
33
+
34
+ 当前邮箱不能在个人页面自行修改;不要直接改账号数据库来换邮箱。本功能不增加邮箱修改或邮件找回密码入口。账号页面显示邮箱是否已验证,成员管理会标出“待验证邮箱”。
35
+
36
+ 实现依据:[Better Auth Email OTP](https://better-auth.com/docs/plugins/email-otp)、[Nodemailer SMTP](https://nodemailer.com/smtp)。自动测试使用虚构账号和模拟邮件传输,不会给真实成员发信;上线前仍须实际验证发件域名、服务商授权、收件和垃圾邮件规则。
37
+
20
38
  ## 部署时替换品牌
21
39
 
22
40
  默认图标为 `dashboard/public/airalogy-logo.svg`,来自 Airalogy Platform 的 `packages/shared/src/assets/icons/logo-icon.svg`,保留原始图形。登录页和管理中心默认名称为 Airalogy BD;工作台顶部继续显示工作区名称。
@@ -1,8 +1,8 @@
1
1
  {
2
- "reviewed_on": "2026-10-22",
3
- "scope": "Declared npm and Python locked packages, including optional platforms. Metadata review; not a complete inventory of embedded native libraries, bundled framework code or container operating-system packages.",
2
+ "reviewed_on": "2026-10-26",
3
+ "scope": "Declared npm and Python locked packages, including optional platforms. Metadata review; not a complete inventory of embedded native libraries, bundled framework code or container operating-system packages. 2026-10-26 update: reviewed the two added Nodemailer packages; all other locked package versions are unchanged.",
4
4
  "lock_sha256": {
5
- "pnpm-lock.yaml": "7f401dd71a9750b70ab8d70b27bc23c1f8e527663d31c77afa799193ebb6b272",
5
+ "pnpm-lock.yaml": "250e84fb3d38ff8fbce9e404353bc5d9f1a7f930dd86bbaff0c1034aaa3c5ca0",
6
6
  "scripts/generate_dashboard_data.py.lock": "e56196a8de04123f7065acb41ae9e3c7cf89920e56e24791d1145e9d11ad1d27",
7
7
  "scripts/oss_backup.py.lock": "511c468ad7c286474ba7b15dbb2687c5ca6cd7dcc6378e17ba04d4e220fe72c6",
8
8
  "scripts/validate.py.lock": "1317fe8e93e990c287105fe019de4606bef3e16cfa5363c6d450dee4935dd8d8",
@@ -855,6 +855,12 @@
855
855
  "license": "MIT",
856
856
  "evidence": "installed package metadata"
857
857
  },
858
+ {
859
+ "name": "@types/nodemailer",
860
+ "version": "8.0.2",
861
+ "license": "MIT",
862
+ "evidence": "installed package metadata"
863
+ },
858
864
  {
859
865
  "name": "@types/react",
860
866
  "version": "19.2.14",
@@ -2757,6 +2763,12 @@
2757
2763
  "license": "MIT",
2758
2764
  "evidence": "installed package metadata"
2759
2765
  },
2766
+ {
2767
+ "name": "nodemailer",
2768
+ "version": "10.2.1",
2769
+ "license": "MIT-0",
2770
+ "evidence": "installed package metadata"
2771
+ },
2760
2772
  {
2761
2773
  "name": "object-assign",
2762
2774
  "version": "4.1.1",
@@ -4,9 +4,9 @@
4
4
 
5
5
  ## 成员使用
6
6
 
7
- - 维护人员初始化第一名管理员,管理员在“管理中心 → 成员与权限”发放 48 小时一次性邀请链接。系统不发送邮件,不开放注册。
7
+ - 维护人员初始化第一名管理员,管理员在“管理中心 → 成员与权限”发放 48 小时一次性邀请链接。邀请链接由管理员自行转交,不开放注册。管理员邀请时可以不填邮箱,由成员激活时补充。
8
8
  - 成员打开链接,设置唯一用户名、至少 12 个字符的密码,并将密钥加入支持 TOTP 的验证器。此后使用用户名或邮箱、密码和 6 位动态验证码登录。登录分为密码和动态码两步;尚未完成验证器绑定或动态码验证的会话不能访问业务资料。设置中断后可从登录页面继续绑定。服务器和手机应开启时间同步。
9
- - 会话最长 12 小时;退出、停用账号、调整授权或重置登录会立即使相应旧会话失效。账号邮箱仍保留为联系方式和可用的登录标识,未通过邮件验证;管理员应把链接私下交给正确的人。
9
+ - 会话最长 12 小时;退出、停用账号、调整授权或重置登录会立即使相应旧会话失效。账号邮箱保留为联系方式和可用的登录标识;邮箱归属验证默认关闭,可按[账号说明](accounts-and-branding.md)配置 SMTP 后开启。管理员应把邀请链接私下交给正确的人。
10
10
  - 管理员:全部资料、邀请和权限、共享人员/公司动态、事项创建、全局历史和备份。
11
11
  - 编辑成员:修改已授权事项、添加每日进展、维护关联附件。跨事项附件必须拥有全部关联事项权限。新增人员、对接方或将范围外人员关联到事项需管理员处理。
12
12
  - 只读成员:浏览、下载和导出已授权资料。导出基于同一份过滤后数据。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@airalogy/airalogy-bd",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "Business development workspace for local use and team collaboration.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -57,6 +57,7 @@
57
57
  "@radix-ui/react-toggle-group": "^1.1.19",
58
58
  "@tailwindcss/postcss": "4.2.1",
59
59
  "@types/node": "22.19.19",
60
+ "@types/nodemailer": "8.0.2",
60
61
  "@types/react": "19.2.14",
61
62
  "@types/react-dom": "19.2.3",
62
63
  "better-auth": "1.7.12",
@@ -64,6 +65,7 @@
64
65
  "eslint": "9.39.4",
65
66
  "eslint-config-next": "16.4.2",
66
67
  "next": "16.4.2",
68
+ "nodemailer": "10.2.1",
67
69
  "react": "19.2.6",
68
70
  "react-dom": "19.2.6",
69
71
  "react-markdown": "^10.1.0",
@@ -82,7 +82,7 @@ class RemoteAccess:
82
82
  if not token or token["expires"] <= int(time.time()):
83
83
  raise AuthError("远程凭据无效或已失效。", "unauthenticated")
84
84
  row = self.db.execute("SELECT * FROM users WHERE id=? AND active=1 AND revision=?", (token["owner"], token["binding"])).fetchone()
85
- if not row or not self.accounts.enrolled(row["id"]):
85
+ if not row or not self.accounts.enrolled(row["id"]) or not self.accounts.email_allowed(row["id"]):
86
86
  raise AuthError("账号或权限已变化,请重新登录并生成凭据。", "unauthenticated")
87
87
  actor = public_user(row)
88
88
  self.grant(actor)
@@ -26,6 +26,13 @@ def fingerprint(value):
26
26
  return hashlib.sha256(value.encode()).hexdigest()
27
27
 
28
28
 
29
+ def email_verification_required():
30
+ value = os.environ.get("BD_REQUIRE_EMAIL_VERIFICATION", "0") or "0"
31
+ if value not in {"0", "1"}:
32
+ raise AuthError("BD_REQUIRE_EMAIL_VERIFICATION 只支持 0 或 1。", "unavailable")
33
+ return os.environ.get("BD_MODE") == "server" and value == "1"
34
+
35
+
29
36
  def public_user(row):
30
37
  return {key: row[key] for key in ("id", "email", "name", "username", "role", "active", "revision")} | {
31
38
  "all_projects": bool(row["all_projects"]), "project_ids": json.loads(row["project_ids"]),
@@ -48,7 +55,7 @@ class Accounts:
48
55
  self.db.executescript("""
49
56
  PRAGMA foreign_keys=ON;
50
57
  CREATE TABLE IF NOT EXISTS users (
51
- id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL,
58
+ id TEXT PRIMARY KEY, email TEXT UNIQUE, name TEXT NOT NULL,
52
59
  role TEXT NOT NULL, all_projects INTEGER NOT NULL, project_ids TEXT NOT NULL,
53
60
  active INTEGER NOT NULL DEFAULT 0, credential_revision TEXT NOT NULL DEFAULT '', revision TEXT NOT NULL);
54
61
  CREATE TABLE IF NOT EXISTS invitations (
@@ -60,6 +67,7 @@ class Accounts:
60
67
  target TEXT NOT NULL, outcome TEXT NOT NULL);
61
68
  """)
62
69
 
70
+ self.allow_pending_email()
63
71
  # Additive migration keeps existing IDs, grants and email logins unchanged.
64
72
  with self.db:
65
73
  self.db.execute("BEGIN IMMEDIATE")
@@ -73,6 +81,34 @@ class Accounts:
73
81
  raise AuthError('账号用户名格式无效。', 'invalid')
74
82
  self.db.execute('UPDATE users SET username=? WHERE id=?', (row['username'],row['id']))
75
83
 
84
+ def allow_pending_email(self):
85
+ if not any(row['name'] == 'email' and row['notnull'] for row in self.db.execute('PRAGMA table_info(users)')):
86
+ return
87
+ # SQLite requires a table rebuild to remove NOT NULL. Keep parent IDs and
88
+ # all referencing rows, then verify foreign keys before committing the swap.
89
+ self.db.execute('PRAGMA foreign_keys=OFF')
90
+ try:
91
+ with self.db:
92
+ self.db.execute('BEGIN IMMEDIATE')
93
+ columns = {row['name'] for row in self.db.execute('PRAGMA table_info(users)')}
94
+ if any(row['name'] == 'email' and row['notnull'] for row in self.db.execute('PRAGMA table_info(users)')):
95
+ self.db.execute('''CREATE TABLE users_pending_email (
96
+ id TEXT PRIMARY KEY, email TEXT UNIQUE, name TEXT NOT NULL,
97
+ role TEXT NOT NULL, all_projects INTEGER NOT NULL, project_ids TEXT NOT NULL,
98
+ active INTEGER NOT NULL DEFAULT 0, credential_revision TEXT NOT NULL DEFAULT '',
99
+ revision TEXT NOT NULL, username TEXT)''')
100
+ names = 'id,email,name,role,all_projects,project_ids,active,credential_revision,revision'
101
+ if 'username' in columns:
102
+ names += ',username'
103
+ self.db.execute(f'INSERT INTO users_pending_email({names}) SELECT {names} FROM users')
104
+ self.db.execute('DROP TABLE users')
105
+ self.db.execute('ALTER TABLE users_pending_email RENAME TO users')
106
+ self.db.execute('CREATE UNIQUE INDEX users_username_unique ON users(username COLLATE NOCASE)')
107
+ if self.db.execute('PRAGMA foreign_key_check').fetchone():
108
+ raise AuthError('账号结构更新未通过关联校验。', 'invalid')
109
+ finally:
110
+ self.db.execute('PRAGMA foreign_keys=ON')
111
+
76
112
  def close(self):
77
113
  self.db.close()
78
114
 
@@ -86,14 +122,14 @@ class Accounts:
86
122
  # session again under the business lock so revocation wins over queued writes.
87
123
  if not token or not self.has_provider():
88
124
  raise AuthError("请登录后继续。", "unauthenticated")
89
- row = self.db.execute("""SELECT u.*,s.csrf,CAST(strftime('%s',s.expiresAt) AS INTEGER) AS expires FROM ba_session s
125
+ row = self.db.execute("""SELECT u.*,b.emailVerified AS email_verified,s.csrf,CAST(strftime('%s',s.expiresAt) AS INTEGER) AS expires FROM ba_session s
90
126
  JOIN ba_user b ON b.id=s.userId JOIN users u ON u.id=b.memberId
91
127
  WHERE s.token=? AND CAST(strftime('%s',s.expiresAt) AS INTEGER)>? AND u.active=1 AND b.twoFactorEnabled=1
92
128
  AND s.mfaVerified=1 AND s.permissionRevision=u.revision
93
129
  AND b.credentialRevision=u.credential_revision""", (token, int(time.time()))).fetchone()
94
- if not row:
130
+ if not row or (email_verification_required() and not row["email_verified"]):
95
131
  raise AuthError("登录已失效,请重新登录。", "unauthenticated")
96
- return public_user(row) | {"csrf": row["csrf"], "expires": row["expires"]}
132
+ return public_user(row) | {"csrf": row["csrf"], "expires": row["expires"], "email_verified": bool(row["email_verified"])}
97
133
 
98
134
  def has_provider(self):
99
135
  return bool(self.db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='ba_session'").fetchone())
@@ -103,12 +139,20 @@ class Accounts:
103
139
  if self.db.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone():
104
140
  self.db.execute("DELETE FROM " + table + " WHERE owner=?", (identity,))
105
141
  if self.has_provider():
142
+ row = self.db.execute("SELECT email FROM users WHERE id=?", (identity,)).fetchone()
143
+ if row and row["email"]:
144
+ self.db.execute("DELETE FROM ba_verification WHERE identifier IN (?,?)", ("email-verification-otp-" + row["email"], "bd-email-send-" + row["email"]))
106
145
  self.db.execute("DELETE FROM ba_session WHERE userId IN (SELECT id FROM ba_user WHERE memberId=?)", (identity,))
107
146
  self.db.execute("DELETE FROM ba_verification WHERE value IN (SELECT id FROM ba_user WHERE memberId=?)", (identity,))
108
147
 
109
148
  def enrolled(self, identity):
110
149
  return self.has_provider() and bool(self.db.execute("SELECT 1 FROM ba_user b JOIN users u ON u.id=b.memberId WHERE u.id=? AND b.credentialRevision=u.credential_revision AND b.twoFactorEnabled=1", (identity,)).fetchone())
111
150
 
151
+ def email_allowed(self, identity):
152
+ if not email_verification_required():
153
+ return True
154
+ return self.has_provider() and bool(self.db.execute("SELECT 1 FROM ba_user b JOIN users u ON u.id=b.memberId WHERE u.id=? AND b.credentialRevision=u.credential_revision AND b.emailVerified=1", (identity,)).fetchone())
155
+
112
156
  @staticmethod
113
157
  def require_admin(actor):
114
158
  if not actor or actor["role"] != "admin":
@@ -135,7 +179,9 @@ class Accounts:
135
179
  def invite(self, payload, actor, known, bootstrap=False):
136
180
  if not bootstrap:
137
181
  self.require_admin(actor)
138
- email = self.email(payload.get("email"))
182
+ supplied = payload.get("email")
183
+ empty_email = supplied is None or (isinstance(supplied, str) and not supplied.strip())
184
+ email = self.email(supplied) if bootstrap or not empty_email else None
139
185
  name = str(payload.get("name", "")).strip()
140
186
  if not 1 <= len(name) <= 100:
141
187
  raise AuthError("请填写成员姓名(不超过 100 个字符)。", "invalid")
@@ -143,7 +189,7 @@ class Accounts:
143
189
  with self.db:
144
190
  if bootstrap and self.db.execute("SELECT 1 FROM users LIMIT 1").fetchone():
145
191
  raise AuthError("管理员已初始化,请通过成员管理邀请其他账号。", "conflict")
146
- if self.db.execute("SELECT 1 FROM users WHERE email=?", (email,)).fetchone():
192
+ if email and self.db.execute("SELECT 1 FROM users WHERE email=? COLLATE NOCASE", (email,)).fetchone():
147
193
  raise AuthError("该邮箱已有账号,请在成员列表中重新签发邀请或重置登录。", "conflict")
148
194
  identity = secrets.token_hex(16)
149
195
  self.db.execute("INSERT INTO users(id,email,name,role,all_projects,project_ids,revision,credential_revision) VALUES(?,?,?,?,?,?,?,?)",
@@ -175,6 +221,8 @@ class Accounts:
175
221
  invitation = self.db.execute('SELECT expires FROM invitations WHERE user_id=?', (row['id'],)).fetchone()
176
222
  status = ('active' if self.enrolled(row['id']) else 'setup') if row['active'] else (
177
223
  ('invited' if invitation['expires'] > time.time() else 'expired') if invitation else 'disabled')
224
+ if row['active'] and not self.email_allowed(row['id']):
225
+ status = 'email'
178
226
  return public_user(row) | {'status': status, 'invitation_expires': invitation['expires'] if invitation else None}
179
227
 
180
228
  def update(self, payload, actor, known):
@@ -256,6 +304,12 @@ def validate_account_backup(path):
256
304
  current = {'id','email','name','role','all_projects','project_ids','active','credential_revision','revision'}
257
305
  if columns not in (current, current | {'username'}):
258
306
  raise AuthError('账号备份格式不受支持。', 'invalid')
307
+ for row in db.execute('SELECT email,active FROM users'):
308
+ if row[0] is None:
309
+ if row[1]:
310
+ raise AuthError('已激活账号不能缺少邮箱。', 'invalid')
311
+ elif Accounts.email(row[0]) != row[0]:
312
+ raise AuthError('账号邮箱格式无效。', 'invalid')
259
313
  if 'username' in columns:
260
314
  names = [row[0] for row in db.execute('SELECT username FROM users WHERE username IS NOT NULL')]
261
315
  if any(not isinstance(name, str) or not re.fullmatch(r'[a-z0-9][a-z0-9._]{2,29}', name) for name in names) or len(names) != len(set(names)):