Initial commit: Office365 web management platform

This commit is contained in:
youbin
2026-03-21 21:11:01 +08:00
commit 8d715a3a15
21 changed files with 3828 additions and 0 deletions

22
.env.example Normal file
View 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
View 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
View 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 IDAzure 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
View 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,
)

View 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
View 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
View 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
View 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
View 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
View 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),
)

View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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();

View File

@@ -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,
1 userPrincipalName displayName givenName surname department jobTitle usageLocation skuPartNumber password
2 alice Alice Zhang Alice Zhang Sales Manager US ENTERPRISEPACK Temp123!2026
3 bob@contoso.com Bob Li Bob Li IT Engineer US ENTERPRISEPACK

View File

@@ -0,0 +1,3 @@
userPrincipalName
alice@contoso.com
bob@contoso.com
1 userPrincipalName
2 alice@contoso.com
3 bob@contoso.com

View File

@@ -0,0 +1,3 @@
userPrincipalName,password,forceChangePasswordNextSignIn
alice@contoso.com,Temp123!2026,true
bob@contoso.com,Another123!2026,true
1 userPrincipalName password forceChangePasswordNextSignIn
2 alice@contoso.com Temp123!2026 true
3 bob@contoso.com Another123!2026 true

View File

@@ -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,
1 userPrincipalName department jobTitle officeLocation mobilePhone accountEnabled skuPartNumber
2 alice@contoso.com Operations Lead New York +15550000001 true ENTERPRISEPACK
3 bob@contoso.com Finance Analyst Seattle false

View 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
View 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)

View 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&#10;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
View 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
View 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
View 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"]