8 Commits

Author SHA1 Message Date
darroyo de5f7ca4ee Cleanup: remove dead auth middleware and fix security log leak
CI / test (push) Failing after 12m28s
- Remove dead AdminOnly() and AdminTokenAuth() middleware (unused since JWT switch)
- Remove dead RequireScope() middleware (also unused)
- Fix seed.go: only log password when auto-generated (was logging every time)
- Fix seed.go: use admin.Username (normalized) instead of raw username in log
- Simplify generateRandomPassword: 12 bytes (24 hex chars) instead of 24 bytes then truncate
- Me handler: use claims.Subject (UUID) for lookup instead of username
- Remove unused /logout endpoint (stateless JWT)
2026-07-31 17:26:31 -04:00
darroyo ddc73957cb Fix: move keys and model-load routes under /api/v1/admin prefix
CI / test (push) Failing after 12m38s
Routes now consistently under admin session auth:
- /api/v1/admin/keys (GET, POST, DELETE)
- /api/v1/admin/models/:name/load (POST)

Rebuilt frontend assets.
2026-07-31 17:17:23 -04:00
darroyo 4d34c6d31a Replace token-based admin auth with JWT session authentication
CI / test (push) Failing after 12m45s
- Add AdminUser model (bcrypt hashed passwords) and admin_users table
- Add AdminJWTService for HS256 JWT sessions (24h TTL)
- Add AdminSessionAuth middleware for /api/v1/admin/* routes
- Add admin handlers: login, logout, me, change-password, users CRUD
- Keys and model management routes now require admin JWT session
- Remove ADMIN_TOKEN, add ADMIN_USERNAME, ADMIN_PASSWORD env vars
- Update frontend: username/password login, admin_session storage, AdminUsers CRUD view
2026-07-31 17:15:29 -04:00
darroyo b18d4bd146 fix: use mime.TypeByExtension for static asset Content-Type
CI / test (push) Failing after 12m25s
http.DetectContentType() cannot detect JS, CSS, SVG, etc. from magic
bytes — it falls back to text/plain, causing browsers to reject module
scripts with 'Expected a JavaScript module' error.

Use mime.TypeByExtension(ext) which correctly maps .js ->
application/javascript, .css -> text/css, etc.
2026-07-31 15:37:16 -04:00
darroyo 596f194c3a fix: set WorkingDirectory to /opt/llamalink/data in systemd unit
CI / test (push) Failing after 12m40s
The llamalink service fails to start because ProtectSystem=strict only
allows writes to ReadWritePaths=/opt/llamalink/data, but the default
DATABASE_URL=sqlite:///./llamalink.db resolves relative to WorkingDirectory.

With WorkingDirectory=/opt/llamalink, the db lands at /opt/llamalink/llamalink.db
which is not in ReadWritePaths, causing sqlite open to fail.

Setting WorkingDirectory=/opt/llamalink/data aligns with the package layout
(postinst creates /opt/llamalink/data) and makes the default ./llamalink.db
resolve to the correct writable location.
2026-07-31 15:32:31 -04:00
darroyo 210d27f9c4 fix: strip leading 'v' from version in build-deb.sh unconditionally
CI / test (push) Failing after 12m28s
Debian versions must start with a digit. When called via 'make deb',
VERSION is passed from Makefile (git describe output includes 'v' prefix).
The strip was previously only done inside the auto-detection branch,
so 'make deb' produced invalid versions like 'v2026.07.30-2-g05604a8-1'.

Now VERSION= runs after the version is determined, regardless
of whether it came from an argument or auto-detection.
2026-07-31 15:24:39 -04:00
darroyo 05604a8f12 Makefile: frontend/install uses npm ci, frontend/build depends on frontend/install
CI / test (push) Failing after 12m51s
2026-07-31 15:15:25 -04:00
darroyo 0d6c8c7989 Add embedded Vue SPA admin panel with go:embed
CI / test (push) Failing after 13m11s
This is the complete fix for the missing /admin/ route and frontend serving:

Backend:
- internal/web/web.go: new package with go:embed for web/dist/
- internal/api/router.go: add routes for /admin/, /admin/*, /assets/*
- internal/db/db.go: fix SQLite DSN parsing (sqlite:///path -> path)

Build system:
- Makefile: new 'embed-prep' target copies web/dist to internal/web/dist
- make build now runs embed-prep -> frontend/build automatically

Deployment:
- deploy/llamalink.service: remove invalid --host/--port flags,
  add EnvironmentFile=/etc/llamalink/env

Verified:
- /health returns 200
- /admin/ serves Vue SPA HTML
- /assets/* serves CSS and JS files from embedded FS
- sqlite:///./llamalink.db works correctly
2026-07-31 14:47:15 -04:00
41 changed files with 1067 additions and 136 deletions
+3 -1
View File
@@ -20,7 +20,9 @@ RATE_LIMIT_PER_MINUTE=60
RATE_LIMIT_STORAGE=memory
# RATE_LIMIT_REDIS_URL=redis://localhost:6379/0
ADMIN_TOKEN=change-me-in-production
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-in-production
ADMIN_SESSION_TTL=24h
LOG_LEVEL=info
LOG_FORMAT=json
+12 -4
View File
@@ -24,12 +24,19 @@ GOLINT=golangci-lint
# Default target
all: deps build
## build: Build the binary
build:
## build: Build the binary (includes frontend embed)
build: embed-prep
@echo "Building $(BINARY)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(BINARY) ./cmd/llamalink
## embed-prep: Copy web/dist into internal/web/dist for go:embed
embed-prep: frontend/build
@echo "Preparing embedded frontend..."
@rm -rf internal/web/dist
@mkdir -p internal/web/dist
@cp -r web/dist/* internal/web/dist/
## run: Build and run
run: build
@echo "Running..."
@@ -63,6 +70,7 @@ deps:
## clean: Remove build artifacts
clean:
rm -rf $(BUILD_DIR) $(DIST_DIR)
rm -rf internal/web/dist
rm -f coverage.out
rm -f *.db
@@ -81,14 +89,14 @@ migrate/create:
## frontend/install: Install frontend dependencies
frontend/install:
cd $(FRONTEND_DIR) && npm install
cd $(FRONTEND_DIR) && npm ci
## frontend/dev: Run frontend dev server
frontend/dev:
cd $(FRONTEND_DIR) && npm run dev
## frontend/build: Build frontend for production
frontend/build:
frontend/build: frontend/install
cd $(FRONTEND_DIR) && npm run build
## docker/build: Build Docker image
+3 -3
View File
@@ -52,9 +52,9 @@ func main() {
}
logger.Info("database migrations complete")
// Seed admin key if needed
if err := db.SeedAdminKey(database, cfg.AdminToken); err != nil {
logger.Error("failed to seed admin key", "error", err)
// Seed admin user if needed
if err := db.SeedAdminUser(database, cfg.AdminUsername, cfg.AdminPassword); err != nil {
logger.Error("failed to seed admin user", "error", err)
os.Exit(1)
}
+3 -6
View File
@@ -5,24 +5,21 @@ After=network.target
[Service]
Type=simple
User=llamalink
WorkingDirectory=/opt/llamalink
ExecStart=/usr/local/bin/llamalink \
--host 0.0.0.0 \
--port 8000
WorkingDirectory=/opt/llamalink/data
EnvironmentFile=/etc/llamalink/env
ExecStart=/usr/local/bin/llamalink
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=llamalink
# Security
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/llamalink/data
ReadOnlyPaths=/opt/llamalink/models
Environment=LLAMALINK_ENV=production
[Install]
WantedBy=multi-user.target
+1
View File
@@ -23,6 +23,7 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
+2
View File
@@ -27,6 +27,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+69
View File
@@ -0,0 +1,69 @@
package admin
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/llamalink/llamalink/internal/auth"
"github.com/llamalink/llamalink/internal/db"
)
type LoginHandler struct {
db *gorm.DB
jwtService *auth.AdminJWTService
}
func NewLoginHandler(db *gorm.DB, jwtService *auth.AdminJWTService) *LoginHandler {
return &LoginHandler{db: db, jwtService: jwtService}
}
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=8"`
}
func (h *LoginHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "username and password are required (password min 8 chars)"}})
return
}
var user db.AdminUser
if err := h.db.Where("username = ?", req.Username).First(&user).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "invalid_credentials", "message": "Invalid username or password"}})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "internal_error", "message": "Internal server error"}})
return
}
if err := auth.ValidateAdminPassword(&user, req.Password); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "invalid_credentials", "message": "Invalid username or password"}})
return
}
token, expiresAt, err := h.jwtService.IssueToken(&user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "internal_error", "message": "Failed to issue session token"}})
return
}
h.db.Model(&user).Update("last_login_at", time.Now())
c.JSON(http.StatusOK, gin.H{
"token": token,
"expires_at": expiresAt.Unix(),
"user": gin.H{
"id": user.ID,
"username": user.Username,
"is_active": user.IsActive,
},
})
}
+98
View File
@@ -0,0 +1,98 @@
package admin
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/llamalink/llamalink/internal/api/middleware"
"github.com/llamalink/llamalink/internal/db"
)
type MeHandler struct {
db *gorm.DB
}
func NewMeHandler(db *gorm.DB) *MeHandler {
return &MeHandler{db: db}
}
func (h *MeHandler) Me(c *gin.Context) {
claims := middleware.GetAdminClaims(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "unauthorized", "message": "Not authenticated"}})
return
}
var user db.AdminUser
userID, err := uuid.Parse(claims.Subject)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "unauthorized", "message": "Invalid token claims"}})
return
}
if err := h.db.Where("id = ?", userID).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "unauthorized", "message": "User not found"}})
return
}
c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"username": user.Username,
"is_active": user.IsActive,
"created_at": user.CreatedAt,
"last_login_at": user.LastLoginAt,
})
}
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8"`
}
func (h *MeHandler) ChangePassword(c *gin.Context) {
claims := middleware.GetAdminClaims(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "unauthorized", "message": "Not authenticated"}})
return
}
var req ChangePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "current_password and new_password required (min 8 chars)"}})
return
}
userID, err := uuid.Parse(claims.Subject)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "invalid_request", "message": "Invalid token claims"}})
return
}
var user db.AdminUser
if err := h.db.Where("id = ?", userID).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"code": "not_found", "message": "User not found"}})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.CurrentPassword)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "invalid_credentials", "message": "Current password is incorrect"}})
return
}
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "internal_error", "message": "Failed to hash password"}})
return
}
h.db.Model(&user).Updates(map[string]interface{}{
"password_hash": string(newHash),
"updated_at": time.Now(),
})
c.Status(http.StatusOK)
}
+190
View File
@@ -0,0 +1,190 @@
package admin
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/llamalink/llamalink/internal/api/middleware"
"github.com/llamalink/llamalink/internal/db"
)
type UsersHandler struct {
db *gorm.DB
}
func NewUsersHandler(db *gorm.DB) *UsersHandler {
return &UsersHandler{db: db}
}
type CreateUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8"`
}
type UpdateUserRequest struct {
Password *string `json:"password"`
IsActive *bool `json:"is_active"`
}
func (h *UsersHandler) List(c *gin.Context) {
var users []db.AdminUser
if err := h.db.Order("created_at DESC").Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "internal_error", "message": "Failed to list users"}})
return
}
result := make([]gin.H, len(users))
for i, u := range users {
result[i] = gin.H{
"id": u.ID,
"username": u.Username,
"is_active": u.IsActive,
"created_at": u.CreatedAt,
"last_login_at": u.LastLoginAt,
}
}
c.JSON(http.StatusOK, result)
}
func (h *UsersHandler) Create(c *gin.Context) {
claims := middleware.GetAdminClaims(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "unauthorized", "message": "Not authenticated"}})
return
}
var req CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "username (3-64 chars) and password (min 8 chars) required"}})
return
}
username := strings.ToLower(strings.TrimSpace(req.Username))
var count int64
h.db.Model(&db.AdminUser{}).Where("username = ?", username).Count(&count)
if count > 0 {
c.JSON(http.StatusConflict, gin.H{"error": gin.H{"code": "conflict", "message": "Username already exists"}})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "internal_error", "message": "Failed to hash password"}})
return
}
user := &db.AdminUser{
ID: uuid.New(),
Username: username,
PasswordHash: string(hash),
IsActive: true,
}
if err := h.db.Create(user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "internal_error", "message": "Failed to create user"}})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": user.ID,
"username": user.Username,
"is_active": user.IsActive,
"created_at": user.CreatedAt,
})
}
func (h *UsersHandler) Update(c *gin.Context) {
claims := middleware.GetAdminClaims(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "unauthorized", "message": "Not authenticated"}})
return
}
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "Invalid user ID"}})
return
}
currentUser, _ := uuid.Parse(claims.Subject)
if currentUser == id {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "Cannot modify your own account via this endpoint"}})
return
}
var req UpdateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "Invalid request body"}})
return
}
var user db.AdminUser
if err := h.db.Where("id = ?", id).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"code": "not_found", "message": "User not found"}})
return
}
updates := map[string]interface{}{"updated_at": time.Now()}
if req.Password != nil && *req.Password != "" {
if len(*req.Password) < 8 {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "Password must be at least 8 characters"}})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"code": "internal_error", "message": "Failed to hash password"}})
return
}
updates["password_hash"] = string(hash)
}
if req.IsActive != nil {
updates["is_active"] = *req.IsActive
}
h.db.Model(&user).Updates(updates)
c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"username": user.Username,
"is_active": user.IsActive,
"updated_at": user.UpdatedAt,
})
}
func (h *UsersHandler) Delete(c *gin.Context) {
claims := middleware.GetAdminClaims(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{"code": "unauthorized", "message": "Not authenticated"}})
return
}
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "Invalid user ID"}})
return
}
currentUser, _ := uuid.Parse(claims.Subject)
if currentUser == id {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"code": "validation_error", "message": "Cannot delete your own account"}})
return
}
var user db.AdminUser
if err := h.db.Where("id = ?", id).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"code": "not_found", "message": "User not found"}})
return
}
h.db.Delete(&user)
c.Status(http.StatusNoContent)
}
-13
View File
@@ -6,7 +6,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/llamalink/llamalink/internal/api/middleware"
"github.com/llamalink/llamalink/internal/auth"
)
@@ -97,18 +96,6 @@ func (h *KeysHandler) RevokeKey(c *gin.Context) {
return
}
// Can't revoke own key
currentKey := middleware.GetAPIKey(c)
if currentKey.ID == id {
c.JSON(http.StatusBadRequest, gin.H{
"error": gin.H{
"code": "validation_error",
"message": "Cannot revoke your own admin key",
},
})
return
}
if err := h.authService.Revoke(id); err != nil {
if err == auth.ErrKeyNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "key not found"})
+28 -57
View File
@@ -14,6 +14,7 @@ import (
const (
ApiKeyCtx = "api_key"
ApiKeyIDCtx = "api_key_id"
AdminCtx = "admin_user"
)
func APIKeyAuth(authService *auth.Service) gin.HandlerFunc {
@@ -64,80 +65,42 @@ func APIKeyAuth(authService *auth.Service) gin.HandlerFunc {
}
}
func RequireScope(authService *auth.Service, scope string) gin.HandlerFunc {
func AdminSessionAuth(jwtService *auth.AdminJWTService) gin.HandlerFunc {
return func(c *gin.Context) {
apiKey, exists := c.Get(ApiKeyCtx)
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"code": "invalid_api_key",
"message": "Authentication required",
},
})
return
}
key := apiKey.(*db.ApiKey)
if !authService.HasScope(key, scope) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": gin.H{
"code": "insufficient_scope",
"message": "API key lacks required scope: " + scope,
},
})
return
}
c.Next()
}
}
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
apiKey, exists := c.Get(ApiKeyCtx)
if !exists {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"code": "unauthorized",
"message": "Admin access required",
"message": "Authorization header required",
},
})
return
}
key := apiKey.(*db.ApiKey)
if !key.IsAdmin {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": gin.H{
"code": "unauthorized",
"message": "Admin access required",
},
})
return
}
c.Next()
}
}
func AdminTokenAuth(token string) gin.HandlerFunc {
return func(c *gin.Context) {
adminToken := c.GetHeader("X-Admin-Token")
if adminToken == "" {
adminToken = c.GetHeader("Authorization")
adminToken = strings.TrimPrefix(adminToken, "Bearer ")
}
if adminToken != token {
token := strings.TrimPrefix(authHeader, "Bearer ")
if token == authHeader || strings.HasPrefix(token, auth.TokenPrefix) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"code": "unauthorized",
"message": "Invalid admin token",
"message": "Admin session required",
},
})
return
}
claims, err := jwtService.VerifyToken(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": gin.H{
"code": "unauthorized",
"message": "Invalid or expired session",
},
})
return
}
c.Set(AdminCtx, claims)
c.Next()
}
}
@@ -154,3 +117,11 @@ func GetAPIKey(c *gin.Context) *db.ApiKey {
}
return key.(*db.ApiKey)
}
func GetAdminClaims(c *gin.Context) *auth.AdminClaims {
claims, _ := c.Get(AdminCtx)
if claims == nil {
return nil
}
return claims.(*auth.AdminClaims)
}
+58 -14
View File
@@ -1,13 +1,19 @@
package api
import (
"mime"
"net/http"
"path"
"github.com/gin-gonic/gin"
"github.com/llamalink/llamalink/internal/api/handlers"
"github.com/llamalink/llamalink/internal/api/handlers/admin"
"github.com/llamalink/llamalink/internal/api/middleware"
"github.com/llamalink/llamalink/internal/auth"
"github.com/llamalink/llamalink/internal/config"
"github.com/llamalink/llamalink/internal/llama"
"github.com/llamalink/llamalink/internal/quota"
"github.com/llamalink/llamalink/internal/web"
"gorm.io/gorm"
)
@@ -25,6 +31,7 @@ func New(cfg *config.Config, db *gorm.DB, llamaManager *llama.Manager) *gin.Engi
quotaSvc := quota.NewService(db)
webhookSvc := quota.NewWebhookService(db)
proxy := llama.NewProxy(llamaManager)
adminJWTService := auth.NewAdminJWTService(cfg.JWTSecret, cfg.AdminSessionTTL)
// Initialize handlers
healthHandler := handlers.NewHealthHandler(db, llamaManager)
@@ -32,6 +39,9 @@ func New(cfg *config.Config, db *gorm.DB, llamaManager *llama.Manager) *gin.Engi
keysHandler := handlers.NewKeysHandler(authService)
modelsHandler := handlers.NewModelsHandler(llamaManager)
usageHandler := handlers.NewUsageHandler(db, quotaSvc)
adminLoginHandler := admin.NewLoginHandler(db, adminJWTService)
adminMeHandler := admin.NewMeHandler(db)
adminUsersHandler := admin.NewUsersHandler(db)
// Health endpoints (public)
r.GET("/health", healthHandler.Health)
@@ -45,14 +55,6 @@ func New(cfg *config.Config, db *gorm.DB, llamaManager *llama.Manager) *gin.Engi
chat.Use(middleware.APIKeyAuth(authService))
chat.POST("/completions", chatHandler.ChatCompletions)
// Keys management (admin only)
keys := v1.Group("/keys")
keys.Use(middleware.APIKeyAuth(authService))
keys.Use(middleware.AdminOnly())
keys.GET("", keysHandler.ListKeys)
keys.POST("", keysHandler.CreateKey)
keys.DELETE("/:id", keysHandler.RevokeKey)
// Models management
models := v1.Group("/models")
models.Use(middleware.APIKeyAuth(authService))
@@ -61,17 +63,59 @@ func New(cfg *config.Config, db *gorm.DB, llamaManager *llama.Manager) *gin.Engi
models.GET("/active", modelsHandler.GetActiveModel)
models.GET("/:name", modelsHandler.GetActiveModel) // alias for compatibility
// Model load (admin only)
modelLoad := v1.Group("/models")
modelLoad.Use(middleware.APIKeyAuth(authService))
modelLoad.Use(middleware.AdminOnly())
modelLoad.POST("/:name/load", modelsHandler.LoadModel)
// Usage (requires API key auth)
usage := v1.Group("/usage")
usage.Use(middleware.APIKeyAuth(authService))
usage.GET("", usageHandler.GetCurrentKeyUsage)
usage.GET("/:key_id", usageHandler.GetUsage)
// Admin SPA (static files with embedded frontend)
r.GET("/assets/*filepath", func(c *gin.Context) {
filepath := c.Param("filepath")
data, err := web.ServeAsset(filepath)
if err != nil {
c.String(http.StatusNotFound, "asset not found")
return
}
ext := path.Ext(filepath)
contentType := mime.TypeByExtension(ext)
if contentType == "" {
contentType = "application/octet-stream"
}
c.Data(http.StatusOK, contentType, data)
})
r.GET("/admin", func(c *gin.Context) {
index, err := web.Index()
if err != nil {
c.String(http.StatusInternalServerError, "index.html not found")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
})
r.GET("/admin/*filepath", func(c *gin.Context) {
index, err := web.Index()
if err != nil {
c.String(http.StatusInternalServerError, "index.html not found")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
})
// Admin API v1 (JWT session auth)
adminV1 := v1.Group("/admin")
adminV1.POST("/login", adminLoginHandler.Login)
adminSession := adminV1.Group("")
adminSession.Use(middleware.AdminSessionAuth(adminJWTService))
adminSession.GET("/me", adminMeHandler.Me)
adminSession.POST("/change-password", adminMeHandler.ChangePassword)
adminSession.GET("/users", adminUsersHandler.List)
adminSession.POST("/users", adminUsersHandler.Create)
adminSession.PUT("/users/:id", adminUsersHandler.Update)
adminSession.DELETE("/users/:id", adminUsersHandler.Delete)
adminSession.GET("/keys", keysHandler.ListKeys)
adminSession.POST("/keys", keysHandler.CreateKey)
adminSession.DELETE("/keys/:id", keysHandler.RevokeKey)
adminSession.POST("/models/:name/load", modelsHandler.LoadModel)
return r
}
+86
View File
@@ -0,0 +1,86 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"github.com/llamalink/llamalink/internal/db"
)
var (
ErrInvalidAdminCredentials = errors.New("invalid username or password")
ErrAdminNotFound = errors.New("admin user not found")
ErrAdminInactive = errors.New("admin user is inactive")
)
type AdminClaims struct {
Username string `json:"username"`
jwt.RegisteredClaims
}
type AdminJWTService struct {
secret []byte
ttl time.Duration
}
func NewAdminJWTService(secret []byte, ttlSeconds int) *AdminJWTService {
return &AdminJWTService{
secret: secret,
ttl: time.Duration(ttlSeconds) * time.Second,
}
}
func (s *AdminJWTService) IssueToken(user *db.AdminUser) (string, time.Time, error) {
expiresAt := time.Now().Add(s.ttl)
claims := &AdminClaims{
Username: user.Username,
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.ID.String(),
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenStr, err := token.SignedString(s.secret)
return tokenStr, expiresAt, err
}
func (s *AdminJWTService) VerifyToken(tokenStr string) (*AdminClaims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &AdminClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return s.secret, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*AdminClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
func ValidateAdminPassword(user *db.AdminUser, password string) error {
if !user.IsActive {
return ErrAdminInactive
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return ErrInvalidAdminCredentials
}
return nil
}
func GenerateSecureToken(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
+14 -2
View File
@@ -33,7 +33,10 @@ type Config struct {
RateLimitStorage string // "memory" or "redis"
// Auth
AdminToken string
AdminUsername string
AdminPassword string
JWTSecret []byte
AdminSessionTTL int // seconds
// Logging
LogLevel string
@@ -58,10 +61,19 @@ func Load() *Config {
ModelSwapCooldown: intEnv("MODEL_SWAP_COOLDOWN", 2),
RateLimitPerMinute: intEnv("RATE_LIMIT_PER_MINUTE", 60),
RateLimitStorage: getEnv("RATE_LIMIT_STORAGE", "memory"),
AdminToken: getEnv("ADMIN_TOKEN", "changeme"),
AdminUsername: getEnv("ADMIN_USERNAME", "admin"),
AdminPassword: getEnv("ADMIN_PASSWORD", ""),
AdminSessionTTL: intEnv("ADMIN_SESSION_TTL", 86400),
LogLevel: getEnv("LOG_LEVEL", "info"),
LogFormat: getEnv("LOG_FORMAT", "json"),
}
secretKey := getEnv("LLAMALINK_SECRET_KEY", "change-me-in-production")
if secretKey == "change-me-in-production" {
slog.Warn("LLAMALINK_SECRET_KEY is using the default value — set a secure random string in production")
}
c.JWTSecret = []byte(secretKey)
return c
}
+8 -3
View File
@@ -32,7 +32,8 @@ func TestLoad(t *testing.T) {
func TestLoadEnvOverride(t *testing.T) {
t.Setenv("LLAMALINK_PORT", "9000")
t.Setenv("LLAMALINK_ENV", "production")
t.Setenv("ADMIN_TOKEN", "secret-token")
t.Setenv("ADMIN_USERNAME", "admin")
t.Setenv("ADMIN_PASSWORD", "secret-password")
cfg := Load()
@@ -44,8 +45,12 @@ func TestLoadEnvOverride(t *testing.T) {
t.Errorf("expected env production, got %s", cfg.LlamalinkEnv)
}
if cfg.AdminToken != "secret-token" {
t.Errorf("expected AdminToken secret-token, got %s", cfg.AdminToken)
if cfg.AdminUsername != "admin" {
t.Errorf("expected AdminUsername admin, got %s", cfg.AdminUsername)
}
if cfg.AdminPassword != "secret-password" {
t.Errorf("expected AdminPassword secret-password, got %s", cfg.AdminPassword)
}
}
+6 -3
View File
@@ -13,9 +13,12 @@ import (
)
func Open(cfg *config.Config) (*gorm.DB, error) {
dsn := strings.TrimPrefix(cfg.DatabaseURL, "sqlite://")
if dsn == cfg.DatabaseURL {
dsn = cfg.DatabaseURL
dsn := cfg.DatabaseURL
if strings.HasPrefix(dsn, "sqlite://") {
dsn = strings.TrimPrefix(dsn, "sqlite://")
if strings.HasPrefix(dsn, "/") {
dsn = dsn[1:]
}
}
gormConfig := &gorm.Config{
+19
View File
@@ -27,6 +27,25 @@ func (a *StringArray) Scan(value interface{}) error {
return json.Unmarshal(b, a)
}
type AdminUser struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
}
func (AdminUser) TableName() string { return "admin_users" }
func (u *AdminUser) BeforeCreate(tx *gorm.DB) error {
if u.ID == uuid.Nil {
u.ID = uuid.New()
}
return nil
}
type ApiKey struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
Name string `gorm:"size:255;not null" json:"name"`
+35 -15
View File
@@ -1,42 +1,62 @@
package db
import (
"crypto/rand"
"encoding/hex"
"log/slog"
"strings"
"golang.org/x/crypto/bcrypt"
"github.com/google/uuid"
"gorm.io/gorm"
)
func SeedAdminKey(db *gorm.DB, adminToken string) error {
var count int64
db.Model(&ApiKey{}).Count(&count)
func generateRandomPassword(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func SeedAdminUser(db *gorm.DB, username, password string) error {
var count int64
db.Model(&AdminUser{}).Count(&count)
if count > 0 {
slog.Info("admin key already exists, skipping seed")
slog.Info("admin user already exists, skipping seed")
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(adminToken), bcrypt.DefaultCost)
pwd := password
autoGenerated := false
if pwd == "" {
var err error
pwd, err = generateRandomPassword(12)
if err != nil {
return err
}
autoGenerated = true
}
hash, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
if err != nil {
return err
}
admin := ApiKey{
ID: uuid.New(),
Name: "admin",
KeyHash: string(hash),
KeyPrefix: "admin-",
Scopes: StringArray{"chat", "models", "usage", "admin"},
IsActive: true,
IsAdmin: true,
admin := AdminUser{
ID: uuid.New(),
Username: strings.ToLower(username),
PasswordHash: string(hash),
IsActive: true,
}
if err := db.Create(&admin).Error; err != nil {
return err
}
slog.Info("admin key seeded", "prefix", admin.KeyPrefix)
slog.Warn("ADMIN TOKEN: " + adminToken + " (save this!)")
slog.Info("admin user seeded", "username", admin.Username)
if autoGenerated {
slog.Warn("ADMIN USER: username=" + admin.Username + " password=" + pwd + " (save this, it won't be shown again)")
}
return nil
}
+6
View File
@@ -0,0 +1,6 @@
import{d as D,q as L,c as n,a as e,b as k,e as h,x as M,g as f,F as $,i as S,w as U,f as y,v as _,t as u,y as j,r as l,s as w,o,n as z}from"./index-DGy3EgY7.js";import{P as E}from"./plus-DEUh38TR.js";import{c as I}from"./createLucideIcon-C8BmASko.js";import{T}from"./trash-2-LJ7Ux6P7.js";/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const q=I("PenIcon",[["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",key:"1a8usu"}]]),B={class:"flex items-center justify-between mb-6"},G={key:0,class:"text-text-muted"},H={class:"card"},J={class:"table"},K={class:"flex gap-2"},O=["onClick"],Q=["onClick"],R={key:0},W={key:1,class:"fixed inset-0 bg-black/50 flex items-center justify-center z-50"},X={class:"card w-full max-w-md"},Y={class:"mb-4"},Z={class:"mb-4"},ee={class:"flex gap-3 justify-end"},te={key:2,class:"fixed inset-0 bg-black/50 flex items-center justify-center z-50"},se={class:"card w-full max-w-md"},ae={class:"text-lg font-semibold mb-4"},le={class:"mb-4"},ne={class:"mb-4"},oe={class:"flex items-center gap-2"},ie={class:"flex gap-3 justify-end"},ve=D({__name:"AdminUsers",setup(re){const x=l([]),g=l(!0),d=l(!1),c=l(!1),m=l(""),v=l(""),i=l(null),r=l(""),b=l(!0);async function p(){g.value=!0;try{const s=await w.get("/api/v1/admin/users");x.value=s.data}catch(s){console.error("Failed to fetch users:",s)}finally{g.value=!1}}async function F(){try{await w.post("/api/v1/admin/users",{username:m.value,password:v.value}),d.value=!1,m.value="",v.value="",await p()}catch(s){console.error("Failed to create user:",s),alert("Failed to create user")}}async function A(){if(i.value)try{const s={};r.value&&(s.password=r.value),s.is_active=b.value,await w.put(`/api/v1/admin/users/${i.value.id}`,s),c.value=!1,i.value=null,r.value="",await p()}catch(s){console.error("Failed to update user:",s),alert("Failed to update user")}}async function N(s){if(confirm("Are you sure you want to delete this user?"))try{await w.delete(`/api/v1/admin/users/${s}`),await p()}catch(t){console.error("Failed to delete user:",t),alert("Failed to delete user")}}function P(s){i.value=s,b.value=s.is_active,r.value="",c.value=!0}function C(s){return s?new Date(s).toLocaleString():"Never"}return L(p),(s,t)=>(o(),n("div",null,[e("div",B,[t[8]||(t[8]=e("h1",{class:"text-2xl font-bold"},"Admin Users",-1)),e("button",{onClick:t[0]||(t[0]=a=>d.value=!0),class:"btn btn-primary"},[k(h(E),{class:"w-4 h-4 mr-2"}),t[7]||(t[7]=M(" New User ",-1))])]),g.value?(o(),n("div",G,"Loading...")):f("",!0),e("div",H,[e("table",J,[t[10]||(t[10]=e("thead",null,[e("tr",null,[e("th",null,"Username"),e("th",null,"Status"),e("th",null,"Created"),e("th",null,"Last Login"),e("th",null,"Actions")])],-1)),e("tbody",null,[(o(!0),n($,null,S(x.value,a=>(o(),n("tr",{key:a.id},[e("td",null,u(a.username),1),e("td",null,[e("span",{class:z([a.is_active?"badge-success":"badge-error","badge"])},u(a.is_active?"Active":"Inactive"),3)]),e("td",null,u(C(a.created_at)),1),e("td",null,u(C(a.last_login_at)),1),e("td",null,[e("div",K,[e("button",{onClick:V=>P(a),class:"btn btn-secondary btn-sm"},[k(h(q),{class:"w-4 h-4"})],8,O),e("button",{onClick:V=>N(a.id),class:"btn btn-danger btn-sm"},[k(h(T),{class:"w-4 h-4"})],8,Q)])])]))),128)),x.value.length===0?(o(),n("tr",R,[...t[9]||(t[9]=[e("td",{colspan:"5",class:"text-center text-text-muted py-8"}," No admin users yet. Create one to get started. ",-1)])])):f("",!0)])])]),d.value?(o(),n("div",W,[e("div",X,[t[14]||(t[14]=e("h2",{class:"text-lg font-semibold mb-4"},"Create Admin User",-1)),e("form",{onSubmit:U(F,["prevent"])},[e("div",Y,[t[11]||(t[11]=e("label",{class:"block text-sm font-medium mb-2"},"Username",-1)),y(e("input",{"onUpdate:modelValue":t[1]||(t[1]=a=>m.value=a),type:"text",class:"input",placeholder:"admin",required:"",minlength:"3",maxlength:"64"},null,512),[[_,m.value]])]),e("div",Z,[t[12]||(t[12]=e("label",{class:"block text-sm font-medium mb-2"},"Password",-1)),y(e("input",{"onUpdate:modelValue":t[2]||(t[2]=a=>v.value=a),type:"password",class:"input",placeholder:"Min 8 characters",required:"",minlength:"8"},null,512),[[_,v.value]])]),e("div",ee,[e("button",{type:"button",onClick:t[3]||(t[3]=a=>d.value=!1),class:"btn btn-secondary"}," Cancel "),t[13]||(t[13]=e("button",{type:"submit",class:"btn btn-primary"},"Create",-1))])],32)])])):f("",!0),c.value&&i.value?(o(),n("div",te,[e("div",se,[e("h2",ae,"Edit User: "+u(i.value.username),1),e("form",{onSubmit:U(A,["prevent"])},[e("div",le,[t[15]||(t[15]=e("label",{class:"block text-sm font-medium mb-2"},"New Password (leave blank to keep current)",-1)),y(e("input",{"onUpdate:modelValue":t[4]||(t[4]=a=>r.value=a),type:"password",class:"input",placeholder:"Min 8 characters",minlength:"8"},null,512),[[_,r.value]])]),e("div",ne,[e("label",oe,[y(e("input",{"onUpdate:modelValue":t[5]||(t[5]=a=>b.value=a),type:"checkbox",class:"rounded"},null,512),[[j,b.value]]),t[16]||(t[16]=e("span",{class:"text-sm font-medium"},"Active",-1))])]),e("div",ie,[e("button",{type:"button",onClick:t[6]||(t[6]=a=>c.value=!1),class:"btn btn-secondary"}," Cancel "),t[17]||(t[17]=e("button",{type:"submit",class:"btn btn-primary"},"Save",-1))])],32)])])):f("",!0)]))}});export{ve as default};
+6
View File
@@ -0,0 +1,6 @@
import{d as A,q as P,c as a,a as e,b as p,e as x,x as g,g as c,t as o,F as h,i as C,w as I,f as T,v as V,r,s as k,o as l}from"./index-DGy3EgY7.js";import{P as $}from"./plus-DEUh38TR.js";import{c as j}from"./createLucideIcon-C8BmASko.js";import{T as D}from"./trash-2-LJ7Ux6P7.js";/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const F=j("CopyIcon",[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2",key:"17jyea"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2",key:"zix9uf"}]]),L={class:"flex items-center justify-between mb-6"},M={key:0,class:"text-text-muted"},S={key:1,class:"card mb-6 bg-success/5 border-success/20"},B={class:"flex items-center justify-between"},q={class:"mt-4 p-3 bg-background rounded-lg font-mono text-sm break-all"},z={class:"card"},U={class:"table"},E={class:"font-mono text-text-muted"},O=["onClick"],Y={key:1,class:"badge badge-info"},G={key:0},H={key:2,class:"fixed inset-0 bg-black/50 flex items-center justify-center z-50"},J={class:"card w-full max-w-md"},Q={class:"mb-4"},R={class:"flex gap-3 justify-end"},se=A({__name:"ApiKeys",setup(W){const m=r([]),y=r(!0),u=r(!1),d=r(""),i=r(null),v=r(!1);async function b(){y.value=!0;try{const s=await k.get("/api/v1/admin/keys");m.value=s.data}catch(s){console.error("Failed to fetch keys:",s)}finally{y.value=!1}}async function _(){try{const s=await k.post("/api/v1/admin/keys",{name:d.value});i.value=s.data,u.value=!1,d.value="",await b()}catch(s){console.error("Failed to create key:",s)}}async function K(s){if(confirm("Are you sure you want to revoke this key?"))try{await k.delete(`/api/v1/admin/keys/${s}`),await b()}catch(t){console.error("Failed to revoke key:",t)}}async function N(s){await navigator.clipboard.writeText(s),v.value=!0,setTimeout(()=>v.value=!1,2e3)}function w(s){return s?new Date(s).toLocaleString():"Never"}return P(b),(s,t)=>(l(),a("div",null,[e("div",L,[t[6]||(t[6]=e("h1",{class:"text-2xl font-bold"},"API Keys",-1)),e("button",{onClick:t[0]||(t[0]=n=>u.value=!0),class:"btn btn-primary"},[p(x($),{class:"w-4 h-4 mr-2"}),t[5]||(t[5]=g(" New Key ",-1))])]),y.value?(l(),a("div",M,"Loading...")):c("",!0),i.value?(l(),a("div",S,[e("div",B,[t[7]||(t[7]=e("div",null,[e("h3",{class:"font-semibold text-success"},"API Key Created"),e("p",{class:"text-sm text-text-muted mt-1"}," Copy this key now. You won't be able to see it again. ")],-1)),e("button",{onClick:t[1]||(t[1]=n=>N(i.value.key)),class:"btn btn-secondary"},[p(x(F),{class:"w-4 h-4 mr-2"}),g(" "+o(v.value?"Copied!":"Copy"),1)])]),e("div",q,o(i.value.key),1),e("button",{onClick:t[2]||(t[2]=n=>i.value=null),class:"mt-4 text-sm text-text-muted hover:text-text"}," Close ")])):c("",!0),e("div",z,[e("table",U,[t[9]||(t[9]=e("thead",null,[e("tr",null,[e("th",null,"Name"),e("th",null,"Prefix"),e("th",null,"Scopes"),e("th",null,"Owner"),e("th",null,"Created"),e("th",null,"Last Used"),e("th",null,"Actions")])],-1)),e("tbody",null,[(l(!0),a(h,null,C(m.value,n=>(l(),a("tr",{key:n.id},[e("td",null,o(n.name),1),e("td",E,o(n.key_prefix)+"...",1),e("td",null,[(l(!0),a(h,null,C(n.scopes,f=>(l(),a("span",{key:f,class:"badge mr-1"},o(f),1))),128))]),e("td",null,o(n.owner_label||"-"),1),e("td",null,o(w(n.created_at)),1),e("td",null,o(w(n.last_used_at)),1),e("td",null,[n.is_admin?(l(),a("span",Y,"Admin")):(l(),a("button",{key:0,onClick:f=>K(n.id),class:"btn btn-danger btn-sm"},[p(x(D),{class:"w-4 h-4"})],8,O))])]))),128)),m.value.length===0?(l(),a("tr",G,[...t[8]||(t[8]=[e("td",{colspan:"7",class:"text-center text-text-muted py-8"}," No API keys yet. Create one to get started. ",-1)])])):c("",!0)])])]),u.value?(l(),a("div",H,[e("div",J,[t[12]||(t[12]=e("h2",{class:"text-lg font-semibold mb-4"},"Create API Key",-1)),e("form",{onSubmit:I(_,["prevent"])},[e("div",Q,[t[10]||(t[10]=e("label",{class:"block text-sm font-medium mb-2"},"Key Name",-1)),T(e("input",{"onUpdate:modelValue":t[3]||(t[3]=n=>d.value=n),type:"text",class:"input",placeholder:"My API Key",required:""},null,512),[[V,d.value]])]),e("div",R,[e("button",{type:"button",onClick:t[4]||(t[4]=n=>u.value=!1),class:"btn btn-secondary"}," Cancel "),t[11]||(t[11]=e("button",{type:"submit",class:"btn btn-primary"},"Create",-1))])],32)])])):c("",!0)]))}});export{se as default};
+11
View File
@@ -0,0 +1,11 @@
import{d as A,q as C,c as e,a as t,t as d,F as h,b as _,e as m,n as L,g as b,i as M,r as u,s as v,o as a}from"./index-DGy3EgY7.js";import{c as y}from"./createLucideIcon-C8BmASko.js";import{C as N}from"./cpu-D29LpvoC.js";import{K as S}from"./key-BRxFGImj.js";/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const j=y("ActivityIcon",[["path",{d:"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2",key:"169zse"}]]);/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const D=y("ClockIcon",[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["polyline",{points:"12 6 12 12 16 14",key:"68esgv"}]]),F={key:0,class:"text-text-muted"},I={key:1,class:"p-4 bg-error/10 border border-error/20 rounded-lg text-error"},q={class:"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"},R={class:"card"},B={class:"flex items-center gap-4"},K={class:"w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center"},T={class:"text-2xl font-bold"},V={class:"card"},z={class:"flex items-center gap-4"},P={class:"w-12 h-12 bg-success/10 rounded-xl flex items-center justify-center"},$={class:"text-2xl font-bold"},E={class:"card"},H={class:"flex items-center gap-4"},Y={class:"w-12 h-12 bg-warning/10 rounded-xl flex items-center justify-center"},G={class:"text-2xl font-bold"},J={class:"card"},O={class:"flex items-center gap-4"},Q={class:"w-12 h-12 bg-info/10 rounded-xl flex items-center justify-center"},U={class:"text-2xl font-bold"},W={class:"card mb-8"},X={class:"flex items-center justify-between mb-4"},Z={key:0,class:"mb-4"},tt={class:"text-lg font-mono"},st={key:1,class:"p-3 bg-error/10 border border-error/20 rounded-lg text-error text-sm"},et={class:"card"},at={class:"table"},ot={class:"font-mono"},lt={class:"font-mono text-text-muted"},dt={key:0,class:"badge badge-info"},nt={key:1,class:"badge"},rt={key:0,class:"badge badge-success"},it={key:1,class:"badge"},ct=["onClick"],ut={key:1,class:"badge badge-success"},_t={key:0},bt=A({__name:"Dashboard",setup(mt){const r=u({total_requests:0,total_tokens:0,avg_latency_ms:0,active_keys:0,total_models:0}),l=u({status:"stopped",current_model:null,loaded_at:null,last_error:null}),x=u([]),g=u(!0),i=u("");async function f(){var c,s,o;g.value=!0,i.value="";try{const[n,p,w]=await Promise.all([v.get("/api/v1/admin/dashboard"),v.get("/api/v1/admin/models"),v.get("/api/v1/admin/status")]);r.value=n.data.stats,x.value=p.data.data,l.value=w.data}catch(n){i.value=((o=(s=(c=n.response)==null?void 0:c.data)==null?void 0:s.error)==null?void 0:o.message)||"Failed to load dashboard"}finally{g.value=!1}}async function k(c){var s,o,n;try{await v.post(`/api/v1/admin/models/${c}/load`),await f()}catch(p){i.value=((n=(o=(s=p.response)==null?void 0:s.data)==null?void 0:o.error)==null?void 0:n.message)||"Failed to load model"}}return C(f),(c,s)=>(a(),e("div",null,[s[9]||(s[9]=t("h1",{class:"text-2xl font-bold mb-6"},"Dashboard",-1)),g.value?(a(),e("div",F,"Loading...")):i.value?(a(),e("div",I,d(i.value),1)):(a(),e(h,{key:2},[t("div",q,[t("div",R,[t("div",B,[t("div",K,[_(m(j),{class:"w-6 h-6 text-primary"})]),t("div",null,[s[0]||(s[0]=t("p",{class:"text-text-muted text-sm"},"Total Requests",-1)),t("p",T,d(r.value.total_requests.toLocaleString()),1)])])]),t("div",V,[t("div",z,[t("div",P,[_(m(N),{class:"w-6 h-6 text-success"})]),t("div",null,[s[1]||(s[1]=t("p",{class:"text-text-muted text-sm"},"Total Tokens",-1)),t("p",$,d(r.value.total_tokens.toLocaleString()),1)])])]),t("div",E,[t("div",H,[t("div",Y,[_(m(D),{class:"w-6 h-6 text-warning"})]),t("div",null,[s[2]||(s[2]=t("p",{class:"text-text-muted text-sm"},"Avg Latency",-1)),t("p",G,d(r.value.avg_latency_ms.toFixed(0))+"ms",1)])])]),t("div",J,[t("div",O,[t("div",Q,[_(m(S),{class:"w-6 h-6 text-info"})]),t("div",null,[s[3]||(s[3]=t("p",{class:"text-text-muted text-sm"},"Active Keys",-1)),t("p",U,d(r.value.active_keys),1)])])])]),t("div",W,[t("div",X,[s[4]||(s[4]=t("h2",{class:"text-lg font-semibold"},"Model Status",-1)),t("span",{class:L(["badge",{"badge-success":l.value.status==="ready","badge-warning":l.value.status==="loading"||l.value.status==="swapping","badge-error":l.value.status==="failed"}])},d(l.value.status),3)]),l.value.current_model?(a(),e("div",Z,[s[5]||(s[5]=t("p",{class:"text-text-muted text-sm"},"Current Model",-1)),t("p",tt,d(l.value.current_model),1)])):b("",!0),l.value.last_error?(a(),e("div",st,d(l.value.last_error),1)):b("",!0)]),t("div",et,[s[8]||(s[8]=t("h2",{class:"text-lg font-semibold mb-4"},"Models",-1)),t("table",at,[s[7]||(s[7]=t("thead",null,[t("tr",null,[t("th",null,"Name"),t("th",null,"Alias"),t("th",null,"Default"),t("th",null,"Status"),t("th",null,"Actions")])],-1)),t("tbody",null,[(a(!0),e(h,null,M(x.value,o=>(a(),e("tr",{key:o.id},[t("td",ot,d(o.name),1),t("td",lt,d(o.alias),1),t("td",null,[o.is_default?(a(),e("span",dt,"Yes")):(a(),e("span",nt,"No"))]),t("td",null,[o.is_active?(a(),e("span",rt,"Active")):(a(),e("span",it,"Inactive"))]),t("td",null,[o.is_active?(a(),e("span",ut,"Loaded")):(a(),e("button",{key:0,onClick:n=>k(o.name),class:"btn btn-primary btn-sm"}," Load ",8,ct))])]))),128)),x.value.length===0?(a(),e("tr",_t,[...s[6]||(s[6]=[t("td",{colspan:"5",class:"text-center text-text-muted py-8"}," No models configured. Add models via the API. ",-1)])])):b("",!0)])])])],64))]))}});export{bt as default};
+21
View File
@@ -0,0 +1,21 @@
import{d as u,u as m,c,a as e,F as y,i as x,b as r,e as a,R as k,h as g,o as n,j as b,n as f,k as v,l as _,m as L,p as C,t as w}from"./index-DGy3EgY7.js";import{c as s}from"./createLucideIcon-C8BmASko.js";import{K as M}from"./key-BRxFGImj.js";import{C as I}from"./cpu-D29LpvoC.js";/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const V=s("ChartColumnIcon",[["path",{d:"M3 3v16a2 2 0 0 0 2 2h16",key:"c24i48"}],["path",{d:"M18 17V9",key:"2bz60n"}],["path",{d:"M13 17V5",key:"1frdt8"}],["path",{d:"M8 17v-3",key:"17ska0"}]]);/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const D=s("LayoutDashboardIcon",[["rect",{width:"7",height:"9",x:"3",y:"3",rx:"1",key:"10lvy0"}],["rect",{width:"7",height:"5",x:"14",y:"3",rx:"1",key:"16une8"}],["rect",{width:"7",height:"9",x:"14",y:"12",rx:"1",key:"1hutg5"}],["rect",{width:"7",height:"5",x:"3",y:"16",rx:"1",key:"ldoo1y"}]]);/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const R=s("LogOutIcon",[["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4",key:"1uf3rs"}],["polyline",{points:"16 17 21 12 16 7",key:"1gabdz"}],["line",{x1:"21",x2:"9",y1:"12",y2:"12",key:"1uyos4"}]]);/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const A=s("UsersIcon",[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",key:"1yyitq"}],["circle",{cx:"9",cy:"7",r:"4",key:"nufk8"}],["path",{d:"M22 21v-2a4 4 0 0 0-3-3.87",key:"kshegd"}],["path",{d:"M16 3.13a4 4 0 0 1 0 7.75",key:"1da9ce"}]]),B={class:"min-h-screen bg-background"},U={class:"fixed left-0 top-0 h-full w-64 bg-surface border-r border-border flex flex-col"},z={class:"flex-1 p-4 space-y-1"},K={class:"p-4 border-t border-border"},S={class:"ml-64 min-h-screen p-8"},q=u({__name:"Layout",setup(F){const d=_(),i=g(),l=m(),h=[{name:"Dashboard",path:"/admin/",icon:D},{name:"API Keys",path:"/admin/keys",icon:M},{name:"Models",path:"/admin/models",icon:I},{name:"Usage",path:"/admin/usage",icon:V},{name:"Admin Users",path:"/admin/users",icon:A}];function p(){l.logout(),i.push("/admin/login")}return(H,o)=>(n(),c("div",B,[e("aside",U,[o[1]||(o[1]=e("div",{class:"p-6 border-b border-border"},[e("h1",{class:"text-xl font-bold text-primary"},"LlamaLink"),e("p",{class:"text-xs text-text-muted mt-1"},"Admin Panel")],-1)),e("nav",z,[(n(),c(y,null,x(h,t=>r(a(v),{key:t.path,to:t.path,class:f(["flex items-center gap-3 px-4 py-3 rounded-lg transition-colors",a(d).path===t.path||t.path!=="/admin/"&&a(d).path.startsWith(t.path)?"bg-primary/10 text-primary":"text-text-muted hover:text-text hover:bg-border"])},{default:b(()=>[(n(),L(C(t.icon),{class:"w-5 h-5"})),e("span",null,w(t.name),1)]),_:2},1032,["to","class"])),64))]),e("div",K,[e("button",{onClick:p,class:"flex items-center gap-3 w-full px-4 py-3 rounded-lg text-text-muted hover:text-error hover:bg-error/10 transition-colors"},[r(a(R),{class:"w-5 h-5"}),o[0]||(o[0]=e("span",null,"Logout",-1))])])]),e("main",S,[r(a(k))])]))}});export{q as default};
+1
View File
@@ -0,0 +1 @@
import{d as b,u as g,c as n,a as e,b as x,e as u,w as y,f as m,v as c,t as _,g as w,r as d,o as l,h}from"./index-DGy3EgY7.js";import{K as k}from"./key-BRxFGImj.js";import"./createLucideIcon-C8BmASko.js";const L={class:"min-h-screen bg-background flex items-center justify-center"},V={class:"w-full max-w-md"},S={class:"card"},U={class:"flex items-center gap-3 mb-6"},B={class:"w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center"},E={key:0,class:"p-3 bg-error/10 border border-error/20 rounded-lg text-error text-sm"},N=["disabled"],j={key:0},q={key:1},M=b({__name:"Login",setup(A){const p=h(),o=g(),r=d(""),a=d(""),t=d("");async function v(){if(!r.value.trim()){t.value="Username is required";return}if(!a.value){t.value="Password is required";return}await o.login(r.value,a.value)?p.push("/admin/"):t.value=o.error||"Login failed"}return(f,s)=>(l(),n("div",L,[e("div",V,[e("div",S,[e("div",U,[e("div",B,[x(u(k),{class:"w-6 h-6 text-primary"})]),s[2]||(s[2]=e("div",null,[e("h1",{class:"text-2xl font-bold"},"LlamaLink"),e("p",{class:"text-text-muted text-sm"},"Admin Login")],-1))]),e("form",{onSubmit:y(v,["prevent"]),class:"space-y-4"},[e("div",null,[s[3]||(s[3]=e("label",{class:"block text-sm font-medium mb-2"},"Username",-1)),m(e("input",{"onUpdate:modelValue":s[0]||(s[0]=i=>r.value=i),type:"text",class:"input",placeholder:"Enter your username",autocomplete:"username"},null,512),[[c,r.value]])]),e("div",null,[s[4]||(s[4]=e("label",{class:"block text-sm font-medium mb-2"},"Password",-1)),m(e("input",{"onUpdate:modelValue":s[1]||(s[1]=i=>a.value=i),type:"password",class:"input",placeholder:"Enter your password",autocomplete:"current-password"},null,512),[[c,a.value]])]),t.value?(l(),n("div",E,_(t.value),1)):w("",!0),e("button",{type:"submit",class:"btn btn-primary w-full",disabled:u(o).loading},[u(o).loading?(l(),n("span",j,"Logging in...")):(l(),n("span",q,"Login"))],8,N)],32)])])]))}});export{M as default};
+6
View File
@@ -0,0 +1,6 @@
import{d as w,q as h,c as a,a as t,b as y,e as x,x as g,g as v,F as M,i as C,w as U,f as d,v as i,y as V,r as m,s as f,o as n,t as u}from"./index-DGy3EgY7.js";import{P as L}from"./plus-DEUh38TR.js";import{c as N}from"./createLucideIcon-C8BmASko.js";/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const z=N("UploadIcon",[["path",{d:"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4",key:"ih7n3h"}],["polyline",{points:"17 8 12 3 7 8",key:"t8dd8p"}],["line",{x1:"12",x2:"12",y1:"3",y2:"15",key:"widbto"}]]),A={class:"flex items-center justify-between mb-6"},P={key:0,class:"text-text-muted"},S={class:"card"},q={class:"table"},D={class:"font-mono"},F={class:"font-mono text-text-muted text-sm"},j={class:"font-mono"},$={key:0,class:"badge badge-info"},B={key:1,class:"badge"},I={key:0,class:"badge badge-success"},G={key:1,class:"badge"},T=["onClick"],E={key:1,class:"badge badge-success"},H={key:0},O={key:1,class:"fixed inset-0 bg-black/50 flex items-center justify-center z-50"},J={class:"card w-full max-w-lg"},K={class:"grid grid-cols-2 gap-4"},Q={class:"flex items-center gap-2"},R={class:"flex gap-3 justify-end pt-2"},et=w({__name:"Models",setup(W){const p=m([]),c=m(!0),r=m(!1),s=m({name:"",model_path:"",alias:"",ctx_size:8192,n_gpu_layers:-1,is_default:!1});async function b(){c.value=!0;try{const o=await f.get("/api/v1/models");p.value=o.data.data}catch(o){console.error("Failed to fetch models:",o)}finally{c.value=!1}}async function _(){try{await f.post("/api/v1/models",s.value),r.value=!1,Object.assign(s.value,{name:"",model_path:"",alias:"",ctx_size:8192,n_gpu_layers:-1,is_default:!1}),await b()}catch(o){console.error("Failed to create model:",o)}}async function k(o){try{await f.post(`/api/v1/models/${o}/load`),await b()}catch(e){console.error("Failed to load model:",e)}}return h(b),(o,e)=>(n(),a("div",null,[t("div",A,[e[9]||(e[9]=t("h1",{class:"text-2xl font-bold"},"Models",-1)),t("button",{onClick:e[0]||(e[0]=l=>r.value=!0),class:"btn btn-primary"},[y(x(L),{class:"w-4 h-4 mr-2"}),e[8]||(e[8]=g(" Add Model ",-1))])]),c.value?(n(),a("div",P,"Loading...")):v("",!0),t("div",S,[t("table",q,[e[12]||(e[12]=t("thead",null,[t("tr",null,[t("th",null,"Name"),t("th",null,"Path"),t("th",null,"Alias"),t("th",null,"Context"),t("th",null,"GPU Layers"),t("th",null,"Default"),t("th",null,"Status"),t("th",null,"Actions")])],-1)),t("tbody",null,[(n(!0),a(M,null,C(p.value,l=>(n(),a("tr",{key:l.id},[t("td",D,u(l.name),1),t("td",F,u(l.model_path),1),t("td",j,u(l.alias),1),t("td",null,u(l.ctx_size.toLocaleString()),1),t("td",null,u(l.n_gpu_layers),1),t("td",null,[l.is_default?(n(),a("span",$,"Default")):(n(),a("span",B,"No"))]),t("td",null,[l.is_active?(n(),a("span",I,"Active")):(n(),a("span",G,"Inactive"))]),t("td",null,[l.is_active?(n(),a("span",E,"Loaded")):(n(),a("button",{key:0,onClick:X=>k(l.name),class:"btn btn-primary btn-sm"},[y(x(z),{class:"w-4 h-4 mr-1"}),e[10]||(e[10]=g(" Load ",-1))],8,T))])]))),128)),p.value.length===0?(n(),a("tr",H,[...e[11]||(e[11]=[t("td",{colspan:"8",class:"text-center text-text-muted py-8"}," No models configured. Add one to get started. ",-1)])])):v("",!0)])])]),r.value?(n(),a("div",O,[t("div",J,[e[20]||(e[20]=t("h2",{class:"text-lg font-semibold mb-4"},"Add Model",-1)),t("form",{onSubmit:U(_,["prevent"]),class:"space-y-4"},[t("div",null,[e[13]||(e[13]=t("label",{class:"block text-sm font-medium mb-2"},"Name",-1)),d(t("input",{"onUpdate:modelValue":e[1]||(e[1]=l=>s.value.name=l),type:"text",class:"input",placeholder:"llama-3.2-1b",required:""},null,512),[[i,s.value.name]])]),t("div",null,[e[14]||(e[14]=t("label",{class:"block text-sm font-medium mb-2"},"Model Path",-1)),d(t("input",{"onUpdate:modelValue":e[2]||(e[2]=l=>s.value.model_path=l),type:"text",class:"input",placeholder:"/models/llama-3.2-1b.q4_k_m.gguf",required:""},null,512),[[i,s.value.model_path]])]),t("div",null,[e[15]||(e[15]=t("label",{class:"block text-sm font-medium mb-2"},"Alias",-1)),d(t("input",{"onUpdate:modelValue":e[3]||(e[3]=l=>s.value.alias=l),type:"text",class:"input",placeholder:"llama-3.2-1b",required:""},null,512),[[i,s.value.alias]])]),t("div",K,[t("div",null,[e[16]||(e[16]=t("label",{class:"block text-sm font-medium mb-2"},"Context Size",-1)),d(t("input",{"onUpdate:modelValue":e[4]||(e[4]=l=>s.value.ctx_size=l),type:"number",class:"input"},null,512),[[i,s.value.ctx_size,void 0,{number:!0}]])]),t("div",null,[e[17]||(e[17]=t("label",{class:"block text-sm font-medium mb-2"},"GPU Layers",-1)),d(t("input",{"onUpdate:modelValue":e[5]||(e[5]=l=>s.value.n_gpu_layers=l),type:"number",class:"input"},null,512),[[i,s.value.n_gpu_layers,void 0,{number:!0}]])])]),t("div",Q,[d(t("input",{"onUpdate:modelValue":e[6]||(e[6]=l=>s.value.is_default=l),type:"checkbox",id:"is_default",class:"w-4 h-4 rounded"},null,512),[[V,s.value.is_default]]),e[18]||(e[18]=t("label",{for:"is_default",class:"text-sm"},"Set as default model",-1))]),t("div",R,[t("button",{type:"button",onClick:e[7]||(e[7]=l=>r.value=!1),class:"btn btn-secondary"}," Cancel "),e[19]||(e[19]=t("button",{type:"submit",class:"btn btn-primary"},"Create",-1))])],32)])])):v("",!0)]))}});export{et as default};
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
import{c as e}from"./createLucideIcon-C8BmASko.js";/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const h=e("CpuIcon",[["rect",{width:"16",height:"16",x:"4",y:"4",rx:"2",key:"14l7u7"}],["rect",{width:"6",height:"6",x:"9",y:"9",rx:"1",key:"5aljv4"}],["path",{d:"M15 2v2",key:"13l42r"}],["path",{d:"M15 20v2",key:"15mkzm"}],["path",{d:"M2 15h2",key:"1gxd5l"}],["path",{d:"M2 9h2",key:"1bbxkp"}],["path",{d:"M20 15h2",key:"19e6y8"}],["path",{d:"M20 9h2",key:"19tzq7"}],["path",{d:"M9 2v2",key:"165o2o"}],["path",{d:"M9 20v2",key:"i2bqo8"}]]);export{h as C};
+21
View File
@@ -0,0 +1,21 @@
import{A as a}from"./index-DGy3EgY7.js";/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const d=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase();/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/var o={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"};/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const h=({size:e,strokeWidth:t=2,absoluteStrokeWidth:r,color:s,iconNode:n,name:i,class:w,...l},{slots:c})=>a("svg",{...o,width:e||o.width,height:e||o.height,stroke:s||o.stroke,"stroke-width":r?Number(t)*24/Number(e):t,class:["lucide",`lucide-${d(i??"icon")}`],...l},[...n.map(u=>a(...u)),...c.default?[c.default()]:[]]);/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const m=(e,t)=>(r,{slots:s})=>a(h,{...r,iconNode:t,name:e},s);export{m as c};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
import{c}from"./createLucideIcon-C8BmASko.js";/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const a=c("KeyIcon",[["path",{d:"m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4",key:"g0fldk"}],["path",{d:"m21 2-9.6 9.6",key:"1j0ho8"}],["circle",{cx:"7.5",cy:"15.5",r:"5.5",key:"yqb3hr"}]]);export{a as K};
+6
View File
@@ -0,0 +1,6 @@
import{c as e}from"./createLucideIcon-C8BmASko.js";/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const a=e("PlusIcon",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"M12 5v14",key:"s699le"}]]);export{a as P};
+6
View File
@@ -0,0 +1,6 @@
import{c as e}from"./createLucideIcon-C8BmASko.js";/**
* @license lucide-vue-next v0.460.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const a=e("Trash2Icon",[["path",{d:"M3 6h18",key:"d0wm0j"}],["path",{d:"M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6",key:"4alrt4"}],["path",{d:"M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2",key:"v07s0e"}],["line",{x1:"10",x2:"10",y1:"11",y2:"17",key:"1uufr5"}],["line",{x1:"14",x2:"14",y1:"11",y2:"17",key:"xtxkd"}]]);export{a as T};
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LlamaLink Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-DGy3EgY7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DkKprt_C.css">
</head>
<body class="bg-background text-text">
<div id="app"></div>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
package web
import (
"embed"
"io/fs"
"net/http"
"path"
)
//go:embed all:dist
var distFS embed.FS
func FileSystem() http.FileSystem {
sub, _ := fs.Sub(distFS, "dist")
return http.FS(sub)
}
func Index() ([]byte, error) {
return distFS.ReadFile("dist/index.html")
}
func ServeAsset(filepath string) ([]byte, error) {
fullPath := path.Join("dist/assets", filepath)
return distFS.ReadFile(fullPath)
}
+2 -1
View File
@@ -7,9 +7,10 @@ ARCH="${2:-$(dpkg --print-architecture 2>/dev/null || echo "amd64")}"
if [[ -z "$VERSION" ]]; then
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
VERSION="${VERSION#v}"
fi
VERSION="${VERSION#v}"
if ! command -v dpkg-deb &>/dev/null; then
echo "Error: dpkg-deb not found. Install with: sudo apt install dpkg" >&2
exit 1
+1 -1
View File
@@ -12,7 +12,7 @@ api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_session')
window.location.href = '/admin/login'
}
return Promise.reject(error)
+5
View File
@@ -33,6 +33,11 @@ const routes = [
name: 'Usage',
component: () => import('@/views/Usage.vue'),
},
{
path: 'users',
name: 'AdminUsers',
component: () => import('@/views/AdminUsers.vue'),
},
],
},
{
+5 -5
View File
@@ -3,20 +3,20 @@ import { ref, computed } from 'vue'
import { api } from '@/lib/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('admin_token'))
const token = ref<string | null>(localStorage.getItem('admin_session'))
const loading = ref(false)
const error = ref<string | null>(null)
const isAuthenticated = computed(() => !!token.value)
async function login(adminToken: string) {
async function login(username: string, password: string) {
loading.value = true
error.value = null
try {
const response = await api.post('/api/v1/admin/login', { admin_token: adminToken })
const response = await api.post('/api/v1/admin/login', { username, password })
token.value = response.data.token
localStorage.setItem('admin_token', response.data.token)
localStorage.setItem('admin_session', response.data.token)
api.defaults.headers.common['Authorization'] = `Bearer ${response.data.token}`
return true
} catch (err: any) {
@@ -29,7 +29,7 @@ export const useAuthStore = defineStore('auth', () => {
function logout() {
token.value = null
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_session')
delete api.defaults.headers.common['Authorization']
}
+199
View File
@@ -0,0 +1,199 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api } from '@/lib/api'
import { Plus, Trash2, Edit2 } from 'lucide-vue-next'
interface AdminUser {
id: string
username: string
is_active: boolean
created_at: string
last_login_at: string | null
}
const users = ref<AdminUser[]>([])
const loading = ref(true)
const showCreateDialog = ref(false)
const showEditDialog = ref(false)
const newUsername = ref('')
const newPassword = ref('')
const editingUser = ref<AdminUser | null>(null)
const editPassword = ref('')
const editIsActive = ref(true)
async function fetchUsers() {
loading.value = true
try {
const res = await api.get('/api/v1/admin/users')
users.value = res.data
} catch (err) {
console.error('Failed to fetch users:', err)
} finally {
loading.value = false
}
}
async function createUser() {
try {
await api.post('/api/v1/admin/users', {
username: newUsername.value,
password: newPassword.value,
})
showCreateDialog.value = false
newUsername.value = ''
newPassword.value = ''
await fetchUsers()
} catch (err) {
console.error('Failed to create user:', err)
alert('Failed to create user')
}
}
async function updateUser() {
if (!editingUser.value) return
try {
const payload: any = {}
if (editPassword.value) {
payload.password = editPassword.value
}
payload.is_active = editIsActive.value
await api.put(`/api/v1/admin/users/${editingUser.value.id}`, payload)
showEditDialog.value = false
editingUser.value = null
editPassword.value = ''
await fetchUsers()
} catch (err) {
console.error('Failed to update user:', err)
alert('Failed to update user')
}
}
async function deleteUser(id: string) {
if (!confirm('Are you sure you want to delete this user?')) return
try {
await api.delete(`/api/v1/admin/users/${id}`)
await fetchUsers()
} catch (err) {
console.error('Failed to delete user:', err)
alert('Failed to delete user')
}
}
function openEditDialog(user: AdminUser) {
editingUser.value = user
editIsActive.value = user.is_active
editPassword.value = ''
showEditDialog.value = true
}
function formatDate(date: string | null) {
if (!date) return 'Never'
return new Date(date).toLocaleString()
}
onMounted(fetchUsers)
</script>
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Admin Users</h1>
<button @click="showCreateDialog = true" class="btn btn-primary">
<Plus class="w-4 h-4 mr-2" />
New User
</button>
</div>
<div v-if="loading" class="text-text-muted">Loading...</div>
<!-- Users Table -->
<div class="card">
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Status</th>
<th>Created</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.username }}</td>
<td>
<span :class="user.is_active ? 'badge-success' : 'badge-error'" class="badge">
{{ user.is_active ? 'Active' : 'Inactive' }}
</span>
</td>
<td>{{ formatDate(user.created_at) }}</td>
<td>{{ formatDate(user.last_login_at) }}</td>
<td>
<div class="flex gap-2">
<button @click="openEditDialog(user)" class="btn btn-secondary btn-sm">
<Edit2 class="w-4 h-4" />
</button>
<button @click="deleteUser(user.id)" class="btn btn-danger btn-sm">
<Trash2 class="w-4 h-4" />
</button>
</div>
</td>
</tr>
<tr v-if="users.length === 0">
<td colspan="5" class="text-center text-text-muted py-8">
No admin users yet. Create one to get started.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create Dialog -->
<div v-if="showCreateDialog" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card w-full max-w-md">
<h2 class="text-lg font-semibold mb-4">Create Admin User</h2>
<form @submit.prevent="createUser">
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Username</label>
<input v-model="newUsername" type="text" class="input" placeholder="admin" required minlength="3" maxlength="64" />
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2">Password</label>
<input v-model="newPassword" type="password" class="input" placeholder="Min 8 characters" required minlength="8" />
</div>
<div class="flex gap-3 justify-end">
<button type="button" @click="showCreateDialog = false" class="btn btn-secondary">
Cancel
</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
<!-- Edit Dialog -->
<div v-if="showEditDialog && editingUser" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="card w-full max-w-md">
<h2 class="text-lg font-semibold mb-4">Edit User: {{ editingUser.username }}</h2>
<form @submit.prevent="updateUser">
<div class="mb-4">
<label class="block text-sm font-medium mb-2">New Password (leave blank to keep current)</label>
<input v-model="editPassword" type="password" class="input" placeholder="Min 8 characters" minlength="8" />
</div>
<div class="mb-4">
<label class="flex items-center gap-2">
<input v-model="editIsActive" type="checkbox" class="rounded" />
<span class="text-sm font-medium">Active</span>
</label>
</div>
<div class="flex gap-3 justify-end">
<button type="button" @click="showEditDialog = false" class="btn btn-secondary">
Cancel
</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
</template>
+2 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { LayoutDashboard, Key, Cpu, BarChart3, LogOut } from 'lucide-vue-next'
import { LayoutDashboard, Key, Cpu, BarChart3, LogOut, Users } from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
@@ -12,6 +12,7 @@ const navItems = [
{ name: 'API Keys', path: '/admin/keys', icon: Key },
{ name: 'Models', path: '/admin/models', icon: Cpu },
{ name: 'Usage', path: '/admin/usage', icon: BarChart3 },
{ name: 'Admin Users', path: '/admin/users', icon: Users },
]
function handleLogout() {
+23 -7
View File
@@ -7,16 +7,21 @@ import { Key } from 'lucide-vue-next'
const router = useRouter()
const authStore = useAuthStore()
const adminToken = ref('')
const username = ref('')
const password = ref('')
const error = ref('')
async function handleLogin() {
if (!adminToken.value.trim()) {
error.value = 'Admin token is required'
if (!username.value.trim()) {
error.value = 'Username is required'
return
}
if (!password.value) {
error.value = 'Password is required'
return
}
const success = await authStore.login(adminToken.value)
const success = await authStore.login(username.value, password.value)
if (success) {
router.push('/admin/')
} else {
@@ -41,12 +46,23 @@ async function handleLogin() {
<form @submit.prevent="handleLogin" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Admin Token</label>
<label class="block text-sm font-medium mb-2">Username</label>
<input
v-model="adminToken"
v-model="username"
type="text"
class="input"
placeholder="Enter your username"
autocomplete="username"
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Password</label>
<input
v-model="password"
type="password"
class="input"
placeholder="Enter your admin token"
placeholder="Enter your password"
autocomplete="current-password"
/>
</div>