add licensed random user creation flow

This commit is contained in:
2026-04-03 12:22:12 +08:00
parent df9dd80bb3
commit b1f0dbc6f4
6 changed files with 221 additions and 7 deletions

View File

@@ -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

View File

@@ -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接口会返回错误。
- 如果许可证分配最终失败,接口会自动删除刚创建的用户,避免留下无授权的脏账号。

View File

@@ -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);

View File

@@ -11,8 +11,12 @@
<aside class="sidebar">
<div class="sidebar-header">
<p class="eyebrow">Microsoft 365</p>
<div class="sidebar-title-row">
<h1>邮箱控制台</h1>
<button id="create-user" type="button">随机建号</button>
</div>
<p class="sidebar-copy">列出租户账号,点击后读取该账号收件箱中的最新一封邮件。</p>
<p id="sidebar-status" class="sidebar-status hidden"></p>
</div>
<label class="search-box" for="search-input">

View File

@@ -62,11 +62,41 @@ input {
.user-address,
.user-meta,
.user-empty,
.sidebar-status,
.status,
.empty-block p {
color: var(--muted);
}
.sidebar-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.sidebar-title-row button {
padding: 9px 12px;
white-space: nowrap;
}
.sidebar-status {
margin: 12px 0 0;
font-size: 13px;
}
.sidebar-status.loading {
color: var(--accent);
}
.sidebar-status.success {
color: #9fe8b2;
}
.sidebar-status.warning {
color: var(--warning);
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
@@ -292,4 +322,9 @@ a {
align-items: flex-start;
flex-direction: column;
}
.sidebar-title-row {
align-items: flex-start;
flex-direction: column;
}
}

100
server.js
View File

@@ -14,6 +14,8 @@ const {
CLIENT_SECRET,
MAILBOX_ADDRESS = '',
DEFAULT_DOMAIN = '',
DEFAULT_LICENSE_SKU_ID = '',
DEFAULT_USAGE_LOCATION = 'US',
ACCESS_PASSWORD = '',
PORT = '3000',
} = process.env;
@@ -48,6 +50,7 @@ const tokenCache = {
expiresAt: 0,
};
let cachedCreateUserDomain = DEFAULT_DOMAIN || '';
let cachedDefaultLicenseSkuId = DEFAULT_LICENSE_SKU_ID || '';
function timingSafeStringEqual(left, right) {
const leftBuffer = Buffer.from(left);
@@ -278,6 +281,12 @@ function buildTemporaryPassword() {
return `${crypto.randomBytes(12).toString('base64url')}Aa1!`;
}
function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function extractDomainFromMailbox() {
if (DEFAULT_DOMAIN) {
return DEFAULT_DOMAIN;
@@ -413,6 +422,84 @@ async function resolveDomainForUserCreation() {
return cachedCreateUserDomain;
}
async function resolveDefaultLicenseSkuId() {
if (cachedDefaultLicenseSkuId) {
return cachedDefaultLicenseSkuId;
}
const payload = await graphRequest('/subscribedSkus', {
'$select': 'skuId,skuPartNumber,capabilityStatus,consumedUnits,prepaidUnits',
});
const availableSku = (payload.value || []).find((sku) => {
if (sku.capabilityStatus !== 'Enabled') {
return false;
}
const enabled = Number(sku.prepaidUnits?.enabled || 0);
const consumed = Number(sku.consumedUnits || 0);
return enabled === 0 || enabled > consumed;
});
if (!availableSku?.skuId) {
const error = new Error('No available subscribed SKU found for license assignment.');
error.status = 400;
error.details = { code: 'missing_available_sku' };
throw error;
}
cachedDefaultLicenseSkuId = availableSku.skuId;
return cachedDefaultLicenseSkuId;
}
async function enableMailboxLicense(userId) {
const skuId = await resolveDefaultLicenseSkuId();
await graphRequest(`/users/${encodeURIComponent(userId)}`, undefined, {
method: 'PATCH',
json: {
usageLocation: DEFAULT_USAGE_LOCATION,
},
});
let lastError = null;
for (let attempt = 0; attempt < 6; attempt += 1) {
try {
await graphRequest(`/users/${encodeURIComponent(userId)}/assignLicense`, undefined, {
method: 'POST',
json: {
addLicenses: [
{
skuId,
},
],
removeLicenses: [],
},
});
return skuId;
} catch (error) {
lastError = error;
const message = String(error.details?.error?.message || error.message || '').toLowerCase();
const shouldRetry = error.status === 400 && message.includes('usage location');
if (!shouldRetry || attempt === 5) {
throw error;
}
await sleep(2000);
}
}
throw lastError;
}
async function deleteUser(userId) {
await graphRequest(`/users/${encodeURIComponent(userId)}`, undefined, {
method: 'DELETE',
});
}
async function createRandomUser() {
const domain = await resolveDomainForUserCreation();
@@ -444,16 +531,27 @@ async function createRandomUser() {
},
});
let assignedSkuId;
try {
assignedSkuId = await enableMailboxLicense(user.id);
} catch (error) {
await deleteUser(user.id).catch(() => null);
throw error;
}
return {
id: user.id,
displayName: user.displayName || payload.displayName,
userPrincipalName: user.userPrincipalName || payload.userPrincipalName,
mailNickname: payload.mailNickname,
temporaryPassword: payload.password,
usageLocation: DEFAULT_USAGE_LOCATION,
licenseSkuId: assignedSkuId,
};
} catch (error) {
lastError = error;
if (error.status !== 409 && error.status !== 400) {
if (error.status !== 409) {
throw error;
}
}