feat: 完善 API Token 管理功能

This commit is contained in:
2026-05-25 12:23:49 +08:00
parent 190ffa34f9
commit 4971cc380f
8 changed files with 279 additions and 44 deletions
+3 -2
View File
@@ -220,11 +220,11 @@ Server Portable 和桌面版需要在目标系统原生构建:Windows 包在 W
- `ExtractField`:字段映射配置。
登录密码保存在本地 `auth.ini`,该文件不应提交到版本库。
API Token 保存在本地 `api_tokens.json`,只保存 HMAC 哈希和显示用前后缀;完整 Token 只在创建或重生成时显示一次。该文件属于运行时数据,不应提交到版本库。
API Token 保存在本地 `api_tokens.json`,包含 HMAC 哈希、显示用前后缀和完整 Token,登录后可在列表中重复复制。该文件属于运行时数据,不应提交到版本库。
## API Token 与文档
登录后在 `系统设置 > API Token` 可生成、停用、设置永久或指定日期到期的 API Token;左侧 `API 文档` 只展示内置 Swagger 文档。API 文档基于本地 `swagger-ui-dist` 打包,不依赖外网 CDN。
登录后在 `系统设置 > API Token` 可生成、复制、启用/停用、批量删除、设置永久或指定日期到期的 API Token;左侧 `API 文档` 只展示内置 Swagger 文档。API 文档基于本地 `swagger-ui-dist` 打包,不依赖外网 CDN。
如果 Token 未设置为永久有效,则必须明确选择到期日期;到期、停用或重生成后的旧 Token 都不能继续调用业务 API。
Token 调用方式:
@@ -239,6 +239,7 @@ X-API-Token: <token>
API Token 与登录态一样可访问业务 API,包括文件上传、远程下载并处理、数据库表查询、筛选查询、导出以及 `/api/database/execute` 自定义 SQL 执行。Token 管理、系统配置、授权和 API 文档本身仍要求登录后访问。
桌面端和配置了 `VITE_API_BASE` 的部署中,API 文档会自动使用当前后端基址加载 OpenAPI,并且不会覆盖用户在 Swagger UI 中手动填写的 API Token;Swagger 默认隐藏底部 Schemas 区域,接口分组、说明和常用请求示例使用中文。
配置下载会在 JSON 中附带 `ApiTokens`,配置上传时如果包含该字段会同步恢复 API Token。
授权到期日期保存在本地加密文件 `license.dat`,默认到期日由 `app/services/license.py` 中的 `DEFAULT_EXPIRES_ON` 控制,当前为 `2026-06-20`。处理任务不会读取系统日期,而是从任务目录 ZIP 文件名中的 `YYYYMMDDHHMM` 或 `YYYYMMDDHHMMSS` 时间戳取最大日期进行比对。登录后连续点击左上角品牌图标 8 次,可主动打开授权延期窗口。
+21 -7
View File
@@ -3,7 +3,7 @@ from typing import Any
from fastapi import APIRouter, Body, HTTPException, Request
from app.auth import resolve_login_context
from app.services.api_tokens import create_token, delete_token, list_tokens, regenerate_token, update_token
from app.services.api_tokens import create_token, delete_token, delete_tokens, list_tokens, regenerate_token, update_token
router = APIRouter(tags=["api-tokens"])
@@ -64,13 +64,16 @@ async def update_api_token(request: Request, payload: dict[str, Any] = Body(...)
if not token_id:
raise HTTPException(status_code=400, detail="缺少 Token ID")
changes: dict[str, Any] = {}
if "name" in payload:
changes["name"] = payload.get("name")
if "enabled" in payload:
changes["enabled"] = payload.get("enabled")
if "permanent" in payload or "expires_at" in payload:
changes["expires_at"] = _resolve_expires_at(payload)
try:
record = update_token(
token_id,
name=payload.get("name"),
enabled=payload.get("enabled"),
expires_at=_resolve_expires_at(payload),
)
record = update_token(token_id, **changes)
return {"success": True, "message": "API Token 已更新", "record": record}
except ValueError as exc:
raise _bad_expiration_error(exc) from exc
@@ -108,6 +111,17 @@ async def delete_api_token(request: Request, payload: dict[str, Any] = Body(...)
return {"success": True, "message": "API Token 已删除"}
@router.post("/api/tokens/batch-delete")
async def batch_delete_api_tokens(request: Request, payload: dict[str, Any] = Body(...)):
_require_login(request)
token_ids = payload.get("ids", [])
if not isinstance(token_ids, list) or not token_ids:
raise HTTPException(status_code=400, detail="请选择要删除的 Token")
deleted_count = delete_tokens([str(token_id) for token_id in token_ids])
return {"success": True, "message": f"已删除 {deleted_count} 个 API Token", "deleted_count": deleted_count}
@router.get("/api/docs-info")
async def docs_info(request: Request):
_require_login(request)
+6 -1
View File
@@ -8,6 +8,7 @@ from fastapi.responses import Response
from app import state
from app.config import HistoryRetentionConfig, RemoteDataConfig
from app.services.api_tokens import export_tokens, import_tokens
router = APIRouter(tags=["config"])
@@ -77,7 +78,9 @@ async def update_extract_fields(fields: list[dict[str, Any]] = Body(...)):
async def download_config():
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"Configure_{timestamp}.json"
content = json.dumps(state.current_config().to_file_dict(), ensure_ascii=False, indent=2)
config_data = state.current_config().to_file_dict()
config_data["ApiTokens"] = export_tokens()
content = json.dumps(config_data, ensure_ascii=False, indent=2)
return Response(
content=content,
media_type="application/json",
@@ -97,6 +100,8 @@ async def upload_config(file: UploadFile = File(...)):
state.reload_config()
_apply_config_data(data)
if "ApiTokens" in data:
import_tokens(data["ApiTokens"])
state.config.save()
return {"success": True, "message": "配置文件上传成功", "update": state.config.update}
except json.JSONDecodeError as exc:
+13 -5
View File
@@ -63,7 +63,7 @@ OPENAPI_TAGS = [
{"name": "系统配置", "description": "数据库、远程数据源、过滤规则、字段映射等配置。仅登录态可调用。"},
{"name": "缓存", "description": "查看服务端缓存占用。"},
{"name": "授权", "description": "查看和延长程序授权有效期。仅登录态可调用。"},
{"name": "API Token", "description": "生成、编辑、重生成和删除 API Token。仅登录态可调用。"},
{"name": "API Token", "description": "生成、复制、启停、编辑、重生成和批量删除 API Token。仅登录态可调用。"},
{"name": "健康检查", "description": "服务健康状态检查。"},
]
OPENAPI_OPERATION_DOCS = {
@@ -266,10 +266,13 @@ OPENAPI_OPERATION_DOCS = {
"description": "设置 Excel/CSV 源字段到数据库字段的映射规则。",
"example": [{"Field": "日期时间", "Extract": ["开始时间"], "Type": "datetime"}],
},
("get", "/api/config/download"): {"summary": "下载配置文件", "description": "下载当前 Configure.json。"},
("get", "/api/config/download"): {
"summary": "下载配置文件",
"description": "下载当前 Configure.json,并附带 ApiTokens,用于迁移或恢复 API Token 配置。",
},
("post", "/api/config/upload"): {
"summary": "上传配置文件",
"description": "上传并应用 Configure.json。仅登录态可访问。",
"description": "上传并应用 Configure.json。文件中包含 ApiTokens 时会同步恢复 API Token。仅登录态可访问。",
"request_description": "multipart/form-data,file 为 Configure.json 文件。",
},
("get", "/api/cache/size"): {"summary": "查询缓存大小", "description": "统计当前 cache 目录的大小、文件数和目录数。"},
@@ -279,10 +282,10 @@ OPENAPI_OPERATION_DOCS = {
"description": "提交激活码,将授权到期日期延长 30 天。",
"example": {"code": "sha256-value"},
},
("get", "/api/tokens"): {"summary": "列出 API Token", "description": "返回已创建 Token 的脱敏列表。仅登录态可访问。"},
("get", "/api/tokens"): {"summary": "列出 API Token", "description": "返回已创建 Token 列表,包含可复制的完整 Token。仅登录态可访问。"},
("post", "/api/tokens/create"): {
"summary": "生成 API Token",
"description": "创建新的 API Token。完整 Token 只在本次响应中返回一次。",
"description": "创建新的 API Token。完整 Token 会保存到本地,后续可在列表中重复复制。",
"example": {"name": "外部系统接入", "permanent": True, "expires_at": None, "enabled": True},
},
("post", "/api/tokens/update"): {
@@ -300,6 +303,11 @@ OPENAPI_OPERATION_DOCS = {
"description": "删除指定 Token。",
"example": {"id": "token-id"},
},
("post", "/api/tokens/batch-delete"): {
"summary": "批量删除 API Token",
"description": "按 ID 批量删除 Token。",
"example": {"ids": ["token-id-1", "token-id-2"]},
},
("get", "/api/docs-info"): {"summary": "查询 API 文档入口", "description": "返回 API 文档和 OpenAPI JSON 地址。仅登录态可访问。"},
}
+69 -14
View File
@@ -29,6 +29,7 @@ class ApiTokenRecord:
enabled: bool
last_used_at: str | None = None
last_used_from: str | None = None
token: str | None = None
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@@ -48,6 +49,28 @@ def list_tokens() -> list[dict[str, Any]]:
return [record_to_public_dict(record) for record in _load_records()]
def export_tokens() -> list[dict[str, Any]]:
with _STORE_LOCK:
return [record.to_dict() for record in _load_records()]
def import_tokens(items: Any) -> int:
if not isinstance(items, list):
return 0
records: list[ApiTokenRecord] = []
for item in items:
if not isinstance(item, dict):
continue
record = _record_from_dict(item)
if record.token_hash:
records.append(record)
with _STORE_LOCK:
_save_records(records)
return len(records)
def create_token(
name: str,
expires_in_days: int | None = None,
@@ -68,6 +91,7 @@ def create_token(
created_at=utc_now(),
expires_at=resolved_expires_at,
enabled=bool(enabled),
token=raw_token,
)
with _STORE_LOCK:
@@ -105,6 +129,18 @@ def delete_token(token_id: str) -> None:
_save_records(records)
def delete_tokens(token_ids: list[str]) -> int:
token_id_set = {str(token_id).strip() for token_id in token_ids if str(token_id).strip()}
if not token_id_set:
return 0
with _STORE_LOCK:
records = _load_records()
kept_records = [record for record in records if record.id not in token_id_set]
_save_records(kept_records)
return len(records) - len(kept_records)
def regenerate_token(token_id: str) -> tuple[str, dict[str, Any]]:
with _STORE_LOCK:
records = _load_records()
@@ -116,6 +152,7 @@ def regenerate_token(token_id: str) -> tuple[str, dict[str, Any]]:
record.token_hash = hash_token(raw_token)
record.prefix = raw_token[:12]
record.suffix = raw_token[-12:]
record.token = raw_token
record.created_at = utc_now()
record.last_used_at = None
record.last_used_from = None
@@ -222,6 +259,8 @@ def record_to_public_dict(record: ApiTokenRecord, include_hash: bool = False) ->
"last_used_at": record.last_used_at,
"last_used_from": record.last_used_from,
"expired": bool(expires_at and expires_at < datetime.now(timezone.utc)),
"token": record.token,
"token_available": bool(record.token),
}
if include_hash:
data["token_hash"] = record.token_hash
@@ -250,25 +289,41 @@ def _load_records() -> list[ApiTokenRecord]:
for item in tokens:
if not isinstance(item, dict):
continue
records.append(
ApiTokenRecord(
id=str(item.get("id", "")) or secrets.token_hex(8),
name=str(item.get("name", "未命名 Token")),
token_hash=str(item.get("token_hash", "")),
prefix=str(item.get("prefix", "")),
suffix=str(item.get("suffix", "")),
created_at=str(item.get("created_at", utc_now())),
expires_at=item.get("expires_at"),
enabled=bool(item.get("enabled", True)),
last_used_at=item.get("last_used_at"),
last_used_from=item.get("last_used_from"),
)
)
record = _record_from_dict(item)
if record.token_hash:
records.append(record)
records.sort(key=lambda record: record.created_at, reverse=True)
return records
def _record_from_dict(item: dict[str, Any]) -> ApiTokenRecord:
raw_token = str(item.get("token") or "").strip() or None
token_hash = str(item.get("token_hash") or "").strip()
if raw_token and not token_hash:
token_hash = hash_token(raw_token)
prefix = str(item.get("prefix") or "")
suffix = str(item.get("suffix") or "")
if raw_token:
prefix = prefix or raw_token[:12]
suffix = suffix or raw_token[-12:]
return ApiTokenRecord(
id=str(item.get("id", "")) or secrets.token_hex(8),
name=str(item.get("name", "未命名 Token")),
token_hash=token_hash,
prefix=prefix,
suffix=suffix,
created_at=str(item.get("created_at", utc_now())),
expires_at=item.get("expires_at"),
enabled=bool(item.get("enabled", True)),
last_used_at=item.get("last_used_at"),
last_used_from=item.get("last_used_from"),
token=raw_token,
)
def _save_records(records: list[ApiTokenRecord]) -> None:
payload = {"tokens": [record.to_dict() for record in records]}
API_TOKENS_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+9
View File
@@ -518,3 +518,12 @@
- `scripts/build.ps1` and `scripts/build.sh` now use `dist/.tmp/` for PyInstaller work output and copy final deliverables to `dist/server/`, `dist/desktop/`, and `dist/docker/`. Successful builds remove `dist/.tmp`, `frontend/dist`, `src-tauri/target`, and `src-tauri/binaries`.
- Docker builds now include `Configure.json` in the image and also create a deployable `dist/docker/` bundle containing `capacity-report-app-latest.tar`, `docker-compose.yml`, `Configure.json`, `ReportScript.sql`, `mysql/`, `cache/`, and `logs/`.
- Server Portable still includes `Configure.json` and `ReportScript.sql` inside `dist/server/CapacityReport-Server-<platform>-x64/`. Tauri desktop still bundles both files through `src-tauri/tauri.conf.json` resources and copies them to app data on first run.
## 2026-05-25: API Token management improvements
- API Token records now persist the complete token value in `api_tokens.json` in addition to the HMAC hash, prefix, and suffix. Existing hash-only records remain readable but cannot expose the full token; the UI asks users to regenerate those tokens before copying.
- `app/services/api_tokens.py` now supports exporting/importing token records for configuration migration and batch deletion by token ID. Config download adds an `ApiTokens` block, and config upload restores it when present.
- Token update is a partial update path: callers may change only `name`, `enabled`, or expiration fields without accidentally changing unspecified fields.
- `frontend/src/components/ApiTokenManager.vue` now shows selectable token rows, a batch delete button, a compact per-row action dropdown, copy-token action, and enable/disable action. New token creation defaults to a specified expiration date one month after the current browser date, while permanent tokens remain available via the radio option.
- OpenAPI and README text were updated to describe repeatable token copying, token migration through config upload/download, and batch delete.
- Verification performed: `api_tokens` service create/list/verify/enable/disable/export/import/batch-delete test passed, HTTP endpoints for create/list/update/config download/batch-delete passed on local port `9081`, `.venv\Scripts\python.exe -m compileall app` passed, and `npm run build` passed with only the existing Vite large chunk warning.
+156 -15
View File
@@ -3,6 +3,16 @@
<n-card size="small" class="work-card api-token-card" title="API Token">
<template #header-extra>
<n-space size="small">
<n-button
v-if="selectedTokenIds.length > 0"
size="small"
tertiary
type="error"
:loading="batchDeleting"
@click="confirmBatchDelete"
>
批量删除 {{ selectedTokenIds.length }}
</n-button>
<n-button size="small" type="primary" @click="openCreateDialog">
<template #icon><n-icon><AddOutline /></n-icon></template>
生成 Token
@@ -15,13 +25,18 @@
</template>
<div class="api-token-intro">
API Token 用于内网程序直接调用业务接口。完整 Token 只会在生成或重生成时显示一次,请立即复制保存。
API Token 用于内网程序直接调用业务接口。完整 Token 会保存到本地,可在列表中随时复制。
</div>
<n-spin v-if="loadingTokens" class="api-token-loading" />
<n-empty v-else-if="tokens.length === 0" description="暂无 API Token" />
<n-scrollbar v-else class="api-token-list">
<div v-for="token in tokens" :key="token.id" class="api-token-item">
<n-checkbox
class="api-token-select"
:checked="isTokenSelected(token.id)"
@update:checked="setTokenSelected(token.id, $event)"
/>
<div class="api-token-main">
<div class="api-token-title-row">
<span class="api-token-name">{{ token.name }}</span>
@@ -37,9 +52,16 @@
</div>
</div>
<div class="api-token-actions">
<n-button size="tiny" tertiary @click="openEditDialog(token)">编辑</n-button>
<n-button size="tiny" tertiary type="warning" @click="confirmRegenerate(token)">重生成</n-button>
<n-button size="tiny" tertiary type="error" @click="confirmDelete(token)">删除</n-button>
<n-dropdown
trigger="click"
:options="tokenActionOptions(token)"
@select="handleTokenAction(token, $event)"
>
<n-button size="tiny" tertiary>
<template #icon><n-icon><ChevronDownOutline /></n-icon></template>
操作
</n-button>
</n-dropdown>
</div>
</div>
</n-scrollbar>
@@ -57,7 +79,7 @@
<n-input v-model:value="tokenForm.name" placeholder="例如:外部系统接入" />
</n-form-item>
<n-form-item label="有效期">
<n-radio-group v-model:value="tokenForm.permanent">
<n-radio-group v-model:value="tokenForm.permanent" @update:value="handlePermanentChange">
<n-space>
<n-radio :value="true">永久有效</n-radio>
<n-radio :value="false">指定日期</n-radio>
@@ -93,8 +115,8 @@
title="Token 已生成"
:style="{ width: '520px', maxWidth: 'calc(100vw - 32px)' }"
>
<n-alert type="warning" :bordered="false">
这是唯一一次显示完整 Token,请立即复制并妥善保存。
<n-alert type="success" :bordered="false">
完整 Token 已保存,可在列表中随时复制。请注意只在可信内网环境中使用。
</n-alert>
<n-input
class="raw-token-input"
@@ -115,8 +137,8 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { useDialog, useMessage } from 'naive-ui';
import { AddOutline, RefreshOutline } from '@vicons/ionicons5';
import { useDialog, useMessage, type DropdownOption } from 'naive-ui';
import { AddOutline, ChevronDownOutline, RefreshOutline } from '@vicons/ionicons5';
import { apiGet, apiPost } from '../api/client';
import type { ApiMessage, ApiTokenListResponse, ApiTokenMutationResponse, ApiTokenRecord } from '../types';
@@ -130,13 +152,15 @@ const dialog = useDialog();
const tokens = ref<ApiTokenRecord[]>([]);
const loadingTokens = ref(false);
const savingToken = ref(false);
const batchDeleting = ref(false);
const tokenDialogVisible = ref(false);
const rawTokenVisible = ref(false);
const rawToken = ref('');
const editingToken = ref<ApiTokenRecord | null>(null);
const selectedTokenIds = ref<string[]>([]);
const tokenForm = reactive({
name: '',
permanent: true,
permanent: false,
expires_at: '',
enabled: true
});
@@ -150,6 +174,8 @@ async function loadTokens() {
try {
const result = await apiGet<ApiTokenListResponse>('/api/tokens');
tokens.value = result.tokens;
const tokenIds = new Set(tokens.value.map(token => token.id));
selectedTokenIds.value = selectedTokenIds.value.filter(id => tokenIds.has(id));
} catch (error) {
message.error(error instanceof Error ? error.message : '加载 Token 失败');
} finally {
@@ -160,8 +186,8 @@ async function loadTokens() {
function openCreateDialog() {
editingToken.value = null;
tokenForm.name = '';
tokenForm.permanent = true;
tokenForm.expires_at = '';
tokenForm.permanent = false;
tokenForm.expires_at = defaultExpirationDate();
tokenForm.enabled = true;
tokenDialogVisible.value = true;
}
@@ -170,17 +196,25 @@ function openEditDialog(token: ApiTokenRecord) {
editingToken.value = token;
tokenForm.name = token.name;
tokenForm.permanent = !token.expires_at;
tokenForm.expires_at = token.expires_at?.slice(0, 10) || '';
tokenForm.expires_at = token.expires_at?.slice(0, 10) || defaultExpirationDate();
tokenForm.enabled = token.enabled;
tokenDialogVisible.value = true;
}
function handlePermanentChange(value: boolean) {
tokenForm.permanent = value;
if (!value && !tokenForm.expires_at) {
tokenForm.expires_at = defaultExpirationDate();
}
}
async function saveToken() {
const expiresAt = tokenForm.expires_at || defaultExpirationDate();
const payload = {
id: editingToken.value?.id,
name: tokenForm.name.trim(),
permanent: tokenForm.permanent,
expires_at: tokenForm.permanent ? null : tokenForm.expires_at,
expires_at: tokenForm.permanent ? null : expiresAt,
enabled: tokenForm.enabled
};
if (!payload.name) {
@@ -233,6 +267,22 @@ async function regenerateToken(token: ApiTokenRecord) {
}
}
async function toggleTokenEnabled(token: ApiTokenRecord) {
try {
const result = await apiPost<ApiTokenMutationResponse>('/api/tokens/update', {
id: token.id,
name: token.name,
permanent: !token.expires_at,
expires_at: token.expires_at?.slice(0, 10) || null,
enabled: !token.enabled
});
message.success(result.message || (token.enabled ? 'Token 已停用' : 'Token 已启用'));
await loadTokens();
} catch (error) {
message.error(error instanceof Error ? error.message : '更新 Token 状态失败');
}
}
function confirmDelete(token: ApiTokenRecord) {
dialog.error({
title: '删除 Token',
@@ -253,11 +303,84 @@ async function deleteToken(token: ApiTokenRecord) {
}
}
function confirmBatchDelete() {
if (selectedTokenIds.value.length === 0) return;
dialog.error({
title: '批量删除 Token',
content: `确认删除选中的 ${selectedTokenIds.value.length} 个 Token 吗?此操作不可恢复。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: batchDeleteTokens
});
}
async function batchDeleteTokens() {
batchDeleting.value = true;
try {
const result = await apiPost<ApiMessage>('/api/tokens/batch-delete', { ids: selectedTokenIds.value });
selectedTokenIds.value = [];
message.success(result.message || 'Token 已删除');
await loadTokens();
} catch (error) {
message.error(error instanceof Error ? error.message : '批量删除 Token 失败');
} finally {
batchDeleting.value = false;
}
}
async function copyToken(token: ApiTokenRecord) {
if (!token.token) {
message.warning('该 Token 是旧版本生成的,未保存完整值,请重生成后再复制');
return;
}
await writeClipboard(token.token);
message.success('Token 已复制');
}
async function copyRawToken() {
await writeClipboard(rawToken.value);
message.success('Token 已复制');
}
function tokenActionOptions(token: ApiTokenRecord): DropdownOption[] {
return [
{ label: '复制 Token', key: 'copy' },
{ label: '编辑', key: 'edit' },
{ label: token.enabled ? '停用' : '启用', key: 'toggle' },
{ label: '重生成', key: 'regenerate' },
{ label: '删除', key: 'delete' }
];
}
function handleTokenAction(token: ApiTokenRecord, rawKey: string | number) {
const key = String(rawKey);
if (key === 'copy') {
void copyToken(token);
} else if (key === 'edit') {
openEditDialog(token);
} else if (key === 'toggle') {
void toggleTokenEnabled(token);
} else if (key === 'regenerate') {
confirmRegenerate(token);
} else if (key === 'delete') {
confirmDelete(token);
}
}
function isTokenSelected(tokenId: string): boolean {
return selectedTokenIds.value.includes(tokenId);
}
function setTokenSelected(tokenId: string, checked: boolean | string | number) {
if (Boolean(checked)) {
if (!selectedTokenIds.value.includes(tokenId)) {
selectedTokenIds.value.push(tokenId);
}
return;
}
selectedTokenIds.value = selectedTokenIds.value.filter(id => id !== tokenId);
}
async function writeClipboard(text: string) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
@@ -292,6 +415,19 @@ function formatDateTime(value?: string | null): string {
if (!value) return '-';
return value.replace('T', ' ').slice(0, 19);
}
function defaultExpirationDate(): string {
const date = new Date();
date.setMonth(date.getMonth() + 1);
return formatDateInput(date);
}
function formatDateInput(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
</script>
<style scoped>
@@ -342,6 +478,11 @@ function formatDateTime(value?: string | null): string {
border-bottom: 1px solid var(--td-border-color-light);
}
.api-token-select {
flex: 0 0 auto;
margin-top: 2px;
}
.api-token-main {
min-width: 0;
flex: 1;
@@ -382,7 +523,7 @@ function formatDateTime(value?: string | null): string {
.api-token-actions {
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
+2
View File
@@ -152,6 +152,8 @@ export interface ApiTokenRecord {
name: string;
prefix: string;
suffix: string;
token?: string | null;
token_available?: boolean;
created_at: string;
expires_at: string | null;
enabled: boolean;