feat: 交付用户 API Key 生命周期 (#42)

This commit is contained in:
ila
2026-08-24 14:55:13 +08:00
parent 701e609f44
commit b12721ba29
18 changed files with 943 additions and 20 deletions
+12 -2
View File
@@ -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 -->
+12 -2
View File
@@ -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 -->
+19 -2
View File
@@ -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 -->
+3 -2
View File
@@ -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{}).
+126
View File
@@ -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")
}
+74 -2
View File
@@ -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())
}
+9
View File
@@ -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)
+178
View File
@@ -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
}
+41
View File
@@ -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)
}
}
}
+3
View File
@@ -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
}
+60
View File
@@ -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");
+4 -4
View File
@@ -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 });
+61 -2
View File
@@ -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; }
+289
View File
@@ -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
View File
@@ -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:
(**
+43
View File
@@ -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}}
+1 -1
View File
@@ -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">