feat: API key management improvements

- Rename routes: /dashboard/tokens → /dashboard/apikeys, /dashboard/manager/keys → /dashboard/manager/channels
- Add KeyPlain field to store plaintext API keys for re-viewing
- API key list shows masked key (sk-ot-123456****abcd) with eye toggle to reveal
- Copy button with 2s feedback on key list
- TokenNew shows full key + copy after creation
- Increase key prefix display to 12 characters
- Fix SQLite driver: replace gorm.io/driver/sqlite with ncruces/go-sqlite3/gormlite
- Fix user.status === 'active' checks across frontend views
- Add channel store for new channels API
- Update Makefile frontend build to work reliably

BREAKING CHANGE: Existing API keys created before this change will not show their plaintext value (only prefix visible).
This commit is contained in:
Sakurasan
2026-08-30 15:21:49 +08:00
parent 96e4853d6e
commit 094230a293
26 changed files with 1141 additions and 912 deletions
+336 -18
View File
@@ -37,13 +37,33 @@ func NewHandler(db *gorm.DB) *Handler {
}
}
// --- Helpers ---
func userToResponse(user *store.User) gin.H {
roleNum := 1
if user.Role == store.RoleAdmin {
roleNum = 10
}
return gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": roleNum,
"status": user.Status,
"balance": user.Balance,
"created_at": user.CreatedAt,
"updated_at": user.UpdatedAt,
"last_login_at": user.LastLoginAt,
}
}
// --- Auth ---
func (h *Handler) Register(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Email string `json:"email" binding:"required,email"`
Email string `json:"email" binding:"omitempty,email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -60,9 +80,13 @@ func (h *Handler) Register(c *gin.Context) {
}
hash := crypto.Sha256Hex(req.Password)
email := req.Email
if email == "" {
email = req.Username + "@placeholder.local"
}
user := &store.User{
Username: req.Username,
Email: req.Email,
Email: email,
PasswordHash: hash,
Role: role,
Status: store.UserStatusActive,
@@ -127,34 +151,121 @@ func (h *Handler) Me(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
// Map role string to number for frontend compatibility
roleNum := 1 // default user
if user.Role == store.RoleAdmin {
roleNum = 10
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"data": gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": roleNum,
"status": user.Status,
},
"data": userToResponse(user),
})
}
func (h *Handler) UpdateProfile(c *gin.Context) {
userID, _ := c.Get("user_id")
user, err := h.userDAO.GetByID(userID.(uint64))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var req struct {
Email string `json:"email"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Email != "" {
user.Email = req.Email
}
if err := h.userDAO.Update(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "profile updated"})
}
func (h *Handler) UpdatePassword(c *gin.Context) {
userID, _ := c.Get("user_id")
user, err := h.userDAO.GetByID(userID.(uint64))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var req struct {
Password string `json:"password" binding:"required"`
NewPassword string `json:"newpassword" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify old password
oldHash := crypto.Sha256Hex(req.Password)
if user.PasswordHash != oldHash {
c.JSON(http.StatusBadRequest, gin.H{"error": "incorrect password"})
return
}
// Update to new password
user.PasswordHash = crypto.Sha256Hex(req.NewPassword)
if err := h.userDAO.Update(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "password updated"})
}
// --- Users ---
func (h *Handler) ListUsers(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
// If pageSize/page are provided, use them instead
if pageSize := c.Query("pageSize"); pageSize != "" {
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
limit = size
}
}
if page := c.Query("page"); page != "" {
if p, err := strconv.Atoi(page); err == nil && p > 0 {
offset = (p - 1) * limit
}
}
users, total, err := h.userDAO.List(limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": users, "total": total})
data := make([]gin.H, len(users))
for i, u := range users {
data[i] = userToResponse(u)
}
c.JSON(http.StatusOK, gin.H{"data": data, "total": total})
}
func (h *Handler) GetUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
user, err := h.userDAO.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, gin.H{
"data": userToResponse(user),
})
}
func (h *Handler) CreateUser(c *gin.Context) {
@@ -188,7 +299,87 @@ func (h *Handler) CreateUser(c *gin.Context) {
return
}
c.JSON(http.StatusOK, user)
c.JSON(http.StatusOK, userToResponse(user))
}
func (h *Handler) UpdateUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
user, err := h.userDAO.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Username != "" {
user.Username = req.Username
}
if req.Email != "" {
user.Email = req.Email
}
if req.Password != "" {
user.PasswordHash = crypto.Sha256Hex(req.Password)
}
if req.Role != "" {
user.Role = req.Role
}
if req.Status != "" {
user.Status = req.Status
}
if err := h.userDAO.Update(user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, userToResponse(user))
}
func (h *Handler) BatchUsers(c *gin.Context) {
option := c.Param("option")
var req struct {
IDs []uint64 `json:"ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
for _, id := range req.IDs {
user, err := h.userDAO.GetByID(id)
if err != nil {
continue
}
switch option {
case "enable":
user.Status = store.UserStatusActive
case "disable":
user.Status = store.UserStatusDisabled
case "delete":
h.userDAO.Delete(id)
continue
}
h.userDAO.Update(user)
}
c.JSON(http.StatusOK, gin.H{"message": "batch operation completed"})
}
func (h *Handler) DeleteUser(c *gin.Context) {
@@ -208,8 +399,22 @@ func (h *Handler) DeleteUser(c *gin.Context) {
func (h *Handler) ListApiKeys(c *gin.Context) {
userID, _ := c.Get("user_id")
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
// If pageSize/page are provided, use them instead
if pageSize := c.Query("pageSize"); pageSize != "" {
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
limit = size
}
}
if page := c.Query("page"); page != "" {
if p, err := strconv.Atoi(page); err == nil && p > 0 {
offset = (p - 1) * limit
}
}
keys, total, err := h.apiKeyDAO.ListByUserID(userID.(uint64), limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -218,6 +423,20 @@ func (h *Handler) ListApiKeys(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": keys, "total": total})
}
func (h *Handler) GetApiKey(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
key, err := h.apiKeyDAO.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
return
}
c.JSON(http.StatusOK, gin.H{"data": key})
}
func (h *Handler) CreateApiKey(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
@@ -236,7 +455,8 @@ func (h *Handler) CreateApiKey(c *gin.Context) {
UserID: userID.(uint64),
Name: req.Name,
KeyHash: apikey.Hash(keyValue),
KeyPrefix: keyValue[:8],
KeyPlain: keyValue,
KeyPrefix: keyValue[:12],
QuotaTokensPerDay: req.QuotaTokensPerDay,
QuotaRequestsPerDay: req.QuotaRequestsPerDay,
Status: store.KeyStatusActive,
@@ -249,10 +469,80 @@ func (h *Handler) CreateApiKey(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"key": keyValue,
"id": key.ID,
"data": key,
})
}
func (h *Handler) UpdateApiKey(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
key, err := h.apiKeyDAO.GetByID(id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
return
}
var req struct {
Name string `json:"name"`
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
AllowedModels []string `json:"allowed_models"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Name != "" {
key.Name = req.Name
}
if req.QuotaTokensPerDay != nil {
key.QuotaTokensPerDay = req.QuotaTokensPerDay
}
if req.QuotaRequestsPerDay != nil {
key.QuotaRequestsPerDay = req.QuotaRequestsPerDay
}
if req.AllowedModels != nil {
key.AllowedModels = req.AllowedModels
}
if req.Status != "" {
key.Status = req.Status
}
if err := h.apiKeyDAO.Update(key); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": key})
}
func (h *Handler) BatchApiKeys(c *gin.Context) {
option := c.Param("option")
var req struct {
IDs []uint64 `json:"ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
switch option {
case "delete":
if err := h.apiKeyDAO.BatchDelete(req.IDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "batch operation completed"})
}
func (h *Handler) DeleteApiKey(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
@@ -269,8 +559,22 @@ func (h *Handler) DeleteApiKey(c *gin.Context) {
// --- Channels ---
func (h *Handler) ListChannels(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
// If pageSize/page are provided, use them instead
if pageSize := c.Query("pageSize"); pageSize != "" {
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
limit = size
}
}
if page := c.Query("page"); page != "" {
if p, err := strconv.Atoi(page); err == nil && p > 0 {
offset = (p - 1) * limit
}
}
channels, total, err := h.channelDAO.List(limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -401,8 +705,22 @@ func (h *Handler) DeleteChannel(c *gin.Context) {
// --- Models ---
func (h *Handler) ListModels(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
// If pageSize/page are provided, use them instead
if pageSize := c.Query("pageSize"); pageSize != "" {
if size, err := strconv.Atoi(pageSize); err == nil && size > 0 {
limit = size
}
}
if page := c.Query("page"); page != "" {
if p, err := strconv.Atoi(page); err == nil && p > 0 {
offset = (p - 1) * limit
}
}
models, total, err := h.modelDAO.List(limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+2 -2
View File
@@ -6,9 +6,9 @@ import (
"opencatd-open/pkg/config"
_ "github.com/lib/pq"
"github.com/ncruces/go-sqlite3/gormlite"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -52,7 +52,7 @@ func sqliteDialector(dsn string) gorm.Dialector {
if dsn == "" {
dsn = "opencatd.db"
}
return sqlite.Open(dsn)
return gormlite.Open(dsn)
}
func postgresDialector(dsn string) gorm.Dialector {
+1
View File
@@ -58,6 +58,7 @@ type APIKey struct {
UserID uint64 `gorm:"index;not null" json:"user_id"`
Name string `gorm:"size:64;not null" json:"name"`
KeyHash string `gorm:"uniqueIndex;size:64;not null" json:"-"`
KeyPlain string `gorm:"size:255;not null" json:"key_plain"`
KeyPrefix string `gorm:"size:32;not null" json:"key_prefix"`
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day,omitempty"`
QuotaRequestsPerDay *int `json:"quota_requests_per_day,omitempty"`