feat: 交付用户 API Key 生命周期 (#42)
This commit is contained in:
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 98adf48ab517493786cf27b241eecec500fce4c9
|
||||
synchronized_at: 2026-08-24T06:15:40Z
|
||||
wiki_revision: 92ed37aff92d1f54fd18a8aac4f15835b8082251
|
||||
synchronized_at: 2026-08-24T06:51:38Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -395,3 +395,13 @@ Provider 限流检查发生在真实上游调用前。放行后才执行 `BeginP
|
||||
- `internal/platform/apikey` 使用 `crypto/rand` 生成 256 bit secret,令牌格式为 `chorus_<public_id>_<secret>`;只计算并保存 secret 的 SHA-256,认证比较使用常量时间。完整令牌与哈希均被排除在默认 JSON 序列化之外。
|
||||
- `internal/core/apikey` 提供按 `user_id` 隔离的创建、读取、列表、改名和撤销仓储。改名与撤销可安全重放;跨用户读取不会返回其他用户记录。
|
||||
- `000006` 的 down 在 `api_keys` 或 `api_audit_events` 存在数据时主动失败,防止静默丢失安全数据。不得在生产用 `force` 绕过;只有完成备份、停服、风险确认和数据处置后才能回退。
|
||||
|
||||
<!-- issue-42:start -->
|
||||
## #42 用户 API Key 生命周期与 Portal 页面
|
||||
|
||||
- `portal/handler` 提供会话认证的 `/api-keys` 页面,以及 `GET/POST /api/api-keys`、`PATCH/DELETE /api/api-keys/:id`。写操作沿用 Portal CSRF;这些页面与响应统一禁止缓存。
|
||||
- `portal/service` 在事务内锁定并确认终端用户仍为 active,再按 `user_id` 调用 `internal/core/apikey` 仓储;改名、撤销和查询均不能越过用户边界。
|
||||
- 创建时由 `internal/platform/apikey` 生成凭据,数据库只保存 public id、可识别前缀与 secret hash。完整 token 只存在于单次创建响应,列表、改名、撤销和刷新响应均不返回。
|
||||
- `portal/web/templates/api_keys.html` 与 `static/api-keys.js` 实现加载、空、错误、停用、限流、一次展示、改名和不可恢复撤销状态;移动端改为卡片式行布局,桌面端保持紧凑表格。
|
||||
- OpenAPI Bearer 认证和生成接口属于后续工单,不在 #42 中从 API Key 页面直接调用上游。
|
||||
<!-- issue-42:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: 615749965eaf9a587a618f3698ea6dab52f02df7
|
||||
synchronized_at: 2026-08-24T06:15:45Z
|
||||
wiki_revision: 1bdd0adffefbf1fbb33ddfdab777e2f27e6aef83
|
||||
synchronized_at: 2026-08-24T06:51:46Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -146,3 +146,13 @@ synchronized_at: 2026-08-24T06:15:45Z
|
||||
- Key 的有效性同时取决于未撤销、未到期以及所属终端用户可用;管理员凭据与终端用户 API Key 继续分离。
|
||||
- API Key 名称去除首尾空白后必须为 1 至 80 字符。改名和撤销按 Key 所属用户执行并支持幂等重放,不物理删除记录。
|
||||
- `api_audit_events` 是追加写安全审计模型,摘要只能保存脱敏结构化信息;完整 Key、Authorization、Cookie、Prompt、文件内容和响应正文不得进入审计。
|
||||
|
||||
<!-- issue-42:start -->
|
||||
## 用户 API Key 生命周期(#42)
|
||||
|
||||
- 终端用户只能查看和管理自己的 API Key;停用用户不能列出、创建、改名或撤销 Key。
|
||||
- 名称去除首尾空白后必须为 1 至 80 个 Unicode 字符。创建时有效期只能选择 30、90、180 天或不设置到期时间。
|
||||
- 完整 Key 只在创建成功后显示一次并禁止缓存;数据库只保存不可逆 hash,刷新或以后查询不能再次取回。遗失时只能创建新 Key,并撤销旧 Key。
|
||||
- 撤销立即生效、不能恢复;重复撤销保持幂等。已过期或已撤销 Key 仍保留名称、前缀和状态,便于用户识别历史记录。
|
||||
- API Key 生命周期不包含注册、管理员代管、计费、点数、每日配额或 OpenAPI 调用;请求限流与审计分别由后续单元工单交付。
|
||||
<!-- issue-42:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/OPC/chorus/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 9661a72756f4c6d53eabd454df03625d382f1ac0
|
||||
synchronized_at: 2026-08-24T04:20:17Z
|
||||
wiki_revision: 71cf8fd56b2a18494d380464f59fc58d81283bd9
|
||||
synchronized_at: 2026-08-24T06:51:53Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -424,3 +424,20 @@ go test ./internal/core/apikey -count=1
|
||||
```
|
||||
|
||||
`CHORUS_DSN` 与 `CHORUS_MIGRATE_URL` 必须指向同一个明确创建的可丢弃库。测试会重置目标库结构,严禁指向 `chorus`、当前开发库、共享库或生产库。#41 已在本机 MySQL 8.4.8 独立库完成迁移 up/down/up、非空数据 down 拒绝、仓储创建/读取/改名/撤销和事务回滚验证。
|
||||
|
||||
<!-- issue-42:start -->
|
||||
## #42 Portal API Key 验证
|
||||
|
||||
普通回归和前端产物构建:
|
||||
|
||||
```powershell
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go -C admin test ./...
|
||||
pnpm --dir portal/web build
|
||||
```
|
||||
|
||||
涉及真实仓储与 Handler 的验证必须使用显式命名、可丢弃的 MySQL 8 隔离库,不得指向当前开发库、共享库或生产库。#42 已在独立库覆盖 CSRF、创建后仅一次返回完整 token、数据库 hash、刷新不回显、非法有效期、跨用户 404、停用用户 403、改名和幂等撤销。
|
||||
|
||||
浏览器 E2E 使用 `portal/web/e2e/fixture` 的合成用户和独立库,在 375、768、1024、1440 四种视口执行创建、一次展示、关闭后清除、刷新不可恢复、改名和撤销,并检查无外部请求、无页面横向溢出和至少 44px 的操作目标。测试截图只能在完整 token 已从 DOM 清除后生成。
|
||||
<!-- issue-42:end -->
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/model"
|
||||
"gorm.io/gorm"
|
||||
@@ -41,7 +42,7 @@ func (r *GORMRepository) Create(ctx context.Context, key *model.APIKey) error {
|
||||
key.Name = strings.TrimSpace(key.Name)
|
||||
key.PublicID = strings.TrimSpace(key.PublicID)
|
||||
key.KeyPrefix = strings.TrimSpace(key.KeyPrefix)
|
||||
if key.UserID == 0 || key.Name == "" || len(key.Name) > 80 || len(key.PublicID) != 24 || len(key.KeyPrefix) != 32 || len(key.SecretHash) != 32 {
|
||||
if key.UserID == 0 || key.Name == "" || !utf8.ValidString(key.Name) || utf8.RuneCountInString(key.Name) > 80 || len(key.PublicID) != 24 || len(key.KeyPrefix) != 32 || len(key.SecretHash) != 32 {
|
||||
return ErrInvalidAPIKey
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(key).Error; err != nil {
|
||||
@@ -86,7 +87,7 @@ func (r *GORMRepository) ListForUser(ctx context.Context, userID uint64) ([]mode
|
||||
|
||||
func (r *GORMRepository) Rename(ctx context.Context, id, userID uint64, name string) (bool, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if id == 0 || userID == 0 || name == "" || len(name) > 80 {
|
||||
if id == 0 || userID == 0 || name == "" || !utf8.ValidString(name) || utf8.RuneCountInString(name) > 80 {
|
||||
return false, ErrInvalidAPIKey
|
||||
}
|
||||
result := r.db.WithContext(ctx).Model(&model.APIKey{}).
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/OPC/chorus/portal/service"
|
||||
"git.ilapage.cn/OPC/chorus/portal/web"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const apiKeyBodyLimit = 16 << 10
|
||||
|
||||
func (h *Handler) apiKeysPage(c *gin.Context) {
|
||||
state := currentSession(c)
|
||||
if state.UserID == 0 {
|
||||
c.Redirect(http.StatusSeeOther, "/login?return_to=%2Fapi-keys")
|
||||
return
|
||||
}
|
||||
user, err := h.service.User(c.Request.Context(), state.UserID)
|
||||
if err != nil {
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.renderer.Render(c.Writer, "api-keys", web.Page{Title: "API Key", DisplayName: user.DisplayName, CSRFToken: state.CSRFToken}); err != nil {
|
||||
c.Status(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) listAPIKeys(c *gin.Context) {
|
||||
keys, err := h.service.ListAPIKeys(c.Request.Context(), currentSession(c).UserID, time.Now().UTC())
|
||||
if err != nil {
|
||||
h.apiKeyServiceError(c, err)
|
||||
return
|
||||
}
|
||||
noStore(c)
|
||||
c.JSON(http.StatusOK, gin.H{"items": keys})
|
||||
}
|
||||
|
||||
func (h *Handler) createAPIKey(c *gin.Context) {
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
ExpiresInDays *int `json:"expires_in_days"`
|
||||
}
|
||||
if !decodeAPIKeyJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
created, err := h.service.CreateAPIKey(c.Request.Context(), currentSession(c).UserID, input.Name, input.ExpiresInDays, time.Now().UTC())
|
||||
if err != nil {
|
||||
h.apiKeyServiceError(c, err)
|
||||
return
|
||||
}
|
||||
noStore(c)
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (h *Handler) renameAPIKey(c *gin.Context) {
|
||||
id, ok := uintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if !decodeAPIKeyJSON(c, &input) {
|
||||
return
|
||||
}
|
||||
key, err := h.service.RenameAPIKey(c.Request.Context(), currentSession(c).UserID, id, input.Name, time.Now().UTC())
|
||||
if err != nil {
|
||||
h.apiKeyServiceError(c, err)
|
||||
return
|
||||
}
|
||||
noStore(c)
|
||||
c.JSON(http.StatusOK, gin.H{"api_key": key})
|
||||
}
|
||||
|
||||
func (h *Handler) revokeAPIKey(c *gin.Context) {
|
||||
id, ok := uintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
key, err := h.service.RevokeAPIKey(c.Request.Context(), currentSession(c).UserID, id, time.Now().UTC())
|
||||
if err != nil {
|
||||
h.apiKeyServiceError(c, err)
|
||||
return
|
||||
}
|
||||
noStore(c)
|
||||
c.JSON(http.StatusOK, gin.H{"api_key": key})
|
||||
}
|
||||
|
||||
func decodeAPIKeyJSON(c *gin.Context, target any) bool {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, apiKeyBodyLimit)
|
||||
decoder := json.NewDecoder(c.Request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil || decoder.Decode(&struct{}{}) != io.EOF {
|
||||
writeError(c, http.StatusBadRequest, "invalid_request", "request body is invalid")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Handler) apiKeyServiceError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrAccountDisabled):
|
||||
noStore(c)
|
||||
writeError(c, http.StatusForbidden, "account_disabled", "account is not allowed to manage API keys")
|
||||
case errors.Is(err, service.ErrInvalidAPIKeyName):
|
||||
writeError(c, http.StatusBadRequest, "invalid_api_key_name", "API key name is invalid")
|
||||
case errors.Is(err, service.ErrInvalidAPIKeyExpiry):
|
||||
writeError(c, http.StatusBadRequest, "invalid_api_key_expiry", "API key expiry is invalid")
|
||||
case errors.Is(err, service.ErrNotFound):
|
||||
writeError(c, http.StatusNotFound, "not_found", "resource was not found")
|
||||
default:
|
||||
writeError(c, http.StatusInternalServerError, "internal_error", "request could not be completed")
|
||||
}
|
||||
}
|
||||
|
||||
func noStore(c *gin.Context) {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Pragma", "no-cache")
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/queue"
|
||||
corerouter "git.ilapage.cn/OPC/chorus/internal/core/router"
|
||||
corestorage "git.ilapage.cn/OPC/chorus/internal/core/storage"
|
||||
platformapikey "git.ilapage.cn/OPC/chorus/internal/platform/apikey"
|
||||
passwordpkg "git.ilapage.cn/OPC/chorus/internal/platform/password"
|
||||
platformstorage "git.ilapage.cn/OPC/chorus/internal/platform/storage"
|
||||
"git.ilapage.cn/OPC/chorus/portal/auth"
|
||||
@@ -97,8 +98,12 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var databaseName string
|
||||
if err := db.Raw("SELECT DATABASE()").Scan(&databaseName).Error; err != nil || databaseName != "chorus_test" {
|
||||
t.Fatalf("portal integration requires chorus_test database, got %q: %v", databaseName, err)
|
||||
expectedDatabase := strings.TrimSpace(os.Getenv("CHORUS_MIGRATION_TEST_DATABASE"))
|
||||
if expectedDatabase == "" {
|
||||
expectedDatabase = "chorus_test"
|
||||
}
|
||||
if err := db.Raw("SELECT DATABASE()").Scan(&databaseName).Error; err != nil || databaseName != expectedDatabase {
|
||||
t.Fatalf("portal integration requires %s database, got %q: %v", expectedDatabase, databaseName, err)
|
||||
}
|
||||
sqlDB, _ := db.DB()
|
||||
defer sqlDB.Close()
|
||||
@@ -122,6 +127,7 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) {
|
||||
}
|
||||
routeIDs, providerID := createPortalRoutes(t, db, suffix, templates)
|
||||
defer func() {
|
||||
db.Where("user_id IN ?", []uint64{users[0].ID, users[1].ID}).Delete(&model.APIKey{})
|
||||
db.Where("user_id IN ?", []uint64{users[0].ID, users[1].ID}).Delete(&model.Generation{})
|
||||
db.Exec("DELETE FROM active_routes WHERE route_pool_id IN ?", routeIDs)
|
||||
db.Exec("DELETE FROM route_pools WHERE id IN ?", routeIDs)
|
||||
@@ -208,6 +214,47 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) {
|
||||
if workspace.Code != http.StatusOK || !strings.Contains(workspace.Body.String(), "User A") || !strings.Contains(workspace.Body.String(), `content="1000"`) || !strings.Contains(workspace.Body.String(), `content="3"`) || strings.Contains(workspace.Body.String(), "cdn.") {
|
||||
t.Fatalf("workspace=%d %s", workspace.Code, workspace.Body.String())
|
||||
}
|
||||
apiKeyPage := clientA.do(http.MethodGet, "/api-keys", nil, "")
|
||||
if apiKeyPage.Code != http.StatusOK || !strings.Contains(apiKeyPage.Body.String(), "完整 Key 只在创建成功后显示一次") || !strings.Contains(apiKeyPage.Body.String(), `/static/api-keys.js`) {
|
||||
t.Fatalf("API key page=%d %s", apiKeyPage.Code, apiKeyPage.Body.String())
|
||||
}
|
||||
createKeyBody, _ := json.Marshal(map[string]any{"name": "自动化脚本", "expires_in_days": 90})
|
||||
validCSRF := clientA.csrf
|
||||
clientA.csrf = ""
|
||||
if response := clientA.do(http.MethodPost, "/api/api-keys", createKeyBody, "application/json"); response.Code != http.StatusForbidden || response.Header().Get("Cache-Control") != "no-store" {
|
||||
t.Fatalf("API key CSRF response=%d cache=%q %s", response.Code, response.Header().Get("Cache-Control"), response.Body.String())
|
||||
}
|
||||
clientA.csrf = validCSRF
|
||||
createKeyResponse := clientA.do(http.MethodPost, "/api/api-keys", createKeyBody, "application/json")
|
||||
var createdKey struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
KeyPrefix string `json:"key_prefix"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
if createKeyResponse.Code != http.StatusCreated || createKeyResponse.Header().Get("Cache-Control") != "no-store" || createKeyResponse.Header().Get("Pragma") != "no-cache" || json.Unmarshal(createKeyResponse.Body.Bytes(), &createdKey) != nil || createdKey.ID == 0 || createdKey.Token == "" || !strings.HasPrefix(createdKey.Token, createdKey.KeyPrefix) || createdKey.ExpiresAt == nil {
|
||||
t.Fatalf("create API key=%d cache=%q body=%s", createKeyResponse.Code, createKeyResponse.Header().Get("Cache-Control"), createKeyResponse.Body.String())
|
||||
}
|
||||
var storedKey model.APIKey
|
||||
if err := db.First(&storedKey, createdKey.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsedKey, err := platformapikey.Parse(createdKey.Token)
|
||||
if err != nil || parsedKey.PublicID != storedKey.PublicID || !bytes.Equal(parsedKey.SecretHash, storedKey.SecretHash) {
|
||||
t.Fatalf("stored API key does not match one-time token: %v", err)
|
||||
}
|
||||
listKeys := clientA.do(http.MethodGet, "/api/api-keys", nil, "")
|
||||
if listKeys.Code != http.StatusOK || !strings.Contains(listKeys.Body.String(), "自动化脚本") || strings.Contains(listKeys.Body.String(), createdKey.Token) || strings.Contains(listKeys.Body.String(), `secret_hash`) {
|
||||
t.Fatalf("list API keys=%d %s", listKeys.Code, listKeys.Body.String())
|
||||
}
|
||||
if refreshedPage := clientA.do(http.MethodGet, "/api-keys", nil, ""); strings.Contains(refreshedPage.Body.String(), createdKey.Token) {
|
||||
t.Fatal("API key page recovered the complete token")
|
||||
}
|
||||
invalidExpiry, _ := json.Marshal(map[string]any{"name": "Invalid expiry", "expires_in_days": 365})
|
||||
if response := clientA.do(http.MethodPost, "/api/api-keys", invalidExpiry, "application/json"); response.Code != http.StatusBadRequest || strings.Contains(response.Body.String(), createdKey.Token) {
|
||||
t.Fatalf("invalid API key expiry=%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
pendingPage := clientA.do(http.MethodGet, fmt.Sprintf("/generations/%d", textID), nil, "")
|
||||
if pendingPage.Code != http.StatusOK || !strings.Contains(pendingPage.Body.String(), "任务正在排队") || !strings.Contains(pendingPage.Body.String(), "hx-get=") {
|
||||
t.Fatalf("pending page=%d %s", pendingPage.Code, pendingPage.Body.String())
|
||||
@@ -338,6 +385,31 @@ func TestPortalAuthenticationSubmissionAndAuthorization(t *testing.T) {
|
||||
if response := clientB.do(http.MethodGet, fmt.Sprintf("/api/generations/%d", imageID), nil, ""); response.Code != 404 {
|
||||
t.Fatalf("cross-user detail=%d", response.Code)
|
||||
}
|
||||
renameKeyBody, _ := json.Marshal(map[string]string{"name": "其他用户不能改名"})
|
||||
if response := clientB.do(http.MethodPatch, fmt.Sprintf("/api/api-keys/%d", createdKey.ID), renameKeyBody, "application/json"); response.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-user API key rename=%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
if response := clientB.do(http.MethodDelete, fmt.Sprintf("/api/api-keys/%d", createdKey.ID), nil, ""); response.Code != http.StatusNotFound {
|
||||
t.Fatalf("cross-user API key revoke=%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
renameKeyBody, _ = json.Marshal(map[string]string{"name": "已改名脚本"})
|
||||
if response := clientA.do(http.MethodPatch, fmt.Sprintf("/api/api-keys/%d", createdKey.ID), renameKeyBody, "application/json"); response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "已改名脚本") || strings.Contains(response.Body.String(), createdKey.Token) {
|
||||
t.Fatalf("rename own API key=%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
if err := db.Model(&model.User{}).Where("id = ?", users[0].ID).Update("status", "disabled").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response := clientA.do(http.MethodGet, "/api/api-keys", nil, ""); response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), `"code":"account_disabled"`) {
|
||||
t.Fatalf("disabled user API keys=%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
if err := db.Model(&model.User{}).Where("id = ?", users[0].ID).Update("status", "active").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
firstRevoke := clientA.do(http.MethodDelete, fmt.Sprintf("/api/api-keys/%d", createdKey.ID), nil, "")
|
||||
secondRevoke := clientA.do(http.MethodDelete, fmt.Sprintf("/api/api-keys/%d", createdKey.ID), nil, "")
|
||||
if firstRevoke.Code != http.StatusOK || secondRevoke.Code != http.StatusOK || firstRevoke.Body.String() != secondRevoke.Body.String() || !strings.Contains(firstRevoke.Body.String(), `"status":"revoked"`) || strings.Contains(firstRevoke.Body.String(), createdKey.Token) {
|
||||
t.Fatalf("idempotent API key revoke=%d/%d %s / %s", firstRevoke.Code, secondRevoke.Code, firstRevoke.Body.String(), secondRevoke.Body.String())
|
||||
}
|
||||
if response := clientB.do(http.MethodGet, "/api/generations?limit=2&cursor="+firstHistory.NextCursor, nil, ""); response.Code != 400 || !strings.Contains(response.Body.String(), `"code":"invalid_cursor"`) {
|
||||
t.Fatalf("cross-user cursor=%d %s", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
@@ -58,12 +58,21 @@ func NewRouter(sessions *session.Manager, authService *auth.Service, generationS
|
||||
router.Use(handler.sessionMiddleware)
|
||||
router.GET("/login", handler.loginPage)
|
||||
router.GET("/", handler.appPage)
|
||||
router.GET("/api-keys", handler.apiKeysPage)
|
||||
router.GET("/generations/:id", handler.appPage)
|
||||
router.GET("/ui/generations/:id/result", handler.resultFragment)
|
||||
api := router.Group("/api")
|
||||
api.GET("/session", handler.sessionState)
|
||||
api.POST("/session/login", handler.csrf, handler.login)
|
||||
api.POST("/session/logout", handler.requireAuth, handler.csrf, handler.logout)
|
||||
apiKeys := api.Group("/api-keys", func(c *gin.Context) {
|
||||
noStore(c)
|
||||
c.Next()
|
||||
}, handler.requireAuth)
|
||||
apiKeys.GET("", handler.listAPIKeys)
|
||||
apiKeys.POST("", handler.csrf, handler.createAPIKey)
|
||||
apiKeys.PATCH("/:id", handler.csrf, handler.renameAPIKey)
|
||||
apiKeys.DELETE("/:id", handler.csrf, handler.revokeAPIKey)
|
||||
generations := api.Group("/generations", handler.requireAuth)
|
||||
generations.GET("", handler.history)
|
||||
generations.POST("/text", handler.csrf, handler.submitText)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
coreapikey "git.ilapage.cn/OPC/chorus/internal/core/apikey"
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/model"
|
||||
platformapikey "git.ilapage.cn/OPC/chorus/internal/platform/apikey"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAccountDisabled = errors.New("account_disabled")
|
||||
ErrInvalidAPIKeyName = errors.New("invalid_api_key_name")
|
||||
ErrInvalidAPIKeyExpiry = errors.New("invalid_api_key_expiry")
|
||||
)
|
||||
|
||||
type APIKeySummary struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
KeyPrefix string `json:"key_prefix"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
RevokedAt *time.Time `json:"revoked_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreatedAPIKey struct {
|
||||
APIKeySummary
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func (s *Service) ListAPIKeys(ctx context.Context, userID uint64, now time.Time) ([]APIKeySummary, error) {
|
||||
var rows []model.APIKey
|
||||
err := s.withActiveUser(ctx, userID, func(repository *coreapikey.GORMRepository) error {
|
||||
var err error
|
||||
rows, err = repository.ListForUser(ctx, userID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]APIKeySummary, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, apiKeySummary(row, now))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateAPIKey(ctx context.Context, userID uint64, name string, expiresInDays *int, now time.Time) (CreatedAPIKey, error) {
|
||||
name, err := validateAPIKeyName(name)
|
||||
if err != nil {
|
||||
return CreatedAPIKey{}, err
|
||||
}
|
||||
var expiresAt *time.Time
|
||||
if expiresInDays != nil {
|
||||
if *expiresInDays != 30 && *expiresInDays != 90 && *expiresInDays != 180 {
|
||||
return CreatedAPIKey{}, ErrInvalidAPIKeyExpiry
|
||||
}
|
||||
value := now.UTC().Add(time.Duration(*expiresInDays) * 24 * time.Hour)
|
||||
expiresAt = &value
|
||||
}
|
||||
credential, err := platformapikey.Generate()
|
||||
if err != nil {
|
||||
return CreatedAPIKey{}, err
|
||||
}
|
||||
row := model.APIKey{UserID: userID, Name: name, PublicID: credential.PublicID, KeyPrefix: credential.KeyPrefix, SecretHash: credential.SecretHash, ExpiresAt: expiresAt}
|
||||
if err := s.withActiveUser(ctx, userID, func(repository *coreapikey.GORMRepository) error {
|
||||
return repository.Create(ctx, &row)
|
||||
}); err != nil {
|
||||
return CreatedAPIKey{}, err
|
||||
}
|
||||
return CreatedAPIKey{APIKeySummary: apiKeySummary(row, now), Token: credential.Token}, nil
|
||||
}
|
||||
|
||||
func (s *Service) RenameAPIKey(ctx context.Context, userID, keyID uint64, name string, now time.Time) (APIKeySummary, error) {
|
||||
name, err := validateAPIKeyName(name)
|
||||
if err != nil {
|
||||
return APIKeySummary{}, err
|
||||
}
|
||||
var row model.APIKey
|
||||
err = s.withActiveUser(ctx, userID, func(repository *coreapikey.GORMRepository) error {
|
||||
updated, err := repository.Rename(ctx, keyID, userID, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !updated {
|
||||
return ErrNotFound
|
||||
}
|
||||
row, err = repository.ByIDForUser(ctx, keyID, userID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return APIKeySummary{}, mapAPIKeyRepositoryError(err)
|
||||
}
|
||||
return apiKeySummary(row, now), nil
|
||||
}
|
||||
|
||||
func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID uint64, now time.Time) (APIKeySummary, error) {
|
||||
var row model.APIKey
|
||||
err := s.withActiveUser(ctx, userID, func(repository *coreapikey.GORMRepository) error {
|
||||
revoked, err := repository.Revoke(ctx, keyID, userID, now.UTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !revoked {
|
||||
return ErrNotFound
|
||||
}
|
||||
row, err = repository.ByIDForUser(ctx, keyID, userID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return APIKeySummary{}, mapAPIKeyRepositoryError(err)
|
||||
}
|
||||
return apiKeySummary(row, now), nil
|
||||
}
|
||||
|
||||
func (s *Service) withActiveUser(ctx context.Context, userID uint64, operation func(*coreapikey.GORMRepository) error) error {
|
||||
if userID == 0 {
|
||||
return ErrAccountDisabled
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var user model.User
|
||||
err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Select("id").Where("id = ? AND status = 'active'", userID).First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrAccountDisabled
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify API key user: %w", err)
|
||||
}
|
||||
repository, err := coreapikey.NewGORMRepository(tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return operation(repository)
|
||||
})
|
||||
}
|
||||
|
||||
func validateAPIKeyName(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || !utf8.ValidString(name) || utf8.RuneCountInString(name) > 80 {
|
||||
return "", ErrInvalidAPIKeyName
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func mapAPIKeyRepositoryError(err error) error {
|
||||
if errors.Is(err, coreapikey.ErrAPIKeyNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func apiKeySummary(key model.APIKey, now time.Time) APIKeySummary {
|
||||
status := "active"
|
||||
if key.RevokedAt != nil {
|
||||
status = "revoked"
|
||||
} else if key.ExpiresAt != nil && !key.ExpiresAt.After(now) {
|
||||
status = "expired"
|
||||
}
|
||||
return APIKeySummary{ID: key.ID, Name: key.Name, KeyPrefix: key.KeyPrefix, Status: status, ExpiresAt: utcTime(key.ExpiresAt), LastUsedAt: utcTime(key.LastUsedAt), RevokedAt: utcTime(key.RevokedAt), CreatedAt: key.CreatedAt.UTC(), UpdatedAt: key.UpdatedAt.UTC()}
|
||||
}
|
||||
|
||||
func utcTime(value *time.Time) *time.Time {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
result := value.UTC()
|
||||
return &result
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/OPC/chorus/internal/core/model"
|
||||
)
|
||||
|
||||
func TestValidateAPIKeyNameUsesCharactersAndTrims(t *testing.T) {
|
||||
value := strings.Repeat("密", 80)
|
||||
if got, err := validateAPIKeyName(" " + value + " "); err != nil || got != value {
|
||||
t.Fatalf("validateAPIKeyName() = %q, %v", got, err)
|
||||
}
|
||||
if _, err := validateAPIKeyName(strings.Repeat("密", 81)); err != ErrInvalidAPIKeyName {
|
||||
t.Fatalf("81-character name error = %v", err)
|
||||
}
|
||||
if _, err := validateAPIKeyName(" "); err != ErrInvalidAPIKeyName {
|
||||
t.Fatalf("blank name error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeySummaryStatus(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
expiredAt := now.Add(-time.Minute)
|
||||
revokedAt := now.Add(-time.Hour)
|
||||
tests := []struct {
|
||||
key model.APIKey
|
||||
want string
|
||||
}{
|
||||
{model.APIKey{}, "active"},
|
||||
{model.APIKey{ExpiresAt: &expiredAt}, "expired"},
|
||||
{model.APIKey{ExpiresAt: &expiredAt, RevokedAt: &revokedAt}, "revoked"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := apiKeySummary(test.key, now).Status; got != test.want {
|
||||
t.Errorf("status = %q, want %q", got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,9 @@ func cleanup(db *gorm.DB, email string) error {
|
||||
var userID uint64
|
||||
_ = tx.Raw("SELECT id FROM users WHERE email=?", strings.ToLower(strings.TrimSpace(email))).Scan(&userID).Error
|
||||
if userID != 0 {
|
||||
if err := tx.Exec("DELETE FROM api_keys WHERE user_id=?", userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Exec("DELETE FROM generations WHERE user_id=?", userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -115,6 +115,66 @@ test.describe("portal responsive workflow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("API key responsive workflow", () => {
|
||||
test.skip(!email || !password, "CHORUS_E2E_EMAIL and CHORUS_E2E_PASSWORD are required");
|
||||
|
||||
test("create once, rename, refresh without secret, and revoke", async ({ page }, testInfo) => {
|
||||
const browserErrors = [];
|
||||
const externalRequests = [];
|
||||
page.on("console", message => { if (message.type() === "error") browserErrors.push(message.text()); });
|
||||
page.on("pageerror", error => browserErrors.push(error.message));
|
||||
page.on("request", request => {
|
||||
const url = new URL(request.url());
|
||||
if (url.protocol !== "blob:" && url.origin !== new URL(baseURL).origin) externalRequests.push(request.url());
|
||||
});
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await login(page);
|
||||
await page.getByRole("link", { name: "API Key" }).click();
|
||||
await expect(page).toHaveURL(/\/api-keys$/);
|
||||
await expect(page.getByRole("heading", { name: "API Key", level: 1 })).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expectMinimumTargetSize(page, ".button, .icon-button, .api-key-action, .nav-item");
|
||||
|
||||
const name = `浏览器测试 ${testInfo.project.name}`;
|
||||
await page.locator("#create-key-button").click();
|
||||
await expect(page.getByRole("dialog", { name: "创建 API Key" })).toBeVisible();
|
||||
await page.locator("#api-key-name").fill(name);
|
||||
await page.locator("#api-key-expiry").selectOption("30");
|
||||
await page.locator("#submit-create-key").click();
|
||||
await expect(page.getByRole("dialog", { name: "API Key 已创建" })).toBeVisible();
|
||||
const token = await page.locator("#secret-key-value").textContent();
|
||||
expect(token).toMatch(/^chorus_[A-Za-z0-9_-]{24}_[A-Za-z0-9_-]{43}$/);
|
||||
await page.locator("#confirm-secret-key").click();
|
||||
await expect(page.locator("#secret-key-modal")).toBeHidden();
|
||||
await expect(page.locator("#secret-key-value")).toHaveText("");
|
||||
await expect(page.getByText(token, { exact: true })).toHaveCount(0);
|
||||
|
||||
let row = page.getByRole("row").filter({ hasText: name });
|
||||
await expect(row).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByText(token, { exact: true })).toHaveCount(0);
|
||||
row = page.getByRole("row").filter({ hasText: name });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole("button", { name: "改名" }).click();
|
||||
const renamed = `${name} 已改名`;
|
||||
await page.locator("#rename-key-name").fill(renamed);
|
||||
await page.locator("#submit-rename-key").click();
|
||||
row = page.getByRole("row").filter({ hasText: renamed });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole("button", { name: "撤销" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "撤销 API Key" })).toContainText(renamed);
|
||||
await page.locator("#confirm-revoke-key").click();
|
||||
row = page.getByRole("row").filter({ hasText: renamed });
|
||||
await expect(row).toContainText("已撤销");
|
||||
await expect(row.getByRole("button", { name: "撤销" })).toHaveCount(0);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("api-keys-revoked.png"), fullPage: true });
|
||||
expect(externalRequests).toEqual([]);
|
||||
expect(browserErrors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("browser submits image generation and ordered image editing", async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "desktop-small", "terminal state flow runs once");
|
||||
test.skip(!email || !password || !process.env.CHORUS_E2E_DSN || !process.env.CHORUS_E2E_STORAGE_ROOT, "E2E fixture environment is required");
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
AlertCircle, ArrowDown, ArrowUp, Check, Clock3, Copy, Download, Eye, EyeOff,
|
||||
FileText, Image, LogOut, Plus, RefreshCw, Search, ShieldCheck, X,
|
||||
AlertCircle, ArrowDown, ArrowUp, Ban, Check, CircleCheck, Clock3, Copy, Download, Eye, EyeOff,
|
||||
FileText, Image, KeyRound, LogOut, Pencil, Plus, RefreshCw, Search, ShieldCheck, TriangleAlert, X,
|
||||
createIcons
|
||||
} from "lucide";
|
||||
|
||||
const icons = {
|
||||
AlertCircle, ArrowDown, ArrowUp, Check, Clock3, Copy, Download, Eye, EyeOff,
|
||||
FileText, Image, LogOut, Plus, RefreshCw, Search, ShieldCheck, X
|
||||
AlertCircle, ArrowDown, ArrowUp, Ban, Check, CircleCheck, Clock3, Copy, Download, Eye, EyeOff,
|
||||
FileText, Image, KeyRound, LogOut, Pencil, Plus, RefreshCw, Search, ShieldCheck, TriangleAlert, X
|
||||
};
|
||||
|
||||
window.renderChorusIcons = (root = document) => createIcons({ icons, attrs: { "aria-hidden": "true" }, root });
|
||||
|
||||
@@ -48,7 +48,9 @@ button:disabled { cursor: not-allowed; opacity: .48; }
|
||||
.brand { display: inline-flex; align-items: center; gap: 10px; min-width: 0; color: var(--text); font-size: 20px; font-weight: 750; text-decoration: none; }
|
||||
.brand-mark { display: grid; width: 34px; height: 34px; place-items: center; color: #fff; background: var(--primary); border-radius: 6px; font-size: 17px; font-weight: 800; }
|
||||
.primary-nav { display: flex; align-items: center; align-self: stretch; gap: 8px; }
|
||||
.nav-item { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; color: var(--primary); text-decoration: none; border-bottom: 3px solid var(--primary); font-weight: 650; }
|
||||
.nav-item { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; color: var(--text-muted); text-decoration: none; border-bottom: 3px solid transparent; font-weight: 650; }
|
||||
.nav-item:hover { color: var(--text); background: var(--surface-subtle); }
|
||||
.nav-item[aria-current="page"] { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
.account-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.user-name { max-width: 130px; overflow: hidden; color: var(--text-muted); text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
@@ -58,6 +60,8 @@ button:disabled { cursor: not-allowed; opacity: .48; }
|
||||
.button-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
|
||||
.button-secondary { color: var(--text); background: var(--surface); border-color: var(--border-strong); }
|
||||
.button-secondary:hover { background: var(--surface-strong); border-color: #8fa1a5; }
|
||||
.button-danger { color: #fff; background: var(--danger); border-color: var(--danger); }
|
||||
.button-danger:hover { background: #862b27; border-color: #862b27; }
|
||||
.button-block { width: 100%; }
|
||||
.icon-button { display: inline-grid; width: 44px; height: 44px; padding: 0; place-items: center; color: var(--text-muted); background: transparent; border: 1px solid transparent; }
|
||||
.icon-button:hover { color: var(--text); background: var(--surface-strong); border-color: var(--border); }
|
||||
@@ -191,6 +195,45 @@ h3 { margin: 0; font-size: 16px; line-height: 1.4; letter-spacing: 0; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
||||
.toast { position: fixed; z-index: 120; right: 20px; bottom: 20px; display: flex; align-items: center; gap: 10px; max-width: min(420px,calc(100vw - 32px)); padding: 12px 14px; color: #fff; background: #203038; border-radius: 6px; box-shadow: var(--shadow); }
|
||||
|
||||
.api-key-main { min-height: calc(100dvh - var(--header-height)); padding: 28px clamp(16px,4vw,48px) 56px; }
|
||||
.api-key-inner { width: 100%; max-width: 1240px; margin: 0 auto; }
|
||||
.api-key-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 20px; }
|
||||
.api-key-heading > div { min-width: 0; }
|
||||
.api-key-heading p:last-child { max-width: 72ch; margin: 8px 0 0; color: var(--text-muted); }
|
||||
.api-key-panel { overflow: hidden; }
|
||||
.api-key-count { flex: 0 0 auto; color: var(--text-muted); font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.api-key-table-wrap { width: 100%; overflow-x: auto; }
|
||||
.api-key-table { width: 100%; min-width: 940px; border-collapse: collapse; }
|
||||
.api-key-table th,.api-key-table td { padding: 13px 14px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); }
|
||||
.api-key-table th { color: var(--text-muted); background: var(--surface-subtle); font-size: 13px; font-weight: 700; white-space: nowrap; }
|
||||
.api-key-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.api-key-table tbody tr:hover { background: #fbfdfc; }
|
||||
.api-key-name { max-width: 220px; font-weight: 700; }
|
||||
.api-key-prefix { color: #41565a; font-family: ui-monospace,SFMono-Regular,Consolas,monospace; font-size: 13px; }
|
||||
.api-key-badge { display: inline-flex; align-items: center; min-height: 28px; padding: 3px 9px; border: 1px solid var(--border); border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; }
|
||||
.api-key-badge.active { color: var(--success); background: var(--success-soft); border-color: #b6d6c0; }
|
||||
.api-key-badge.expired { color: var(--warning); background: var(--warning-soft); border-color: #e5c59e; }
|
||||
.api-key-badge.revoked { color: var(--text-soft); background: var(--surface-strong); }
|
||||
.api-key-actions { display: flex; align-items: center; gap: 4px; }
|
||||
.api-key-action { display: inline-flex; align-items: center; gap: 5px; min-height: 44px; padding: 6px 8px; color: var(--primary); background: transparent; border: 0; border-radius: 4px; font-weight: 650; white-space: nowrap; }
|
||||
.api-key-action:hover { background: var(--primary-soft); }
|
||||
.api-key-action.danger { color: var(--danger); }
|
||||
.api-key-action.danger:hover { background: var(--danger-soft); }
|
||||
.api-key-action .icon { width: 17px; height: 17px; }
|
||||
.api-key-loading { display: grid; gap: 14px; min-height: 260px; padding: 24px; }
|
||||
.api-key-loading span { height: 48px; background: linear-gradient(90deg,var(--surface-strong),#f7f9f9,var(--surface-strong)); background-size: 220% 100%; border-radius: 5px; animation: api-key-loading 1.4s ease-in-out infinite; }
|
||||
@keyframes api-key-loading { from { background-position: 100% 0; } to { background-position: -100% 0; } }
|
||||
.api-key-state { display: grid; place-items: center; align-content: center; min-height: 330px; padding: 28px; text-align: center; }
|
||||
.api-key-state p { max-width: 52ch; margin: 8px 0 18px; color: var(--text-muted); }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: -8px -8px 18px 0; }
|
||||
.modal-body { display: grid; gap: 18px; }
|
||||
.api-key-modal { width: min(100%,520px); }
|
||||
.one-time-warning { display: flex; align-items: flex-start; gap: 12px; padding: 13px 14px; color: var(--warning); background: var(--warning-soft); border: 1px solid #e5c59e; border-radius: 6px; }
|
||||
.one-time-warning .icon { flex: 0 0 22px; width: 22px; height: 22px; }
|
||||
.one-time-warning p { margin: 3px 0 0; color: #694522; font-size: 13px; }
|
||||
.secret-value { display: block; max-width: 100%; padding: 14px; color: var(--text); background: var(--surface-subtle); border: 1px solid var(--border-strong); border-radius: 6px; overflow-wrap: anywhere; user-select: all; }
|
||||
.confirm-key { padding: 12px; color: #41565a; background: var(--surface-subtle); border: 1px solid var(--border); border-radius: 6px; font-family: ui-monospace,SFMono-Regular,Consolas,monospace; overflow-wrap: anywhere; }
|
||||
|
||||
.login-view { display: grid; min-height: 100dvh; grid-template-columns: minmax(280px,.9fr) minmax(360px,1.1fr); background: var(--surface); }
|
||||
.login-context { display: flex; min-height: 100dvh; flex-direction: column; justify-content: space-between; padding: clamp(28px,6vw,72px); color: #f4fbfa; background: #173a3a; }
|
||||
.login-context .brand { color: #fff; }
|
||||
@@ -219,7 +262,8 @@ h3 { margin: 0; font-size: 16px; line-height: 1.4; letter-spacing: 0; }
|
||||
@media (max-width: 767px) {
|
||||
:root { --header-height: 56px; }
|
||||
.topbar { grid-template-columns: minmax(0,1fr) auto; height: auto; min-height: var(--header-height); padding: 0 12px; }
|
||||
.primary-nav { display: none; }
|
||||
.primary-nav { grid-column: 1 / -1; order: 3; width: 100%; overflow-x: auto; }
|
||||
.nav-item { flex: 1 0 auto; justify-content: center; padding: 0 10px; }
|
||||
.brand { font-size: 18px; }
|
||||
.user-name { display: none; }
|
||||
.workspace { grid-template-columns: minmax(0,1fr); grid-template-areas: "main" "history"; }
|
||||
@@ -238,6 +282,21 @@ h3 { margin: 0; font-size: 16px; line-height: 1.4; letter-spacing: 0; }
|
||||
.order-button { width: 100%; }
|
||||
.drag-hint { display: none; }
|
||||
.toast { right: 16px; bottom: 16px; left: 16px; max-width: none; }
|
||||
.api-key-main { padding: 20px 12px 32px; }
|
||||
.api-key-heading { align-items: stretch; flex-direction: column; gap: 14px; }
|
||||
.api-key-heading .button { width: 100%; }
|
||||
.api-key-panel .panel-header { align-items: flex-start; }
|
||||
.api-key-table,.api-key-table tbody,.api-key-table tr,.api-key-table td { display: block; min-width: 0; width: 100%; }
|
||||
.api-key-table thead { display: none; }
|
||||
.api-key-table tr { padding: 10px 14px; border-bottom: 1px solid var(--border); }
|
||||
.api-key-table tr:last-child { border-bottom: 0; }
|
||||
.api-key-table td { display: grid; grid-template-columns: 108px minmax(0,1fr); gap: 10px; padding: 7px 0; border: 0; }
|
||||
.api-key-table td::before { content: attr(data-label); color: var(--text-soft); font-size: 13px; font-weight: 650; }
|
||||
.api-key-name { max-width: none; }
|
||||
.api-key-actions { flex-wrap: wrap; margin-top: -6px; }
|
||||
.api-key-action { min-height: 44px; }
|
||||
.modal-backdrop { padding: 12px; }
|
||||
.modal { max-height: calc(100dvh - 24px); padding: 20px; overflow-y: auto; }
|
||||
.login-view { grid-template-columns: 1fr; }
|
||||
.login-context { min-height: auto; padding: 24px 20px; }
|
||||
.login-copy h1 { margin-top: 56px; font-size: 32px; }
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
let keys = [];
|
||||
let selectedKey = null;
|
||||
let lastTrigger = null;
|
||||
let toastTimer = null;
|
||||
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const csrfToken = () => $("meta[name='csrf-token']")?.content || "";
|
||||
const show = element => element?.classList.remove("hidden");
|
||||
const hide = element => element?.classList.add("hidden");
|
||||
const renderIcons = root => window.renderChorusIcons?.(root || document);
|
||||
|
||||
function toast(message) {
|
||||
$("#toast-text").textContent = message;
|
||||
show($("#toast"));
|
||||
window.clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => hide($("#toast")), 4000);
|
||||
}
|
||||
|
||||
function openModal(id, focusSelector) {
|
||||
lastTrigger = document.activeElement;
|
||||
const modal = document.getElementById(id);
|
||||
show(modal);
|
||||
window.setTimeout(() => modal?.querySelector(focusSelector)?.focus(), 0);
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
hide(document.getElementById(id));
|
||||
lastTrigger?.focus?.();
|
||||
lastTrigger = null;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "-";
|
||||
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
return { active: "有效", expired: "已过期", revoked: "已撤销" }[status] || "未知";
|
||||
}
|
||||
|
||||
function stateMarkup(type, retryAfter) {
|
||||
const states = {
|
||||
empty: ["key-round", "还没有 API Key", "创建后可用于程序调用;完整 Key 只显示一次。"],
|
||||
error: ["alert-circle", "API Key 加载失败", "已有数据不会被修改。请检查网络后重试。"],
|
||||
forbidden: ["shield-check", "当前账户无法管理 API Key", "账户已停用或当前会话不再具备访问条件。"],
|
||||
limited: ["clock-3", "请求过于频繁", `请在 ${retryAfter || 1} 秒后重试。已有 Key 不会受到影响。`]
|
||||
};
|
||||
const [icon, title, message] = states[type];
|
||||
const action = type === "empty" ? '<button class="button button-primary" type="button" data-action="create"><i class="icon" data-lucide="plus"></i>创建 API Key</button>' : (type === "error" || type === "limited" ? '<button class="button button-secondary" type="button" data-action="retry"><i class="icon" data-lucide="refresh-cw"></i>重试</button>' : "");
|
||||
return `<div class="api-key-state"><span class="state-icon ${type === "error" || type === "forbidden" ? "danger" : ""}"><i class="icon" data-lucide="${icon}"></i></span><h3>${title}</h3><p>${message}</p>${action}</div>`;
|
||||
}
|
||||
|
||||
function tableCell(label, value, className) {
|
||||
const cell = document.createElement("td");
|
||||
cell.dataset.label = label;
|
||||
if (className) cell.className = className;
|
||||
cell.textContent = value;
|
||||
return cell;
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const content = $("#api-key-content");
|
||||
content.setAttribute("aria-busy", "false");
|
||||
$("#api-key-count").textContent = `共 ${keys.length} 个`;
|
||||
if (!keys.length) {
|
||||
content.innerHTML = stateMarkup("empty");
|
||||
renderIcons(content);
|
||||
return;
|
||||
}
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "api-key-table-wrap";
|
||||
const table = document.createElement("table");
|
||||
table.className = "api-key-table";
|
||||
table.innerHTML = "<thead><tr><th>名称</th><th>Key 前缀</th><th>状态</th><th>最近使用</th><th>到期时间</th><th>创建时间</th><th>操作</th></tr></thead>";
|
||||
const tbody = document.createElement("tbody");
|
||||
keys.forEach(key => {
|
||||
const row = document.createElement("tr");
|
||||
row.append(tableCell("名称", key.name, "api-key-name"));
|
||||
row.append(tableCell("Key 前缀", key.key_prefix, "api-key-prefix"));
|
||||
const status = document.createElement("td");
|
||||
status.dataset.label = "状态";
|
||||
const badge = document.createElement("span");
|
||||
badge.className = `api-key-badge ${key.status}`;
|
||||
badge.textContent = statusLabel(key.status);
|
||||
status.append(badge);
|
||||
row.append(status);
|
||||
row.append(tableCell("最近使用", key.last_used_at ? formatDate(key.last_used_at) : "从未使用"));
|
||||
row.append(tableCell("到期时间", key.expires_at ? formatDate(key.expires_at) : "永不过期"));
|
||||
row.append(tableCell("创建时间", formatDate(key.created_at)));
|
||||
const actions = document.createElement("td");
|
||||
actions.dataset.label = "操作";
|
||||
const group = document.createElement("div");
|
||||
group.className = "api-key-actions";
|
||||
const rename = document.createElement("button");
|
||||
rename.className = "api-key-action";
|
||||
rename.type = "button";
|
||||
rename.dataset.action = "rename";
|
||||
rename.dataset.keyId = String(key.id);
|
||||
rename.innerHTML = '<i class="icon" data-lucide="pencil"></i><span>改名</span>';
|
||||
group.append(rename);
|
||||
if (key.status === "active") {
|
||||
const revoke = document.createElement("button");
|
||||
revoke.className = "api-key-action danger";
|
||||
revoke.type = "button";
|
||||
revoke.dataset.action = "revoke";
|
||||
revoke.dataset.keyId = String(key.id);
|
||||
revoke.innerHTML = '<i class="icon" data-lucide="ban"></i><span>撤销</span>';
|
||||
group.append(revoke);
|
||||
}
|
||||
actions.append(group);
|
||||
row.append(actions);
|
||||
tbody.append(row);
|
||||
});
|
||||
table.append(tbody);
|
||||
wrap.append(table);
|
||||
content.replaceChildren(wrap);
|
||||
renderIcons(content);
|
||||
}
|
||||
|
||||
function renderRequestState(type, retryAfter) {
|
||||
const content = $("#api-key-content");
|
||||
content.setAttribute("aria-busy", "false");
|
||||
$("#api-key-count").textContent = "";
|
||||
content.innerHTML = stateMarkup(type, retryAfter);
|
||||
renderIcons(content);
|
||||
}
|
||||
|
||||
function handleAccessResponse(response, modalID) {
|
||||
if (response.status === 401) {
|
||||
if (modalID) closeModal(modalID);
|
||||
openModal("auth-modal", "a");
|
||||
return true;
|
||||
}
|
||||
if (response.status === 403 || response.status === 429) {
|
||||
if (modalID) closeModal(modalID);
|
||||
renderRequestState(response.status === 403 ? "forbidden" : "limited", response.headers.get("Retry-After"));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function apiErrorMessage(payload, fallback) {
|
||||
return {
|
||||
invalid_api_key_name: "名称不能为空且不能超过 80 个字符。",
|
||||
invalid_api_key_expiry: "请选择有效的到期时间。",
|
||||
not_found: "API Key 不存在或已无法访问。"
|
||||
}[payload.error?.code] || fallback;
|
||||
}
|
||||
|
||||
async function loadKeys() {
|
||||
const content = $("#api-key-content");
|
||||
content.setAttribute("aria-busy", "true");
|
||||
content.innerHTML = '<div class="api-key-loading" aria-label="正在加载 API Key"><span></span><span></span><span></span></div>';
|
||||
try {
|
||||
const response = await fetch("/api/api-keys", { headers: { Accept: "application/json" }, cache: "no-store" });
|
||||
if (response.status === 401) { openModal("auth-modal", "a"); return; }
|
||||
if (response.status === 403) { renderRequestState("forbidden"); return; }
|
||||
if (response.status === 429) { renderRequestState("limited", response.headers.get("Retry-After")); return; }
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error?.code || "load_failed");
|
||||
keys = Array.isArray(payload.items) ? payload.items : [];
|
||||
renderList();
|
||||
} catch (_) {
|
||||
renderRequestState("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function createKey(event) {
|
||||
event.preventDefault();
|
||||
const name = $("#api-key-name");
|
||||
const error = $("#api-key-name-error");
|
||||
hide(error);
|
||||
if (!name.value.trim()) { error.textContent = "请输入 API Key 名称。"; show(error); name.focus(); return; }
|
||||
const button = $("#submit-create-key");
|
||||
button.disabled = true;
|
||||
button.querySelector("span").textContent = "正在创建";
|
||||
const expiry = $("#api-key-expiry").value;
|
||||
try {
|
||||
const response = await fetch("/api/api-keys", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken() }, body: JSON.stringify({ name: name.value, expires_in_days: expiry ? Number(expiry) : null }), cache: "no-store" });
|
||||
if (handleAccessResponse(response, "create-key-modal")) return;
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) { error.textContent = apiErrorMessage(payload, "创建失败,请稍后重试。"); show(error); return; }
|
||||
keys.unshift({ id: payload.id, name: payload.name, key_prefix: payload.key_prefix, status: payload.status, expires_at: payload.expires_at, last_used_at: payload.last_used_at, revoked_at: payload.revoked_at, created_at: payload.created_at, updated_at: payload.updated_at });
|
||||
closeModal("create-key-modal");
|
||||
renderList();
|
||||
$("#secret-key-value").textContent = payload.token;
|
||||
hide($("#copy-secret-error"));
|
||||
openModal("secret-key-modal", "#copy-secret-key");
|
||||
name.value = "";
|
||||
} catch (_) {
|
||||
error.textContent = "创建请求失败,请检查网络后重试。";
|
||||
show(error);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.querySelector("span").textContent = "创建";
|
||||
}
|
||||
}
|
||||
|
||||
async function renameKey(event) {
|
||||
event.preventDefault();
|
||||
const input = $("#rename-key-name");
|
||||
const error = $("#rename-key-error");
|
||||
hide(error);
|
||||
if (!selectedKey || !input.value.trim()) { error.textContent = "请输入 API Key 名称。"; show(error); input.focus(); return; }
|
||||
const button = $("#submit-rename-key");
|
||||
button.disabled = true;
|
||||
button.querySelector("span").textContent = "正在保存";
|
||||
try {
|
||||
const response = await fetch(`/api/api-keys/${encodeURIComponent(selectedKey.id)}`, { method: "PATCH", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken() }, body: JSON.stringify({ name: input.value }), cache: "no-store" });
|
||||
if (handleAccessResponse(response, "rename-key-modal")) return;
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) { error.textContent = apiErrorMessage(payload, "保存失败,请稍后重试。"); show(error); return; }
|
||||
const index = keys.findIndex(key => key.id === payload.api_key.id);
|
||||
if (index >= 0) keys[index] = payload.api_key;
|
||||
closeModal("rename-key-modal");
|
||||
renderList();
|
||||
toast("名称已更新");
|
||||
} catch (_) {
|
||||
error.textContent = "保存请求失败,请检查网络后重试。";
|
||||
show(error);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.querySelector("span").textContent = "保存";
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeKey() {
|
||||
if (!selectedKey) return;
|
||||
const error = $("#revoke-key-error");
|
||||
const button = $("#confirm-revoke-key");
|
||||
hide(error);
|
||||
button.disabled = true;
|
||||
button.querySelector("span").textContent = "正在撤销";
|
||||
try {
|
||||
const response = await fetch(`/api/api-keys/${encodeURIComponent(selectedKey.id)}`, { method: "DELETE", headers: { "X-CSRF-Token": csrfToken() }, cache: "no-store" });
|
||||
if (handleAccessResponse(response, "revoke-key-modal")) return;
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) { error.textContent = apiErrorMessage(payload, "撤销失败,请稍后重试。"); show(error); return; }
|
||||
const index = keys.findIndex(key => key.id === payload.api_key.id);
|
||||
if (index >= 0) keys[index] = payload.api_key;
|
||||
closeModal("revoke-key-modal");
|
||||
renderList();
|
||||
toast("API Key 已撤销");
|
||||
} catch (_) {
|
||||
error.textContent = "撤销请求失败,请检查网络后重试。";
|
||||
show(error);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.querySelector("span").textContent = "确认撤销";
|
||||
}
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
$("#create-key-button").addEventListener("click", () => openModal("create-key-modal", "#api-key-name"));
|
||||
$("#create-key-form").addEventListener("submit", createKey);
|
||||
$("#rename-key-form").addEventListener("submit", renameKey);
|
||||
$("#confirm-revoke-key").addEventListener("click", revokeKey);
|
||||
$("#confirm-secret-key").addEventListener("click", () => { $("#secret-key-value").textContent = ""; closeModal("secret-key-modal"); toast("API Key 已创建"); });
|
||||
$("#copy-secret-key").addEventListener("click", async () => {
|
||||
try { await navigator.clipboard.writeText($("#secret-key-value").textContent); hide($("#copy-secret-error")); toast("API Key 已复制"); }
|
||||
catch (_) { show($("#copy-secret-error")); }
|
||||
});
|
||||
document.addEventListener("click", event => {
|
||||
const close = event.target.closest("[data-close-modal]");
|
||||
if (close) { closeModal(close.dataset.closeModal); return; }
|
||||
const action = event.target.closest("[data-action]");
|
||||
if (!action) return;
|
||||
if (action.dataset.action === "create") { openModal("create-key-modal", "#api-key-name"); return; }
|
||||
if (action.dataset.action === "retry") { loadKeys(); return; }
|
||||
selectedKey = keys.find(key => String(key.id) === action.dataset.keyId);
|
||||
if (!selectedKey) return;
|
||||
if (action.dataset.action === "rename") { $("#rename-key-name").value = selectedKey.name; hide($("#rename-key-error")); openModal("rename-key-modal", "#rename-key-name"); }
|
||||
if (action.dataset.action === "revoke") { $("#revoke-key-summary").textContent = `${selectedKey.name} · ${selectedKey.key_prefix}`; hide($("#revoke-key-error")); openModal("revoke-key-modal", "#confirm-revoke-key"); }
|
||||
});
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key !== "Escape" || !$("#secret-key-modal").classList.contains("hidden")) return;
|
||||
const open = document.querySelector(".modal-backdrop:not(.hidden)");
|
||||
if (open && open.id !== "auth-modal") closeModal(open.id);
|
||||
});
|
||||
$("#logout-button").addEventListener("click", async () => { await fetch("/api/session/logout", { method: "POST", headers: { "X-CSRF-Token": csrfToken() } }).catch(() => {}); window.location.assign("/login"); });
|
||||
renderIcons();
|
||||
loadKeys();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initialize);
|
||||
})();
|
||||
File diff suppressed because one or more lines are too long
+7
-2
@@ -1,7 +1,7 @@
|
||||
(()=>{var u={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};var R=([e,a,t])=>{let r=document.createElementNS("http://www.w3.org/2000/svg",e);return Object.keys(a).forEach(o=>{r.setAttribute(o,String(a[o]))}),t?.length&&t.forEach(o=>{let s=R(o);r.appendChild(s)}),r},T=(e,a={})=>{let r={...u,...a};return R(["svg",r,e])};var q=e=>{for(let a in e)if(a.startsWith("aria-")||a==="role"||a==="title")return!0;return!1};var b=(...e)=>e.filter((a,t,r)=>!!a&&a.trim()!==""&&r.indexOf(a)===t).join(" ").trim();var U=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(a,t,r)=>r?r.toUpperCase():t.toLowerCase());var O=e=>{let a=U(e);return a.charAt(0).toUpperCase()+a.slice(1)};var I=e=>Array.from(e.attributes).reduce((a,t)=>(a[t.name]=t.value,a),{}),v=e=>typeof e=="string"?e:!e||!e.class?"":e.class&&typeof e.class=="string"?e.class.split(" "):e.class&&Array.isArray(e.class)?e.class:"",p=(e,{nameAttr:a,icons:t,attrs:r})=>{let o=e.getAttribute(a);if(o==null)return;let s=O(o),f=t[s];if(!f)return console.warn(`${e.outerHTML} icon name was not found in the provided icons object.`);let l=I(e),H=q(l)?{}:{"aria-hidden":"true"},L={...u,"data-lucide":o,...H,...r,...l},E=v(l),G=v(r),y=b("lucide",`lucide-${o}`,...E,...G);y&&Object.assign(L,{class:y});let V=T(f,L);return e.parentNode?.replaceChild(V,e)};var m=[["path",{d:"M12 5v14"}],["path",{d:"m19 12-7 7-7-7"}]];var x=[["path",{d:"m5 12 7-7 7 7"}],["path",{d:"M12 19V5"}]];var i=[["path",{d:"M20 6 9 17l-5-5"}]];var d=[["circle",{cx:"12",cy:"12",r:"10"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16"}]];var n=[["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"M12 6v6h4"}]];var c=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"}]];var C=[["path",{d:"M12 15V3"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["path",{d:"m7 10 5 5 5-5"}]];var h=[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"}],["path",{d:"m2 2 20 20"}]];var S=[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"}],["circle",{cx:"12",cy:"12",r:"3"}]];var g=[["path",{d:"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"}],["path",{d:"M14 2v5a1 1 0 0 0 1 1h5"}],["path",{d:"M10 9H8"}],["path",{d:"M16 13H8"}],["path",{d:"M16 17H8"}]];var w=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2"}],["circle",{cx:"9",cy:"9",r:"2"}],["path",{d:"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"}]];var k=[["path",{d:"m16 17 5-5-5-5"}],["path",{d:"M21 12H9"}],["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"}]];var P=[["path",{d:"M5 12h14"}],["path",{d:"M12 5v14"}]];var A=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"}],["path",{d:"M21 3v5h-5"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"}],["path",{d:"M8 16H3v5"}]];var M=[["path",{d:"m21 21-4.34-4.34"}],["circle",{cx:"11",cy:"11",r:"8"}]];var B=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"}],["path",{d:"m9 12 2 2 4-4"}]];var F=[["path",{d:"M18 6 6 18"}],["path",{d:"m6 6 12 12"}]];var D=({icons:e={},nameAttr:a="data-lucide",attrs:t={},root:r=document,inTemplates:o}={})=>{if(!Object.values(e).length)throw new Error(`Please provide an icons object.
|
||||
(()=>{var u={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};var O=([e,a,t])=>{let r=document.createElementNS("http://www.w3.org/2000/svg",e);return Object.keys(a).forEach(o=>{r.setAttribute(o,String(a[o]))}),t?.length&&t.forEach(o=>{let s=O(o);r.appendChild(s)}),r},v=(e,a={})=>{let r={...u,...a};return O(["svg",r,e])};var H=e=>{for(let a in e)if(a.startsWith("aria-")||a==="role"||a==="title")return!0;return!1};var E=(...e)=>e.filter((a,t,r)=>!!a&&a.trim()!==""&&r.indexOf(a)===t).join(" ").trim();var G=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(a,t,r)=>r?r.toUpperCase():t.toLowerCase());var V=e=>{let a=G(e);return a.charAt(0).toUpperCase()+a.slice(1)};var K=e=>Array.from(e.attributes).reduce((a,t)=>(a[t.name]=t.value,a),{}),I=e=>typeof e=="string"?e:!e||!e.class?"":e.class&&typeof e.class=="string"?e.class.split(" "):e.class&&Array.isArray(e.class)?e.class:"",x=(e,{nameAttr:a,icons:t,attrs:r})=>{let o=e.getAttribute(a);if(o==null)return;let s=V(o),f=t[s];if(!f)return console.warn(`${e.outerHTML} icon name was not found in the provided icons object.`);let l=K(e),W=H(l)?{}:{"aria-hidden":"true"},b={...u,"data-lucide":o,...W,...r,...l},z=I(l),X=I(r),U=E("lucide",`lucide-${o}`,...z,...X);U&&Object.assign(b,{class:U});let N=v(f,b);return e.parentNode?.replaceChild(N,e)};var i=[["path",{d:"M12 5v14"}],["path",{d:"m19 12-7 7-7-7"}]];var n=[["path",{d:"m5 12 7-7 7 7"}],["path",{d:"M12 19V5"}]];var c=[["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"M4.929 4.929 19.07 19.071"}]];var C=[["path",{d:"M20 6 9 17l-5-5"}]];var d=[["circle",{cx:"12",cy:"12",r:"10"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16"}]];var p=[["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"m9 12 2 2 4-4"}]];var h=[["circle",{cx:"12",cy:"12",r:"10"}],["path",{d:"M12 6v6h4"}]];var S=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"}]];var g=[["path",{d:"M12 15V3"}],["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["path",{d:"m7 10 5 5 5-5"}]];var w=[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"}],["path",{d:"m2 2 20 20"}]];var k=[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"}],["circle",{cx:"12",cy:"12",r:"3"}]];var P=[["path",{d:"M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"}],["path",{d:"M14 2v5a1 1 0 0 0 1 1h5"}],["path",{d:"M10 9H8"}],["path",{d:"M16 13H8"}],["path",{d:"M16 17H8"}]];var A=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2"}],["circle",{cx:"9",cy:"9",r:"2"}],["path",{d:"m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"}]];var M=[["path",{d:"M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"}],["circle",{cx:"16.5",cy:"7.5",r:".5",fill:"currentColor"}]];var B=[["path",{d:"m16 17 5-5-5-5"}],["path",{d:"M21 12H9"}],["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"}]];var F=[["path",{d:"M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"}],["path",{d:"m15 5 4 4"}]];var D=[["path",{d:"M5 12h14"}],["path",{d:"M12 5v14"}]];var L=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"}],["path",{d:"M21 3v5h-5"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"}],["path",{d:"M8 16H3v5"}]];var y=[["path",{d:"m21 21-4.34-4.34"}],["circle",{cx:"11",cy:"11",r:"8"}]];var R=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"}],["path",{d:"m9 12 2 2 4-4"}]];var m=[["path",{d:"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"}],["path",{d:"M12 9v4"}],["path",{d:"M12 17h.01"}]];var T=[["path",{d:"M18 6 6 18"}],["path",{d:"m6 6 12 12"}]];var q=({icons:e={},nameAttr:a="data-lucide",attrs:t={},root:r=document,inTemplates:o}={})=>{if(!Object.values(e).length)throw new Error(`Please provide an icons object.
|
||||
If you want to use all the icons you can import it like:
|
||||
\`import { createIcons, icons } from 'lucide';
|
||||
lucide.createIcons({icons});\``);if(typeof r>"u")throw new Error("`createIcons()` only works in a browser environment.");if(Array.from(r.querySelectorAll(`[${a}]`)).forEach(f=>p(f,{nameAttr:a,icons:e,attrs:t})),o&&Array.from(r.querySelectorAll("template")).forEach(l=>D({icons:e,nameAttr:a,attrs:t,root:l.content,inTemplates:o})),a==="data-lucide"){let f=r.querySelectorAll("[icon-name]");f.length>0&&(console.warn("[Lucide] Some icons were found with the now deprecated icon-name attribute. These will still be replaced for backwards compatibility, but will no longer be supported in v1.0 and you should switch to data-lucide"),Array.from(f).forEach(l=>p(l,{nameAttr:"icon-name",icons:e,attrs:t})))}};var W={AlertCircle:d,ArrowDown:m,ArrowUp:x,Check:i,Clock3:n,Copy:c,Download:C,Eye:S,EyeOff:h,FileText:g,Image:w,LogOut:k,Plus:P,RefreshCw:A,Search:M,ShieldCheck:B,X:F};window.renderChorusIcons=(e=document)=>D({icons:W,attrs:{"aria-hidden":"true"},root:e});document.addEventListener("DOMContentLoaded",()=>window.renderChorusIcons());})();
|
||||
lucide.createIcons({icons});\``);if(typeof r>"u")throw new Error("`createIcons()` only works in a browser environment.");if(Array.from(r.querySelectorAll(`[${a}]`)).forEach(f=>x(f,{nameAttr:a,icons:e,attrs:t})),o&&Array.from(r.querySelectorAll("template")).forEach(l=>q({icons:e,nameAttr:a,attrs:t,root:l.content,inTemplates:o})),a==="data-lucide"){let f=r.querySelectorAll("[icon-name]");f.length>0&&(console.warn("[Lucide] Some icons were found with the now deprecated icon-name attribute. These will still be replaced for backwards compatibility, but will no longer be supported in v1.0 and you should switch to data-lucide"),Array.from(f).forEach(l=>x(l,{nameAttr:"icon-name",icons:e,attrs:t})))}};var Z={AlertCircle:d,ArrowDown:i,ArrowUp:n,Ban:c,Check:C,CircleCheck:p,Clock3:h,Copy:S,Download:g,Eye:k,EyeOff:w,FileText:P,Image:A,KeyRound:M,LogOut:B,Pencil:F,Plus:D,RefreshCw:L,Search:y,ShieldCheck:R,TriangleAlert:m,X:T};window.renderChorusIcons=(e=document)=>q({icons:Z,attrs:{"aria-hidden":"true"},root:e});document.addEventListener("DOMContentLoaded",()=>window.renderChorusIcons());})();
|
||||
/*! Bundled license information:
|
||||
|
||||
lucide/dist/esm/defaultAttributes.mjs:
|
||||
@@ -13,8 +13,10 @@ lucide/dist/esm/shared/src/utils/toPascalCase.mjs:
|
||||
lucide/dist/esm/replaceElement.mjs:
|
||||
lucide/dist/esm/icons/arrow-down.mjs:
|
||||
lucide/dist/esm/icons/arrow-up.mjs:
|
||||
lucide/dist/esm/icons/ban.mjs:
|
||||
lucide/dist/esm/icons/check.mjs:
|
||||
lucide/dist/esm/icons/circle-alert.mjs:
|
||||
lucide/dist/esm/icons/circle-check.mjs:
|
||||
lucide/dist/esm/icons/clock-3.mjs:
|
||||
lucide/dist/esm/icons/copy.mjs:
|
||||
lucide/dist/esm/icons/download.mjs:
|
||||
@@ -22,11 +24,14 @@ lucide/dist/esm/icons/eye-off.mjs:
|
||||
lucide/dist/esm/icons/eye.mjs:
|
||||
lucide/dist/esm/icons/file-text.mjs:
|
||||
lucide/dist/esm/icons/image.mjs:
|
||||
lucide/dist/esm/icons/key-round.mjs:
|
||||
lucide/dist/esm/icons/log-out.mjs:
|
||||
lucide/dist/esm/icons/pencil.mjs:
|
||||
lucide/dist/esm/icons/plus.mjs:
|
||||
lucide/dist/esm/icons/refresh-cw.mjs:
|
||||
lucide/dist/esm/icons/search.mjs:
|
||||
lucide/dist/esm/icons/shield-check.mjs:
|
||||
lucide/dist/esm/icons/triangle-alert.mjs:
|
||||
lucide/dist/esm/icons/x.mjs:
|
||||
lucide/dist/esm/lucide.mjs:
|
||||
(**
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "api-keys"}}<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="csrf-token" content="{{.CSRFToken}}">
|
||||
<title>{{.Title}} - Chorus</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script defer src="/static/vendor/icons.min.js"></script>
|
||||
<script defer src="/static/api-keys.js"></script>
|
||||
</head>
|
||||
<body data-page="api-keys">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/" aria-label="Chorus 生成工作台"><span class="brand-mark" aria-hidden="true">C</span><span>CHORUS</span></a>
|
||||
<nav class="primary-nav" aria-label="主导航"><a class="nav-item" href="/"><i class="icon" data-lucide="plus"></i>生成工作台</a><a class="nav-item" href="/api-keys" aria-current="page"><i class="icon" data-lucide="key-round"></i>API Key</a></nav>
|
||||
<div class="account-actions"><span class="user-name">{{.DisplayName}}</span><button class="icon-button" id="logout-button" type="button" aria-label="退出登录" title="退出登录"><i class="icon" data-lucide="log-out"></i></button></div>
|
||||
</header>
|
||||
<main class="api-key-main" id="main-content" tabindex="-1">
|
||||
<div class="api-key-inner">
|
||||
<header class="api-key-heading"><div><p class="eyebrow">开发者访问</p><h1>API Key</h1><p>完整 Key 只在创建成功后显示一次,之后只能查看名称、前缀和状态。</p></div><button class="button button-primary" id="create-key-button" type="button"><i class="icon" data-lucide="plus"></i>创建 API Key</button></header>
|
||||
<section class="panel api-key-panel" aria-labelledby="api-key-list-title">
|
||||
<div class="panel-header"><div class="panel-heading-group"><h2 id="api-key-list-title">我的 API Key</h2><p>撤销后立即失效且不能恢复;轮换时请先创建新 Key。</p></div><span class="api-key-count" id="api-key-count" aria-live="polite"></span></div>
|
||||
<div id="api-key-content" aria-live="polite" aria-busy="true">
|
||||
<div class="api-key-loading" aria-label="正在加载 API Key"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="modal-backdrop hidden" id="create-key-modal"><section class="modal api-key-modal" role="dialog" aria-modal="true" aria-labelledby="create-key-title"><div class="modal-header"><h2 id="create-key-title">创建 API Key</h2><button class="icon-button" type="button" data-close-modal="create-key-modal" aria-label="关闭"><i class="icon" data-lucide="x"></i></button></div><form id="create-key-form"><div class="modal-body"><div class="form-field"><label class="field-label" for="api-key-name">名称</label><input class="input" id="api-key-name" maxlength="80" required autocomplete="off" placeholder="例如:自动化脚本"><p class="field-help">使用能区分调用方和用途的名称。</p><p class="field-error hidden" id="api-key-name-error" role="alert"></p></div><div class="form-field"><label class="field-label" for="api-key-expiry">有效期</label><select class="input" id="api-key-expiry"><option value="30">30 天</option><option value="90" selected>90 天</option><option value="180">180 天</option><option value="">不设置到期时间</option></select></div></div><div class="modal-actions"><button class="button button-secondary" type="button" data-close-modal="create-key-modal">取消</button><button class="button button-primary" id="submit-create-key" type="submit"><i class="icon" data-lucide="key-round"></i><span>创建</span></button></div></form></section></div>
|
||||
|
||||
<div class="modal-backdrop hidden" id="secret-key-modal"><section class="modal api-key-modal" role="dialog" aria-modal="true" aria-labelledby="secret-key-title"><div class="modal-header"><h2 id="secret-key-title">API Key 已创建</h2></div><div class="modal-body"><div class="one-time-warning"><i class="icon" data-lucide="triangle-alert"></i><div><strong>这是唯一一次显示完整 Key</strong><p>关闭后无法再次查看;遗失时请创建新 Key 并撤销旧 Key。</p></div></div><code class="secret-value" id="secret-key-value"></code><button class="button button-secondary" id="copy-secret-key" type="button"><i class="icon" data-lucide="copy"></i><span>复制 Key</span></button><p class="field-error hidden" id="copy-secret-error" role="alert">浏览器未允许复制,请手动选择完整 Key。</p></div><div class="modal-actions"><button class="button button-primary" id="confirm-secret-key" type="button">我已妥善保存</button></div></section></div>
|
||||
|
||||
<div class="modal-backdrop hidden" id="rename-key-modal"><section class="modal api-key-modal" role="dialog" aria-modal="true" aria-labelledby="rename-key-title"><div class="modal-header"><h2 id="rename-key-title">修改名称</h2><button class="icon-button" type="button" data-close-modal="rename-key-modal" aria-label="关闭"><i class="icon" data-lucide="x"></i></button></div><form id="rename-key-form"><div class="modal-body"><div class="form-field"><label class="field-label" for="rename-key-name">名称</label><input class="input" id="rename-key-name" maxlength="80" required autocomplete="off"><p class="field-error hidden" id="rename-key-error" role="alert"></p></div></div><div class="modal-actions"><button class="button button-secondary" type="button" data-close-modal="rename-key-modal">取消</button><button class="button button-primary" id="submit-rename-key" type="submit"><span>保存</span></button></div></form></section></div>
|
||||
|
||||
<div class="modal-backdrop hidden" id="revoke-key-modal"><section class="modal api-key-modal" role="dialog" aria-modal="true" aria-labelledby="revoke-key-title"><div class="modal-header"><h2 id="revoke-key-title">撤销 API Key</h2><button class="icon-button" type="button" data-close-modal="revoke-key-modal" aria-label="关闭"><i class="icon" data-lucide="x"></i></button></div><div class="modal-body"><p>撤销后,使用此 Key 的请求会立即认证失败,且无法恢复。</p><div class="confirm-key" id="revoke-key-summary"></div><p class="field-error hidden" id="revoke-key-error" role="alert"></p></div><div class="modal-actions"><button class="button button-secondary" type="button" data-close-modal="revoke-key-modal">取消</button><button class="button button-danger" id="confirm-revoke-key" type="button"><i class="icon" data-lucide="ban"></i><span>确认撤销</span></button></div></section></div>
|
||||
|
||||
<div class="modal-backdrop hidden" id="auth-modal"><section class="modal" role="dialog" aria-modal="true" aria-labelledby="auth-title"><h2 id="auth-title">登录状态已失效</h2><p>请重新登录后继续。</p><div class="modal-actions"><a class="button button-primary" href="/login?return_to=%2Fapi-keys">重新登录</a></div></section></div>
|
||||
<div class="toast hidden" id="toast" role="status" aria-live="polite"><i class="icon" data-lucide="circle-check"></i><span id="toast-text"></span></div>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
@@ -19,7 +19,7 @@
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/" aria-label="Chorus 新建生成"><span class="brand-mark" aria-hidden="true">C</span><span>CHORUS</span></a>
|
||||
<nav class="primary-nav" aria-label="主导航"><a class="nav-item" href="/" aria-current="page"><i class="icon" data-lucide="plus"></i>生成工作台</a></nav>
|
||||
<nav class="primary-nav" aria-label="主导航"><a class="nav-item" href="/" aria-current="page"><i class="icon" data-lucide="plus"></i>生成工作台</a><a class="nav-item" href="/api-keys"><i class="icon" data-lucide="key-round"></i>API Key</a></nav>
|
||||
<div class="account-actions"><span class="user-name">{{.DisplayName}}</span><button class="icon-button" id="logout-button" type="button" aria-label="退出登录" title="退出登录"><i class="icon" data-lucide="log-out"></i></button></div>
|
||||
</header>
|
||||
<div class="workspace">
|
||||
|
||||
Reference in New Issue
Block a user