diff --git a/.env.example b/.env.example index f916a08..2cf4bd3 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,7 @@ CLIENT_ID=your-client-id CLIENT_SECRET=your-client-secret MAILBOX_ADDRESS= DEFAULT_DOMAIN= +DEFAULT_LICENSE_SKU_ID= +DEFAULT_USAGE_LOCATION=US ACCESS_PASSWORD=change-this-password PORT=3000 diff --git a/README.md b/README.md index 2f3a156..5298d5b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - `Mail.Read` - `User.Read.All` - `User.ReadWrite.All` + - `LicenseAssignment.ReadWrite.All` ## 配置 @@ -25,6 +26,8 @@ CLIENT_ID=your-client-id CLIENT_SECRET=your-client-secret MAILBOX_ADDRESS= DEFAULT_DOMAIN= +DEFAULT_LICENSE_SKU_ID= +DEFAULT_USAGE_LOCATION=US ACCESS_PASSWORD=change-this-password PORT=3000 ``` @@ -33,6 +36,8 @@ PORT=3000 - `MAILBOX_ADDRESS` 为可选默认选中账号。页面首次加载完成后,如果能在租户中找到这个邮箱地址,会自动展示它的最新邮件。 - `DEFAULT_DOMAIN` 为创建随机用户时使用的域名。未填写时,程序会尝试从 `MAILBOX_ADDRESS` 提取域名。 +- `DEFAULT_LICENSE_SKU_ID` 为创建用户后自动分配的许可证 SKU ID。留空时,程序会自动从租户已订阅且仍有剩余可用数的 SKU 中选择一个。 +- `DEFAULT_USAGE_LOCATION` 为许可证分配前写入的使用地区,默认 `US`。 - `ACCESS_PASSWORD` 为访问控制台所需的口令。设置后,用户必须先输入该密码才能打开页面和访问 API。 - 读取租户账号和邮件实际依赖的是 `TENANT_ID`、`CLIENT_ID`、`CLIENT_SECRET`。 @@ -46,14 +51,16 @@ PORT=3000 | `Mail.Read` | 是 | 读取邮箱内容所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 | | `User.Read.All` | 是 | 列出租户成员账号所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 | | `User.ReadWrite.All` | 是 | 创建随机用户所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 | +| `LicenseAssignment.ReadWrite.All` | 是 | 为新用户分配许可证所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 | | `ACCESS_PASSWORD` | 可选 | 控制台访问密码,设置后访问页面前必须先登录 | 自定义 | 补充说明: -- 这三个 Graph 权限都要选择「应用程序权限」,并完成管理员同意。 +- 这四个 Graph 权限都要选择「应用程序权限」,并完成管理员同意。 - 本项目采用 `client_credentials` 模式,不需要配置重定向 URI。 - 如果只配置 `Mail.Read`,页面可以读邮件,但无法列出所有账号;如果只配置 `User.Read.All`,页面可以列出账号,但无法读取邮件正文。 -- 创建随机用户需要 `User.ReadWrite.All`,并且目标域名必须是租户可用域名。 +- 创建随机用户需要 `User.ReadWrite.All`、`LicenseAssignment.ReadWrite.All`,并且目标域名必须是租户可用域名。 +- `DEFAULT_LICENSE_SKU_ID` 如果填写,优先使用该值;如果留空,程序会自动选择租户内一个已启用且仍可分配的 `skuId`。 ## 运行 @@ -97,7 +104,7 @@ docker compose down - `GET /api/users/:userId/latest-email/body` - 返回该账号收件箱最新一封邮件正文 - `POST /api/users/random` - - 自动创建一个随机用户名的新用户,并返回用户主体与临时密码 + - 自动创建一个随机用户名的新用户,设置使用地区并分配许可证,然后返回用户主体与临时密码 ## 注意事项 @@ -105,3 +112,5 @@ docker compose down - 某些账号虽然存在于租户中,但不一定拥有 Exchange 邮箱;这类账号读取邮件时可能返回空结果或权限错误。 - 当前页面以邮件全文为主,保留发件人和接收时间,并显示完整 HTML / 纯文本正文。 - 当前版本使用基于 Cookie 的本地访问验证;更换 `ACCESS_PASSWORD` 后,旧登录会话会失效。 +- 建号接口会在创建用户后立即分配许可证;如果指定的 `DEFAULT_LICENSE_SKU_ID` 不可用,或租户中没有可自动选择的可用 SKU,接口会返回错误。 +- 如果许可证分配最终失败,接口会自动删除刚创建的用户,避免留下无授权的脏账号。 diff --git a/public/app.js b/public/app.js index 2568aef..1c89332 100644 --- a/public/app.js +++ b/public/app.js @@ -10,6 +10,8 @@ const state = { const elements = { userList: document.getElementById('user-list'), userCount: document.getElementById('user-count'), + createUser: document.getElementById('create-user'), + sidebarStatus: document.getElementById('sidebar-status'), searchInput: document.getElementById('search-input'), refreshUsers: document.getElementById('refresh-users'), refreshMail: document.getElementById('refresh-mail'), @@ -56,6 +58,16 @@ function clearStatus() { elements.mailStatus.className = 'status hidden'; } +function setSidebarStatus(message, tone = 'neutral') { + elements.sidebarStatus.textContent = message; + elements.sidebarStatus.className = `sidebar-status ${tone}`; +} + +function clearSidebarStatus() { + elements.sidebarStatus.textContent = ''; + elements.sidebarStatus.className = 'sidebar-status hidden'; +} + function renderUserList() { elements.userCount.textContent = `共 ${state.filteredUsers.length} 个账号`; @@ -184,8 +196,8 @@ function renderMessage(message) { renderMessageBody(message); } -async function requestJson(url) { - const response = await fetch(url); +async function requestJson(url, init) { + const response = await fetch(url, init); const payload = await response.json().catch(() => ({})); if (response.status === 401) { @@ -237,6 +249,56 @@ async function loadUsers() { } } +function wait(ms) { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); +} + +async function refreshUsersUntilPresent(userPrincipalName, attempts = 6) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + await loadUsers(); + + const createdUser = state.users.find((user) => user.userPrincipalName === userPrincipalName); + if (createdUser) { + return createdUser; + } + + if (attempt < attempts - 1) { + await wait(1500); + } + } + + return null; +} + +async function createRandomUser() { + elements.createUser.disabled = true; + setSidebarStatus('正在创建随机用户...', 'loading'); + + try { + const payload = await requestJson('/api/users/random', { + method: 'POST', + }); + setSidebarStatus('用户已创建,正在同步账号列表...', 'loading'); + + const createdUser = await refreshUsersUntilPresent(payload.user.userPrincipalName); + if (createdUser) { + state.selectedUser = createdUser; + renderUserList(); + setSidebarStatus(`已创建: ${payload.user.userPrincipalName}`, 'success'); + await loadLatestEmail(createdUser.id); + return; + } + + setSidebarStatus(`用户已创建,但列表尚未同步: ${payload.user.userPrincipalName}`, 'warning'); + } catch (error) { + setSidebarStatus(error.message, 'warning'); + } finally { + elements.createUser.disabled = false; + } +} + async function loadLatestEmail(userId) { const user = state.users.find((entry) => entry.id === userId); @@ -297,6 +359,10 @@ elements.refreshUsers.addEventListener('click', () => { loadUsers(); }); +elements.createUser.addEventListener('click', () => { + createRandomUser(); +}); + elements.refreshMail.addEventListener('click', () => { if (state.selectedUser) { loadLatestEmail(state.selectedUser.id); diff --git a/public/index.html b/public/index.html index 0468d40..71bf6a9 100644 --- a/public/index.html +++ b/public/index.html @@ -11,8 +11,12 @@