add licensed random user creation flow
This commit is contained in:
@@ -3,5 +3,7 @@ CLIENT_ID=your-client-id
|
|||||||
CLIENT_SECRET=your-client-secret
|
CLIENT_SECRET=your-client-secret
|
||||||
MAILBOX_ADDRESS=
|
MAILBOX_ADDRESS=
|
||||||
DEFAULT_DOMAIN=
|
DEFAULT_DOMAIN=
|
||||||
|
DEFAULT_LICENSE_SKU_ID=
|
||||||
|
DEFAULT_USAGE_LOCATION=US
|
||||||
ACCESS_PASSWORD=change-this-password
|
ACCESS_PASSWORD=change-this-password
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -13,6 +13,7 @@
|
|||||||
- `Mail.Read`
|
- `Mail.Read`
|
||||||
- `User.Read.All`
|
- `User.Read.All`
|
||||||
- `User.ReadWrite.All`
|
- `User.ReadWrite.All`
|
||||||
|
- `LicenseAssignment.ReadWrite.All`
|
||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
@@ -25,6 +26,8 @@ CLIENT_ID=your-client-id
|
|||||||
CLIENT_SECRET=your-client-secret
|
CLIENT_SECRET=your-client-secret
|
||||||
MAILBOX_ADDRESS=
|
MAILBOX_ADDRESS=
|
||||||
DEFAULT_DOMAIN=
|
DEFAULT_DOMAIN=
|
||||||
|
DEFAULT_LICENSE_SKU_ID=
|
||||||
|
DEFAULT_USAGE_LOCATION=US
|
||||||
ACCESS_PASSWORD=change-this-password
|
ACCESS_PASSWORD=change-this-password
|
||||||
PORT=3000
|
PORT=3000
|
||||||
```
|
```
|
||||||
@@ -33,6 +36,8 @@ PORT=3000
|
|||||||
|
|
||||||
- `MAILBOX_ADDRESS` 为可选默认选中账号。页面首次加载完成后,如果能在租户中找到这个邮箱地址,会自动展示它的最新邮件。
|
- `MAILBOX_ADDRESS` 为可选默认选中账号。页面首次加载完成后,如果能在租户中找到这个邮箱地址,会自动展示它的最新邮件。
|
||||||
- `DEFAULT_DOMAIN` 为创建随机用户时使用的域名。未填写时,程序会尝试从 `MAILBOX_ADDRESS` 提取域名。
|
- `DEFAULT_DOMAIN` 为创建随机用户时使用的域名。未填写时,程序会尝试从 `MAILBOX_ADDRESS` 提取域名。
|
||||||
|
- `DEFAULT_LICENSE_SKU_ID` 为创建用户后自动分配的许可证 SKU ID。留空时,程序会自动从租户已订阅且仍有剩余可用数的 SKU 中选择一个。
|
||||||
|
- `DEFAULT_USAGE_LOCATION` 为许可证分配前写入的使用地区,默认 `US`。
|
||||||
- `ACCESS_PASSWORD` 为访问控制台所需的口令。设置后,用户必须先输入该密码才能打开页面和访问 API。
|
- `ACCESS_PASSWORD` 为访问控制台所需的口令。设置后,用户必须先输入该密码才能打开页面和访问 API。
|
||||||
- 读取租户账号和邮件实际依赖的是 `TENANT_ID`、`CLIENT_ID`、`CLIENT_SECRET`。
|
- 读取租户账号和邮件实际依赖的是 `TENANT_ID`、`CLIENT_ID`、`CLIENT_SECRET`。
|
||||||
|
|
||||||
@@ -46,14 +51,16 @@ PORT=3000
|
|||||||
| `Mail.Read` | 是 | 读取邮箱内容所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 |
|
| `Mail.Read` | 是 | 读取邮箱内容所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 |
|
||||||
| `User.Read.All` | 是 | 列出租户成员账号所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 |
|
| `User.Read.All` | 是 | 列出租户成员账号所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 |
|
||||||
| `User.ReadWrite.All` | 是 | 创建随机用户所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 |
|
| `User.ReadWrite.All` | 是 | 创建随机用户所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 |
|
||||||
|
| `LicenseAssignment.ReadWrite.All` | 是 | 为新用户分配许可证所需的 Microsoft Graph 应用程序权限 | Azure AD「API 权限」 |
|
||||||
| `ACCESS_PASSWORD` | 可选 | 控制台访问密码,设置后访问页面前必须先登录 | 自定义 |
|
| `ACCESS_PASSWORD` | 可选 | 控制台访问密码,设置后访问页面前必须先登录 | 自定义 |
|
||||||
|
|
||||||
补充说明:
|
补充说明:
|
||||||
|
|
||||||
- 这三个 Graph 权限都要选择「应用程序权限」,并完成管理员同意。
|
- 这四个 Graph 权限都要选择「应用程序权限」,并完成管理员同意。
|
||||||
- 本项目采用 `client_credentials` 模式,不需要配置重定向 URI。
|
- 本项目采用 `client_credentials` 模式,不需要配置重定向 URI。
|
||||||
- 如果只配置 `Mail.Read`,页面可以读邮件,但无法列出所有账号;如果只配置 `User.Read.All`,页面可以列出账号,但无法读取邮件正文。
|
- 如果只配置 `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`
|
- `GET /api/users/:userId/latest-email/body`
|
||||||
- 返回该账号收件箱最新一封邮件正文
|
- 返回该账号收件箱最新一封邮件正文
|
||||||
- `POST /api/users/random`
|
- `POST /api/users/random`
|
||||||
- 自动创建一个随机用户名的新用户,并返回用户主体与临时密码
|
- 自动创建一个随机用户名的新用户,设置使用地区并分配许可证,然后返回用户主体与临时密码
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
@@ -105,3 +112,5 @@ docker compose down
|
|||||||
- 某些账号虽然存在于租户中,但不一定拥有 Exchange 邮箱;这类账号读取邮件时可能返回空结果或权限错误。
|
- 某些账号虽然存在于租户中,但不一定拥有 Exchange 邮箱;这类账号读取邮件时可能返回空结果或权限错误。
|
||||||
- 当前页面以邮件全文为主,保留发件人和接收时间,并显示完整 HTML / 纯文本正文。
|
- 当前页面以邮件全文为主,保留发件人和接收时间,并显示完整 HTML / 纯文本正文。
|
||||||
- 当前版本使用基于 Cookie 的本地访问验证;更换 `ACCESS_PASSWORD` 后,旧登录会话会失效。
|
- 当前版本使用基于 Cookie 的本地访问验证;更换 `ACCESS_PASSWORD` 后,旧登录会话会失效。
|
||||||
|
- 建号接口会在创建用户后立即分配许可证;如果指定的 `DEFAULT_LICENSE_SKU_ID` 不可用,或租户中没有可自动选择的可用 SKU,接口会返回错误。
|
||||||
|
- 如果许可证分配最终失败,接口会自动删除刚创建的用户,避免留下无授权的脏账号。
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const state = {
|
|||||||
const elements = {
|
const elements = {
|
||||||
userList: document.getElementById('user-list'),
|
userList: document.getElementById('user-list'),
|
||||||
userCount: document.getElementById('user-count'),
|
userCount: document.getElementById('user-count'),
|
||||||
|
createUser: document.getElementById('create-user'),
|
||||||
|
sidebarStatus: document.getElementById('sidebar-status'),
|
||||||
searchInput: document.getElementById('search-input'),
|
searchInput: document.getElementById('search-input'),
|
||||||
refreshUsers: document.getElementById('refresh-users'),
|
refreshUsers: document.getElementById('refresh-users'),
|
||||||
refreshMail: document.getElementById('refresh-mail'),
|
refreshMail: document.getElementById('refresh-mail'),
|
||||||
@@ -56,6 +58,16 @@ function clearStatus() {
|
|||||||
elements.mailStatus.className = 'status hidden';
|
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() {
|
function renderUserList() {
|
||||||
elements.userCount.textContent = `共 ${state.filteredUsers.length} 个账号`;
|
elements.userCount.textContent = `共 ${state.filteredUsers.length} 个账号`;
|
||||||
|
|
||||||
@@ -184,8 +196,8 @@ function renderMessage(message) {
|
|||||||
renderMessageBody(message);
|
renderMessageBody(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestJson(url) {
|
async function requestJson(url, init) {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url, init);
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
if (response.status === 401) {
|
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) {
|
async function loadLatestEmail(userId) {
|
||||||
const user = state.users.find((entry) => entry.id === userId);
|
const user = state.users.find((entry) => entry.id === userId);
|
||||||
|
|
||||||
@@ -297,6 +359,10 @@ elements.refreshUsers.addEventListener('click', () => {
|
|||||||
loadUsers();
|
loadUsers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
elements.createUser.addEventListener('click', () => {
|
||||||
|
createRandomUser();
|
||||||
|
});
|
||||||
|
|
||||||
elements.refreshMail.addEventListener('click', () => {
|
elements.refreshMail.addEventListener('click', () => {
|
||||||
if (state.selectedUser) {
|
if (state.selectedUser) {
|
||||||
loadLatestEmail(state.selectedUser.id);
|
loadLatestEmail(state.selectedUser.id);
|
||||||
|
|||||||
@@ -11,8 +11,12 @@
|
|||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<p class="eyebrow">Microsoft 365</p>
|
<p class="eyebrow">Microsoft 365</p>
|
||||||
|
<div class="sidebar-title-row">
|
||||||
<h1>邮箱控制台</h1>
|
<h1>邮箱控制台</h1>
|
||||||
|
<button id="create-user" type="button">随机建号</button>
|
||||||
|
</div>
|
||||||
<p class="sidebar-copy">列出租户账号,点击后读取该账号收件箱中的最新一封邮件。</p>
|
<p class="sidebar-copy">列出租户账号,点击后读取该账号收件箱中的最新一封邮件。</p>
|
||||||
|
<p id="sidebar-status" class="sidebar-status hidden"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="search-box" for="search-input">
|
<label class="search-box" for="search-input">
|
||||||
|
|||||||
@@ -62,11 +62,41 @@ input {
|
|||||||
.user-address,
|
.user-address,
|
||||||
.user-meta,
|
.user-meta,
|
||||||
.user-empty,
|
.user-empty,
|
||||||
|
.sidebar-status,
|
||||||
.status,
|
.status,
|
||||||
.empty-block p {
|
.empty-block p {
|
||||||
color: var(--muted);
|
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 {
|
.eyebrow {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -292,4 +322,9 @@ a {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-title-row {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
100
server.js
100
server.js
@@ -14,6 +14,8 @@ const {
|
|||||||
CLIENT_SECRET,
|
CLIENT_SECRET,
|
||||||
MAILBOX_ADDRESS = '',
|
MAILBOX_ADDRESS = '',
|
||||||
DEFAULT_DOMAIN = '',
|
DEFAULT_DOMAIN = '',
|
||||||
|
DEFAULT_LICENSE_SKU_ID = '',
|
||||||
|
DEFAULT_USAGE_LOCATION = 'US',
|
||||||
ACCESS_PASSWORD = '',
|
ACCESS_PASSWORD = '',
|
||||||
PORT = '3000',
|
PORT = '3000',
|
||||||
} = process.env;
|
} = process.env;
|
||||||
@@ -48,6 +50,7 @@ const tokenCache = {
|
|||||||
expiresAt: 0,
|
expiresAt: 0,
|
||||||
};
|
};
|
||||||
let cachedCreateUserDomain = DEFAULT_DOMAIN || '';
|
let cachedCreateUserDomain = DEFAULT_DOMAIN || '';
|
||||||
|
let cachedDefaultLicenseSkuId = DEFAULT_LICENSE_SKU_ID || '';
|
||||||
|
|
||||||
function timingSafeStringEqual(left, right) {
|
function timingSafeStringEqual(left, right) {
|
||||||
const leftBuffer = Buffer.from(left);
|
const leftBuffer = Buffer.from(left);
|
||||||
@@ -278,6 +281,12 @@ function buildTemporaryPassword() {
|
|||||||
return `${crypto.randomBytes(12).toString('base64url')}Aa1!`;
|
return `${crypto.randomBytes(12).toString('base64url')}Aa1!`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function extractDomainFromMailbox() {
|
function extractDomainFromMailbox() {
|
||||||
if (DEFAULT_DOMAIN) {
|
if (DEFAULT_DOMAIN) {
|
||||||
return DEFAULT_DOMAIN;
|
return DEFAULT_DOMAIN;
|
||||||
@@ -413,6 +422,84 @@ async function resolveDomainForUserCreation() {
|
|||||||
return cachedCreateUserDomain;
|
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() {
|
async function createRandomUser() {
|
||||||
const domain = await resolveDomainForUserCreation();
|
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 {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
displayName: user.displayName || payload.displayName,
|
displayName: user.displayName || payload.displayName,
|
||||||
userPrincipalName: user.userPrincipalName || payload.userPrincipalName,
|
userPrincipalName: user.userPrincipalName || payload.userPrincipalName,
|
||||||
mailNickname: payload.mailNickname,
|
mailNickname: payload.mailNickname,
|
||||||
temporaryPassword: payload.password,
|
temporaryPassword: payload.password,
|
||||||
|
usageLocation: DEFAULT_USAGE_LOCATION,
|
||||||
|
licenseSkuId: assignedSkuId,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (error.status !== 409 && error.status !== 400) {
|
if (error.status !== 409) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user