Initial commit: Office365 web management platform
This commit is contained in:
22
.env.example
Normal file
22
.env.example
Normal file
@@ -0,0 +1,22 @@
|
||||
APP_NAME=Office 365 User Management Platform
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DEBUG=false
|
||||
|
||||
# Web login. If left empty, the management UI will run without login protection.
|
||||
WEB_AUTH_ENABLED=true
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=ChangeMe123!
|
||||
SESSION_SECRET=please-change-this-session-secret
|
||||
|
||||
# Microsoft Graph app registration
|
||||
CLIENT_ID=
|
||||
TENANT_ID=
|
||||
CLIENT_SECRET=
|
||||
|
||||
# User defaults
|
||||
DEFAULT_PASSWORD=ChangeMe!2026
|
||||
DEFAULT_DOMAIN=yourtenant.onmicrosoft.com
|
||||
DEFAULT_USAGE_LOCATION=US
|
||||
DEFAULT_LICENSE_SKU=
|
||||
FORCE_CHANGE_PASSWORD=true
|
||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
.env
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.pycache/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
venv/
|
||||
dist/
|
||||
build/
|
||||
logs/
|
||||
reference-office365-tools/
|
||||
250
README.md
Normal file
250
README.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Office 365 Web 管理平台
|
||||
|
||||
基于你指定的 [`eggyrooch-blip/office365-tools`](https://github.com/eggyrooch-blip/office365-tools) 思路重新封装,做成了一套可直接 Web 管理的 Office 365 / Microsoft 365 账号管理后台。当前项目只保留国际版 Microsoft 365 / Microsoft Graph 实现。
|
||||
|
||||
当前版本覆盖的核心能力:
|
||||
|
||||
- 单个账号增删改查
|
||||
- 用户列表勾选后批量删除、批量启用、批量停用、批量重置密码
|
||||
- 批量创建、批量更新、批量删除
|
||||
- 单个与批量重置密码
|
||||
- 批量模板 CSV 下载后上传执行
|
||||
- 批量任务进度展示,详细处理过程写入日志文件
|
||||
- 许可证 SKU 列表与剩余席位查看
|
||||
- 基于 `.env` 的租户配置
|
||||
- 可选的后台登录保护
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── app.py
|
||||
├── office365_admin/
|
||||
│ ├── __init__.py
|
||||
│ ├── batch.py
|
||||
│ ├── graph.py
|
||||
│ ├── routes.py
|
||||
│ ├── services.py
|
||||
│ ├── settings.py
|
||||
│ ├── static/
|
||||
│ └── templates/
|
||||
├── tests/
|
||||
└── reference-office365-tools/ # 参考仓库,保留用于对照
|
||||
```
|
||||
|
||||
## 启动方式
|
||||
|
||||
1. 创建虚拟环境并安装依赖
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. 复制配置文件
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. 在 `.env` 中填写你的租户配置
|
||||
|
||||
必填项:
|
||||
|
||||
- `CLIENT_ID`
|
||||
- `TENANT_ID`
|
||||
- `CLIENT_SECRET`
|
||||
- `DEFAULT_PASSWORD`
|
||||
|
||||
常用项:
|
||||
|
||||
- `DEFAULT_DOMAIN`
|
||||
- `DEFAULT_LICENSE_SKU`
|
||||
- `ADMIN_USERNAME`
|
||||
- `ADMIN_PASSWORD`
|
||||
|
||||
4. 启动服务
|
||||
|
||||
```bash
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
默认访问地址:
|
||||
|
||||
- [http://127.0.0.1:8000](http://127.0.0.1:8000)
|
||||
|
||||
## 批量导入格式
|
||||
|
||||
### 批量创建 CSV
|
||||
|
||||
```csv
|
||||
userPrincipalName,displayName,givenName,surname,department,jobTitle,usageLocation,skuPartNumber,password
|
||||
alice,Alice Zhang,Alice,Zhang,Sales,Manager,US,ENTERPRISEPACK,Temp123!
|
||||
bob@contoso.com,Bob Li,Bob,Li,IT,Engineer,US,ENTERPRISEPACK,
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `userPrincipalName` 可以只写用户名,系统会自动拼接 `DEFAULT_DOMAIN`
|
||||
- `password` 为空时使用 `DEFAULT_PASSWORD`
|
||||
- `skuPartNumber` 为空时使用 `DEFAULT_LICENSE_SKU`
|
||||
|
||||
### 批量更新 CSV
|
||||
|
||||
```csv
|
||||
userPrincipalName,department,jobTitle,accountEnabled,skuPartNumber
|
||||
alice@contoso.com,Operations,Lead,true,O365_BUSINESS
|
||||
bob@contoso.com,Finance,Analyst,false,
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 至少要有一列账号标识
|
||||
- 批量更新里的空白字段默认忽略,不会清空已有值
|
||||
|
||||
### 批量删除文本
|
||||
|
||||
```text
|
||||
alice
|
||||
bob@contoso.com
|
||||
charlie@contoso.com
|
||||
```
|
||||
|
||||
### 批量重置密码 CSV
|
||||
|
||||
```csv
|
||||
userPrincipalName,password,forceChangePasswordNextSignIn
|
||||
alice@contoso.com,Temp123!,true
|
||||
bob@contoso.com,Another123!,true
|
||||
```
|
||||
|
||||
也可以直接传纯文本账号列表,此时统一使用 `DEFAULT_PASSWORD`。
|
||||
|
||||
## 配置说明
|
||||
|
||||
### `.env` 参数配置与获取方式
|
||||
|
||||
| 参数名 | 是否必填 | 说明 | 获取方式 / 示例 |
|
||||
| --- | --- | --- | --- |
|
||||
| `CLIENT_ID` | 是 | Entra 应用的客户端 ID | 在应用注册概览页复制“应用程序(客户端) ID” |
|
||||
| `TENANT_ID` | 是 | 租户 ID | 在应用注册概览页复制“目录(租户) ID” |
|
||||
| `CLIENT_SECRET` | 是 | 应用客户端密钥值 | 在“证书和密码”中新建客户端密码后复制 `Value` |
|
||||
| `DEFAULT_PASSWORD` | 是 | 新建账号或重置密码时的默认密码 | 由你自行定义,建议使用高强度临时密码 |
|
||||
| `DEFAULT_DOMAIN` | 建议 | 用户名自动补全时使用的默认域名 | 例如 `yourtenant.onmicrosoft.com` 或已验证自定义域名 |
|
||||
| `DEFAULT_USAGE_LOCATION` | 建议 | 账号默认使用地区 | 国际版常见示例:`US`、`SG`、`JP` |
|
||||
| `DEFAULT_LICENSE_SKU` | 可选 | 创建用户时默认分配的许可证 SKU | 例如 `ENTERPRISEPACK`、`M365_BUSINESS_PREMIUM` |
|
||||
| `WEB_AUTH_ENABLED` | 可选 | 是否启用平台网页登录保护 | `true` 或 `false` |
|
||||
| `ADMIN_USERNAME` | 建议 | 本平台网页登录用户名 | 例如 `admin` |
|
||||
| `ADMIN_PASSWORD` | 建议 | 本平台网页登录密码 | 由你自行定义;这不是 Microsoft 365 账号密码 |
|
||||
| `SESSION_SECRET` | 建议 | Flask 会话密钥 | 建议使用随机长字符串 |
|
||||
| `HOST` | 可选 | Web 服务监听地址 | 默认 `0.0.0.0` |
|
||||
| `PORT` | 可选 | Web 服务监听端口 | 默认 `8000` |
|
||||
| `DEBUG` | 可选 | 是否启用调试模式 | 默认 `false` |
|
||||
|
||||
说明:
|
||||
|
||||
- `DEFAULT_LICENSE_SKU` 需要填写租户里实际存在的 SKU Part Number;如果不确定,可以先启动系统,在“许可证概览”里查看。
|
||||
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` 是这个管理平台自己的登录账号,不是 Microsoft 365 管理员邮箱账号。
|
||||
- 如果不填写 `DEFAULT_DOMAIN`,创建用户时必须填写完整邮箱格式的 `userPrincipalName`。
|
||||
|
||||
### Entra ID 应用注册配置
|
||||
|
||||
在使用本工具之前,需要在 Microsoft Entra ID(Azure AD)中注册应用程序并配置权限。
|
||||
|
||||
#### 步骤 1:创建应用注册
|
||||
|
||||
1. 登录 Microsoft Entra 管理中心
|
||||
2. 导航到 `身份` -> `应用注册`
|
||||
3. 点击 `+ 新建注册`
|
||||
4. 填写应用信息:
|
||||
- 名称:输入应用名称,例如 `Office 365 用户管理工具`
|
||||
- 支持的帐户类型:选择“仅此组织目录中的帐户”
|
||||
- 重定向 URI:无需填写,本项目使用客户端凭据流程
|
||||
5. 点击 `注册`
|
||||
|
||||
#### 步骤 2:获取应用信息
|
||||
|
||||
注册完成后,在应用概览页面可以获取:
|
||||
|
||||
- 应用程序(客户端) ID:复制此值作为 `CLIENT_ID`
|
||||
- 目录(租户) ID:复制此值作为 `TENANT_ID`
|
||||
|
||||
#### 步骤 3:创建客户端密钥
|
||||
|
||||
1. 在应用注册页面,导航到 `证书和密码`
|
||||
2. 点击 `+ 新建客户端密码`
|
||||
3. 填写描述和过期时间
|
||||
4. 点击 `添加`
|
||||
5. 重要:立即复制密钥值,它只会显示一次;将其填写为 `CLIENT_SECRET`
|
||||
|
||||
#### 步骤 4:配置 API 权限
|
||||
|
||||
1. 在应用注册页面,导航到 `API 权限`
|
||||
2. 点击 `+ 添加权限` -> `Microsoft Graph` -> `应用程序权限`
|
||||
|
||||
必需权限列表:
|
||||
|
||||
| 权限名称 | 说明 | 是否必需 |
|
||||
| --- | --- | --- |
|
||||
| `User.ReadWrite.All` | 读取和写入所有用户配置文件 | 必需,用于创建、更新、删除用户 |
|
||||
| `User-PasswordProfile.ReadWrite.All` | 读取和写入所有用户的密码配置文件 | 必需,用于重置密码 |
|
||||
| `LicenseAssignment.ReadWrite.All` | 读取和写入所有许可证分配 | 必需,用于分配许可证 |
|
||||
| `Directory.ReadWrite.All` | 读取和写入目录数据 | 可选,更高权限,可替代部分用户写入权限 |
|
||||
| `AuditLog.Read.All` | 读取审核日志数据 | 可选,用于后续扩展登录活动或审计场景 |
|
||||
|
||||
权限配置步骤:
|
||||
|
||||
1. 搜索并选择每个权限
|
||||
2. 点击 `添加权限`
|
||||
3. 添加完成后,点击 `为 [你的组织名称] 授予管理员同意`
|
||||
4. 确认授予同意
|
||||
|
||||
重要提示:
|
||||
|
||||
- 所有应用程序权限都需要管理员同意才能生效。
|
||||
- 确保你是该应用程序的 `所有者`。
|
||||
- 权限生效可能需要几分钟。
|
||||
- 如果权限 Owner 发生变化,或者管理员重新授予了同意,建议重启本项目服务以重新获取访问令牌。
|
||||
|
||||
#### 步骤 5:验证权限配置
|
||||
|
||||
在 `API 权限` 页面,确认所有需要的权限状态都显示为“已授予(管理员同意)”。
|
||||
|
||||
### Graph 端点
|
||||
|
||||
项目固定使用国际版端点:
|
||||
|
||||
- Graph API: `https://graph.microsoft.com/v1.0`
|
||||
- Token Endpoint: `https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token`
|
||||
- Scope: `https://graph.microsoft.com/.default`
|
||||
|
||||
### 平台登录
|
||||
|
||||
如果你希望平台访问前需要登录,请配置:
|
||||
|
||||
```env
|
||||
WEB_AUTH_ENABLED=true
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=ChangeMe123!
|
||||
SESSION_SECRET=random-long-secret
|
||||
```
|
||||
|
||||
如果 `WEB_AUTH_ENABLED=true` 但没有设置用户名密码,系统会自动降级为无登录保护模式,并在页面状态区显示提醒。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
## 日志
|
||||
|
||||
服务运行后会把详细操作日志写入:
|
||||
|
||||
- [logs/office365_admin.log](/Users/youbin/Desktop/Office365UserManage/logs/office365_admin.log)
|
||||
|
||||
## 说明
|
||||
|
||||
- `reference-office365-tools` 是我拉下来的参考仓库,当前运行时不会直接引用它。
|
||||
- Web 平台的 Graph 调用逻辑已经按后台服务方式重构,便于继续扩展审批流、组织架构、日志审计等能力。
|
||||
12
app.py
Normal file
12
app.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from office365_admin import create_app
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(
|
||||
host=app.config["SETTINGS"].host,
|
||||
port=app.config["SETTINGS"].port,
|
||||
debug=app.config["SETTINGS"].debug,
|
||||
)
|
||||
|
||||
69
office365_admin/__init__.py
Normal file
69
office365_admin/__init__.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from .services import Office365Service
|
||||
from .settings import Settings, load_settings
|
||||
from .tasks import BackgroundTaskManager
|
||||
|
||||
|
||||
def _configure_logging(app: Flask) -> None:
|
||||
log_dir = Path(app.root_path).parent / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_dir / "office365_admin.log"
|
||||
|
||||
root_logger = logging.getLogger()
|
||||
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s - %(message)s")
|
||||
|
||||
if not any(isinstance(handler, RotatingFileHandler) for handler in root_logger.handlers):
|
||||
file_handler = RotatingFileHandler(
|
||||
log_path,
|
||||
maxBytes=2 * 1024 * 1024,
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
root_logger.setLevel(logging.INFO)
|
||||
app.logger.info("Logging initialized at %s", log_path)
|
||||
|
||||
|
||||
def create_app(
|
||||
settings_override: Settings | None = None,
|
||||
service_factory=None,
|
||||
task_manager_factory=None,
|
||||
) -> Flask:
|
||||
settings = settings_override or load_settings()
|
||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||
app.config["SETTINGS"] = settings
|
||||
app.config["JSON_AS_ASCII"] = False
|
||||
app.secret_key = settings.session_secret
|
||||
app.permanent_session_lifetime = timedelta(hours=8)
|
||||
_configure_logging(app)
|
||||
|
||||
if service_factory is None:
|
||||
service = Office365Service(settings)
|
||||
elif callable(service_factory):
|
||||
service = service_factory(settings)
|
||||
else:
|
||||
service = service_factory
|
||||
|
||||
app.extensions["office365_service"] = service
|
||||
if task_manager_factory is None:
|
||||
task_manager = BackgroundTaskManager()
|
||||
elif callable(task_manager_factory):
|
||||
task_manager = task_manager_factory()
|
||||
else:
|
||||
task_manager = task_manager_factory
|
||||
app.extensions["task_manager"] = task_manager
|
||||
|
||||
from .routes import bp
|
||||
|
||||
app.register_blueprint(bp)
|
||||
return app
|
||||
149
office365_admin/batch.py
Normal file
149
office365_admin/batch.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
IDENTIFIER_KEYS = (
|
||||
"userprincipalname",
|
||||
"user_id",
|
||||
"userid",
|
||||
"username",
|
||||
"email",
|
||||
"mail",
|
||||
"id",
|
||||
"upn",
|
||||
)
|
||||
|
||||
|
||||
class BatchInputError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _strip_bom(raw_text: str) -> str:
|
||||
return raw_text.lstrip("\ufeff").strip()
|
||||
|
||||
|
||||
def _clean_dict(row: dict[str, Any]) -> dict[str, Any]:
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, value in row.items():
|
||||
normalized_key = str(key).strip()
|
||||
if not normalized_key:
|
||||
continue
|
||||
cleaned[normalized_key] = value.strip() if isinstance(value, str) else value
|
||||
return cleaned
|
||||
|
||||
|
||||
def parse_table_content(raw_text: str) -> list[dict[str, Any]]:
|
||||
raw_text = _strip_bom(raw_text)
|
||||
if not raw_text:
|
||||
raise BatchInputError("批量内容不能为空。")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_text)
|
||||
except json.JSONDecodeError:
|
||||
payload = None
|
||||
|
||||
if payload is not None:
|
||||
if not isinstance(payload, list):
|
||||
raise BatchInputError("JSON 批量内容必须是数组。")
|
||||
rows: list[dict[str, Any]] = []
|
||||
for index, item in enumerate(payload, start=1):
|
||||
if not isinstance(item, dict):
|
||||
raise BatchInputError(f"第 {index} 条 JSON 记录不是对象。")
|
||||
rows.append(_clean_dict(item))
|
||||
if not rows:
|
||||
raise BatchInputError("批量内容中没有可用记录。")
|
||||
return rows
|
||||
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(raw_text))
|
||||
except csv.Error as exc:
|
||||
raise BatchInputError(f"CSV 解析失败: {exc}") from exc
|
||||
|
||||
if not reader.fieldnames:
|
||||
raise BatchInputError("CSV 缺少表头。")
|
||||
|
||||
rows = []
|
||||
for row in reader:
|
||||
if not row:
|
||||
continue
|
||||
if not any((value or "").strip() for value in row.values()):
|
||||
continue
|
||||
rows.append(_clean_dict(row))
|
||||
|
||||
if not rows:
|
||||
raise BatchInputError("未解析到任何批量记录。")
|
||||
return rows
|
||||
|
||||
|
||||
def parse_identifier_content(raw_text: str) -> list[str]:
|
||||
raw_text = _strip_bom(raw_text)
|
||||
if not raw_text:
|
||||
raise BatchInputError("批量内容不能为空。")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_text)
|
||||
except json.JSONDecodeError:
|
||||
payload = None
|
||||
|
||||
if payload is not None:
|
||||
if not isinstance(payload, list):
|
||||
raise BatchInputError("JSON 批量删除内容必须是数组。")
|
||||
values: list[str] = []
|
||||
for item in payload:
|
||||
if isinstance(item, str) and item.strip():
|
||||
values.append(item.strip())
|
||||
elif isinstance(item, dict):
|
||||
identifier = extract_identifier(item)
|
||||
if identifier:
|
||||
values.append(identifier)
|
||||
if not values:
|
||||
raise BatchInputError("JSON 内容中没有可用的账号标识。")
|
||||
return values
|
||||
|
||||
first_line = raw_text.splitlines()[0] if raw_text.splitlines() else ""
|
||||
if "," in first_line:
|
||||
try:
|
||||
reader = csv.DictReader(io.StringIO(raw_text))
|
||||
if reader.fieldnames:
|
||||
values = []
|
||||
for row in reader:
|
||||
identifier = extract_identifier(_clean_dict(row))
|
||||
if identifier:
|
||||
values.append(identifier)
|
||||
if values:
|
||||
return values
|
||||
except csv.Error:
|
||||
pass
|
||||
|
||||
raw_text = raw_text.replace(",", "\n")
|
||||
values = []
|
||||
for line in raw_text.splitlines():
|
||||
normalized = line.strip()
|
||||
if normalized and not normalized.startswith("#"):
|
||||
values.append(normalized)
|
||||
|
||||
if not values:
|
||||
raise BatchInputError("没有找到可用的账号标识。")
|
||||
return values
|
||||
|
||||
|
||||
def extract_identifier(row: dict[str, Any]) -> str:
|
||||
key_map = {normalize_key(key): value for key, value in row.items()}
|
||||
for key in IDENTIFIER_KEYS:
|
||||
value = key_map.get(normalize_key(key))
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
if value:
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_key(key: str) -> str:
|
||||
return "".join(ch for ch in key.lower() if ch.isalnum())
|
||||
|
||||
189
office365_admin/graph.py
Normal file
189
office365_admin/graph.py
Normal file
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
REQUEST_TIMEOUT = 30
|
||||
|
||||
|
||||
class TokenError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class GraphAPIError(RuntimeError):
|
||||
def __init__(self, message: str, status_code: int | None = None, response: Any = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
self.response = response
|
||||
|
||||
|
||||
class TokenManager:
|
||||
def __init__(self, client_id: str, client_secret: str, token_endpoint: str, scope: str):
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.token_endpoint = token_endpoint
|
||||
self.scope = scope
|
||||
self._token = ""
|
||||
self._expires_at = 0
|
||||
|
||||
def get_access_token(self) -> str:
|
||||
now = int(time.time())
|
||||
if self._token and now < self._expires_at:
|
||||
return self._token
|
||||
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"scope": self.scope,
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
self.token_endpoint,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
message = f"获取访问令牌失败: {exc}"
|
||||
raise TokenError(message) from exc
|
||||
|
||||
payload = response.json()
|
||||
token = payload.get("access_token")
|
||||
if not token:
|
||||
raise TokenError("访问令牌响应缺少 access_token。")
|
||||
|
||||
expires_in = int(payload.get("expires_in", 3600))
|
||||
self._token = token
|
||||
self._expires_at = now + max(expires_in - 300, 60)
|
||||
return token
|
||||
|
||||
|
||||
class GraphClient:
|
||||
def __init__(self, token_manager: TokenManager, base_url: str):
|
||||
self.token_manager = token_manager
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
|
||||
def _headers(self, extra_headers: dict[str, str] | None = None) -> dict[str, str]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.token_manager.get_access_token()}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
return headers
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
json_data: dict[str, Any] | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
absolute_url: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
url = endpoint if absolute_url else f"{self.base_url}/{endpoint.lstrip('/')}"
|
||||
try:
|
||||
response = self.session.request(
|
||||
method=method.upper(),
|
||||
url=url,
|
||||
params=params,
|
||||
json=json_data,
|
||||
headers=self._headers(extra_headers),
|
||||
timeout=REQUEST_TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise GraphAPIError(f"调用 Microsoft Graph 失败: {exc}") from exc
|
||||
|
||||
if response.status_code == 204:
|
||||
return {}
|
||||
|
||||
if not response.ok:
|
||||
details: Any
|
||||
try:
|
||||
details = response.json()
|
||||
except ValueError:
|
||||
details = {"error": response.text}
|
||||
message = self._extract_error_message(details) or f"Graph API 返回 HTTP {response.status_code}"
|
||||
raise GraphAPIError(message, status_code=response.status_code, response=details)
|
||||
|
||||
if not response.content:
|
||||
return {}
|
||||
return response.json()
|
||||
|
||||
@staticmethod
|
||||
def _extract_error_message(details: Any) -> str:
|
||||
if isinstance(details, dict):
|
||||
graph_error = details.get("error")
|
||||
if isinstance(graph_error, dict):
|
||||
return str(graph_error.get("message") or graph_error.get("code") or "")
|
||||
if graph_error:
|
||||
return str(graph_error)
|
||||
if "message" in details:
|
||||
return str(details["message"])
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _quote_identifier(identifier: str) -> str:
|
||||
return quote(str(identifier), safe="@._-$")
|
||||
|
||||
def create_user(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._request("POST", "/users", json_data=payload)
|
||||
|
||||
def get_user(self, identifier: str, select_fields: list[str] | None = None) -> dict[str, Any]:
|
||||
params = {}
|
||||
if select_fields:
|
||||
params["$select"] = ",".join(select_fields)
|
||||
return self._request("GET", f"/users/{self._quote_identifier(identifier)}", params=params or None)
|
||||
|
||||
def update_user(self, identifier: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return self._request("PATCH", f"/users/{self._quote_identifier(identifier)}", json_data=payload)
|
||||
|
||||
def delete_user(self, identifier: str) -> None:
|
||||
self._request("DELETE", f"/users/{self._quote_identifier(identifier)}")
|
||||
|
||||
def list_users(self, select_fields: list[str] | None = None) -> list[dict[str, Any]]:
|
||||
params = {}
|
||||
if select_fields:
|
||||
params["$select"] = ",".join(select_fields)
|
||||
|
||||
response = self._request("GET", "/users", params=params or None)
|
||||
users = list(response.get("value", []))
|
||||
next_link = response.get("@odata.nextLink")
|
||||
|
||||
while next_link:
|
||||
response = self._request("GET", next_link, absolute_url=True)
|
||||
users.extend(response.get("value", []))
|
||||
next_link = response.get("@odata.nextLink")
|
||||
|
||||
return users
|
||||
|
||||
def list_subscribed_skus(self) -> list[dict[str, Any]]:
|
||||
return self._request("GET", "/subscribedSkus").get("value", [])
|
||||
|
||||
def assign_license(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
add_licenses: list[dict[str, Any]],
|
||||
remove_licenses: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"addLicenses": add_licenses,
|
||||
"removeLicenses": remove_licenses or [],
|
||||
}
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/users/{self._quote_identifier(user_id)}/assignLicense",
|
||||
json_data=payload,
|
||||
)
|
||||
|
||||
291
office365_admin/routes.py
Normal file
291
office365_admin/routes.py
Normal file
@@ -0,0 +1,291 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request, session
|
||||
|
||||
from .batch import BatchInputError, parse_identifier_content, parse_table_content
|
||||
from .services import Office365Service, ServiceConfigurationError, ServiceOperationError
|
||||
from .tasks import BackgroundTaskManager, TaskNotFoundError
|
||||
|
||||
|
||||
bp = Blueprint("office365_admin", __name__)
|
||||
logger = logging.getLogger("office365_admin.routes")
|
||||
|
||||
|
||||
def _settings():
|
||||
return current_app.config["SETTINGS"]
|
||||
|
||||
|
||||
def _service() -> Office365Service:
|
||||
return current_app.extensions["office365_service"]
|
||||
|
||||
|
||||
def _task_manager() -> BackgroundTaskManager:
|
||||
return current_app.extensions["task_manager"]
|
||||
|
||||
|
||||
def _success(data=None, message: str = "ok", status: int = 200):
|
||||
return jsonify({"success": True, "message": message, "data": data}), status
|
||||
|
||||
|
||||
def _error(message: str, status: int = 400, details=None):
|
||||
payload = {"success": False, "message": message}
|
||||
if details is not None:
|
||||
payload["details"] = details
|
||||
return jsonify(payload), status
|
||||
|
||||
|
||||
def _authenticated() -> bool:
|
||||
settings = _settings()
|
||||
if not settings.effective_auth_enabled:
|
||||
return True
|
||||
return bool(session.get("authenticated"))
|
||||
|
||||
|
||||
def require_auth(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not _authenticated():
|
||||
return _error("请先登录后台管理平台。", status=401)
|
||||
return view_func(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _handle_service_call(callback):
|
||||
try:
|
||||
return callback()
|
||||
except BatchInputError as exc:
|
||||
return _error(str(exc), status=400)
|
||||
except ServiceConfigurationError as exc:
|
||||
return _error(str(exc), status=503)
|
||||
except ServiceOperationError as exc:
|
||||
return _error(exc.message, status=exc.status_code, details=exc.details)
|
||||
except ValueError as exc:
|
||||
return _error(str(exc), status=400)
|
||||
|
||||
|
||||
def _json_payload() -> dict:
|
||||
return request.get_json(silent=True) or {}
|
||||
|
||||
|
||||
def _text_payload() -> str:
|
||||
payload = _json_payload()
|
||||
if payload.get("content"):
|
||||
return str(payload["content"])
|
||||
|
||||
if request.form.get("content"):
|
||||
return request.form["content"]
|
||||
|
||||
uploaded_file = request.files.get("file")
|
||||
if uploaded_file and uploaded_file.filename:
|
||||
return uploaded_file.read().decode("utf-8-sig")
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
def index():
|
||||
return render_template("index.html", bootstrap=_settings().to_public_dict())
|
||||
|
||||
|
||||
@bp.get("/api/health")
|
||||
def health():
|
||||
settings = _settings()
|
||||
return _success(
|
||||
{
|
||||
"platform": settings.to_public_dict(),
|
||||
"authenticated": _authenticated(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/api/session")
|
||||
def session_info():
|
||||
return _success(
|
||||
{
|
||||
"authenticated": _authenticated(),
|
||||
"authEnabled": _settings().effective_auth_enabled,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/api/login")
|
||||
def login():
|
||||
settings = _settings()
|
||||
if not settings.effective_auth_enabled:
|
||||
session["authenticated"] = True
|
||||
session.permanent = True
|
||||
return _success({"authenticated": True}, message="当前平台未启用登录保护。")
|
||||
|
||||
payload = _json_payload()
|
||||
username = str(payload.get("username", "")).strip()
|
||||
password = str(payload.get("password", "")).strip()
|
||||
|
||||
if username == settings.admin_username and password == settings.admin_password:
|
||||
session["authenticated"] = True
|
||||
session.permanent = True
|
||||
return _success({"authenticated": True}, message="登录成功。")
|
||||
return _error("用户名或密码错误。", status=401)
|
||||
|
||||
|
||||
@bp.post("/api/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return _success({"authenticated": False}, message="已退出登录。")
|
||||
|
||||
|
||||
@bp.get("/api/config")
|
||||
@require_auth
|
||||
def config_info():
|
||||
return _success(_settings().to_public_dict())
|
||||
|
||||
|
||||
@bp.get("/api/tasks/<task_id>")
|
||||
@require_auth
|
||||
def task_status(task_id: str):
|
||||
try:
|
||||
return _success(_task_manager().get_task(task_id))
|
||||
except TaskNotFoundError:
|
||||
return _error("任务不存在。", status=404)
|
||||
|
||||
|
||||
@bp.get("/api/licenses")
|
||||
@require_auth
|
||||
def licenses():
|
||||
return _handle_service_call(lambda: _success(_service().list_licenses()))
|
||||
|
||||
|
||||
@bp.get("/api/users")
|
||||
@require_auth
|
||||
def list_users():
|
||||
search = request.args.get("search", "").strip()
|
||||
try:
|
||||
page = int(request.args.get("page", "1"))
|
||||
page_size = int(request.args.get("pageSize", str(_settings().default_page_size)))
|
||||
except ValueError:
|
||||
return _error("page 和 pageSize 必须是整数。", status=400)
|
||||
|
||||
return _handle_service_call(
|
||||
lambda: _success(_service().list_users(search=search, page=page, page_size=page_size))
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/api/users/selection")
|
||||
@require_auth
|
||||
def list_user_identifiers():
|
||||
search = request.args.get("search", "").strip()
|
||||
return _handle_service_call(
|
||||
lambda: _success(_service().list_user_identifiers(search=search))
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/api/users/<path:identifier>")
|
||||
@require_auth
|
||||
def get_user(identifier: str):
|
||||
return _handle_service_call(lambda: _success(_service().get_user(identifier)))
|
||||
|
||||
|
||||
@bp.post("/api/users")
|
||||
@require_auth
|
||||
def create_user():
|
||||
payload = _json_payload()
|
||||
return _handle_service_call(
|
||||
lambda: _success(_service().create_user(payload), message="用户创建成功。", status=201)
|
||||
)
|
||||
|
||||
|
||||
@bp.patch("/api/users/<path:identifier>")
|
||||
@require_auth
|
||||
def update_user(identifier: str):
|
||||
payload = _json_payload()
|
||||
return _handle_service_call(
|
||||
lambda: _success(_service().update_user(identifier, payload), message="用户更新成功。")
|
||||
)
|
||||
|
||||
|
||||
@bp.delete("/api/users/<path:identifier>")
|
||||
@require_auth
|
||||
def delete_user(identifier: str):
|
||||
return _handle_service_call(
|
||||
lambda: _success(_service().delete_user(identifier), message="用户删除成功。")
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/api/users/<path:identifier>/reset-password")
|
||||
@require_auth
|
||||
def reset_password(identifier: str):
|
||||
payload = _json_payload()
|
||||
return _handle_service_call(
|
||||
lambda: _success(_service().reset_password(identifier, payload), message="密码重置成功。")
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/api/users/batch/create")
|
||||
@require_auth
|
||||
def batch_create():
|
||||
payload = _json_payload()
|
||||
rows = payload.get("rows")
|
||||
if rows is None:
|
||||
rows = parse_table_content(_text_payload())
|
||||
return _handle_service_call(lambda: _submit_batch_task("create", rows))
|
||||
|
||||
|
||||
@bp.post("/api/users/batch/update")
|
||||
@require_auth
|
||||
def batch_update():
|
||||
payload = _json_payload()
|
||||
rows = payload.get("rows")
|
||||
if rows is None:
|
||||
rows = parse_table_content(_text_payload())
|
||||
return _handle_service_call(lambda: _submit_batch_task("update", rows))
|
||||
|
||||
|
||||
@bp.post("/api/users/batch/delete")
|
||||
@require_auth
|
||||
def batch_delete():
|
||||
payload = _json_payload()
|
||||
identifiers = payload.get("identifiers")
|
||||
if identifiers is None:
|
||||
identifiers = parse_identifier_content(_text_payload())
|
||||
return _handle_service_call(lambda: _submit_batch_task("delete", identifiers))
|
||||
|
||||
|
||||
@bp.post("/api/users/batch/reset-password")
|
||||
@require_auth
|
||||
def batch_reset_password():
|
||||
payload = _json_payload()
|
||||
rows = payload.get("rows")
|
||||
if rows is None:
|
||||
text = _text_payload()
|
||||
try:
|
||||
rows = parse_table_content(text)
|
||||
except BatchInputError:
|
||||
rows = parse_identifier_content(text)
|
||||
return _handle_service_call(lambda: _submit_batch_task("reset-password", rows))
|
||||
|
||||
|
||||
def _submit_batch_task(operation: str, items):
|
||||
service = _service()
|
||||
task_manager = _task_manager()
|
||||
total = len(items)
|
||||
if operation == "create":
|
||||
runner = lambda progress: service.batch_create(items, progress_callback=progress)
|
||||
message = "批量创建任务已提交。"
|
||||
elif operation == "update":
|
||||
runner = lambda progress: service.batch_update(items, progress_callback=progress)
|
||||
message = "批量更新任务已提交。"
|
||||
elif operation == "delete":
|
||||
runner = lambda progress: service.batch_delete(items, progress_callback=progress)
|
||||
message = "批量删除任务已提交。"
|
||||
elif operation == "reset-password":
|
||||
runner = lambda progress: service.batch_reset_password(items, progress_callback=progress)
|
||||
message = "批量重置密码任务已提交。"
|
||||
else:
|
||||
raise ValueError("不支持的批量任务类型。")
|
||||
|
||||
logger.info("Submitting batch task: operation=%s total=%s", operation, total)
|
||||
task = task_manager.submit(operation=operation, total=total, runner=runner)
|
||||
return _success(task, message=message, status=202)
|
||||
597
office365_admin/services.py
Normal file
597
office365_admin/services.py
Normal file
@@ -0,0 +1,597 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from .graph import GraphAPIError, GraphClient, TokenManager
|
||||
from .settings import Settings
|
||||
|
||||
|
||||
logger = logging.getLogger("office365_admin.service")
|
||||
|
||||
|
||||
USER_SELECT_FIELDS = [
|
||||
"id",
|
||||
"displayName",
|
||||
"userPrincipalName",
|
||||
"mail",
|
||||
"givenName",
|
||||
"surname",
|
||||
"department",
|
||||
"jobTitle",
|
||||
"officeLocation",
|
||||
"mobilePhone",
|
||||
"usageLocation",
|
||||
"accountEnabled",
|
||||
"assignedLicenses",
|
||||
"createdDateTime",
|
||||
]
|
||||
|
||||
|
||||
IDENTIFIER_ALIASES = [
|
||||
"userPrincipalName",
|
||||
"user_principal_name",
|
||||
"user_id",
|
||||
"userId",
|
||||
"username",
|
||||
"email",
|
||||
"mail",
|
||||
"upn",
|
||||
"id",
|
||||
]
|
||||
|
||||
|
||||
OPTIONAL_FIELD_ALIASES = {
|
||||
"displayName": ["displayName", "display_name"],
|
||||
"mailNickname": ["mailNickname", "mail_nickname", "nickname"],
|
||||
"givenName": ["givenName", "given_name", "firstName", "firstname"],
|
||||
"surname": ["surname", "lastName", "lastname", "last_name"],
|
||||
"department": ["department"],
|
||||
"jobTitle": ["jobTitle", "job_title"],
|
||||
"officeLocation": ["officeLocation", "office_location"],
|
||||
"mobilePhone": ["mobilePhone", "mobile", "phone"],
|
||||
"usageLocation": ["usageLocation", "usage_location"],
|
||||
"userPrincipalName": ["userPrincipalName", "user_principal_name", "upn"],
|
||||
}
|
||||
|
||||
|
||||
NULLABLE_FIELDS = {
|
||||
"displayName",
|
||||
"givenName",
|
||||
"surname",
|
||||
"department",
|
||||
"jobTitle",
|
||||
"officeLocation",
|
||||
"mobilePhone",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceOperationError(RuntimeError):
|
||||
message: str
|
||||
status_code: int = 400
|
||||
details: Any = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.message
|
||||
|
||||
|
||||
class ServiceConfigurationError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class Office365Service:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
self._graph_client: GraphClient | None = None
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return {
|
||||
"ready": self.settings.graph_ready,
|
||||
"validationErrors": list(self.settings.validation_errors),
|
||||
"warnings": list(self.settings.warnings),
|
||||
"graphFlavor": "Microsoft Graph Global",
|
||||
}
|
||||
|
||||
def _ensure_client(self) -> GraphClient:
|
||||
if not self.settings.graph_ready:
|
||||
joined = ";".join(self.settings.validation_errors)
|
||||
raise ServiceConfigurationError(f"Graph 配置不完整: {joined}")
|
||||
|
||||
if self._graph_client is None:
|
||||
token_manager = TokenManager(
|
||||
client_id=self.settings.client_id,
|
||||
client_secret=self.settings.client_secret,
|
||||
token_endpoint=self.settings.token_endpoint,
|
||||
scope=self.settings.scope,
|
||||
)
|
||||
self._graph_client = GraphClient(token_manager, self.settings.graph_base_url)
|
||||
return self._graph_client
|
||||
|
||||
def list_licenses(self) -> list[dict[str, Any]]:
|
||||
client = self._ensure_client()
|
||||
try:
|
||||
skus = client.list_subscribed_skus()
|
||||
except GraphAPIError as exc:
|
||||
raise self._translate_graph_error(exc, "读取许可证列表失败")
|
||||
|
||||
items = []
|
||||
for sku in skus:
|
||||
total = int(sku.get("prepaidUnits", {}).get("enabled", 0) or 0)
|
||||
consumed = int(sku.get("consumedUnits", 0) or 0)
|
||||
items.append(
|
||||
{
|
||||
"skuId": sku.get("skuId"),
|
||||
"skuPartNumber": sku.get("skuPartNumber"),
|
||||
"availableUnits": max(total - consumed, 0),
|
||||
"totalUnits": total,
|
||||
"consumedUnits": consumed,
|
||||
}
|
||||
)
|
||||
return sorted(items, key=lambda item: item["skuPartNumber"] or "")
|
||||
|
||||
def list_users(self, search: str = "", page: int = 1, page_size: int | None = None) -> dict[str, Any]:
|
||||
requested_page_size = page_size or self.settings.default_page_size
|
||||
requested_page_size = min(max(requested_page_size, 1), self.settings.max_page_size)
|
||||
page = max(page, 1)
|
||||
users, total_before_search = self._list_filtered_users(search)
|
||||
|
||||
total = len(users)
|
||||
start = (page - 1) * requested_page_size
|
||||
end = start + requested_page_size
|
||||
paged_users = users[start:end]
|
||||
|
||||
return {
|
||||
"items": paged_users,
|
||||
"page": page,
|
||||
"pageSize": requested_page_size,
|
||||
"total": total,
|
||||
"totalBeforeSearch": total_before_search,
|
||||
"summary": {
|
||||
"active": sum(1 for user in users if user["accountEnabled"]),
|
||||
"disabled": sum(1 for user in users if not user["accountEnabled"]),
|
||||
},
|
||||
}
|
||||
|
||||
def list_user_identifiers(self, search: str = "") -> dict[str, Any]:
|
||||
users, _ = self._list_filtered_users(search)
|
||||
identifiers = [
|
||||
user["userPrincipalName"]
|
||||
for user in users
|
||||
if user.get("userPrincipalName")
|
||||
]
|
||||
return {
|
||||
"identifiers": identifiers,
|
||||
"total": len(identifiers),
|
||||
}
|
||||
|
||||
def get_user(self, identifier: str) -> dict[str, Any]:
|
||||
client = self._ensure_client()
|
||||
identifier = self._normalize_identifier(identifier)
|
||||
sku_lookup = self._get_sku_lookup()
|
||||
|
||||
try:
|
||||
user = client.get_user(identifier, USER_SELECT_FIELDS)
|
||||
except GraphAPIError as exc:
|
||||
raise self._translate_graph_error(exc, f"读取用户 {identifier} 失败")
|
||||
|
||||
serialized = self._serialize_user(user, sku_lookup=sku_lookup)
|
||||
serialized["licenses"] = [
|
||||
{
|
||||
"skuId": sku_id,
|
||||
"skuPartNumber": sku_lookup.get(sku_id, sku_id),
|
||||
}
|
||||
for sku_id in serialized["assignedLicenses"]
|
||||
]
|
||||
return serialized
|
||||
|
||||
def create_user(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
client = self._ensure_client()
|
||||
identifier = self._resolve_identifier(payload, required=True)
|
||||
upn = self._normalize_identifier(identifier)
|
||||
username = upn.split("@", 1)[0]
|
||||
|
||||
password = self._string_value(payload, ["password"]) or self.settings.default_password
|
||||
force_change_password = self._bool_value(
|
||||
payload,
|
||||
["forceChangePasswordNextSignIn", "force_change_password"],
|
||||
self.settings.force_change_password,
|
||||
)
|
||||
account_enabled = self._bool_value(payload, ["accountEnabled", "enabled"], True)
|
||||
|
||||
create_payload = {
|
||||
"accountEnabled": account_enabled,
|
||||
"displayName": self._string_value(payload, OPTIONAL_FIELD_ALIASES["displayName"]) or username,
|
||||
"mailNickname": self._string_value(payload, OPTIONAL_FIELD_ALIASES["mailNickname"]) or username,
|
||||
"userPrincipalName": upn,
|
||||
"passwordProfile": {
|
||||
"password": password,
|
||||
"forceChangePasswordNextSignIn": force_change_password,
|
||||
},
|
||||
}
|
||||
|
||||
for graph_field, aliases in OPTIONAL_FIELD_ALIASES.items():
|
||||
if graph_field in {"displayName", "mailNickname", "userPrincipalName"}:
|
||||
continue
|
||||
value = self._string_value(payload, aliases)
|
||||
if value:
|
||||
create_payload[graph_field] = value
|
||||
|
||||
if "usageLocation" not in create_payload:
|
||||
create_payload["usageLocation"] = self.settings.default_usage_location
|
||||
|
||||
try:
|
||||
user = client.create_user(create_payload)
|
||||
except GraphAPIError as exc:
|
||||
raise self._translate_graph_error(exc, f"创建用户 {upn} 失败")
|
||||
|
||||
license_result = None
|
||||
sku_part_number = self._string_value(payload, ["skuPartNumber", "sku", "license"]) or self.settings.default_license_sku
|
||||
if sku_part_number:
|
||||
license_result = self._assign_license(user["id"], sku_part_number)
|
||||
|
||||
return {
|
||||
"user": self.get_user(user["id"]),
|
||||
"temporaryPassword": password,
|
||||
"licenseAssigned": bool(license_result),
|
||||
"licenseResult": license_result,
|
||||
}
|
||||
|
||||
def update_user(
|
||||
self,
|
||||
identifier: str,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
blank_strategy: str = "clear",
|
||||
) -> dict[str, Any]:
|
||||
client = self._ensure_client()
|
||||
identifier = self._normalize_identifier(identifier)
|
||||
|
||||
patch_payload: dict[str, Any] = {}
|
||||
for graph_field, aliases in OPTIONAL_FIELD_ALIASES.items():
|
||||
value = self._raw_value(payload, aliases)
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
|
||||
if value == "" and graph_field in NULLABLE_FIELDS:
|
||||
if blank_strategy == "clear":
|
||||
patch_payload[graph_field] = None
|
||||
continue
|
||||
|
||||
if value != "":
|
||||
patch_payload[graph_field] = value
|
||||
|
||||
if self._raw_value(payload, ["accountEnabled", "enabled"]) is not None:
|
||||
patch_payload["accountEnabled"] = self._bool_value(payload, ["accountEnabled", "enabled"], True)
|
||||
|
||||
password = self._string_value(payload, ["password"])
|
||||
if password:
|
||||
patch_payload["passwordProfile"] = {
|
||||
"password": password,
|
||||
"forceChangePasswordNextSignIn": self._bool_value(
|
||||
payload,
|
||||
["forceChangePasswordNextSignIn", "force_change_password"],
|
||||
self.settings.force_change_password,
|
||||
),
|
||||
}
|
||||
|
||||
if patch_payload:
|
||||
try:
|
||||
client.update_user(identifier, patch_payload)
|
||||
except GraphAPIError as exc:
|
||||
raise self._translate_graph_error(exc, f"更新用户 {identifier} 失败")
|
||||
|
||||
license_result = None
|
||||
sku_part_number = self._string_value(payload, ["skuPartNumber", "sku", "license"])
|
||||
if sku_part_number:
|
||||
existing_user = self.get_user(identifier)
|
||||
license_result = self._assign_license(existing_user["id"], sku_part_number)
|
||||
|
||||
updated_identifier = patch_payload.get("userPrincipalName", identifier)
|
||||
return {
|
||||
"user": self.get_user(updated_identifier),
|
||||
"licenseAssigned": bool(license_result),
|
||||
"licenseResult": license_result,
|
||||
}
|
||||
|
||||
def delete_user(self, identifier: str) -> dict[str, Any]:
|
||||
client = self._ensure_client()
|
||||
identifier = self._normalize_identifier(identifier)
|
||||
try:
|
||||
user = self.get_user(identifier)
|
||||
client.delete_user(identifier)
|
||||
except GraphAPIError as exc:
|
||||
raise self._translate_graph_error(exc, f"删除用户 {identifier} 失败")
|
||||
return {"user": user}
|
||||
|
||||
def reset_password(self, identifier: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
client = self._ensure_client()
|
||||
identifier = self._normalize_identifier(identifier)
|
||||
payload = payload or {}
|
||||
password = self._string_value(payload, ["password"]) or self.settings.default_password
|
||||
force_change_password = self._bool_value(
|
||||
payload,
|
||||
["forceChangePasswordNextSignIn", "force_change_password"],
|
||||
self.settings.force_change_password,
|
||||
)
|
||||
reset_payload = {
|
||||
"passwordProfile": {
|
||||
"password": password,
|
||||
"forceChangePasswordNextSignIn": force_change_password,
|
||||
}
|
||||
}
|
||||
try:
|
||||
client.update_user(identifier, reset_payload)
|
||||
except GraphAPIError as exc:
|
||||
raise self._translate_graph_error(exc, f"重置用户 {identifier} 密码失败")
|
||||
return {
|
||||
"user": self.get_user(identifier),
|
||||
"temporaryPassword": password,
|
||||
}
|
||||
|
||||
def batch_create(self, rows: list[dict[str, Any]], progress_callback=None) -> dict[str, Any]:
|
||||
return self._run_batch(
|
||||
operation="create",
|
||||
items=rows,
|
||||
callback=lambda row: self.create_user(row),
|
||||
identifier_getter=lambda row: self._resolve_identifier(row, required=False),
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
def batch_update(self, rows: list[dict[str, Any]], progress_callback=None) -> dict[str, Any]:
|
||||
return self._run_batch(
|
||||
operation="update",
|
||||
items=rows,
|
||||
callback=lambda row: self.update_user(
|
||||
self._resolve_identifier(row, required=True),
|
||||
row,
|
||||
blank_strategy="ignore",
|
||||
),
|
||||
identifier_getter=lambda row: self._resolve_identifier(row, required=False),
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
def batch_delete(self, identifiers: list[str], progress_callback=None) -> dict[str, Any]:
|
||||
return self._run_batch(
|
||||
operation="delete",
|
||||
items=identifiers,
|
||||
callback=lambda identifier: self.delete_user(identifier),
|
||||
identifier_getter=lambda identifier: identifier,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
def batch_reset_password(self, rows: list[dict[str, Any]] | list[str], progress_callback=None) -> dict[str, Any]:
|
||||
return self._run_batch(
|
||||
operation="reset-password",
|
||||
items=rows,
|
||||
callback=self._batch_reset_callback,
|
||||
identifier_getter=self._batch_reset_identifier,
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
def _batch_reset_callback(self, item: dict[str, Any] | str) -> dict[str, Any]:
|
||||
if isinstance(item, str):
|
||||
return self.reset_password(item)
|
||||
identifier = self._resolve_identifier(item, required=True)
|
||||
return self.reset_password(identifier, item)
|
||||
|
||||
def _batch_reset_identifier(self, item: dict[str, Any] | str) -> str:
|
||||
if isinstance(item, str):
|
||||
return item
|
||||
return self._resolve_identifier(item, required=False)
|
||||
|
||||
def _run_batch(self, operation: str, items: list[Any], callback, identifier_getter, progress_callback=None) -> dict[str, Any]:
|
||||
results = []
|
||||
success_count = 0
|
||||
logger.info("Batch %s started: total=%s", operation, len(items))
|
||||
|
||||
for index, item in enumerate(items, start=1):
|
||||
identifier = identifier_getter(item) or f"item-{index}"
|
||||
try:
|
||||
result = callback(item)
|
||||
success_count += 1
|
||||
record = {
|
||||
"index": index,
|
||||
"identifier": identifier,
|
||||
"success": True,
|
||||
"message": "执行成功",
|
||||
"data": result,
|
||||
}
|
||||
logger.info("Batch %s item success: %s", operation, identifier)
|
||||
except (ServiceConfigurationError, ServiceOperationError, ValueError) as exc:
|
||||
record = {
|
||||
"index": index,
|
||||
"identifier": identifier,
|
||||
"success": False,
|
||||
"message": str(exc),
|
||||
}
|
||||
logger.warning("Batch %s item failed: %s - %s", operation, identifier, exc)
|
||||
except Exception as exc:
|
||||
record = {
|
||||
"index": index,
|
||||
"identifier": identifier,
|
||||
"success": False,
|
||||
"message": str(exc),
|
||||
}
|
||||
logger.exception("Batch %s item crashed: %s", operation, identifier)
|
||||
|
||||
results.append(record)
|
||||
if progress_callback:
|
||||
progress_callback(
|
||||
{
|
||||
"completed": index,
|
||||
"total": len(items),
|
||||
"successCount": success_count,
|
||||
"failureCount": index - success_count,
|
||||
"identifier": identifier,
|
||||
"success": record["success"],
|
||||
"message": record["message"],
|
||||
}
|
||||
)
|
||||
|
||||
summary = {
|
||||
"operation": operation,
|
||||
"total": len(items),
|
||||
"successCount": success_count,
|
||||
"failureCount": len(items) - success_count,
|
||||
"results": results,
|
||||
}
|
||||
logger.info(
|
||||
"Batch %s finished: total=%s success=%s failure=%s",
|
||||
operation,
|
||||
summary["total"],
|
||||
summary["successCount"],
|
||||
summary["failureCount"],
|
||||
)
|
||||
return summary
|
||||
|
||||
def _assign_license(self, user_id: str, sku_part_number: str) -> dict[str, Any]:
|
||||
client = self._ensure_client()
|
||||
skus = self.list_licenses()
|
||||
matched = next(
|
||||
(sku for sku in skus if (sku["skuPartNumber"] or "").upper() == sku_part_number.upper()),
|
||||
None,
|
||||
)
|
||||
if not matched:
|
||||
raise ServiceOperationError(f"未找到许可证 SKU: {sku_part_number}", status_code=404)
|
||||
if matched["availableUnits"] <= 0:
|
||||
raise ServiceOperationError(f"许可证 {sku_part_number} 已无可用席位。", status_code=409)
|
||||
|
||||
try:
|
||||
return client.assign_license(
|
||||
user_id,
|
||||
add_licenses=[{"skuId": matched["skuId"], "disabledPlans": []}],
|
||||
)
|
||||
except GraphAPIError as exc:
|
||||
raise self._translate_graph_error(exc, f"为用户分配许可证 {sku_part_number} 失败")
|
||||
|
||||
def _get_sku_lookup(self) -> dict[str, str]:
|
||||
return {
|
||||
item["skuId"]: item["skuPartNumber"]
|
||||
for item in self.list_licenses()
|
||||
if item.get("skuId")
|
||||
}
|
||||
|
||||
def _list_filtered_users(self, search: str = "") -> tuple[list[dict[str, Any]], int]:
|
||||
client = self._ensure_client()
|
||||
try:
|
||||
raw_users = client.list_users(USER_SELECT_FIELDS)
|
||||
except GraphAPIError as exc:
|
||||
raise self._translate_graph_error(exc, "读取用户列表失败")
|
||||
|
||||
users = [self._serialize_user(user) for user in raw_users]
|
||||
total_before_search = len(users)
|
||||
if search.strip():
|
||||
query = search.strip().lower()
|
||||
users = [
|
||||
user
|
||||
for user in users
|
||||
if any(
|
||||
query in str(user.get(field, "") or "").lower()
|
||||
for field in (
|
||||
"displayName",
|
||||
"userPrincipalName",
|
||||
"mail",
|
||||
"department",
|
||||
"jobTitle",
|
||||
"givenName",
|
||||
"surname",
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
users.sort(key=lambda item: (item["userPrincipalName"] or "").lower())
|
||||
return users, total_before_search
|
||||
|
||||
def _serialize_user(self, user: dict[str, Any], sku_lookup: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
assigned_license_ids = [
|
||||
item.get("skuId")
|
||||
for item in (user.get("assignedLicenses") or [])
|
||||
if item.get("skuId")
|
||||
]
|
||||
license_labels = [sku_lookup.get(item, item) for item in assigned_license_ids] if sku_lookup else []
|
||||
return {
|
||||
"id": user.get("id"),
|
||||
"displayName": user.get("displayName") or "",
|
||||
"userPrincipalName": user.get("userPrincipalName") or "",
|
||||
"mail": user.get("mail") or "",
|
||||
"givenName": user.get("givenName") or "",
|
||||
"surname": user.get("surname") or "",
|
||||
"department": user.get("department") or "",
|
||||
"jobTitle": user.get("jobTitle") or "",
|
||||
"officeLocation": user.get("officeLocation") or "",
|
||||
"mobilePhone": user.get("mobilePhone") or "",
|
||||
"usageLocation": user.get("usageLocation") or "",
|
||||
"accountEnabled": bool(user.get("accountEnabled", True)),
|
||||
"assignedLicenses": assigned_license_ids,
|
||||
"assignedLicensesCount": len(assigned_license_ids),
|
||||
"licenseLabels": license_labels,
|
||||
"createdDateTime": user.get("createdDateTime") or "",
|
||||
}
|
||||
|
||||
def _normalize_identifier(self, identifier: str) -> str:
|
||||
normalized = str(identifier).strip()
|
||||
if not normalized:
|
||||
raise ValueError("账号标识不能为空。")
|
||||
if "@" in normalized:
|
||||
return normalized
|
||||
if self.settings.default_domain:
|
||||
return f"{normalized}@{self.settings.default_domain}"
|
||||
return normalized
|
||||
|
||||
def _resolve_identifier(self, payload: dict[str, Any], required: bool = False) -> str:
|
||||
value = self._string_value(payload, IDENTIFIER_ALIASES)
|
||||
if value:
|
||||
return value
|
||||
if required:
|
||||
raise ValueError("缺少账号标识字段,至少需要 userPrincipalName / user_id / username / email 之一。")
|
||||
return ""
|
||||
|
||||
def _raw_value(self, payload: dict[str, Any], aliases: list[str]) -> Any:
|
||||
normalized_payload = {self._normalize_key(key): value for key, value in payload.items()}
|
||||
for alias in aliases:
|
||||
normalized_alias = self._normalize_key(alias)
|
||||
if normalized_alias in normalized_payload:
|
||||
return normalized_payload[normalized_alias]
|
||||
return None
|
||||
|
||||
def _string_value(self, payload: dict[str, Any], aliases: list[str]) -> str:
|
||||
value = self._raw_value(payload, aliases)
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return str(value).strip()
|
||||
|
||||
def _bool_value(self, payload: dict[str, Any], aliases: list[str], default: bool) -> bool:
|
||||
value = self._raw_value(payload, aliases)
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
normalized = str(value).strip().lower()
|
||||
if normalized in {"1", "true", "yes", "y", "enabled", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "n", "disabled", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _normalize_key(key: str) -> str:
|
||||
return "".join(ch for ch in str(key).lower() if ch.isalnum())
|
||||
|
||||
@staticmethod
|
||||
def _translate_graph_error(exc: GraphAPIError, fallback_message: str) -> ServiceOperationError:
|
||||
message = fallback_message
|
||||
if exc.message:
|
||||
message = f"{fallback_message}: {exc.message}"
|
||||
status_code = exc.status_code or 502
|
||||
lowered = message.lower()
|
||||
if "already exists" in lowered or "another object with the same value" in lowered:
|
||||
status_code = 409
|
||||
return ServiceOperationError(message=message, status_code=status_code, details=exc.response)
|
||||
131
office365_admin/settings.py
Normal file
131
office365_admin/settings.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0"
|
||||
GRAPH_SCOPE = "https://graph.microsoft.com/.default"
|
||||
TOKEN_ENDPOINT_TEMPLATE = "https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
return os.getenv(name, str(default)).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
return int(os.getenv(name, str(default)).strip())
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
app_name: str
|
||||
host: str
|
||||
port: int
|
||||
debug: bool
|
||||
session_secret: str
|
||||
auth_enabled: bool
|
||||
admin_username: str
|
||||
admin_password: str
|
||||
client_id: str
|
||||
tenant_id: str
|
||||
client_secret: str
|
||||
default_password: str
|
||||
default_domain: str
|
||||
default_usage_location: str
|
||||
default_license_sku: str
|
||||
force_change_password: bool
|
||||
graph_base_url: str
|
||||
token_endpoint: str
|
||||
scope: str
|
||||
default_page_size: int = 25
|
||||
max_page_size: int = 100
|
||||
validation_errors: tuple[str, ...] = field(default_factory=tuple)
|
||||
warnings: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
@property
|
||||
def graph_ready(self) -> bool:
|
||||
return not self.validation_errors
|
||||
|
||||
@property
|
||||
def effective_auth_enabled(self) -> bool:
|
||||
return self.auth_enabled and bool(self.admin_username and self.admin_password)
|
||||
|
||||
def to_public_dict(self) -> dict:
|
||||
return {
|
||||
"appName": self.app_name,
|
||||
"graphFlavor": "Microsoft Graph Global",
|
||||
"graphReady": self.graph_ready,
|
||||
"validationErrors": list(self.validation_errors),
|
||||
"warnings": list(self.warnings),
|
||||
"authEnabled": self.effective_auth_enabled,
|
||||
"defaultDomain": self.default_domain,
|
||||
"defaultUsageLocation": self.default_usage_location,
|
||||
"defaultLicenseSku": self.default_license_sku,
|
||||
"forceChangePassword": self.force_change_password,
|
||||
"pageSize": self.default_page_size,
|
||||
"maxPageSize": self.max_page_size,
|
||||
}
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
load_dotenv()
|
||||
|
||||
tenant_id = os.getenv("TENANT_ID", "").strip()
|
||||
graph_base_url = GRAPH_BASE_URL
|
||||
token_endpoint = TOKEN_ENDPOINT_TEMPLATE.format(tenant_id=tenant_id) if tenant_id else ""
|
||||
scope = GRAPH_SCOPE
|
||||
|
||||
validation_errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
required_fields = {
|
||||
"CLIENT_ID": os.getenv("CLIENT_ID", "").strip(),
|
||||
"TENANT_ID": tenant_id,
|
||||
"CLIENT_SECRET": os.getenv("CLIENT_SECRET", "").strip(),
|
||||
"DEFAULT_PASSWORD": os.getenv("DEFAULT_PASSWORD", "").strip(),
|
||||
}
|
||||
|
||||
for field_name, value in required_fields.items():
|
||||
if not value:
|
||||
validation_errors.append(f"{field_name} 未配置")
|
||||
|
||||
if not os.getenv("DEFAULT_DOMAIN", "").strip():
|
||||
warnings.append("DEFAULT_DOMAIN 未配置,创建账号时必须填写完整 userPrincipalName。")
|
||||
|
||||
auth_enabled = _env_bool("WEB_AUTH_ENABLED", True)
|
||||
admin_username = os.getenv("ADMIN_USERNAME", "").strip()
|
||||
admin_password = os.getenv("ADMIN_PASSWORD", "").strip()
|
||||
if auth_enabled and not (admin_username and admin_password):
|
||||
warnings.append("WEB_AUTH_ENABLED=true 但未配置后台登录账号,已自动退回为无登录保护模式。")
|
||||
|
||||
return Settings(
|
||||
app_name=os.getenv("APP_NAME", "Office 365 User Management Platform").strip(),
|
||||
host=os.getenv("HOST", "0.0.0.0").strip(),
|
||||
port=_env_int("PORT", 8000),
|
||||
debug=_env_bool("DEBUG", False),
|
||||
session_secret=os.getenv("SESSION_SECRET", "office365-admin-dev-secret").strip(),
|
||||
auth_enabled=auth_enabled,
|
||||
admin_username=admin_username,
|
||||
admin_password=admin_password,
|
||||
client_id=required_fields["CLIENT_ID"],
|
||||
tenant_id=required_fields["TENANT_ID"],
|
||||
client_secret=required_fields["CLIENT_SECRET"],
|
||||
default_password=required_fields["DEFAULT_PASSWORD"],
|
||||
default_domain=os.getenv("DEFAULT_DOMAIN", "").strip(),
|
||||
default_usage_location=os.getenv("DEFAULT_USAGE_LOCATION", "US").strip() or "US",
|
||||
default_license_sku=os.getenv("DEFAULT_LICENSE_SKU", "").strip(),
|
||||
force_change_password=_env_bool("FORCE_CHANGE_PASSWORD", True),
|
||||
graph_base_url=graph_base_url,
|
||||
token_endpoint=token_endpoint,
|
||||
scope=scope,
|
||||
default_page_size=min(max(_env_int("DEFAULT_PAGE_SIZE", 25), 1), 100),
|
||||
max_page_size=min(max(_env_int("MAX_PAGE_SIZE", 100), 10), 500),
|
||||
validation_errors=tuple(validation_errors),
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
839
office365_admin/static/app.js
Normal file
839
office365_admin/static/app.js
Normal file
@@ -0,0 +1,839 @@
|
||||
const state = {
|
||||
bootstrap: window.APP_BOOTSTRAP || {},
|
||||
authenticated: false,
|
||||
users: [],
|
||||
page: 1,
|
||||
pageSize: (window.APP_BOOTSTRAP && window.APP_BOOTSTRAP.pageSize) || 25,
|
||||
total: 0,
|
||||
totalBeforeSearch: 0,
|
||||
summary: { active: 0, disabled: 0 },
|
||||
search: "",
|
||||
licenses: [],
|
||||
selectedUser: null,
|
||||
selectedUsers: new Set(),
|
||||
activeTaskId: "",
|
||||
activeTaskTimer: null,
|
||||
};
|
||||
|
||||
const elements = {
|
||||
platformStatus: document.getElementById("platform-status"),
|
||||
platformSubstatus: document.getElementById("platform-substatus"),
|
||||
metricTotal: document.getElementById("metric-total"),
|
||||
metricActive: document.getElementById("metric-active"),
|
||||
metricDisabled: document.getElementById("metric-disabled"),
|
||||
metricLicense: document.getElementById("metric-license"),
|
||||
usersTableBody: document.getElementById("users-table-body"),
|
||||
paginationInfo: document.getElementById("pagination-info"),
|
||||
licenseList: document.getElementById("license-list"),
|
||||
resultConsole: document.getElementById("result-console"),
|
||||
loginSection: document.getElementById("login-section"),
|
||||
loginForm: document.getElementById("login-form"),
|
||||
logoutBtn: document.getElementById("logout-btn"),
|
||||
refreshAllBtn: document.getElementById("refresh-all-btn"),
|
||||
searchForm: document.getElementById("search-form"),
|
||||
searchInput: document.getElementById("search-input"),
|
||||
prevPageBtn: document.getElementById("prev-page-btn"),
|
||||
nextPageBtn: document.getElementById("next-page-btn"),
|
||||
userForm: document.getElementById("user-form"),
|
||||
selectedUserId: document.getElementById("selected-user-id"),
|
||||
deleteUserBtn: document.getElementById("delete-user-btn"),
|
||||
newUserBtn: document.getElementById("new-user-btn"),
|
||||
clearFormBtn: document.getElementById("clear-form-btn"),
|
||||
resetPasswordBtn: document.getElementById("reset-password-btn"),
|
||||
selectedCount: document.getElementById("selected-count"),
|
||||
selectAllResultsBtn: document.getElementById("select-all-results-btn"),
|
||||
clearSelectionBtn: document.getElementById("clear-selection-btn"),
|
||||
bulkEnableBtn: document.getElementById("bulk-enable-btn"),
|
||||
bulkDisableBtn: document.getElementById("bulk-disable-btn"),
|
||||
bulkResetBtn: document.getElementById("bulk-reset-btn"),
|
||||
bulkDeleteBtn: document.getElementById("bulk-delete-btn"),
|
||||
selectPageCheckbox: document.getElementById("select-page-checkbox"),
|
||||
taskStatusCard: document.getElementById("task-status-card"),
|
||||
taskStatusTitle: document.getElementById("task-status-title"),
|
||||
taskStatusState: document.getElementById("task-status-state"),
|
||||
taskProgressFill: document.getElementById("task-progress-fill"),
|
||||
taskStatusText: document.getElementById("task-status-text"),
|
||||
};
|
||||
|
||||
const TASK_LABELS = {
|
||||
"create": "批量创建",
|
||||
"update": "批量更新",
|
||||
"delete": "批量删除",
|
||||
"reset-password": "批量改密",
|
||||
};
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const config = { ...options };
|
||||
config.headers = config.headers || {};
|
||||
|
||||
if (config.body && !(config.body instanceof FormData)) {
|
||||
config.headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const response = await fetch(path, config);
|
||||
const payload = await response.json().catch(() => ({
|
||||
success: false,
|
||||
message: "接口返回了无法解析的响应。",
|
||||
}));
|
||||
|
||||
if (response.status === 401) {
|
||||
state.authenticated = false;
|
||||
updateAuthView();
|
||||
}
|
||||
|
||||
if (!response.ok || !payload.success) {
|
||||
throw new Error(payload.message || "请求失败");
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
function updatePlatformStatus() {
|
||||
if (state.bootstrap.graphReady) {
|
||||
elements.platformStatus.textContent = "Graph 已就绪";
|
||||
elements.platformSubstatus.textContent = state.bootstrap.graphFlavor || "Microsoft Graph Global";
|
||||
} else {
|
||||
elements.platformStatus.textContent = "等待配置";
|
||||
const errors = state.bootstrap.validationErrors || [];
|
||||
elements.platformSubstatus.textContent = errors.join(";") || "请补充 .env";
|
||||
}
|
||||
}
|
||||
|
||||
function updateAuthView() {
|
||||
const needsLogin = state.bootstrap.authEnabled && !state.authenticated;
|
||||
elements.loginSection.classList.toggle("hidden", !needsLogin);
|
||||
}
|
||||
|
||||
function setConsole(content) {
|
||||
elements.resultConsole.textContent = content;
|
||||
}
|
||||
|
||||
function appendConsole(title, summary) {
|
||||
const lines = [
|
||||
`[${new Date().toLocaleString("zh-CN")}] ${title}`,
|
||||
String(summary || ""),
|
||||
"",
|
||||
];
|
||||
elements.resultConsole.textContent = lines.join("\n") + elements.resultConsole.textContent;
|
||||
}
|
||||
|
||||
function formatTaskLabel(operation) {
|
||||
return TASK_LABELS[operation] || operation;
|
||||
}
|
||||
|
||||
function formatTaskState(task) {
|
||||
if (task.status === "queued") {
|
||||
return "已提交";
|
||||
}
|
||||
if (task.status === "running") {
|
||||
return "执行中";
|
||||
}
|
||||
if (task.status === "succeeded") {
|
||||
return "已完成";
|
||||
}
|
||||
if (task.status === "failed") {
|
||||
return "失败";
|
||||
}
|
||||
return task.status || "未知";
|
||||
}
|
||||
|
||||
function summarizeTask(task) {
|
||||
const base = `${formatTaskLabel(task.operation)}:${task.completed}/${task.total},成功 ${task.successCount},失败 ${task.failureCount}`;
|
||||
if (task.failureCount > 0) {
|
||||
return `${base}。详细失败项请查看 logs/office365_admin.log`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function showTaskCard(task) {
|
||||
if (!elements.taskStatusCard) {
|
||||
return;
|
||||
}
|
||||
elements.taskStatusCard.classList.remove("hidden");
|
||||
elements.taskStatusTitle.textContent = formatTaskLabel(task.operation);
|
||||
elements.taskStatusState.textContent = formatTaskState(task);
|
||||
elements.taskProgressFill.style.width = `${task.progressPercent || 0}%`;
|
||||
const statusText = [
|
||||
`进度 ${task.completed || 0} / ${task.total || 0}`,
|
||||
`成功 ${task.successCount || 0}`,
|
||||
`失败 ${task.failureCount || 0}`,
|
||||
].join(" · ");
|
||||
const currentText = task.currentItem
|
||||
? ` · 当前 ${task.currentItem}`
|
||||
: "";
|
||||
elements.taskStatusText.textContent = `${statusText}${currentText}`;
|
||||
}
|
||||
|
||||
function showSingleActionStatus(title, text) {
|
||||
if (!elements.taskStatusCard) {
|
||||
return;
|
||||
}
|
||||
elements.taskStatusCard.classList.remove("hidden");
|
||||
elements.taskStatusTitle.textContent = title;
|
||||
elements.taskStatusState.textContent = "处理中";
|
||||
elements.taskProgressFill.style.width = "30%";
|
||||
elements.taskStatusText.textContent = text;
|
||||
}
|
||||
|
||||
function completeSingleActionStatus(title, text) {
|
||||
if (!elements.taskStatusCard) {
|
||||
return;
|
||||
}
|
||||
elements.taskStatusCard.classList.remove("hidden");
|
||||
elements.taskStatusTitle.textContent = title;
|
||||
elements.taskStatusState.textContent = "已完成";
|
||||
elements.taskProgressFill.style.width = "100%";
|
||||
elements.taskStatusText.textContent = text;
|
||||
}
|
||||
|
||||
function showFailedActionStatus(title, text) {
|
||||
if (!elements.taskStatusCard) {
|
||||
return;
|
||||
}
|
||||
elements.taskStatusCard.classList.remove("hidden");
|
||||
elements.taskStatusTitle.textContent = title;
|
||||
elements.taskStatusState.textContent = "失败";
|
||||
elements.taskProgressFill.style.width = "100%";
|
||||
elements.taskStatusText.textContent = text;
|
||||
}
|
||||
|
||||
function clearTaskPolling() {
|
||||
if (state.activeTaskTimer) {
|
||||
window.clearTimeout(state.activeTaskTimer);
|
||||
state.activeTaskTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollTask(taskId) {
|
||||
try {
|
||||
const task = await api(`/api/tasks/${encodeURIComponent(taskId)}`);
|
||||
showTaskCard(task);
|
||||
|
||||
if (task.status === "queued" || task.status === "running") {
|
||||
clearTaskPolling();
|
||||
state.activeTaskTimer = window.setTimeout(() => pollTask(taskId), 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
state.activeTaskId = "";
|
||||
clearTaskPolling();
|
||||
appendConsole(
|
||||
task.status === "failed" ? `${formatTaskLabel(task.operation)}失败` : `${formatTaskLabel(task.operation)}完成`,
|
||||
summarizeTask(task),
|
||||
);
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
state.activeTaskId = "";
|
||||
clearTaskPolling();
|
||||
showFailedActionStatus("任务轮询失败", error.message);
|
||||
appendConsole("任务轮询失败", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function startTask(task, submittedMessage) {
|
||||
state.activeTaskId = task.id;
|
||||
clearTaskPolling();
|
||||
showTaskCard(task);
|
||||
appendConsole("任务已提交", submittedMessage);
|
||||
pollTask(task.id);
|
||||
}
|
||||
|
||||
function statusPill(enabled) {
|
||||
const klass = enabled ? "active" : "disabled";
|
||||
const label = enabled ? "启用" : "停用";
|
||||
return `<span class="status-pill ${klass}">${label}</span>`;
|
||||
}
|
||||
|
||||
function selectedIdentifiers() {
|
||||
return Array.from(state.selectedUsers);
|
||||
}
|
||||
|
||||
function updateSelectionUi() {
|
||||
const count = state.selectedUsers.size;
|
||||
if (elements.selectedCount) {
|
||||
elements.selectedCount.textContent = count ? `已选择 ${count} 个账号` : "未选择账号";
|
||||
}
|
||||
|
||||
const currentPageIds = state.users.map((user) => user.userPrincipalName).filter(Boolean);
|
||||
const selectedOnPage = currentPageIds.filter((identifier) => state.selectedUsers.has(identifier)).length;
|
||||
|
||||
if (elements.selectPageCheckbox) {
|
||||
elements.selectPageCheckbox.checked = currentPageIds.length > 0 && selectedOnPage === currentPageIds.length;
|
||||
elements.selectPageCheckbox.indeterminate = selectedOnPage > 0 && selectedOnPage < currentPageIds.length;
|
||||
elements.selectPageCheckbox.disabled = currentPageIds.length === 0;
|
||||
}
|
||||
|
||||
[
|
||||
elements.clearSelectionBtn,
|
||||
elements.bulkEnableBtn,
|
||||
elements.bulkDisableBtn,
|
||||
elements.bulkResetBtn,
|
||||
elements.bulkDeleteBtn,
|
||||
].forEach((button) => {
|
||||
if (button) {
|
||||
button.disabled = count === 0;
|
||||
}
|
||||
});
|
||||
|
||||
if (elements.selectAllResultsBtn) {
|
||||
elements.selectAllResultsBtn.disabled = state.total === 0;
|
||||
}
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
if (!state.users.length) {
|
||||
elements.usersTableBody.innerHTML = `<tr><td colspan="7" class="empty-card">没有匹配到用户</td></tr>`;
|
||||
updateSelectionUi();
|
||||
return;
|
||||
}
|
||||
|
||||
elements.usersTableBody.innerHTML = state.users.map((user) => {
|
||||
const identifier = user.userPrincipalName || "";
|
||||
const checked = state.selectedUsers.has(identifier) ? "checked" : "";
|
||||
return `
|
||||
<tr>
|
||||
<td class="check-cell">
|
||||
<input type="checkbox" data-select-identifier="${encodeURIComponent(identifier)}" ${checked}>
|
||||
</td>
|
||||
<td>${escapeHtml(user.displayName || "-")}</td>
|
||||
<td>${escapeHtml(identifier || "-")}</td>
|
||||
<td>
|
||||
<div>${escapeHtml(user.department || "-")}</div>
|
||||
<div class="muted">${escapeHtml(user.jobTitle || "-")}</div>
|
||||
</td>
|
||||
<td>${statusPill(user.accountEnabled)}</td>
|
||||
<td>${user.assignedLicensesCount || 0}</td>
|
||||
<td>
|
||||
<div class="actions-inline">
|
||||
<button class="btn btn-secondary" data-action="view" data-identifier="${encodeURIComponent(identifier)}">查看</button>
|
||||
<button class="btn btn-ghost" data-action="reset" data-identifier="${encodeURIComponent(identifier)}">改密</button>
|
||||
<button class="btn btn-danger" data-action="delete" data-identifier="${encodeURIComponent(identifier)}">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
elements.paginationInfo.textContent = `第 ${state.page} 页,当前 ${state.users.length} / 共 ${state.total} 条`;
|
||||
updateSelectionUi();
|
||||
}
|
||||
|
||||
function renderMetrics() {
|
||||
elements.metricTotal.textContent = String(state.total);
|
||||
elements.metricActive.textContent = String(state.summary.active || 0);
|
||||
elements.metricDisabled.textContent = String(state.summary.disabled || 0);
|
||||
const available = state.licenses.reduce((sum, item) => sum + (item.availableUnits || 0), 0);
|
||||
elements.metricLicense.textContent = String(available);
|
||||
const totalFoot = state.search
|
||||
? `搜索结果 ${state.total} / 全量 ${state.totalBeforeSearch}`
|
||||
: `全量账号 ${state.totalBeforeSearch}`;
|
||||
document.getElementById("metric-total-foot").textContent = totalFoot;
|
||||
}
|
||||
|
||||
function renderLicenses() {
|
||||
if (!state.licenses.length) {
|
||||
elements.licenseList.innerHTML = `<div class="empty-card">未获取到许可证信息</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
elements.licenseList.innerHTML = state.licenses.map((item) => `
|
||||
<article class="license-card">
|
||||
<strong>${escapeHtml(item.skuPartNumber || "UNKNOWN")}</strong>
|
||||
<div>可用席位:${item.availableUnits}</div>
|
||||
<div>已用席位:${item.consumedUnits}</div>
|
||||
<div>总席位:${item.totalUnits}</div>
|
||||
</article>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
async function fetchSession() {
|
||||
const data = await api("/api/session");
|
||||
state.authenticated = data.authenticated;
|
||||
updateAuthView();
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
const query = new URLSearchParams({
|
||||
page: String(state.page),
|
||||
pageSize: String(state.pageSize),
|
||||
search: state.search,
|
||||
});
|
||||
const data = await api(`/api/users?${query.toString()}`);
|
||||
state.users = data.items;
|
||||
state.total = data.total;
|
||||
state.totalBeforeSearch = data.totalBeforeSearch;
|
||||
state.summary = data.summary;
|
||||
renderUsers();
|
||||
renderMetrics();
|
||||
}
|
||||
|
||||
async function fetchLicenses() {
|
||||
const data = await api("/api/licenses");
|
||||
state.licenses = data;
|
||||
renderLicenses();
|
||||
renderMetrics();
|
||||
}
|
||||
|
||||
async function fetchUserDetail(identifier) {
|
||||
const data = await api(`/api/users/${encodeURIComponent(identifier)}`);
|
||||
state.selectedUser = data;
|
||||
fillUserForm(data);
|
||||
appendConsole("已加载用户", data.userPrincipalName || identifier);
|
||||
}
|
||||
|
||||
function formPayload() {
|
||||
return {
|
||||
userPrincipalName: document.getElementById("userPrincipalName").value.trim(),
|
||||
displayName: document.getElementById("displayName").value.trim(),
|
||||
givenName: document.getElementById("givenName").value.trim(),
|
||||
surname: document.getElementById("surname").value.trim(),
|
||||
department: document.getElementById("department").value.trim(),
|
||||
jobTitle: document.getElementById("jobTitle").value.trim(),
|
||||
officeLocation: document.getElementById("officeLocation").value.trim(),
|
||||
mobilePhone: document.getElementById("mobilePhone").value.trim(),
|
||||
usageLocation: document.getElementById("usageLocation").value.trim(),
|
||||
skuPartNumber: document.getElementById("skuPartNumber").value.trim(),
|
||||
password: document.getElementById("password").value,
|
||||
accountEnabled: document.getElementById("accountEnabled").checked,
|
||||
forceChangePasswordNextSignIn: document.getElementById("forceChangePasswordNextSignIn").checked,
|
||||
};
|
||||
}
|
||||
|
||||
function fillUserForm(user) {
|
||||
elements.selectedUserId.value = user.id || "";
|
||||
document.getElementById("userPrincipalName").value = user.userPrincipalName || "";
|
||||
document.getElementById("displayName").value = user.displayName || "";
|
||||
document.getElementById("givenName").value = user.givenName || "";
|
||||
document.getElementById("surname").value = user.surname || "";
|
||||
document.getElementById("department").value = user.department || "";
|
||||
document.getElementById("jobTitle").value = user.jobTitle || "";
|
||||
document.getElementById("officeLocation").value = user.officeLocation || "";
|
||||
document.getElementById("mobilePhone").value = user.mobilePhone || "";
|
||||
document.getElementById("usageLocation").value = user.usageLocation || state.bootstrap.defaultUsageLocation || "";
|
||||
document.getElementById("skuPartNumber").value = (user.licenseLabels && user.licenseLabels[0]) || "";
|
||||
document.getElementById("password").value = "";
|
||||
document.getElementById("accountEnabled").checked = Boolean(user.accountEnabled);
|
||||
document.getElementById("forceChangePasswordNextSignIn").checked = state.bootstrap.forceChangePassword;
|
||||
}
|
||||
|
||||
function clearUserForm() {
|
||||
state.selectedUser = null;
|
||||
elements.selectedUserId.value = "";
|
||||
elements.userForm.reset();
|
||||
document.getElementById("accountEnabled").checked = true;
|
||||
document.getElementById("forceChangePasswordNextSignIn").checked = state.bootstrap.forceChangePassword;
|
||||
document.getElementById("usageLocation").value = state.bootstrap.defaultUsageLocation || "";
|
||||
}
|
||||
|
||||
function toggleUserSelection(identifier, checked) {
|
||||
if (!identifier) {
|
||||
return;
|
||||
}
|
||||
if (checked) {
|
||||
state.selectedUsers.add(identifier);
|
||||
} else {
|
||||
state.selectedUsers.delete(identifier);
|
||||
}
|
||||
updateSelectionUi();
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
state.selectedUsers.clear();
|
||||
updateSelectionUi();
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
function toggleCurrentPageSelection(checked) {
|
||||
state.users.forEach((user) => {
|
||||
if (!user.userPrincipalName) {
|
||||
return;
|
||||
}
|
||||
if (checked) {
|
||||
state.selectedUsers.add(user.userPrincipalName);
|
||||
} else {
|
||||
state.selectedUsers.delete(user.userPrincipalName);
|
||||
}
|
||||
});
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
async function selectAllMatchingUsers() {
|
||||
const query = new URLSearchParams({ search: state.search });
|
||||
const data = await api(`/api/users/selection?${query.toString()}`);
|
||||
state.selectedUsers = new Set(data.identifiers || []);
|
||||
renderUsers();
|
||||
appendConsole("已选中搜索结果", `共 ${state.selectedUsers.size} 个账号`);
|
||||
}
|
||||
|
||||
async function saveUser(event) {
|
||||
event.preventDefault();
|
||||
const payload = formPayload();
|
||||
const identifier = payload.userPrincipalName;
|
||||
if (!identifier) {
|
||||
throw new Error("请先填写账号或邮箱。");
|
||||
}
|
||||
|
||||
showSingleActionStatus("保存用户", identifier);
|
||||
|
||||
let data;
|
||||
if (state.selectedUser && state.selectedUser.userPrincipalName) {
|
||||
data = await api(`/api/users/${encodeURIComponent(state.selectedUser.userPrincipalName)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
appendConsole("用户已更新", data.user.userPrincipalName || identifier);
|
||||
} else {
|
||||
data = await api("/api/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
appendConsole("用户已创建", data.user.userPrincipalName || identifier);
|
||||
}
|
||||
|
||||
completeSingleActionStatus("保存用户", data.user.userPrincipalName || identifier);
|
||||
await refreshAll();
|
||||
if (data.user && data.user.userPrincipalName) {
|
||||
await fetchUserDetail(data.user.userPrincipalName);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrentUser(identifier) {
|
||||
if (!identifier) {
|
||||
throw new Error("请先选择一个用户。");
|
||||
}
|
||||
const confirmDelete = window.confirm(`确认删除 ${identifier} 吗?此操作不可撤销。`);
|
||||
if (!confirmDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
showSingleActionStatus("删除用户", identifier);
|
||||
await api(`/api/users/${encodeURIComponent(identifier)}`, { method: "DELETE" });
|
||||
state.selectedUsers.delete(identifier);
|
||||
appendConsole("用户已删除", identifier);
|
||||
completeSingleActionStatus("删除用户", identifier);
|
||||
clearUserForm();
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
async function resetPassword(identifier) {
|
||||
if (!identifier) {
|
||||
throw new Error("请先选择一个用户。");
|
||||
}
|
||||
const payload = {
|
||||
password: document.getElementById("password").value,
|
||||
forceChangePasswordNextSignIn: document.getElementById("forceChangePasswordNextSignIn").checked,
|
||||
};
|
||||
showSingleActionStatus("重置密码", identifier);
|
||||
await api(`/api/users/${encodeURIComponent(identifier)}/reset-password`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
appendConsole("密码已重置", identifier);
|
||||
completeSingleActionStatus("重置密码", identifier);
|
||||
}
|
||||
|
||||
async function submitBatchTask(action, body, summaryText) {
|
||||
const task = await api(`/api/users/batch/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
startTask(task, summaryText);
|
||||
}
|
||||
|
||||
async function runSelectedBulk(action) {
|
||||
const identifiers = selectedIdentifiers();
|
||||
if (!identifiers.length) {
|
||||
throw new Error("请先勾选至少一个用户。");
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
const confirmed = window.confirm(`确认批量删除这 ${identifiers.length} 个账号吗?此操作不可撤销。`);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
await submitBatchTask("delete", { identifiers }, `批量删除任务已提交,共 ${identifiers.length} 个账号`);
|
||||
} else if (action === "enable" || action === "disable") {
|
||||
const rows = identifiers.map((userPrincipalName) => ({
|
||||
userPrincipalName,
|
||||
accountEnabled: action === "enable",
|
||||
}));
|
||||
await submitBatchTask("update", { rows }, `${action === "enable" ? "批量启用" : "批量停用"}任务已提交,共 ${identifiers.length} 个账号`);
|
||||
} else if (action === "reset-password") {
|
||||
const promptedPassword = window.prompt("输入统一临时密码;留空则使用系统默认密码。", "");
|
||||
if (promptedPassword === null) {
|
||||
return;
|
||||
}
|
||||
const password = promptedPassword.trim();
|
||||
const rows = identifiers.map((userPrincipalName) => ({
|
||||
userPrincipalName,
|
||||
...(password ? { password } : {}),
|
||||
forceChangePasswordNextSignIn: true,
|
||||
}));
|
||||
await submitBatchTask("reset-password", { rows }, `批量改密任务已提交,共 ${identifiers.length} 个账号`);
|
||||
} else {
|
||||
throw new Error("不支持的批量操作。");
|
||||
}
|
||||
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
async function runBatch(action) {
|
||||
const textareaMap = {
|
||||
"create": document.getElementById("batch-create-content"),
|
||||
"update": document.getElementById("batch-update-content"),
|
||||
"delete": document.getElementById("batch-delete-content"),
|
||||
"reset-password": document.getElementById("batch-reset-content"),
|
||||
};
|
||||
|
||||
const textarea = textareaMap[action];
|
||||
const content = textarea.value.trim();
|
||||
if (!content) {
|
||||
throw new Error("请先粘贴批量内容或上传文件。");
|
||||
}
|
||||
|
||||
await submitBatchTask(action, { content }, `${formatTaskLabel(action)}任务已提交,等待后台处理`);
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
updatePlatformStatus();
|
||||
if (state.bootstrap.authEnabled && !state.authenticated) {
|
||||
return;
|
||||
}
|
||||
if (!state.bootstrap.graphReady) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([fetchUsers(), fetchLicenses()]);
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
elements.loginForm?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
showSingleActionStatus("登录后台", "正在验证管理员账号");
|
||||
await api("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById("login-username").value.trim(),
|
||||
password: document.getElementById("login-password").value,
|
||||
}),
|
||||
});
|
||||
await fetchSession();
|
||||
await refreshAll();
|
||||
appendConsole("登录成功", "现在可以开始管理租户账号");
|
||||
completeSingleActionStatus("登录后台", "登录成功");
|
||||
} catch (error) {
|
||||
showFailedActionStatus("登录后台", error.message);
|
||||
appendConsole("登录失败", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
elements.logoutBtn?.addEventListener("click", async () => {
|
||||
await api("/api/logout", { method: "POST" });
|
||||
state.authenticated = false;
|
||||
updateAuthView();
|
||||
appendConsole("已退出后台", "如需继续操作,请重新登录");
|
||||
});
|
||||
|
||||
elements.refreshAllBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
showSingleActionStatus("刷新数据", "正在重新加载用户与许可证");
|
||||
await refreshAll();
|
||||
appendConsole("刷新完成", "用户列表和许可证信息已更新");
|
||||
completeSingleActionStatus("刷新数据", "刷新完成");
|
||||
} catch (error) {
|
||||
showFailedActionStatus("刷新数据", error.message);
|
||||
appendConsole("刷新失败", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
elements.searchForm?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
state.search = elements.searchInput.value.trim();
|
||||
state.page = 1;
|
||||
try {
|
||||
showSingleActionStatus("搜索用户", state.search || "全部用户");
|
||||
await fetchUsers();
|
||||
appendConsole("搜索完成", `${state.total} 条匹配结果`);
|
||||
completeSingleActionStatus("搜索用户", `${state.total} 条匹配结果`);
|
||||
} catch (error) {
|
||||
showFailedActionStatus("搜索用户", error.message);
|
||||
appendConsole("搜索失败", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
elements.prevPageBtn?.addEventListener("click", async () => {
|
||||
if (state.page <= 1) {
|
||||
return;
|
||||
}
|
||||
state.page -= 1;
|
||||
await fetchUsers();
|
||||
});
|
||||
|
||||
elements.nextPageBtn?.addEventListener("click", async () => {
|
||||
if (state.page * state.pageSize >= state.total) {
|
||||
return;
|
||||
}
|
||||
state.page += 1;
|
||||
await fetchUsers();
|
||||
});
|
||||
|
||||
elements.selectPageCheckbox?.addEventListener("change", (event) => {
|
||||
toggleCurrentPageSelection(event.target.checked);
|
||||
});
|
||||
|
||||
elements.selectAllResultsBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await selectAllMatchingUsers();
|
||||
} catch (error) {
|
||||
appendConsole("全选失败", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
elements.clearSelectionBtn?.addEventListener("click", () => {
|
||||
clearSelection();
|
||||
appendConsole("已清空选择", "当前未勾选任何账号");
|
||||
});
|
||||
|
||||
elements.bulkEnableBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await runSelectedBulk("enable");
|
||||
} catch (error) {
|
||||
appendConsole("批量启用失败", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
elements.bulkDisableBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await runSelectedBulk("disable");
|
||||
} catch (error) {
|
||||
appendConsole("批量停用失败", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
elements.bulkResetBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await runSelectedBulk("reset-password");
|
||||
} catch (error) {
|
||||
appendConsole("批量改密失败", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
elements.bulkDeleteBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await runSelectedBulk("delete");
|
||||
} catch (error) {
|
||||
appendConsole("批量删除失败", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
elements.usersTableBody?.addEventListener("change", (event) => {
|
||||
const checkbox = event.target.closest("input[data-select-identifier]");
|
||||
if (!checkbox) {
|
||||
return;
|
||||
}
|
||||
const identifier = decodeURIComponent(checkbox.dataset.selectIdentifier || "");
|
||||
toggleUserSelection(identifier, checkbox.checked);
|
||||
});
|
||||
|
||||
elements.usersTableBody?.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("button[data-action]");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
const action = button.dataset.action;
|
||||
const identifier = decodeURIComponent(button.dataset.identifier || "");
|
||||
try {
|
||||
if (action === "view") {
|
||||
await fetchUserDetail(identifier);
|
||||
} else if (action === "delete") {
|
||||
await deleteCurrentUser(identifier);
|
||||
} else if (action === "reset") {
|
||||
await resetPassword(identifier);
|
||||
}
|
||||
} catch (error) {
|
||||
appendConsole("用户操作失败", error.message);
|
||||
showFailedActionStatus("用户操作失败", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
elements.userForm?.addEventListener("submit", async (event) => {
|
||||
try {
|
||||
await saveUser(event);
|
||||
} catch (error) {
|
||||
appendConsole("保存失败", error.message);
|
||||
showFailedActionStatus("保存用户", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
elements.newUserBtn?.addEventListener("click", () => clearUserForm());
|
||||
elements.clearFormBtn?.addEventListener("click", () => clearUserForm());
|
||||
|
||||
elements.deleteUserBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await deleteCurrentUser(state.selectedUser && state.selectedUser.userPrincipalName);
|
||||
} catch (error) {
|
||||
appendConsole("删除失败", error.message);
|
||||
showFailedActionStatus("删除用户", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
elements.resetPasswordBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
await resetPassword(state.selectedUser && state.selectedUser.userPrincipalName);
|
||||
} catch (error) {
|
||||
appendConsole("重置密码失败", error.message);
|
||||
showFailedActionStatus("重置密码", error.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-batch-action]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
try {
|
||||
await runBatch(button.dataset.batchAction);
|
||||
} catch (error) {
|
||||
appendConsole("批量任务提交失败", error.message);
|
||||
showFailedActionStatus("批量任务提交失败", error.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".file-input").forEach((input) => {
|
||||
input.addEventListener("change", async (event) => {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const targetId = event.target.dataset.target;
|
||||
const target = document.getElementById(targetId);
|
||||
target.value = await file.text();
|
||||
appendConsole("文件已加载", `${file.name} 已写入输入框`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
setConsole("等待操作...");
|
||||
updatePlatformStatus();
|
||||
bindEvents();
|
||||
updateSelectionUi();
|
||||
|
||||
try {
|
||||
await fetchSession();
|
||||
if (!state.bootstrap.authEnabled || state.authenticated) {
|
||||
await refreshAll();
|
||||
}
|
||||
} catch (error) {
|
||||
appendConsole("初始化失败", error.message);
|
||||
showFailedActionStatus("初始化失败", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
boot();
|
||||
@@ -0,0 +1,3 @@
|
||||
userPrincipalName,displayName,givenName,surname,department,jobTitle,usageLocation,skuPartNumber,password
|
||||
alice,Alice Zhang,Alice,Zhang,Sales,Manager,US,ENTERPRISEPACK,Temp123!2026
|
||||
bob@contoso.com,Bob Li,Bob,Li,IT,Engineer,US,ENTERPRISEPACK,
|
||||
|
@@ -0,0 +1,3 @@
|
||||
userPrincipalName
|
||||
alice@contoso.com
|
||||
bob@contoso.com
|
||||
|
@@ -0,0 +1,3 @@
|
||||
userPrincipalName,password,forceChangePasswordNextSignIn
|
||||
alice@contoso.com,Temp123!2026,true
|
||||
bob@contoso.com,Another123!2026,true
|
||||
|
@@ -0,0 +1,3 @@
|
||||
userPrincipalName,department,jobTitle,officeLocation,mobilePhone,accountEnabled,skuPartNumber
|
||||
alice@contoso.com,Operations,Lead,New York,+15550000001,true,ENTERPRISEPACK
|
||||
bob@contoso.com,Finance,Analyst,Seattle,,false,
|
||||
|
542
office365_admin/static/styles.css
Normal file
542
office365_admin/static/styles.css
Normal file
@@ -0,0 +1,542 @@
|
||||
:root {
|
||||
--bg: #f6f1e8;
|
||||
--panel: rgba(255, 250, 244, 0.82);
|
||||
--panel-strong: rgba(255, 252, 248, 0.94);
|
||||
--text: #112238;
|
||||
--muted: #5f6f80;
|
||||
--line: rgba(17, 34, 56, 0.12);
|
||||
--brand: #0f6c7b;
|
||||
--brand-strong: #0a425a;
|
||||
--accent: #c65d3a;
|
||||
--danger: #a33a2b;
|
||||
--success: #217253;
|
||||
--shadow: 0 20px 50px rgba(17, 34, 56, 0.12);
|
||||
--radius: 24px;
|
||||
--radius-sm: 16px;
|
||||
--font-sans: "Avenir Next", "Trebuchet MS", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 108, 123, 0.18), transparent 30%),
|
||||
linear-gradient(135deg, #f8f4ec 0%, #f0ebe3 45%, #e2edf1 100%);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.background-glow {
|
||||
position: fixed;
|
||||
inset: auto;
|
||||
width: 28rem;
|
||||
height: 28rem;
|
||||
border-radius: 999px;
|
||||
filter: blur(70px);
|
||||
opacity: 0.32;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.glow-a {
|
||||
top: -8rem;
|
||||
right: -10rem;
|
||||
background: #f4a261;
|
||||
}
|
||||
|
||||
.glow-b {
|
||||
left: -8rem;
|
||||
bottom: -12rem;
|
||||
background: #4da6b3;
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding: 36px 24px 48px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.8fr) minmax(280px, 0.8fr);
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.hero-side,
|
||||
.panel,
|
||||
.metric-card {
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
padding: 28px;
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
margin: 10px 0 12px;
|
||||
font-size: clamp(2rem, 3vw, 3.4rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
margin: 0;
|
||||
max-width: 48rem;
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.7;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-side {
|
||||
padding: 24px;
|
||||
border-radius: calc(var(--radius) + 4px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(145deg, rgba(15, 108, 123, 0.09), rgba(198, 93, 58, 0.06));
|
||||
}
|
||||
|
||||
.status-card strong {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.status-label,
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-actions,
|
||||
.editor-actions,
|
||||
.pager,
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border-radius: 22px;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.metric-card strong {
|
||||
font-size: clamp(1.8rem, 4vw, 2.6rem);
|
||||
}
|
||||
|
||||
.metric-foot {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.panel-head h2,
|
||||
.batch-card h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.panel-head p,
|
||||
.batch-card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.search-form,
|
||||
.inline-form {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.search-form input,
|
||||
.inline-form input,
|
||||
.user-form input,
|
||||
.batch-card textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.inline-form label,
|
||||
.user-form label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 11px 18px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: transform 0.16s ease, opacity 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.48;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--brand), var(--brand-strong));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #d7ecef;
|
||||
color: var(--brand-strong);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: rgba(17, 34, 56, 0.08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, var(--danger), #7d241d);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.selection-toolbar {
|
||||
margin-bottom: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-strong);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.selection-meta,
|
||||
.selection-actions,
|
||||
.table-checkbox,
|
||||
.batch-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.batch-card-top {
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.table-checkbox {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.check-col,
|
||||
.check-cell {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.check-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
font-size: 0.84rem;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: rgba(15, 108, 123, 0.05);
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.status-pill.active {
|
||||
background: rgba(33, 114, 83, 0.14);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-pill.disabled {
|
||||
background: rgba(163, 58, 43, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.actions-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.actions-inline .btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.user-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px 16px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.checkbox input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
grid-column: 1 / -1;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.license-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.license-card,
|
||||
.empty-card {
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.license-card strong {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.batch-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.batch-card {
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--line);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.batch-card textarea {
|
||||
min-height: 180px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.console {
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
min-height: 220px;
|
||||
border-radius: 18px;
|
||||
background: #102338;
|
||||
color: #edf6ff;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.task-status-card {
|
||||
margin-bottom: 14px;
|
||||
padding: 16px 18px;
|
||||
border-radius: 18px;
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--line);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.task-status-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-progress-track {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(17, 34, 56, 0.1);
|
||||
}
|
||||
|
||||
.task-progress-fill {
|
||||
width: 0%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, var(--brand), var(--accent));
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.empty-cell,
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 108, 123, 0.09);
|
||||
color: var(--brand-strong);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.hero,
|
||||
.metrics,
|
||||
.batch-grid,
|
||||
.user-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.panel-head,
|
||||
.table-footer,
|
||||
.selection-toolbar,
|
||||
.batch-card-top {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-form input {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.shell {
|
||||
padding: 18px 14px 28px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.hero-copy,
|
||||
.hero-side {
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
172
office365_admin/tasks.py
Normal file
172
office365_admin/tasks.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
logger = logging.getLogger("office365_admin.tasks")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskRecord:
|
||||
id: str
|
||||
operation: str
|
||||
total: int
|
||||
status: str = "queued"
|
||||
message: str = "任务已提交"
|
||||
created_at: str = field(default_factory=_now_iso)
|
||||
started_at: str = ""
|
||||
finished_at: str = ""
|
||||
completed: int = 0
|
||||
success_count: int = 0
|
||||
failure_count: int = 0
|
||||
current_item: str = ""
|
||||
current_message: str = ""
|
||||
recent_failures: list[dict[str, str]] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
progress_percent = 0
|
||||
if self.total > 0:
|
||||
progress_percent = int((self.completed / self.total) * 100)
|
||||
return {
|
||||
"id": self.id,
|
||||
"operation": self.operation,
|
||||
"status": self.status,
|
||||
"message": self.message,
|
||||
"createdAt": self.created_at,
|
||||
"startedAt": self.started_at,
|
||||
"finishedAt": self.finished_at,
|
||||
"total": self.total,
|
||||
"completed": self.completed,
|
||||
"successCount": self.success_count,
|
||||
"failureCount": self.failure_count,
|
||||
"progressPercent": progress_percent,
|
||||
"currentItem": self.current_item,
|
||||
"currentMessage": self.current_message,
|
||||
"recentFailures": self.recent_failures,
|
||||
}
|
||||
|
||||
|
||||
class TaskNotFoundError(KeyError):
|
||||
pass
|
||||
|
||||
|
||||
class BackgroundTaskManager:
|
||||
def __init__(self, max_workers: int = 4):
|
||||
self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="office365-task")
|
||||
self._tasks: dict[str, TaskRecord] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def submit(
|
||||
self,
|
||||
operation: str,
|
||||
total: int,
|
||||
runner: Callable[[Callable[[dict[str, Any]], None]], dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
task_id = uuid.uuid4().hex
|
||||
record = TaskRecord(
|
||||
id=task_id,
|
||||
operation=operation,
|
||||
total=total,
|
||||
message=f"{self._label(operation)}任务已提交",
|
||||
)
|
||||
with self._lock:
|
||||
self._tasks[task_id] = record
|
||||
|
||||
logger.info("Task %s queued: operation=%s total=%s", task_id, operation, total)
|
||||
self._executor.submit(self._run_task, task_id, runner)
|
||||
return record.to_dict()
|
||||
|
||||
def get_task(self, task_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
record = self._tasks.get(task_id)
|
||||
if record is None:
|
||||
raise TaskNotFoundError(task_id)
|
||||
return record.to_dict()
|
||||
|
||||
def _run_task(
|
||||
self,
|
||||
task_id: str,
|
||||
runner: Callable[[Callable[[dict[str, Any]], None]], dict[str, Any]],
|
||||
) -> None:
|
||||
self._update(
|
||||
task_id,
|
||||
status="running",
|
||||
started_at=_now_iso(),
|
||||
message="任务执行中",
|
||||
)
|
||||
logger.info("Task %s started", task_id)
|
||||
|
||||
try:
|
||||
result = runner(lambda update: self._handle_progress(task_id, update))
|
||||
summary_message = (
|
||||
f"任务完成,成功 {result.get('successCount', 0)},失败 {result.get('failureCount', 0)}"
|
||||
)
|
||||
self._update(
|
||||
task_id,
|
||||
status="succeeded",
|
||||
finished_at=_now_iso(),
|
||||
completed=result.get("total", 0),
|
||||
success_count=result.get("successCount", 0),
|
||||
failure_count=result.get("failureCount", 0),
|
||||
current_message=summary_message,
|
||||
message=summary_message,
|
||||
)
|
||||
logger.info("Task %s finished: %s", task_id, summary_message)
|
||||
except Exception as exc:
|
||||
logger.exception("Task %s failed", task_id)
|
||||
self._update(
|
||||
task_id,
|
||||
status="failed",
|
||||
finished_at=_now_iso(),
|
||||
message=f"任务执行失败: {exc}",
|
||||
current_message=str(exc),
|
||||
)
|
||||
|
||||
def _handle_progress(self, task_id: str, update: dict[str, Any]) -> None:
|
||||
update_payload = {
|
||||
"completed": update.get("completed", 0),
|
||||
"success_count": update.get("successCount", 0),
|
||||
"failure_count": update.get("failureCount", 0),
|
||||
"current_item": update.get("identifier", ""),
|
||||
"current_message": update.get("message", ""),
|
||||
"message": f"正在执行 {update.get('completed', 0)} / {update.get('total', 0)}",
|
||||
}
|
||||
if not update.get("success") and update.get("identifier"):
|
||||
with self._lock:
|
||||
record = self._tasks.get(task_id)
|
||||
if record is not None and len(record.recent_failures) < 5:
|
||||
record.recent_failures.append(
|
||||
{
|
||||
"identifier": update.get("identifier", ""),
|
||||
"message": update.get("message", ""),
|
||||
}
|
||||
)
|
||||
self._update(task_id, **update_payload)
|
||||
|
||||
def _update(self, task_id: str, **changes: Any) -> None:
|
||||
with self._lock:
|
||||
record = self._tasks.get(task_id)
|
||||
if record is None:
|
||||
return
|
||||
for key, value in changes.items():
|
||||
setattr(record, key, value)
|
||||
|
||||
@staticmethod
|
||||
def _label(operation: str) -> str:
|
||||
labels = {
|
||||
"create": "批量创建",
|
||||
"update": "批量更新",
|
||||
"delete": "批量删除",
|
||||
"reset-password": "批量改密",
|
||||
}
|
||||
return labels.get(operation, operation)
|
||||
319
office365_admin/templates/index.html
Normal file
319
office365_admin/templates/index.html
Normal file
@@ -0,0 +1,319 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Office 365 User Management Platform</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="background-glow glow-a"></div>
|
||||
<div class="background-glow glow-b"></div>
|
||||
<div class="shell">
|
||||
<header class="hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Microsoft Graph + Web Console</p>
|
||||
<h1>Office 365 账号管理平台</h1>
|
||||
<p class="hero-text">
|
||||
基于你提供的 <code>office365-tools</code> 能力升级而来,当前默认按国际版 Microsoft 365 / Microsoft Graph 工作,支持账号单个与批量增删改查、许可证查看、密码重置与批处理回显。
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-side">
|
||||
<div class="status-card">
|
||||
<span class="status-label">平台状态</span>
|
||||
<strong id="platform-status">检测中</strong>
|
||||
<span id="platform-substatus" class="muted">正在读取配置</span>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<button id="refresh-all-btn" class="btn btn-secondary">刷新数据</button>
|
||||
<button id="logout-btn" class="btn btn-ghost">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="dashboard">
|
||||
<section id="login-section" class="panel panel-login hidden">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>后台登录</h2>
|
||||
<p>如果启用了平台登录保护,请先输入后台管理员账号。</p>
|
||||
</div>
|
||||
</div>
|
||||
<form id="login-form" class="inline-form">
|
||||
<label>
|
||||
<span>用户名</span>
|
||||
<input id="login-username" name="username" autocomplete="username" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>密码</span>
|
||||
<input id="login-password" name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">登录平台</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="metrics">
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">匹配用户数</span>
|
||||
<strong id="metric-total">0</strong>
|
||||
<span class="metric-foot" id="metric-total-foot">当前筛选结果</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">启用账号</span>
|
||||
<strong id="metric-active">0</strong>
|
||||
<span class="metric-foot">accountEnabled = true</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">停用账号</span>
|
||||
<strong id="metric-disabled">0</strong>
|
||||
<span class="metric-foot">便于快速排查离职或冻结账号</span>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">可用许可证</span>
|
||||
<strong id="metric-license">0</strong>
|
||||
<span class="metric-foot">来自当前租户订阅</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-users">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>用户列表</h2>
|
||||
<p>支持搜索、查看详情、全选当前页或全部搜索结果,并执行批量删除 / 启停 / 改密。</p>
|
||||
</div>
|
||||
<form id="search-form" class="search-form">
|
||||
<input id="search-input" placeholder="搜索邮箱、显示名、部门、职位">
|
||||
<button class="btn btn-secondary" type="submit">搜索</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="selection-toolbar">
|
||||
<div class="selection-meta">
|
||||
<span id="selected-count" class="tag">未选择账号</span>
|
||||
<button id="select-all-results-btn" class="btn btn-ghost" type="button">选中全部搜索结果</button>
|
||||
<button id="clear-selection-btn" class="btn btn-ghost" type="button">清空选择</button>
|
||||
</div>
|
||||
<div class="selection-actions">
|
||||
<button id="bulk-enable-btn" class="btn btn-secondary" type="button">批量启用</button>
|
||||
<button id="bulk-disable-btn" class="btn btn-secondary" type="button">批量停用</button>
|
||||
<button id="bulk-reset-btn" class="btn btn-secondary" type="button">批量改密</button>
|
||||
<button id="bulk-delete-btn" class="btn btn-danger" type="button">批量删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="check-col">
|
||||
<label class="table-checkbox">
|
||||
<input id="select-page-checkbox" type="checkbox">
|
||||
<span>全选</span>
|
||||
</label>
|
||||
</th>
|
||||
<th>显示名</th>
|
||||
<th>登录名</th>
|
||||
<th>部门 / 职位</th>
|
||||
<th>状态</th>
|
||||
<th>许可证</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-table-body">
|
||||
<tr>
|
||||
<td colspan="7" class="empty-cell">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="table-footer">
|
||||
<span id="pagination-info" class="muted">第 1 页</span>
|
||||
<div class="pager">
|
||||
<button id="prev-page-btn" class="btn btn-ghost" type="button">上一页</button>
|
||||
<button id="next-page-btn" class="btn btn-ghost" type="button">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-editor">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>单个账号维护</h2>
|
||||
<p>新建账号时支持只填用户名;若配置了 <code>DEFAULT_DOMAIN</code>,系统会自动补全 UPN。</p>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button id="new-user-btn" class="btn btn-secondary" type="button">新建用户</button>
|
||||
<button id="delete-user-btn" class="btn btn-danger" type="button">删除当前</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="user-form" class="user-form">
|
||||
<input id="selected-user-id" type="hidden">
|
||||
<label>
|
||||
<span>账号 / 邮箱</span>
|
||||
<input id="userPrincipalName" name="userPrincipalName" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>显示名</span>
|
||||
<input id="displayName" name="displayName">
|
||||
</label>
|
||||
<label>
|
||||
<span>名字</span>
|
||||
<input id="givenName" name="givenName">
|
||||
</label>
|
||||
<label>
|
||||
<span>姓氏</span>
|
||||
<input id="surname" name="surname">
|
||||
</label>
|
||||
<label>
|
||||
<span>部门</span>
|
||||
<input id="department" name="department">
|
||||
</label>
|
||||
<label>
|
||||
<span>职位</span>
|
||||
<input id="jobTitle" name="jobTitle">
|
||||
</label>
|
||||
<label>
|
||||
<span>办公地点</span>
|
||||
<input id="officeLocation" name="officeLocation">
|
||||
</label>
|
||||
<label>
|
||||
<span>手机号</span>
|
||||
<input id="mobilePhone" name="mobilePhone">
|
||||
</label>
|
||||
<label>
|
||||
<span>使用地区</span>
|
||||
<input id="usageLocation" name="usageLocation" placeholder="US">
|
||||
</label>
|
||||
<label>
|
||||
<span>许可证 SKU</span>
|
||||
<input id="skuPartNumber" name="skuPartNumber" placeholder="如 O365_BUSINESS">
|
||||
</label>
|
||||
<label>
|
||||
<span>临时密码</span>
|
||||
<input id="password" name="password" type="password" placeholder="不填则使用默认密码">
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input id="accountEnabled" name="accountEnabled" type="checkbox" checked>
|
||||
<span>启用账号</span>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input id="forceChangePasswordNextSignIn" name="forceChangePasswordNextSignIn" type="checkbox" checked>
|
||||
<span>首次登录强制改密</span>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button id="save-user-btn" class="btn btn-primary" type="submit">保存用户</button>
|
||||
<button id="reset-password-btn" class="btn btn-secondary" type="button">重置密码</button>
|
||||
<button id="clear-form-btn" class="btn btn-ghost" type="button">清空表单</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-license">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>许可证概览</h2>
|
||||
<p>实时读取租户已订阅 SKU 和剩余席位。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="license-list" class="license-list">
|
||||
<div class="empty-card">正在加载许可证信息...</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-batch">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>批量管理中心</h2>
|
||||
<p>支持 CSV 或 JSON。删除也支持纯文本,一行一个账号。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="batch-grid">
|
||||
<article class="batch-card">
|
||||
<div class="batch-card-top">
|
||||
<div>
|
||||
<h3>批量创建</h3>
|
||||
<p>先下载示例 CSV,编辑后再上传执行。</p>
|
||||
</div>
|
||||
<a class="btn btn-ghost" href="{{ url_for('static', filename='examples/batch-create-template.csv') }}" download>下载示例 CSV</a>
|
||||
</div>
|
||||
<p>CSV 表头示例:<code>userPrincipalName,displayName,givenName,surname,department,jobTitle,usageLocation,skuPartNumber,password</code></p>
|
||||
<input data-target="batch-create-content" class="file-input" type="file" accept=".csv,.json,.txt">
|
||||
<textarea id="batch-create-content" placeholder='或直接粘贴 JSON 数组 / CSV 内容'></textarea>
|
||||
<button data-batch-action="create" class="btn btn-primary" type="button">执行批量创建</button>
|
||||
</article>
|
||||
|
||||
<article class="batch-card">
|
||||
<div class="batch-card-top">
|
||||
<div>
|
||||
<h3>批量更新</h3>
|
||||
<p>下载模板后只改需要更新的列,再上传执行。</p>
|
||||
</div>
|
||||
<a class="btn btn-ghost" href="{{ url_for('static', filename='examples/batch-update-template.csv') }}" download>下载示例 CSV</a>
|
||||
</div>
|
||||
<p>CSV 至少需要一列账号标识。空值默认忽略,不会清空原字段。</p>
|
||||
<input data-target="batch-update-content" class="file-input" type="file" accept=".csv,.json,.txt">
|
||||
<textarea id="batch-update-content" placeholder='示例:userPrincipalName,department,jobTitle,accountEnabled'></textarea>
|
||||
<button data-batch-action="update" class="btn btn-primary" type="button">执行批量更新</button>
|
||||
</article>
|
||||
|
||||
<article class="batch-card">
|
||||
<div class="batch-card-top">
|
||||
<div>
|
||||
<h3>批量删除</h3>
|
||||
<p>可以直接下载删除模板,编辑后上传执行。</p>
|
||||
</div>
|
||||
<a class="btn btn-ghost" href="{{ url_for('static', filename='examples/batch-delete-template.csv') }}" download>下载示例 CSV</a>
|
||||
</div>
|
||||
<p>支持 CSV/JSON,也支持纯文本,一行一个 UPN 或用户名。</p>
|
||||
<input data-target="batch-delete-content" class="file-input" type="file" accept=".csv,.json,.txt">
|
||||
<textarea id="batch-delete-content" placeholder="alice bob@contoso.com"></textarea>
|
||||
<button data-batch-action="delete" class="btn btn-danger" type="button">执行批量删除</button>
|
||||
</article>
|
||||
|
||||
<article class="batch-card">
|
||||
<div class="batch-card-top">
|
||||
<div>
|
||||
<h3>批量重置密码</h3>
|
||||
<p>可先下载模板,编辑密码后上传执行。</p>
|
||||
</div>
|
||||
<a class="btn btn-ghost" href="{{ url_for('static', filename='examples/batch-reset-password-template.csv') }}" download>下载示例 CSV</a>
|
||||
</div>
|
||||
<p>可使用纯文本账号列表,也可传入 CSV:<code>userPrincipalName,password,forceChangePasswordNextSignIn</code></p>
|
||||
<input data-target="batch-reset-content" class="file-input" type="file" accept=".csv,.json,.txt">
|
||||
<textarea id="batch-reset-content" placeholder="一行一个账号,默认使用系统默认密码"></textarea>
|
||||
<button data-batch-action="reset-password" class="btn btn-secondary" type="button">执行批量改密</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel panel-console">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>执行结果</h2>
|
||||
<p>界面只展示简要摘要,详细逐条处理信息写入服务日志。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="task-status-card" class="task-status-card hidden">
|
||||
<div class="task-status-head">
|
||||
<strong id="task-status-title">暂无任务</strong>
|
||||
<span id="task-status-state" class="tag">待命</span>
|
||||
</div>
|
||||
<div class="task-progress-track">
|
||||
<div id="task-progress-fill" class="task-progress-fill"></div>
|
||||
</div>
|
||||
<div id="task-status-text" class="muted">等待任务提交</div>
|
||||
</div>
|
||||
<pre id="result-console" class="console">等待操作...</pre>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.APP_BOOTSTRAP = {{ bootstrap | tojson }};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Flask>=3.0,<4
|
||||
python-dotenv>=1.0,<2
|
||||
requests>=2.31,<3
|
||||
pytest>=8.0,<9
|
||||
|
||||
184
tests/test_app.py
Normal file
184
tests/test_app.py
Normal file
@@ -0,0 +1,184 @@
|
||||
from office365_admin import create_app
|
||||
from office365_admin.settings import Settings
|
||||
|
||||
|
||||
class FakeService:
|
||||
def list_licenses(self):
|
||||
return [{"skuId": "1", "skuPartNumber": "O365_BUSINESS", "availableUnits": 5, "consumedUnits": 5, "totalUnits": 10}]
|
||||
|
||||
def list_users(self, search="", page=1, page_size=25):
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": "1",
|
||||
"displayName": "Alice",
|
||||
"userPrincipalName": "alice@example.com",
|
||||
"mail": "alice@example.com",
|
||||
"givenName": "Alice",
|
||||
"surname": "Zhang",
|
||||
"department": "IT",
|
||||
"jobTitle": "Engineer",
|
||||
"officeLocation": "",
|
||||
"mobilePhone": "",
|
||||
"usageLocation": "US",
|
||||
"accountEnabled": True,
|
||||
"assignedLicenses": ["1"],
|
||||
"assignedLicensesCount": 1,
|
||||
"licenseLabels": ["O365_BUSINESS"],
|
||||
"createdDateTime": "",
|
||||
}
|
||||
],
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
"total": 1,
|
||||
"totalBeforeSearch": 1,
|
||||
"summary": {"active": 1, "disabled": 0},
|
||||
}
|
||||
|
||||
def list_user_identifiers(self, search=""):
|
||||
return {"identifiers": ["alice@example.com"], "total": 1}
|
||||
|
||||
def get_user(self, identifier):
|
||||
return self.list_users()["items"][0]
|
||||
|
||||
def create_user(self, payload):
|
||||
return {"user": self.get_user(payload["userPrincipalName"]), "temporaryPassword": "temp"}
|
||||
|
||||
def update_user(self, identifier, payload):
|
||||
return {"user": self.get_user(identifier)}
|
||||
|
||||
def delete_user(self, identifier):
|
||||
return {"user": self.get_user(identifier)}
|
||||
|
||||
def reset_password(self, identifier, payload=None):
|
||||
return {"user": self.get_user(identifier), "temporaryPassword": "newpass"}
|
||||
|
||||
def batch_create(self, rows, progress_callback=None):
|
||||
return {"operation": "create", "total": len(rows), "successCount": len(rows), "failureCount": 0, "results": []}
|
||||
|
||||
def batch_update(self, rows, progress_callback=None):
|
||||
return {"operation": "update", "total": len(rows), "successCount": len(rows), "failureCount": 0, "results": []}
|
||||
|
||||
def batch_delete(self, identifiers, progress_callback=None):
|
||||
return {"operation": "delete", "total": len(identifiers), "successCount": len(identifiers), "failureCount": 0, "results": []}
|
||||
|
||||
def batch_reset_password(self, rows, progress_callback=None):
|
||||
return {"operation": "reset-password", "total": len(rows), "successCount": len(rows), "failureCount": 0, "results": []}
|
||||
|
||||
|
||||
class FakeTaskManager:
|
||||
def __init__(self):
|
||||
self.last_task = {
|
||||
"id": "task-1",
|
||||
"operation": "reset-password",
|
||||
"status": "succeeded",
|
||||
"message": "任务完成,成功 1,失败 0",
|
||||
"createdAt": "2026-03-21T12:00:00",
|
||||
"startedAt": "2026-03-21T12:00:00",
|
||||
"finishedAt": "2026-03-21T12:00:01",
|
||||
"total": 1,
|
||||
"completed": 1,
|
||||
"successCount": 1,
|
||||
"failureCount": 0,
|
||||
"progressPercent": 100,
|
||||
"currentItem": "alice@example.com",
|
||||
"currentMessage": "任务完成,成功 1,失败 0",
|
||||
"recentFailures": [],
|
||||
}
|
||||
|
||||
def submit(self, operation, total, runner):
|
||||
self.last_task = {
|
||||
**self.last_task,
|
||||
"operation": operation,
|
||||
"total": total,
|
||||
"message": f"{operation} submitted",
|
||||
}
|
||||
return self.last_task
|
||||
|
||||
def get_task(self, task_id):
|
||||
return self.last_task
|
||||
|
||||
|
||||
def build_settings():
|
||||
return Settings(
|
||||
app_name="Test App",
|
||||
host="127.0.0.1",
|
||||
port=8000,
|
||||
debug=False,
|
||||
session_secret="test-secret",
|
||||
auth_enabled=True,
|
||||
admin_username="admin",
|
||||
admin_password="password",
|
||||
client_id="client",
|
||||
tenant_id="tenant",
|
||||
client_secret="secret",
|
||||
default_password="pass",
|
||||
default_domain="example.com",
|
||||
default_usage_location="US",
|
||||
default_license_sku="O365_BUSINESS",
|
||||
force_change_password=True,
|
||||
graph_base_url="https://example.test",
|
||||
token_endpoint="https://login.example.test/token",
|
||||
scope="scope",
|
||||
)
|
||||
|
||||
|
||||
def build_client():
|
||||
app = create_app(
|
||||
settings_override=build_settings(),
|
||||
service_factory=FakeService(),
|
||||
task_manager_factory=FakeTaskManager(),
|
||||
)
|
||||
app.config["TESTING"] = True
|
||||
return app.test_client()
|
||||
|
||||
|
||||
def login(client):
|
||||
response = client.post("/api/login", json={"username": "admin", "password": "password"})
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_users_requires_login():
|
||||
client = build_client()
|
||||
response = client.get("/api/users")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_login_and_list_users():
|
||||
client = build_client()
|
||||
login(client)
|
||||
response = client.get("/api/users")
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload["success"] is True
|
||||
assert payload["data"]["items"][0]["userPrincipalName"] == "alice@example.com"
|
||||
|
||||
|
||||
def test_list_user_identifiers_after_login():
|
||||
client = build_client()
|
||||
login(client)
|
||||
response = client.get("/api/users/selection")
|
||||
assert response.status_code == 200
|
||||
payload = response.get_json()
|
||||
assert payload["success"] is True
|
||||
assert payload["data"]["identifiers"] == ["alice@example.com"]
|
||||
|
||||
|
||||
def test_create_user_after_login():
|
||||
client = build_client()
|
||||
login(client)
|
||||
response = client.post("/api/users", json={"userPrincipalName": "alice@example.com"})
|
||||
assert response.status_code == 201
|
||||
payload = response.get_json()
|
||||
assert payload["success"] is True
|
||||
assert payload["data"]["temporaryPassword"] == "temp"
|
||||
|
||||
|
||||
def test_batch_reset_password_submits_task():
|
||||
client = build_client()
|
||||
login(client)
|
||||
response = client.post("/api/users/batch/reset-password", json={"rows": [{"userPrincipalName": "alice@example.com"}]})
|
||||
assert response.status_code == 202
|
||||
payload = response.get_json()
|
||||
assert payload["success"] is True
|
||||
assert payload["data"]["id"] == "task-1"
|
||||
31
tests/test_batch.py
Normal file
31
tests/test_batch.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from office365_admin.batch import parse_identifier_content, parse_table_content
|
||||
|
||||
|
||||
def test_parse_table_content_from_csv():
|
||||
content = "userPrincipalName,displayName,department\nalice, Alice Zhang,Sales\n"
|
||||
rows = parse_table_content(content)
|
||||
assert rows == [
|
||||
{
|
||||
"userPrincipalName": "alice",
|
||||
"displayName": "Alice Zhang",
|
||||
"department": "Sales",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_parse_table_content_from_json():
|
||||
rows = parse_table_content('[{"userPrincipalName":"bob@example.com","department":"IT"}]')
|
||||
assert rows[0]["userPrincipalName"] == "bob@example.com"
|
||||
assert rows[0]["department"] == "IT"
|
||||
|
||||
|
||||
def test_parse_identifier_content_from_lines():
|
||||
values = parse_identifier_content("alice\nbob@example.com\n")
|
||||
assert values == ["alice", "bob@example.com"]
|
||||
|
||||
|
||||
def test_parse_identifier_content_from_csv():
|
||||
content = "userPrincipalName,displayName\nalice@example.com,Alice\n"
|
||||
values = parse_identifier_content(content)
|
||||
assert values == ["alice@example.com"]
|
||||
|
||||
Reference in New Issue
Block a user