mirror of
https://github.com/mudler/LocalAI
synced 2026-04-21 13:27:21 +00:00
feat: add users and authentication support (#9061)
* feat(ui): add users and authentication support Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat: allow the admin user to impersonificate users Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: ui improvements, disable 'Users' button in navbar when no auth is configured Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat: add OIDC support Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix: gate models Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: cache requests to optimize speed Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * small UI enhancements Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(ui): style improvements Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix: cover other paths by auth Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: separate local auth, refactor Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * security hardening, approval mode Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix: fix tests and expectations Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: update localagi/localrecall Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
parent
bbe9067227
commit
aea21951a2
102 changed files with 13369 additions and 1421 deletions
|
|
@ -256,7 +256,7 @@ RUN apt-get update && \
|
||||||
|
|
||||||
FROM build-requirements AS builder-base
|
FROM build-requirements AS builder-base
|
||||||
|
|
||||||
ARG GO_TAGS=""
|
ARG GO_TAGS="auth"
|
||||||
ARG GRPC_BACKENDS
|
ARG GRPC_BACKENDS
|
||||||
ARG MAKEFLAGS
|
ARG MAKEFLAGS
|
||||||
ARG LD_FLAGS="-s -w"
|
ARG LD_FLAGS="-s -w"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"github.com/mudler/LocalAI/core/templates"
|
"github.com/mudler/LocalAI/core/templates"
|
||||||
"github.com/mudler/LocalAI/pkg/model"
|
"github.com/mudler/LocalAI/pkg/model"
|
||||||
"github.com/mudler/xlog"
|
"github.com/mudler/xlog"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Application struct {
|
type Application struct {
|
||||||
|
|
@ -22,6 +23,7 @@ type Application struct {
|
||||||
galleryService *services.GalleryService
|
galleryService *services.GalleryService
|
||||||
agentJobService *services.AgentJobService
|
agentJobService *services.AgentJobService
|
||||||
agentPoolService atomic.Pointer[services.AgentPoolService]
|
agentPoolService atomic.Pointer[services.AgentPoolService]
|
||||||
|
authDB *gorm.DB
|
||||||
watchdogMutex sync.Mutex
|
watchdogMutex sync.Mutex
|
||||||
watchdogStop chan bool
|
watchdogStop chan bool
|
||||||
p2pMutex sync.Mutex
|
p2pMutex sync.Mutex
|
||||||
|
|
@ -74,6 +76,11 @@ func (a *Application) AgentPoolService() *services.AgentPoolService {
|
||||||
return a.agentPoolService.Load()
|
return a.agentPoolService.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthDB returns the auth database connection, or nil if auth is not enabled.
|
||||||
|
func (a *Application) AuthDB() *gorm.DB {
|
||||||
|
return a.authDB
|
||||||
|
}
|
||||||
|
|
||||||
// StartupConfig returns the original startup configuration (from env vars, before file loading)
|
// StartupConfig returns the original startup configuration (from env vars, before file loading)
|
||||||
func (a *Application) StartupConfig() *config.ApplicationConfig {
|
func (a *Application) StartupConfig() *config.ApplicationConfig {
|
||||||
return a.startupConfig
|
return a.startupConfig
|
||||||
|
|
@ -118,9 +125,23 @@ func (a *Application) StartAgentPool() {
|
||||||
xlog.Error("Failed to create agent pool service", "error", err)
|
xlog.Error("Failed to create agent pool service", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if a.authDB != nil {
|
||||||
|
aps.SetAuthDB(a.authDB)
|
||||||
|
}
|
||||||
if err := aps.Start(a.applicationConfig.Context); err != nil {
|
if err := aps.Start(a.applicationConfig.Context); err != nil {
|
||||||
xlog.Error("Failed to start agent pool", "error", err)
|
xlog.Error("Failed to start agent pool", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wire per-user scoped services so collections, skills, and jobs are isolated per user
|
||||||
|
usm := services.NewUserServicesManager(
|
||||||
|
aps.UserStorage(),
|
||||||
|
a.applicationConfig,
|
||||||
|
a.modelLoader,
|
||||||
|
a.backendLoader,
|
||||||
|
a.templatesEvaluator,
|
||||||
|
)
|
||||||
|
aps.SetUserServicesManager(usm)
|
||||||
|
|
||||||
a.agentPoolService.Store(aps)
|
a.agentPoolService.Store(aps)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,7 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand
|
||||||
envF16 := appConfig.F16 == startupAppConfig.F16
|
envF16 := appConfig.F16 == startupAppConfig.F16
|
||||||
envDebug := appConfig.Debug == startupAppConfig.Debug
|
envDebug := appConfig.Debug == startupAppConfig.Debug
|
||||||
envCORS := appConfig.CORS == startupAppConfig.CORS
|
envCORS := appConfig.CORS == startupAppConfig.CORS
|
||||||
envCSRF := appConfig.CSRF == startupAppConfig.CSRF
|
envCSRF := appConfig.DisableCSRF == startupAppConfig.DisableCSRF
|
||||||
envCORSAllowOrigins := appConfig.CORSAllowOrigins == startupAppConfig.CORSAllowOrigins
|
envCORSAllowOrigins := appConfig.CORSAllowOrigins == startupAppConfig.CORSAllowOrigins
|
||||||
envP2PToken := appConfig.P2PToken == startupAppConfig.P2PToken
|
envP2PToken := appConfig.P2PToken == startupAppConfig.P2PToken
|
||||||
envP2PNetworkID := appConfig.P2PNetworkID == startupAppConfig.P2PNetworkID
|
envP2PNetworkID := appConfig.P2PNetworkID == startupAppConfig.P2PNetworkID
|
||||||
|
|
@ -313,7 +313,7 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand
|
||||||
appConfig.CORS = *settings.CORS
|
appConfig.CORS = *settings.CORS
|
||||||
}
|
}
|
||||||
if settings.CSRF != nil && !envCSRF {
|
if settings.CSRF != nil && !envCSRF {
|
||||||
appConfig.CSRF = *settings.CSRF
|
appConfig.DisableCSRF = *settings.CSRF
|
||||||
}
|
}
|
||||||
if settings.CORSAllowOrigins != nil && !envCORSAllowOrigins {
|
if settings.CORSAllowOrigins != nil && !envCORSAllowOrigins {
|
||||||
appConfig.CORSAllowOrigins = *settings.CORSAllowOrigins
|
appConfig.CORSAllowOrigins = *settings.CORSAllowOrigins
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package application
|
package application
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -10,6 +12,7 @@ import (
|
||||||
"github.com/mudler/LocalAI/core/backend"
|
"github.com/mudler/LocalAI/core/backend"
|
||||||
"github.com/mudler/LocalAI/core/config"
|
"github.com/mudler/LocalAI/core/config"
|
||||||
"github.com/mudler/LocalAI/core/gallery"
|
"github.com/mudler/LocalAI/core/gallery"
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
"github.com/mudler/LocalAI/core/services"
|
"github.com/mudler/LocalAI/core/services"
|
||||||
coreStartup "github.com/mudler/LocalAI/core/startup"
|
coreStartup "github.com/mudler/LocalAI/core/startup"
|
||||||
"github.com/mudler/LocalAI/internal"
|
"github.com/mudler/LocalAI/internal"
|
||||||
|
|
@ -81,6 +84,45 @@ func New(opts ...config.AppOption) (*Application, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize auth database if auth is enabled
|
||||||
|
if options.Auth.Enabled {
|
||||||
|
// Auto-generate HMAC secret if not provided
|
||||||
|
if options.Auth.APIKeyHMACSecret == "" {
|
||||||
|
secretFile := filepath.Join(options.DataPath, ".hmac_secret")
|
||||||
|
secret, err := loadOrGenerateHMACSecret(secretFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize HMAC secret: %w", err)
|
||||||
|
}
|
||||||
|
options.Auth.APIKeyHMACSecret = secret
|
||||||
|
}
|
||||||
|
|
||||||
|
authDB, err := auth.InitDB(options.Auth.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize auth database: %w", err)
|
||||||
|
}
|
||||||
|
application.authDB = authDB
|
||||||
|
xlog.Info("Auth enabled", "database", options.Auth.DatabaseURL)
|
||||||
|
|
||||||
|
// Start session and expired API key cleanup goroutine
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-options.Context.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := auth.CleanExpiredSessions(authDB); err != nil {
|
||||||
|
xlog.Error("failed to clean expired sessions", "error", err)
|
||||||
|
}
|
||||||
|
if err := auth.CleanExpiredAPIKeys(authDB); err != nil {
|
||||||
|
xlog.Error("failed to clean expired API keys", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
if err := coreStartup.InstallModels(options.Context, application.GalleryService(), options.Galleries, options.BackendGalleries, options.SystemState, application.ModelLoader(), options.EnforcePredownloadScans, options.AutoloadBackendGalleries, nil, options.ModelsURL...); err != nil {
|
if err := coreStartup.InstallModels(options.Context, application.GalleryService(), options.Galleries, options.BackendGalleries, options.SystemState, application.ModelLoader(), options.EnforcePredownloadScans, options.AutoloadBackendGalleries, nil, options.ModelsURL...); err != nil {
|
||||||
xlog.Error("error installing models", "error", err)
|
xlog.Error("error installing models", "error", err)
|
||||||
}
|
}
|
||||||
|
|
@ -434,6 +476,31 @@ func initializeWatchdog(application *Application, options *config.ApplicationCon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadOrGenerateHMACSecret loads an HMAC secret from the given file path,
|
||||||
|
// or generates a random 32-byte secret and persists it if the file doesn't exist.
|
||||||
|
func loadOrGenerateHMACSecret(path string) (string, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err == nil {
|
||||||
|
secret := string(data)
|
||||||
|
if len(secret) >= 32 {
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate HMAC secret: %w", err)
|
||||||
|
}
|
||||||
|
secret := hex.EncodeToString(b)
|
||||||
|
|
||||||
|
if err := os.WriteFile(path, []byte(secret), 0600); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to persist HMAC secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
xlog.Info("Generated new HMAC secret for API key hashing", "path", path)
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
// migrateDataFiles moves persistent data files from the old config directory
|
// migrateDataFiles moves persistent data files from the old config directory
|
||||||
// to the new data directory. Only moves files that exist in src but not in dst.
|
// to the new data directory. Only moves files that exist in src but not in dst.
|
||||||
func migrateDataFiles(srcDir, dstDir string) {
|
func migrateDataFiles(srcDir, dstDir string) {
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ type RunCMD struct {
|
||||||
Address string `env:"LOCALAI_ADDRESS,ADDRESS" default:":8080" help:"Bind address for the API server" group:"api"`
|
Address string `env:"LOCALAI_ADDRESS,ADDRESS" default:":8080" help:"Bind address for the API server" group:"api"`
|
||||||
CORS bool `env:"LOCALAI_CORS,CORS" help:"" group:"api"`
|
CORS bool `env:"LOCALAI_CORS,CORS" help:"" group:"api"`
|
||||||
CORSAllowOrigins string `env:"LOCALAI_CORS_ALLOW_ORIGINS,CORS_ALLOW_ORIGINS" group:"api"`
|
CORSAllowOrigins string `env:"LOCALAI_CORS_ALLOW_ORIGINS,CORS_ALLOW_ORIGINS" group:"api"`
|
||||||
CSRF bool `env:"LOCALAI_CSRF" help:"Enables fiber CSRF middleware" group:"api"`
|
DisableCSRF bool `env:"LOCALAI_DISABLE_CSRF" help:"Disable CSRF middleware (enabled by default)" group:"api"`
|
||||||
UploadLimit int `env:"LOCALAI_UPLOAD_LIMIT,UPLOAD_LIMIT" default:"15" help:"Default upload-limit in MB" group:"api"`
|
UploadLimit int `env:"LOCALAI_UPLOAD_LIMIT,UPLOAD_LIMIT" default:"15" help:"Default upload-limit in MB" group:"api"`
|
||||||
APIKeys []string `env:"LOCALAI_API_KEY,API_KEY" help:"List of API Keys to enable API authentication. When this is set, all the requests must be authenticated with one of these API keys" group:"api"`
|
APIKeys []string `env:"LOCALAI_API_KEY,API_KEY" help:"List of API Keys to enable API authentication. When this is set, all the requests must be authenticated with one of these API keys" group:"api"`
|
||||||
DisableWebUI bool `env:"LOCALAI_DISABLE_WEBUI,DISABLE_WEBUI" default:"false" help:"Disables the web user interface. When set to true, the server will only expose API endpoints without serving the web interface" group:"api"`
|
DisableWebUI bool `env:"LOCALAI_DISABLE_WEBUI,DISABLE_WEBUI" default:"false" help:"Disables the web user interface. When set to true, the server will only expose API endpoints without serving the web interface" group:"api"`
|
||||||
|
|
@ -121,6 +121,21 @@ type RunCMD struct {
|
||||||
AgentPoolCollectionDBPath string `env:"LOCALAI_AGENT_POOL_COLLECTION_DB_PATH" help:"Database path for agent collections" group:"agents"`
|
AgentPoolCollectionDBPath string `env:"LOCALAI_AGENT_POOL_COLLECTION_DB_PATH" help:"Database path for agent collections" group:"agents"`
|
||||||
AgentHubURL string `env:"LOCALAI_AGENT_HUB_URL" default:"https://agenthub.localai.io" help:"URL for the agent hub where users can browse and download agent configurations" group:"agents"`
|
AgentHubURL string `env:"LOCALAI_AGENT_HUB_URL" default:"https://agenthub.localai.io" help:"URL for the agent hub where users can browse and download agent configurations" group:"agents"`
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
AuthEnabled bool `env:"LOCALAI_AUTH" default:"false" help:"Enable user authentication and authorization" group:"auth"`
|
||||||
|
AuthDatabaseURL string `env:"LOCALAI_AUTH_DATABASE_URL,DATABASE_URL" help:"Database URL for auth (postgres:// or file path for SQLite). Defaults to {DataPath}/database.db" group:"auth"`
|
||||||
|
GitHubClientID string `env:"GITHUB_CLIENT_ID" help:"GitHub OAuth App Client ID (auto-enables auth when set)" group:"auth"`
|
||||||
|
GitHubClientSecret string `env:"GITHUB_CLIENT_SECRET" help:"GitHub OAuth App Client Secret" group:"auth"`
|
||||||
|
OIDCIssuer string `env:"LOCALAI_OIDC_ISSUER" help:"OIDC issuer URL for auto-discovery" group:"auth"`
|
||||||
|
OIDCClientID string `env:"LOCALAI_OIDC_CLIENT_ID" help:"OIDC Client ID (auto-enables auth)" group:"auth"`
|
||||||
|
OIDCClientSecret string `env:"LOCALAI_OIDC_CLIENT_SECRET" help:"OIDC Client Secret" group:"auth"`
|
||||||
|
AuthBaseURL string `env:"LOCALAI_BASE_URL" help:"Base URL for OAuth callbacks (e.g. http://localhost:8080)" group:"auth"`
|
||||||
|
AuthAdminEmail string `env:"LOCALAI_ADMIN_EMAIL" help:"Email address to auto-promote to admin role" group:"auth"`
|
||||||
|
AuthRegistrationMode string `env:"LOCALAI_REGISTRATION_MODE" default:"open" help:"Registration mode: 'open' (default), 'approval', or 'invite' (invite code required)" group:"auth"`
|
||||||
|
DisableLocalAuth bool `env:"LOCALAI_DISABLE_LOCAL_AUTH" default:"false" help:"Disable local email/password registration and login (use with OAuth/OIDC-only setups)" group:"auth"`
|
||||||
|
AuthAPIKeyHMACSecret string `env:"LOCALAI_AUTH_HMAC_SECRET" help:"HMAC secret for API key hashing (auto-generated if empty)" group:"auth"`
|
||||||
|
DefaultAPIKeyExpiry string `env:"LOCALAI_DEFAULT_API_KEY_EXPIRY" help:"Default expiry for API keys (e.g. 90d, 1y; empty = no expiry)" group:"auth"`
|
||||||
|
|
||||||
Version bool
|
Version bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +180,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
|
||||||
config.WithBackendGalleries(r.BackendGalleries),
|
config.WithBackendGalleries(r.BackendGalleries),
|
||||||
config.WithCors(r.CORS),
|
config.WithCors(r.CORS),
|
||||||
config.WithCorsAllowOrigins(r.CORSAllowOrigins),
|
config.WithCorsAllowOrigins(r.CORSAllowOrigins),
|
||||||
config.WithCsrf(r.CSRF),
|
config.WithDisableCSRF(r.DisableCSRF),
|
||||||
config.WithThreads(r.Threads),
|
config.WithThreads(r.Threads),
|
||||||
config.WithUploadLimitMB(r.UploadLimit),
|
config.WithUploadLimitMB(r.UploadLimit),
|
||||||
config.WithApiKeys(r.APIKeys),
|
config.WithApiKeys(r.APIKeys),
|
||||||
|
|
@ -311,6 +326,46 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
|
||||||
opts = append(opts, config.WithAgentHubURL(r.AgentHubURL))
|
opts = append(opts, config.WithAgentHubURL(r.AgentHubURL))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
authEnabled := r.AuthEnabled || r.GitHubClientID != "" || r.OIDCClientID != ""
|
||||||
|
if authEnabled {
|
||||||
|
opts = append(opts, config.WithAuthEnabled(true))
|
||||||
|
|
||||||
|
dbURL := r.AuthDatabaseURL
|
||||||
|
if dbURL == "" {
|
||||||
|
dbURL = filepath.Join(r.DataPath, "database.db")
|
||||||
|
}
|
||||||
|
opts = append(opts, config.WithAuthDatabaseURL(dbURL))
|
||||||
|
|
||||||
|
if r.GitHubClientID != "" {
|
||||||
|
opts = append(opts, config.WithAuthGitHubClientID(r.GitHubClientID))
|
||||||
|
opts = append(opts, config.WithAuthGitHubClientSecret(r.GitHubClientSecret))
|
||||||
|
}
|
||||||
|
if r.OIDCClientID != "" {
|
||||||
|
opts = append(opts, config.WithAuthOIDCIssuer(r.OIDCIssuer))
|
||||||
|
opts = append(opts, config.WithAuthOIDCClientID(r.OIDCClientID))
|
||||||
|
opts = append(opts, config.WithAuthOIDCClientSecret(r.OIDCClientSecret))
|
||||||
|
}
|
||||||
|
if r.AuthBaseURL != "" {
|
||||||
|
opts = append(opts, config.WithAuthBaseURL(r.AuthBaseURL))
|
||||||
|
}
|
||||||
|
if r.AuthAdminEmail != "" {
|
||||||
|
opts = append(opts, config.WithAuthAdminEmail(r.AuthAdminEmail))
|
||||||
|
}
|
||||||
|
if r.AuthRegistrationMode != "" {
|
||||||
|
opts = append(opts, config.WithAuthRegistrationMode(r.AuthRegistrationMode))
|
||||||
|
}
|
||||||
|
if r.DisableLocalAuth {
|
||||||
|
opts = append(opts, config.WithAuthDisableLocalAuth(true))
|
||||||
|
}
|
||||||
|
if r.AuthAPIKeyHMACSecret != "" {
|
||||||
|
opts = append(opts, config.WithAuthAPIKeyHMACSecret(r.AuthAPIKeyHMACSecret))
|
||||||
|
}
|
||||||
|
if r.DefaultAPIKeyExpiry != "" {
|
||||||
|
opts = append(opts, config.WithAuthDefaultAPIKeyExpiry(r.DefaultAPIKeyExpiry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if idleWatchDog || busyWatchDog {
|
if idleWatchDog || busyWatchDog {
|
||||||
opts = append(opts, config.EnableWatchDog)
|
opts = append(opts, config.EnableWatchDog)
|
||||||
if idleWatchDog {
|
if idleWatchDog {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ type ApplicationConfig struct {
|
||||||
DynamicConfigsDir string
|
DynamicConfigsDir string
|
||||||
DynamicConfigsDirPollInterval time.Duration
|
DynamicConfigsDirPollInterval time.Duration
|
||||||
CORS bool
|
CORS bool
|
||||||
CSRF bool
|
DisableCSRF bool
|
||||||
PreloadJSONModels string
|
PreloadJSONModels string
|
||||||
PreloadModelsFromPath string
|
PreloadModelsFromPath string
|
||||||
CORSAllowOrigins string
|
CORSAllowOrigins string
|
||||||
|
|
@ -96,6 +96,26 @@ type ApplicationConfig struct {
|
||||||
|
|
||||||
// Agent Pool (LocalAGI integration)
|
// Agent Pool (LocalAGI integration)
|
||||||
AgentPool AgentPoolConfig
|
AgentPool AgentPoolConfig
|
||||||
|
|
||||||
|
// Authentication & Authorization
|
||||||
|
Auth AuthConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthConfig holds configuration for user authentication and authorization.
|
||||||
|
type AuthConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
DatabaseURL string // "postgres://..." or file path for SQLite
|
||||||
|
GitHubClientID string
|
||||||
|
GitHubClientSecret string
|
||||||
|
OIDCIssuer string // OIDC issuer URL for auto-discovery (e.g. https://accounts.google.com)
|
||||||
|
OIDCClientID string
|
||||||
|
OIDCClientSecret string
|
||||||
|
BaseURL string // for OAuth callback URLs (e.g. "http://localhost:8080")
|
||||||
|
AdminEmail string // auto-promote to admin on login
|
||||||
|
RegistrationMode string // "open", "approval" (default when empty), "invite"
|
||||||
|
DisableLocalAuth bool // disable local email/password registration and login
|
||||||
|
APIKeyHMACSecret string // HMAC secret for API key hashing; auto-generated if empty
|
||||||
|
DefaultAPIKeyExpiry string // default expiry duration for API keys (e.g. "90d"); empty = no expiry
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentPoolConfig holds configuration for the LocalAGI agent pool integration.
|
// AgentPoolConfig holds configuration for the LocalAGI agent pool integration.
|
||||||
|
|
@ -150,6 +170,8 @@ func NewApplicationConfig(o ...AppOption) *ApplicationConfig {
|
||||||
"/favicon.svg",
|
"/favicon.svg",
|
||||||
"/readyz",
|
"/readyz",
|
||||||
"/healthz",
|
"/healthz",
|
||||||
|
"/api/auth/",
|
||||||
|
"/assets/",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, oo := range o {
|
for _, oo := range o {
|
||||||
|
|
@ -194,9 +216,9 @@ func WithP2PNetworkID(s string) AppOption {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithCsrf(b bool) AppOption {
|
func WithDisableCSRF(b bool) AppOption {
|
||||||
return func(o *ApplicationConfig) {
|
return func(o *ApplicationConfig) {
|
||||||
o.CSRF = b
|
o.DisableCSRF = b
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -711,6 +733,86 @@ func WithAgentHubURL(url string) AppOption {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth options
|
||||||
|
|
||||||
|
func WithAuthEnabled(enabled bool) AppOption {
|
||||||
|
return func(o *ApplicationConfig) {
|
||||||
|
o.Auth.Enabled = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAuthDatabaseURL(url string) AppOption {
|
||||||
|
return func(o *ApplicationConfig) {
|
||||||
|
o.Auth.DatabaseURL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAuthGitHubClientID(clientID string) AppOption {
|
||||||
|
return func(o *ApplicationConfig) {
|
||||||
|
o.Auth.GitHubClientID = clientID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAuthGitHubClientSecret(clientSecret string) AppOption {
|
||||||
|
return func(o *ApplicationConfig) {
|
||||||
|
o.Auth.GitHubClientSecret = clientSecret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAuthBaseURL(baseURL string) AppOption {
|
||||||
|
return func(o *ApplicationConfig) {
|
||||||
|
o.Auth.BaseURL = baseURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAuthAdminEmail(email string) AppOption {
|
||||||
|
return func(o *ApplicationConfig) {
|
||||||
|
o.Auth.AdminEmail = email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAuthRegistrationMode(mode string) AppOption {
|
||||||
|
return func(o *ApplicationConfig) {
|
||||||
|
o.Auth.RegistrationMode = mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAuthDisableLocalAuth(disable bool) AppOption {
|
||||||
|
return func(o *ApplicationConfig) {
|
||||||
|
o.Auth.DisableLocalAuth = disable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAuthOIDCIssuer(issuer string) AppOption {
|
||||||
|
return func(o *ApplicationConfig) {
|
||||||
|
o.Auth.OIDCIssuer = issuer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAuthOIDCClientID(clientID string) AppOption {
|
||||||
|
return func(o *ApplicationConfig) {
|
||||||
|
o.Auth.OIDCClientID = clientID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAuthOIDCClientSecret(clientSecret string) AppOption {
|
||||||
|
return func(o *ApplicationConfig) {
|
||||||
|
o.Auth.OIDCClientSecret = clientSecret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAuthAPIKeyHMACSecret(secret string) AppOption {
|
||||||
|
return func(o *ApplicationConfig) {
|
||||||
|
o.Auth.APIKeyHMACSecret = secret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAuthDefaultAPIKeyExpiry(expiry string) AppOption {
|
||||||
|
return func(o *ApplicationConfig) {
|
||||||
|
o.Auth.DefaultAPIKeyExpiry = expiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ToConfigLoaderOptions returns a slice of ConfigLoader Option.
|
// ToConfigLoaderOptions returns a slice of ConfigLoader Option.
|
||||||
// Some options defined at the application level are going to be passed as defaults for
|
// Some options defined at the application level are going to be passed as defaults for
|
||||||
// all the configuration for the models.
|
// all the configuration for the models.
|
||||||
|
|
@ -750,7 +852,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
|
||||||
enableTracing := o.EnableTracing
|
enableTracing := o.EnableTracing
|
||||||
enableBackendLogging := o.EnableBackendLogging
|
enableBackendLogging := o.EnableBackendLogging
|
||||||
cors := o.CORS
|
cors := o.CORS
|
||||||
csrf := o.CSRF
|
csrf := o.DisableCSRF
|
||||||
corsAllowOrigins := o.CORSAllowOrigins
|
corsAllowOrigins := o.CORSAllowOrigins
|
||||||
p2pToken := o.P2PToken
|
p2pToken := o.P2PToken
|
||||||
p2pNetworkID := o.P2PNetworkID
|
p2pNetworkID := o.P2PNetworkID
|
||||||
|
|
@ -958,7 +1060,7 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
|
||||||
o.CORS = *settings.CORS
|
o.CORS = *settings.CORS
|
||||||
}
|
}
|
||||||
if settings.CSRF != nil {
|
if settings.CSRF != nil {
|
||||||
o.CSRF = *settings.CSRF
|
o.DisableCSRF = *settings.CSRF
|
||||||
}
|
}
|
||||||
if settings.CORSAllowOrigins != nil {
|
if settings.CORSAllowOrigins != nil {
|
||||||
o.CORSAllowOrigins = *settings.CORSAllowOrigins
|
o.CORSAllowOrigins = *settings.CORSAllowOrigins
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
|
||||||
F16: true,
|
F16: true,
|
||||||
Debug: true,
|
Debug: true,
|
||||||
CORS: true,
|
CORS: true,
|
||||||
CSRF: true,
|
DisableCSRF: true,
|
||||||
CORSAllowOrigins: "https://example.com",
|
CORSAllowOrigins: "https://example.com",
|
||||||
P2PToken: "test-token",
|
P2PToken: "test-token",
|
||||||
P2PNetworkID: "test-network",
|
P2PNetworkID: "test-network",
|
||||||
|
|
@ -377,7 +377,7 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
|
||||||
appConfig.ApplyRuntimeSettings(rs)
|
appConfig.ApplyRuntimeSettings(rs)
|
||||||
|
|
||||||
Expect(appConfig.CORS).To(BeTrue())
|
Expect(appConfig.CORS).To(BeTrue())
|
||||||
Expect(appConfig.CSRF).To(BeTrue())
|
Expect(appConfig.DisableCSRF).To(BeTrue())
|
||||||
Expect(appConfig.CORSAllowOrigins).To(Equal("https://example.com,https://other.com"))
|
Expect(appConfig.CORSAllowOrigins).To(Equal("https://example.com,https://other.com"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -463,7 +463,7 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
|
||||||
F16: true,
|
F16: true,
|
||||||
Debug: false,
|
Debug: false,
|
||||||
CORS: true,
|
CORS: true,
|
||||||
CSRF: false,
|
DisableCSRF: false,
|
||||||
CORSAllowOrigins: "https://test.com",
|
CORSAllowOrigins: "https://test.com",
|
||||||
P2PToken: "round-trip-token",
|
P2PToken: "round-trip-token",
|
||||||
P2PNetworkID: "round-trip-network",
|
P2PNetworkID: "round-trip-network",
|
||||||
|
|
@ -495,7 +495,7 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
|
||||||
Expect(target.F16).To(Equal(original.F16))
|
Expect(target.F16).To(Equal(original.F16))
|
||||||
Expect(target.Debug).To(Equal(original.Debug))
|
Expect(target.Debug).To(Equal(original.Debug))
|
||||||
Expect(target.CORS).To(Equal(original.CORS))
|
Expect(target.CORS).To(Equal(original.CORS))
|
||||||
Expect(target.CSRF).To(Equal(original.CSRF))
|
Expect(target.DisableCSRF).To(Equal(original.DisableCSRF))
|
||||||
Expect(target.CORSAllowOrigins).To(Equal(original.CORSAllowOrigins))
|
Expect(target.CORSAllowOrigins).To(Equal(original.CORSAllowOrigins))
|
||||||
Expect(target.P2PToken).To(Equal(original.P2PToken))
|
Expect(target.P2PToken).To(Equal(original.P2PToken))
|
||||||
Expect(target.P2PNetworkID).To(Equal(original.P2PNetworkID))
|
Expect(target.P2PNetworkID).To(Equal(original.P2PNetworkID))
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
|
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
"github.com/mudler/LocalAI/core/http/endpoints/localai"
|
"github.com/mudler/LocalAI/core/http/endpoints/localai"
|
||||||
httpMiddleware "github.com/mudler/LocalAI/core/http/middleware"
|
httpMiddleware "github.com/mudler/LocalAI/core/http/middleware"
|
||||||
"github.com/mudler/LocalAI/core/http/routes"
|
"github.com/mudler/LocalAI/core/http/routes"
|
||||||
|
|
@ -170,11 +171,9 @@ func API(application *application.Application) (*echo.Echo, error) {
|
||||||
// Health Checks should always be exempt from auth, so register these first
|
// Health Checks should always be exempt from auth, so register these first
|
||||||
routes.HealthRoutes(e)
|
routes.HealthRoutes(e)
|
||||||
|
|
||||||
// Get key auth middleware
|
// Build auth middleware: use the new auth.Middleware when auth is enabled or
|
||||||
keyAuthMiddleware, err := httpMiddleware.GetKeyAuthConfig(application.ApplicationConfig())
|
// as a unified replacement for the legacy key-auth middleware.
|
||||||
if err != nil {
|
authMiddleware := auth.Middleware(application.AuthDB(), application.ApplicationConfig())
|
||||||
return nil, fmt.Errorf("failed to create key auth config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Favicon handler
|
// Favicon handler
|
||||||
e.GET("/favicon.svg", func(c echo.Context) error {
|
e.GET("/favicon.svg", func(c echo.Context) error {
|
||||||
|
|
@ -209,8 +208,20 @@ func API(application *application.Application) (*echo.Echo, error) {
|
||||||
e.Static("/generated-videos", videoPath)
|
e.Static("/generated-videos", videoPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth is applied to _all_ endpoints. No exceptions. Filtering out endpoints to bypass is the role of the Skipper property of the KeyAuth Configuration
|
// Initialize usage recording when auth DB is available
|
||||||
e.Use(keyAuthMiddleware)
|
if application.AuthDB() != nil {
|
||||||
|
httpMiddleware.InitUsageRecorder(application.AuthDB())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth is applied to _all_ endpoints. Filtering out endpoints to bypass is
|
||||||
|
// the role of the exempt-path logic inside the middleware.
|
||||||
|
e.Use(authMiddleware)
|
||||||
|
|
||||||
|
// Feature and model access control (after auth middleware, before routes)
|
||||||
|
if application.AuthDB() != nil {
|
||||||
|
e.Use(auth.RequireRouteFeature(application.AuthDB()))
|
||||||
|
e.Use(auth.RequireModelAccess(application.AuthDB()))
|
||||||
|
}
|
||||||
|
|
||||||
// CORS middleware
|
// CORS middleware
|
||||||
if application.ApplicationConfig().CORS {
|
if application.ApplicationConfig().CORS {
|
||||||
|
|
@ -223,14 +234,63 @@ func API(application *application.Application) (*echo.Echo, error) {
|
||||||
e.Use(middleware.CORS())
|
e.Use(middleware.CORS())
|
||||||
}
|
}
|
||||||
|
|
||||||
// CSRF middleware
|
// CSRF middleware (enabled by default, disable with LOCALAI_DISABLE_CSRF=true)
|
||||||
if application.ApplicationConfig().CSRF {
|
//
|
||||||
xlog.Debug("Enabling CSRF middleware. Tokens are now required for state-modifying requests")
|
// Protection relies on Echo's Sec-Fetch-Site header check (supported by all
|
||||||
e.Use(middleware.CSRF())
|
// modern browsers). The legacy cookie+token approach is removed because
|
||||||
|
// Echo's Sec-Fetch-Site short-circuit never sets the cookie, so the frontend
|
||||||
|
// could never read a token to send back.
|
||||||
|
if !application.ApplicationConfig().DisableCSRF {
|
||||||
|
xlog.Debug("Enabling CSRF middleware (Sec-Fetch-Site mode)")
|
||||||
|
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
|
||||||
|
Skipper: func(c echo.Context) bool {
|
||||||
|
// Skip CSRF for API clients using auth headers (may be cross-origin)
|
||||||
|
if c.Request().Header.Get("Authorization") != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if c.Request().Header.Get("x-api-key") != "" || c.Request().Header.Get("xi-api-key") != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Skip when Sec-Fetch-Site header is absent (older browsers, reverse
|
||||||
|
// proxies that strip the header). The SameSite=Lax cookie attribute
|
||||||
|
// provides baseline CSRF protection for these clients.
|
||||||
|
if c.Request().Header.Get("Sec-Fetch-Site") == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
// Allow same-site requests (subdomains / different ports) in addition
|
||||||
|
// to same-origin which Echo already permits by default.
|
||||||
|
AllowSecFetchSiteFunc: func(c echo.Context) (bool, error) {
|
||||||
|
secFetchSite := c.Request().Header.Get("Sec-Fetch-Site")
|
||||||
|
if secFetchSite == "same-site" {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
// cross-site: block
|
||||||
|
return false, nil
|
||||||
|
},
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin middleware: enforces admin role when auth is enabled, no-op otherwise
|
||||||
|
var adminMiddleware echo.MiddlewareFunc
|
||||||
|
if application.AuthDB() != nil {
|
||||||
|
adminMiddleware = auth.RequireAdmin()
|
||||||
|
} else {
|
||||||
|
adminMiddleware = auth.NoopMiddleware()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature middlewares: per-feature access control
|
||||||
|
agentsMw := auth.RequireFeature(application.AuthDB(), auth.FeatureAgents)
|
||||||
|
skillsMw := auth.RequireFeature(application.AuthDB(), auth.FeatureSkills)
|
||||||
|
collectionsMw := auth.RequireFeature(application.AuthDB(), auth.FeatureCollections)
|
||||||
|
mcpJobsMw := auth.RequireFeature(application.AuthDB(), auth.FeatureMCPJobs)
|
||||||
|
|
||||||
requestExtractor := httpMiddleware.NewRequestExtractor(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
|
requestExtractor := httpMiddleware.NewRequestExtractor(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
|
||||||
|
|
||||||
|
// Register auth routes (login, callback, API keys, user management)
|
||||||
|
routes.RegisterAuthRoutes(e, application)
|
||||||
|
|
||||||
routes.RegisterElevenLabsRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
|
routes.RegisterElevenLabsRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
|
||||||
|
|
||||||
// Create opcache for tracking UI operations (used by both UI and LocalAI routes)
|
// Create opcache for tracking UI operations (used by both UI and LocalAI routes)
|
||||||
|
|
@ -239,14 +299,15 @@ func API(application *application.Application) (*echo.Echo, error) {
|
||||||
opcache = services.NewOpCache(application.GalleryService())
|
opcache = services.NewOpCache(application.GalleryService())
|
||||||
}
|
}
|
||||||
|
|
||||||
routes.RegisterLocalAIRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application.TemplatesEvaluator(), application)
|
mcpMw := auth.RequireFeature(application.AuthDB(), auth.FeatureMCP)
|
||||||
routes.RegisterAgentPoolRoutes(e, application)
|
routes.RegisterLocalAIRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application.TemplatesEvaluator(), application, adminMiddleware, mcpJobsMw, mcpMw)
|
||||||
|
routes.RegisterAgentPoolRoutes(e, application, agentsMw, skillsMw, collectionsMw)
|
||||||
routes.RegisterOpenAIRoutes(e, requestExtractor, application)
|
routes.RegisterOpenAIRoutes(e, requestExtractor, application)
|
||||||
routes.RegisterAnthropicRoutes(e, requestExtractor, application)
|
routes.RegisterAnthropicRoutes(e, requestExtractor, application)
|
||||||
routes.RegisterOpenResponsesRoutes(e, requestExtractor, application)
|
routes.RegisterOpenResponsesRoutes(e, requestExtractor, application)
|
||||||
if !application.ApplicationConfig().DisableWebUI {
|
if !application.ApplicationConfig().DisableWebUI {
|
||||||
routes.RegisterUIAPIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application)
|
routes.RegisterUIAPIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application, adminMiddleware)
|
||||||
routes.RegisterUIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())
|
routes.RegisterUIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), adminMiddleware)
|
||||||
|
|
||||||
// Serve React SPA from / with SPA fallback via 404 handler
|
// Serve React SPA from / with SPA fallback via 404 handler
|
||||||
reactFS, fsErr := fs.Sub(reactUI, "react-ui/dist")
|
reactFS, fsErr := fs.Sub(reactUI, "react-ui/dist")
|
||||||
|
|
|
||||||
|
|
@ -428,8 +428,10 @@ var _ = Describe("API test", func() {
|
||||||
"X-Forwarded-Prefix": {"/myprefix/"},
|
"X-Forwarded-Prefix": {"/myprefix/"},
|
||||||
})
|
})
|
||||||
Expect(err).To(BeNil(), "error")
|
Expect(err).To(BeNil(), "error")
|
||||||
Expect(sc).To(Equal(401), "status code")
|
Expect(sc).To(Equal(200), "status code")
|
||||||
|
// Non-API paths pass through to the React SPA (which handles login client-side)
|
||||||
Expect(string(body)).To(ContainSubstring(`<base href="https://example.org/myprefix/" />`), "body")
|
Expect(string(body)).To(ContainSubstring(`<base href="https://example.org/myprefix/" />`), "body")
|
||||||
|
Expect(string(body)).To(ContainSubstring(`<div id="root">`), "should serve React SPA")
|
||||||
})
|
})
|
||||||
|
|
||||||
It("Should support reverse-proxy when authenticated", func() {
|
It("Should support reverse-proxy when authenticated", func() {
|
||||||
|
|
|
||||||
121
core/http/auth/apikeys.go
Normal file
121
core/http/auth/apikeys.go
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
apiKeyPrefix = "lai-"
|
||||||
|
apiKeyRandBytes = 32 // 32 bytes = 64 hex chars
|
||||||
|
keyPrefixLen = 8 // display prefix length (from the random part)
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateAPIKey generates a new API key. Returns the plaintext key,
|
||||||
|
// its HMAC-SHA256 hash, and a display prefix.
|
||||||
|
func GenerateAPIKey(hmacSecret string) (plaintext, hash, prefix string, err error) {
|
||||||
|
b := make([]byte, apiKeyRandBytes)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to generate API key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
randHex := hex.EncodeToString(b)
|
||||||
|
plaintext = apiKeyPrefix + randHex
|
||||||
|
hash = HashAPIKey(plaintext, hmacSecret)
|
||||||
|
prefix = plaintext[:len(apiKeyPrefix)+keyPrefixLen]
|
||||||
|
|
||||||
|
return plaintext, hash, prefix, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashAPIKey returns the HMAC-SHA256 hex digest of the given plaintext key.
|
||||||
|
// If hmacSecret is empty, falls back to plain SHA-256 for backward compatibility.
|
||||||
|
func HashAPIKey(plaintext, hmacSecret string) string {
|
||||||
|
if hmacSecret == "" {
|
||||||
|
h := sha256.Sum256([]byte(plaintext))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, []byte(hmacSecret))
|
||||||
|
mac.Write([]byte(plaintext))
|
||||||
|
return hex.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAPIKey generates and stores a new API key for the given user.
|
||||||
|
// Returns the plaintext key (shown once) and the database record.
|
||||||
|
func CreateAPIKey(db *gorm.DB, userID, name, role, hmacSecret string, expiresAt *time.Time) (string, *UserAPIKey, error) {
|
||||||
|
plaintext, hash, prefix, err := GenerateAPIKey(hmacSecret)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
record := &UserAPIKey{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
KeyHash: hash,
|
||||||
|
KeyPrefix: prefix,
|
||||||
|
Role: role,
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(record).Error; err != nil {
|
||||||
|
return "", nil, fmt.Errorf("failed to store API key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAPIKey looks up an API key by hashing the plaintext and searching
|
||||||
|
// the database. Returns the key record if found, or an error.
|
||||||
|
// Updates LastUsed on successful validation.
|
||||||
|
func ValidateAPIKey(db *gorm.DB, plaintext, hmacSecret string) (*UserAPIKey, error) {
|
||||||
|
hash := HashAPIKey(plaintext, hmacSecret)
|
||||||
|
|
||||||
|
var key UserAPIKey
|
||||||
|
if err := db.Preload("User").Where("key_hash = ?", hash).First(&key).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid API key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.ExpiresAt != nil && time.Now().After(*key.ExpiresAt) {
|
||||||
|
return nil, fmt.Errorf("API key expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.User.Status != StatusActive {
|
||||||
|
return nil, fmt.Errorf("user account is not active")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update LastUsed
|
||||||
|
now := time.Now()
|
||||||
|
db.Model(&key).Update("last_used", now)
|
||||||
|
|
||||||
|
return &key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAPIKeys returns all API keys for the given user (without plaintext).
|
||||||
|
func ListAPIKeys(db *gorm.DB, userID string) ([]UserAPIKey, error) {
|
||||||
|
var keys []UserAPIKey
|
||||||
|
if err := db.Where("user_id = ?", userID).Order("created_at DESC").Find(&keys).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeAPIKey deletes an API key. Only the owner can revoke their own key.
|
||||||
|
func RevokeAPIKey(db *gorm.DB, keyID, userID string) error {
|
||||||
|
result := db.Where("id = ? AND user_id = ?", keyID, userID).Delete(&UserAPIKey{})
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return fmt.Errorf("API key not found or not owned by user")
|
||||||
|
}
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanExpiredAPIKeys removes all API keys that have passed their expiry time.
|
||||||
|
func CleanExpiredAPIKeys(db *gorm.DB) error {
|
||||||
|
return db.Where("expires_at IS NOT NULL AND expires_at < ?", time.Now()).Delete(&UserAPIKey{}).Error
|
||||||
|
}
|
||||||
212
core/http/auth/apikeys_test.go
Normal file
212
core/http/auth/apikeys_test.go
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
//go:build auth
|
||||||
|
|
||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("API Keys", func() {
|
||||||
|
var (
|
||||||
|
db *gorm.DB
|
||||||
|
user *auth.User
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use empty HMAC secret for tests (falls back to plain SHA-256)
|
||||||
|
hmacSecret := ""
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
db = testDB()
|
||||||
|
user = createTestUser(db, "apikey@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GenerateAPIKey", func() {
|
||||||
|
It("returns key with 'lai-' prefix", func() {
|
||||||
|
plaintext, _, _, err := auth.GenerateAPIKey(hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(plaintext).To(HavePrefix("lai-"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns consistent hash for same plaintext", func() {
|
||||||
|
plaintext, hash, _, err := auth.GenerateAPIKey(hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(auth.HashAPIKey(plaintext, hmacSecret)).To(Equal(hash))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns prefix for display", func() {
|
||||||
|
_, _, prefix, err := auth.GenerateAPIKey(hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(prefix).To(HavePrefix("lai-"))
|
||||||
|
Expect(len(prefix)).To(Equal(12)) // "lai-" + 8 chars
|
||||||
|
})
|
||||||
|
|
||||||
|
It("generates unique keys", func() {
|
||||||
|
key1, _, _, _ := auth.GenerateAPIKey(hmacSecret)
|
||||||
|
key2, _, _, _ := auth.GenerateAPIKey(hmacSecret)
|
||||||
|
Expect(key1).ToNot(Equal(key2))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("CreateAPIKey", func() {
|
||||||
|
It("stores hashed key in DB", func() {
|
||||||
|
plaintext, record, err := auth.CreateAPIKey(db, user.ID, "test key", auth.RoleUser, hmacSecret, nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(plaintext).To(HavePrefix("lai-"))
|
||||||
|
Expect(record.KeyHash).To(Equal(auth.HashAPIKey(plaintext, hmacSecret)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not store plaintext in DB", func() {
|
||||||
|
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "test key", auth.RoleUser, hmacSecret, nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
var keys []auth.UserAPIKey
|
||||||
|
db.Find(&keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
Expect(k.KeyHash).ToNot(Equal(plaintext))
|
||||||
|
Expect(strings.Contains(k.KeyHash, "lai-")).To(BeFalse())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
It("inherits role from parameter", func() {
|
||||||
|
_, record, err := auth.CreateAPIKey(db, user.ID, "admin key", auth.RoleAdmin, hmacSecret, nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(record.Role).To(Equal(auth.RoleAdmin))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("ValidateAPIKey", func() {
|
||||||
|
It("returns UserAPIKey for valid key", func() {
|
||||||
|
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "valid key", auth.RoleUser, hmacSecret, nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
found, err := auth.ValidateAPIKey(db, plaintext, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(found).ToNot(BeNil())
|
||||||
|
Expect(found.UserID).To(Equal(user.ID))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns error for invalid key", func() {
|
||||||
|
_, err := auth.ValidateAPIKey(db, "lai-invalidkey12345678901234567890", hmacSecret)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("updates LastUsed timestamp", func() {
|
||||||
|
plaintext, record, err := auth.CreateAPIKey(db, user.ID, "used key", auth.RoleUser, hmacSecret, nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(record.LastUsed).To(BeNil())
|
||||||
|
|
||||||
|
_, err = auth.ValidateAPIKey(db, plaintext, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
var updated auth.UserAPIKey
|
||||||
|
db.First(&updated, "id = ?", record.ID)
|
||||||
|
Expect(updated.LastUsed).ToNot(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("loads associated user", func() {
|
||||||
|
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "with user", auth.RoleUser, hmacSecret, nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
found, err := auth.ValidateAPIKey(db, plaintext, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(found.User.ID).To(Equal(user.ID))
|
||||||
|
Expect(found.User.Email).To(Equal("apikey@example.com"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("ListAPIKeys", func() {
|
||||||
|
It("returns all keys for the user", func() {
|
||||||
|
auth.CreateAPIKey(db, user.ID, "key1", auth.RoleUser, hmacSecret, nil)
|
||||||
|
auth.CreateAPIKey(db, user.ID, "key2", auth.RoleUser, hmacSecret, nil)
|
||||||
|
|
||||||
|
keys, err := auth.ListAPIKeys(db, user.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(keys).To(HaveLen(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not return other users' keys", func() {
|
||||||
|
other := createTestUser(db, "other@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||||
|
auth.CreateAPIKey(db, user.ID, "my key", auth.RoleUser, hmacSecret, nil)
|
||||||
|
auth.CreateAPIKey(db, other.ID, "other key", auth.RoleUser, hmacSecret, nil)
|
||||||
|
|
||||||
|
keys, err := auth.ListAPIKeys(db, user.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(keys).To(HaveLen(1))
|
||||||
|
Expect(keys[0].Name).To(Equal("my key"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("with HMAC secret", func() {
|
||||||
|
hmacSecretVal := "test-hmac-secret-456"
|
||||||
|
|
||||||
|
It("generates different hash than empty secret", func() {
|
||||||
|
plaintext, _, _, err := auth.GenerateAPIKey("")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
hashEmpty := auth.HashAPIKey(plaintext, "")
|
||||||
|
hashHMAC := auth.HashAPIKey(plaintext, hmacSecretVal)
|
||||||
|
Expect(hashEmpty).ToNot(Equal(hashHMAC))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("round-trips CreateAPIKey and ValidateAPIKey with HMAC secret", func() {
|
||||||
|
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "hmac key", auth.RoleUser, hmacSecretVal, nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
found, err := auth.ValidateAPIKey(db, plaintext, hmacSecretVal)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(found).ToNot(BeNil())
|
||||||
|
Expect(found.UserID).To(Equal(user.ID))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not validate with wrong HMAC secret", func() {
|
||||||
|
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "hmac key2", auth.RoleUser, hmacSecretVal, nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
_, err = auth.ValidateAPIKey(db, plaintext, "wrong-secret")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not validate key created with empty secret using non-empty secret", func() {
|
||||||
|
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "empty-secret key", auth.RoleUser, "", nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
_, err = auth.ValidateAPIKey(db, plaintext, hmacSecretVal)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not validate key created with non-empty secret using empty secret", func() {
|
||||||
|
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "nonempty-secret key", auth.RoleUser, hmacSecretVal, nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
_, err = auth.ValidateAPIKey(db, plaintext, "")
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("RevokeAPIKey", func() {
|
||||||
|
It("deletes the key record", func() {
|
||||||
|
plaintext, record, err := auth.CreateAPIKey(db, user.ID, "to revoke", auth.RoleUser, hmacSecret, nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
err = auth.RevokeAPIKey(db, record.ID, user.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
_, err = auth.ValidateAPIKey(db, plaintext, hmacSecret)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("only allows owner to revoke their own key", func() {
|
||||||
|
_, record, err := auth.CreateAPIKey(db, user.ID, "mine", auth.RoleUser, hmacSecret, nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
other := createTestUser(db, "attacker@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||||
|
err = auth.RevokeAPIKey(db, record.ID, other.ID)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
15
core/http/auth/auth_suite_test.go
Normal file
15
core/http/auth/auth_suite_test.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
//go:build auth
|
||||||
|
|
||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuth(t *testing.T) {
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "Auth Suite")
|
||||||
|
}
|
||||||
49
core/http/auth/db.go
Normal file
49
core/http/auth/db.go
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitDB initializes the auth database. If databaseURL starts with "postgres://"
|
||||||
|
// or "postgresql://", it connects to PostgreSQL; otherwise it treats the value
|
||||||
|
// as a SQLite file path (use ":memory:" for in-memory).
|
||||||
|
// SQLite support requires building with the "auth" build tag (CGO).
|
||||||
|
func InitDB(databaseURL string) (*gorm.DB, error) {
|
||||||
|
var dialector gorm.Dialector
|
||||||
|
|
||||||
|
if strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://") {
|
||||||
|
dialector = postgres.Open(databaseURL)
|
||||||
|
} else {
|
||||||
|
d, err := openSQLiteDialector(databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dialector = d
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(dialector, &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open auth database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.AutoMigrate(&User{}, &Session{}, &UserAPIKey{}, &UsageRecord{}, &UserPermission{}, &InviteCode{}); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to migrate auth tables: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create composite index on users(provider, subject) for fast OAuth lookups
|
||||||
|
if err := db.Exec("CREATE INDEX IF NOT EXISTS idx_users_provider_subject ON users(provider, subject)").Error; err != nil {
|
||||||
|
// Ignore error on postgres if index already exists
|
||||||
|
if !strings.Contains(err.Error(), "already exists") {
|
||||||
|
return nil, fmt.Errorf("failed to create composite index: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
13
core/http/auth/db_nosqlite.go
Normal file
13
core/http/auth/db_nosqlite.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
//go:build !auth
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openSQLiteDialector(path string) (gorm.Dialector, error) {
|
||||||
|
return nil, fmt.Errorf("SQLite auth database requires building with -tags auth (CGO); use DATABASE_URL with PostgreSQL instead")
|
||||||
|
}
|
||||||
12
core/http/auth/db_sqlite.go
Normal file
12
core/http/auth/db_sqlite.go
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
//go:build auth
|
||||||
|
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openSQLiteDialector(path string) (gorm.Dialector, error) {
|
||||||
|
return sqlite.Open(path), nil
|
||||||
|
}
|
||||||
53
core/http/auth/db_test.go
Normal file
53
core/http/auth/db_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
//go:build auth
|
||||||
|
|
||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("InitDB", func() {
|
||||||
|
Context("SQLite", func() {
|
||||||
|
It("creates all tables with in-memory SQLite", func() {
|
||||||
|
db, err := auth.InitDB(":memory:")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(db).ToNot(BeNil())
|
||||||
|
|
||||||
|
// Verify tables exist
|
||||||
|
Expect(db.Migrator().HasTable(&auth.User{})).To(BeTrue())
|
||||||
|
Expect(db.Migrator().HasTable(&auth.Session{})).To(BeTrue())
|
||||||
|
Expect(db.Migrator().HasTable(&auth.UserAPIKey{})).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("is idempotent - running twice does not error", func() {
|
||||||
|
db, err := auth.InitDB(":memory:")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Re-migrate on same DB should succeed
|
||||||
|
err = db.AutoMigrate(&auth.User{}, &auth.Session{}, &auth.UserAPIKey{})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("creates composite index on users(provider, subject)", func() {
|
||||||
|
db, err := auth.InitDB(":memory:")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Insert a user to verify the index doesn't prevent normal operations
|
||||||
|
user := &auth.User{
|
||||||
|
ID: "test-1",
|
||||||
|
Provider: auth.ProviderGitHub,
|
||||||
|
Subject: "12345",
|
||||||
|
Role: "admin",
|
||||||
|
Status: auth.StatusActive,
|
||||||
|
}
|
||||||
|
Expect(db.Create(user).Error).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Query using the indexed columns should work
|
||||||
|
var found auth.User
|
||||||
|
Expect(db.Where("provider = ? AND subject = ?", auth.ProviderGitHub, "12345").First(&found).Error).ToNot(HaveOccurred())
|
||||||
|
Expect(found.ID).To(Equal("test-1"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
125
core/http/auth/features.go
Normal file
125
core/http/auth/features.go
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
// RouteFeature maps a route pattern + HTTP method to a required feature.
|
||||||
|
type RouteFeature struct {
|
||||||
|
Method string // "POST", "GET", "*" (any)
|
||||||
|
Pattern string // Echo route pattern, e.g. "/v1/chat/completions"
|
||||||
|
Feature string // Feature constant, e.g. FeatureChat
|
||||||
|
}
|
||||||
|
|
||||||
|
// RouteFeatureRegistry is the single source of truth for endpoint -> feature mappings.
|
||||||
|
// To gate a new endpoint, add an entry here -- no other file changes needed.
|
||||||
|
var RouteFeatureRegistry = []RouteFeature{
|
||||||
|
// Chat / Completions
|
||||||
|
{"POST", "/v1/chat/completions", FeatureChat},
|
||||||
|
{"POST", "/chat/completions", FeatureChat},
|
||||||
|
{"POST", "/v1/completions", FeatureChat},
|
||||||
|
{"POST", "/completions", FeatureChat},
|
||||||
|
{"POST", "/v1/engines/:model/completions", FeatureChat},
|
||||||
|
{"POST", "/v1/edits", FeatureChat},
|
||||||
|
{"POST", "/edits", FeatureChat},
|
||||||
|
|
||||||
|
// Anthropic
|
||||||
|
{"POST", "/v1/messages", FeatureChat},
|
||||||
|
{"POST", "/messages", FeatureChat},
|
||||||
|
|
||||||
|
// Open Responses
|
||||||
|
{"POST", "/v1/responses", FeatureChat},
|
||||||
|
{"POST", "/responses", FeatureChat},
|
||||||
|
{"GET", "/v1/responses", FeatureChat},
|
||||||
|
{"GET", "/responses", FeatureChat},
|
||||||
|
|
||||||
|
// Embeddings
|
||||||
|
{"POST", "/v1/embeddings", FeatureEmbeddings},
|
||||||
|
{"POST", "/embeddings", FeatureEmbeddings},
|
||||||
|
{"POST", "/v1/engines/:model/embeddings", FeatureEmbeddings},
|
||||||
|
|
||||||
|
// Images
|
||||||
|
{"POST", "/v1/images/generations", FeatureImages},
|
||||||
|
{"POST", "/images/generations", FeatureImages},
|
||||||
|
{"POST", "/v1/images/inpainting", FeatureImages},
|
||||||
|
{"POST", "/images/inpainting", FeatureImages},
|
||||||
|
|
||||||
|
// Audio transcription
|
||||||
|
{"POST", "/v1/audio/transcriptions", FeatureAudioTranscription},
|
||||||
|
{"POST", "/audio/transcriptions", FeatureAudioTranscription},
|
||||||
|
|
||||||
|
// Audio speech / TTS
|
||||||
|
{"POST", "/v1/audio/speech", FeatureAudioSpeech},
|
||||||
|
{"POST", "/audio/speech", FeatureAudioSpeech},
|
||||||
|
{"POST", "/tts", FeatureAudioSpeech},
|
||||||
|
{"POST", "/v1/text-to-speech/:voice-id", FeatureAudioSpeech},
|
||||||
|
|
||||||
|
// VAD
|
||||||
|
{"POST", "/vad", FeatureVAD},
|
||||||
|
{"POST", "/v1/vad", FeatureVAD},
|
||||||
|
|
||||||
|
// Detection
|
||||||
|
{"POST", "/v1/detection", FeatureDetection},
|
||||||
|
|
||||||
|
// Video
|
||||||
|
{"POST", "/video", FeatureVideo},
|
||||||
|
|
||||||
|
// Sound generation
|
||||||
|
{"POST", "/v1/sound-generation", FeatureSound},
|
||||||
|
|
||||||
|
// Realtime
|
||||||
|
{"GET", "/v1/realtime", FeatureRealtime},
|
||||||
|
{"POST", "/v1/realtime/sessions", FeatureRealtime},
|
||||||
|
{"POST", "/v1/realtime/transcription_session", FeatureRealtime},
|
||||||
|
{"POST", "/v1/realtime/calls", FeatureRealtime},
|
||||||
|
|
||||||
|
// MCP
|
||||||
|
{"POST", "/v1/mcp/chat/completions", FeatureMCP},
|
||||||
|
{"POST", "/mcp/v1/chat/completions", FeatureMCP},
|
||||||
|
{"POST", "/mcp/chat/completions", FeatureMCP},
|
||||||
|
|
||||||
|
// Tokenize
|
||||||
|
{"POST", "/v1/tokenize", FeatureTokenize},
|
||||||
|
|
||||||
|
// Rerank
|
||||||
|
{"POST", "/v1/rerank", FeatureRerank},
|
||||||
|
|
||||||
|
// Stores
|
||||||
|
{"POST", "/stores/set", FeatureStores},
|
||||||
|
{"POST", "/stores/delete", FeatureStores},
|
||||||
|
{"POST", "/stores/get", FeatureStores},
|
||||||
|
{"POST", "/stores/find", FeatureStores},
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeatureMeta describes a feature for the admin API/UI.
|
||||||
|
type FeatureMeta struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
DefaultValue bool `json:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentFeatureMetas returns metadata for agent features.
|
||||||
|
func AgentFeatureMetas() []FeatureMeta {
|
||||||
|
return []FeatureMeta{
|
||||||
|
{FeatureAgents, "Agents", false},
|
||||||
|
{FeatureSkills, "Skills", false},
|
||||||
|
{FeatureCollections, "Collections", false},
|
||||||
|
{FeatureMCPJobs, "MCP CI Jobs", false},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIFeatureMetas returns metadata for API endpoint features.
|
||||||
|
func APIFeatureMetas() []FeatureMeta {
|
||||||
|
return []FeatureMeta{
|
||||||
|
{FeatureChat, "Chat Completions", true},
|
||||||
|
{FeatureImages, "Image Generation", true},
|
||||||
|
{FeatureAudioSpeech, "Audio Speech / TTS", true},
|
||||||
|
{FeatureAudioTranscription, "Audio Transcription", true},
|
||||||
|
{FeatureVAD, "Voice Activity Detection", true},
|
||||||
|
{FeatureDetection, "Detection", true},
|
||||||
|
{FeatureVideo, "Video Generation", true},
|
||||||
|
{FeatureEmbeddings, "Embeddings", true},
|
||||||
|
{FeatureSound, "Sound Generation", true},
|
||||||
|
{FeatureRealtime, "Realtime", true},
|
||||||
|
{FeatureRerank, "Rerank", true},
|
||||||
|
{FeatureTokenize, "Tokenize", true},
|
||||||
|
{FeatureMCP, "MCP", true},
|
||||||
|
{FeatureStores, "Stores", true},
|
||||||
|
}
|
||||||
|
}
|
||||||
155
core/http/auth/helpers_test.go
Normal file
155
core/http/auth/helpers_test.go
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
//go:build auth
|
||||||
|
|
||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/mudler/LocalAI/core/config"
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testDB creates an in-memory SQLite GORM instance with auto-migration.
|
||||||
|
func testDB() *gorm.DB {
|
||||||
|
db, err := auth.InitDB(":memory:")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestUser inserts a user directly into the DB for test setup.
|
||||||
|
func createTestUser(db *gorm.DB, email, role, provider string) *auth.User {
|
||||||
|
user := &auth.User{
|
||||||
|
ID: generateTestID(),
|
||||||
|
Email: email,
|
||||||
|
Name: "Test User",
|
||||||
|
Provider: provider,
|
||||||
|
Subject: generateTestID(),
|
||||||
|
Role: role,
|
||||||
|
Status: auth.StatusActive,
|
||||||
|
}
|
||||||
|
err := db.Create(user).Error
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestSession creates a session for a user, returns plaintext session token.
|
||||||
|
func createTestSession(db *gorm.DB, userID string) string {
|
||||||
|
sessionID, err := auth.CreateSession(db, userID, "")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
var testIDCounter int
|
||||||
|
|
||||||
|
func generateTestID() string {
|
||||||
|
testIDCounter++
|
||||||
|
return "test-id-" + string(rune('a'+testIDCounter))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ok is a simple handler that returns 200 OK.
|
||||||
|
func ok(c echo.Context) error {
|
||||||
|
return c.String(http.StatusOK, "ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAuthTestApp creates a minimal Echo app with the new auth middleware.
|
||||||
|
func newAuthTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
|
||||||
|
e := echo.New()
|
||||||
|
e.Use(auth.Middleware(db, appConfig))
|
||||||
|
|
||||||
|
// API routes (require auth)
|
||||||
|
e.GET("/v1/models", ok)
|
||||||
|
e.POST("/v1/chat/completions", ok)
|
||||||
|
e.GET("/api/settings", ok)
|
||||||
|
e.POST("/api/settings", ok)
|
||||||
|
|
||||||
|
// Auth routes (exempt)
|
||||||
|
e.GET("/api/auth/status", ok)
|
||||||
|
e.GET("/api/auth/github/login", ok)
|
||||||
|
|
||||||
|
// Static routes
|
||||||
|
e.GET("/app", ok)
|
||||||
|
e.GET("/app/*", ok)
|
||||||
|
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAdminTestApp creates an Echo app with admin-protected routes.
|
||||||
|
func newAdminTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
|
||||||
|
e := echo.New()
|
||||||
|
e.Use(auth.Middleware(db, appConfig))
|
||||||
|
|
||||||
|
// Regular routes
|
||||||
|
e.GET("/v1/models", ok)
|
||||||
|
e.POST("/v1/chat/completions", ok)
|
||||||
|
|
||||||
|
// Admin-only routes
|
||||||
|
adminMw := auth.RequireAdmin()
|
||||||
|
e.POST("/api/settings", ok, adminMw)
|
||||||
|
e.POST("/models/apply", ok, adminMw)
|
||||||
|
e.POST("/backends/apply", ok, adminMw)
|
||||||
|
e.GET("/api/agents", ok, adminMw)
|
||||||
|
|
||||||
|
// Trace/log endpoints (admin only)
|
||||||
|
e.GET("/api/traces", ok, adminMw)
|
||||||
|
e.POST("/api/traces/clear", ok, adminMw)
|
||||||
|
e.GET("/api/backend-logs", ok, adminMw)
|
||||||
|
e.GET("/api/backend-logs/:modelId", ok, adminMw)
|
||||||
|
|
||||||
|
// Gallery/management reads (admin only)
|
||||||
|
e.GET("/api/operations", ok, adminMw)
|
||||||
|
e.GET("/api/models", ok, adminMw)
|
||||||
|
e.GET("/api/backends", ok, adminMw)
|
||||||
|
e.GET("/api/resources", ok, adminMw)
|
||||||
|
e.GET("/api/p2p/workers", ok, adminMw)
|
||||||
|
|
||||||
|
// Agent task/job routes (admin only)
|
||||||
|
e.POST("/api/agent/tasks", ok, adminMw)
|
||||||
|
e.GET("/api/agent/tasks", ok, adminMw)
|
||||||
|
e.GET("/api/agent/jobs", ok, adminMw)
|
||||||
|
|
||||||
|
// System info (admin only)
|
||||||
|
e.GET("/system", ok, adminMw)
|
||||||
|
e.GET("/backend/monitor", ok, adminMw)
|
||||||
|
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// doRequest performs an HTTP request against the given Echo app and returns the recorder.
|
||||||
|
func doRequest(e *echo.Echo, method, path string, opts ...func(*http.Request)) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(method, path, nil)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(req)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func withBearerToken(token string) func(*http.Request) {
|
||||||
|
return func(req *http.Request) {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withXApiKey(key string) func(*http.Request) {
|
||||||
|
return func(req *http.Request) {
|
||||||
|
req.Header.Set("x-api-key", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withSessionCookie(sessionID string) func(*http.Request) {
|
||||||
|
return func(req *http.Request) {
|
||||||
|
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withTokenCookie(token string) func(*http.Request) {
|
||||||
|
return func(req *http.Request) {
|
||||||
|
req.AddCookie(&http.Cookie{Name: "token", Value: token})
|
||||||
|
}
|
||||||
|
}
|
||||||
522
core/http/auth/middleware.go
Normal file
522
core/http/auth/middleware.go
Normal file
|
|
@ -0,0 +1,522 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/mudler/LocalAI/core/config"
|
||||||
|
"github.com/mudler/LocalAI/core/schema"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
contextKeyUser = "auth_user"
|
||||||
|
contextKeyRole = "auth_role"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Middleware returns an Echo middleware that handles authentication.
|
||||||
|
//
|
||||||
|
// Resolution order:
|
||||||
|
// 1. If auth not enabled AND no legacy API keys → pass through
|
||||||
|
// 2. Skip auth for exempt paths (PathWithoutAuth + /api/auth/)
|
||||||
|
// 3. If auth enabled (db != nil):
|
||||||
|
// a. Try "session" cookie → DB lookup
|
||||||
|
// b. Try Authorization: Bearer → session ID, then user API key
|
||||||
|
// c. Try x-api-key / xi-api-key → user API key
|
||||||
|
// d. Try "token" cookie → legacy API key check
|
||||||
|
// e. Check all extracted keys against legacy ApiKeys → synthetic admin
|
||||||
|
// 4. If auth not enabled → delegate to legacy API key validation
|
||||||
|
// 5. If no auth found for /api/ or /v1/ paths → 401
|
||||||
|
// 6. Otherwise pass through (static assets, UI pages, etc.)
|
||||||
|
func Middleware(db *gorm.DB, appConfig *config.ApplicationConfig) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
authEnabled := db != nil
|
||||||
|
hasLegacyKeys := len(appConfig.ApiKeys) > 0
|
||||||
|
|
||||||
|
// 1. No auth at all
|
||||||
|
if !authEnabled && !hasLegacyKeys {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := c.Request().URL.Path
|
||||||
|
exempt := isExemptPath(path, appConfig)
|
||||||
|
authenticated := false
|
||||||
|
|
||||||
|
// 2. Try to authenticate (populates user in context if possible)
|
||||||
|
if authEnabled {
|
||||||
|
user := tryAuthenticate(c, db, appConfig)
|
||||||
|
if user != nil {
|
||||||
|
c.Set(contextKeyUser, user)
|
||||||
|
c.Set(contextKeyRole, user.Role)
|
||||||
|
authenticated = true
|
||||||
|
|
||||||
|
// Session rotation for cookie-based sessions
|
||||||
|
if session, ok := c.Get("_auth_session").(*Session); ok {
|
||||||
|
MaybeRotateSession(c, db, session, appConfig.Auth.APIKeyHMACSecret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Legacy API key validation (works whether auth is enabled or not)
|
||||||
|
if !authenticated && hasLegacyKeys {
|
||||||
|
key := extractKey(c)
|
||||||
|
if key != "" && isValidLegacyKey(key, appConfig) {
|
||||||
|
syntheticUser := &User{
|
||||||
|
ID: "legacy-api-key",
|
||||||
|
Name: "API Key User",
|
||||||
|
Role: RoleAdmin,
|
||||||
|
}
|
||||||
|
c.Set(contextKeyUser, syntheticUser)
|
||||||
|
c.Set(contextKeyRole, RoleAdmin)
|
||||||
|
authenticated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. If authenticated or exempt path, proceed
|
||||||
|
if authenticated || exempt {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Require auth for API paths
|
||||||
|
if isAPIPath(path) {
|
||||||
|
// Check GET exemptions for legacy keys
|
||||||
|
if hasLegacyKeys && appConfig.DisableApiKeyRequirementForHttpGet && c.Request().Method == http.MethodGet {
|
||||||
|
for _, rx := range appConfig.HttpGetExemptedEndpoints {
|
||||||
|
if rx.MatchString(c.Path()) {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return authError(c, appConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Non-API paths (UI, static assets) pass through.
|
||||||
|
// The React UI handles login redirects client-side.
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAdmin returns middleware that checks the user has admin role.
|
||||||
|
func RequireAdmin() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
user := GetUser(c)
|
||||||
|
if user == nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
|
||||||
|
Error: &schema.APIError{
|
||||||
|
Message: "Authentication required",
|
||||||
|
Code: http.StatusUnauthorized,
|
||||||
|
Type: "authentication_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if user.Role != RoleAdmin {
|
||||||
|
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||||
|
Error: &schema.APIError{
|
||||||
|
Message: "Admin access required",
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Type: "authorization_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoopMiddleware returns a middleware that does nothing (pass-through).
|
||||||
|
// Used when auth is disabled to satisfy route registration that expects
|
||||||
|
// an admin middleware parameter.
|
||||||
|
func NoopMiddleware() echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireFeature returns middleware that checks the user has access to the given feature.
|
||||||
|
// If no auth DB is provided, it passes through (backward compat).
|
||||||
|
// Admins always pass. Regular users must have the feature enabled in their permissions.
|
||||||
|
func RequireFeature(db *gorm.DB, feature string) echo.MiddlewareFunc {
|
||||||
|
if db == nil {
|
||||||
|
return NoopMiddleware()
|
||||||
|
}
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
user := GetUser(c)
|
||||||
|
if user == nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
|
||||||
|
Error: &schema.APIError{
|
||||||
|
Message: "Authentication required",
|
||||||
|
Code: http.StatusUnauthorized,
|
||||||
|
Type: "authentication_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if user.Role == RoleAdmin {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
perm, err := GetCachedUserPermissions(c, db, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||||
|
Error: &schema.APIError{
|
||||||
|
Message: "feature not enabled for your account",
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Type: "authorization_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
val, exists := perm.Permissions[feature]
|
||||||
|
if !exists {
|
||||||
|
if !isDefaultOnFeature(feature) {
|
||||||
|
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||||
|
Error: &schema.APIError{
|
||||||
|
Message: "feature not enabled for your account",
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Type: "authorization_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if !val {
|
||||||
|
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||||
|
Error: &schema.APIError{
|
||||||
|
Message: "feature not enabled for your account",
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Type: "authorization_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser returns the authenticated user from the echo context, or nil.
|
||||||
|
func GetUser(c echo.Context) *User {
|
||||||
|
u, ok := c.Get(contextKeyUser).(*User)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserRole returns the role of the authenticated user, or empty string.
|
||||||
|
func GetUserRole(c echo.Context) string {
|
||||||
|
role, _ := c.Get(contextKeyRole).(string)
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireRouteFeature returns a global middleware that checks the user has access
|
||||||
|
// to the feature required by the matched route. It uses the RouteFeatureRegistry
|
||||||
|
// to look up the required feature for each route pattern + HTTP method.
|
||||||
|
// If no entry matches, the request passes through (no restriction).
|
||||||
|
func RequireRouteFeature(db *gorm.DB) echo.MiddlewareFunc {
|
||||||
|
if db == nil {
|
||||||
|
return NoopMiddleware()
|
||||||
|
}
|
||||||
|
// Pre-build lookup map: "METHOD:pattern" -> feature
|
||||||
|
lookup := map[string]string{}
|
||||||
|
for _, rf := range RouteFeatureRegistry {
|
||||||
|
lookup[rf.Method+":"+rf.Pattern] = rf.Feature
|
||||||
|
}
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
path := c.Path() // Echo route pattern (e.g. "/v1/engines/:model/completions")
|
||||||
|
method := c.Request().Method
|
||||||
|
feature := lookup[method+":"+path]
|
||||||
|
if feature == "" {
|
||||||
|
feature = lookup["*:"+path]
|
||||||
|
}
|
||||||
|
if feature == "" {
|
||||||
|
return next(c) // no restriction for this route
|
||||||
|
}
|
||||||
|
user := GetUser(c)
|
||||||
|
if user == nil {
|
||||||
|
return next(c) // auth middleware handles unauthenticated
|
||||||
|
}
|
||||||
|
if user.Role == RoleAdmin {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
perm, err := GetCachedUserPermissions(c, db, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, schema.ErrorResponse{
|
||||||
|
Error: &schema.APIError{
|
||||||
|
Message: "failed to check permissions",
|
||||||
|
Code: http.StatusInternalServerError,
|
||||||
|
Type: "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
val, exists := perm.Permissions[feature]
|
||||||
|
if !exists {
|
||||||
|
if !isDefaultOnFeature(feature) {
|
||||||
|
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||||
|
Error: &schema.APIError{
|
||||||
|
Message: "feature not enabled for your account: " + feature,
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Type: "authorization_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if !val {
|
||||||
|
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||||
|
Error: &schema.APIError{
|
||||||
|
Message: "feature not enabled for your account: " + feature,
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Type: "authorization_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireModelAccess returns a global middleware that checks the user is allowed
|
||||||
|
// to use the resolved model. It extracts the model name directly from the request
|
||||||
|
// (path param, query param, JSON body, or form value) rather than relying on a
|
||||||
|
// context key set by downstream route-specific middleware.
|
||||||
|
func RequireModelAccess(db *gorm.DB) echo.MiddlewareFunc {
|
||||||
|
if db == nil {
|
||||||
|
return NoopMiddleware()
|
||||||
|
}
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
user := GetUser(c)
|
||||||
|
if user == nil {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
if user.Role == RoleAdmin {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this user even has a model allowlist enabled before
|
||||||
|
// doing the expensive body read. Most users won't have restrictions.
|
||||||
|
// Uses request-scoped cache to avoid duplicate DB hit when
|
||||||
|
// RequireRouteFeature already fetched permissions.
|
||||||
|
perm, err := GetCachedUserPermissions(c, db, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, schema.ErrorResponse{
|
||||||
|
Error: &schema.APIError{
|
||||||
|
Message: "failed to check permissions",
|
||||||
|
Code: http.StatusInternalServerError,
|
||||||
|
Type: "server_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
allowlist := perm.AllowedModels
|
||||||
|
if !allowlist.Enabled {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
modelName := extractModelFromRequest(c)
|
||||||
|
if modelName == "" {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range allowlist.Models {
|
||||||
|
if m == modelName {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||||
|
Error: &schema.APIError{
|
||||||
|
Message: "access denied to model: " + modelName,
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Type: "authorization_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractModelFromRequest extracts the model name from various request sources.
|
||||||
|
// It checks URL path params, query params, JSON body, and form values.
|
||||||
|
// For JSON bodies, it peeks at the body and resets it so downstream handlers
|
||||||
|
// can still read it.
|
||||||
|
func extractModelFromRequest(c echo.Context) string {
|
||||||
|
// 1. URL path param (e.g. /v1/engines/:model/completions)
|
||||||
|
if model := c.Param("model"); model != "" {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
// 2. Query param
|
||||||
|
if model := c.QueryParam("model"); model != "" {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
// 3. Peek at JSON body
|
||||||
|
if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "application/json") {
|
||||||
|
body, err := io.ReadAll(c.Request().Body)
|
||||||
|
c.Request().Body = io.NopCloser(bytes.NewReader(body)) // always reset
|
||||||
|
if err == nil && len(body) > 0 {
|
||||||
|
var m struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(body, &m) == nil && m.Model != "" {
|
||||||
|
return m.Model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4. Form value (multipart/form-data)
|
||||||
|
if model := c.FormValue("model"); model != "" {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryAuthenticate attempts to authenticate the request using the database.
|
||||||
|
func tryAuthenticate(c echo.Context, db *gorm.DB, appConfig *config.ApplicationConfig) *User {
|
||||||
|
hmacSecret := appConfig.Auth.APIKeyHMACSecret
|
||||||
|
|
||||||
|
// a. Session cookie
|
||||||
|
if cookie, err := c.Cookie(sessionCookie); err == nil && cookie.Value != "" {
|
||||||
|
if user, session := ValidateSession(db, cookie.Value, hmacSecret); user != nil {
|
||||||
|
// Store session for rotation check in middleware
|
||||||
|
c.Set("_auth_session", session)
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// b. Authorization: Bearer token
|
||||||
|
authHeader := c.Request().Header.Get("Authorization")
|
||||||
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
|
// Try as session ID first
|
||||||
|
if user, _ := ValidateSession(db, token, hmacSecret); user != nil {
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try as user API key
|
||||||
|
if key, err := ValidateAPIKey(db, token, hmacSecret); err == nil {
|
||||||
|
return &key.User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// c. x-api-key / xi-api-key headers
|
||||||
|
for _, header := range []string{"x-api-key", "xi-api-key"} {
|
||||||
|
if key := c.Request().Header.Get(header); key != "" {
|
||||||
|
if apiKey, err := ValidateAPIKey(db, key, hmacSecret); err == nil {
|
||||||
|
return &apiKey.User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// d. token cookie (legacy)
|
||||||
|
if cookie, err := c.Cookie("token"); err == nil && cookie.Value != "" {
|
||||||
|
// Try as user API key
|
||||||
|
if key, err := ValidateAPIKey(db, cookie.Value, hmacSecret); err == nil {
|
||||||
|
return &key.User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractKey extracts an API key from the request (all sources).
|
||||||
|
func extractKey(c echo.Context) string {
|
||||||
|
// Authorization header
|
||||||
|
auth := c.Request().Header.Get("Authorization")
|
||||||
|
if strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
return strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
}
|
||||||
|
if auth != "" {
|
||||||
|
return auth
|
||||||
|
}
|
||||||
|
|
||||||
|
// x-api-key
|
||||||
|
if key := c.Request().Header.Get("x-api-key"); key != "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// xi-api-key
|
||||||
|
if key := c.Request().Header.Get("xi-api-key"); key != "" {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
// token cookie
|
||||||
|
if cookie, err := c.Cookie("token"); err == nil && cookie.Value != "" {
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidLegacyKey checks if the key matches any configured API key
|
||||||
|
// using constant-time comparison to prevent timing attacks.
|
||||||
|
func isValidLegacyKey(key string, appConfig *config.ApplicationConfig) bool {
|
||||||
|
for _, validKey := range appConfig.ApiKeys {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(key), []byte(validKey)) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isExemptPath returns true if the path should skip authentication.
|
||||||
|
func isExemptPath(path string, appConfig *config.ApplicationConfig) bool {
|
||||||
|
// Auth endpoints are always public
|
||||||
|
if strings.HasPrefix(path, "/api/auth/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check configured exempt paths
|
||||||
|
for _, p := range appConfig.PathWithoutAuth {
|
||||||
|
if strings.HasPrefix(path, p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAPIPath returns true for paths that always require authentication.
|
||||||
|
func isAPIPath(path string) bool {
|
||||||
|
return strings.HasPrefix(path, "/api/") ||
|
||||||
|
strings.HasPrefix(path, "/v1/") ||
|
||||||
|
strings.HasPrefix(path, "/models/") ||
|
||||||
|
strings.HasPrefix(path, "/backends/") ||
|
||||||
|
strings.HasPrefix(path, "/backend/") ||
|
||||||
|
strings.HasPrefix(path, "/tts") ||
|
||||||
|
strings.HasPrefix(path, "/vad") ||
|
||||||
|
strings.HasPrefix(path, "/video") ||
|
||||||
|
strings.HasPrefix(path, "/stores/") ||
|
||||||
|
strings.HasPrefix(path, "/system") ||
|
||||||
|
strings.HasPrefix(path, "/ws/") ||
|
||||||
|
strings.HasPrefix(path, "/generated-") ||
|
||||||
|
path == "/metrics"
|
||||||
|
}
|
||||||
|
|
||||||
|
// authError returns an appropriate error response.
|
||||||
|
func authError(c echo.Context, appConfig *config.ApplicationConfig) error {
|
||||||
|
c.Response().Header().Set("WWW-Authenticate", "Bearer")
|
||||||
|
|
||||||
|
if appConfig.OpaqueErrors {
|
||||||
|
return c.NoContent(http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := c.Request().Header.Get("Content-Type")
|
||||||
|
if strings.Contains(contentType, "application/json") {
|
||||||
|
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
|
||||||
|
Error: &schema.APIError{
|
||||||
|
Message: "An authentication key is required",
|
||||||
|
Code: http.StatusUnauthorized,
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
|
||||||
|
Error: &schema.APIError{
|
||||||
|
Message: "An authentication key is required",
|
||||||
|
Code: http.StatusUnauthorized,
|
||||||
|
Type: "invalid_request_error",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
306
core/http/auth/middleware_test.go
Normal file
306
core/http/auth/middleware_test.go
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
//go:build auth
|
||||||
|
|
||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/mudler/LocalAI/core/config"
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Auth Middleware", func() {
|
||||||
|
|
||||||
|
Context("auth disabled, no API keys", func() {
|
||||||
|
var app *echo.Echo
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
appConfig := config.NewApplicationConfig()
|
||||||
|
app = newAuthTestApp(nil, appConfig)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("passes through all requests", func() {
|
||||||
|
rec := doRequest(app, http.MethodGet, "/v1/models")
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("passes through POST requests", func() {
|
||||||
|
rec := doRequest(app, http.MethodPost, "/v1/chat/completions")
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("auth disabled, API keys configured", func() {
|
||||||
|
var app *echo.Echo
|
||||||
|
const validKey = "sk-test-key-123"
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
appConfig := config.NewApplicationConfig()
|
||||||
|
appConfig.ApiKeys = []string{validKey}
|
||||||
|
app = newAuthTestApp(nil, appConfig)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 401 for request without key", func() {
|
||||||
|
rec := doRequest(app, http.MethodGet, "/v1/models")
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("passes with valid Bearer token", func() {
|
||||||
|
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(validKey))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("passes with valid x-api-key header", func() {
|
||||||
|
rec := doRequest(app, http.MethodGet, "/v1/models", withXApiKey(validKey))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("passes with valid token cookie", func() {
|
||||||
|
rec := doRequest(app, http.MethodGet, "/v1/models", withTokenCookie(validKey))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 401 for invalid key", func() {
|
||||||
|
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken("wrong-key"))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("auth enabled with database", func() {
|
||||||
|
var (
|
||||||
|
db *gorm.DB
|
||||||
|
app *echo.Echo
|
||||||
|
appConfig *config.ApplicationConfig
|
||||||
|
user *auth.User
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
db = testDB()
|
||||||
|
appConfig = config.NewApplicationConfig()
|
||||||
|
app = newAuthTestApp(db, appConfig)
|
||||||
|
user = createTestUser(db, "user@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("allows requests with valid session cookie", func() {
|
||||||
|
sessionID := createTestSession(db, user.ID)
|
||||||
|
rec := doRequest(app, http.MethodGet, "/v1/models", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("allows requests with valid session as Bearer token", func() {
|
||||||
|
sessionID := createTestSession(db, user.ID)
|
||||||
|
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("allows requests with valid user API key as Bearer token", func() {
|
||||||
|
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "test", auth.RoleUser, "", nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(plaintext))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("allows requests with legacy API_KEY as admin bypass", func() {
|
||||||
|
appConfig.ApiKeys = []string{"legacy-key-123"}
|
||||||
|
app = newAuthTestApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken("legacy-key-123"))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 401 for expired session", func() {
|
||||||
|
sessionID := createTestSession(db, user.ID)
|
||||||
|
// Manually expire (session ID in DB is the hash)
|
||||||
|
hash := auth.HashAPIKey(sessionID, "")
|
||||||
|
db.Model(&auth.Session{}).Where("id = ?", hash).
|
||||||
|
Update("expires_at", "2020-01-01")
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodGet, "/v1/models", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 401 for invalid session ID", func() {
|
||||||
|
rec := doRequest(app, http.MethodGet, "/v1/models", withSessionCookie("invalid-session-id"))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 401 for revoked API key", func() {
|
||||||
|
plaintext, record, err := auth.CreateAPIKey(db, user.ID, "to revoke", auth.RoleUser, "", nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
err = auth.RevokeAPIKey(db, record.ID, user.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(plaintext))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("skips auth for /api/auth/* paths", func() {
|
||||||
|
rec := doRequest(app, http.MethodGet, "/api/auth/status")
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("skips auth for PathWithoutAuth paths", func() {
|
||||||
|
rec := doRequest(app, http.MethodGet, "/healthz")
|
||||||
|
// healthz is not registered in our test app, so it'll be 404/405 but NOT 401
|
||||||
|
Expect(rec.Code).ToNot(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 401 for unauthenticated API requests", func() {
|
||||||
|
rec := doRequest(app, http.MethodGet, "/v1/models")
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("allows unauthenticated access to non-API paths when no legacy keys", func() {
|
||||||
|
rec := doRequest(app, http.MethodGet, "/app")
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("RequireAdmin", func() {
|
||||||
|
var (
|
||||||
|
db *gorm.DB
|
||||||
|
appConfig *config.ApplicationConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
db = testDB()
|
||||||
|
appConfig = config.NewApplicationConfig()
|
||||||
|
})
|
||||||
|
|
||||||
|
It("passes for admin user", func() {
|
||||||
|
admin := createTestUser(db, "admin@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||||
|
sessionID := createTestSession(db, admin.ID)
|
||||||
|
app := newAdminTestApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodPost, "/api/settings", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 403 for user role", func() {
|
||||||
|
user := createTestUser(db, "user@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||||
|
sessionID := createTestSession(db, user.ID)
|
||||||
|
app := newAdminTestApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodPost, "/api/settings", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 401 when no user in context", func() {
|
||||||
|
app := newAdminTestApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodPost, "/api/settings")
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("allows admin to access model management", func() {
|
||||||
|
admin := createTestUser(db, "admin@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||||
|
sessionID := createTestSession(db, admin.ID)
|
||||||
|
app := newAdminTestApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodPost, "/models/apply", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("blocks user from model management", func() {
|
||||||
|
user := createTestUser(db, "user@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||||
|
sessionID := createTestSession(db, user.ID)
|
||||||
|
app := newAdminTestApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodPost, "/models/apply", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("allows user to access regular inference endpoints", func() {
|
||||||
|
user := createTestUser(db, "user@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||||
|
sessionID := createTestSession(db, user.ID)
|
||||||
|
app := newAdminTestApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodPost, "/v1/chat/completions", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("allows legacy API key (admin bypass) on admin routes", func() {
|
||||||
|
appConfig.ApiKeys = []string{"admin-key"}
|
||||||
|
app := newAdminTestApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodPost, "/api/settings", withBearerToken("admin-key"))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("allows admin to access trace endpoints", func() {
|
||||||
|
admin := createTestUser(db, "admin2@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||||
|
sessionID := createTestSession(db, admin.ID)
|
||||||
|
app := newAdminTestApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodGet, "/api/traces", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
rec = doRequest(app, http.MethodGet, "/api/backend-logs", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("blocks non-admin from trace endpoints", func() {
|
||||||
|
user := createTestUser(db, "user2@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||||
|
sessionID := createTestSession(db, user.ID)
|
||||||
|
app := newAdminTestApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodGet, "/api/traces", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||||
|
|
||||||
|
rec = doRequest(app, http.MethodGet, "/api/backend-logs", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("allows admin to access agent job endpoints", func() {
|
||||||
|
admin := createTestUser(db, "admin3@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||||
|
sessionID := createTestSession(db, admin.ID)
|
||||||
|
app := newAdminTestApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodGet, "/api/agent/tasks", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
rec = doRequest(app, http.MethodGet, "/api/agent/jobs", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("blocks non-admin from agent job endpoints", func() {
|
||||||
|
user := createTestUser(db, "user3@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||||
|
sessionID := createTestSession(db, user.ID)
|
||||||
|
app := newAdminTestApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doRequest(app, http.MethodGet, "/api/agent/tasks", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||||
|
|
||||||
|
rec = doRequest(app, http.MethodGet, "/api/agent/jobs", withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("blocks non-admin from system/management endpoints", func() {
|
||||||
|
user := createTestUser(db, "user4@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||||
|
sessionID := createTestSession(db, user.ID)
|
||||||
|
app := newAdminTestApp(db, appConfig)
|
||||||
|
|
||||||
|
for _, path := range []string{"/api/operations", "/api/models", "/api/backends", "/api/resources", "/api/p2p/workers", "/system", "/backend/monitor"} {
|
||||||
|
rec := doRequest(app, http.MethodGet, path, withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusForbidden), "expected 403 for path: "+path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
It("allows admin to access system/management endpoints", func() {
|
||||||
|
admin := createTestUser(db, "admin4@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||||
|
sessionID := createTestSession(db, admin.ID)
|
||||||
|
app := newAdminTestApp(db, appConfig)
|
||||||
|
|
||||||
|
for _, path := range []string{"/api/operations", "/api/models", "/api/backends", "/api/resources", "/api/p2p/workers", "/system", "/backend/monitor"} {
|
||||||
|
rec := doRequest(app, http.MethodGet, path, withSessionCookie(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK), "expected 200 for path: "+path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
148
core/http/auth/models.go
Normal file
148
core/http/auth/models.go
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Auth provider constants.
|
||||||
|
const (
|
||||||
|
ProviderLocal = "local"
|
||||||
|
ProviderGitHub = "github"
|
||||||
|
ProviderOIDC = "oidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// User represents an authenticated user.
|
||||||
|
type User struct {
|
||||||
|
ID string `gorm:"primaryKey;size:36"`
|
||||||
|
Email string `gorm:"size:255;index"`
|
||||||
|
Name string `gorm:"size:255"`
|
||||||
|
AvatarURL string `gorm:"size:512"`
|
||||||
|
Provider string `gorm:"size:50"` // ProviderLocal, ProviderGitHub, ProviderOIDC
|
||||||
|
Subject string `gorm:"size:255"` // provider-specific user ID
|
||||||
|
PasswordHash string `json:"-"` // bcrypt hash, empty for OAuth-only users
|
||||||
|
Role string `gorm:"size:20;default:user"`
|
||||||
|
Status string `gorm:"size:20;default:active"` // "active", "pending"
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session represents a user login session.
|
||||||
|
type Session struct {
|
||||||
|
ID string `gorm:"primaryKey;size:64"` // HMAC-SHA256 hash of session token
|
||||||
|
UserID string `gorm:"size:36;index"`
|
||||||
|
ExpiresAt time.Time
|
||||||
|
RotatedAt time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserAPIKey represents a user-generated API key for programmatic access.
|
||||||
|
type UserAPIKey struct {
|
||||||
|
ID string `gorm:"primaryKey;size:36"`
|
||||||
|
UserID string `gorm:"size:36;index"`
|
||||||
|
Name string `gorm:"size:255"` // user-provided label
|
||||||
|
KeyHash string `gorm:"size:64;uniqueIndex"`
|
||||||
|
KeyPrefix string `gorm:"size:12"` // first 8 chars of key for display
|
||||||
|
Role string `gorm:"size:20"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
ExpiresAt *time.Time `gorm:"index"`
|
||||||
|
LastUsed *time.Time
|
||||||
|
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PermissionMap is a flexible map of feature -> enabled, stored as JSON text.
|
||||||
|
// Known features: "agents", "skills", "collections", "mcp_jobs".
|
||||||
|
// New features can be added without schema changes.
|
||||||
|
type PermissionMap map[string]bool
|
||||||
|
|
||||||
|
// Value implements driver.Valuer for GORM JSON serialization.
|
||||||
|
func (p PermissionMap) Value() (driver.Value, error) {
|
||||||
|
if p == nil {
|
||||||
|
return "{}", nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal PermissionMap: %w", err)
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan implements sql.Scanner for GORM JSON deserialization.
|
||||||
|
func (p *PermissionMap) Scan(value any) error {
|
||||||
|
if value == nil {
|
||||||
|
*p = PermissionMap{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var bytes []byte
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
bytes = []byte(v)
|
||||||
|
case []byte:
|
||||||
|
bytes = v
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot scan %T into PermissionMap", value)
|
||||||
|
}
|
||||||
|
return json.Unmarshal(bytes, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InviteCode represents an admin-generated invitation for user registration.
|
||||||
|
type InviteCode struct {
|
||||||
|
ID string `gorm:"primaryKey;size:36"`
|
||||||
|
Code string `gorm:"uniqueIndex;not null;size:64"` // HMAC-SHA256 hash of invite code
|
||||||
|
CodePrefix string `gorm:"size:12"` // first 8 chars for admin display
|
||||||
|
CreatedBy string `gorm:"size:36;not null"`
|
||||||
|
UsedBy *string `gorm:"size:36"`
|
||||||
|
UsedAt *time.Time
|
||||||
|
ExpiresAt time.Time `gorm:"not null;index"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
Creator User `gorm:"foreignKey:CreatedBy"`
|
||||||
|
Consumer *User `gorm:"foreignKey:UsedBy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelAllowlist controls which models a user can access.
|
||||||
|
// When Enabled is false (default), all models are allowed.
|
||||||
|
type ModelAllowlist struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Models []string `json:"models,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements driver.Valuer for GORM JSON serialization.
|
||||||
|
func (m ModelAllowlist) Value() (driver.Value, error) {
|
||||||
|
b, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal ModelAllowlist: %w", err)
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan implements sql.Scanner for GORM JSON deserialization.
|
||||||
|
func (m *ModelAllowlist) Scan(value any) error {
|
||||||
|
if value == nil {
|
||||||
|
*m = ModelAllowlist{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var bytes []byte
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
bytes = []byte(v)
|
||||||
|
case []byte:
|
||||||
|
bytes = v
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot scan %T into ModelAllowlist", value)
|
||||||
|
}
|
||||||
|
return json.Unmarshal(bytes, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserPermission stores per-user feature permissions.
|
||||||
|
type UserPermission struct {
|
||||||
|
ID string `gorm:"primaryKey;size:36"`
|
||||||
|
UserID string `gorm:"size:36;uniqueIndex"`
|
||||||
|
Permissions PermissionMap `gorm:"type:text"`
|
||||||
|
AllowedModels ModelAllowlist `gorm:"type:text"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
|
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
|
||||||
|
}
|
||||||
439
core/http/auth/oauth.go
Normal file
439
core/http/auth/oauth.go
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/mudler/xlog"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
githubOAuth "golang.org/x/oauth2/github"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// providerEntry holds the OAuth2/OIDC config for a single provider.
|
||||||
|
type providerEntry struct {
|
||||||
|
oauth2Config oauth2.Config
|
||||||
|
oidcVerifier *oidc.IDTokenVerifier // nil for GitHub (API-based user info)
|
||||||
|
name string
|
||||||
|
userInfoURL string // only used for GitHub
|
||||||
|
}
|
||||||
|
|
||||||
|
// oauthUserInfo is a provider-agnostic representation of an authenticated user.
|
||||||
|
type oauthUserInfo struct {
|
||||||
|
Subject string
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
AvatarURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthManager manages multiple OAuth/OIDC providers.
|
||||||
|
type OAuthManager struct {
|
||||||
|
providers map[string]*providerEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthParams groups the parameters needed to create an OAuthManager.
|
||||||
|
type OAuthParams struct {
|
||||||
|
GitHubClientID string
|
||||||
|
GitHubClientSecret string
|
||||||
|
OIDCIssuer string
|
||||||
|
OIDCClientID string
|
||||||
|
OIDCClientSecret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOAuthManager creates an OAuthManager from the given params.
|
||||||
|
func NewOAuthManager(baseURL string, params OAuthParams) (*OAuthManager, error) {
|
||||||
|
m := &OAuthManager{providers: make(map[string]*providerEntry)}
|
||||||
|
|
||||||
|
if params.GitHubClientID != "" {
|
||||||
|
m.providers[ProviderGitHub] = &providerEntry{
|
||||||
|
name: ProviderGitHub,
|
||||||
|
oauth2Config: oauth2.Config{
|
||||||
|
ClientID: params.GitHubClientID,
|
||||||
|
ClientSecret: params.GitHubClientSecret,
|
||||||
|
Endpoint: githubOAuth.Endpoint,
|
||||||
|
RedirectURL: baseURL + "/api/auth/github/callback",
|
||||||
|
Scopes: []string{"user:email", "read:user"},
|
||||||
|
},
|
||||||
|
userInfoURL: "https://api.github.com/user",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.OIDCClientID != "" && params.OIDCIssuer != "" {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
provider, err := oidc.NewProvider(ctx, params.OIDCIssuer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("OIDC discovery failed for %s: %w", params.OIDCIssuer, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifier := provider.Verifier(&oidc.Config{ClientID: params.OIDCClientID})
|
||||||
|
|
||||||
|
m.providers[ProviderOIDC] = &providerEntry{
|
||||||
|
name: ProviderOIDC,
|
||||||
|
oauth2Config: oauth2.Config{
|
||||||
|
ClientID: params.OIDCClientID,
|
||||||
|
ClientSecret: params.OIDCClientSecret,
|
||||||
|
Endpoint: provider.Endpoint(),
|
||||||
|
RedirectURL: baseURL + "/api/auth/oidc/callback",
|
||||||
|
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||||
|
},
|
||||||
|
oidcVerifier: verifier,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providers returns the list of configured provider names.
|
||||||
|
func (m *OAuthManager) Providers() []string {
|
||||||
|
names := make([]string, 0, len(m.providers))
|
||||||
|
for name := range m.providers {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginHandler redirects the user to the OAuth provider's login page.
|
||||||
|
func (m *OAuthManager) LoginHandler(providerName string) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
provider, ok := m.providers[providerName]
|
||||||
|
if !ok {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "unknown provider"})
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := generateState()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate state"})
|
||||||
|
}
|
||||||
|
|
||||||
|
secure := isSecure(c)
|
||||||
|
c.SetCookie(&http.Cookie{
|
||||||
|
Name: "oauth_state",
|
||||||
|
Value: state,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: 600, // 10 minutes
|
||||||
|
})
|
||||||
|
|
||||||
|
// Store invite code in cookie if provided
|
||||||
|
if inviteCode := c.QueryParam("invite_code"); inviteCode != "" {
|
||||||
|
c.SetCookie(&http.Cookie{
|
||||||
|
Name: "invite_code",
|
||||||
|
Value: inviteCode,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: secure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: 600,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
url := provider.oauth2Config.AuthCodeURL(state)
|
||||||
|
return c.Redirect(http.StatusTemporaryRedirect, url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallbackHandler handles the OAuth callback, creates/updates the user, and
|
||||||
|
// creates a session.
|
||||||
|
func (m *OAuthManager) CallbackHandler(providerName string, db *gorm.DB, adminEmail, registrationMode, hmacSecret string) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
provider, ok := m.providers[providerName]
|
||||||
|
if !ok {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "unknown provider"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate state
|
||||||
|
stateCookie, err := c.Cookie("oauth_state")
|
||||||
|
if err != nil || stateCookie.Value == "" || subtle.ConstantTimeCompare([]byte(stateCookie.Value), []byte(c.QueryParam("state"))) != 1 {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid OAuth state"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state cookie
|
||||||
|
c.SetCookie(&http.Cookie{
|
||||||
|
Name: "oauth_state",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecure(c),
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Exchange code for token
|
||||||
|
code := c.QueryParam("code")
|
||||||
|
if code == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "missing authorization code"})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
token, err := provider.oauth2Config.Exchange(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
xlog.Error("OAuth code exchange failed", "provider", providerName, "error", err)
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "OAuth authentication failed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user info — branch based on provider type
|
||||||
|
var userInfo *oauthUserInfo
|
||||||
|
if provider.oidcVerifier != nil {
|
||||||
|
userInfo, err = extractOIDCUserInfo(ctx, provider.oidcVerifier, token)
|
||||||
|
} else {
|
||||||
|
userInfo, err = fetchGitHubUserInfoAsOAuth(ctx, token.AccessToken)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to fetch user info"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve invite code from cookie if present
|
||||||
|
var inviteCode string
|
||||||
|
if ic, err := c.Cookie("invite_code"); err == nil && ic.Value != "" {
|
||||||
|
inviteCode = ic.Value
|
||||||
|
// Clear the invite code cookie
|
||||||
|
c.SetCookie(&http.Cookie{
|
||||||
|
Name: "invite_code",
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecure(c),
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert user (with invite code support)
|
||||||
|
user, err := upsertOAuthUser(db, providerName, userInfo, adminEmail, registrationMode)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// For new users that are pending, check if they have a valid invite
|
||||||
|
if user.Status != StatusActive && inviteCode != "" {
|
||||||
|
if invite, err := ValidateInvite(db, inviteCode, hmacSecret); err == nil {
|
||||||
|
user.Status = StatusActive
|
||||||
|
db.Model(user).Update("status", StatusActive)
|
||||||
|
ConsumeInvite(db, invite, user.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status != StatusActive {
|
||||||
|
if registrationMode == "invite" {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "a valid invite code is required to register"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "account pending approval"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maybe promote on login
|
||||||
|
MaybePromote(db, user, adminEmail)
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
sessionID, err := CreateSession(db, user.ID, hmacSecret)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
|
||||||
|
}
|
||||||
|
|
||||||
|
SetSessionCookie(c, sessionID)
|
||||||
|
return c.Redirect(http.StatusTemporaryRedirect, "/app")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractOIDCUserInfo extracts user info from the OIDC ID token.
|
||||||
|
func extractOIDCUserInfo(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token) (*oauthUserInfo, error) {
|
||||||
|
rawIDToken, ok := token.Extra("id_token").(string)
|
||||||
|
if !ok || rawIDToken == "" {
|
||||||
|
return nil, fmt.Errorf("no id_token in token response")
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to verify ID token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims struct {
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Picture string `json:"picture"`
|
||||||
|
}
|
||||||
|
if err := idToken.Claims(&claims); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse ID token claims: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &oauthUserInfo{
|
||||||
|
Subject: claims.Sub,
|
||||||
|
Email: claims.Email,
|
||||||
|
Name: claims.Name,
|
||||||
|
AvatarURL: claims.Picture,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubUserInfo struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
AvatarURL string `json:"avatar_url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type githubEmail struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Primary bool `json:"primary"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchGitHubUserInfoAsOAuth fetches GitHub user info and returns it as oauthUserInfo.
|
||||||
|
func fetchGitHubUserInfoAsOAuth(ctx context.Context, accessToken string) (*oauthUserInfo, error) {
|
||||||
|
info, err := fetchGitHubUserInfo(ctx, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &oauthUserInfo{
|
||||||
|
Subject: fmt.Sprintf("%d", info.ID),
|
||||||
|
Email: info.Email,
|
||||||
|
Name: info.Name,
|
||||||
|
AvatarURL: info.AvatarURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchGitHubUserInfo(ctx context.Context, accessToken string) (*githubUserInfo, error) {
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var info githubUserInfo
|
||||||
|
if err := json.Unmarshal(body, &info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no public email, fetch from /user/emails
|
||||||
|
if info.Email == "" {
|
||||||
|
info.Email, _ = fetchGitHubPrimaryEmail(ctx, accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchGitHubPrimaryEmail(ctx context.Context, accessToken string) (string, error) {
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user/emails", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var emails []githubEmail
|
||||||
|
if err := json.Unmarshal(body, &emails); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range emails {
|
||||||
|
if e.Primary && e.Verified {
|
||||||
|
return e.Email, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to first verified email
|
||||||
|
for _, e := range emails {
|
||||||
|
if e.Verified {
|
||||||
|
return e.Email, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no verified email found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertOAuthUser(db *gorm.DB, provider string, info *oauthUserInfo, adminEmail, registrationMode string) (*User, error) {
|
||||||
|
// Normalize email from provider (#10)
|
||||||
|
if info.Email != "" {
|
||||||
|
info.Email = strings.ToLower(strings.TrimSpace(info.Email))
|
||||||
|
}
|
||||||
|
|
||||||
|
var user User
|
||||||
|
err := db.Where("provider = ? AND subject = ?", provider, info.Subject).First(&user).Error
|
||||||
|
if err == nil {
|
||||||
|
// Existing user — update profile fields
|
||||||
|
user.Name = info.Name
|
||||||
|
user.AvatarURL = info.AvatarURL
|
||||||
|
if info.Email != "" {
|
||||||
|
user.Email = info.Email
|
||||||
|
}
|
||||||
|
db.Save(&user)
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// New user — empty registration mode defaults to "approval"
|
||||||
|
effectiveMode := registrationMode
|
||||||
|
if effectiveMode == "" {
|
||||||
|
effectiveMode = "approval"
|
||||||
|
}
|
||||||
|
status := StatusActive
|
||||||
|
if effectiveMode == "approval" || effectiveMode == "invite" {
|
||||||
|
status = StatusPending
|
||||||
|
}
|
||||||
|
|
||||||
|
role := AssignRole(db, info.Email, adminEmail)
|
||||||
|
// First user is always active regardless of registration mode
|
||||||
|
if role == RoleAdmin {
|
||||||
|
status = StatusActive
|
||||||
|
}
|
||||||
|
|
||||||
|
user = User{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
Email: info.Email,
|
||||||
|
Name: info.Name,
|
||||||
|
AvatarURL: info.AvatarURL,
|
||||||
|
Provider: provider,
|
||||||
|
Subject: info.Subject,
|
||||||
|
Role: role,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&user).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateState() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
14
core/http/auth/password.go
Normal file
14
core/http/auth/password.go
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import "golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
// HashPassword returns a bcrypt hash of the given password.
|
||||||
|
func HashPassword(password string) (string, error) {
|
||||||
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
return string(bytes), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPassword compares a bcrypt hash with a plaintext password.
|
||||||
|
func CheckPassword(hash, password string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||||
|
}
|
||||||
211
core/http/auth/permissions.go
Normal file
211
core/http/auth/permissions.go
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const contextKeyPermissions = "auth_permissions"
|
||||||
|
|
||||||
|
// GetCachedUserPermissions returns the user's permission record, using a
|
||||||
|
// request-scoped cache stored in the echo context. This avoids duplicate
|
||||||
|
// DB lookups when multiple middlewares (RequireRouteFeature, RequireModelAccess)
|
||||||
|
// both need permissions in the same request.
|
||||||
|
func GetCachedUserPermissions(c echo.Context, db *gorm.DB, userID string) (*UserPermission, error) {
|
||||||
|
if perm, ok := c.Get(contextKeyPermissions).(*UserPermission); ok && perm != nil {
|
||||||
|
return perm, nil
|
||||||
|
}
|
||||||
|
perm, err := GetUserPermissions(db, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c.Set(contextKeyPermissions, perm)
|
||||||
|
return perm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feature name constants — all code must use these, never bare strings.
|
||||||
|
const (
|
||||||
|
// Agent features (default OFF for new users)
|
||||||
|
FeatureAgents = "agents"
|
||||||
|
FeatureSkills = "skills"
|
||||||
|
FeatureCollections = "collections"
|
||||||
|
FeatureMCPJobs = "mcp_jobs"
|
||||||
|
|
||||||
|
// API features (default ON for new users)
|
||||||
|
FeatureChat = "chat"
|
||||||
|
FeatureImages = "images"
|
||||||
|
FeatureAudioSpeech = "audio_speech"
|
||||||
|
FeatureAudioTranscription = "audio_transcription"
|
||||||
|
FeatureVAD = "vad"
|
||||||
|
FeatureDetection = "detection"
|
||||||
|
FeatureVideo = "video"
|
||||||
|
FeatureEmbeddings = "embeddings"
|
||||||
|
FeatureSound = "sound"
|
||||||
|
FeatureRealtime = "realtime"
|
||||||
|
FeatureRerank = "rerank"
|
||||||
|
FeatureTokenize = "tokenize"
|
||||||
|
FeatureMCP = "mcp"
|
||||||
|
FeatureStores = "stores"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentFeatures lists agent-related features (default OFF).
|
||||||
|
var AgentFeatures = []string{FeatureAgents, FeatureSkills, FeatureCollections, FeatureMCPJobs}
|
||||||
|
|
||||||
|
// APIFeatures lists API endpoint features (default ON).
|
||||||
|
var APIFeatures = []string{
|
||||||
|
FeatureChat, FeatureImages, FeatureAudioSpeech, FeatureAudioTranscription,
|
||||||
|
FeatureVAD, FeatureDetection, FeatureVideo, FeatureEmbeddings, FeatureSound,
|
||||||
|
FeatureRealtime, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllFeatures lists all known features (used by UI and validation).
|
||||||
|
var AllFeatures = append(append([]string{}, AgentFeatures...), APIFeatures...)
|
||||||
|
|
||||||
|
// defaultOnFeatures is the set of features that default to ON when absent from a user's permission map.
|
||||||
|
var defaultOnFeatures = func() map[string]bool {
|
||||||
|
m := map[string]bool{}
|
||||||
|
for _, f := range APIFeatures {
|
||||||
|
m[f] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
// isDefaultOnFeature returns true if the feature defaults to ON when not explicitly set.
|
||||||
|
func isDefaultOnFeature(feature string) bool {
|
||||||
|
return defaultOnFeatures[feature]
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserPermissions returns the permission record for a user, creating a default
|
||||||
|
// (empty map = all disabled) if none exists.
|
||||||
|
func GetUserPermissions(db *gorm.DB, userID string) (*UserPermission, error) {
|
||||||
|
var perm UserPermission
|
||||||
|
err := db.Where("user_id = ?", userID).First(&perm).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
perm = UserPermission{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
UserID: userID,
|
||||||
|
Permissions: PermissionMap{},
|
||||||
|
}
|
||||||
|
if err := db.Create(&perm).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &perm, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &perm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserPermissions upserts the permission map for a user.
|
||||||
|
func UpdateUserPermissions(db *gorm.DB, userID string, perms PermissionMap) error {
|
||||||
|
var perm UserPermission
|
||||||
|
err := db.Where("user_id = ?", userID).First(&perm).Error
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
perm = UserPermission{
|
||||||
|
ID: uuid.New().String(),
|
||||||
|
UserID: userID,
|
||||||
|
Permissions: perms,
|
||||||
|
}
|
||||||
|
return db.Create(&perm).Error
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
perm.Permissions = perms
|
||||||
|
return db.Save(&perm).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasFeatureAccess returns true if the user is an admin or has the given feature enabled.
|
||||||
|
// When a feature key is absent from the user's permission map, it checks whether the
|
||||||
|
// feature defaults to ON (API features) or OFF (agent features) for backward compatibility.
|
||||||
|
func HasFeatureAccess(db *gorm.DB, user *User, feature string) bool {
|
||||||
|
if user == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if user.Role == RoleAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
perm, err := GetUserPermissions(db, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val, exists := perm.Permissions[feature]
|
||||||
|
if !exists {
|
||||||
|
return isDefaultOnFeature(feature)
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPermissionMapForUser returns the effective permission map for a user.
|
||||||
|
// Admins get all features as true (virtual).
|
||||||
|
// For regular users, absent keys are filled with their defaults so the
|
||||||
|
// UI/API always returns a complete picture.
|
||||||
|
func GetPermissionMapForUser(db *gorm.DB, user *User) PermissionMap {
|
||||||
|
if user == nil {
|
||||||
|
return PermissionMap{}
|
||||||
|
}
|
||||||
|
if user.Role == RoleAdmin {
|
||||||
|
m := PermissionMap{}
|
||||||
|
for _, f := range AllFeatures {
|
||||||
|
m[f] = true
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
perm, err := GetUserPermissions(db, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return PermissionMap{}
|
||||||
|
}
|
||||||
|
// Fill in defaults for absent keys
|
||||||
|
effective := PermissionMap{}
|
||||||
|
for _, f := range AllFeatures {
|
||||||
|
val, exists := perm.Permissions[f]
|
||||||
|
if exists {
|
||||||
|
effective[f] = val
|
||||||
|
} else {
|
||||||
|
effective[f] = isDefaultOnFeature(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return effective
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModelAllowlist returns the model allowlist for a user.
|
||||||
|
func GetModelAllowlist(db *gorm.DB, userID string) ModelAllowlist {
|
||||||
|
perm, err := GetUserPermissions(db, userID)
|
||||||
|
if err != nil {
|
||||||
|
return ModelAllowlist{}
|
||||||
|
}
|
||||||
|
return perm.AllowedModels
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateModelAllowlist updates the model allowlist for a user.
|
||||||
|
func UpdateModelAllowlist(db *gorm.DB, userID string, allowlist ModelAllowlist) error {
|
||||||
|
perm, err := GetUserPermissions(db, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
perm.AllowedModels = allowlist
|
||||||
|
return db.Save(perm).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsModelAllowed returns true if the user is allowed to use the given model.
|
||||||
|
// Admins always have access. If the allowlist is not enabled, all models are allowed.
|
||||||
|
func IsModelAllowed(db *gorm.DB, user *User, modelName string) bool {
|
||||||
|
if user == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if user.Role == RoleAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
allowlist := GetModelAllowlist(db, user.ID)
|
||||||
|
if !allowlist.Enabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, m := range allowlist.Models {
|
||||||
|
if m == modelName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
103
core/http/auth/roles.go
Normal file
103
core/http/auth/roles.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
RoleAdmin = "admin"
|
||||||
|
RoleUser = "user"
|
||||||
|
|
||||||
|
StatusActive = "active"
|
||||||
|
StatusPending = "pending"
|
||||||
|
StatusDisabled = "disabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssignRole determines the role for a new user.
|
||||||
|
// First user in the database becomes admin. If adminEmail is set and matches,
|
||||||
|
// the user becomes admin. Otherwise, the user gets the "user" role.
|
||||||
|
// Must be called within a transaction that also creates the user to prevent
|
||||||
|
// race conditions on the first-user admin assignment.
|
||||||
|
func AssignRole(tx *gorm.DB, email, adminEmail string) string {
|
||||||
|
var count int64
|
||||||
|
tx.Model(&User{}).Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
return RoleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
if adminEmail != "" && strings.EqualFold(email, adminEmail) {
|
||||||
|
return RoleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
return RoleUser
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaybePromote promotes a user to admin on login if their email matches
|
||||||
|
// adminEmail. It does not demote existing admins. Returns true if the user
|
||||||
|
// was promoted.
|
||||||
|
func MaybePromote(db *gorm.DB, user *User, adminEmail string) bool {
|
||||||
|
if user.Role == RoleAdmin {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if adminEmail != "" && strings.EqualFold(user.Email, adminEmail) {
|
||||||
|
user.Role = RoleAdmin
|
||||||
|
db.Model(user).Update("role", RoleAdmin)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateInvite checks that an invite code exists, is unused, and has not expired.
|
||||||
|
// The code is hashed with HMAC-SHA256 before lookup.
|
||||||
|
func ValidateInvite(db *gorm.DB, code, hmacSecret string) (*InviteCode, error) {
|
||||||
|
hash := HashAPIKey(code, hmacSecret)
|
||||||
|
var invite InviteCode
|
||||||
|
if err := db.Where("code = ?", hash).First(&invite).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("invite code not found")
|
||||||
|
}
|
||||||
|
if invite.UsedBy != nil {
|
||||||
|
return nil, fmt.Errorf("invite code already used")
|
||||||
|
}
|
||||||
|
if time.Now().After(invite.ExpiresAt) {
|
||||||
|
return nil, fmt.Errorf("invite code expired")
|
||||||
|
}
|
||||||
|
return &invite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumeInvite marks an invite code as used by the given user.
|
||||||
|
func ConsumeInvite(db *gorm.DB, invite *InviteCode, userID string) {
|
||||||
|
now := time.Now()
|
||||||
|
invite.UsedBy = &userID
|
||||||
|
invite.UsedAt = &now
|
||||||
|
db.Save(invite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsInviteOrApproval returns true if registration gating applies for the given mode.
|
||||||
|
// Admins (first user or matching adminEmail) are never gated.
|
||||||
|
// Must be called within a transaction that also creates the user.
|
||||||
|
func NeedsInviteOrApproval(tx *gorm.DB, email, adminEmail, registrationMode string) bool {
|
||||||
|
// Empty registration mode defaults to "approval"
|
||||||
|
if registrationMode == "" {
|
||||||
|
registrationMode = "approval"
|
||||||
|
}
|
||||||
|
if registrationMode != "approval" && registrationMode != "invite" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Admin email is never gated
|
||||||
|
if adminEmail != "" && strings.EqualFold(email, adminEmail) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// First user is never gated
|
||||||
|
var count int64
|
||||||
|
tx.Model(&User{}).Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
84
core/http/auth/roles_test.go
Normal file
84
core/http/auth/roles_test.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
//go:build auth
|
||||||
|
|
||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Roles", func() {
|
||||||
|
var db *gorm.DB
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
db = testDB()
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("AssignRole", func() {
|
||||||
|
It("returns admin for the first user (empty DB)", func() {
|
||||||
|
role := auth.AssignRole(db, "first@example.com", "")
|
||||||
|
Expect(role).To(Equal(auth.RoleAdmin))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns user for the second user", func() {
|
||||||
|
createTestUser(db, "first@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||||
|
|
||||||
|
role := auth.AssignRole(db, "second@example.com", "")
|
||||||
|
Expect(role).To(Equal(auth.RoleUser))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns admin when email matches adminEmail", func() {
|
||||||
|
createTestUser(db, "first@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||||
|
|
||||||
|
role := auth.AssignRole(db, "admin@example.com", "admin@example.com")
|
||||||
|
Expect(role).To(Equal(auth.RoleAdmin))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("is case-insensitive for admin email match", func() {
|
||||||
|
createTestUser(db, "first@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||||
|
|
||||||
|
role := auth.AssignRole(db, "Admin@Example.COM", "admin@example.com")
|
||||||
|
Expect(role).To(Equal(auth.RoleAdmin))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns user when email does not match adminEmail", func() {
|
||||||
|
createTestUser(db, "first@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||||
|
|
||||||
|
role := auth.AssignRole(db, "other@example.com", "admin@example.com")
|
||||||
|
Expect(role).To(Equal(auth.RoleUser))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("MaybePromote", func() {
|
||||||
|
It("promotes user to admin when email matches", func() {
|
||||||
|
user := createTestUser(db, "promoted@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||||
|
|
||||||
|
promoted := auth.MaybePromote(db, user, "promoted@example.com")
|
||||||
|
Expect(promoted).To(BeTrue())
|
||||||
|
Expect(user.Role).To(Equal(auth.RoleAdmin))
|
||||||
|
|
||||||
|
// Verify in DB
|
||||||
|
var dbUser auth.User
|
||||||
|
db.First(&dbUser, "id = ?", user.ID)
|
||||||
|
Expect(dbUser.Role).To(Equal(auth.RoleAdmin))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not promote when email does not match", func() {
|
||||||
|
user := createTestUser(db, "user@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||||
|
|
||||||
|
promoted := auth.MaybePromote(db, user, "admin@example.com")
|
||||||
|
Expect(promoted).To(BeFalse())
|
||||||
|
Expect(user.Role).To(Equal(auth.RoleUser))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not demote an existing admin", func() {
|
||||||
|
user := createTestUser(db, "admin@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||||
|
|
||||||
|
promoted := auth.MaybePromote(db, user, "other@example.com")
|
||||||
|
Expect(promoted).To(BeFalse())
|
||||||
|
Expect(user.Role).To(Equal(auth.RoleAdmin))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
182
core/http/auth/session.go
Normal file
182
core/http/auth/session.go
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionDuration = 30 * 24 * time.Hour // 30 days
|
||||||
|
sessionIDBytes = 32 // 32 bytes = 64 hex chars
|
||||||
|
sessionCookie = "session"
|
||||||
|
sessionRotationInterval = 1 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateSession creates a new session for the given user, returning the
|
||||||
|
// plaintext token (64-char hex string). The stored session ID is the
|
||||||
|
// HMAC-SHA256 hash of the token.
|
||||||
|
func CreateSession(db *gorm.DB, userID, hmacSecret string) (string, error) {
|
||||||
|
b := make([]byte, sessionIDBytes)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate session ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := hex.EncodeToString(b)
|
||||||
|
hash := HashAPIKey(plaintext, hmacSecret)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
session := Session{
|
||||||
|
ID: hash,
|
||||||
|
UserID: userID,
|
||||||
|
ExpiresAt: now.Add(sessionDuration),
|
||||||
|
RotatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Create(&session).Error; err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateSession hashes the plaintext token and looks up the session.
|
||||||
|
// Returns the associated user and session, or (nil, nil) if not found/expired.
|
||||||
|
func ValidateSession(db *gorm.DB, token, hmacSecret string) (*User, *Session) {
|
||||||
|
hash := HashAPIKey(token, hmacSecret)
|
||||||
|
|
||||||
|
var session Session
|
||||||
|
if err := db.Preload("User").Where("id = ? AND expires_at > ?", hash, time.Now()).First(&session).Error; err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if session.User.Status != StatusActive {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &session.User, &session
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSession removes a session by hashing the plaintext token.
|
||||||
|
func DeleteSession(db *gorm.DB, token, hmacSecret string) error {
|
||||||
|
hash := HashAPIKey(token, hmacSecret)
|
||||||
|
return db.Where("id = ?", hash).Delete(&Session{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanExpiredSessions removes all sessions that have passed their expiry time.
|
||||||
|
func CleanExpiredSessions(db *gorm.DB) error {
|
||||||
|
return db.Where("expires_at < ?", time.Now()).Delete(&Session{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUserSessions removes all sessions for the given user.
|
||||||
|
func DeleteUserSessions(db *gorm.DB, userID string) error {
|
||||||
|
return db.Where("user_id = ?", userID).Delete(&Session{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateSession creates a new session for the same user, deletes the old one,
|
||||||
|
// and returns the new plaintext token.
|
||||||
|
func RotateSession(db *gorm.DB, oldSession *Session, hmacSecret string) (string, error) {
|
||||||
|
b := make([]byte, sessionIDBytes)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate session ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext := hex.EncodeToString(b)
|
||||||
|
hash := HashAPIKey(plaintext, hmacSecret)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
newSession := Session{
|
||||||
|
ID: hash,
|
||||||
|
UserID: oldSession.UserID,
|
||||||
|
ExpiresAt: oldSession.ExpiresAt,
|
||||||
|
RotatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&newSession).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Where("id = ?", oldSession.ID).Delete(&Session{}).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to rotate session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaybeRotateSession checks if the session should be rotated and does so if needed.
|
||||||
|
// Called from the auth middleware after successful cookie-based authentication.
|
||||||
|
func MaybeRotateSession(c echo.Context, db *gorm.DB, session *Session, hmacSecret string) {
|
||||||
|
if session == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rotatedAt := session.RotatedAt
|
||||||
|
if rotatedAt.IsZero() {
|
||||||
|
rotatedAt = session.CreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(rotatedAt) < sessionRotationInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newToken, err := RotateSession(db, session, hmacSecret)
|
||||||
|
if err != nil {
|
||||||
|
// Rotation failure is non-fatal; the old session remains valid
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SetSessionCookie(c, newToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSecure returns true when the request arrived over HTTPS, either directly
|
||||||
|
// or via a reverse proxy that sets X-Forwarded-Proto.
|
||||||
|
func isSecure(c echo.Context) bool {
|
||||||
|
return c.Scheme() == "https"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSessionCookie sets the session cookie on the response.
|
||||||
|
func SetSessionCookie(c echo.Context, sessionID string) {
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: sessionID,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecure(c),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: int(sessionDuration.Seconds()),
|
||||||
|
}
|
||||||
|
c.SetCookie(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTokenCookie sets an httpOnly "token" cookie for legacy API key auth.
|
||||||
|
func SetTokenCookie(c echo.Context, token string) {
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: "token",
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecure(c),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: int(sessionDuration.Seconds()),
|
||||||
|
}
|
||||||
|
c.SetCookie(cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSessionCookie clears the session cookie.
|
||||||
|
func ClearSessionCookie(c echo.Context) {
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: sessionCookie,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: isSecure(c),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
}
|
||||||
|
c.SetCookie(cookie)
|
||||||
|
}
|
||||||
272
core/http/auth/session_test.go
Normal file
272
core/http/auth/session_test.go
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
//go:build auth
|
||||||
|
|
||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Sessions", func() {
|
||||||
|
var (
|
||||||
|
db *gorm.DB
|
||||||
|
user *auth.User
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use empty HMAC secret for basic tests
|
||||||
|
hmacSecret := ""
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
db = testDB()
|
||||||
|
user = createTestUser(db, "session@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("CreateSession", func() {
|
||||||
|
It("creates a session and returns 64-char hex plaintext token", func() {
|
||||||
|
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(token).To(HaveLen(64))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("stores the hash (not plaintext) in the DB", func() {
|
||||||
|
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
hash := auth.HashAPIKey(token, hmacSecret)
|
||||||
|
var session auth.Session
|
||||||
|
err = db.First(&session, "id = ?", hash).Error
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(session.UserID).To(Equal(user.ID))
|
||||||
|
// The plaintext token should NOT be stored as the ID
|
||||||
|
Expect(session.ID).ToNot(Equal(token))
|
||||||
|
Expect(session.ID).To(Equal(hash))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("sets expiry to approximately 30 days from now", func() {
|
||||||
|
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
hash := auth.HashAPIKey(token, hmacSecret)
|
||||||
|
var session auth.Session
|
||||||
|
db.First(&session, "id = ?", hash)
|
||||||
|
|
||||||
|
expectedExpiry := time.Now().Add(30 * 24 * time.Hour)
|
||||||
|
Expect(session.ExpiresAt).To(BeTemporally("~", expectedExpiry, time.Minute))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("sets RotatedAt on creation", func() {
|
||||||
|
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
hash := auth.HashAPIKey(token, hmacSecret)
|
||||||
|
var session auth.Session
|
||||||
|
db.First(&session, "id = ?", hash)
|
||||||
|
|
||||||
|
Expect(session.RotatedAt).To(BeTemporally("~", time.Now(), time.Minute))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("associates session with correct user", func() {
|
||||||
|
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
hash := auth.HashAPIKey(token, hmacSecret)
|
||||||
|
var session auth.Session
|
||||||
|
db.First(&session, "id = ?", hash)
|
||||||
|
Expect(session.UserID).To(Equal(user.ID))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("ValidateSession", func() {
|
||||||
|
It("returns user for valid session", func() {
|
||||||
|
token := createTestSession(db, user.ID)
|
||||||
|
|
||||||
|
found, session := auth.ValidateSession(db, token, hmacSecret)
|
||||||
|
Expect(found).ToNot(BeNil())
|
||||||
|
Expect(found.ID).To(Equal(user.ID))
|
||||||
|
Expect(session).ToNot(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns nil for non-existent session", func() {
|
||||||
|
found, session := auth.ValidateSession(db, "nonexistent-session-id", hmacSecret)
|
||||||
|
Expect(found).To(BeNil())
|
||||||
|
Expect(session).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns nil for expired session", func() {
|
||||||
|
token := createTestSession(db, user.ID)
|
||||||
|
hash := auth.HashAPIKey(token, hmacSecret)
|
||||||
|
|
||||||
|
// Manually expire the session
|
||||||
|
db.Model(&auth.Session{}).Where("id = ?", hash).
|
||||||
|
Update("expires_at", time.Now().Add(-1*time.Hour))
|
||||||
|
|
||||||
|
found, _ := auth.ValidateSession(db, token, hmacSecret)
|
||||||
|
Expect(found).To(BeNil())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("DeleteSession", func() {
|
||||||
|
It("removes the session from DB", func() {
|
||||||
|
token := createTestSession(db, user.ID)
|
||||||
|
|
||||||
|
err := auth.DeleteSession(db, token, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
found, _ := auth.ValidateSession(db, token, hmacSecret)
|
||||||
|
Expect(found).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not error on non-existent session", func() {
|
||||||
|
err := auth.DeleteSession(db, "nonexistent", hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("CleanExpiredSessions", func() {
|
||||||
|
It("removes expired sessions", func() {
|
||||||
|
token := createTestSession(db, user.ID)
|
||||||
|
hash := auth.HashAPIKey(token, hmacSecret)
|
||||||
|
|
||||||
|
// Manually expire the session
|
||||||
|
db.Model(&auth.Session{}).Where("id = ?", hash).
|
||||||
|
Update("expires_at", time.Now().Add(-1*time.Hour))
|
||||||
|
|
||||||
|
err := auth.CleanExpiredSessions(db)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
db.Model(&auth.Session{}).Where("id = ?", hash).Count(&count)
|
||||||
|
Expect(count).To(Equal(int64(0)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("keeps active sessions", func() {
|
||||||
|
token := createTestSession(db, user.ID)
|
||||||
|
hash := auth.HashAPIKey(token, hmacSecret)
|
||||||
|
|
||||||
|
err := auth.CleanExpiredSessions(db)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
db.Model(&auth.Session{}).Where("id = ?", hash).Count(&count)
|
||||||
|
Expect(count).To(Equal(int64(1)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("RotateSession", func() {
|
||||||
|
It("creates a new session and deletes the old one", func() {
|
||||||
|
token := createTestSession(db, user.ID)
|
||||||
|
hash := auth.HashAPIKey(token, hmacSecret)
|
||||||
|
|
||||||
|
// Get the old session
|
||||||
|
var oldSession auth.Session
|
||||||
|
db.First(&oldSession, "id = ?", hash)
|
||||||
|
|
||||||
|
newToken, err := auth.RotateSession(db, &oldSession, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(newToken).To(HaveLen(64))
|
||||||
|
Expect(newToken).ToNot(Equal(token))
|
||||||
|
|
||||||
|
// Old session should be gone
|
||||||
|
var count int64
|
||||||
|
db.Model(&auth.Session{}).Where("id = ?", hash).Count(&count)
|
||||||
|
Expect(count).To(Equal(int64(0)))
|
||||||
|
|
||||||
|
// New session should exist and validate
|
||||||
|
found, _ := auth.ValidateSession(db, newToken, hmacSecret)
|
||||||
|
Expect(found).ToNot(BeNil())
|
||||||
|
Expect(found.ID).To(Equal(user.ID))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("preserves user ID and expiry", func() {
|
||||||
|
token := createTestSession(db, user.ID)
|
||||||
|
hash := auth.HashAPIKey(token, hmacSecret)
|
||||||
|
|
||||||
|
var oldSession auth.Session
|
||||||
|
db.First(&oldSession, "id = ?", hash)
|
||||||
|
|
||||||
|
newToken, err := auth.RotateSession(db, &oldSession, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
newHash := auth.HashAPIKey(newToken, hmacSecret)
|
||||||
|
var newSession auth.Session
|
||||||
|
db.First(&newSession, "id = ?", newHash)
|
||||||
|
|
||||||
|
Expect(newSession.UserID).To(Equal(oldSession.UserID))
|
||||||
|
Expect(newSession.ExpiresAt).To(BeTemporally("~", oldSession.ExpiresAt, time.Second))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("with HMAC secret", func() {
|
||||||
|
hmacSecret := "test-hmac-secret-123"
|
||||||
|
|
||||||
|
It("creates and validates sessions with HMAC secret", func() {
|
||||||
|
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
found, session := auth.ValidateSession(db, token, hmacSecret)
|
||||||
|
Expect(found).ToNot(BeNil())
|
||||||
|
Expect(found.ID).To(Equal(user.ID))
|
||||||
|
Expect(session).ToNot(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not validate with wrong HMAC secret", func() {
|
||||||
|
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
found, _ := auth.ValidateSession(db, token, "wrong-secret")
|
||||||
|
Expect(found).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not validate with empty HMAC secret", func() {
|
||||||
|
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
found, _ := auth.ValidateSession(db, token, "")
|
||||||
|
Expect(found).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("session created with empty secret does not validate with non-empty secret", func() {
|
||||||
|
token, err := auth.CreateSession(db, user.ID, "")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
found, _ := auth.ValidateSession(db, token, hmacSecret)
|
||||||
|
Expect(found).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("deletes session with correct HMAC secret", func() {
|
||||||
|
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
err = auth.DeleteSession(db, token, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
found, _ := auth.ValidateSession(db, token, hmacSecret)
|
||||||
|
Expect(found).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rotates session with HMAC secret", func() {
|
||||||
|
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
hash := auth.HashAPIKey(token, hmacSecret)
|
||||||
|
var oldSession auth.Session
|
||||||
|
db.First(&oldSession, "id = ?", hash)
|
||||||
|
|
||||||
|
newToken, err := auth.RotateSession(db, &oldSession, hmacSecret)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Old token should not validate
|
||||||
|
found, _ := auth.ValidateSession(db, token, hmacSecret)
|
||||||
|
Expect(found).To(BeNil())
|
||||||
|
|
||||||
|
// New token should validate
|
||||||
|
found, _ = auth.ValidateSession(db, newToken, hmacSecret)
|
||||||
|
Expect(found).ToNot(BeNil())
|
||||||
|
Expect(found.ID).To(Equal(user.ID))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
151
core/http/auth/usage.go
Normal file
151
core/http/auth/usage.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UsageRecord represents a single API request's token usage.
|
||||||
|
type UsageRecord struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
|
UserID string `gorm:"size:36;index:idx_usage_user_time"`
|
||||||
|
UserName string `gorm:"size:255"`
|
||||||
|
Model string `gorm:"size:255;index"`
|
||||||
|
Endpoint string `gorm:"size:255"`
|
||||||
|
PromptTokens int64
|
||||||
|
CompletionTokens int64
|
||||||
|
TotalTokens int64
|
||||||
|
Duration int64 // milliseconds
|
||||||
|
CreatedAt time.Time `gorm:"index:idx_usage_user_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordUsage inserts a usage record.
|
||||||
|
func RecordUsage(db *gorm.DB, record *UsageRecord) error {
|
||||||
|
return db.Create(record).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageBucket is an aggregated time bucket for the dashboard.
|
||||||
|
type UsageBucket struct {
|
||||||
|
Bucket string `json:"bucket"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
UserName string `json:"user_name,omitempty"`
|
||||||
|
PromptTokens int64 `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int64 `json:"completion_tokens"`
|
||||||
|
TotalTokens int64 `json:"total_tokens"`
|
||||||
|
RequestCount int64 `json:"request_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageTotals is a summary of all usage.
|
||||||
|
type UsageTotals struct {
|
||||||
|
PromptTokens int64 `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int64 `json:"completion_tokens"`
|
||||||
|
TotalTokens int64 `json:"total_tokens"`
|
||||||
|
RequestCount int64 `json:"request_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// periodToWindow returns the time window and SQL date format for a period.
|
||||||
|
func periodToWindow(period string, isSQLite bool) (time.Time, string) {
|
||||||
|
now := time.Now()
|
||||||
|
var since time.Time
|
||||||
|
var dateFmt string
|
||||||
|
|
||||||
|
switch period {
|
||||||
|
case "day":
|
||||||
|
since = now.Add(-24 * time.Hour)
|
||||||
|
if isSQLite {
|
||||||
|
dateFmt = "strftime('%Y-%m-%d %H:00', created_at)"
|
||||||
|
} else {
|
||||||
|
dateFmt = "to_char(date_trunc('hour', created_at), 'YYYY-MM-DD HH24:00')"
|
||||||
|
}
|
||||||
|
case "week":
|
||||||
|
since = now.Add(-7 * 24 * time.Hour)
|
||||||
|
if isSQLite {
|
||||||
|
dateFmt = "strftime('%Y-%m-%d', created_at)"
|
||||||
|
} else {
|
||||||
|
dateFmt = "to_char(date_trunc('day', created_at), 'YYYY-MM-DD')"
|
||||||
|
}
|
||||||
|
case "all":
|
||||||
|
since = time.Time{} // zero time = no filter
|
||||||
|
if isSQLite {
|
||||||
|
dateFmt = "strftime('%Y-%m', created_at)"
|
||||||
|
} else {
|
||||||
|
dateFmt = "to_char(date_trunc('month', created_at), 'YYYY-MM')"
|
||||||
|
}
|
||||||
|
default: // "month"
|
||||||
|
since = now.Add(-30 * 24 * time.Hour)
|
||||||
|
if isSQLite {
|
||||||
|
dateFmt = "strftime('%Y-%m-%d', created_at)"
|
||||||
|
} else {
|
||||||
|
dateFmt = "to_char(date_trunc('day', created_at), 'YYYY-MM-DD')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return since, dateFmt
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSQLiteDB(db *gorm.DB) bool {
|
||||||
|
return strings.Contains(db.Dialector.Name(), "sqlite")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserUsage returns aggregated usage for a single user.
|
||||||
|
func GetUserUsage(db *gorm.DB, userID, period string) ([]UsageBucket, error) {
|
||||||
|
sqlite := isSQLiteDB(db)
|
||||||
|
since, dateFmt := periodToWindow(period, sqlite)
|
||||||
|
|
||||||
|
bucketExpr := fmt.Sprintf("%s as bucket", dateFmt)
|
||||||
|
|
||||||
|
query := db.Model(&UsageRecord{}).
|
||||||
|
Select(bucketExpr+", model, "+
|
||||||
|
"SUM(prompt_tokens) as prompt_tokens, "+
|
||||||
|
"SUM(completion_tokens) as completion_tokens, "+
|
||||||
|
"SUM(total_tokens) as total_tokens, "+
|
||||||
|
"COUNT(*) as request_count").
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Group("bucket, model").
|
||||||
|
Order("bucket ASC")
|
||||||
|
|
||||||
|
if !since.IsZero() {
|
||||||
|
query = query.Where("created_at >= ?", since)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buckets []UsageBucket
|
||||||
|
if err := query.Find(&buckets).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buckets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllUsage returns aggregated usage for all users (admin). Optional userID filter.
|
||||||
|
func GetAllUsage(db *gorm.DB, period, userID string) ([]UsageBucket, error) {
|
||||||
|
sqlite := isSQLiteDB(db)
|
||||||
|
since, dateFmt := periodToWindow(period, sqlite)
|
||||||
|
|
||||||
|
bucketExpr := fmt.Sprintf("%s as bucket", dateFmt)
|
||||||
|
|
||||||
|
query := db.Model(&UsageRecord{}).
|
||||||
|
Select(bucketExpr+", model, user_id, user_name, "+
|
||||||
|
"SUM(prompt_tokens) as prompt_tokens, "+
|
||||||
|
"SUM(completion_tokens) as completion_tokens, "+
|
||||||
|
"SUM(total_tokens) as total_tokens, "+
|
||||||
|
"COUNT(*) as request_count").
|
||||||
|
Group("bucket, model, user_id, user_name").
|
||||||
|
Order("bucket ASC")
|
||||||
|
|
||||||
|
if !since.IsZero() {
|
||||||
|
query = query.Where("created_at >= ?", since)
|
||||||
|
}
|
||||||
|
|
||||||
|
if userID != "" {
|
||||||
|
query = query.Where("user_id = ?", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buckets []UsageBucket
|
||||||
|
if err := query.Find(&buckets).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buckets, nil
|
||||||
|
}
|
||||||
161
core/http/auth/usage_test.go
Normal file
161
core/http/auth/usage_test.go
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
//go:build auth
|
||||||
|
|
||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Usage", func() {
|
||||||
|
Describe("RecordUsage", func() {
|
||||||
|
It("inserts a usage record", func() {
|
||||||
|
db := testDB()
|
||||||
|
record := &auth.UsageRecord{
|
||||||
|
UserID: "user-1",
|
||||||
|
UserName: "Test User",
|
||||||
|
Model: "gpt-4",
|
||||||
|
Endpoint: "/v1/chat/completions",
|
||||||
|
PromptTokens: 100,
|
||||||
|
CompletionTokens: 50,
|
||||||
|
TotalTokens: 150,
|
||||||
|
Duration: 1200,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
err := auth.RecordUsage(db, record)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(record.ID).ToNot(BeZero())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GetUserUsage", func() {
|
||||||
|
It("returns aggregated usage for a specific user", func() {
|
||||||
|
db := testDB()
|
||||||
|
|
||||||
|
// Insert records for two users
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
err := auth.RecordUsage(db, &auth.UsageRecord{
|
||||||
|
UserID: "user-a",
|
||||||
|
UserName: "Alice",
|
||||||
|
Model: "gpt-4",
|
||||||
|
Endpoint: "/v1/chat/completions",
|
||||||
|
PromptTokens: 100,
|
||||||
|
TotalTokens: 150,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
}
|
||||||
|
err := auth.RecordUsage(db, &auth.UsageRecord{
|
||||||
|
UserID: "user-b",
|
||||||
|
UserName: "Bob",
|
||||||
|
Model: "gpt-4",
|
||||||
|
PromptTokens: 200,
|
||||||
|
TotalTokens: 300,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
buckets, err := auth.GetUserUsage(db, "user-a", "month")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(buckets).ToNot(BeEmpty())
|
||||||
|
|
||||||
|
// All returned buckets should be for user-a's model
|
||||||
|
totalPrompt := int64(0)
|
||||||
|
for _, b := range buckets {
|
||||||
|
totalPrompt += b.PromptTokens
|
||||||
|
}
|
||||||
|
Expect(totalPrompt).To(Equal(int64(300)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("filters by period", func() {
|
||||||
|
db := testDB()
|
||||||
|
|
||||||
|
// Record in the past (beyond day window)
|
||||||
|
err := auth.RecordUsage(db, &auth.UsageRecord{
|
||||||
|
UserID: "user-c",
|
||||||
|
UserName: "Carol",
|
||||||
|
Model: "gpt-4",
|
||||||
|
PromptTokens: 100,
|
||||||
|
TotalTokens: 100,
|
||||||
|
CreatedAt: time.Now().Add(-48 * time.Hour),
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Record now
|
||||||
|
err = auth.RecordUsage(db, &auth.UsageRecord{
|
||||||
|
UserID: "user-c",
|
||||||
|
UserName: "Carol",
|
||||||
|
Model: "gpt-4",
|
||||||
|
PromptTokens: 200,
|
||||||
|
TotalTokens: 200,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
// Day period should only include recent record
|
||||||
|
buckets, err := auth.GetUserUsage(db, "user-c", "day")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
totalPrompt := int64(0)
|
||||||
|
for _, b := range buckets {
|
||||||
|
totalPrompt += b.PromptTokens
|
||||||
|
}
|
||||||
|
Expect(totalPrompt).To(Equal(int64(200)))
|
||||||
|
|
||||||
|
// Month period should include both
|
||||||
|
buckets, err = auth.GetUserUsage(db, "user-c", "month")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
totalPrompt = 0
|
||||||
|
for _, b := range buckets {
|
||||||
|
totalPrompt += b.PromptTokens
|
||||||
|
}
|
||||||
|
Expect(totalPrompt).To(Equal(int64(300)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("GetAllUsage", func() {
|
||||||
|
It("returns usage for all users", func() {
|
||||||
|
db := testDB()
|
||||||
|
|
||||||
|
for _, uid := range []string{"user-x", "user-y"} {
|
||||||
|
err := auth.RecordUsage(db, &auth.UsageRecord{
|
||||||
|
UserID: uid,
|
||||||
|
UserName: uid,
|
||||||
|
Model: "gpt-4",
|
||||||
|
PromptTokens: 100,
|
||||||
|
TotalTokens: 150,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets, err := auth.GetAllUsage(db, "month", "")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(len(buckets)).To(BeNumerically(">=", 2))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("filters by user ID when specified", func() {
|
||||||
|
db := testDB()
|
||||||
|
|
||||||
|
err := auth.RecordUsage(db, &auth.UsageRecord{
|
||||||
|
UserID: "user-p", UserName: "Pat", Model: "gpt-4",
|
||||||
|
PromptTokens: 100, TotalTokens: 100, CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
err = auth.RecordUsage(db, &auth.UsageRecord{
|
||||||
|
UserID: "user-q", UserName: "Quinn", Model: "gpt-4",
|
||||||
|
PromptTokens: 200, TotalTokens: 200, CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
buckets, err := auth.GetAllUsage(db, "month", "user-p")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
for _, b := range buckets {
|
||||||
|
Expect(b.UserID).To(Equal("user-p"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -12,27 +12,54 @@ import (
|
||||||
func ListCollectionsEndpoint(app *application.Application) echo.HandlerFunc {
|
func ListCollectionsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
collections, err := svc.ListCollections()
|
userID := getUserID(c)
|
||||||
|
cols, err := svc.ListCollectionsForUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]any{
|
|
||||||
"collections": collections,
|
resp := map[string]any{
|
||||||
"count": len(collections),
|
"collections": cols,
|
||||||
})
|
"count": len(cols),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin cross-user aggregation
|
||||||
|
if wantsAllUsers(c) {
|
||||||
|
usm := svc.UserServicesManager()
|
||||||
|
if usm != nil {
|
||||||
|
userIDs, _ := usm.ListAllUserIDs()
|
||||||
|
userGroups := map[string]any{}
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
if uid == userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userCols, err := svc.ListCollectionsForUser(uid)
|
||||||
|
if err != nil || len(userCols) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userGroups[uid] = map[string]any{"collections": userCols}
|
||||||
|
}
|
||||||
|
if len(userGroups) > 0 {
|
||||||
|
resp["user_groups"] = userGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateCollectionEndpoint(app *application.Application) echo.HandlerFunc {
|
func CreateCollectionEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := getUserID(c)
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
if err := c.Bind(&payload); err != nil {
|
if err := c.Bind(&payload); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
if err := svc.CreateCollection(payload.Name); err != nil {
|
if err := svc.CreateCollectionForUser(userID, payload.Name); err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusCreated, map[string]string{"status": "ok", "name": payload.Name})
|
return c.JSON(http.StatusCreated, map[string]string{"status": "ok", "name": payload.Name})
|
||||||
|
|
@ -42,20 +69,18 @@ func CreateCollectionEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func UploadToCollectionEndpoint(app *application.Application) echo.HandlerFunc {
|
func UploadToCollectionEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "file required"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "file required"})
|
||||||
}
|
}
|
||||||
if svc.CollectionEntryExists(name, file.Filename) {
|
|
||||||
return c.JSON(http.StatusConflict, map[string]string{"error": "entry already exists"})
|
|
||||||
}
|
|
||||||
src, err := file.Open()
|
src, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
defer src.Close()
|
defer src.Close()
|
||||||
if err := svc.UploadToCollection(name, file.Filename, src); err != nil {
|
if err := svc.UploadToCollectionForUser(userID, name, file.Filename, src); err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +93,8 @@ func UploadToCollectionEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func ListCollectionEntriesEndpoint(app *application.Application) echo.HandlerFunc {
|
func ListCollectionEntriesEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
entries, err := svc.ListCollectionEntries(c.Param("name"))
|
userID := effectiveUserID(c)
|
||||||
|
entries, err := svc.ListCollectionEntriesForUser(userID, c.Param("name"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
|
@ -85,12 +111,13 @@ func ListCollectionEntriesEndpoint(app *application.Application) echo.HandlerFun
|
||||||
func GetCollectionEntryContentEndpoint(app *application.Application) echo.HandlerFunc {
|
func GetCollectionEntryContentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
entryParam := c.Param("*")
|
entryParam := c.Param("*")
|
||||||
entry, err := url.PathUnescape(entryParam)
|
entry, err := url.PathUnescape(entryParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
entry = entryParam
|
entry = entryParam
|
||||||
}
|
}
|
||||||
content, chunkCount, err := svc.GetCollectionEntryContent(c.Param("name"), entry)
|
content, chunkCount, err := svc.GetCollectionEntryContentForUser(userID, c.Param("name"), entry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
|
@ -107,6 +134,7 @@ func GetCollectionEntryContentEndpoint(app *application.Application) echo.Handle
|
||||||
func SearchCollectionEndpoint(app *application.Application) echo.HandlerFunc {
|
func SearchCollectionEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
MaxResults int `json:"max_results"`
|
MaxResults int `json:"max_results"`
|
||||||
|
|
@ -114,7 +142,7 @@ func SearchCollectionEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
if err := c.Bind(&payload); err != nil {
|
if err := c.Bind(&payload); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
results, err := svc.SearchCollection(c.Param("name"), payload.Query, payload.MaxResults)
|
results, err := svc.SearchCollectionForUser(userID, c.Param("name"), payload.Query, payload.MaxResults)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
|
@ -131,7 +159,8 @@ func SearchCollectionEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func ResetCollectionEndpoint(app *application.Application) echo.HandlerFunc {
|
func ResetCollectionEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
if err := svc.ResetCollection(c.Param("name")); err != nil {
|
userID := effectiveUserID(c)
|
||||||
|
if err := svc.ResetCollectionForUser(userID, c.Param("name")); err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -144,13 +173,14 @@ func ResetCollectionEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func DeleteCollectionEntryEndpoint(app *application.Application) echo.HandlerFunc {
|
func DeleteCollectionEntryEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Entry string `json:"entry"`
|
Entry string `json:"entry"`
|
||||||
}
|
}
|
||||||
if err := c.Bind(&payload); err != nil {
|
if err := c.Bind(&payload); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
remaining, err := svc.DeleteCollectionEntry(c.Param("name"), payload.Entry)
|
remaining, err := svc.DeleteCollectionEntryForUser(userID, c.Param("name"), payload.Entry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
|
@ -167,6 +197,7 @@ func DeleteCollectionEntryEndpoint(app *application.Application) echo.HandlerFun
|
||||||
func AddCollectionSourceEndpoint(app *application.Application) echo.HandlerFunc {
|
func AddCollectionSourceEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
var payload struct {
|
var payload struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
UpdateInterval int `json:"update_interval"`
|
UpdateInterval int `json:"update_interval"`
|
||||||
|
|
@ -177,7 +208,7 @@ func AddCollectionSourceEndpoint(app *application.Application) echo.HandlerFunc
|
||||||
if payload.UpdateInterval < 1 {
|
if payload.UpdateInterval < 1 {
|
||||||
payload.UpdateInterval = 60
|
payload.UpdateInterval = 60
|
||||||
}
|
}
|
||||||
if err := svc.AddCollectionSource(c.Param("name"), payload.URL, payload.UpdateInterval); err != nil {
|
if err := svc.AddCollectionSourceForUser(userID, c.Param("name"), payload.URL, payload.UpdateInterval); err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -190,13 +221,14 @@ func AddCollectionSourceEndpoint(app *application.Application) echo.HandlerFunc
|
||||||
func RemoveCollectionSourceEndpoint(app *application.Application) echo.HandlerFunc {
|
func RemoveCollectionSourceEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
var payload struct {
|
var payload struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
if err := c.Bind(&payload); err != nil {
|
if err := c.Bind(&payload); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
if err := svc.RemoveCollectionSource(c.Param("name"), payload.URL); err != nil {
|
if err := svc.RemoveCollectionSourceForUser(userID, c.Param("name"), payload.URL); err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
|
@ -207,12 +239,13 @@ func RemoveCollectionSourceEndpoint(app *application.Application) echo.HandlerFu
|
||||||
func GetCollectionEntryRawFileEndpoint(app *application.Application) echo.HandlerFunc {
|
func GetCollectionEntryRawFileEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
entryParam := c.Param("*")
|
entryParam := c.Param("*")
|
||||||
entry, err := url.PathUnescape(entryParam)
|
entry, err := url.PathUnescape(entryParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
entry = entryParam
|
entry = entryParam
|
||||||
}
|
}
|
||||||
fpath, err := svc.GetCollectionEntryFilePath(c.Param("name"), entry)
|
fpath, err := svc.GetCollectionEntryFilePathForUser(userID, c.Param("name"), entry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
|
@ -226,7 +259,8 @@ func GetCollectionEntryRawFileEndpoint(app *application.Application) echo.Handle
|
||||||
func ListCollectionSourcesEndpoint(app *application.Application) echo.HandlerFunc {
|
func ListCollectionSourcesEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
sources, err := svc.ListCollectionSources(c.Param("name"))
|
userID := effectiveUserID(c)
|
||||||
|
sources, err := svc.ListCollectionSourcesForUser(userID, c.Param("name"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,27 @@ import (
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/mudler/LocalAI/core/application"
|
"github.com/mudler/LocalAI/core/application"
|
||||||
"github.com/mudler/LocalAI/core/schema"
|
"github.com/mudler/LocalAI/core/schema"
|
||||||
|
"github.com/mudler/LocalAI/core/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateTaskEndpoint creates a new agent task
|
// getJobService returns the job service for the current user.
|
||||||
// @Summary Create a new agent task
|
// Falls back to the global service when no user is authenticated.
|
||||||
// @Description Create a new reusable agent task with prompt template and configuration
|
func getJobService(app *application.Application, c echo.Context) *services.AgentJobService {
|
||||||
// @Tags agent-jobs
|
userID := getUserID(c)
|
||||||
// @Accept json
|
if userID == "" {
|
||||||
// @Produce json
|
return app.AgentJobService()
|
||||||
// @Param task body schema.Task true "Task definition"
|
}
|
||||||
// @Success 201 {object} map[string]string "Task created"
|
svc := app.AgentPoolService()
|
||||||
// @Failure 400 {object} map[string]string "Invalid request"
|
if svc == nil {
|
||||||
// @Failure 500 {object} map[string]string "Internal server error"
|
return app.AgentJobService()
|
||||||
// @Router /api/agent/tasks [post]
|
}
|
||||||
|
jobSvc, err := svc.JobServiceForUser(userID)
|
||||||
|
if err != nil {
|
||||||
|
return app.AgentJobService()
|
||||||
|
}
|
||||||
|
return jobSvc
|
||||||
|
}
|
||||||
|
|
||||||
func CreateTaskEndpoint(app *application.Application) echo.HandlerFunc {
|
func CreateTaskEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
var task schema.Task
|
var task schema.Task
|
||||||
|
|
@ -28,7 +36,7 @@ func CreateTaskEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body: " + err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body: " + err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
id, err := app.AgentJobService().CreateTask(task)
|
id, err := getJobService(app, c).CreateTask(task)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -37,18 +45,6 @@ func CreateTaskEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateTaskEndpoint updates an existing task
|
|
||||||
// @Summary Update an agent task
|
|
||||||
// @Description Update an existing agent task
|
|
||||||
// @Tags agent-jobs
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Task ID"
|
|
||||||
// @Param task body schema.Task true "Updated task definition"
|
|
||||||
// @Success 200 {object} map[string]string "Task updated"
|
|
||||||
// @Failure 400 {object} map[string]string "Invalid request"
|
|
||||||
// @Failure 404 {object} map[string]string "Task not found"
|
|
||||||
// @Router /api/agent/tasks/{id} [put]
|
|
||||||
func UpdateTaskEndpoint(app *application.Application) echo.HandlerFunc {
|
func UpdateTaskEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
@ -57,7 +53,7 @@ func UpdateTaskEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body: " + err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body: " + err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := app.AgentJobService().UpdateTask(id, task); err != nil {
|
if err := getJobService(app, c).UpdateTask(id, task); err != nil {
|
||||||
if err.Error() == "task not found: "+id {
|
if err.Error() == "task not found: "+id {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -68,19 +64,10 @@ func UpdateTaskEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteTaskEndpoint deletes a task
|
|
||||||
// @Summary Delete an agent task
|
|
||||||
// @Description Delete an agent task by ID
|
|
||||||
// @Tags agent-jobs
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Task ID"
|
|
||||||
// @Success 200 {object} map[string]string "Task deleted"
|
|
||||||
// @Failure 404 {object} map[string]string "Task not found"
|
|
||||||
// @Router /api/agent/tasks/{id} [delete]
|
|
||||||
func DeleteTaskEndpoint(app *application.Application) echo.HandlerFunc {
|
func DeleteTaskEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
if err := app.AgentJobService().DeleteTask(id); err != nil {
|
if err := getJobService(app, c).DeleteTask(id); err != nil {
|
||||||
if err.Error() == "task not found: "+id {
|
if err.Error() == "task not found: "+id {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -91,33 +78,52 @@ func DeleteTaskEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTasksEndpoint lists all tasks
|
|
||||||
// @Summary List all agent tasks
|
|
||||||
// @Description Get a list of all agent tasks
|
|
||||||
// @Tags agent-jobs
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {array} schema.Task "List of tasks"
|
|
||||||
// @Router /api/agent/tasks [get]
|
|
||||||
func ListTasksEndpoint(app *application.Application) echo.HandlerFunc {
|
func ListTasksEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
tasks := app.AgentJobService().ListTasks()
|
jobSvc := getJobService(app, c)
|
||||||
|
tasks := jobSvc.ListTasks()
|
||||||
|
|
||||||
|
// Admin cross-user aggregation
|
||||||
|
if wantsAllUsers(c) {
|
||||||
|
svc := app.AgentPoolService()
|
||||||
|
if svc != nil {
|
||||||
|
usm := svc.UserServicesManager()
|
||||||
|
if usm != nil {
|
||||||
|
userID := getUserID(c)
|
||||||
|
userIDs, _ := usm.ListAllUserIDs()
|
||||||
|
userGroups := map[string]any{}
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
if uid == userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userJobSvc, err := svc.JobServiceForUser(uid)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userTasks := userJobSvc.ListTasks()
|
||||||
|
if len(userTasks) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userGroups[uid] = map[string]any{"tasks": userTasks}
|
||||||
|
}
|
||||||
|
if len(userGroups) > 0 {
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{
|
||||||
|
"tasks": tasks,
|
||||||
|
"user_groups": userGroups,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, tasks)
|
return c.JSON(http.StatusOK, tasks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTaskEndpoint gets a task by ID
|
|
||||||
// @Summary Get an agent task
|
|
||||||
// @Description Get an agent task by ID
|
|
||||||
// @Tags agent-jobs
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Task ID"
|
|
||||||
// @Success 200 {object} schema.Task "Task details"
|
|
||||||
// @Failure 404 {object} map[string]string "Task not found"
|
|
||||||
// @Router /api/agent/tasks/{id} [get]
|
|
||||||
func GetTaskEndpoint(app *application.Application) echo.HandlerFunc {
|
func GetTaskEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
task, err := app.AgentJobService().GetTask(id)
|
task, err := getJobService(app, c).GetTask(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -126,16 +132,6 @@ func GetTaskEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteJobEndpoint executes a job
|
|
||||||
// @Summary Execute an agent job
|
|
||||||
// @Description Create and execute a new agent job
|
|
||||||
// @Tags agent-jobs
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param request body schema.JobExecutionRequest true "Job execution request"
|
|
||||||
// @Success 201 {object} schema.JobExecutionResponse "Job created"
|
|
||||||
// @Failure 400 {object} map[string]string "Invalid request"
|
|
||||||
// @Router /api/agent/jobs/execute [post]
|
|
||||||
func ExecuteJobEndpoint(app *application.Application) echo.HandlerFunc {
|
func ExecuteJobEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
var req schema.JobExecutionRequest
|
var req schema.JobExecutionRequest
|
||||||
|
|
@ -147,7 +143,6 @@ func ExecuteJobEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
req.Parameters = make(map[string]string)
|
req.Parameters = make(map[string]string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build multimedia struct from request
|
|
||||||
var multimedia *schema.MultimediaAttachment
|
var multimedia *schema.MultimediaAttachment
|
||||||
if len(req.Images) > 0 || len(req.Videos) > 0 || len(req.Audios) > 0 || len(req.Files) > 0 {
|
if len(req.Images) > 0 || len(req.Videos) > 0 || len(req.Audios) > 0 || len(req.Files) > 0 {
|
||||||
multimedia = &schema.MultimediaAttachment{
|
multimedia = &schema.MultimediaAttachment{
|
||||||
|
|
@ -158,7 +153,7 @@ func ExecuteJobEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jobID, err := app.AgentJobService().ExecuteJob(req.TaskID, req.Parameters, "api", multimedia)
|
jobID, err := getJobService(app, c).ExecuteJob(req.TaskID, req.Parameters, "api", multimedia)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -172,19 +167,10 @@ func ExecuteJobEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetJobEndpoint gets a job by ID
|
|
||||||
// @Summary Get an agent job
|
|
||||||
// @Description Get an agent job by ID
|
|
||||||
// @Tags agent-jobs
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Job ID"
|
|
||||||
// @Success 200 {object} schema.Job "Job details"
|
|
||||||
// @Failure 404 {object} map[string]string "Job not found"
|
|
||||||
// @Router /api/agent/jobs/{id} [get]
|
|
||||||
func GetJobEndpoint(app *application.Application) echo.HandlerFunc {
|
func GetJobEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
job, err := app.AgentJobService().GetJob(id)
|
job, err := getJobService(app, c).GetJob(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -193,16 +179,6 @@ func GetJobEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListJobsEndpoint lists jobs with optional filtering
|
|
||||||
// @Summary List agent jobs
|
|
||||||
// @Description Get a list of agent jobs, optionally filtered by task_id and status
|
|
||||||
// @Tags agent-jobs
|
|
||||||
// @Produce json
|
|
||||||
// @Param task_id query string false "Filter by task ID"
|
|
||||||
// @Param status query string false "Filter by status (pending, running, completed, failed, cancelled)"
|
|
||||||
// @Param limit query int false "Limit number of results"
|
|
||||||
// @Success 200 {array} schema.Job "List of jobs"
|
|
||||||
// @Router /api/agent/jobs [get]
|
|
||||||
func ListJobsEndpoint(app *application.Application) echo.HandlerFunc {
|
func ListJobsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
var taskID *string
|
var taskID *string
|
||||||
|
|
@ -224,25 +200,50 @@ func ListJobsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jobs := app.AgentJobService().ListJobs(taskID, status, limit)
|
jobSvc := getJobService(app, c)
|
||||||
|
jobs := jobSvc.ListJobs(taskID, status, limit)
|
||||||
|
|
||||||
|
// Admin cross-user aggregation
|
||||||
|
if wantsAllUsers(c) {
|
||||||
|
svc := app.AgentPoolService()
|
||||||
|
if svc != nil {
|
||||||
|
usm := svc.UserServicesManager()
|
||||||
|
if usm != nil {
|
||||||
|
userID := getUserID(c)
|
||||||
|
userIDs, _ := usm.ListAllUserIDs()
|
||||||
|
userGroups := map[string]any{}
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
if uid == userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userJobSvc, err := svc.JobServiceForUser(uid)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userJobs := userJobSvc.ListJobs(taskID, status, limit)
|
||||||
|
if len(userJobs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userGroups[uid] = map[string]any{"jobs": userJobs}
|
||||||
|
}
|
||||||
|
if len(userGroups) > 0 {
|
||||||
|
return c.JSON(http.StatusOK, map[string]any{
|
||||||
|
"jobs": jobs,
|
||||||
|
"user_groups": userGroups,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, jobs)
|
return c.JSON(http.StatusOK, jobs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CancelJobEndpoint cancels a running job
|
|
||||||
// @Summary Cancel an agent job
|
|
||||||
// @Description Cancel a running or pending agent job
|
|
||||||
// @Tags agent-jobs
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Job ID"
|
|
||||||
// @Success 200 {object} map[string]string "Job cancelled"
|
|
||||||
// @Failure 400 {object} map[string]string "Job cannot be cancelled"
|
|
||||||
// @Failure 404 {object} map[string]string "Job not found"
|
|
||||||
// @Router /api/agent/jobs/{id}/cancel [post]
|
|
||||||
func CancelJobEndpoint(app *application.Application) echo.HandlerFunc {
|
func CancelJobEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
if err := app.AgentJobService().CancelJob(id); err != nil {
|
if err := getJobService(app, c).CancelJob(id); err != nil {
|
||||||
if err.Error() == "job not found: "+id {
|
if err.Error() == "job not found: "+id {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -253,19 +254,10 @@ func CancelJobEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteJobEndpoint deletes a job
|
|
||||||
// @Summary Delete an agent job
|
|
||||||
// @Description Delete an agent job by ID
|
|
||||||
// @Tags agent-jobs
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path string true "Job ID"
|
|
||||||
// @Success 200 {object} map[string]string "Job deleted"
|
|
||||||
// @Failure 404 {object} map[string]string "Job not found"
|
|
||||||
// @Router /api/agent/jobs/{id} [delete]
|
|
||||||
func DeleteJobEndpoint(app *application.Application) echo.HandlerFunc {
|
func DeleteJobEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
if err := app.AgentJobService().DeleteJob(id); err != nil {
|
if err := getJobService(app, c).DeleteJob(id); err != nil {
|
||||||
if err.Error() == "job not found: "+id {
|
if err.Error() == "job not found: "+id {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -276,52 +268,33 @@ func DeleteJobEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteTaskByNameEndpoint executes a task by name
|
|
||||||
// @Summary Execute a task by name
|
|
||||||
// @Description Execute an agent task by its name (convenience endpoint). Parameters can be provided in the request body as a JSON object with string values.
|
|
||||||
// @Tags agent-jobs
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Param name path string true "Task name"
|
|
||||||
// @Param request body map[string]string false "Template parameters (JSON object with string values)"
|
|
||||||
// @Success 201 {object} schema.JobExecutionResponse "Job created"
|
|
||||||
// @Failure 400 {object} map[string]string "Invalid request"
|
|
||||||
// @Failure 404 {object} map[string]string "Task not found"
|
|
||||||
// @Router /api/agent/tasks/{name}/execute [post]
|
|
||||||
func ExecuteTaskByNameEndpoint(app *application.Application) echo.HandlerFunc {
|
func ExecuteTaskByNameEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
var params map[string]string
|
var params map[string]string
|
||||||
|
|
||||||
// Try to bind parameters from request body
|
|
||||||
// If body is empty or invalid, use empty params
|
|
||||||
if c.Request().ContentLength > 0 {
|
if c.Request().ContentLength > 0 {
|
||||||
if err := c.Bind(¶ms); err != nil {
|
if err := c.Bind(¶ms); err != nil {
|
||||||
// If binding fails, try to read as raw JSON
|
|
||||||
body := make(map[string]interface{})
|
body := make(map[string]interface{})
|
||||||
if err := c.Bind(&body); err == nil {
|
if err := c.Bind(&body); err == nil {
|
||||||
// Convert interface{} values to strings
|
|
||||||
params = make(map[string]string)
|
params = make(map[string]string)
|
||||||
for k, v := range body {
|
for k, v := range body {
|
||||||
if str, ok := v.(string); ok {
|
if str, ok := v.(string); ok {
|
||||||
params[k] = str
|
params[k] = str
|
||||||
} else {
|
} else {
|
||||||
// Convert non-string values to string
|
|
||||||
params[k] = fmt.Sprintf("%v", v)
|
params[k] = fmt.Sprintf("%v", v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If all binding fails, use empty params
|
|
||||||
params = make(map[string]string)
|
params = make(map[string]string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No body provided, use empty params
|
|
||||||
params = make(map[string]string)
|
params = make(map[string]string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find task by name
|
jobSvc := getJobService(app, c)
|
||||||
tasks := app.AgentJobService().ListTasks()
|
tasks := jobSvc.ListTasks()
|
||||||
var task *schema.Task
|
var task *schema.Task
|
||||||
for _, t := range tasks {
|
for _, t := range tasks {
|
||||||
if t.Name == name {
|
if t.Name == name {
|
||||||
|
|
@ -334,7 +307,7 @@ func ExecuteTaskByNameEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Task not found: " + name})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Task not found: " + name})
|
||||||
}
|
}
|
||||||
|
|
||||||
jobID, err := app.AgentJobService().ExecuteJob(task.ID, params, "api", nil)
|
jobID, err := jobSvc.ExecuteJob(task.ID, params, "api", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,38 @@ func skillsToResponses(skills []skilldomain.Skill) []skillResponse {
|
||||||
func ListSkillsEndpoint(app *application.Application) echo.HandlerFunc {
|
func ListSkillsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
skills, err := svc.ListSkills()
|
userID := getUserID(c)
|
||||||
|
skills, err := svc.ListSkillsForUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin cross-user aggregation
|
||||||
|
if wantsAllUsers(c) {
|
||||||
|
usm := svc.UserServicesManager()
|
||||||
|
if usm != nil {
|
||||||
|
userIDs, _ := usm.ListAllUserIDs()
|
||||||
|
userGroups := map[string]any{}
|
||||||
|
for _, uid := range userIDs {
|
||||||
|
if uid == userID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userSkills, err := svc.ListSkillsForUser(uid)
|
||||||
|
if err != nil || len(userSkills) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userGroups[uid] = map[string]any{"skills": skillsToResponses(userSkills)}
|
||||||
|
}
|
||||||
|
resp := map[string]any{
|
||||||
|
"skills": skillsToResponses(skills),
|
||||||
|
}
|
||||||
|
if len(userGroups) > 0 {
|
||||||
|
resp["user_groups"] = userGroups
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, skillsToResponses(skills))
|
return c.JSON(http.StatusOK, skillsToResponses(skills))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +83,8 @@ func ListSkillsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func GetSkillsConfigEndpoint(app *application.Application) echo.HandlerFunc {
|
func GetSkillsConfigEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
cfg := svc.GetSkillsConfig()
|
userID := getUserID(c)
|
||||||
|
cfg := svc.GetSkillsConfigForUser(userID)
|
||||||
return c.JSON(http.StatusOK, cfg)
|
return c.JSON(http.StatusOK, cfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -63,8 +92,9 @@ func GetSkillsConfigEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func SearchSkillsEndpoint(app *application.Application) echo.HandlerFunc {
|
func SearchSkillsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := getUserID(c)
|
||||||
query := c.QueryParam("q")
|
query := c.QueryParam("q")
|
||||||
skills, err := svc.SearchSkills(query)
|
skills, err := svc.SearchSkillsForUser(userID, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +105,7 @@ func SearchSkillsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func CreateSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
func CreateSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := getUserID(c)
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
|
@ -87,7 +118,7 @@ func CreateSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
if err := c.Bind(&payload); err != nil {
|
if err := c.Bind(&payload); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
skill, err := svc.CreateSkill(payload.Name, payload.Description, payload.Content, payload.License, payload.Compatibility, payload.AllowedTools, payload.Metadata)
|
skill, err := svc.CreateSkillForUser(userID, payload.Name, payload.Description, payload.Content, payload.License, payload.Compatibility, payload.AllowedTools, payload.Metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "already exists") {
|
if strings.Contains(err.Error(), "already exists") {
|
||||||
return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()})
|
||||||
|
|
@ -101,7 +132,8 @@ func CreateSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func GetSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
func GetSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
skill, err := svc.GetSkill(c.Param("name"))
|
userID := effectiveUserID(c)
|
||||||
|
skill, err := svc.GetSkillForUser(userID, c.Param("name"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -112,6 +144,7 @@ func GetSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func UpdateSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
func UpdateSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
|
|
@ -123,7 +156,7 @@ func UpdateSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
if err := c.Bind(&payload); err != nil {
|
if err := c.Bind(&payload); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
skill, err := svc.UpdateSkill(c.Param("name"), payload.Description, payload.Content, payload.License, payload.Compatibility, payload.AllowedTools, payload.Metadata)
|
skill, err := svc.UpdateSkillForUser(userID, c.Param("name"), payload.Description, payload.Content, payload.License, payload.Compatibility, payload.AllowedTools, payload.Metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
|
@ -137,7 +170,8 @@ func UpdateSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func DeleteSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
func DeleteSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
if err := svc.DeleteSkill(c.Param("name")); err != nil {
|
userID := effectiveUserID(c)
|
||||||
|
if err := svc.DeleteSkillForUser(userID, c.Param("name")); err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
|
@ -147,9 +181,9 @@ func DeleteSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func ExportSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
func ExportSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
// The wildcard param captures the path after /export/
|
userID := effectiveUserID(c)
|
||||||
name := c.Param("*")
|
name := c.Param("*")
|
||||||
data, err := svc.ExportSkill(name)
|
data, err := svc.ExportSkillForUser(userID, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -162,6 +196,7 @@ func ExportSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func ImportSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
func ImportSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := getUserID(c)
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "file required"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "file required"})
|
||||||
|
|
@ -175,7 +210,7 @@ func ImportSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
skill, err := svc.ImportSkill(data)
|
skill, err := svc.ImportSkillForUser(userID, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -188,7 +223,8 @@ func ImportSkillEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func ListSkillResourcesEndpoint(app *application.Application) echo.HandlerFunc {
|
func ListSkillResourcesEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
resources, skill, err := svc.ListSkillResources(c.Param("name"))
|
userID := effectiveUserID(c)
|
||||||
|
resources, skill, err := svc.ListSkillResourcesForUser(userID, c.Param("name"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -225,7 +261,8 @@ func ListSkillResourcesEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func GetSkillResourceEndpoint(app *application.Application) echo.HandlerFunc {
|
func GetSkillResourceEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
content, info, err := svc.GetSkillResource(c.Param("name"), c.Param("*"))
|
userID := effectiveUserID(c)
|
||||||
|
content, info, err := svc.GetSkillResourceForUser(userID, c.Param("name"), c.Param("*"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -245,6 +282,7 @@ func GetSkillResourceEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func CreateSkillResourceEndpoint(app *application.Application) echo.HandlerFunc {
|
func CreateSkillResourceEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := getUserID(c)
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "file is required"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "file is required"})
|
||||||
|
|
@ -262,7 +300,7 @@ func CreateSkillResourceEndpoint(app *application.Application) echo.HandlerFunc
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
if err := svc.CreateSkillResource(c.Param("name"), path, data); err != nil {
|
if err := svc.CreateSkillResourceForUser(userID, c.Param("name"), path, data); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusCreated, map[string]string{"path": path})
|
return c.JSON(http.StatusCreated, map[string]string{"path": path})
|
||||||
|
|
@ -272,13 +310,14 @@ func CreateSkillResourceEndpoint(app *application.Application) echo.HandlerFunc
|
||||||
func UpdateSkillResourceEndpoint(app *application.Application) echo.HandlerFunc {
|
func UpdateSkillResourceEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := getUserID(c)
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
if err := c.Bind(&payload); err != nil {
|
if err := c.Bind(&payload); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
if err := svc.UpdateSkillResource(c.Param("name"), c.Param("*"), payload.Content); err != nil {
|
if err := svc.UpdateSkillResourceForUser(userID, c.Param("name"), c.Param("*"), payload.Content); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
|
@ -288,7 +327,8 @@ func UpdateSkillResourceEndpoint(app *application.Application) echo.HandlerFunc
|
||||||
func DeleteSkillResourceEndpoint(app *application.Application) echo.HandlerFunc {
|
func DeleteSkillResourceEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
if err := svc.DeleteSkillResource(c.Param("name"), c.Param("*")); err != nil {
|
userID := getUserID(c)
|
||||||
|
if err := svc.DeleteSkillResourceForUser(userID, c.Param("name"), c.Param("*")); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
|
@ -300,7 +340,8 @@ func DeleteSkillResourceEndpoint(app *application.Application) echo.HandlerFunc
|
||||||
func ListGitReposEndpoint(app *application.Application) echo.HandlerFunc {
|
func ListGitReposEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
repos, err := svc.ListGitRepos()
|
userID := getUserID(c)
|
||||||
|
repos, err := svc.ListGitReposForUser(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -311,13 +352,14 @@ func ListGitReposEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func AddGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
func AddGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := getUserID(c)
|
||||||
var payload struct {
|
var payload struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
if err := c.Bind(&payload); err != nil {
|
if err := c.Bind(&payload); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
repo, err := svc.AddGitRepo(payload.URL)
|
repo, err := svc.AddGitRepoForUser(userID, payload.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -328,6 +370,7 @@ func AddGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func UpdateGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
func UpdateGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := getUserID(c)
|
||||||
var payload struct {
|
var payload struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
|
|
@ -335,7 +378,7 @@ func UpdateGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
if err := c.Bind(&payload); err != nil {
|
if err := c.Bind(&payload); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
repo, err := svc.UpdateGitRepo(c.Param("id"), payload.URL, payload.Enabled)
|
repo, err := svc.UpdateGitRepoForUser(userID, c.Param("id"), payload.URL, payload.Enabled)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
|
@ -349,7 +392,8 @@ func UpdateGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func DeleteGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
func DeleteGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
if err := svc.DeleteGitRepo(c.Param("id")); err != nil {
|
userID := getUserID(c)
|
||||||
|
if err := svc.DeleteGitRepoForUser(userID, c.Param("id")); err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -362,7 +406,8 @@ func DeleteGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func SyncGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
func SyncGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
if err := svc.SyncGitRepo(c.Param("id")); err != nil {
|
userID := getUserID(c)
|
||||||
|
if err := svc.SyncGitRepoForUser(userID, c.Param("id")); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusAccepted, map[string]string{"status": "syncing"})
|
return c.JSON(http.StatusAccepted, map[string]string{"status": "syncing"})
|
||||||
|
|
@ -372,7 +417,8 @@ func SyncGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func ToggleGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
func ToggleGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
repo, err := svc.ToggleGitRepo(c.Param("id"))
|
userID := getUserID(c)
|
||||||
|
repo, err := svc.ToggleGitRepoForUser(userID, c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/mudler/LocalAI/core/application"
|
"github.com/mudler/LocalAI/core/application"
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
"github.com/mudler/LocalAI/core/services"
|
"github.com/mudler/LocalAI/core/services"
|
||||||
"github.com/mudler/LocalAI/pkg/utils"
|
"github.com/mudler/LocalAI/pkg/utils"
|
||||||
"github.com/mudler/LocalAGI/core/state"
|
"github.com/mudler/LocalAGI/core/state"
|
||||||
|
|
@ -19,10 +20,42 @@ import (
|
||||||
agiServices "github.com/mudler/LocalAGI/services"
|
agiServices "github.com/mudler/LocalAGI/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// getUserID extracts the scoped user ID from the request context.
|
||||||
|
// Returns empty string when auth is not active (backward compat).
|
||||||
|
func getUserID(c echo.Context) string {
|
||||||
|
user := auth.GetUser(c)
|
||||||
|
if user == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return user.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAdminUser returns true if the authenticated user has admin role.
|
||||||
|
func isAdminUser(c echo.Context) bool {
|
||||||
|
user := auth.GetUser(c)
|
||||||
|
return user != nil && user.Role == auth.RoleAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
// wantsAllUsers returns true if the request has ?all_users=true and the user is admin.
|
||||||
|
func wantsAllUsers(c echo.Context) bool {
|
||||||
|
return c.QueryParam("all_users") == "true" && isAdminUser(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// effectiveUserID returns the user ID to scope operations to.
|
||||||
|
// SECURITY: Only admins may supply ?user_id=<id> to operate on another user's
|
||||||
|
// resources. Non-admin callers always get their own ID regardless of query params.
|
||||||
|
func effectiveUserID(c echo.Context) string {
|
||||||
|
if targetUID := c.QueryParam("user_id"); targetUID != "" && isAdminUser(c) {
|
||||||
|
return targetUID
|
||||||
|
}
|
||||||
|
return getUserID(c)
|
||||||
|
}
|
||||||
|
|
||||||
func ListAgentsEndpoint(app *application.Application) echo.HandlerFunc {
|
func ListAgentsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
statuses := svc.ListAgents()
|
userID := getUserID(c)
|
||||||
|
statuses := svc.ListAgentsForUser(userID)
|
||||||
agents := make([]string, 0, len(statuses))
|
agents := make([]string, 0, len(statuses))
|
||||||
for name := range statuses {
|
for name := range statuses {
|
||||||
agents = append(agents, name)
|
agents = append(agents, name)
|
||||||
|
|
@ -38,6 +71,22 @@ func ListAgentsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
if hubURL := svc.AgentHubURL(); hubURL != "" {
|
if hubURL := svc.AgentHubURL(); hubURL != "" {
|
||||||
resp["agent_hub_url"] = hubURL
|
resp["agent_hub_url"] = hubURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin cross-user aggregation
|
||||||
|
if wantsAllUsers(c) {
|
||||||
|
grouped := svc.ListAllAgentsGrouped()
|
||||||
|
userGroups := map[string]any{}
|
||||||
|
for uid, agentList := range grouped {
|
||||||
|
if uid == userID || uid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userGroups[uid] = map[string]any{"agents": agentList}
|
||||||
|
}
|
||||||
|
if len(userGroups) > 0 {
|
||||||
|
resp["user_groups"] = userGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, resp)
|
return c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -45,11 +94,12 @@ func ListAgentsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func CreateAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
func CreateAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := getUserID(c)
|
||||||
var cfg state.AgentConfig
|
var cfg state.AgentConfig
|
||||||
if err := c.Bind(&cfg); err != nil {
|
if err := c.Bind(&cfg); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
if err := svc.CreateAgent(&cfg); err != nil {
|
if err := svc.CreateAgentForUser(userID, &cfg); err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
|
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
|
||||||
|
|
@ -59,8 +109,9 @@ func CreateAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func GetAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
func GetAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
ag := svc.GetAgent(name)
|
ag := svc.GetAgentForUser(userID, name)
|
||||||
if ag == nil {
|
if ag == nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
|
||||||
}
|
}
|
||||||
|
|
@ -73,12 +124,13 @@ func GetAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func UpdateAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
func UpdateAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
var cfg state.AgentConfig
|
var cfg state.AgentConfig
|
||||||
if err := c.Bind(&cfg); err != nil {
|
if err := c.Bind(&cfg); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
if err := svc.UpdateAgent(name, &cfg); err != nil {
|
if err := svc.UpdateAgentForUser(userID, name, &cfg); err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -91,8 +143,9 @@ func UpdateAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func DeleteAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
func DeleteAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
if err := svc.DeleteAgent(name); err != nil {
|
if err := svc.DeleteAgentForUser(userID, name); err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
|
@ -102,8 +155,9 @@ func DeleteAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func GetAgentConfigEndpoint(app *application.Application) echo.HandlerFunc {
|
func GetAgentConfigEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
cfg := svc.GetAgentConfig(name)
|
cfg := svc.GetAgentConfigForUser(userID, name)
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +168,8 @@ func GetAgentConfigEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func PauseAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
func PauseAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
if err := svc.PauseAgent(c.Param("name")); err != nil {
|
userID := effectiveUserID(c)
|
||||||
|
if err := svc.PauseAgentForUser(userID, c.Param("name")); err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
|
@ -124,7 +179,8 @@ func PauseAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func ResumeAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
func ResumeAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
if err := svc.ResumeAgent(c.Param("name")); err != nil {
|
userID := effectiveUserID(c)
|
||||||
|
if err := svc.ResumeAgentForUser(userID, c.Param("name")); err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
|
@ -134,8 +190,9 @@ func ResumeAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func GetAgentStatusEndpoint(app *application.Application) echo.HandlerFunc {
|
func GetAgentStatusEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
history := svc.GetAgentStatus(name)
|
history := svc.GetAgentStatusForUser(userID, name)
|
||||||
if history == nil {
|
if history == nil {
|
||||||
history = &state.Status{ActionResults: []coreTypes.ActionState{}}
|
history = &state.Status{ActionResults: []coreTypes.ActionState{}}
|
||||||
}
|
}
|
||||||
|
|
@ -162,8 +219,9 @@ func GetAgentStatusEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func GetAgentObservablesEndpoint(app *application.Application) echo.HandlerFunc {
|
func GetAgentObservablesEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
history, err := svc.GetAgentObservables(name)
|
history, err := svc.GetAgentObservablesForUser(userID, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -177,8 +235,9 @@ func GetAgentObservablesEndpoint(app *application.Application) echo.HandlerFunc
|
||||||
func ClearAgentObservablesEndpoint(app *application.Application) echo.HandlerFunc {
|
func ClearAgentObservablesEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
if err := svc.ClearAgentObservables(name); err != nil {
|
if err := svc.ClearAgentObservablesForUser(userID, name); err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, map[string]any{"Name": name, "cleared": true})
|
return c.JSON(http.StatusOK, map[string]any{"Name": name, "cleared": true})
|
||||||
|
|
@ -188,6 +247,7 @@ func ClearAgentObservablesEndpoint(app *application.Application) echo.HandlerFun
|
||||||
func ChatWithAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
func ChatWithAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
var payload struct {
|
var payload struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
|
@ -199,7 +259,7 @@ func ChatWithAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
if message == "" {
|
if message == "" {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Message cannot be empty"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Message cannot be empty"})
|
||||||
}
|
}
|
||||||
messageID, err := svc.Chat(name, message)
|
messageID, err := svc.ChatForUser(userID, name, message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "not found") {
|
if strings.Contains(err.Error(), "not found") {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
|
|
@ -216,8 +276,9 @@ func ChatWithAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func AgentSSEEndpoint(app *application.Application) echo.HandlerFunc {
|
func AgentSSEEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
manager := svc.GetSSEManager(name)
|
manager := svc.GetSSEManagerForUser(userID, name)
|
||||||
if manager == nil {
|
if manager == nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Agent not found"})
|
||||||
}
|
}
|
||||||
|
|
@ -243,8 +304,9 @@ func GetAgentConfigMetaEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func ExportAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
func ExportAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := effectiveUserID(c)
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
data, err := svc.ExportAgent(name)
|
data, err := svc.ExportAgentForUser(userID, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
@ -256,6 +318,7 @@ func ExportAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
func ImportAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
func ImportAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
svc := app.AgentPoolService()
|
svc := app.AgentPoolService()
|
||||||
|
userID := getUserID(c)
|
||||||
|
|
||||||
// Try multipart form file first
|
// Try multipart form file first
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
|
|
@ -269,7 +332,7 @@ func ImportAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to read file"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "failed to read file"})
|
||||||
}
|
}
|
||||||
if err := svc.ImportAgent(data); err != nil {
|
if err := svc.ImportAgentForUser(userID, data); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
|
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
|
||||||
|
|
@ -284,7 +347,7 @@ func ImportAgentEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
if err := svc.ImportAgent(data); err != nil {
|
if err := svc.ImportAgentForUser(userID, data); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
|
return c.JSON(http.StatusCreated, map[string]string{"status": "ok"})
|
||||||
|
|
@ -358,10 +421,16 @@ func AgentFileEndpoint(app *application.Application) echo.HandlerFunc {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only serve files from the outputs subdirectory
|
// Determine the allowed outputs directory — scoped to the user when auth is active
|
||||||
outputsDir, _ := filepath.EvalSymlinks(filepath.Clean(svc.OutputsDir()))
|
allowedDir := svc.OutputsDir()
|
||||||
|
user := auth.GetUser(c)
|
||||||
|
if user != nil {
|
||||||
|
allowedDir = filepath.Join(allowedDir, user.ID)
|
||||||
|
}
|
||||||
|
|
||||||
if utils.InTrustedRoot(resolved, outputsDir) != nil {
|
allowedDirResolved, _ := filepath.EvalSymlinks(filepath.Clean(allowedDir))
|
||||||
|
|
||||||
|
if utils.InTrustedRoot(resolved, allowedDirResolved) != nil {
|
||||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "access denied"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,22 @@ package openai
|
||||||
import (
|
import (
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/mudler/LocalAI/core/config"
|
"github.com/mudler/LocalAI/core/config"
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
"github.com/mudler/LocalAI/core/schema"
|
"github.com/mudler/LocalAI/core/schema"
|
||||||
"github.com/mudler/LocalAI/core/services"
|
"github.com/mudler/LocalAI/core/services"
|
||||||
model "github.com/mudler/LocalAI/pkg/model"
|
model "github.com/mudler/LocalAI/pkg/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListModelsEndpoint is the OpenAI Models API endpoint https://platform.openai.com/docs/api-reference/models
|
// ListModelsEndpoint is the OpenAI Models API endpoint https://platform.openai.com/docs/api-reference/models
|
||||||
// @Summary List and describe the various models available in the API.
|
// @Summary List and describe the various models available in the API.
|
||||||
// @Success 200 {object} schema.ModelsDataResponse "Response"
|
// @Success 200 {object} schema.ModelsDataResponse "Response"
|
||||||
// @Router /v1/models [get]
|
// @Router /v1/models [get]
|
||||||
func ListModelsEndpoint(bcl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
|
func ListModelsEndpoint(bcl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, db ...*gorm.DB) echo.HandlerFunc {
|
||||||
|
var authDB *gorm.DB
|
||||||
|
if len(db) > 0 {
|
||||||
|
authDB = db[0]
|
||||||
|
}
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
// If blank, no filter is applied.
|
// If blank, no filter is applied.
|
||||||
filter := c.QueryParam("filter")
|
filter := c.QueryParam("filter")
|
||||||
|
|
@ -36,6 +42,26 @@ func ListModelsEndpoint(bcl *config.ModelConfigLoader, ml *model.ModelLoader, ap
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter models by user's allowlist if auth is enabled
|
||||||
|
if authDB != nil {
|
||||||
|
if user := auth.GetUser(c); user != nil && user.Role != auth.RoleAdmin {
|
||||||
|
perm, err := auth.GetCachedUserPermissions(c, authDB, user.ID)
|
||||||
|
if err == nil && perm.AllowedModels.Enabled {
|
||||||
|
allowed := map[string]bool{}
|
||||||
|
for _, m := range perm.AllowedModels.Models {
|
||||||
|
allowed[m] = true
|
||||||
|
}
|
||||||
|
filtered := make([]string, 0, len(modelNames))
|
||||||
|
for _, m := range modelNames {
|
||||||
|
if allowed[m] {
|
||||||
|
filtered = append(filtered, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
modelNames = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Map from a slice of names to a slice of OpenAIModel response objects
|
// Map from a slice of names to a slice of OpenAIModel response objects
|
||||||
dataModels := []schema.OpenAIModel{}
|
dataModels := []schema.OpenAIModel{}
|
||||||
for _, m := range modelNames {
|
for _, m := range modelNames {
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,16 @@ package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"github.com/emirpasic/gods/v2/queues/circularbuffer"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/emirpasic/gods/v2/queues/circularbuffer"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/mudler/LocalAI/core/application"
|
"github.com/mudler/LocalAI/core/application"
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
"github.com/mudler/xlog"
|
"github.com/mudler/xlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -33,6 +34,8 @@ type APIExchange struct {
|
||||||
Request APIExchangeRequest `json:"request"`
|
Request APIExchangeRequest `json:"request"`
|
||||||
Response APIExchangeResponse `json:"response"`
|
Response APIExchangeResponse `json:"response"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
UserName string `json:"user_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var traceBuffer *circularbuffer.Queue[APIExchange]
|
var traceBuffer *circularbuffer.Queue[APIExchange]
|
||||||
|
|
@ -147,6 +150,11 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
|
||||||
exchange.Error = handlerErr.Error()
|
exchange.Error = handlerErr.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user := auth.GetUser(c); user != nil {
|
||||||
|
exchange.UserID = user.ID
|
||||||
|
exchange.UserName = user.Name
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case logChan <- exchange:
|
case logChan <- exchange:
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
185
core/http/middleware/usage.go
Normal file
185
core/http/middleware/usage.go
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
|
"github.com/mudler/xlog"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
usageFlushInterval = 5 * time.Second
|
||||||
|
usageMaxPending = 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
// usageBatcher accumulates usage records and flushes them to the DB periodically.
|
||||||
|
type usageBatcher struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
pending []*auth.UsageRecord
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *usageBatcher) add(r *auth.UsageRecord) {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.pending = append(b.pending, r)
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *usageBatcher) flush() {
|
||||||
|
b.mu.Lock()
|
||||||
|
batch := b.pending
|
||||||
|
b.pending = nil
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
if len(batch) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.db.Create(&batch).Error; err != nil {
|
||||||
|
xlog.Error("Failed to flush usage batch", "count", len(batch), "error", err)
|
||||||
|
// Re-queue failed records with a cap to avoid unbounded growth
|
||||||
|
b.mu.Lock()
|
||||||
|
if len(b.pending) < usageMaxPending {
|
||||||
|
b.pending = append(batch, b.pending...)
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var batcher *usageBatcher
|
||||||
|
|
||||||
|
// InitUsageRecorder starts a background goroutine that periodically flushes
|
||||||
|
// accumulated usage records to the database.
|
||||||
|
func InitUsageRecorder(db *gorm.DB) {
|
||||||
|
if db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batcher = &usageBatcher{db: db}
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(usageFlushInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
batcher.flush()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// usageResponseBody is the minimal structure we need from the response JSON.
|
||||||
|
type usageResponseBody struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Usage *struct {
|
||||||
|
PromptTokens int64 `json:"prompt_tokens"`
|
||||||
|
CompletionTokens int64 `json:"completion_tokens"`
|
||||||
|
TotalTokens int64 `json:"total_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageMiddleware extracts token usage from OpenAI-compatible response JSON
|
||||||
|
// and records it per-user.
|
||||||
|
func UsageMiddleware(db *gorm.DB) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
if db == nil || batcher == nil {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Wrap response writer to capture body
|
||||||
|
resBody := new(bytes.Buffer)
|
||||||
|
origWriter := c.Response().Writer
|
||||||
|
mw := &bodyWriter{
|
||||||
|
ResponseWriter: origWriter,
|
||||||
|
body: resBody,
|
||||||
|
}
|
||||||
|
c.Response().Writer = mw
|
||||||
|
|
||||||
|
handlerErr := next(c)
|
||||||
|
|
||||||
|
// Restore original writer
|
||||||
|
c.Response().Writer = origWriter
|
||||||
|
|
||||||
|
// Only record on successful responses
|
||||||
|
if c.Response().Status < 200 || c.Response().Status >= 300 {
|
||||||
|
return handlerErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get authenticated user
|
||||||
|
user := auth.GetUser(c)
|
||||||
|
if user == nil {
|
||||||
|
return handlerErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse usage from response
|
||||||
|
responseBytes := resBody.Bytes()
|
||||||
|
if len(responseBytes) == 0 {
|
||||||
|
return handlerErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content type
|
||||||
|
ct := c.Response().Header().Get("Content-Type")
|
||||||
|
isJSON := ct == "" || ct == "application/json" || bytes.HasPrefix([]byte(ct), []byte("application/json"))
|
||||||
|
isSSE := bytes.HasPrefix([]byte(ct), []byte("text/event-stream"))
|
||||||
|
|
||||||
|
if !isJSON && !isSSE {
|
||||||
|
return handlerErr
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp usageResponseBody
|
||||||
|
if isSSE {
|
||||||
|
last, ok := lastSSEData(responseBytes)
|
||||||
|
if !ok {
|
||||||
|
return handlerErr
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(last, &resp); err != nil {
|
||||||
|
return handlerErr
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := json.Unmarshal(responseBytes, &resp); err != nil {
|
||||||
|
return handlerErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Usage == nil {
|
||||||
|
return handlerErr
|
||||||
|
}
|
||||||
|
|
||||||
|
record := &auth.UsageRecord{
|
||||||
|
UserID: user.ID,
|
||||||
|
UserName: user.Name,
|
||||||
|
Model: resp.Model,
|
||||||
|
Endpoint: c.Request().URL.Path,
|
||||||
|
PromptTokens: resp.Usage.PromptTokens,
|
||||||
|
CompletionTokens: resp.Usage.CompletionTokens,
|
||||||
|
TotalTokens: resp.Usage.TotalTokens,
|
||||||
|
Duration: time.Since(startTime).Milliseconds(),
|
||||||
|
CreatedAt: startTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
batcher.add(record)
|
||||||
|
|
||||||
|
return handlerErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastSSEData returns the payload of the last "data: " line whose content is not "[DONE]".
|
||||||
|
func lastSSEData(b []byte) ([]byte, bool) {
|
||||||
|
prefix := []byte("data: ")
|
||||||
|
var last []byte
|
||||||
|
for _, line := range bytes.Split(b, []byte("\n")) {
|
||||||
|
line = bytes.TrimRight(line, "\r")
|
||||||
|
if bytes.HasPrefix(line, prefix) {
|
||||||
|
payload := line[len(prefix):]
|
||||||
|
if !bytes.Equal(payload, []byte("[DONE]")) {
|
||||||
|
last = payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last, last != nil
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ test.describe('Navigation', () => {
|
||||||
test('/app shows home page with LocalAI title', async ({ page }) => {
|
test('/app shows home page with LocalAI title', async ({ page }) => {
|
||||||
await page.goto('/app')
|
await page.goto('/app')
|
||||||
await expect(page.locator('.sidebar')).toBeVisible()
|
await expect(page.locator('.sidebar')).toBeVisible()
|
||||||
await expect(page.getByRole('heading', { name: 'How can I help you today?' })).toBeVisible()
|
await expect(page.locator('.home-page')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('sidebar traces link navigates to /app/traces', async ({ page }) => {
|
test('sidebar traces link navigates to /app/traces', async ({ page }) => {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -29,6 +29,11 @@ export default function App() {
|
||||||
return () => window.removeEventListener('sidebar-collapse', handler)
|
return () => window.removeEventListener('sidebar-collapse', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Scroll to top on route change
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
}, [location.pathname])
|
||||||
|
|
||||||
const layoutClasses = [
|
const layoutClasses = [
|
||||||
'app-layout',
|
'app-layout',
|
||||||
isChatRoute ? 'app-layout-chat' : '',
|
isChatRoute ? 'app-layout-chat' : '',
|
||||||
|
|
@ -51,7 +56,9 @@ export default function App() {
|
||||||
<span className="mobile-title">LocalAI</span>
|
<span className="mobile-title">LocalAI</span>
|
||||||
</header>
|
</header>
|
||||||
<div className="main-content-inner">
|
<div className="main-content-inner">
|
||||||
<Outlet context={{ addToast }} />
|
<div className="page-transition" key={location.pathname}>
|
||||||
|
<Outlet context={{ addToast }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isChatRoute && (
|
{!isChatRoute && (
|
||||||
<footer className="app-footer">
|
<footer className="app-footer">
|
||||||
|
|
|
||||||
90
core/http/react-ui/src/components/ConfirmDialog.jsx
Normal file
90
core/http/react-ui/src/components/ConfirmDialog.jsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export default function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title = 'Confirm',
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
danger = false,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}) {
|
||||||
|
const dialogRef = useRef(null)
|
||||||
|
const confirmRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
confirmRef.current?.focus()
|
||||||
|
|
||||||
|
const dialog = dialogRef.current
|
||||||
|
if (!dialog) return
|
||||||
|
|
||||||
|
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
const getFocusable = () => dialog.querySelectorAll(focusableSelector)
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onCancel?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
const focusable = getFocusable()
|
||||||
|
if (focusable.length === 0) return
|
||||||
|
const first = focusable[0]
|
||||||
|
const last = focusable[focusable.length - 1]
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, onCancel])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
const titleId = 'confirm-dialog-title'
|
||||||
|
const bodyId = 'confirm-dialog-body'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="confirm-dialog-backdrop" onClick={onCancel}>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
className="confirm-dialog"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-describedby={message ? bodyId : undefined}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="confirm-dialog-header">
|
||||||
|
{danger && <i className="fas fa-exclamation-triangle confirm-dialog-danger-icon" />}
|
||||||
|
<span id={titleId} className="confirm-dialog-title">{title}</span>
|
||||||
|
</div>
|
||||||
|
{message && <div id={bodyId} className="confirm-dialog-body">{message}</div>}
|
||||||
|
<div className="confirm-dialog-actions">
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={onCancel}>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
ref={confirmRef}
|
||||||
|
className={`btn btn-sm ${danger ? 'btn-danger' : 'btn-primary'}`}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,22 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { apiUrl } from '../utils/basePath'
|
||||||
|
|
||||||
export default function LoadingSpinner({ size = 'md', className = '' }) {
|
export default function LoadingSpinner({ size = 'md', className = '' }) {
|
||||||
const sizeClass = size === 'sm' ? 'spinner-sm' : size === 'lg' ? 'spinner-lg' : 'spinner-md'
|
const sizeClass = size === 'sm' ? 'spinner-sm' : size === 'lg' ? 'spinner-lg' : 'spinner-md'
|
||||||
|
const [imgFailed, setImgFailed] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`spinner ${sizeClass} ${className}`}>
|
<div className={`spinner ${sizeClass} ${className}`}>
|
||||||
<div className="spinner-ring" />
|
{imgFailed ? (
|
||||||
|
<div className="spinner-ring" />
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={apiUrl('/static/logo.png')}
|
||||||
|
alt=""
|
||||||
|
className="spinner-logo"
|
||||||
|
onError={() => setImgFailed(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,66 @@
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import '../pages/auth.css'
|
||||||
|
|
||||||
export default function Modal({ onClose, children, maxWidth = '600px' }) {
|
export default function Modal({ onClose, children, maxWidth = '600px' }) {
|
||||||
|
const dialogRef = useRef(null)
|
||||||
|
const lastFocusRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
lastFocusRef.current = document.activeElement
|
||||||
|
|
||||||
|
// Focus trap
|
||||||
|
const dialog = dialogRef.current
|
||||||
|
if (!dialog) return
|
||||||
|
|
||||||
|
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
const getFocusable = () => dialog.querySelectorAll(focusableSelector)
|
||||||
|
|
||||||
|
const firstFocusable = getFocusable()[0]
|
||||||
|
firstFocusable?.focus()
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
const focusable = getFocusable()
|
||||||
|
if (focusable.length === 0) return
|
||||||
|
const first = focusable[0]
|
||||||
|
const last = focusable[focusable.length - 1]
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === first) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
lastFocusRef.current?.focus()
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
position: 'fixed', inset: 0, zIndex: 1000,
|
role="dialog"
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
aria-modal="true"
|
||||||
background: 'var(--color-modal-backdrop)', backdropFilter: 'blur(4px)',
|
className="modal-backdrop"
|
||||||
}} onClick={onClose}>
|
onClick={onClose}
|
||||||
<div style={{
|
>
|
||||||
background: 'var(--color-bg-secondary)',
|
<div
|
||||||
border: '1px solid var(--color-border-subtle)',
|
ref={dialogRef}
|
||||||
borderRadius: 'var(--radius-lg)',
|
className="modal-panel"
|
||||||
maxWidth, width: '90%', maxHeight: '80vh',
|
style={{ maxWidth }}
|
||||||
display: 'flex', flexDirection: 'column',
|
onClick={e => e.stopPropagation()}
|
||||||
overflow: 'auto',
|
>
|
||||||
}} onClick={e => e.stopPropagation()}>
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
10
core/http/react-ui/src/components/RequireAdmin.jsx
Normal file
10
core/http/react-ui/src/components/RequireAdmin.jsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
export default function RequireAdmin({ children }) {
|
||||||
|
const { isAdmin, authEnabled, user, loading } = useAuth()
|
||||||
|
if (loading) return null
|
||||||
|
if (authEnabled && !user) return <Navigate to="/login" replace />
|
||||||
|
if (!isAdmin) return <Navigate to="/app" replace />
|
||||||
|
return children
|
||||||
|
}
|
||||||
9
core/http/react-ui/src/components/RequireAuth.jsx
Normal file
9
core/http/react-ui/src/components/RequireAuth.jsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
export default function RequireAuth({ children }) {
|
||||||
|
const { authEnabled, user, loading } = useAuth()
|
||||||
|
if (loading) return null
|
||||||
|
if (authEnabled && !user) return <Navigate to="/login" replace />
|
||||||
|
return children
|
||||||
|
}
|
||||||
10
core/http/react-ui/src/components/RequireFeature.jsx
Normal file
10
core/http/react-ui/src/components/RequireFeature.jsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
export default function RequireFeature({ feature, children }) {
|
||||||
|
const { isAdmin, hasFeature, authEnabled, user, loading } = useAuth()
|
||||||
|
if (loading) return null
|
||||||
|
if (authEnabled && !user) return <Navigate to="/login" replace />
|
||||||
|
if (!isAdmin && !hasFeature(feature)) return <Navigate to="/app" replace />
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
@ -41,7 +41,10 @@ export default function ResourceCards({ metadata, onOpenArtifact, messageIndex,
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`resource-card resource-card-${item.type}`}
|
className={`resource-card resource-card-${item.type}`}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => onOpenArtifact && onOpenArtifact(item.id)}
|
onClick={() => onOpenArtifact && onOpenArtifact(item.id)}
|
||||||
|
onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && onOpenArtifact) { e.preventDefault(); onOpenArtifact(item.id) } }}
|
||||||
>
|
>
|
||||||
{item.type === 'image' ? (
|
{item.type === 'image' ? (
|
||||||
<img src={item.url} alt={item.title} className="resource-card-thumb" />
|
<img src={item.url} alt={item.title} className="resource-card-thumb" />
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,8 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
|
||||||
background: var(--color-bg-primary);
|
background: var(--color-bg-primary);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
box-shadow: var(--shadow-md);
|
||||||
|
animation: dropdownIn 120ms ease-out;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
.sms-item {
|
.sms-item {
|
||||||
|
|
@ -115,6 +116,8 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
|
||||||
`}</style>
|
`}</style>
|
||||||
<input
|
<input
|
||||||
className="input"
|
className="input"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value)
|
setQuery(e.target.value)
|
||||||
|
|
@ -128,7 +131,7 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
|
||||||
placeholder={loading ? 'Loading models...' : placeholder}
|
placeholder={loading ? 'Loading models...' : placeholder}
|
||||||
/>
|
/>
|
||||||
{open && !loading && (
|
{open && !loading && (
|
||||||
<div className="sms-dropdown" ref={listRef}>
|
<div className="sms-dropdown" ref={listRef} role="listbox">
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="sms-empty">
|
<div className="sms-empty">
|
||||||
{query ? 'No matching models — value will be used as-is' : 'No models available'}
|
{query ? 'No matching models — value will be used as-is' : 'No models available'}
|
||||||
|
|
@ -137,6 +140,8 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
|
||||||
filtered.map((m, i) => (
|
filtered.map((m, i) => (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
|
role="option"
|
||||||
|
aria-selected={m.id === value}
|
||||||
className={`sms-item${i === focusIndex ? ' sms-focused' : ''}${m.id === value ? ' sms-active' : ''}`}
|
className={`sms-item${i === focusIndex ? ' sms-focused' : ''}${m.id === value ? ' sms-active' : ''}`}
|
||||||
onMouseEnter={() => setFocusIndex(i)}
|
onMouseEnter={() => setFocusIndex(i)}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,8 @@ export default function SearchableSelect({
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
className="input"
|
className="input"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
onClick={() => { if (!disabled) { setOpen(!open); setQuery(''); setFocusIndex(-1) } }}
|
onClick={() => { if (!disabled) { setOpen(!open); setQuery(''); setFocusIndex(-1) } }}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '4px 8px', fontSize: '0.8125rem',
|
width: '100%', padding: '4px 8px', fontSize: '0.8125rem',
|
||||||
|
|
@ -112,7 +114,8 @@ export default function SearchableSelect({
|
||||||
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 100, marginTop: 4,
|
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 100, marginTop: 4,
|
||||||
minWidth: 200, maxHeight: 260, background: 'var(--color-bg-secondary)',
|
minWidth: 200, maxHeight: 260, background: 'var(--color-bg-secondary)',
|
||||||
border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)',
|
border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)',
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', display: 'flex', flexDirection: 'column',
|
boxShadow: 'var(--shadow-md)', display: 'flex', flexDirection: 'column',
|
||||||
|
animation: 'dropdownIn 120ms ease-out',
|
||||||
}}>
|
}}>
|
||||||
<div style={{ padding: '6px', borderBottom: '1px solid var(--color-border-subtle)' }}>
|
<div style={{ padding: '6px', borderBottom: '1px solid var(--color-border-subtle)' }}>
|
||||||
<input
|
<input
|
||||||
|
|
@ -126,9 +129,11 @@ export default function SearchableSelect({
|
||||||
style={{ width: '100%', padding: '4px 8px', fontSize: '0.8125rem' }}
|
style={{ width: '100%', padding: '4px 8px', fontSize: '0.8125rem' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div ref={listRef} style={{ overflowY: 'auto', maxHeight: 200 }}>
|
<div ref={listRef} role="listbox" style={{ overflowY: 'auto', maxHeight: 200 }}>
|
||||||
{allOption && (
|
{allOption && (
|
||||||
<div
|
<div
|
||||||
|
role="option"
|
||||||
|
aria-selected={!value}
|
||||||
onClick={() => select('')}
|
onClick={() => select('')}
|
||||||
style={itemStyle(!value, focusIndex === -1 && enterTarget?.type === 'all')}
|
style={itemStyle(!value, focusIndex === -1 && enterTarget?.type === 'all')}
|
||||||
onMouseEnter={() => setFocusIndex(-1)}
|
onMouseEnter={() => setFocusIndex(-1)}
|
||||||
|
|
@ -146,6 +151,8 @@ export default function SearchableSelect({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={o.value}
|
key={o.value}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isActive}
|
||||||
onClick={() => select(o.value)}
|
onClick={() => select(o.value)}
|
||||||
style={itemStyle(isActive, isFocused)}
|
style={itemStyle(isActive, isFocused)}
|
||||||
onMouseEnter={() => setFocusIndex(i)}
|
onMouseEnter={() => setFocusIndex(i)}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink, useNavigate } from 'react-router-dom'
|
||||||
import ThemeToggle from './ThemeToggle'
|
import ThemeToggle from './ThemeToggle'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { apiUrl } from '../utils/basePath'
|
import { apiUrl } from '../utils/basePath'
|
||||||
|
|
||||||
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
|
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
|
||||||
|
|
||||||
const mainItems = [
|
const mainItems = [
|
||||||
{ path: '/app', icon: 'fas fa-home', label: 'Home' },
|
{ path: '/app', icon: 'fas fa-home', label: 'Home' },
|
||||||
{ path: '/app/models', icon: 'fas fa-download', label: 'Install Models' },
|
{ path: '/app/models', icon: 'fas fa-download', label: 'Install Models', adminOnly: true },
|
||||||
{ path: '/app/chat', icon: 'fas fa-comments', label: 'Chat' },
|
{ path: '/app/chat', icon: 'fas fa-comments', label: 'Chat' },
|
||||||
{ path: '/app/image', icon: 'fas fa-image', label: 'Images' },
|
{ path: '/app/image', icon: 'fas fa-image', label: 'Images' },
|
||||||
{ path: '/app/video', icon: 'fas fa-video', label: 'Video' },
|
{ path: '/app/video', icon: 'fas fa-video', label: 'Video' },
|
||||||
{ path: '/app/tts', icon: 'fas fa-music', label: 'TTS' },
|
{ path: '/app/tts', icon: 'fas fa-music', label: 'TTS' },
|
||||||
{ path: '/app/sound', icon: 'fas fa-volume-high', label: 'Sound' },
|
{ path: '/app/sound', icon: 'fas fa-volume-high', label: 'Sound' },
|
||||||
{ path: '/app/talk', icon: 'fas fa-phone', label: 'Talk' },
|
{ path: '/app/talk', icon: 'fas fa-phone', label: 'Talk' },
|
||||||
|
{ path: '/app/usage', icon: 'fas fa-chart-bar', label: 'Usage', authOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const agentItems = [
|
const agentItems = [
|
||||||
|
|
@ -24,11 +26,12 @@ const agentItems = [
|
||||||
]
|
]
|
||||||
|
|
||||||
const systemItems = [
|
const systemItems = [
|
||||||
{ path: '/app/backends', icon: 'fas fa-server', label: 'Backends' },
|
{ path: '/app/users', icon: 'fas fa-users', label: 'Users', adminOnly: true, authOnly: true },
|
||||||
{ path: '/app/traces', icon: 'fas fa-chart-line', label: 'Traces' },
|
{ path: '/app/backends', icon: 'fas fa-server', label: 'Backends', adminOnly: true },
|
||||||
{ path: '/app/p2p', icon: 'fas fa-circle-nodes', label: 'Swarm' },
|
{ path: '/app/traces', icon: 'fas fa-chart-line', label: 'Traces', adminOnly: true },
|
||||||
{ path: '/app/manage', icon: 'fas fa-desktop', label: 'System' },
|
{ path: '/app/p2p', icon: 'fas fa-circle-nodes', label: 'Swarm', adminOnly: true },
|
||||||
{ path: '/app/settings', icon: 'fas fa-cog', label: 'Settings' },
|
{ path: '/app/manage', icon: 'fas fa-desktop', label: 'System', adminOnly: true },
|
||||||
|
{ path: '/app/settings', icon: 'fas fa-cog', label: 'Settings', adminOnly: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
function NavItem({ item, onClose, collapsed }) {
|
function NavItem({ item, onClose, collapsed }) {
|
||||||
|
|
@ -53,6 +56,8 @@ export default function Sidebar({ isOpen, onClose }) {
|
||||||
const [collapsed, setCollapsed] = useState(() => {
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
try { return localStorage.getItem(COLLAPSED_KEY) === 'true' } catch (_) { return false }
|
try { return localStorage.getItem(COLLAPSED_KEY) === 'true' } catch (_) { return false }
|
||||||
})
|
})
|
||||||
|
const { isAdmin, authEnabled, user, logout, hasFeature } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(apiUrl('/api/features')).then(r => r.json()).then(setFeatures).catch(() => {})
|
fetch(apiUrl('/api/features')).then(r => r.json()).then(setFeatures).catch(() => {})
|
||||||
|
|
@ -67,6 +72,18 @@ export default function Sidebar({ isOpen, onClose }) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibleMainItems = mainItems.filter(item => {
|
||||||
|
if (item.adminOnly && !isAdmin) return false
|
||||||
|
if (item.authOnly && !authEnabled) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleSystemItems = systemItems.filter(item => {
|
||||||
|
if (item.adminOnly && !isAdmin) return false
|
||||||
|
if (item.authOnly && !authEnabled) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isOpen && <div className="sidebar-overlay" onClick={onClose} />}
|
{isOpen && <div className="sidebar-overlay" onClick={onClose} />}
|
||||||
|
|
@ -89,24 +106,40 @@ export default function Sidebar({ isOpen, onClose }) {
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
{/* Main section */}
|
{/* Main section */}
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
{mainItems.map(item => (
|
{visibleMainItems.map(item => (
|
||||||
<NavItem key={item.path} item={item} onClose={onClose} collapsed={collapsed} />
|
<NavItem key={item.path} item={item} onClose={onClose} collapsed={collapsed} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agents section */}
|
{/* Agents section (per-feature permissions) */}
|
||||||
{features.agents !== false && (
|
{features.agents !== false && (() => {
|
||||||
<div className="sidebar-section">
|
const featureMap = {
|
||||||
<div className="sidebar-section-title">Agents</div>
|
'/app/agents': 'agents',
|
||||||
{agentItems.filter(item => !item.feature || features[item.feature] !== false).map(item => (
|
'/app/skills': 'skills',
|
||||||
<NavItem key={item.path} item={item} onClose={onClose} collapsed={collapsed} />
|
'/app/collections': 'collections',
|
||||||
))}
|
'/app/agent-jobs': 'mcp_jobs',
|
||||||
</div>
|
}
|
||||||
)}
|
const visibleAgentItems = agentItems.filter(item => {
|
||||||
|
if (item.feature && features[item.feature] === false) return false
|
||||||
|
const featureName = featureMap[item.path]
|
||||||
|
return featureName ? hasFeature(featureName) : isAdmin
|
||||||
|
})
|
||||||
|
if (visibleAgentItems.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<div className="sidebar-section-title">Agents</div>
|
||||||
|
{visibleAgentItems.map(item => (
|
||||||
|
<NavItem key={item.path} item={item} onClose={onClose} collapsed={collapsed} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* System section */}
|
{/* System section */}
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section-title">System</div>
|
{visibleSystemItems.length > 0 && (
|
||||||
|
<div className="sidebar-section-title">System</div>
|
||||||
|
)}
|
||||||
<a
|
<a
|
||||||
href={apiUrl('/swagger/index.html')}
|
href={apiUrl('/swagger/index.html')}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -118,7 +151,7 @@ export default function Sidebar({ isOpen, onClose }) {
|
||||||
<span className="nav-label">API</span>
|
<span className="nav-label">API</span>
|
||||||
<i className="fas fa-external-link-alt nav-external" />
|
<i className="fas fa-external-link-alt nav-external" />
|
||||||
</a>
|
</a>
|
||||||
{systemItems.map(item => (
|
{visibleSystemItems.map(item => (
|
||||||
<NavItem key={item.path} item={item} onClose={onClose} collapsed={collapsed} />
|
<NavItem key={item.path} item={item} onClose={onClose} collapsed={collapsed} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -126,6 +159,25 @@ export default function Sidebar({ isOpen, onClose }) {
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
|
{authEnabled && user && (
|
||||||
|
<div className="sidebar-user" title={collapsed ? (user.name || user.email) : undefined}>
|
||||||
|
<button
|
||||||
|
className="sidebar-user-link"
|
||||||
|
onClick={() => { navigate('/app/account'); onClose?.() }}
|
||||||
|
title="Account settings"
|
||||||
|
>
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img src={user.avatarUrl} alt="" className="sidebar-user-avatar" />
|
||||||
|
) : (
|
||||||
|
<i className="fas fa-user-circle sidebar-user-avatar-icon" />
|
||||||
|
)}
|
||||||
|
<span className="nav-label sidebar-user-name">{user.name || user.email}</span>
|
||||||
|
</button>
|
||||||
|
<button className="sidebar-logout-btn" onClick={logout} title="Logout">
|
||||||
|
<i className="fas fa-sign-out-alt" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<button
|
<button
|
||||||
className="sidebar-collapse-btn"
|
className="sidebar-collapse-btn"
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,20 @@ export function useToast() {
|
||||||
setToasts(prev => [...prev, { id, message, type }])
|
setToasts(prev => [...prev, { id, message, type }])
|
||||||
if (duration > 0) {
|
if (duration > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setToasts(prev => prev.filter(t => t.id !== id))
|
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t))
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id))
|
||||||
|
}, 150)
|
||||||
}, duration)
|
}, duration)
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const removeToast = useCallback((id) => {
|
const removeToast = useCallback((id) => {
|
||||||
setToasts(prev => prev.filter(t => t.id !== id))
|
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t))
|
||||||
|
setTimeout(() => {
|
||||||
|
setToasts(prev => prev.filter(t => t.id !== id))
|
||||||
|
}, 150)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { toasts, addToast, removeToast }
|
return { toasts, addToast, removeToast }
|
||||||
|
|
@ -39,7 +45,7 @@ const colorMap = {
|
||||||
|
|
||||||
export function ToastContainer({ toasts, removeToast }) {
|
export function ToastContainer({ toasts, removeToast }) {
|
||||||
return (
|
return (
|
||||||
<div className="toast-container">
|
<div className="toast-container" aria-live="polite" role="status">
|
||||||
{toasts.map(toast => (
|
{toasts.map(toast => (
|
||||||
<ToastItem key={toast.id} toast={toast} onRemove={removeToast} />
|
<ToastItem key={toast.id} toast={toast} onRemove={removeToast} />
|
||||||
))}
|
))}
|
||||||
|
|
@ -60,10 +66,10 @@ function ToastItem({ toast, onRemove }) {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={`toast ${colorMap[toast.type] || 'toast-info'}`}>
|
<div ref={ref} className={`toast ${colorMap[toast.type] || 'toast-info'} ${toast.exiting ? 'toast-exit' : ''}`}>
|
||||||
<i className={`fas ${iconMap[toast.type] || 'fa-circle-info'}`} />
|
<i className={`fas ${iconMap[toast.type] || 'fa-circle-info'}`} />
|
||||||
<span>{toast.message}</span>
|
<span>{toast.message}</span>
|
||||||
<button onClick={() => onRemove(toast.id)} className="toast-close">
|
<button onClick={() => onRemove(toast.id)} className="toast-close" aria-label="Dismiss notification">
|
||||||
<i className="fas fa-xmark" />
|
<i className="fas fa-xmark" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
170
core/http/react-ui/src/components/UserGroupSection.jsx
Normal file
170
core/http/react-ui/src/components/UserGroupSection.jsx
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserGroupSection — collapsible section showing other users' resources.
|
||||||
|
*
|
||||||
|
* Props:
|
||||||
|
* title — e.g. "Other Users' Agents"
|
||||||
|
* userGroups — { [userId]: { agents: [...], skills: [...], etc } }
|
||||||
|
* userMap — { [userId]: { name, email, avatarUrl } }
|
||||||
|
* currentUserId — current user's ID (excluded from display)
|
||||||
|
* renderGroup — (items, userId) => JSX — renders the items for one user
|
||||||
|
* itemKey — key in the group object to count items (e.g. "agents", "skills")
|
||||||
|
*/
|
||||||
|
export default function UserGroupSection({ title, userGroups, userMap, currentUserId, renderGroup, itemKey }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
if (!userGroups || Object.keys(userGroups).length === 0) return null
|
||||||
|
|
||||||
|
const userIds = Object.keys(userGroups).filter(id => id !== currentUserId)
|
||||||
|
if (userIds.length === 0) return null
|
||||||
|
|
||||||
|
const totalUsers = userIds.length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 'var(--spacing-lg)' }}>
|
||||||
|
<style>{`
|
||||||
|
.ugs-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
border-top: 1px solid var(--color-border-subtle);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.ugs-header:hover { opacity: 0.8; }
|
||||||
|
.ugs-chevron {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.ugs-chevron.open { transform: rotate(90deg); }
|
||||||
|
.ugs-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
.ugs-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.ugs-content {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
margin-top: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
.ugs-user-section {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
.ugs-user-section:last-child { margin-bottom: 0; }
|
||||||
|
.ugs-user-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ugs-avatar {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ugs-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.ugs-user-name {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.ugs-user-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="ugs-header"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(v => !v) } }}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<i className={`fas fa-chevron-right ugs-chevron ${open ? 'open' : ''}`} />
|
||||||
|
<span className="ugs-title">{title}</span>
|
||||||
|
<span className="ugs-badge">{totalUsers} user{totalUsers !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="ugs-content">
|
||||||
|
{userIds.map(uid => {
|
||||||
|
const user = userMap[uid] || {}
|
||||||
|
const displayName = user.name || user.email || uid.slice(0, 8) + '...'
|
||||||
|
const initials = (displayName[0] || '?').toUpperCase()
|
||||||
|
const group = userGroups[uid]
|
||||||
|
const items = itemKey ? group[itemKey] : group
|
||||||
|
const count = Array.isArray(items) ? items.length : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserSubSection
|
||||||
|
key={uid}
|
||||||
|
uid={uid}
|
||||||
|
displayName={displayName}
|
||||||
|
initials={initials}
|
||||||
|
avatarUrl={user.avatarUrl}
|
||||||
|
count={count}
|
||||||
|
itemKey={itemKey}
|
||||||
|
>
|
||||||
|
{renderGroup(items, uid)}
|
||||||
|
</UserSubSection>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserSubSection({ uid, displayName, initials, avatarUrl, count, itemKey, children }) {
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ugs-user-section">
|
||||||
|
<div
|
||||||
|
className="ugs-user-header"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(v => !v) } }}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<i className={`fas fa-chevron-right ugs-chevron ${open ? 'open' : ''}`} style={{ fontSize: '0.625rem' }} />
|
||||||
|
<div className="ugs-avatar">
|
||||||
|
{avatarUrl ? <img src={avatarUrl} alt="" /> : initials}
|
||||||
|
</div>
|
||||||
|
<span className="ugs-user-name">{displayName}</span>
|
||||||
|
<span className="ugs-user-count">
|
||||||
|
{count} {itemKey || 'item'}{count !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{open && children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
75
core/http/react-ui/src/context/AuthContext.jsx
Normal file
75
core/http/react-ui/src/context/AuthContext.jsx
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
import { apiUrl } from '../utils/basePath'
|
||||||
|
|
||||||
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [state, setState] = useState({
|
||||||
|
loading: true,
|
||||||
|
authEnabled: false,
|
||||||
|
user: null,
|
||||||
|
permissions: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchStatus = () => {
|
||||||
|
return fetch(apiUrl('/api/auth/status'))
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const user = data.user || null
|
||||||
|
const permissions = user?.permissions || {}
|
||||||
|
setState({
|
||||||
|
loading: false,
|
||||||
|
authEnabled: data.authEnabled || false,
|
||||||
|
user,
|
||||||
|
permissions,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setState({ loading: false, authEnabled: false, user: null, permissions: {} })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch(apiUrl('/api/auth/logout'), { method: 'POST' })
|
||||||
|
} catch (_) { /* ignore */ }
|
||||||
|
// Clear cookies
|
||||||
|
document.cookie = 'session=; path=/; max-age=-1'
|
||||||
|
document.cookie = 'token=; path=/; max-age=-1'
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = () => fetchStatus()
|
||||||
|
|
||||||
|
const hasFeature = (name) => {
|
||||||
|
if (state.user?.role === 'admin' || !state.authEnabled) return true
|
||||||
|
return !!state.permissions[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
loading: state.loading,
|
||||||
|
authEnabled: state.authEnabled,
|
||||||
|
user: state.user,
|
||||||
|
permissions: state.permissions,
|
||||||
|
isAdmin: state.user?.role === 'admin' || !state.authEnabled,
|
||||||
|
hasFeature,
|
||||||
|
logout,
|
||||||
|
refresh,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext)
|
||||||
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,15 @@ import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
|
||||||
const ThemeContext = createContext()
|
const ThemeContext = createContext()
|
||||||
|
|
||||||
|
function getInitialTheme() {
|
||||||
|
const stored = localStorage.getItem('localai-theme')
|
||||||
|
if (stored) return stored
|
||||||
|
if (window.matchMedia?.('(prefers-color-scheme: light)').matches) return 'light'
|
||||||
|
return 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
export function ThemeProvider({ children }) {
|
export function ThemeProvider({ children }) {
|
||||||
const [theme, setTheme] = useState(() => {
|
const [theme, setTheme] = useState(getInitialTheme)
|
||||||
return localStorage.getItem('localai-theme') || 'dark'
|
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', theme)
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
|
|
|
||||||
11
core/http/react-ui/src/hooks/useOperations.js
vendored
11
core/http/react-ui/src/hooks/useOperations.js
vendored
|
|
@ -1,16 +1,22 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { operationsApi } from '../utils/api'
|
import { operationsApi } from '../utils/api'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
export function useOperations(pollInterval = 1000) {
|
export function useOperations(pollInterval = 1000) {
|
||||||
const [operations, setOperations] = useState([])
|
const [operations, setOperations] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const intervalRef = useRef(null)
|
const intervalRef = useRef(null)
|
||||||
|
const { isAdmin } = useAuth()
|
||||||
|
|
||||||
const previousCountRef = useRef(0)
|
const previousCountRef = useRef(0)
|
||||||
const onAllCompleteRef = useRef(null)
|
const onAllCompleteRef = useRef(null)
|
||||||
|
|
||||||
const fetchOperations = useCallback(async () => {
|
const fetchOperations = useCallback(async () => {
|
||||||
|
if (!isAdmin) {
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const data = await operationsApi.list()
|
const data = await operationsApi.list()
|
||||||
const ops = data?.operations || (Array.isArray(data) ? data : [])
|
const ops = data?.operations || (Array.isArray(data) ? data : [])
|
||||||
|
|
@ -32,7 +38,7 @@ export function useOperations(pollInterval = 1000) {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isAdmin])
|
||||||
|
|
||||||
const cancelOperation = useCallback(async (jobID) => {
|
const cancelOperation = useCallback(async (jobID) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -57,12 +63,13 @@ export function useOperations(pollInterval = 1000) {
|
||||||
}, [operations, fetchOperations])
|
}, [operations, fetchOperations])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isAdmin) return
|
||||||
fetchOperations()
|
fetchOperations()
|
||||||
intervalRef.current = setInterval(fetchOperations, pollInterval)
|
intervalRef.current = setInterval(fetchOperations, pollInterval)
|
||||||
return () => {
|
return () => {
|
||||||
if (intervalRef.current) clearInterval(intervalRef.current)
|
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||||
}
|
}
|
||||||
}, [fetchOperations, pollInterval])
|
}, [fetchOperations, pollInterval, isAdmin])
|
||||||
|
|
||||||
// Allow callers to register a callback for when all operations finish
|
// Allow callers to register a callback for when all operations finish
|
||||||
const onAllComplete = useCallback((cb) => {
|
const onAllComplete = useCallback((cb) => {
|
||||||
|
|
|
||||||
29
core/http/react-ui/src/hooks/useUserMap.js
vendored
Normal file
29
core/http/react-ui/src/hooks/useUserMap.js
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { adminUsersApi } from '../utils/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that fetches all users and returns a map of userId -> { name, email, avatarUrl }.
|
||||||
|
* Only fetches when the current user is admin and auth is enabled.
|
||||||
|
*/
|
||||||
|
export function useUserMap() {
|
||||||
|
const { isAdmin, authEnabled } = useAuth()
|
||||||
|
const [userMap, setUserMap] = useState({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin || !authEnabled) return
|
||||||
|
let cancelled = false
|
||||||
|
adminUsersApi.list().then(data => {
|
||||||
|
if (cancelled) return
|
||||||
|
const users = Array.isArray(data) ? data : (data?.users || [])
|
||||||
|
const map = {}
|
||||||
|
for (const u of users) {
|
||||||
|
map[u.id] = { name: u.name || u.email || u.id, email: u.email, avatarUrl: u.avatar_url }
|
||||||
|
}
|
||||||
|
setUserMap(map)
|
||||||
|
}).catch(() => {})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [isAdmin, authEnabled])
|
||||||
|
|
||||||
|
return userMap
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@ import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { RouterProvider } from 'react-router-dom'
|
import { RouterProvider } from 'react-router-dom'
|
||||||
import { ThemeProvider } from './contexts/ThemeContext'
|
import { ThemeProvider } from './contexts/ThemeContext'
|
||||||
|
import { AuthProvider } from './context/AuthContext'
|
||||||
import { router } from './router'
|
import { router } from './router'
|
||||||
import '@fortawesome/fontawesome-free/css/all.min.css'
|
import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
@ -11,7 +12,9 @@ import './App.css'
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<RouterProvider router={router} />
|
<AuthProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</AuthProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
448
core/http/react-ui/src/pages/Account.jsx
Normal file
448
core/http/react-ui/src/pages/Account.jsx
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useOutletContext } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { apiKeysApi, profileApi } from '../utils/api'
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner'
|
||||||
|
import SettingRow from '../components/SettingRow'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
import './auth.css'
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
if (!d) return '-'
|
||||||
|
return new Date(d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'profile', icon: 'fa-user', label: 'Profile' },
|
||||||
|
{ id: 'security', icon: 'fa-lock', label: 'Security' },
|
||||||
|
{ id: 'apikeys', icon: 'fa-key', label: 'API Keys' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function ProfileTab({ addToast }) {
|
||||||
|
const { user, refresh } = useAuth()
|
||||||
|
const [name, setName] = useState(user?.name || '')
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl || '')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => { if (user?.name) setName(user.name) }, [user?.name])
|
||||||
|
useEffect(() => { setAvatarUrl(user?.avatarUrl || '') }, [user?.avatarUrl])
|
||||||
|
|
||||||
|
const hasChanges = (name.trim() && name.trim() !== user?.name) || (avatarUrl.trim() !== (user?.avatarUrl || ''))
|
||||||
|
|
||||||
|
const handleSave = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!name.trim() || !hasChanges) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await profileApi.updateProfile(name.trim(), avatarUrl.trim())
|
||||||
|
addToast('Profile updated', 'success')
|
||||||
|
refresh()
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to update profile: ${err.message}`, 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* User info header */}
|
||||||
|
<div className="account-user-header">
|
||||||
|
<div className="account-avatar-frame">
|
||||||
|
{user?.avatarUrl ? (
|
||||||
|
<img src={user.avatarUrl} alt="" className="user-avatar--lg" />
|
||||||
|
) : (
|
||||||
|
<i className="fas fa-user account-avatar-icon" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="account-user-meta">
|
||||||
|
<div className="account-user-email">{user?.email}</div>
|
||||||
|
<div className="account-user-badges">
|
||||||
|
<span className={`role-badge ${user?.role === 'admin' ? 'role-badge-admin' : 'role-badge-user'}`}>
|
||||||
|
{user?.role}
|
||||||
|
</span>
|
||||||
|
<span className="provider-tag">
|
||||||
|
{user?.provider || 'local'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile form */}
|
||||||
|
<form onSubmit={handleSave}>
|
||||||
|
<div className="card">
|
||||||
|
<SettingRow label="Display name" description="Your public display name">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input account-input-sm"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
disabled={saving}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Avatar URL" description="URL to your profile picture">
|
||||||
|
<div className="account-input-row">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
className="input account-input-sm"
|
||||||
|
value={avatarUrl}
|
||||||
|
onChange={(e) => setAvatarUrl(e.target.value)}
|
||||||
|
disabled={saving}
|
||||||
|
maxLength={512}
|
||||||
|
placeholder="https://example.com/avatar.png"
|
||||||
|
/>
|
||||||
|
{avatarUrl.trim() && (
|
||||||
|
<img
|
||||||
|
src={avatarUrl.trim()}
|
||||||
|
alt="preview"
|
||||||
|
className="account-avatar-preview"
|
||||||
|
onError={(e) => { e.target.style.display = 'none' }}
|
||||||
|
onLoad={(e) => { e.target.style.display = 'block' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={saving || !name.trim() || !hasChanges}
|
||||||
|
>
|
||||||
|
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> Save</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecurityTab({ addToast }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const isLocal = user?.provider === 'local'
|
||||||
|
|
||||||
|
const [currentPw, setCurrentPw] = useState('')
|
||||||
|
const [newPw, setNewPw] = useState('')
|
||||||
|
const [confirmPw, setConfirmPw] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (newPw !== confirmPw) {
|
||||||
|
addToast('Passwords do not match', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPw.length < 8) {
|
||||||
|
addToast('New password must be at least 8 characters', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await profileApi.changePassword(currentPw, newPw)
|
||||||
|
addToast('Password changed', 'success')
|
||||||
|
setCurrentPw('')
|
||||||
|
setNewPw('')
|
||||||
|
setConfirmPw('')
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err.message, 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLocal) {
|
||||||
|
return (
|
||||||
|
<div className="card empty-icon-block">
|
||||||
|
<i className="fas fa-shield-halved" />
|
||||||
|
<div className="empty-icon-block-text">
|
||||||
|
Password management is not available for {user?.provider || 'OAuth'} accounts.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="card">
|
||||||
|
<SettingRow label="Current password" description="Enter your existing password to verify your identity">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input account-input-sm"
|
||||||
|
value={currentPw}
|
||||||
|
onChange={(e) => setCurrentPw(e.target.value)}
|
||||||
|
placeholder="Current password"
|
||||||
|
disabled={saving}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="New password" description="Must be at least 8 characters">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input account-input-sm"
|
||||||
|
value={newPw}
|
||||||
|
onChange={(e) => setNewPw(e.target.value)}
|
||||||
|
placeholder="New password"
|
||||||
|
minLength={8}
|
||||||
|
disabled={saving}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow label="Confirm password" description="Re-enter your new password">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input account-input-sm"
|
||||||
|
value={confirmPw}
|
||||||
|
onChange={(e) => setConfirmPw(e.target.value)}
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
disabled={saving}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</div>
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={saving || !currentPw || !newPw || !confirmPw}
|
||||||
|
>
|
||||||
|
{saving ? <><LoadingSpinner size="sm" /> Changing...</> : 'Change password'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApiKeysTab({ addToast }) {
|
||||||
|
const [keys, setKeys] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [newKeyName, setNewKeyName] = useState('')
|
||||||
|
const [newKeyPlaintext, setNewKeyPlaintext] = useState(null)
|
||||||
|
const [revokingId, setRevokingId] = useState(null)
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
|
|
||||||
|
const fetchKeys = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await apiKeysApi.list()
|
||||||
|
setKeys(data.keys || [])
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to load API keys: ${err.message}`, 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [addToast])
|
||||||
|
|
||||||
|
useEffect(() => { fetchKeys() }, [fetchKeys])
|
||||||
|
|
||||||
|
const handleCreate = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!newKeyName.trim()) return
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
const data = await apiKeysApi.create(newKeyName.trim())
|
||||||
|
setNewKeyPlaintext(data.key)
|
||||||
|
setNewKeyName('')
|
||||||
|
await fetchKeys()
|
||||||
|
addToast('API key created', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to create API key: ${err.message}`, 'error')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRevoke = async (id, name) => {
|
||||||
|
setConfirmDialog({
|
||||||
|
title: 'Revoke API Key',
|
||||||
|
message: `Revoke API key "${name}"? This cannot be undone.`,
|
||||||
|
confirmLabel: 'Revoke',
|
||||||
|
danger: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setConfirmDialog(null)
|
||||||
|
setRevokingId(id)
|
||||||
|
try {
|
||||||
|
await apiKeysApi.revoke(id)
|
||||||
|
setKeys(prev => prev.filter(k => k.id !== id))
|
||||||
|
addToast('API key revoked', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to revoke API key: ${err.message}`, 'error')
|
||||||
|
} finally {
|
||||||
|
setRevokingId(null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = (text) => {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(
|
||||||
|
() => addToast('Copied to clipboard', 'success'),
|
||||||
|
() => fallbackCopy(text),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
fallbackCopy(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackCopy = (text) => {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = text
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.opacity = '0'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
try {
|
||||||
|
document.execCommand('copy')
|
||||||
|
addToast('Copied to clipboard', 'success')
|
||||||
|
} catch (_) {
|
||||||
|
addToast('Failed to copy', 'error')
|
||||||
|
}
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Create key form */}
|
||||||
|
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
|
||||||
|
<form onSubmit={handleCreate}>
|
||||||
|
<SettingRow label="Create API key" description="Generate a key for programmatic access">
|
||||||
|
<div className="account-input-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input account-input-xs"
|
||||||
|
placeholder="Key name (e.g. my-app)"
|
||||||
|
value={newKeyName}
|
||||||
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
|
disabled={creating}
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn btn-primary btn-sm" disabled={creating || !newKeyName.trim()}>
|
||||||
|
{creating ? <LoadingSpinner size="sm" /> : <><i className="fas fa-plus" /> Create</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Newly created key banner */}
|
||||||
|
{newKeyPlaintext && (
|
||||||
|
<div className="new-key-banner">
|
||||||
|
<div className="new-key-banner-header">
|
||||||
|
<i className="fas fa-triangle-exclamation" />
|
||||||
|
Copy now — this key won't be shown again
|
||||||
|
</div>
|
||||||
|
<div className="new-key-banner-body">
|
||||||
|
<code className="new-key-value">
|
||||||
|
{newKeyPlaintext}
|
||||||
|
</code>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => copyToClipboard(newKeyPlaintext)}>
|
||||||
|
<i className="fas fa-copy" />
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => setNewKeyPlaintext(null)}>
|
||||||
|
<i className="fas fa-times" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keys list */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="auth-loading">
|
||||||
|
<LoadingSpinner size="sm" />
|
||||||
|
</div>
|
||||||
|
) : keys.length === 0 ? (
|
||||||
|
<div className="card empty-icon-block">
|
||||||
|
<i className="fas fa-key" />
|
||||||
|
<div className="empty-icon-block-text">
|
||||||
|
No API keys yet. Create one above to get programmatic access.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card">
|
||||||
|
{keys.map((k) => (
|
||||||
|
<div key={k.id} className="apikey-row">
|
||||||
|
<i className="fas fa-key apikey-icon" />
|
||||||
|
<div className="apikey-info">
|
||||||
|
<div className="apikey-name">{k.name}</div>
|
||||||
|
<div className="apikey-details">
|
||||||
|
{k.keyPrefix}... · {formatDate(k.createdAt)}
|
||||||
|
{k.lastUsed && <> · last used {formatDate(k.lastUsed)}</>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm apikey-revoke-btn"
|
||||||
|
onClick={() => handleRevoke(k.id, k.name)}
|
||||||
|
disabled={revokingId === k.id}
|
||||||
|
title="Revoke key"
|
||||||
|
>
|
||||||
|
{revokingId === k.id ? <LoadingSpinner size="sm" /> : <i className="fas fa-trash" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDialog}
|
||||||
|
title={confirmDialog?.title}
|
||||||
|
message={confirmDialog?.message}
|
||||||
|
confirmLabel={confirmDialog?.confirmLabel}
|
||||||
|
danger={confirmDialog?.danger}
|
||||||
|
onConfirm={confirmDialog?.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Account() {
|
||||||
|
const { addToast } = useOutletContext()
|
||||||
|
const { authEnabled, user } = useAuth()
|
||||||
|
const [activeTab, setActiveTab] = useState('profile')
|
||||||
|
|
||||||
|
if (!authEnabled) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon"><i className="fas fa-user-gear" /></div>
|
||||||
|
<h2 className="empty-state-title">Account unavailable</h2>
|
||||||
|
<p className="empty-state-text">Authentication must be enabled to manage your account.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter tabs: hide security tab for OAuth-only users
|
||||||
|
const isLocal = user?.provider === 'local'
|
||||||
|
const visibleTabs = isLocal ? TABS : TABS.filter(t => t.id !== 'security')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page account-page">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="page-header">
|
||||||
|
<h1 className="page-title">Account</h1>
|
||||||
|
<p className="page-subtitle">Profile, credentials, and API keys</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="auth-tab-bar auth-tab-bar--flush">
|
||||||
|
{visibleTabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`auth-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
<i className={`fas ${tab.icon} auth-tab-icon`} />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{activeTab === 'profile' && <ProfileTab addToast={addToast} />}
|
||||||
|
{activeTab === 'security' && <SecurityTab addToast={addToast} />}
|
||||||
|
{activeTab === 'apikeys' && <ApiKeysTab addToast={addToast} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||||
import { useParams, useNavigate, useOutletContext } from 'react-router-dom'
|
import { useParams, useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
|
||||||
import { agentsApi } from '../utils/api'
|
import { agentsApi } from '../utils/api'
|
||||||
import { apiUrl } from '../utils/basePath'
|
import { apiUrl } from '../utils/basePath'
|
||||||
import { renderMarkdown, highlightAll } from '../utils/markdown'
|
import { renderMarkdown, highlightAll } from '../utils/markdown'
|
||||||
import { extractCodeArtifacts, extractMetadataArtifacts, renderMarkdownWithArtifacts } from '../utils/artifacts'
|
import { extractCodeArtifacts, extractMetadataArtifacts, renderMarkdownWithArtifacts } from '../utils/artifacts'
|
||||||
import CanvasPanel from '../components/CanvasPanel'
|
import CanvasPanel from '../components/CanvasPanel'
|
||||||
import ResourceCards from '../components/ResourceCards'
|
import ResourceCards from '../components/ResourceCards'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
import { useAgentChat } from '../hooks/useAgentChat'
|
import { useAgentChat } from '../hooks/useAgentChat'
|
||||||
|
|
||||||
function relativeTime(ts) {
|
function relativeTime(ts) {
|
||||||
|
|
@ -86,6 +87,8 @@ export default function AgentChat() {
|
||||||
const { name } = useParams()
|
const { name } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { addToast } = useOutletContext()
|
const { addToast } = useOutletContext()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const userId = searchParams.get('user_id') || undefined
|
||||||
|
|
||||||
const {
|
const {
|
||||||
conversations, activeConversation, activeId,
|
conversations, activeConversation, activeId,
|
||||||
|
|
@ -104,6 +107,7 @@ export default function AgentChat() {
|
||||||
const [editingName, setEditingName] = useState(null)
|
const [editingName, setEditingName] = useState(null)
|
||||||
const [editName, setEditName] = useState('')
|
const [editName, setEditName] = useState('')
|
||||||
const [chatSearch, setChatSearch] = useState('')
|
const [chatSearch, setChatSearch] = useState('')
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
const [streamContent, setStreamContent] = useState('')
|
const [streamContent, setStreamContent] = useState('')
|
||||||
const [streamReasoning, setStreamReasoning] = useState('')
|
const [streamReasoning, setStreamReasoning] = useState('')
|
||||||
const [streamToolCalls, setStreamToolCalls] = useState([])
|
const [streamToolCalls, setStreamToolCalls] = useState([])
|
||||||
|
|
@ -126,7 +130,7 @@ export default function AgentChat() {
|
||||||
|
|
||||||
// Connect to SSE endpoint — only reconnect when agent name changes
|
// Connect to SSE endpoint — only reconnect when agent name changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const url = apiUrl(`/api/agents/${encodeURIComponent(name)}/sse`)
|
const url = apiUrl(agentsApi.sseUrl(name, userId))
|
||||||
const es = new EventSource(url)
|
const es = new EventSource(url)
|
||||||
eventSourceRef.current = es
|
eventSourceRef.current = es
|
||||||
|
|
||||||
|
|
@ -223,7 +227,7 @@ export default function AgentChat() {
|
||||||
es.close()
|
es.close()
|
||||||
eventSourceRef.current = null
|
eventSourceRef.current = null
|
||||||
}
|
}
|
||||||
}, [name, addToast, nextId])
|
}, [name, userId, addToast, nextId])
|
||||||
|
|
||||||
// Auto-scroll to bottom
|
// Auto-scroll to bottom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -305,12 +309,12 @@ export default function AgentChat() {
|
||||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||||
setProcessingChatId(activeId)
|
setProcessingChatId(activeId)
|
||||||
try {
|
try {
|
||||||
await agentsApi.chat(name, msg)
|
await agentsApi.chat(name, msg, userId)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(`Failed to send message: ${err.message}`, 'error')
|
addToast(`Failed to send message: ${err.message}`, 'error')
|
||||||
setProcessingChatId(null)
|
setProcessingChatId(null)
|
||||||
}
|
}
|
||||||
}, [input, processing, name, activeId, addToast])
|
}, [input, processing, name, activeId, addToast, userId])
|
||||||
|
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
|
@ -363,7 +367,13 @@ export default function AgentChat() {
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm('Delete all conversations? This cannot be undone.')) deleteAllConversations()
|
setConfirmDialog({
|
||||||
|
title: 'Delete All Conversations',
|
||||||
|
message: 'Delete all conversations? This cannot be undone.',
|
||||||
|
confirmLabel: 'Delete All',
|
||||||
|
danger: true,
|
||||||
|
onConfirm: () => { setConfirmDialog(null); deleteAllConversations() },
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
title="Delete all conversations"
|
title="Delete all conversations"
|
||||||
style={{ padding: '6px 8px' }}
|
style={{ padding: '6px 8px' }}
|
||||||
|
|
@ -493,7 +503,7 @@ export default function AgentChat() {
|
||||||
<i className="fas fa-layer-group" /> {artifacts.length}
|
<i className="fas fa-layer-group" /> {artifacts.length}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/status`)} title="View status & observables">
|
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/status${userId ? `?user_id=${encodeURIComponent(userId)}` : ''}`)} title="View status & observables">
|
||||||
<i className="fas fa-chart-bar" /> Status
|
<i className="fas fa-chart-bar" /> Status
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => clearMessages()} disabled={messages.length === 0} title="Clear chat history">
|
<button className="btn btn-secondary btn-sm" onClick={() => clearMessages()} disabled={messages.length === 0} title="Clear chat history">
|
||||||
|
|
@ -667,6 +677,15 @@ export default function AgentChat() {
|
||||||
onClose={() => setCanvasOpen(false)}
|
onClose={() => setCanvasOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDialog}
|
||||||
|
title={confirmDialog?.title}
|
||||||
|
message={confirmDialog?.message}
|
||||||
|
confirmLabel={confirmDialog?.confirmLabel}
|
||||||
|
danger={confirmDialog?.danger}
|
||||||
|
onConfirm={confirmDialog?.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useParams, useNavigate, useLocation, useOutletContext } from 'react-router-dom'
|
import { useParams, useNavigate, useLocation, useOutletContext, useSearchParams } from 'react-router-dom'
|
||||||
import { agentsApi } from '../utils/api'
|
import { agentsApi } from '../utils/api'
|
||||||
import SearchableModelSelect from '../components/SearchableModelSelect'
|
import SearchableModelSelect from '../components/SearchableModelSelect'
|
||||||
import Toggle from '../components/Toggle'
|
import Toggle from '../components/Toggle'
|
||||||
|
|
@ -269,6 +269,8 @@ export default function AgentCreate() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { addToast } = useOutletContext()
|
const { addToast } = useOutletContext()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const userId = searchParams.get('user_id') || undefined
|
||||||
const isEdit = !!name
|
const isEdit = !!name
|
||||||
const importedConfig = location.state?.importedConfig || null
|
const importedConfig = location.state?.importedConfig || null
|
||||||
|
|
||||||
|
|
@ -308,7 +310,7 @@ export default function AgentCreate() {
|
||||||
try {
|
try {
|
||||||
const [metaData, config] = await Promise.all([
|
const [metaData, config] = await Promise.all([
|
||||||
agentsApi.configMeta().catch(() => null),
|
agentsApi.configMeta().catch(() => null),
|
||||||
isEdit ? agentsApi.getConfig(name).catch(() => null) : Promise.resolve(null),
|
isEdit ? agentsApi.getConfig(name, userId).catch(() => null) : Promise.resolve(null),
|
||||||
])
|
])
|
||||||
if (metaData) setMeta(metaData)
|
if (metaData) setMeta(metaData)
|
||||||
|
|
||||||
|
|
@ -384,7 +386,7 @@ export default function AgentCreate() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await agentsApi.update(name, payload)
|
await agentsApi.update(name, payload, userId)
|
||||||
addToast(`Agent "${form.name}" updated`, 'success')
|
addToast(`Agent "${form.name}" updated`, 'success')
|
||||||
} else {
|
} else {
|
||||||
await agentsApi.create(payload)
|
await agentsApi.create(payload)
|
||||||
|
|
|
||||||
|
|
@ -2,20 +2,29 @@ import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useNavigate, useOutletContext } from 'react-router-dom'
|
import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||||
import { agentJobsApi, modelsApi } from '../utils/api'
|
import { agentJobsApi, modelsApi } from '../utils/api'
|
||||||
import { useModels } from '../hooks/useModels'
|
import { useModels } from '../hooks/useModels'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useUserMap } from '../hooks/useUserMap'
|
||||||
import LoadingSpinner from '../components/LoadingSpinner'
|
import LoadingSpinner from '../components/LoadingSpinner'
|
||||||
import { fileToBase64 } from '../utils/api'
|
import { fileToBase64 } from '../utils/api'
|
||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
|
import UserGroupSection from '../components/UserGroupSection'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
|
||||||
export default function AgentJobs() {
|
export default function AgentJobs() {
|
||||||
const { addToast } = useOutletContext()
|
const { addToast } = useOutletContext()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { models } = useModels()
|
const { models } = useModels()
|
||||||
|
const { isAdmin, authEnabled, user } = useAuth()
|
||||||
|
const userMap = useUserMap()
|
||||||
const [activeTab, setActiveTab] = useState('tasks')
|
const [activeTab, setActiveTab] = useState('tasks')
|
||||||
const [tasks, setTasks] = useState([])
|
const [tasks, setTasks] = useState([])
|
||||||
const [jobs, setJobs] = useState([])
|
const [jobs, setJobs] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [jobFilter, setJobFilter] = useState('all')
|
const [jobFilter, setJobFilter] = useState('all')
|
||||||
const [hasMCPModels, setHasMCPModels] = useState(false)
|
const [hasMCPModels, setHasMCPModels] = useState(false)
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
|
const [taskUserGroups, setTaskUserGroups] = useState(null)
|
||||||
|
const [jobUserGroups, setJobUserGroups] = useState(null)
|
||||||
|
|
||||||
// Execute modal state
|
// Execute modal state
|
||||||
const [executeModal, setExecuteModal] = useState(null)
|
const [executeModal, setExecuteModal] = useState(null)
|
||||||
|
|
@ -27,19 +36,45 @@ export default function AgentJobs() {
|
||||||
const fileTypeRef = useRef('images')
|
const fileTypeRef = useRef('images')
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
|
const allUsers = isAdmin && authEnabled
|
||||||
try {
|
try {
|
||||||
const [t, j] = await Promise.allSettled([
|
const [t, j] = await Promise.allSettled([
|
||||||
agentJobsApi.listTasks(),
|
agentJobsApi.listTasks(allUsers),
|
||||||
agentJobsApi.listJobs(),
|
agentJobsApi.listJobs(allUsers),
|
||||||
])
|
])
|
||||||
if (t.status === 'fulfilled') setTasks(Array.isArray(t.value) ? t.value : [])
|
if (t.status === 'fulfilled') {
|
||||||
if (j.status === 'fulfilled') setJobs(Array.isArray(j.value) ? j.value : [])
|
const tv = t.value
|
||||||
|
// Handle wrapped response (admin) or flat array
|
||||||
|
if (Array.isArray(tv)) {
|
||||||
|
setTasks(tv)
|
||||||
|
setTaskUserGroups(null)
|
||||||
|
} else if (tv && tv.tasks) {
|
||||||
|
setTasks(Array.isArray(tv.tasks) ? tv.tasks : [])
|
||||||
|
setTaskUserGroups(tv.user_groups || null)
|
||||||
|
} else {
|
||||||
|
setTasks(Array.isArray(tv) ? tv : [])
|
||||||
|
setTaskUserGroups(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (j.status === 'fulfilled') {
|
||||||
|
const jv = j.value
|
||||||
|
if (Array.isArray(jv)) {
|
||||||
|
setJobs(jv)
|
||||||
|
setJobUserGroups(null)
|
||||||
|
} else if (jv && jv.jobs) {
|
||||||
|
setJobs(Array.isArray(jv.jobs) ? jv.jobs : [])
|
||||||
|
setJobUserGroups(jv.user_groups || null)
|
||||||
|
} else {
|
||||||
|
setJobs(Array.isArray(jv) ? jv : [])
|
||||||
|
setJobUserGroups(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(`Failed to load: ${err.message}`, 'error')
|
addToast(`Failed to load: ${err.message}`, 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [addToast])
|
}, [addToast, isAdmin, authEnabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
|
|
@ -62,14 +97,22 @@ export default function AgentJobs() {
|
||||||
}, [models])
|
}, [models])
|
||||||
|
|
||||||
const handleDeleteTask = async (id) => {
|
const handleDeleteTask = async (id) => {
|
||||||
if (!confirm('Delete this task?')) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Delete Task',
|
||||||
await agentJobsApi.deleteTask(id)
|
message: 'Delete this task?',
|
||||||
addToast('Task deleted', 'success')
|
confirmLabel: 'Delete',
|
||||||
fetchData()
|
danger: true,
|
||||||
} catch (err) {
|
onConfirm: async () => {
|
||||||
addToast(`Failed to delete: ${err.message}`, 'error')
|
setConfirmDialog(null)
|
||||||
}
|
try {
|
||||||
|
await agentJobsApi.deleteTask(id)
|
||||||
|
addToast('Task deleted', 'success')
|
||||||
|
fetchData()
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to delete: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancelJob = async (id) => {
|
const handleCancelJob = async (id) => {
|
||||||
|
|
@ -83,16 +126,24 @@ export default function AgentJobs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClearHistory = async () => {
|
const handleClearHistory = async () => {
|
||||||
if (!confirm('Clear all job history?')) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Clear Job History',
|
||||||
// Cancel all running jobs first, then refetch
|
message: 'Clear all job history?',
|
||||||
const running = jobs.filter(j => j.status === 'running' || j.status === 'pending')
|
confirmLabel: 'Clear',
|
||||||
await Promise.all(running.map(j => agentJobsApi.cancelJob(j.id).catch(() => {})))
|
danger: true,
|
||||||
addToast('Job history cleared', 'success')
|
onConfirm: async () => {
|
||||||
fetchData()
|
setConfirmDialog(null)
|
||||||
} catch (err) {
|
try {
|
||||||
addToast(`Failed to clear: ${err.message}`, 'error')
|
// Cancel all running jobs first, then refetch
|
||||||
}
|
const running = jobs.filter(j => j.status === 'running' || j.status === 'pending')
|
||||||
|
await Promise.all(running.map(j => agentJobsApi.cancelJob(j.id).catch(() => {})))
|
||||||
|
addToast('Job history cleared', 'success')
|
||||||
|
fetchData()
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to clear: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const openExecuteModal = (task) => {
|
const openExecuteModal = (task) => {
|
||||||
|
|
@ -256,7 +307,7 @@ export default function AgentJobs() {
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
|
||||||
) : activeTab === 'tasks' ? (
|
) : activeTab === 'tasks' ? (
|
||||||
tasks.length === 0 ? (
|
tasks.length === 0 && !taskUserGroups ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-state-icon"><i className="fas fa-robot" /></div>
|
<div className="empty-state-icon"><i className="fas fa-robot" /></div>
|
||||||
<h2 className="empty-state-title">No tasks defined</h2>
|
<h2 className="empty-state-title">No tasks defined</h2>
|
||||||
|
|
@ -266,73 +317,82 @@ export default function AgentJobs() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-container">
|
<>
|
||||||
<table className="table">
|
{taskUserGroups && <h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>Your Tasks</h2>}
|
||||||
<thead>
|
{tasks.length === 0 ? (
|
||||||
<tr>
|
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-md)' }}>You have no tasks yet.</p>
|
||||||
<th>Name</th>
|
) : (
|
||||||
<th>Description</th>
|
<div className="table-container">
|
||||||
<th>Model</th>
|
<table className="table">
|
||||||
<th>Cron</th>
|
<thead>
|
||||||
<th>Status</th>
|
<tr>
|
||||||
<th style={{ textAlign: 'right' }}>Actions</th>
|
<th>Name</th>
|
||||||
</tr>
|
<th>Description</th>
|
||||||
</thead>
|
<th>Model</th>
|
||||||
<tbody>
|
<th>Cron</th>
|
||||||
{tasks.map(task => (
|
<th>Status</th>
|
||||||
<tr key={task.id || task.name}>
|
<th style={{ textAlign: 'right' }}>Actions</th>
|
||||||
<td>
|
|
||||||
<a onClick={() => navigate(`/app/agent-jobs/tasks/${task.id || task.name}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontWeight: 500 }}>
|
|
||||||
{task.name || task.id}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block' }}>
|
|
||||||
{task.description || '-'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{task.model ? (
|
|
||||||
<a onClick={() => navigate(`/app/model-editor/${encodeURIComponent(task.model)}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontSize: '0.8125rem' }}>
|
|
||||||
{task.model}
|
|
||||||
</a>
|
|
||||||
) : '-'}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{task.cron ? (
|
|
||||||
<span className="badge badge-info" style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem' }}>
|
|
||||||
{task.cron}
|
|
||||||
</span>
|
|
||||||
) : '-'}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{task.enabled === false ? (
|
|
||||||
<span className="badge" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>Disabled</span>
|
|
||||||
) : (
|
|
||||||
<span className="badge badge-success">Enabled</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
|
|
||||||
<button className="btn btn-primary btn-sm" onClick={() => openExecuteModal(task)} title="Execute">
|
|
||||||
<i className="fas fa-play" />
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/agent-jobs/tasks/${task.id || task.name}/edit`)} title="Edit">
|
|
||||||
<i className="fas fa-edit" />
|
|
||||||
</button>
|
|
||||||
<button className="btn btn-danger btn-sm" onClick={() => handleDeleteTask(task.id || task.name)} title="Delete">
|
|
||||||
<i className="fas fa-trash" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{tasks.map(task => (
|
||||||
</div>
|
<tr key={task.id || task.name}>
|
||||||
|
<td>
|
||||||
|
<a onClick={() => navigate(`/app/agent-jobs/tasks/${task.id || task.name}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontWeight: 500 }}>
|
||||||
|
{task.name || task.id}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'inline-block' }}>
|
||||||
|
{task.description || '-'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{task.model ? (
|
||||||
|
<a onClick={() => navigate(`/app/model-editor/${encodeURIComponent(task.model)}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontSize: '0.8125rem' }}>
|
||||||
|
{task.model}
|
||||||
|
</a>
|
||||||
|
) : '-'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{task.cron ? (
|
||||||
|
<span className="badge badge-info" style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem' }}>
|
||||||
|
{task.cron}
|
||||||
|
</span>
|
||||||
|
) : '-'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{task.enabled === false ? (
|
||||||
|
<span className="badge" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-muted)' }}>Disabled</span>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-success">Enabled</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={() => openExecuteModal(task)} title="Execute">
|
||||||
|
<i className="fas fa-play" />
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/agent-jobs/tasks/${task.id || task.name}/edit`)} title="Edit">
|
||||||
|
<i className="fas fa-edit" />
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => handleDeleteTask(task.id || task.name)} title="Delete">
|
||||||
|
<i className="fas fa-trash" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{jobUserGroups && <h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>Your Jobs</h2>}
|
||||||
{/* Job History Controls */}
|
{/* Job History Controls */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
|
||||||
|
|
@ -404,9 +464,86 @@ export default function AgentJobs() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'tasks' && taskUserGroups && (
|
||||||
|
<UserGroupSection
|
||||||
|
title="Other Users' Tasks"
|
||||||
|
userGroups={taskUserGroups}
|
||||||
|
userMap={userMap}
|
||||||
|
currentUserId={user?.id}
|
||||||
|
itemKey="tasks"
|
||||||
|
renderGroup={(items) => (
|
||||||
|
<div className="table-container">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Model</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(items || []).map(task => (
|
||||||
|
<tr key={task.id || task.name}>
|
||||||
|
<td style={{ fontWeight: 500 }}>{task.name || task.id}</td>
|
||||||
|
<td style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>{task.description || '-'}</td>
|
||||||
|
<td style={{ fontSize: '0.8125rem' }}>{task.model || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'jobs' && jobUserGroups && (
|
||||||
|
<UserGroupSection
|
||||||
|
title="Other Users' Jobs"
|
||||||
|
userGroups={jobUserGroups}
|
||||||
|
userMap={userMap}
|
||||||
|
currentUserId={user?.id}
|
||||||
|
itemKey="jobs"
|
||||||
|
renderGroup={(items) => (
|
||||||
|
<div className="table-container">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Job ID</th>
|
||||||
|
<th>Task</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(items || []).map(job => (
|
||||||
|
<tr key={job.id}>
|
||||||
|
<td style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.8125rem' }}>{job.id?.slice(0, 12)}...</td>
|
||||||
|
<td>{job.task_id || '-'}</td>
|
||||||
|
<td>{statusBadge(job.status)}</td>
|
||||||
|
<td style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)' }}>{formatDate(job.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDialog}
|
||||||
|
title={confirmDialog?.title}
|
||||||
|
message={confirmDialog?.message}
|
||||||
|
confirmLabel={confirmDialog?.confirmLabel}
|
||||||
|
danger={confirmDialog?.danger}
|
||||||
|
onConfirm={confirmDialog?.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Execute Task Modal */}
|
{/* Execute Task Modal */}
|
||||||
{executeModal && (
|
{executeModal && (
|
||||||
<Modal onClose={() => setExecuteModal(null)}>
|
<Modal onClose={() => setExecuteModal(null)}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useParams, useNavigate, useOutletContext } from 'react-router-dom'
|
import { useParams, useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
|
||||||
import { agentsApi } from '../utils/api'
|
import { agentsApi } from '../utils/api'
|
||||||
import { apiUrl } from '../utils/basePath'
|
import { apiUrl } from '../utils/basePath'
|
||||||
|
|
||||||
|
|
@ -187,26 +187,28 @@ export default function AgentStatus() {
|
||||||
const { name } = useParams()
|
const { name } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { addToast } = useOutletContext()
|
const { addToast } = useOutletContext()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const userId = searchParams.get('user_id') || undefined
|
||||||
const [observables, setObservables] = useState([])
|
const [observables, setObservables] = useState([])
|
||||||
const [status, setStatus] = useState(null)
|
const [status, setStatus] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const obsData = await agentsApi.observables(name)
|
const obsData = await agentsApi.observables(name, userId)
|
||||||
const history = Array.isArray(obsData) ? obsData : (obsData?.History || [])
|
const history = Array.isArray(obsData) ? obsData : (obsData?.History || [])
|
||||||
setObservables(history)
|
setObservables(history)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(`Failed to load observables: ${err.message}`, 'error')
|
addToast(`Failed to load observables: ${err.message}`, 'error')
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const statusData = await agentsApi.status(name)
|
const statusData = await agentsApi.status(name, userId)
|
||||||
setStatus(statusData)
|
setStatus(statusData)
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// status endpoint may fail if no actions have run yet
|
// status endpoint may fail if no actions have run yet
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [name, addToast])
|
}, [name, userId, addToast])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
|
|
@ -216,7 +218,7 @@ export default function AgentStatus() {
|
||||||
|
|
||||||
// SSE for real-time observable updates
|
// SSE for real-time observable updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const url = apiUrl(`/api/agents/${encodeURIComponent(name)}/sse`)
|
const url = apiUrl(agentsApi.sseUrl(name, userId))
|
||||||
const es = new EventSource(url)
|
const es = new EventSource(url)
|
||||||
|
|
||||||
es.addEventListener('observable_update', (e) => {
|
es.addEventListener('observable_update', (e) => {
|
||||||
|
|
@ -243,11 +245,11 @@ export default function AgentStatus() {
|
||||||
|
|
||||||
es.onerror = () => { /* reconnect handled by browser */ }
|
es.onerror = () => { /* reconnect handled by browser */ }
|
||||||
return () => es.close()
|
return () => es.close()
|
||||||
}, [name])
|
}, [name, userId])
|
||||||
|
|
||||||
const handleClear = async () => {
|
const handleClear = async () => {
|
||||||
try {
|
try {
|
||||||
await agentsApi.clearObservables(name)
|
await agentsApi.clearObservables(name, userId)
|
||||||
setObservables([])
|
setObservables([])
|
||||||
addToast('Observables cleared', 'success')
|
addToast('Observables cleared', 'success')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -359,10 +361,10 @@ export default function AgentStatus() {
|
||||||
<p className="page-subtitle">Agent observables and activity history</p>
|
<p className="page-subtitle">Agent observables and activity history</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
|
<div style={{ display: 'flex', gap: 'var(--spacing-sm)' }}>
|
||||||
<button className="btn btn-secondary" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/chat`)}>
|
<button className="btn btn-secondary" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/chat${userId ? `?user_id=${encodeURIComponent(userId)}` : ''}`)}>
|
||||||
<i className="fas fa-comment" /> Chat
|
<i className="fas fa-comment" /> Chat
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/edit`)}>
|
<button className="btn btn-secondary" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/edit${userId ? `?user_id=${encodeURIComponent(userId)}` : ''}`)}>
|
||||||
<i className="fas fa-edit" /> Edit
|
<i className="fas fa-edit" /> Edit
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={fetchData}>
|
<button className="btn btn-secondary" onClick={fetchData}>
|
||||||
|
|
@ -405,7 +407,7 @@ export default function AgentStatus() {
|
||||||
<div className="empty-state-icon"><i className="fas fa-chart-bar" /></div>
|
<div className="empty-state-icon"><i className="fas fa-chart-bar" /></div>
|
||||||
<h2 className="empty-state-title">No observables yet</h2>
|
<h2 className="empty-state-title">No observables yet</h2>
|
||||||
<p className="empty-state-text">Send a message to the agent to see its activity here.</p>
|
<p className="empty-state-text">Send a message to the agent to see its activity here.</p>
|
||||||
<button className="btn btn-primary" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/chat`)}>
|
<button className="btn btn-primary" onClick={() => navigate(`/app/agents/${encodeURIComponent(name)}/chat${userId ? `?user_id=${encodeURIComponent(userId)}` : ''}`)}>
|
||||||
<i className="fas fa-comment" /> Chat with {name}
|
<i className="fas fa-comment" /> Chat with {name}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,30 @@
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
import { useNavigate, useOutletContext } from 'react-router-dom'
|
import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||||
import { agentsApi } from '../utils/api'
|
import { agentsApi } from '../utils/api'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useUserMap } from '../hooks/useUserMap'
|
||||||
|
import UserGroupSection from '../components/UserGroupSection'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
|
||||||
export default function Agents() {
|
export default function Agents() {
|
||||||
const { addToast } = useOutletContext()
|
const { addToast } = useOutletContext()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { isAdmin, authEnabled, user } = useAuth()
|
||||||
|
const userMap = useUserMap()
|
||||||
const [agents, setAgents] = useState([])
|
const [agents, setAgents] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [agentHubURL, setAgentHubURL] = useState('')
|
const [agentHubURL, setAgentHubURL] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
const [userGroups, setUserGroups] = useState(null)
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
|
|
||||||
const fetchAgents = useCallback(async () => {
|
const fetchAgents = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await agentsApi.list()
|
const data = await agentsApi.list(isAdmin && authEnabled)
|
||||||
const names = Array.isArray(data.agents) ? data.agents : []
|
const names = Array.isArray(data.agents) ? data.agents : []
|
||||||
const statuses = data.statuses || {}
|
const statuses = data.statuses || {}
|
||||||
if (data.agent_hub_url) setAgentHubURL(data.agent_hub_url)
|
if (data.agent_hub_url) setAgentHubURL(data.agent_hub_url)
|
||||||
|
setUserGroups(data.user_groups || null)
|
||||||
|
|
||||||
// Fetch observable counts for each agent
|
// Fetch observable counts for each agent
|
||||||
const agentsWithCounts = await Promise.all(
|
const agentsWithCounts = await Promise.all(
|
||||||
|
|
@ -40,7 +49,7 @@ export default function Agents() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [addToast])
|
}, [addToast, isAdmin, authEnabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAgents()
|
fetchAgents()
|
||||||
|
|
@ -54,26 +63,34 @@ export default function Agents() {
|
||||||
return agents.filter(a => a.name.toLowerCase().includes(q))
|
return agents.filter(a => a.name.toLowerCase().includes(q))
|
||||||
}, [agents, search])
|
}, [agents, search])
|
||||||
|
|
||||||
const handleDelete = async (name) => {
|
const handleDelete = (name, userId) => {
|
||||||
if (!window.confirm(`Delete agent "${name}"? This action cannot be undone.`)) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Delete Agent',
|
||||||
await agentsApi.delete(name)
|
message: `Delete agent "${name}"? This action cannot be undone.`,
|
||||||
addToast(`Agent "${name}" deleted`, 'success')
|
confirmLabel: 'Delete',
|
||||||
fetchAgents()
|
danger: true,
|
||||||
} catch (err) {
|
onConfirm: async () => {
|
||||||
addToast(`Failed to delete agent: ${err.message}`, 'error')
|
setConfirmDialog(null)
|
||||||
}
|
try {
|
||||||
|
await agentsApi.delete(name, userId)
|
||||||
|
addToast(`Agent "${name}" deleted`, 'success')
|
||||||
|
fetchAgents()
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to delete agent: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePauseResume = async (agent) => {
|
const handlePauseResume = async (agent, userId) => {
|
||||||
const name = agent.name || agent.id
|
const name = agent.name || agent.id
|
||||||
const isActive = agent.status === 'active'
|
const isActive = agent.status === 'active' || agent.active === true
|
||||||
try {
|
try {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
await agentsApi.pause(name)
|
await agentsApi.pause(name, userId)
|
||||||
addToast(`Agent "${name}" paused`, 'success')
|
addToast(`Agent "${name}" paused`, 'success')
|
||||||
} else {
|
} else {
|
||||||
await agentsApi.resume(name)
|
await agentsApi.resume(name, userId)
|
||||||
addToast(`Agent "${name}" resumed`, 'success')
|
addToast(`Agent "${name}" resumed`, 'success')
|
||||||
}
|
}
|
||||||
fetchAgents()
|
fetchAgents()
|
||||||
|
|
@ -82,9 +99,9 @@ export default function Agents() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExport = async (name) => {
|
const handleExport = async (name, userId) => {
|
||||||
try {
|
try {
|
||||||
const data = await agentsApi.export(name)
|
const data = await agentsApi.export(name, userId)
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
|
|
@ -187,7 +204,7 @@ export default function Agents() {
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
|
||||||
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-primary)' }} />
|
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-primary)' }} />
|
||||||
</div>
|
</div>
|
||||||
) : agents.length === 0 ? (
|
) : agents.length === 0 && !userGroups ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-state-icon"><i className="fas fa-robot" /></div>
|
<div className="empty-state-icon"><i className="fas fa-robot" /></div>
|
||||||
<h2 className="empty-state-title">No agents configured</h2>
|
<h2 className="empty-state-title">No agents configured</h2>
|
||||||
|
|
@ -214,6 +231,7 @@ export default function Agents() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{userGroups && <h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>Your Agents</h2>}
|
||||||
<div className="agents-toolbar">
|
<div className="agents-toolbar">
|
||||||
<div className="agents-search">
|
<div className="agents-search">
|
||||||
<i className="fas fa-search" />
|
<i className="fas fa-search" />
|
||||||
|
|
@ -314,8 +332,96 @@ export default function Agents() {
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{userGroups && (
|
||||||
|
<UserGroupSection
|
||||||
|
title="Other Users' Agents"
|
||||||
|
userGroups={userGroups}
|
||||||
|
userMap={userMap}
|
||||||
|
currentUserId={user?.id}
|
||||||
|
itemKey="agents"
|
||||||
|
renderGroup={(items, userId) => (
|
||||||
|
<div className="table-container">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th style={{ textAlign: 'right' }}>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(items || []).map(a => {
|
||||||
|
const isActive = a.active === true
|
||||||
|
return (
|
||||||
|
<tr key={a.name}>
|
||||||
|
<td>
|
||||||
|
<a className="agents-name" onClick={() => navigate(`/app/agents/${encodeURIComponent(a.name)}/chat?user_id=${encodeURIComponent(userId)}`)}>
|
||||||
|
{a.name}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{statusBadge(isActive ? 'active' : 'paused')}</td>
|
||||||
|
<td>
|
||||||
|
<div className="agents-action-group">
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${isActive ? 'btn-warning' : 'btn-success'}`}
|
||||||
|
onClick={() => handlePauseResume(a, userId)}
|
||||||
|
title={isActive ? 'Pause' : 'Resume'}
|
||||||
|
>
|
||||||
|
<i className={`fas ${isActive ? 'fa-pause' : 'fa-play'}`} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => navigate(`/app/agents/${encodeURIComponent(a.name)}/edit?user_id=${encodeURIComponent(userId)}`)}
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<i className="fas fa-edit" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => navigate(`/app/agents/${encodeURIComponent(a.name)}/chat?user_id=${encodeURIComponent(userId)}`)}
|
||||||
|
title="Chat"
|
||||||
|
>
|
||||||
|
<i className="fas fa-comment" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => handleExport(a.name, userId)}
|
||||||
|
title="Export"
|
||||||
|
>
|
||||||
|
<i className="fas fa-download" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
onClick={() => handleDelete(a.name, userId)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<i className="fas fa-trash" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDialog}
|
||||||
|
title={confirmDialog?.title}
|
||||||
|
message={confirmDialog?.message}
|
||||||
|
confirmLabel={confirmDialog?.confirmLabel}
|
||||||
|
danger={confirmDialog?.danger}
|
||||||
|
onConfirm={confirmDialog?.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import React from 'react'
|
||||||
import { useOperations } from '../hooks/useOperations'
|
import { useOperations } from '../hooks/useOperations'
|
||||||
import LoadingSpinner from '../components/LoadingSpinner'
|
import LoadingSpinner from '../components/LoadingSpinner'
|
||||||
import { renderMarkdown } from '../utils/markdown'
|
import { renderMarkdown } from '../utils/markdown'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
|
||||||
export default function Backends() {
|
export default function Backends() {
|
||||||
const { addToast } = useOutletContext()
|
const { addToast } = useOutletContext()
|
||||||
|
|
@ -22,6 +23,7 @@ export default function Backends() {
|
||||||
const [manualName, setManualName] = useState('')
|
const [manualName, setManualName] = useState('')
|
||||||
const [manualAlias, setManualAlias] = useState('')
|
const [manualAlias, setManualAlias] = useState('')
|
||||||
const [expandedRow, setExpandedRow] = useState(null)
|
const [expandedRow, setExpandedRow] = useState(null)
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
const debounceRef = useRef(null)
|
const debounceRef = useRef(null)
|
||||||
|
|
||||||
const [allBackends, setAllBackends] = useState([])
|
const [allBackends, setAllBackends] = useState([])
|
||||||
|
|
@ -94,14 +96,22 @@ export default function Backends() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id) => {
|
||||||
if (!confirm(`Delete backend ${id}?`)) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Delete Backend',
|
||||||
await backendsApi.delete(id)
|
message: `Delete backend ${id}?`,
|
||||||
addToast(`Deleting ${id}...`, 'info')
|
confirmLabel: 'Delete',
|
||||||
setTimeout(fetchBackends, 1000)
|
danger: true,
|
||||||
} catch (err) {
|
onConfirm: async () => {
|
||||||
addToast(`Delete failed: ${err.message}`, 'error')
|
setConfirmDialog(null)
|
||||||
}
|
try {
|
||||||
|
await backendsApi.delete(id)
|
||||||
|
addToast(`Deleting ${id}...`, 'info')
|
||||||
|
setTimeout(fetchBackends, 1000)
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Delete failed: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleManualInstall = async (e) => {
|
const handleManualInstall = async (e) => {
|
||||||
|
|
@ -400,6 +410,15 @@ export default function Backends() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDialog}
|
||||||
|
title={confirmDialog?.title}
|
||||||
|
message={confirmDialog?.message}
|
||||||
|
confirmLabel={confirmDialog?.confirmLabel}
|
||||||
|
danger={confirmDialog?.danger}
|
||||||
|
onConfirm={confirmDialog?.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import { useMCPClient } from '../hooks/useMCPClient'
|
||||||
import MCPAppFrame from '../components/MCPAppFrame'
|
import MCPAppFrame from '../components/MCPAppFrame'
|
||||||
import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
|
import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
|
||||||
import { loadClientMCPServers } from '../utils/mcpClientStorage'
|
import { loadClientMCPServers } from '../utils/mcpClientStorage'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
function relativeTime(ts) {
|
function relativeTime(ts) {
|
||||||
if (!ts) return ''
|
if (!ts) return ''
|
||||||
|
|
@ -286,6 +288,7 @@ export default function Chat() {
|
||||||
const { model: urlModel } = useParams()
|
const { model: urlModel } = useParams()
|
||||||
const { addToast } = useOutletContext()
|
const { addToast } = useOutletContext()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { isAdmin } = useAuth()
|
||||||
const {
|
const {
|
||||||
chats, activeChat, activeChatId, isStreaming, streamingChatId, streamingContent,
|
chats, activeChat, activeChatId, isStreaming, streamingChatId, streamingContent,
|
||||||
streamingReasoning, streamingToolCalls, tokensPerSecond, maxTokensPerSecond,
|
streamingReasoning, streamingToolCalls, tokensPerSecond, maxTokensPerSecond,
|
||||||
|
|
@ -316,6 +319,9 @@ export default function Chat() {
|
||||||
const [canvasOpen, setCanvasOpen] = useState(false)
|
const [canvasOpen, setCanvasOpen] = useState(false)
|
||||||
const [selectedArtifactId, setSelectedArtifactId] = useState(null)
|
const [selectedArtifactId, setSelectedArtifactId] = useState(null)
|
||||||
const [clientMCPServers, setClientMCPServers] = useState(() => loadClientMCPServers())
|
const [clientMCPServers, setClientMCPServers] = useState(() => loadClientMCPServers())
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
|
const [completionGlowIdx, setCompletionGlowIdx] = useState(-1)
|
||||||
|
const prevStreamingRef = useRef(false)
|
||||||
const {
|
const {
|
||||||
connect: mcpConnect, disconnect: mcpDisconnect, disconnectAll: mcpDisconnectAll,
|
connect: mcpConnect, disconnect: mcpDisconnect, disconnectAll: mcpDisconnectAll,
|
||||||
getToolsForLLM, isClientTool, executeTool, connectionStatuses, getConnectedTools,
|
getToolsForLLM, isClientTool, executeTool, connectionStatuses, getConnectedTools,
|
||||||
|
|
@ -343,10 +349,23 @@ export default function Chat() {
|
||||||
prevArtifactCountRef.current = artifacts.length
|
prevArtifactCountRef.current = artifacts.length
|
||||||
}, [artifacts])
|
}, [artifacts])
|
||||||
|
|
||||||
// Check MCP availability and fetch model config
|
// Completion glow: when streaming finishes, briefly highlight last assistant message
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevStreamingRef.current && !isStreaming && activeChat?.history?.length > 0) {
|
||||||
|
const lastIdx = activeChat.history.length - 1
|
||||||
|
if (activeChat.history[lastIdx]?.role === 'assistant') {
|
||||||
|
setCompletionGlowIdx(lastIdx)
|
||||||
|
const timer = setTimeout(() => setCompletionGlowIdx(-1), 600)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevStreamingRef.current = isStreaming
|
||||||
|
}, [isStreaming, activeChat?.history?.length])
|
||||||
|
|
||||||
|
// Check MCP availability and fetch model config (admin-only endpoint)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const model = activeChat?.model
|
const model = activeChat?.model
|
||||||
if (!model) { setMcpAvailable(false); setModelInfo(null); return }
|
if (!model || !isAdmin) { setMcpAvailable(false); setModelInfo(null); return }
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
modelsApi.getConfigJson(model).then(cfg => {
|
modelsApi.getConfigJson(model).then(cfg => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|
@ -361,7 +380,7 @@ export default function Chat() {
|
||||||
}
|
}
|
||||||
}).catch(() => { if (!cancelled) { setMcpAvailable(false); setModelInfo(null) } })
|
}).catch(() => { if (!cancelled) { setMcpAvailable(false); setModelInfo(null) } })
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [activeChat?.model])
|
}, [activeChat?.model, isAdmin])
|
||||||
|
|
||||||
const fetchMcpServers = useCallback(async () => {
|
const fetchMcpServers = useCallback(async () => {
|
||||||
const model = activeChat?.model
|
const model = activeChat?.model
|
||||||
|
|
@ -732,9 +751,13 @@ export default function Chat() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-sm"
|
||||||
onClick={() => {
|
onClick={() => setConfirmDialog({
|
||||||
if (confirm('Delete all chats? This cannot be undone.')) deleteAllChats()
|
title: 'Delete All Chats',
|
||||||
}}
|
message: 'Delete all chats? This cannot be undone.',
|
||||||
|
confirmLabel: 'Delete all',
|
||||||
|
danger: true,
|
||||||
|
onConfirm: () => { setConfirmDialog(null); deleteAllChats() },
|
||||||
|
})}
|
||||||
title="Delete all chats"
|
title="Delete all chats"
|
||||||
style={{ padding: '6px 8px' }}
|
style={{ padding: '6px 8px' }}
|
||||||
>
|
>
|
||||||
|
|
@ -879,7 +902,7 @@ export default function Chat() {
|
||||||
style={{ flex: '1 1 0', minWidth: 120 }}
|
style={{ flex: '1 1 0', minWidth: 120 }}
|
||||||
/>
|
/>
|
||||||
<div className="chat-header-actions">
|
<div className="chat-header-actions">
|
||||||
{activeChat.model && (
|
{activeChat.model && isAdmin && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary btn-sm"
|
className="btn btn-secondary btn-sm"
|
||||||
|
|
@ -1059,7 +1082,18 @@ export default function Chat() {
|
||||||
<i className="fas fa-comments" />
|
<i className="fas fa-comments" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="chat-empty-title">Start a conversation</h2>
|
<h2 className="chat-empty-title">Start a conversation</h2>
|
||||||
<p className="chat-empty-text">Type a message below to begin chatting{activeChat.model ? ` with ${activeChat.model}` : ''}.</p>
|
<p className="chat-empty-text">{activeChat.model ? `Ready to chat with ${activeChat.model}` : 'Select a model above to get started'}</p>
|
||||||
|
<div className="chat-empty-suggestions">
|
||||||
|
{['Explain how this works', 'Help me write code', 'Summarize a document', 'Brainstorm ideas'].map((prompt) => (
|
||||||
|
<button
|
||||||
|
key={prompt}
|
||||||
|
className="chat-empty-suggestion"
|
||||||
|
onClick={() => { setInput(prompt); textareaRef.current?.focus() }}
|
||||||
|
>
|
||||||
|
{prompt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="chat-empty-hints">
|
<div className="chat-empty-hints">
|
||||||
<span><i className="fas fa-keyboard" /> Enter to send</span>
|
<span><i className="fas fa-keyboard" /> Enter to send</span>
|
||||||
<span><i className="fas fa-level-down-alt" /> Shift+Enter for newline</span>
|
<span><i className="fas fa-level-down-alt" /> Shift+Enter for newline</span>
|
||||||
|
|
@ -1089,7 +1123,7 @@ export default function Chat() {
|
||||||
}
|
}
|
||||||
flushActivity(i)
|
flushActivity(i)
|
||||||
elements.push(
|
elements.push(
|
||||||
<div key={i} className={`chat-message chat-message-${msg.role}`}>
|
<div key={i} className={`chat-message chat-message-${msg.role}${i === completionGlowIdx ? ' chat-message-new' : ''}`}>
|
||||||
<div className="chat-message-avatar">
|
<div className="chat-message-avatar">
|
||||||
<i className={`fas ${msg.role === 'user' ? 'fa-user' : 'fa-robot'}`} />
|
<i className={`fas ${msg.role === 'user' ? 'fa-user' : 'fa-robot'}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1145,6 +1179,11 @@ export default function Chat() {
|
||||||
<span dangerouslySetInnerHTML={{ __html: renderMarkdown(streamingContent) }} />
|
<span dangerouslySetInnerHTML={{ __html: renderMarkdown(streamingContent) }} />
|
||||||
<span className="chat-streaming-cursor" />
|
<span className="chat-streaming-cursor" />
|
||||||
</div>
|
</div>
|
||||||
|
{tokensPerSecond !== null && (
|
||||||
|
<div className="chat-streaming-speed">
|
||||||
|
<i className="fas fa-tachometer-alt" /> {tokensPerSecond} tok/s
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1154,8 +1193,10 @@ export default function Chat() {
|
||||||
<i className="fas fa-robot" />
|
<i className="fas fa-robot" />
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-message-bubble">
|
<div className="chat-message-bubble">
|
||||||
<div className="chat-message-content" style={{ color: 'var(--color-text-muted)' }}>
|
<div className="chat-message-content chat-thinking-indicator">
|
||||||
<i className="fas fa-circle-notch fa-spin" /> Thinking...
|
<span className="chat-thinking-dots">
|
||||||
|
<span /><span /><span />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1220,7 +1261,7 @@ export default function Chat() {
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Type a message..."
|
placeholder="Message..."
|
||||||
rows={1}
|
rows={1}
|
||||||
disabled={isStreaming}
|
disabled={isStreaming}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1249,6 +1290,15 @@ export default function Chat() {
|
||||||
onClose={() => setCanvasOpen(false)}
|
onClose={() => setCanvasOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDialog}
|
||||||
|
title={confirmDialog?.title}
|
||||||
|
message={confirmDialog?.message}
|
||||||
|
confirmLabel={confirmDialog?.confirmLabel}
|
||||||
|
danger={confirmDialog?.danger}
|
||||||
|
onConfirm={confirmDialog?.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useParams, useOutletContext } from 'react-router-dom'
|
import { useParams, useOutletContext, useSearchParams } from 'react-router-dom'
|
||||||
import { agentCollectionsApi } from '../utils/api'
|
import { agentCollectionsApi } from '../utils/api'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
|
||||||
export default function CollectionDetails() {
|
export default function CollectionDetails() {
|
||||||
const { name } = useParams()
|
const { name } = useParams()
|
||||||
const { addToast } = useOutletContext()
|
const { addToast } = useOutletContext()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const userId = searchParams.get('user_id') || undefined
|
||||||
const [activeTab, setActiveTab] = useState('entries')
|
const [activeTab, setActiveTab] = useState('entries')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
|
|
||||||
// Entries tab state
|
// Entries tab state
|
||||||
const [entries, setEntries] = useState([])
|
const [entries, setEntries] = useState([])
|
||||||
|
|
@ -32,21 +36,21 @@ export default function CollectionDetails() {
|
||||||
|
|
||||||
const fetchEntries = useCallback(async () => {
|
const fetchEntries = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await agentCollectionsApi.entries(name)
|
const data = await agentCollectionsApi.entries(name, userId)
|
||||||
setEntries(Array.isArray(data.entries) ? data.entries : [])
|
setEntries(Array.isArray(data.entries) ? data.entries : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(`Failed to load entries: ${err.message}`, 'error')
|
addToast(`Failed to load entries: ${err.message}`, 'error')
|
||||||
}
|
}
|
||||||
}, [name, addToast])
|
}, [name, addToast, userId])
|
||||||
|
|
||||||
const fetchSources = useCallback(async () => {
|
const fetchSources = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await agentCollectionsApi.sources(name)
|
const data = await agentCollectionsApi.sources(name, userId)
|
||||||
setSources(Array.isArray(data.sources) ? data.sources : [])
|
setSources(Array.isArray(data.sources) ? data.sources : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(`Failed to load sources: ${err.message}`, 'error')
|
addToast(`Failed to load sources: ${err.message}`, 'error')
|
||||||
}
|
}
|
||||||
}, [name, addToast])
|
}, [name, addToast, userId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
|
|
@ -62,7 +66,7 @@ export default function CollectionDetails() {
|
||||||
setViewContent(null)
|
setViewContent(null)
|
||||||
setViewLoading(true)
|
setViewLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = await agentCollectionsApi.entryContent(name, entry)
|
const data = await agentCollectionsApi.entryContent(name, entry, userId)
|
||||||
setViewContent(data)
|
setViewContent(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(`Failed to load entry content: ${err.message}`, 'error')
|
addToast(`Failed to load entry content: ${err.message}`, 'error')
|
||||||
|
|
@ -73,14 +77,22 @@ export default function CollectionDetails() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteEntry = async (entry) => {
|
const handleDeleteEntry = async (entry) => {
|
||||||
if (!window.confirm('Are you sure you want to delete this entry?')) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Delete Entry',
|
||||||
await agentCollectionsApi.deleteEntry(name, entry)
|
message: 'Are you sure you want to delete this entry?',
|
||||||
addToast('Entry deleted', 'success')
|
confirmLabel: 'Delete',
|
||||||
fetchEntries()
|
danger: true,
|
||||||
} catch (err) {
|
onConfirm: async () => {
|
||||||
addToast(`Failed to delete entry: ${err.message}`, 'error')
|
setConfirmDialog(null)
|
||||||
}
|
try {
|
||||||
|
await agentCollectionsApi.deleteEntry(name, entry, userId)
|
||||||
|
addToast('Entry deleted', 'success')
|
||||||
|
fetchEntries()
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to delete entry: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpload = async (e) => {
|
const handleUpload = async (e) => {
|
||||||
|
|
@ -90,7 +102,7 @@ export default function CollectionDetails() {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', uploadFile)
|
formData.append('file', uploadFile)
|
||||||
await agentCollectionsApi.upload(name, formData)
|
await agentCollectionsApi.upload(name, formData, userId)
|
||||||
addToast('File uploaded successfully', 'success')
|
addToast('File uploaded successfully', 'success')
|
||||||
setUploadFile(null)
|
setUploadFile(null)
|
||||||
fetchEntries()
|
fetchEntries()
|
||||||
|
|
@ -106,7 +118,7 @@ export default function CollectionDetails() {
|
||||||
if (!searchQuery.trim()) return
|
if (!searchQuery.trim()) return
|
||||||
setSearching(true)
|
setSearching(true)
|
||||||
try {
|
try {
|
||||||
const data = await agentCollectionsApi.search(name, searchQuery, searchMaxResults)
|
const data = await agentCollectionsApi.search(name, searchQuery, searchMaxResults, userId)
|
||||||
setSearchResults(Array.isArray(data.results) ? data.results : [])
|
setSearchResults(Array.isArray(data.results) ? data.results : [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(`Search failed: ${err.message}`, 'error')
|
addToast(`Search failed: ${err.message}`, 'error')
|
||||||
|
|
@ -120,7 +132,7 @@ export default function CollectionDetails() {
|
||||||
if (!newSourceUrl.trim()) return
|
if (!newSourceUrl.trim()) return
|
||||||
setAddingSource(true)
|
setAddingSource(true)
|
||||||
try {
|
try {
|
||||||
await agentCollectionsApi.addSource(name, newSourceUrl, newSourceInterval || undefined)
|
await agentCollectionsApi.addSource(name, newSourceUrl, newSourceInterval || undefined, userId)
|
||||||
addToast('Source added', 'success')
|
addToast('Source added', 'success')
|
||||||
setNewSourceUrl('')
|
setNewSourceUrl('')
|
||||||
setNewSourceInterval('')
|
setNewSourceInterval('')
|
||||||
|
|
@ -133,14 +145,22 @@ export default function CollectionDetails() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveSource = async (url) => {
|
const handleRemoveSource = async (url) => {
|
||||||
if (!window.confirm('Are you sure you want to remove this source?')) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Remove Source',
|
||||||
await agentCollectionsApi.removeSource(name, url)
|
message: 'Are you sure you want to remove this source?',
|
||||||
addToast('Source removed', 'success')
|
confirmLabel: 'Remove',
|
||||||
fetchSources()
|
danger: true,
|
||||||
} catch (err) {
|
onConfirm: async () => {
|
||||||
addToast(`Failed to remove source: ${err.message}`, 'error')
|
setConfirmDialog(null)
|
||||||
}
|
try {
|
||||||
|
await agentCollectionsApi.removeSource(name, url, userId)
|
||||||
|
addToast('Source removed', 'success')
|
||||||
|
fetchSources()
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to remove source: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -470,6 +490,16 @@ export default function CollectionDetails() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDialog}
|
||||||
|
title={confirmDialog?.title}
|
||||||
|
message={confirmDialog?.message}
|
||||||
|
confirmLabel={confirmDialog?.confirmLabel}
|
||||||
|
danger={confirmDialog?.danger}
|
||||||
|
onConfirm={confirmDialog?.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Entry content modal */}
|
{/* Entry content modal */}
|
||||||
{viewEntry && (
|
{viewEntry && (
|
||||||
<div className="collection-detail-modal-overlay" onClick={() => setViewEntry(null)}>
|
<div className="collection-detail-modal-overlay" onClick={() => setViewEntry(null)}>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,34 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useNavigate, useOutletContext } from 'react-router-dom'
|
import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||||
import { agentCollectionsApi } from '../utils/api'
|
import { agentCollectionsApi } from '../utils/api'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useUserMap } from '../hooks/useUserMap'
|
||||||
|
import UserGroupSection from '../components/UserGroupSection'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
|
||||||
export default function Collections() {
|
export default function Collections() {
|
||||||
const { addToast } = useOutletContext()
|
const { addToast } = useOutletContext()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { isAdmin, authEnabled, user } = useAuth()
|
||||||
|
const userMap = useUserMap()
|
||||||
const [collections, setCollections] = useState([])
|
const [collections, setCollections] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [newName, setNewName] = useState('')
|
const [newName, setNewName] = useState('')
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [userGroups, setUserGroups] = useState(null)
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
|
|
||||||
const fetchCollections = useCallback(async () => {
|
const fetchCollections = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await agentCollectionsApi.list()
|
const data = await agentCollectionsApi.list(isAdmin && authEnabled)
|
||||||
setCollections(Array.isArray(data.collections) ? data.collections : [])
|
setCollections(Array.isArray(data.collections) ? data.collections : [])
|
||||||
|
setUserGroups(data.user_groups || null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(`Failed to load collections: ${err.message}`, 'error')
|
addToast(`Failed to load collections: ${err.message}`, 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [addToast])
|
}, [addToast, isAdmin, authEnabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCollections()
|
fetchCollections()
|
||||||
|
|
@ -41,26 +50,42 @@ export default function Collections() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (name) => {
|
const handleDelete = (name, userId) => {
|
||||||
if (!window.confirm(`Delete collection "${name}"? This will remove all entries and cannot be undone.`)) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Delete Collection',
|
||||||
await agentCollectionsApi.reset(name)
|
message: `Delete collection "${name}"? This will remove all entries and cannot be undone.`,
|
||||||
addToast(`Collection "${name}" deleted`, 'success')
|
confirmLabel: 'Delete',
|
||||||
fetchCollections()
|
danger: true,
|
||||||
} catch (err) {
|
onConfirm: async () => {
|
||||||
addToast(`Failed to delete collection: ${err.message}`, 'error')
|
setConfirmDialog(null)
|
||||||
}
|
try {
|
||||||
|
await agentCollectionsApi.reset(name, userId)
|
||||||
|
addToast(`Collection "${name}" deleted`, 'success')
|
||||||
|
fetchCollections()
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to delete collection: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReset = async (name) => {
|
const handleReset = (name, userId) => {
|
||||||
if (!window.confirm(`Reset collection "${name}"? This will remove all entries but keep the collection.`)) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Reset Collection',
|
||||||
await agentCollectionsApi.reset(name)
|
message: `Reset collection "${name}"? This will remove all entries but keep the collection.`,
|
||||||
addToast(`Collection "${name}" reset`, 'success')
|
confirmLabel: 'Reset',
|
||||||
fetchCollections()
|
danger: true,
|
||||||
} catch (err) {
|
onConfirm: async () => {
|
||||||
addToast(`Failed to reset collection: ${err.message}`, 'error')
|
setConfirmDialog(null)
|
||||||
}
|
try {
|
||||||
|
await agentCollectionsApi.reset(name, userId)
|
||||||
|
addToast(`Collection "${name}" reset`, 'success')
|
||||||
|
fetchCollections()
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to reset collection: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -115,13 +140,21 @@ export default function Collections() {
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
|
||||||
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-text-muted)' }} />
|
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-text-muted)' }} />
|
||||||
</div>
|
</div>
|
||||||
) : collections.length === 0 ? (
|
) : collections.length === 0 && !userGroups ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-state-icon"><i className="fas fa-database" /></div>
|
<div className="empty-state-icon"><i className="fas fa-database" /></div>
|
||||||
<h2 className="empty-state-title">No collections yet</h2>
|
<h2 className="empty-state-title">No collections yet</h2>
|
||||||
<p className="empty-state-text">Create a collection above to start building your knowledge base.</p>
|
<p className="empty-state-text">
|
||||||
|
Collections let you organize documents into knowledge bases that agents can search using RAG (Retrieval-Augmented Generation).
|
||||||
|
Create a collection above to get started.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{userGroups && <h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>Your Collections</h2>}
|
||||||
|
{collections.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-md)' }}>You have no collections yet.</p>
|
||||||
|
) : (
|
||||||
<div className="collections-grid">
|
<div className="collections-grid">
|
||||||
{collections.map((collection) => {
|
{collections.map((collection) => {
|
||||||
const name = typeof collection === 'string' ? collection : collection.name
|
const name = typeof collection === 'string' ? collection : collection.name
|
||||||
|
|
@ -146,7 +179,55 @@ export default function Collections() {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{userGroups && (
|
||||||
|
<UserGroupSection
|
||||||
|
title="Other Users' Collections"
|
||||||
|
userGroups={userGroups}
|
||||||
|
userMap={userMap}
|
||||||
|
currentUserId={user?.id}
|
||||||
|
itemKey="collections"
|
||||||
|
renderGroup={(items, userId) => (
|
||||||
|
<div className="collections-grid">
|
||||||
|
{(items || []).map((col) => {
|
||||||
|
const name = typeof col === 'string' ? col : col.name
|
||||||
|
return (
|
||||||
|
<div className="card" key={name}>
|
||||||
|
<div className="collections-card-name">
|
||||||
|
<i className="fas fa-folder" style={{ marginRight: 'var(--spacing-xs)', color: 'var(--color-primary)' }} />
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
<div className="collections-card-actions">
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => navigate(`/app/collections/${encodeURIComponent(name)}?user_id=${encodeURIComponent(userId)}`)} title="View details">
|
||||||
|
<i className="fas fa-eye" /> Details
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={() => handleReset(name, userId)} title="Reset collection">
|
||||||
|
<i className="fas fa-rotate" /> Reset
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-danger btn-sm" onClick={() => handleDelete(name, userId)} title="Delete collection">
|
||||||
|
<i className="fas fa-trash" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDialog}
|
||||||
|
title={confirmDialog?.title}
|
||||||
|
message={confirmDialog?.message}
|
||||||
|
confirmLabel={confirmDialog?.confirmLabel}
|
||||||
|
danger={confirmDialog?.danger}
|
||||||
|
onConfirm={confirmDialog?.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,18 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useNavigate, useOutletContext } from 'react-router-dom'
|
import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||||
import { apiUrl } from '../utils/basePath'
|
import { apiUrl } from '../utils/basePath'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
import ModelSelector from '../components/ModelSelector'
|
import ModelSelector from '../components/ModelSelector'
|
||||||
import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
|
import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
import { useResources } from '../hooks/useResources'
|
import { useResources } from '../hooks/useResources'
|
||||||
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi } from '../utils/api'
|
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi } from '../utils/api'
|
||||||
import { API_CONFIG } from '../utils/config'
|
import { API_CONFIG } from '../utils/config'
|
||||||
|
|
||||||
const placeholderMessages = [
|
|
||||||
'What is the meaning of life?',
|
|
||||||
'Write a poem about AI',
|
|
||||||
'Explain quantum computing simply',
|
|
||||||
'Help me debug my code',
|
|
||||||
'Tell me a creative story',
|
|
||||||
'How do neural networks work?',
|
|
||||||
'Write a haiku about programming',
|
|
||||||
'Explain blockchain in simple terms',
|
|
||||||
'What are the best practices for REST APIs?',
|
|
||||||
'Help me write a cover letter',
|
|
||||||
'What is the Fibonacci sequence?',
|
|
||||||
'Explain the theory of relativity',
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { addToast } = useOutletContext()
|
const { addToast } = useOutletContext()
|
||||||
|
const { isAdmin } = useAuth()
|
||||||
const { resources } = useResources()
|
const { resources } = useResources()
|
||||||
const [configuredModels, setConfiguredModels] = useState(null)
|
const [configuredModels, setConfiguredModels] = useState(null)
|
||||||
const configuredModelsRef = useRef(configuredModels)
|
const configuredModelsRef = useRef(configuredModels)
|
||||||
|
|
@ -42,8 +30,7 @@ export default function Home() {
|
||||||
const [mcpServerCache, setMcpServerCache] = useState({})
|
const [mcpServerCache, setMcpServerCache] = useState({})
|
||||||
const [mcpSelectedServers, setMcpSelectedServers] = useState([])
|
const [mcpSelectedServers, setMcpSelectedServers] = useState([])
|
||||||
const [clientMCPSelectedIds, setClientMCPSelectedIds] = useState([])
|
const [clientMCPSelectedIds, setClientMCPSelectedIds] = useState([])
|
||||||
const [placeholderIdx, setPlaceholderIdx] = useState(0)
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
const [placeholderText, setPlaceholderText] = useState('')
|
|
||||||
const imageInputRef = useRef(null)
|
const imageInputRef = useRef(null)
|
||||||
const audioInputRef = useRef(null)
|
const audioInputRef = useRef(null)
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
@ -103,25 +90,6 @@ export default function Home() {
|
||||||
|
|
||||||
const allFiles = [...imageFiles, ...audioFiles, ...textFiles]
|
const allFiles = [...imageFiles, ...audioFiles, ...textFiles]
|
||||||
|
|
||||||
// Animated typewriter placeholder
|
|
||||||
useEffect(() => {
|
|
||||||
const target = placeholderMessages[placeholderIdx]
|
|
||||||
let charIdx = 0
|
|
||||||
setPlaceholderText('')
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
if (charIdx <= target.length) {
|
|
||||||
setPlaceholderText(target.slice(0, charIdx))
|
|
||||||
charIdx++
|
|
||||||
} else {
|
|
||||||
clearInterval(interval)
|
|
||||||
setTimeout(() => {
|
|
||||||
setPlaceholderIdx(prev => (prev + 1) % placeholderMessages.length)
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
}, 50)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [placeholderIdx])
|
|
||||||
|
|
||||||
const addFiles = useCallback(async (fileList, setter) => {
|
const addFiles = useCallback(async (fileList, setter) => {
|
||||||
const newFiles = []
|
const newFiles = []
|
||||||
for (const file of fileList) {
|
for (const file of fileList) {
|
||||||
|
|
@ -164,7 +132,7 @@ export default function Home() {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const doSubmit = useCallback(() => {
|
const doSubmit = useCallback(() => {
|
||||||
const text = message.trim() || placeholderText
|
const text = message.trim()
|
||||||
if (!text && allFiles.length === 0) return
|
if (!text && allFiles.length === 0) return
|
||||||
if (!selectedModel) {
|
if (!selectedModel) {
|
||||||
addToast('Please select a model first', 'warning')
|
addToast('Please select a model first', 'warning')
|
||||||
|
|
@ -182,7 +150,7 @@ export default function Home() {
|
||||||
}
|
}
|
||||||
localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData))
|
localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData))
|
||||||
navigate(`/app/chat/${encodeURIComponent(selectedModel)}`)
|
navigate(`/app/chat/${encodeURIComponent(selectedModel)}`)
|
||||||
}, [message, placeholderText, allFiles, selectedModel, mcpMode, mcpSelectedServers, clientMCPSelectedIds, addToast, navigate])
|
}, [message, allFiles, selectedModel, mcpMode, mcpSelectedServers, clientMCPSelectedIds, addToast, navigate])
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
if (e) e.preventDefault()
|
if (e) e.preventDefault()
|
||||||
|
|
@ -190,26 +158,41 @@ export default function Home() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStopModel = async (modelName) => {
|
const handleStopModel = async (modelName) => {
|
||||||
if (!confirm(`Stop model ${modelName}?`)) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Stop Model',
|
||||||
await backendControlApi.shutdown({ model: modelName })
|
message: `Stop model ${modelName}?`,
|
||||||
addToast(`Stopped ${modelName}`, 'success')
|
confirmLabel: `Stop ${modelName}`,
|
||||||
// Refresh loaded models list after a short delay
|
danger: true,
|
||||||
setTimeout(fetchSystemInfo, 500)
|
onConfirm: async () => {
|
||||||
} catch (err) {
|
setConfirmDialog(null)
|
||||||
addToast(`Failed to stop: ${err.message}`, 'error')
|
try {
|
||||||
}
|
await backendControlApi.shutdown({ model: modelName })
|
||||||
|
addToast(`Stopped ${modelName}`, 'success')
|
||||||
|
setTimeout(fetchSystemInfo, 500)
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to stop: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStopAll = async () => {
|
const handleStopAll = async () => {
|
||||||
if (!confirm('Stop all loaded models?')) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Stop All Models',
|
||||||
await Promise.all(loadedModels.map(m => backendControlApi.shutdown({ model: m.id })))
|
message: `Stop all ${loadedModels.length} loaded models?`,
|
||||||
addToast('All models stopped', 'success')
|
confirmLabel: 'Stop all',
|
||||||
setTimeout(fetchSystemInfo, 1000)
|
danger: true,
|
||||||
} catch (err) {
|
onConfirm: async () => {
|
||||||
addToast(`Failed to stop: ${err.message}`, 'error')
|
setConfirmDialog(null)
|
||||||
}
|
try {
|
||||||
|
await Promise.all(loadedModels.map(m => backendControlApi.shutdown({ model: m.id })))
|
||||||
|
addToast('All models stopped', 'success')
|
||||||
|
setTimeout(fetchSystemInfo, 1000)
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to stop: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelsLoading = configuredModels === null
|
const modelsLoading = configuredModels === null
|
||||||
|
|
@ -228,10 +211,27 @@ export default function Home() {
|
||||||
{/* Hero with logo */}
|
{/* Hero with logo */}
|
||||||
<div className="home-hero">
|
<div className="home-hero">
|
||||||
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="home-logo" />
|
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="home-logo" />
|
||||||
<h1 className="home-heading">How can I help you today?</h1>
|
|
||||||
<p className="home-subheading">Ask me anything, and I'll do my best to assist you.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Resource monitor - prominent placement */}
|
||||||
|
{resources && (
|
||||||
|
<div className="home-resource-bar">
|
||||||
|
<div className="home-resource-bar-header">
|
||||||
|
<i className={`fas ${resType === 'gpu' ? 'fa-microchip' : 'fa-memory'}`} />
|
||||||
|
<span className="home-resource-label">{resType === 'gpu' ? 'GPU' : 'RAM'}</span>
|
||||||
|
<span className="home-resource-pct" style={{ color: pctColor }}>
|
||||||
|
{usagePct.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="home-resource-track">
|
||||||
|
<div
|
||||||
|
className="home-resource-fill"
|
||||||
|
style={{ width: `${usagePct}%`, background: pctColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Chat input form */}
|
{/* Chat input form */}
|
||||||
<div className="home-chat-card">
|
<div className="home-chat-card">
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
|
|
@ -274,13 +274,13 @@ export default function Home() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Textarea with attach buttons */}
|
{/* Input container with inline send */}
|
||||||
<div className="home-input-area">
|
<div className="home-input-container">
|
||||||
<textarea
|
<textarea
|
||||||
className="home-textarea"
|
className="home-textarea"
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
placeholder={placeholderText}
|
placeholder="Message..."
|
||||||
rows={3}
|
rows={3}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
|
@ -289,65 +289,55 @@ export default function Home() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="home-attach-buttons">
|
<div className="home-input-footer">
|
||||||
<button type="button" className="home-attach-btn" onClick={() => imageInputRef.current?.click()} title="Attach image">
|
<div className="home-attach-buttons">
|
||||||
<i className="fas fa-image" />
|
<button type="button" className="home-attach-btn" onClick={() => imageInputRef.current?.click()} title="Attach image">
|
||||||
</button>
|
<i className="fas fa-image" />
|
||||||
<button type="button" className="home-attach-btn" onClick={() => audioInputRef.current?.click()} title="Attach audio">
|
</button>
|
||||||
<i className="fas fa-microphone" />
|
<button type="button" className="home-attach-btn" onClick={() => audioInputRef.current?.click()} title="Attach audio">
|
||||||
</button>
|
<i className="fas fa-microphone" />
|
||||||
<button type="button" className="home-attach-btn" onClick={() => fileInputRef.current?.click()} title="Attach file">
|
</button>
|
||||||
<i className="fas fa-file" />
|
<button type="button" className="home-attach-btn" onClick={() => fileInputRef.current?.click()} title="Attach file">
|
||||||
|
<i className="fas fa-file" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="home-input-hint">Enter to send</span>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="home-send-btn"
|
||||||
|
disabled={!selectedModel}
|
||||||
|
title={!selectedModel ? 'Select a model first' : 'Send message'}
|
||||||
|
>
|
||||||
|
<i className="fas fa-arrow-up" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input ref={imageInputRef} type="file" multiple accept="image/*" style={{ display: 'none' }} onChange={(e) => addFiles(e.target.files, setImageFiles)} />
|
<input ref={imageInputRef} type="file" multiple accept="image/*" style={{ display: 'none' }} onChange={(e) => addFiles(e.target.files, setImageFiles)} />
|
||||||
<input ref={audioInputRef} type="file" multiple accept="audio/*" style={{ display: 'none' }} onChange={(e) => addFiles(e.target.files, setAudioFiles)} />
|
<input ref={audioInputRef} type="file" multiple accept="audio/*" style={{ display: 'none' }} onChange={(e) => addFiles(e.target.files, setAudioFiles)} />
|
||||||
<input ref={fileInputRef} type="file" multiple accept=".txt,.md,.pdf" style={{ display: 'none' }} onChange={(e) => addFiles(e.target.files, setTextFiles)} />
|
<input ref={fileInputRef} type="file" multiple accept=".txt,.md,.pdf" style={{ display: 'none' }} onChange={(e) => addFiles(e.target.files, setTextFiles)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="home-send-btn"
|
|
||||||
disabled={!selectedModel}
|
|
||||||
>
|
|
||||||
<i className="fas fa-paper-plane" /> Send
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick links */}
|
{/* Quick links */}
|
||||||
<div className="home-quick-links">
|
<div className="home-quick-links">
|
||||||
<button className="home-link-btn" onClick={() => navigate('/app/manage')}>
|
{isAdmin && (
|
||||||
<i className="fas fa-desktop" /> Installed Models and Backends
|
<>
|
||||||
</button>
|
<button className="home-link-btn" onClick={() => navigate('/app/manage')}>
|
||||||
<button className="home-link-btn" onClick={() => navigate('/app/models')}>
|
<i className="fas fa-desktop" /> Installed Models
|
||||||
<i className="fas fa-download" /> Browse Gallery
|
</button>
|
||||||
</button>
|
<button className="home-link-btn" onClick={() => navigate('/app/models')}>
|
||||||
<button className="home-link-btn" onClick={() => navigate('/app/import-model')}>
|
<i className="fas fa-download" /> Browse Gallery
|
||||||
<i className="fas fa-upload" /> Import Model
|
</button>
|
||||||
</button>
|
<button className="home-link-btn" onClick={() => navigate('/app/import-model')}>
|
||||||
|
<i className="fas fa-upload" /> Import Model
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<a className="home-link-btn" href="https://localai.io" target="_blank" rel="noopener noreferrer">
|
<a className="home-link-btn" href="https://localai.io" target="_blank" rel="noopener noreferrer">
|
||||||
<i className="fas fa-book" /> Documentation
|
<i className="fas fa-book" /> Documentation
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compact resource indicator */}
|
|
||||||
{resources && (
|
|
||||||
<div className="home-resource-pill">
|
|
||||||
<i className={`fas ${resType === 'gpu' ? 'fa-microchip' : 'fa-memory'}`} />
|
|
||||||
<span className="home-resource-label">{resType === 'gpu' ? 'GPU' : 'RAM'}</span>
|
|
||||||
<span className="home-resource-pct" style={{ color: pctColor }}>
|
|
||||||
{usagePct.toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
<div className="home-resource-bar-track">
|
|
||||||
<div
|
|
||||||
className="home-resource-bar-fill"
|
|
||||||
style={{ width: `${usagePct}%`, background: pctColor }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loaded models status */}
|
{/* Loaded models status */}
|
||||||
{loadedCount > 0 && (
|
{loadedCount > 0 && (
|
||||||
<div className="home-loaded-models">
|
<div className="home-loaded-models">
|
||||||
|
|
@ -371,66 +361,39 @@ export default function Home() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : isAdmin ? (
|
||||||
/* No models installed wizard */
|
/* No models installed - compact getting started */
|
||||||
<div className="home-wizard">
|
<div className="home-wizard">
|
||||||
<div className="home-wizard-hero">
|
<div className="home-wizard-hero">
|
||||||
<h1>No Models Installed</h1>
|
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="home-logo" />
|
||||||
<p>Get started with LocalAI by installing your first model. Browse our gallery of open-source AI models.</p>
|
<h1>Get started with LocalAI</h1>
|
||||||
|
<p>Install your first model to begin. Browse the gallery or import your own.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feature preview cards */}
|
|
||||||
<div className="home-wizard-features">
|
|
||||||
<div className="home-wizard-feature">
|
|
||||||
<div className="home-wizard-feature-icon" style={{ background: 'var(--color-primary-light)' }}>
|
|
||||||
<i className="fas fa-images" style={{ color: 'var(--color-primary)' }} />
|
|
||||||
</div>
|
|
||||||
<h3>Model Gallery</h3>
|
|
||||||
<p>Browse and install from a curated collection of open-source AI models</p>
|
|
||||||
</div>
|
|
||||||
<div className="home-wizard-feature" onClick={() => navigate('/app/import-model')} style={{ cursor: 'pointer' }}>
|
|
||||||
<div className="home-wizard-feature-icon" style={{ background: 'var(--color-accent-light)' }}>
|
|
||||||
<i className="fas fa-upload" style={{ color: 'var(--color-accent)' }} />
|
|
||||||
</div>
|
|
||||||
<h3>Import Models</h3>
|
|
||||||
<p>Import your own models from HuggingFace or local files</p>
|
|
||||||
</div>
|
|
||||||
<div className="home-wizard-feature">
|
|
||||||
<div className="home-wizard-feature-icon" style={{ background: 'var(--color-success-light)' }}>
|
|
||||||
<i className="fas fa-code" style={{ color: 'var(--color-success)' }} />
|
|
||||||
</div>
|
|
||||||
<h3>API Download</h3>
|
|
||||||
<p>Use the API to download and configure models programmatically</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Setup steps */}
|
|
||||||
<div className="home-wizard-steps card">
|
<div className="home-wizard-steps card">
|
||||||
<h2>How to Get Started</h2>
|
|
||||||
<div className="home-wizard-step">
|
<div className="home-wizard-step">
|
||||||
<div className="home-wizard-step-num">1</div>
|
<div className="home-wizard-step-num">1</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Browse the Model Gallery</strong>
|
<strong>Browse the Model Gallery</strong>
|
||||||
<p>Visit the model gallery to find the right model for your needs.</p>
|
<p>Find the right model for your needs from our curated collection.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="home-wizard-step">
|
<div className="home-wizard-step">
|
||||||
<div className="home-wizard-step-num">2</div>
|
<div className="home-wizard-step-num">2</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Install a Model</strong>
|
<strong>Install a Model</strong>
|
||||||
<p>Click install on any model to download and configure it automatically.</p>
|
<p>Click install to download and configure it automatically.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="home-wizard-step">
|
<div className="home-wizard-step">
|
||||||
<div className="home-wizard-step-num">3</div>
|
<div className="home-wizard-step-num">3</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Start Chatting</strong>
|
<strong>Start Chatting</strong>
|
||||||
<p>Once installed, you can chat with your model right from the browser.</p>
|
<p>Chat with your model right from the browser or use the API.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<div className="home-wizard-actions">
|
<div className="home-wizard-actions">
|
||||||
<button className="btn btn-primary" onClick={() => navigate('/app/models')}>
|
<button className="btn btn-primary" onClick={() => navigate('/app/models')}>
|
||||||
<i className="fas fa-store" /> Browse Model Gallery
|
<i className="fas fa-store" /> Browse Model Gallery
|
||||||
|
|
@ -439,357 +402,35 @@ export default function Home() {
|
||||||
<i className="fas fa-upload" /> Import Model
|
<i className="fas fa-upload" /> Import Model
|
||||||
</button>
|
</button>
|
||||||
<a className="btn btn-secondary" href="https://localai.io/docs/getting-started" target="_blank" rel="noopener noreferrer">
|
<a className="btn btn-secondary" href="https://localai.io/docs/getting-started" target="_blank" rel="noopener noreferrer">
|
||||||
<i className="fas fa-book" /> Getting Started
|
<i className="fas fa-book" /> Docs
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* No models available (non-admin) */
|
||||||
|
<div className="home-wizard">
|
||||||
|
<div className="home-wizard-hero">
|
||||||
|
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="home-logo" />
|
||||||
|
<h1>No Models Available</h1>
|
||||||
|
<p>There are no models installed yet. Ask your administrator to set up models so you can start chatting.</p>
|
||||||
|
</div>
|
||||||
|
<div className="home-wizard-actions">
|
||||||
|
<a className="btn btn-secondary" href="https://localai.io" target="_blank" rel="noopener noreferrer">
|
||||||
|
<i className="fas fa-book" /> Documentation
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<style>{`
|
<ConfirmDialog
|
||||||
.home-page {
|
open={!!confirmDialog}
|
||||||
flex: 1;
|
title={confirmDialog?.title}
|
||||||
display: flex;
|
message={confirmDialog?.message}
|
||||||
flex-direction: column;
|
confirmLabel={confirmDialog?.confirmLabel}
|
||||||
align-items: center;
|
danger={confirmDialog?.danger}
|
||||||
justify-content: center;
|
onConfirm={confirmDialog?.onConfirm}
|
||||||
max-width: 48rem;
|
onCancel={() => setConfirmDialog(null)}
|
||||||
margin: 0 auto;
|
/>
|
||||||
padding: var(--spacing-xl);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.home-hero {
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--spacing-lg) 0;
|
|
||||||
}
|
|
||||||
.home-logo {
|
|
||||||
width: 80px;
|
|
||||||
height: auto;
|
|
||||||
margin: 0 auto var(--spacing-md);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.home-heading {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
.home-subheading {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Chat card */
|
|
||||||
.home-chat-card {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border: 1px solid var(--color-border-subtle);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
.home-model-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
.home-file-tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
.home-file-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
background: var(--color-bg-tertiary);
|
|
||||||
border: 1px solid var(--color-border-subtle);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
.home-file-tag button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 0.625rem;
|
|
||||||
}
|
|
||||||
.home-input-area {
|
|
||||||
position: relative;
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
.home-textarea {
|
|
||||||
width: 100%;
|
|
||||||
background: var(--color-bg-tertiary);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
border: 1px solid var(--color-border-default);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
|
||||||
padding-right: 7rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-family: inherit;
|
|
||||||
outline: none;
|
|
||||||
resize: none;
|
|
||||||
min-height: 80px;
|
|
||||||
transition: border-color var(--duration-fast);
|
|
||||||
}
|
|
||||||
.home-textarea:focus { border-color: var(--color-border-strong); }
|
|
||||||
.home-attach-buttons {
|
|
||||||
position: absolute;
|
|
||||||
right: var(--spacing-sm);
|
|
||||||
bottom: var(--spacing-sm);
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.home-attach-btn {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 6px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
transition: color var(--duration-fast);
|
|
||||||
}
|
|
||||||
.home-attach-btn:hover { color: var(--color-primary); }
|
|
||||||
.home-send-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
padding: var(--spacing-sm) var(--spacing-lg);
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: var(--color-primary-text);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-left: auto;
|
|
||||||
transition: background var(--duration-fast);
|
|
||||||
}
|
|
||||||
.home-send-btn:hover:not(:disabled) { background: var(--color-primary-hover); }
|
|
||||||
.home-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
|
|
||||||
/* Quick links */
|
|
||||||
.home-quick-links {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
justify-content: center;
|
|
||||||
margin: var(--spacing-md) 0;
|
|
||||||
}
|
|
||||||
.home-link-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
padding: var(--spacing-xs) var(--spacing-md);
|
|
||||||
background: var(--color-bg-tertiary);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
border: 1px solid var(--color-border-subtle);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all var(--duration-fast);
|
|
||||||
}
|
|
||||||
.home-link-btn:hover {
|
|
||||||
border-color: var(--color-primary-border);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Resource pill */
|
|
||||||
.home-resource-pill {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
padding: var(--spacing-xs) var(--spacing-sm);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border: 1px solid var(--color-border-subtle);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin: var(--spacing-sm) 0;
|
|
||||||
}
|
|
||||||
.home-resource-label {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.home-resource-pct {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.home-resource-bar-track {
|
|
||||||
width: 16px;
|
|
||||||
height: 6px;
|
|
||||||
background: var(--color-bg-tertiary);
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.home-resource-bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: width 500ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loaded models */
|
|
||||||
.home-loaded-models {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
padding: var(--spacing-sm);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border: 1px solid var(--color-border-subtle);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.home-loaded-dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-success);
|
|
||||||
}
|
|
||||||
.home-loaded-text {
|
|
||||||
font-weight: 500;
|
|
||||||
margin-right: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
.home-loaded-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
.home-loaded-item {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
background: var(--color-bg-tertiary);
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
.home-loaded-item button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-error);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 0.625rem;
|
|
||||||
}
|
|
||||||
.home-stop-all {
|
|
||||||
margin-left: auto;
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--color-error);
|
|
||||||
color: var(--color-error);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* No models wizard */
|
|
||||||
.home-wizard {
|
|
||||||
max-width: 48rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.home-wizard-hero {
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--spacing-xl) 0;
|
|
||||||
}
|
|
||||||
.home-wizard-hero h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: var(--spacing-sm);
|
|
||||||
}
|
|
||||||
.home-wizard-hero p {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
}
|
|
||||||
.home-wizard-features {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
margin-bottom: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
.home-wizard-feature {
|
|
||||||
text-align: center;
|
|
||||||
padding: var(--spacing-md);
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
border: 1px solid var(--color-border-subtle);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
}
|
|
||||||
.home-wizard-feature-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0 auto var(--spacing-sm);
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
.home-wizard-feature h3 {
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: var(--spacing-xs);
|
|
||||||
}
|
|
||||||
.home-wizard-feature p {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.home-wizard-steps {
|
|
||||||
margin-bottom: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
.home-wizard-steps h2 {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: var(--spacing-md);
|
|
||||||
}
|
|
||||||
.home-wizard-step {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-md);
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: var(--spacing-sm) 0;
|
|
||||||
}
|
|
||||||
.home-wizard-step-num {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: white;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.home-wizard-step strong {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.home-wizard-step p {
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.home-wizard-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-sm);
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.home-wizard-features {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,58 +1,368 @@
|
||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { apiUrl } from '../utils/basePath'
|
import { apiUrl } from '../utils/basePath'
|
||||||
|
import './auth.css'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [token, setToken] = useState('')
|
const { code: urlInviteCode } = useParams()
|
||||||
const [error, setError] = useState('')
|
const { authEnabled, user, loading: authLoading, refresh } = useAuth()
|
||||||
|
const [providers, setProviders] = useState([])
|
||||||
|
const [hasUsers, setHasUsers] = useState(true)
|
||||||
|
const [registrationMode, setRegistrationMode] = useState('open')
|
||||||
|
const [statusLoading, setStatusLoading] = useState(true)
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
// Form state
|
||||||
|
const [mode, setMode] = useState('login') // 'login' or 'register'
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
|
const [inviteCode, setInviteCode] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [showTokenLogin, setShowTokenLogin] = useState(false)
|
||||||
|
const [token, setToken] = useState('')
|
||||||
|
|
||||||
|
const extractError = (data, fallback) => {
|
||||||
|
if (!data) return fallback
|
||||||
|
if (typeof data.error === 'string') return data.error
|
||||||
|
if (data.error && typeof data.error === 'object') return data.error.message || fallback
|
||||||
|
if (typeof data.message === 'string') return data.message
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fill invite code from URL and switch to register mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlInviteCode) {
|
||||||
|
setInviteCode(urlInviteCode)
|
||||||
|
setMode('register')
|
||||||
|
}
|
||||||
|
}, [urlInviteCode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(apiUrl('/api/auth/status'))
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
setProviders(data.providers || [])
|
||||||
|
setHasUsers(data.hasUsers !== false)
|
||||||
|
setRegistrationMode(data.registrationMode || 'open')
|
||||||
|
if (!data.hasUsers) setMode('register')
|
||||||
|
setStatusLoading(false)
|
||||||
|
})
|
||||||
|
.catch(() => setStatusLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Redirect if auth is disabled or user is already logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authLoading && (!authEnabled || user)) {
|
||||||
|
navigate('/app', { replace: true })
|
||||||
|
}
|
||||||
|
}, [authLoading, authEnabled, user, navigate])
|
||||||
|
|
||||||
|
const handleEmailLogin = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setMessage('')
|
||||||
|
setSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(apiUrl('/api/auth/login'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(extractError(data, 'Login failed'))
|
||||||
|
setSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await refresh()
|
||||||
|
} catch {
|
||||||
|
setError('Network error')
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRegister = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setMessage('')
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = { email, password, name }
|
||||||
|
if (inviteCode) {
|
||||||
|
body.inviteCode = inviteCode
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(apiUrl('/api/auth/register'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(extractError(data, 'Registration failed'))
|
||||||
|
setSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.pending) {
|
||||||
|
setMessage(data.message || 'Registration successful, awaiting approval.')
|
||||||
|
setSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full reload so the auth provider picks up the new session cookie
|
||||||
|
window.location.href = '/app'
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
setError('Network error')
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTokenLogin = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!token.trim()) {
|
if (!token.trim()) {
|
||||||
setError('Please enter a token')
|
setError('Please enter a token')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Set token as cookie
|
setError('')
|
||||||
document.cookie = `token=${encodeURIComponent(token.trim())}; path=/; SameSite=Strict`
|
setSubmitting(true)
|
||||||
navigate('/app')
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(apiUrl('/api/auth/token-login'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: token.trim() }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(extractError(data, 'Invalid token'))
|
||||||
|
setSubmitting(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await refresh()
|
||||||
|
} catch {
|
||||||
|
setError('Network error')
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (authLoading || statusLoading) return null
|
||||||
|
|
||||||
|
const hasGitHub = providers.includes('github')
|
||||||
|
const hasOIDC = providers.includes('oidc')
|
||||||
|
const hasLocal = providers.includes('local')
|
||||||
|
const hasOAuth = hasGitHub || hasOIDC
|
||||||
|
const showInviteField = (registrationMode === 'invite' || registrationMode === 'approval') && mode === 'register' && hasUsers
|
||||||
|
const inviteRequired = registrationMode === 'invite' && hasUsers
|
||||||
|
|
||||||
|
// Build OAuth login URLs with invite code if present
|
||||||
|
const githubLoginUrl = inviteCode
|
||||||
|
? apiUrl(`/api/auth/github/login?invite_code=${encodeURIComponent(inviteCode)}`)
|
||||||
|
: apiUrl('/api/auth/github/login')
|
||||||
|
|
||||||
|
const oidcLoginUrl = inviteCode
|
||||||
|
? apiUrl(`/api/auth/oidc/login?invite_code=${encodeURIComponent(inviteCode)}`)
|
||||||
|
: apiUrl('/api/auth/oidc/login')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div className="login-page">
|
||||||
minHeight: '100vh',
|
<div className="card login-card">
|
||||||
background: 'var(--color-bg-primary)',
|
<div className="login-header">
|
||||||
display: 'flex',
|
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="login-logo" />
|
||||||
alignItems: 'center',
|
<p className="login-subtitle">
|
||||||
justifyContent: 'center',
|
{!hasUsers ? 'Create your admin account' : mode === 'register' ? 'Create an account' : 'Sign in to continue'}
|
||||||
padding: 'var(--spacing-xl)',
|
</p>
|
||||||
}}>
|
|
||||||
<div className="card" style={{ width: '100%', maxWidth: '400px', padding: 'var(--spacing-xl)' }}>
|
|
||||||
<div style={{ textAlign: 'center', marginBottom: 'var(--spacing-xl)' }}>
|
|
||||||
<img src={apiUrl('/static/logo.png')} alt="LocalAI" style={{ width: 64, height: 64, marginBottom: 'var(--spacing-md)' }} />
|
|
||||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: 'var(--spacing-xs)' }}>
|
|
||||||
<span className="text-gradient">LocalAI</span>
|
|
||||||
</h1>
|
|
||||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>Enter your API token to continue</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
{error && (
|
||||||
<div className="form-group">
|
<div className="login-alert login-alert-error">{error}</div>
|
||||||
<label className="form-label">API Token</label>
|
)}
|
||||||
<input
|
|
||||||
className="input"
|
{message && (
|
||||||
type="password"
|
<div className="login-alert login-alert-success">{message}</div>
|
||||||
value={token}
|
)}
|
||||||
onChange={(e) => { setToken(e.target.value); setError('') }}
|
|
||||||
placeholder="Enter token..."
|
{hasGitHub && (
|
||||||
autoFocus
|
<a
|
||||||
/>
|
href={githubLoginUrl}
|
||||||
{error && <p style={{ color: 'var(--color-error)', fontSize: '0.8125rem', marginTop: 'var(--spacing-xs)' }}>{error}</p>}
|
className="btn btn-primary login-btn-full"
|
||||||
</div>
|
style={{ marginBottom: hasOIDC ? '0.5rem' : undefined }}
|
||||||
<button type="submit" className="btn btn-primary" style={{ width: '100%' }}>
|
>
|
||||||
<i className="fas fa-sign-in-alt" /> Login
|
<i className="fab fa-github" /> Sign in with GitHub
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasOIDC && (
|
||||||
|
<a
|
||||||
|
href={oidcLoginUrl}
|
||||||
|
className="btn btn-primary login-btn-full"
|
||||||
|
>
|
||||||
|
<i className="fas fa-sign-in-alt" /> Sign in with SSO
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasOAuth && hasLocal && (
|
||||||
|
<div className="login-divider">or</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasLocal && mode === 'login' && (
|
||||||
|
<form onSubmit={handleEmailLogin}>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Email</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => { setEmail(e.target.value); setError('') }}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoFocus={!hasGitHub}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Password</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => { setPassword(e.target.value); setError('') }}
|
||||||
|
placeholder="Enter password..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary login-btn-full" disabled={submitting}>
|
||||||
|
{submitting ? 'Signing in...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
<p className="login-footer">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<button type="button" className="login-link" onClick={() => { setMode('register'); setError(''); setMessage('') }}>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasLocal && mode === 'register' && (
|
||||||
|
<form onSubmit={handleRegister}>
|
||||||
|
{showInviteField && (
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">
|
||||||
|
Invite Code{inviteRequired ? '' : ' (optional — skip the approval wait)'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
value={inviteCode}
|
||||||
|
onChange={(e) => { setInviteCode(e.target.value); setError('') }}
|
||||||
|
placeholder="Paste your invite code..."
|
||||||
|
required={inviteRequired}
|
||||||
|
readOnly={!!urlInviteCode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Email</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => { setEmail(e.target.value); setError('') }}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Name</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Your name (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Password</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => { setPassword(e.target.value); setError('') }}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="form-label">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => { setConfirmPassword(e.target.value); setError('') }}
|
||||||
|
placeholder="Repeat password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary login-btn-full" disabled={submitting}>
|
||||||
|
{submitting ? 'Creating account...' : !hasUsers ? 'Create Admin Account' : 'Register'}
|
||||||
|
</button>
|
||||||
|
{hasUsers && (
|
||||||
|
<p className="login-footer">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<button type="button" className="login-link" onClick={() => { setMode('login'); setError(''); setMessage('') }}>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Token login fallback */}
|
||||||
|
<div className="login-token-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowTokenLogin(!showTokenLogin)}
|
||||||
|
>
|
||||||
|
{showTokenLogin ? 'Hide token login' : 'Login with API Token'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
{showTokenLogin && (
|
||||||
|
<form onSubmit={handleTokenLogin} className="login-token-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => { setToken(e.target.value); setError('') }}
|
||||||
|
placeholder="Enter API token..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-secondary login-btn-full" disabled={submitting}>
|
||||||
|
<i className="fas fa-key" /> Login with Token
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
|
||||||
import ResourceMonitor from '../components/ResourceMonitor'
|
import ResourceMonitor from '../components/ResourceMonitor'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
import { useModels } from '../hooks/useModels'
|
import { useModels } from '../hooks/useModels'
|
||||||
import { backendControlApi, modelsApi, backendsApi, systemApi } from '../utils/api'
|
import { backendControlApi, modelsApi, backendsApi, systemApi } from '../utils/api'
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ export default function Manage() {
|
||||||
const [backendsLoading, setBackendsLoading] = useState(true)
|
const [backendsLoading, setBackendsLoading] = useState(true)
|
||||||
const [reloading, setReloading] = useState(false)
|
const [reloading, setReloading] = useState(false)
|
||||||
const [reinstallingBackends, setReinstallingBackends] = useState(new Set())
|
const [reinstallingBackends, setReinstallingBackends] = useState(new Set())
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
|
|
||||||
const handleTabChange = (tab) => {
|
const handleTabChange = (tab) => {
|
||||||
setActiveTab(tab)
|
setActiveTab(tab)
|
||||||
|
|
@ -55,27 +57,43 @@ export default function Manage() {
|
||||||
fetchBackends()
|
fetchBackends()
|
||||||
}, [fetchLoadedModels, fetchBackends])
|
}, [fetchLoadedModels, fetchBackends])
|
||||||
|
|
||||||
const handleStopModel = async (modelName) => {
|
const handleStopModel = (modelName) => {
|
||||||
if (!confirm(`Stop model ${modelName}?`)) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Stop Model',
|
||||||
await backendControlApi.shutdown({ model: modelName })
|
message: `Stop model ${modelName}?`,
|
||||||
addToast(`Stopped ${modelName}`, 'success')
|
confirmLabel: 'Stop',
|
||||||
setTimeout(fetchLoadedModels, 500)
|
danger: true,
|
||||||
} catch (err) {
|
onConfirm: async () => {
|
||||||
addToast(`Failed to stop: ${err.message}`, 'error')
|
setConfirmDialog(null)
|
||||||
}
|
try {
|
||||||
|
await backendControlApi.shutdown({ model: modelName })
|
||||||
|
addToast(`Stopped ${modelName}`, 'success')
|
||||||
|
setTimeout(fetchLoadedModels, 500)
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to stop: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteModel = async (modelName) => {
|
const handleDeleteModel = (modelName) => {
|
||||||
if (!confirm(`Delete model ${modelName}? This cannot be undone.`)) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Delete Model',
|
||||||
await modelsApi.deleteByName(modelName)
|
message: `Delete model ${modelName}? This cannot be undone.`,
|
||||||
addToast(`Deleted ${modelName}`, 'success')
|
confirmLabel: 'Delete',
|
||||||
refetchModels()
|
danger: true,
|
||||||
fetchLoadedModels()
|
onConfirm: async () => {
|
||||||
} catch (err) {
|
setConfirmDialog(null)
|
||||||
addToast(`Failed to delete: ${err.message}`, 'error')
|
try {
|
||||||
}
|
await modelsApi.deleteByName(modelName)
|
||||||
|
addToast(`Deleted ${modelName}`, 'success')
|
||||||
|
refetchModels()
|
||||||
|
fetchLoadedModels()
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to delete: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReload = async () => {
|
const handleReload = async () => {
|
||||||
|
|
@ -106,15 +124,23 @@ export default function Manage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteBackend = async (name) => {
|
const handleDeleteBackend = (name) => {
|
||||||
if (!confirm(`Delete backend ${name}?`)) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Delete Backend',
|
||||||
await backendsApi.deleteInstalled(name)
|
message: `Delete backend ${name}?`,
|
||||||
addToast(`Deleted backend ${name}`, 'success')
|
confirmLabel: 'Delete',
|
||||||
fetchBackends()
|
danger: true,
|
||||||
} catch (err) {
|
onConfirm: async () => {
|
||||||
addToast(`Failed to delete backend: ${err.message}`, 'error')
|
setConfirmDialog(null)
|
||||||
}
|
try {
|
||||||
|
await backendsApi.deleteInstalled(name)
|
||||||
|
addToast(`Deleted backend ${name}`, 'success')
|
||||||
|
fetchBackends()
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to delete backend: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -379,6 +405,16 @@ export default function Manage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDialog}
|
||||||
|
title={confirmDialog?.title}
|
||||||
|
message={confirmDialog?.message}
|
||||||
|
confirmLabel={confirmDialog?.confirmLabel}
|
||||||
|
danger={confirmDialog?.danger}
|
||||||
|
onConfirm={confirmDialog?.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,40 +4,16 @@ import { modelsApi } from '../utils/api'
|
||||||
import { useOperations } from '../hooks/useOperations'
|
import { useOperations } from '../hooks/useOperations'
|
||||||
import { useResources } from '../hooks/useResources'
|
import { useResources } from '../hooks/useResources'
|
||||||
import SearchableSelect from '../components/SearchableSelect'
|
import SearchableSelect from '../components/SearchableSelect'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
|
|
||||||
const LOADING_PHRASES = [
|
const LOADING_PHRASES = [
|
||||||
{ text: 'Rounding up the neural networks...', icon: 'fa-brain' },
|
{ text: 'Loading models...', icon: 'fa-brain' },
|
||||||
{ text: 'Asking the models to line up nicely...', icon: 'fa-people-line' },
|
{ text: 'Fetching gallery...', icon: 'fa-download' },
|
||||||
{ text: 'Convincing transformers to transform...', icon: 'fa-wand-magic-sparkles' },
|
{ text: 'Checking availability...', icon: 'fa-circle-check' },
|
||||||
{ text: 'Herding digital llamas...', icon: 'fa-horse' },
|
{ text: 'Almost ready...', icon: 'fa-hourglass-half' },
|
||||||
{ text: 'Downloading more RAM... just kidding', icon: 'fa-memory' },
|
{ text: 'Preparing gallery...', icon: 'fa-store' },
|
||||||
{ text: 'Counting parameters... lost count at a billion', icon: 'fa-calculator' },
|
|
||||||
{ text: 'Untangling attention heads...', icon: 'fa-diagram-project' },
|
|
||||||
{ text: 'Warming up the GPUs...', icon: 'fa-fire' },
|
|
||||||
{ text: 'Teaching AI to sit and stay...', icon: 'fa-graduation-cap' },
|
|
||||||
{ text: 'Polishing the weights and biases...', icon: 'fa-gem' },
|
|
||||||
{ text: 'Stacking layers like pancakes...', icon: 'fa-layer-group' },
|
|
||||||
{ text: 'Negotiating with the token budget...', icon: 'fa-coins' },
|
|
||||||
{ text: 'Fetching models from the cloud mines...', icon: 'fa-cloud-arrow-down' },
|
|
||||||
{ text: 'Calibrating the vibe check algorithm...', icon: 'fa-gauge-high' },
|
|
||||||
{ text: 'Optimizing inference with good intentions...', icon: 'fa-bolt' },
|
|
||||||
{ text: 'Measuring GPU with a ruler...', icon: 'fa-ruler' },
|
|
||||||
{ text: 'Will it fit? Asking the VRAM oracle...', icon: 'fa-microchip' },
|
|
||||||
{ text: 'Playing Tetris with model layers...', icon: 'fa-cubes' },
|
|
||||||
{ text: 'Checking if we need more RGB...', icon: 'fa-rainbow' },
|
|
||||||
{ text: 'Squeezing tensors into memory...', icon: 'fa-compress' },
|
|
||||||
{ text: 'Whispering sweet nothings to CUDA cores...', icon: 'fa-heart' },
|
|
||||||
{ text: 'Asking the electrons to scoot over...', icon: 'fa-atom' },
|
|
||||||
{ text: 'Defragmenting the flux capacitor...', icon: 'fa-clock-rotate-left' },
|
|
||||||
{ text: 'Consulting the tensor gods...', icon: 'fa-hands-praying' },
|
|
||||||
{ text: 'Checking under the GPU\'s hood...', icon: 'fa-car' },
|
|
||||||
{ text: 'Seeing if the hamsters can run faster...', icon: 'fa-fan' },
|
|
||||||
{ text: 'Running very important math... carry the 1...', icon: 'fa-square-root-variable' },
|
|
||||||
{ text: 'Poking the memory bus gently...', icon: 'fa-bus' },
|
|
||||||
{ text: 'Bribing the scheduler with clock cycles...', icon: 'fa-stopwatch' },
|
|
||||||
{ text: 'Asking models to share their VRAM nicely...', icon: 'fa-handshake' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function GalleryLoader() {
|
function GalleryLoader() {
|
||||||
|
|
@ -142,6 +118,7 @@ export default function Models() {
|
||||||
const [backendFilter, setBackendFilter] = useState('')
|
const [backendFilter, setBackendFilter] = useState('')
|
||||||
const [allBackends, setAllBackends] = useState([])
|
const [allBackends, setAllBackends] = useState([])
|
||||||
const debounceRef = useRef(null)
|
const debounceRef = useRef(null)
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
|
|
||||||
// Total GPU memory for "fits" check
|
// Total GPU memory for "fits" check
|
||||||
const totalGpuMemory = resources?.aggregate?.total_memory || 0
|
const totalGpuMemory = resources?.aggregate?.total_memory || 0
|
||||||
|
|
@ -216,15 +193,24 @@ export default function Models() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (modelId) => {
|
const handleDelete = (modelId) => {
|
||||||
if (!confirm(`Delete model ${modelId}?`)) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Delete Model',
|
||||||
await modelsApi.delete(modelId)
|
message: `Delete model ${modelId}?`,
|
||||||
addToast(`Deleting ${modelId}...`, 'info')
|
confirmLabel: `Delete ${modelId}`,
|
||||||
fetchModels()
|
danger: true,
|
||||||
} catch (err) {
|
onConfirm: async () => {
|
||||||
addToast(`Failed to delete: ${err.message}`, 'error')
|
setConfirmDialog(null)
|
||||||
}
|
try {
|
||||||
|
await modelsApi.delete(modelId)
|
||||||
|
addToast(`Deleting ${modelId}...`, 'info')
|
||||||
|
fetchModels()
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to delete: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear local installing flags when operations finish (success or error)
|
// Clear local installing flags when operations finish (success or error)
|
||||||
|
|
@ -332,7 +318,19 @@ export default function Models() {
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-state-icon"><i className="fas fa-search" /></div>
|
<div className="empty-state-icon"><i className="fas fa-search" /></div>
|
||||||
<h2 className="empty-state-title">No models found</h2>
|
<h2 className="empty-state-title">No models found</h2>
|
||||||
<p className="empty-state-text">Try adjusting your search or filters</p>
|
<p className="empty-state-text">
|
||||||
|
{search || filter || backendFilter
|
||||||
|
? 'No models match your current search or filters.'
|
||||||
|
: 'The model gallery is empty.'}
|
||||||
|
</p>
|
||||||
|
{(search || filter || backendFilter) && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => { handleSearch(''); setFilter(''); setBackendFilter(''); setPage(1) }}
|
||||||
|
>
|
||||||
|
<i className="fas fa-times" /> Clear filters
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-container" style={{ background: 'var(--color-bg-secondary)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }}>
|
<div className="table-container" style={{ background: 'var(--color-bg-secondary)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }}>
|
||||||
|
|
@ -535,6 +533,15 @@ export default function Models() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDialog}
|
||||||
|
title={confirmDialog?.title}
|
||||||
|
message={confirmDialog?.message}
|
||||||
|
confirmLabel={confirmDialog?.confirmLabel}
|
||||||
|
danger={confirmDialog?.danger}
|
||||||
|
onConfirm={confirmDialog?.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export default function NotFound() {
|
||||||
<div className="empty-state-icon"><i className="fas fa-compass" /></div>
|
<div className="empty-state-icon"><i className="fas fa-compass" /></div>
|
||||||
<h1 className="empty-state-title" style={{ fontSize: '3rem' }}>404</h1>
|
<h1 className="empty-state-title" style={{ fontSize: '3rem' }}>404</h1>
|
||||||
<h2 className="empty-state-title">Page Not Found</h2>
|
<h2 className="empty-state-title">Page Not Found</h2>
|
||||||
<p className="empty-state-text">The page you're looking for doesn't exist.</p>
|
<p className="empty-state-text">Looks like this page wandered off. Let's get you back on track.</p>
|
||||||
<button className="btn btn-primary" onClick={() => navigate('/app')}>
|
<button className="btn btn-primary" onClick={() => navigate('/app')}>
|
||||||
<i className="fas fa-home" /> Go Home
|
<i className="fas fa-home" /> Go Home
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, useNavigate, useLocation, useOutletContext } from 'react-router-dom'
|
import { useParams, useNavigate, useLocation, useOutletContext, useSearchParams } from 'react-router-dom'
|
||||||
import { skillsApi } from '../utils/api'
|
import { skillsApi } from '../utils/api'
|
||||||
|
|
||||||
const RESOURCE_PREFIXES = ['scripts/', 'references/', 'assets/']
|
const RESOURCE_PREFIXES = ['scripts/', 'references/', 'assets/']
|
||||||
|
|
@ -265,6 +265,8 @@ export default function SkillEdit() {
|
||||||
const name = nameParam ? decodeURIComponent(nameParam) : undefined
|
const name = nameParam ? decodeURIComponent(nameParam) : undefined
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { addToast } = useOutletContext()
|
const { addToast } = useOutletContext()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const userId = searchParams.get('user_id') || undefined
|
||||||
const [loading, setLoading] = useState(!isNew)
|
const [loading, setLoading] = useState(!isNew)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [activeSection, setActiveSection] = useState('basic')
|
const [activeSection, setActiveSection] = useState('basic')
|
||||||
|
|
@ -284,7 +286,7 @@ export default function SkillEdit() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (name) {
|
if (name) {
|
||||||
skillsApi.get(name)
|
skillsApi.get(name, userId)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
setForm({
|
setForm({
|
||||||
name: data.name || '',
|
name: data.name || '',
|
||||||
|
|
@ -329,7 +331,7 @@ export default function SkillEdit() {
|
||||||
await skillsApi.create(payload)
|
await skillsApi.create(payload)
|
||||||
addToast('Skill created', 'success')
|
addToast('Skill created', 'success')
|
||||||
} else {
|
} else {
|
||||||
await skillsApi.update(name, { ...payload, name: undefined })
|
await skillsApi.update(name, { ...payload, name: undefined }, userId)
|
||||||
addToast('Skill updated', 'success')
|
addToast('Skill updated', 'success')
|
||||||
}
|
}
|
||||||
navigate('/app/skills')
|
navigate('/app/skills')
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { useNavigate, useOutletContext } from 'react-router-dom'
|
import { useNavigate, useOutletContext } from 'react-router-dom'
|
||||||
import { skillsApi } from '../utils/api'
|
import { skillsApi } from '../utils/api'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useUserMap } from '../hooks/useUserMap'
|
||||||
|
import UserGroupSection from '../components/UserGroupSection'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
|
||||||
export default function Skills() {
|
export default function Skills() {
|
||||||
const { addToast } = useOutletContext()
|
const { addToast } = useOutletContext()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { isAdmin, authEnabled, user } = useAuth()
|
||||||
|
const userMap = useUserMap()
|
||||||
const [skills, setSkills] = useState([])
|
const [skills, setSkills] = useState([])
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -15,6 +21,8 @@ export default function Skills() {
|
||||||
const [gitRepoUrl, setGitRepoUrl] = useState('')
|
const [gitRepoUrl, setGitRepoUrl] = useState('')
|
||||||
const [gitReposLoading, setGitReposLoading] = useState(false)
|
const [gitReposLoading, setGitReposLoading] = useState(false)
|
||||||
const [gitReposAction, setGitReposAction] = useState(null)
|
const [gitReposAction, setGitReposAction] = useState(null)
|
||||||
|
const [userGroups, setUserGroups] = useState(null)
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
|
|
||||||
const fetchSkills = useCallback(async () => {
|
const fetchSkills = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -31,9 +39,17 @@ export default function Skills() {
|
||||||
if (searchQuery.trim()) {
|
if (searchQuery.trim()) {
|
||||||
const data = await withTimeout(skillsApi.search(searchQuery.trim()))
|
const data = await withTimeout(skillsApi.search(searchQuery.trim()))
|
||||||
setSkills(Array.isArray(data) ? data : [])
|
setSkills(Array.isArray(data) ? data : [])
|
||||||
|
setUserGroups(null)
|
||||||
} else {
|
} else {
|
||||||
const data = await withTimeout(skillsApi.list())
|
const data = await withTimeout(skillsApi.list(isAdmin && authEnabled))
|
||||||
setSkills(Array.isArray(data) ? data : [])
|
// Handle wrapped response (admin) or flat array (regular user)
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
setSkills(data)
|
||||||
|
setUserGroups(null)
|
||||||
|
} else {
|
||||||
|
setSkills(Array.isArray(data.skills) ? data.skills : [])
|
||||||
|
setUserGroups(data.user_groups || null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.message?.includes('503') || err.message?.includes('skills')) {
|
if (err.message?.includes('503') || err.message?.includes('skills')) {
|
||||||
|
|
@ -46,26 +62,34 @@ export default function Skills() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [searchQuery, addToast])
|
}, [searchQuery, addToast, isAdmin, authEnabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSkills()
|
fetchSkills()
|
||||||
}, [fetchSkills])
|
}, [fetchSkills])
|
||||||
|
|
||||||
const deleteSkill = async (name) => {
|
const deleteSkill = async (name, userId) => {
|
||||||
if (!window.confirm(`Delete skill "${name}"? This action cannot be undone.`)) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Delete Skill',
|
||||||
await skillsApi.delete(name)
|
message: `Delete skill "${name}"? This action cannot be undone.`,
|
||||||
addToast(`Skill "${name}" deleted`, 'success')
|
confirmLabel: 'Delete',
|
||||||
fetchSkills()
|
danger: true,
|
||||||
} catch (err) {
|
onConfirm: async () => {
|
||||||
addToast(err.message || 'Failed to delete skill', 'error')
|
setConfirmDialog(null)
|
||||||
}
|
try {
|
||||||
|
await skillsApi.delete(name, userId)
|
||||||
|
addToast(`Skill "${name}" deleted`, 'success')
|
||||||
|
fetchSkills()
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err.message || 'Failed to delete skill', 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportSkill = async (name) => {
|
const exportSkill = async (name, userId) => {
|
||||||
try {
|
try {
|
||||||
const url = skillsApi.exportUrl(name)
|
const url = skillsApi.exportUrl(name, userId)
|
||||||
const res = await fetch(url, { credentials: 'same-origin' })
|
const res = await fetch(url, { credentials: 'same-origin' })
|
||||||
if (!res.ok) throw new Error(res.statusText || 'Export failed')
|
if (!res.ok) throw new Error(res.statusText || 'Export failed')
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
|
|
@ -159,15 +183,23 @@ export default function Skills() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteGitRepo = async (id) => {
|
const deleteGitRepo = async (id) => {
|
||||||
if (!window.confirm('Remove this Git repository? Skills from it will no longer be available.')) return
|
setConfirmDialog({
|
||||||
try {
|
title: 'Remove Git Repository',
|
||||||
await skillsApi.deleteGitRepo(id)
|
message: 'Remove this Git repository? Skills from it will no longer be available.',
|
||||||
await loadGitRepos()
|
confirmLabel: 'Remove',
|
||||||
fetchSkills()
|
danger: true,
|
||||||
addToast('Repo removed', 'success')
|
onConfirm: async () => {
|
||||||
} catch (err) {
|
setConfirmDialog(null)
|
||||||
addToast(err.message || 'Remove failed', 'error')
|
try {
|
||||||
}
|
await skillsApi.deleteGitRepo(id)
|
||||||
|
await loadGitRepos()
|
||||||
|
fetchSkills()
|
||||||
|
addToast('Repo removed', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err.message || 'Remove failed', 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (unavailable) {
|
if (unavailable) {
|
||||||
|
|
@ -384,7 +416,7 @@ export default function Skills() {
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
|
||||||
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-primary)' }} />
|
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-primary)' }} />
|
||||||
</div>
|
</div>
|
||||||
) : skills.length === 0 ? (
|
) : skills.length === 0 && !userGroups ? (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<div className="empty-state-icon"><i className="fas fa-book" /></div>
|
<div className="empty-state-icon"><i className="fas fa-book" /></div>
|
||||||
<h2 className="empty-state-title">No skills found</h2>
|
<h2 className="empty-state-title">No skills found</h2>
|
||||||
|
|
@ -406,6 +438,11 @@ export default function Skills() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{userGroups && <h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>Your Skills</h2>}
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-md)' }}>You have no skills yet.</p>
|
||||||
|
) : (
|
||||||
<div className="skills-grid">
|
<div className="skills-grid">
|
||||||
{skills.map((s) => (
|
{skills.map((s) => (
|
||||||
<div key={s.name} className="card">
|
<div key={s.name} className="card">
|
||||||
|
|
@ -446,6 +483,68 @@ export default function Skills() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDialog}
|
||||||
|
title={confirmDialog?.title}
|
||||||
|
message={confirmDialog?.message}
|
||||||
|
confirmLabel={confirmDialog?.confirmLabel}
|
||||||
|
danger={confirmDialog?.danger}
|
||||||
|
onConfirm={confirmDialog?.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{userGroups && (
|
||||||
|
<UserGroupSection
|
||||||
|
title="Other Users' Skills"
|
||||||
|
userGroups={userGroups}
|
||||||
|
userMap={userMap}
|
||||||
|
currentUserId={user?.id}
|
||||||
|
itemKey="skills"
|
||||||
|
renderGroup={(items, userId) => (
|
||||||
|
<div className="skills-grid">
|
||||||
|
{(items || []).map((s) => (
|
||||||
|
<div key={s.name} className="card">
|
||||||
|
<div className="skills-card-header">
|
||||||
|
<h3 className="skills-card-name">{s.name}</h3>
|
||||||
|
{s.readOnly && <span className="badge">Read-only</span>}
|
||||||
|
</div>
|
||||||
|
<p className="skills-card-desc">{s.description || 'No description'}</p>
|
||||||
|
<div className="skills-card-actions">
|
||||||
|
{!s.readOnly && (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => navigate(`/app/skills/edit/${encodeURIComponent(s.name)}?user_id=${encodeURIComponent(userId)}`)}
|
||||||
|
title="Edit skill"
|
||||||
|
>
|
||||||
|
<i className="fas fa-edit" /> Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!s.readOnly && (
|
||||||
|
<button
|
||||||
|
className="btn btn-danger btn-sm"
|
||||||
|
onClick={() => deleteSkill(s.name, userId)}
|
||||||
|
title="Delete skill"
|
||||||
|
>
|
||||||
|
<i className="fas fa-trash" /> Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => exportSkill(s.name, userId)}
|
||||||
|
title="Export as .tar.gz"
|
||||||
|
>
|
||||||
|
<i className="fas fa-download" /> Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
501
core/http/react-ui/src/pages/Usage.jsx
Normal file
501
core/http/react-ui/src/pages/Usage.jsx
Normal file
|
|
@ -0,0 +1,501 @@
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useOutletContext } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { apiUrl } from '../utils/basePath'
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner'
|
||||||
|
|
||||||
|
const PERIODS = [
|
||||||
|
{ key: 'day', label: 'Day' },
|
||||||
|
{ key: 'week', label: 'Week' },
|
||||||
|
{ key: 'month', label: 'Month' },
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatNumber(n) {
|
||||||
|
if (n == null) return '0'
|
||||||
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
||||||
|
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ icon, label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 'var(--spacing-sm) var(--spacing-md)', flex: '1 1 0', minWidth: 120 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||||
|
<i className={icon} style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }} />
|
||||||
|
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.03em' }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '1.375rem', fontWeight: 700, fontFamily: 'JetBrains Mono, monospace', color: 'var(--color-text-primary)' }}>
|
||||||
|
{formatNumber(value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageBar({ value, max }) {
|
||||||
|
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height: 6, borderRadius: 3,
|
||||||
|
background: 'var(--color-bg-primary)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: `${pct}%`, height: '100%', borderRadius: 3,
|
||||||
|
background: 'var(--color-primary)',
|
||||||
|
transition: 'width 0.3s ease',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateByModel(buckets) {
|
||||||
|
const map = {}
|
||||||
|
for (const b of buckets) {
|
||||||
|
const key = b.model || '(unknown)'
|
||||||
|
if (!map[key]) {
|
||||||
|
map[key] = { model: key, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, request_count: 0 }
|
||||||
|
}
|
||||||
|
map[key].prompt_tokens += b.prompt_tokens
|
||||||
|
map[key].completion_tokens += b.completion_tokens
|
||||||
|
map[key].total_tokens += b.total_tokens
|
||||||
|
map[key].request_count += b.request_count
|
||||||
|
}
|
||||||
|
return Object.values(map).sort((a, b) => b.total_tokens - a.total_tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateByUser(buckets) {
|
||||||
|
const map = {}
|
||||||
|
for (const b of buckets) {
|
||||||
|
const key = b.user_id || '(unknown)'
|
||||||
|
if (!map[key]) {
|
||||||
|
map[key] = { user_id: key, user_name: b.user_name || key, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, request_count: 0 }
|
||||||
|
}
|
||||||
|
map[key].prompt_tokens += b.prompt_tokens
|
||||||
|
map[key].completion_tokens += b.completion_tokens
|
||||||
|
map[key].total_tokens += b.total_tokens
|
||||||
|
map[key].request_count += b.request_count
|
||||||
|
}
|
||||||
|
return Object.values(map).sort((a, b) => b.total_tokens - a.total_tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateByBucket(buckets) {
|
||||||
|
const map = {}
|
||||||
|
for (const b of buckets) {
|
||||||
|
if (!b.bucket) continue
|
||||||
|
if (!map[b.bucket]) {
|
||||||
|
map[b.bucket] = { bucket: b.bucket, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, request_count: 0 }
|
||||||
|
}
|
||||||
|
map[b.bucket].prompt_tokens += b.prompt_tokens
|
||||||
|
map[b.bucket].completion_tokens += b.completion_tokens
|
||||||
|
map[b.bucket].total_tokens += b.total_tokens
|
||||||
|
map[b.bucket].request_count += b.request_count
|
||||||
|
}
|
||||||
|
return Object.values(map).sort((a, b) => a.bucket.localeCompare(b.bucket))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBucket(bucket, period) {
|
||||||
|
if (!bucket) return ''
|
||||||
|
if (period === 'day') {
|
||||||
|
return bucket.split(' ')[1] || bucket
|
||||||
|
}
|
||||||
|
if (period === 'week' || period === 'month') {
|
||||||
|
const d = new Date(bucket + 'T00:00:00')
|
||||||
|
if (!isNaN(d)) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
|
return bucket
|
||||||
|
}
|
||||||
|
const [y, m] = bucket.split('-')
|
||||||
|
if (y && m) {
|
||||||
|
const d = new Date(Number(y), Number(m) - 1)
|
||||||
|
if (!isNaN(d)) return d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
|
||||||
|
}
|
||||||
|
return bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatYLabel(n) {
|
||||||
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
||||||
|
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
|
||||||
|
return String(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsageTimeChart({ data, period }) {
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const [width, setWidth] = useState(600)
|
||||||
|
const [tooltip, setTooltip] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
const observer = new ResizeObserver(entries => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
setWidth(entry.contentRect.width)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
observer.observe(containerRef.current)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (!data || data.length === 0) return null
|
||||||
|
|
||||||
|
const height = 200
|
||||||
|
const margin = { top: 16, right: 16, bottom: 40, left: 56 }
|
||||||
|
const chartW = width - margin.left - margin.right
|
||||||
|
const chartH = height - margin.top - margin.bottom
|
||||||
|
|
||||||
|
const maxVal = Math.max(...data.map(d => d.total_tokens), 1)
|
||||||
|
const barWidth = Math.max(Math.min(chartW / data.length - 2, 40), 4)
|
||||||
|
const barGap = (chartW - barWidth * data.length) / (data.length + 1)
|
||||||
|
|
||||||
|
// Y-axis ticks (4 ticks)
|
||||||
|
const ticks = [0, 1, 2, 3, 4].map(i => Math.round(maxVal * i / 4))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-sm)' }}>
|
||||||
|
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text-primary)' }}>Tokens over time</span>
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--spacing-md)', fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
|
||||||
|
<span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 2, background: 'var(--color-primary)', marginRight: 4, verticalAlign: 'middle' }} />Prompt</span>
|
||||||
|
<span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 2, background: 'var(--color-primary)', opacity: 0.35, marginRight: 4, verticalAlign: 'middle' }} />Completion</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref={containerRef} style={{ position: 'relative', width: '100%' }}>
|
||||||
|
<svg width={width} height={height} style={{ display: 'block' }}>
|
||||||
|
<g transform={`translate(${margin.left},${margin.top})`}>
|
||||||
|
{/* Grid lines and Y labels */}
|
||||||
|
{ticks.map((t, i) => {
|
||||||
|
const y = chartH - (t / maxVal) * chartH
|
||||||
|
return (
|
||||||
|
<g key={i}>
|
||||||
|
<line x1={0} y1={y} x2={chartW} y2={y} stroke="var(--color-border)" strokeOpacity={0.5} strokeDasharray={i === 0 ? 'none' : '3,3'} />
|
||||||
|
<text x={-8} y={y + 4} textAnchor="end" fontSize="10" fill="var(--color-text-muted)" fontFamily="JetBrains Mono, monospace">
|
||||||
|
{formatYLabel(t)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* Bars */}
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const x = barGap + i * (barWidth + barGap)
|
||||||
|
const promptH = (d.prompt_tokens / maxVal) * chartH
|
||||||
|
const compH = (d.completion_tokens / maxVal) * chartH
|
||||||
|
return (
|
||||||
|
<g key={d.bucket}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
const rect = containerRef.current.getBoundingClientRect()
|
||||||
|
setTooltip({
|
||||||
|
x: e.clientX - rect.left,
|
||||||
|
y: e.clientY - rect.top,
|
||||||
|
data: d,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
const rect = containerRef.current.getBoundingClientRect()
|
||||||
|
setTooltip(prev => prev ? {
|
||||||
|
...prev,
|
||||||
|
x: e.clientX - rect.left,
|
||||||
|
y: e.clientY - rect.top,
|
||||||
|
} : null)
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setTooltip(null)}
|
||||||
|
style={{ cursor: 'default' }}
|
||||||
|
>
|
||||||
|
{/* Invisible hit area */}
|
||||||
|
<rect x={x} y={0} width={barWidth} height={chartH} fill="transparent" />
|
||||||
|
{/* Prompt tokens (bottom) */}
|
||||||
|
<rect x={x} y={chartH - promptH - compH} width={barWidth} height={promptH} fill="var(--color-primary)" rx={2} />
|
||||||
|
{/* Completion tokens (top) */}
|
||||||
|
<rect x={x} y={chartH - compH} width={barWidth} height={compH} fill="var(--color-primary)" opacity={0.35} rx={2} />
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* X-axis labels */}
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const x = barGap + i * (barWidth + barGap) + barWidth / 2
|
||||||
|
// Skip some labels if too many
|
||||||
|
const skip = data.length > 20 ? Math.ceil(data.length / 12) : 1
|
||||||
|
if (i % skip !== 0) return null
|
||||||
|
return (
|
||||||
|
<text key={d.bucket} x={x} y={chartH + 16} textAnchor="middle" fontSize="10" fill="var(--color-text-secondary)" fontFamily="JetBrains Mono, monospace">
|
||||||
|
{formatBucket(d.bucket, period)}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{tooltip && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: tooltip.x + 12,
|
||||||
|
top: tooltip.y - 8,
|
||||||
|
background: 'var(--color-bg-tertiary)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
borderRadius: 'var(--radius-md)',
|
||||||
|
padding: 'var(--spacing-xs) var(--spacing-sm)',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
fontFamily: 'JetBrains Mono, monospace',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 10,
|
||||||
|
boxShadow: 'var(--shadow-md)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 2 }}>{formatBucket(tooltip.data.bucket, period)}</div>
|
||||||
|
<div><span style={{ color: 'var(--color-primary)' }}>Prompt:</span> {tooltip.data.prompt_tokens.toLocaleString()}</div>
|
||||||
|
<div><span style={{ color: 'var(--color-text-secondary)' }}>Completion:</span> {tooltip.data.completion_tokens.toLocaleString()}</div>
|
||||||
|
<div style={{ color: 'var(--color-text-muted)', borderTop: '1px solid var(--color-border)', marginTop: 2, paddingTop: 2 }}>
|
||||||
|
{tooltip.data.request_count} requests
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelDistChart({ rows }) {
|
||||||
|
if (!rows || rows.length === 0) return null
|
||||||
|
|
||||||
|
const maxVal = Math.max(...rows.map(r => r.total_tokens), 1)
|
||||||
|
const barH = 24
|
||||||
|
const gap = 4
|
||||||
|
const height = rows.length * (barH + gap) + gap
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ padding: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-sm)' }}>
|
||||||
|
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text-primary)' }}>Token distribution by model</span>
|
||||||
|
<div style={{ display: 'flex', gap: 'var(--spacing-md)', fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
|
||||||
|
<span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 2, background: 'var(--color-primary)', marginRight: 4, verticalAlign: 'middle' }} />Prompt</span>
|
||||||
|
<span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 2, background: 'var(--color-primary)', opacity: 0.35, marginRight: 4, verticalAlign: 'middle' }} />Completion</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: gap }}>
|
||||||
|
{rows.map(row => {
|
||||||
|
const promptPct = (row.prompt_tokens / maxVal) * 100
|
||||||
|
const compPct = (row.completion_tokens / maxVal) * 100
|
||||||
|
return (
|
||||||
|
<div key={row.model} style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 120, minWidth: 120, fontSize: '0.75rem', fontFamily: 'JetBrains Mono, monospace',
|
||||||
|
color: 'var(--color-text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}} title={row.model}>
|
||||||
|
{row.model}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, height: barH, background: 'var(--color-bg-primary)', borderRadius: 4, overflow: 'hidden', display: 'flex' }}>
|
||||||
|
<div style={{ width: `${promptPct}%`, height: '100%', background: 'var(--color-primary)', transition: 'width 0.3s ease' }} />
|
||||||
|
<div style={{ width: `${compPct}%`, height: '100%', background: 'var(--color-primary)', opacity: 0.35, transition: 'width 0.3s ease' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
minWidth: 60, textAlign: 'right', fontSize: '0.75rem', fontFamily: 'JetBrains Mono, monospace',
|
||||||
|
color: 'var(--color-text-muted)', fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{formatNumber(row.total_tokens)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Usage() {
|
||||||
|
const { addToast } = useOutletContext()
|
||||||
|
const { isAdmin, authEnabled } = useAuth()
|
||||||
|
const [period, setPeriod] = useState('month')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [usage, setUsage] = useState([])
|
||||||
|
const [totals, setTotals] = useState({})
|
||||||
|
const [adminUsage, setAdminUsage] = useState([])
|
||||||
|
const [adminTotals, setAdminTotals] = useState({})
|
||||||
|
const [activeTab, setActiveTab] = useState('models')
|
||||||
|
|
||||||
|
const fetchUsage = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(apiUrl(`/api/auth/usage?period=${period}`))
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
setUsage(data.usage || [])
|
||||||
|
setTotals(data.totals || {})
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
const adminRes = await fetch(apiUrl(`/api/auth/admin/usage?period=${period}`))
|
||||||
|
if (adminRes.ok) {
|
||||||
|
const adminData = await adminRes.json()
|
||||||
|
setAdminUsage(adminData.usage || [])
|
||||||
|
setAdminTotals(adminData.totals || {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to load usage: ${err.message}`, 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [period, isAdmin, addToast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (authEnabled) fetchUsage()
|
||||||
|
else setLoading(false)
|
||||||
|
}, [fetchUsage, authEnabled])
|
||||||
|
|
||||||
|
if (!authEnabled) {
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon"><i className="fas fa-chart-bar" /></div>
|
||||||
|
<h2 className="empty-state-title">Usage tracking unavailable</h2>
|
||||||
|
<p className="empty-state-text">Authentication must be enabled to track API usage.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelRows = aggregateByModel(isAdmin ? adminUsage : usage)
|
||||||
|
const userRows = isAdmin ? aggregateByUser(adminUsage) : []
|
||||||
|
const maxTokens = modelRows.reduce((max, r) => Math.max(max, r.total_tokens), 0)
|
||||||
|
const maxUserTokens = userRows.reduce((max, r) => Math.max(max, r.total_tokens), 0)
|
||||||
|
|
||||||
|
const displayTotals = isAdmin ? adminTotals : totals
|
||||||
|
const displayUsage = isAdmin ? adminUsage : usage
|
||||||
|
const timeSeries = aggregateByBucket(displayUsage)
|
||||||
|
|
||||||
|
const monoCell = { fontFamily: 'JetBrains Mono, monospace', fontSize: '0.8125rem' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header" style={{ marginBottom: 'var(--spacing-sm)' }}>
|
||||||
|
<h1 className="page-title">Usage</h1>
|
||||||
|
<p className="page-subtitle">API token usage statistics</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period selector + tabs */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', marginBottom: 'var(--spacing-md)', flexWrap: 'wrap' }}>
|
||||||
|
{PERIODS.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.key}
|
||||||
|
className={`btn btn-sm ${period === p.key ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setPeriod(p.key)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<div style={{ width: 1, height: 20, background: 'var(--color-border-subtle)', margin: '0 var(--spacing-xs)' }} />
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${activeTab === 'models' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setActiveTab('models')}
|
||||||
|
>
|
||||||
|
<i className="fas fa-cube" style={{ fontSize: '0.7rem' }} /> Models
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${activeTab === 'users' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setActiveTab('users')}
|
||||||
|
>
|
||||||
|
<i className="fas fa-users" style={{ fontSize: '0.7rem' }} /> Users
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={fetchUsage} disabled={loading} style={{ gap: 4 }}>
|
||||||
|
<i className={`fas fa-rotate${loading ? ' fa-spin' : ''}`} /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
|
||||||
|
<StatCard icon="fas fa-arrow-right-arrow-left" label="Requests" value={displayTotals.request_count} />
|
||||||
|
<StatCard icon="fas fa-arrow-up" label="Prompt" value={displayTotals.prompt_tokens} />
|
||||||
|
<StatCard icon="fas fa-arrow-down" label="Completion" value={displayTotals.completion_tokens} />
|
||||||
|
<StatCard icon="fas fa-coins" label="Total" value={displayTotals.total_tokens} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<UsageTimeChart data={timeSeries} period={period} />
|
||||||
|
{activeTab === 'models' && <ModelDistChart rows={modelRows} />}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{activeTab === 'models' && (
|
||||||
|
modelRows.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon"><i className="fas fa-chart-bar" /></div>
|
||||||
|
<h2 className="empty-state-title">No usage data</h2>
|
||||||
|
<p className="empty-state-text">Usage data will appear here as API requests are made.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-container">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Model</th>
|
||||||
|
<th style={{ width: 90 }}>Requests</th>
|
||||||
|
<th style={{ width: 110 }}>Prompt</th>
|
||||||
|
<th style={{ width: 110 }}>Completion</th>
|
||||||
|
<th style={{ width: 110 }}>Total</th>
|
||||||
|
<th style={{ width: 140 }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{modelRows.map(row => (
|
||||||
|
<tr key={row.model}>
|
||||||
|
<td style={monoCell}>{row.model}</td>
|
||||||
|
<td style={monoCell}>{formatNumber(row.request_count)}</td>
|
||||||
|
<td style={monoCell}>{formatNumber(row.prompt_tokens)}</td>
|
||||||
|
<td style={monoCell}>{formatNumber(row.completion_tokens)}</td>
|
||||||
|
<td style={{ ...monoCell, fontWeight: 600 }}>{formatNumber(row.total_tokens)}</td>
|
||||||
|
<td><UsageBar value={row.total_tokens} max={maxTokens} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'users' && isAdmin && (
|
||||||
|
userRows.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon"><i className="fas fa-users" /></div>
|
||||||
|
<h2 className="empty-state-title">No user usage data</h2>
|
||||||
|
<p className="empty-state-text">Per-user usage data will appear here as users make API requests.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-container">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th style={{ width: 90 }}>Requests</th>
|
||||||
|
<th style={{ width: 110 }}>Prompt</th>
|
||||||
|
<th style={{ width: 110 }}>Completion</th>
|
||||||
|
<th style={{ width: 110 }}>Total</th>
|
||||||
|
<th style={{ width: 140 }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{userRows.map(row => (
|
||||||
|
<tr key={row.user_id}>
|
||||||
|
<td style={{ fontSize: '0.8125rem' }}>{row.user_name}</td>
|
||||||
|
<td style={monoCell}>{formatNumber(row.request_count)}</td>
|
||||||
|
<td style={monoCell}>{formatNumber(row.prompt_tokens)}</td>
|
||||||
|
<td style={monoCell}>{formatNumber(row.completion_tokens)}</td>
|
||||||
|
<td style={{ ...monoCell, fontWeight: 600 }}>{formatNumber(row.total_tokens)}</td>
|
||||||
|
<td><UsageBar value={row.total_tokens} max={maxUserTokens} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
737
core/http/react-ui/src/pages/Users.jsx
Normal file
737
core/http/react-ui/src/pages/Users.jsx
Normal file
|
|
@ -0,0 +1,737 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useOutletContext } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { adminUsersApi, adminInvitesApi } from '../utils/api'
|
||||||
|
import LoadingSpinner from '../components/LoadingSpinner'
|
||||||
|
import Modal from '../components/Modal'
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog'
|
||||||
|
import './auth.css'
|
||||||
|
|
||||||
|
function RoleBadge({ role }) {
|
||||||
|
const isPrimary = role === 'admin'
|
||||||
|
return (
|
||||||
|
<span className={`badge ${isPrimary ? 'badge-primary' : 'badge-secondary'}`}>
|
||||||
|
{role}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const variant = status === 'active'
|
||||||
|
? 'success'
|
||||||
|
: status === 'disabled'
|
||||||
|
? 'danger'
|
||||||
|
: 'warning'
|
||||||
|
return (
|
||||||
|
<span className={`status-badge status-badge-${variant}`}>
|
||||||
|
{status || 'unknown'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderBadge({ provider }) {
|
||||||
|
return (
|
||||||
|
<span className="badge badge-secondary" style={{ fontSize: '0.7rem' }}>
|
||||||
|
{provider || 'local'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionSummary({ user, onClick }) {
|
||||||
|
if (user.role === 'admin') {
|
||||||
|
return <span className="perm-summary-text">All (admin)</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
const perms = user.permissions || {}
|
||||||
|
const apiFeatures = ['chat', 'images', 'audio_speech', 'audio_transcription', 'vad', 'detection', 'video', 'embeddings', 'sound']
|
||||||
|
const agentFeatures = ['agents', 'skills', 'collections', 'mcp_jobs']
|
||||||
|
|
||||||
|
const apiOn = apiFeatures.filter(f => perms[f] !== false && (perms[f] === true || perms[f] === undefined)).length
|
||||||
|
const agentOn = agentFeatures.filter(f => perms[f]).length
|
||||||
|
|
||||||
|
const modelRestricted = user.allowed_models?.enabled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-secondary perm-summary-btn"
|
||||||
|
onClick={onClick}
|
||||||
|
title="Edit permissions"
|
||||||
|
>
|
||||||
|
<i className="fas fa-shield-halved" />
|
||||||
|
{apiOn}/{apiFeatures.length} API, {agentOn}/{agentFeatures.length} Agent
|
||||||
|
{modelRestricted && ' | Models restricted'}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionsModal({ user, featureMeta, availableModels, onClose, onSave, addToast }) {
|
||||||
|
const [permissions, setPermissions] = useState({ ...(user.permissions || {}) })
|
||||||
|
const [allowedModels, setAllowedModels] = useState(user.allowed_models || { enabled: false, models: [] })
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const apiFeatures = featureMeta?.api_features || []
|
||||||
|
const agentFeatures = featureMeta?.agent_features || []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const toggleFeature = (key) => {
|
||||||
|
setPermissions(prev => ({ ...prev, [key]: !prev[key] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAllFeatures = (features, value) => {
|
||||||
|
setPermissions(prev => {
|
||||||
|
const updated = { ...prev }
|
||||||
|
features.forEach(f => { updated[f.key] = value })
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleModel = (model) => {
|
||||||
|
setAllowedModels(prev => {
|
||||||
|
const models = prev.models || []
|
||||||
|
const has = models.includes(model)
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
models: has ? models.filter(m => m !== model) : [...models, model],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setAllModels = (value) => {
|
||||||
|
if (value) {
|
||||||
|
setAllowedModels(prev => ({ ...prev, models: [...(availableModels || [])] }))
|
||||||
|
} else {
|
||||||
|
setAllowedModels(prev => ({ ...prev, models: [] }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await adminUsersApi.setPermissions(user.id, permissions)
|
||||||
|
await adminUsersApi.setModels(user.id, allowedModels)
|
||||||
|
onSave(user.id, permissions, allowedModels)
|
||||||
|
addToast(`Permissions updated for ${user.name || user.email}`, 'success')
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to update permissions: ${err.message}`, 'error')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onClose={onClose} maxWidth="640px">
|
||||||
|
<div className="perm-modal-body">
|
||||||
|
{/* Header with avatar */}
|
||||||
|
<div className="perm-modal-header">
|
||||||
|
{user.avatarUrl ? (
|
||||||
|
<img src={user.avatarUrl} alt="" className="perm-modal-avatar" />
|
||||||
|
) : (
|
||||||
|
<i className="fas fa-user-circle user-avatar-placeholder--lg" />
|
||||||
|
)}
|
||||||
|
<h3>Permissions for “{user.name || user.email}”</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Endpoints */}
|
||||||
|
<div className="perm-section">
|
||||||
|
<div className="perm-section-header">
|
||||||
|
<strong className="perm-section-title">
|
||||||
|
<i className="fas fa-plug" />
|
||||||
|
API Endpoints
|
||||||
|
</strong>
|
||||||
|
<div className="action-group">
|
||||||
|
<button className="btn btn-sm btn-secondary perm-btn-all-none" onClick={() => setAllFeatures(apiFeatures, true)}>All</button>
|
||||||
|
<button className="btn btn-sm btn-secondary perm-btn-all-none" onClick={() => setAllFeatures(apiFeatures, false)}>None</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="perm-grid">
|
||||||
|
{apiFeatures.map(f => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
className={`btn btn-sm ${permissions[f.key] ? 'btn-primary' : 'btn-secondary'} perm-btn-feature`}
|
||||||
|
onClick={() => toggleFeature(f.key)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent Features */}
|
||||||
|
<div className="perm-section">
|
||||||
|
<div className="perm-section-header">
|
||||||
|
<strong className="perm-section-title">
|
||||||
|
<i className="fas fa-robot" />
|
||||||
|
Agent Features
|
||||||
|
</strong>
|
||||||
|
<div className="action-group">
|
||||||
|
<button className="btn btn-sm btn-secondary perm-btn-all-none" onClick={() => setAllFeatures(agentFeatures, true)}>All</button>
|
||||||
|
<button className="btn btn-sm btn-secondary perm-btn-all-none" onClick={() => setAllFeatures(agentFeatures, false)}>None</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="perm-grid">
|
||||||
|
{agentFeatures.map(f => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
className={`btn btn-sm ${permissions[f.key] ? 'btn-primary' : 'btn-secondary'} perm-btn-feature`}
|
||||||
|
onClick={() => toggleFeature(f.key)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Access */}
|
||||||
|
<div className="perm-section">
|
||||||
|
<div className="perm-section-header">
|
||||||
|
<strong className="perm-section-title">
|
||||||
|
<i className="fas fa-cubes" />
|
||||||
|
Model Access
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 'var(--spacing-sm)' }}>
|
||||||
|
<label className="perm-toggle-label">
|
||||||
|
<label className="toggle" style={{ flexShrink: 0 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={allowedModels.enabled}
|
||||||
|
onChange={() => setAllowedModels(prev => ({ ...prev, enabled: !prev.enabled }))}
|
||||||
|
/>
|
||||||
|
<span className="toggle-slider" />
|
||||||
|
</label>
|
||||||
|
Restrict to specific models
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{allowedModels.enabled ? (
|
||||||
|
<>
|
||||||
|
<div className="action-group" style={{ marginBottom: 'var(--spacing-sm)' }}>
|
||||||
|
<button className="btn btn-sm btn-secondary perm-btn-all-none" onClick={() => setAllModels(true)}>All</button>
|
||||||
|
<button className="btn btn-sm btn-secondary perm-btn-all-none" onClick={() => setAllModels(false)}>None</button>
|
||||||
|
</div>
|
||||||
|
<div className="model-list">
|
||||||
|
{(availableModels || []).map(m => {
|
||||||
|
const checked = (allowedModels.models || []).includes(m)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={m}
|
||||||
|
className={`model-item${checked ? ' model-item-checked' : ''}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleModel(m)}
|
||||||
|
/>
|
||||||
|
<span className="model-item-check">
|
||||||
|
{checked && <i className="fas fa-check" />}
|
||||||
|
</span>
|
||||||
|
<span className="model-item-name">{m}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{(!availableModels || availableModels.length === 0) && (
|
||||||
|
<span className="perm-empty">No models available</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="perm-hint">All models are accessible</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="perm-modal-actions">
|
||||||
|
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? 'Saving...' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InviteStatusBadge({ invite }) {
|
||||||
|
const now = new Date()
|
||||||
|
const expired = new Date(invite.expiresAt) < now
|
||||||
|
const used = !!invite.usedBy
|
||||||
|
|
||||||
|
if (used) {
|
||||||
|
return <StatusBadge status="used" />
|
||||||
|
}
|
||||||
|
if (expired) {
|
||||||
|
return <span className="status-badge status-badge-danger">expired</span>
|
||||||
|
}
|
||||||
|
return <span className="status-badge status-badge-success">available</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInviteAvailable(invite) {
|
||||||
|
return !invite.usedBy && new Date(invite.expiresAt) > new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvitesTab({ addToast }) {
|
||||||
|
const [invites, setInvites] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
|
const [newInviteCodes, setNewInviteCodes] = useState({})
|
||||||
|
|
||||||
|
const fetchInvites = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminInvitesApi.list()
|
||||||
|
setInvites(Array.isArray(data) ? data : data.invites || [])
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to load invites: ${err.message}`, 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [addToast])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInvites()
|
||||||
|
}, [fetchInvites])
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
const resp = await adminInvitesApi.create(168) // 7 days
|
||||||
|
if (resp && resp.id && resp.code) {
|
||||||
|
setNewInviteCodes(prev => ({ ...prev, [resp.id]: resp.code }))
|
||||||
|
}
|
||||||
|
addToast('Invite link created', 'success')
|
||||||
|
fetchInvites()
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to create invite: ${err.message}`, 'error')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRevoke = async (invite) => {
|
||||||
|
setConfirmDialog({
|
||||||
|
title: 'Revoke Invite',
|
||||||
|
message: 'Revoke this invite link?',
|
||||||
|
confirmLabel: 'Revoke',
|
||||||
|
danger: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setConfirmDialog(null)
|
||||||
|
try {
|
||||||
|
await adminInvitesApi.delete(invite.id)
|
||||||
|
setInvites(prev => prev.filter(x => x.id !== invite.id))
|
||||||
|
addToast('Invite revoked', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to revoke invite: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyUrl = (code) => {
|
||||||
|
const url = `${window.location.origin}/invite/${code}`
|
||||||
|
try {
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
textarea.value = url
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.opacity = '0'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(textarea)
|
||||||
|
addToast('Invite URL copied to clipboard', 'success')
|
||||||
|
} catch {
|
||||||
|
addToast('Failed to copy URL', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="auth-loading">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="auth-toolbar">
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={handleCreate} disabled={creating}>
|
||||||
|
<i className="fas fa-plus" /> {creating ? 'Creating...' : 'Generate Invite Link'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={fetchInvites} disabled={loading}>
|
||||||
|
<i className="fas fa-rotate" /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{invites.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon"><i className="fas fa-envelope-open-text" /></div>
|
||||||
|
<h2 className="empty-state-title">No invite links</h2>
|
||||||
|
<p className="empty-state-text">Generate an invite link to let someone register.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-container">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Invite Link</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created By</th>
|
||||||
|
<th>Used By</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th className="cell-actions--sm">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invites.map(inv => (
|
||||||
|
<tr key={inv.id}>
|
||||||
|
<td className="invite-cell">
|
||||||
|
{(() => {
|
||||||
|
const code = inv.code || newInviteCodes[inv.id]
|
||||||
|
if (isInviteAvailable(inv) && code) {
|
||||||
|
return (
|
||||||
|
<div className="invite-link-row">
|
||||||
|
<span
|
||||||
|
className="invite-link-text"
|
||||||
|
title={`${window.location.origin}/invite/${code}`}
|
||||||
|
>
|
||||||
|
{`${window.location.origin}/invite/${code}`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-secondary invite-copy-btn"
|
||||||
|
onClick={() => handleCopyUrl(code)}
|
||||||
|
title="Copy invite URL"
|
||||||
|
>
|
||||||
|
<i className="fas fa-copy" /> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="mono-text">
|
||||||
|
{inv.codePrefix || code?.substring(0, 8) || '???'}...
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</td>
|
||||||
|
<td><InviteStatusBadge invite={inv} /></td>
|
||||||
|
<td className="cell-sm">
|
||||||
|
{inv.createdBy?.name || inv.createdBy?.id || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="cell-sm">
|
||||||
|
{inv.usedBy?.name || inv.usedBy?.id || '\u2014'}
|
||||||
|
</td>
|
||||||
|
<td className="cell-muted">
|
||||||
|
{inv.expiresAt ? new Date(inv.expiresAt).toLocaleString() : '-'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{isInviteAvailable(inv) && (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
onClick={() => handleRevoke(inv)}
|
||||||
|
title="Revoke invite"
|
||||||
|
>
|
||||||
|
<i className="fas fa-trash" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDialog}
|
||||||
|
title={confirmDialog?.title}
|
||||||
|
message={confirmDialog?.message}
|
||||||
|
confirmLabel={confirmDialog?.confirmLabel}
|
||||||
|
danger={confirmDialog?.danger}
|
||||||
|
onConfirm={confirmDialog?.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Users() {
|
||||||
|
const { addToast } = useOutletContext()
|
||||||
|
const { user: currentUser } = useAuth()
|
||||||
|
const [users, setUsers] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [activeTab, setActiveTab] = useState('users')
|
||||||
|
const [editingUser, setEditingUser] = useState(null)
|
||||||
|
const [featureMeta, setFeatureMeta] = useState(null)
|
||||||
|
const [availableModels, setAvailableModels] = useState([])
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||||
|
|
||||||
|
const fetchUsers = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminUsersApi.list()
|
||||||
|
setUsers(Array.isArray(data) ? data : data.users || [])
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to load users: ${err.message}`, 'error')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [addToast])
|
||||||
|
|
||||||
|
const fetchFeatures = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await adminUsersApi.getFeatures()
|
||||||
|
setFeatureMeta(data)
|
||||||
|
setAvailableModels(data.models || [])
|
||||||
|
} catch {
|
||||||
|
// Features endpoint may not be available, use defaults
|
||||||
|
setFeatureMeta({
|
||||||
|
api_features: [
|
||||||
|
{ key: 'chat', label: 'Chat Completions', default: true },
|
||||||
|
{ key: 'images', label: 'Image Generation', default: true },
|
||||||
|
{ key: 'audio_speech', label: 'Audio Speech / TTS', default: true },
|
||||||
|
{ key: 'audio_transcription', label: 'Audio Transcription', default: true },
|
||||||
|
{ key: 'vad', label: 'Voice Activity Detection', default: true },
|
||||||
|
{ key: 'detection', label: 'Detection', default: true },
|
||||||
|
{ key: 'video', label: 'Video Generation', default: true },
|
||||||
|
{ key: 'embeddings', label: 'Embeddings', default: true },
|
||||||
|
{ key: 'sound', label: 'Sound Generation', default: true },
|
||||||
|
],
|
||||||
|
agent_features: [
|
||||||
|
{ key: 'agents', label: 'Agents', default: false },
|
||||||
|
{ key: 'skills', label: 'Skills', default: false },
|
||||||
|
{ key: 'collections', label: 'Collections', default: false },
|
||||||
|
{ key: 'mcp_jobs', label: 'MCP CI Jobs', default: false },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers()
|
||||||
|
fetchFeatures()
|
||||||
|
}, [fetchUsers, fetchFeatures])
|
||||||
|
|
||||||
|
const handleToggleRole = async (u) => {
|
||||||
|
const newRole = u.role === 'admin' ? 'user' : 'admin'
|
||||||
|
try {
|
||||||
|
await adminUsersApi.setRole(u.id, newRole)
|
||||||
|
setUsers(prev => prev.map(x => x.id === u.id ? { ...x, role: newRole } : x))
|
||||||
|
addToast(`${u.name || u.email} is now ${newRole}`, 'success')
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to update role: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleStatus = async (u) => {
|
||||||
|
const newStatus = u.status === 'active' ? 'disabled' : 'active'
|
||||||
|
const action = newStatus === 'active' ? 'Approve' : 'Disable'
|
||||||
|
try {
|
||||||
|
await adminUsersApi.setStatus(u.id, newStatus)
|
||||||
|
setUsers(prev => prev.map(x => x.id === u.id ? { ...x, status: newStatus } : x))
|
||||||
|
addToast(`${action}d ${u.name || u.email}`, 'success')
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to ${action.toLowerCase()} user: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (u) => {
|
||||||
|
setConfirmDialog({
|
||||||
|
title: 'Delete User',
|
||||||
|
message: `Delete user "${u.name || u.email}"? This will also remove their sessions and API keys.`,
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
setConfirmDialog(null)
|
||||||
|
try {
|
||||||
|
await adminUsersApi.delete(u.id)
|
||||||
|
setUsers(prev => prev.filter(x => x.id !== u.id))
|
||||||
|
addToast(`User deleted`, 'success')
|
||||||
|
} catch (err) {
|
||||||
|
addToast(`Failed to delete user: ${err.message}`, 'error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = users.filter(u => {
|
||||||
|
if (!search) return true
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return (u.name || '').toLowerCase().includes(q) || (u.email || '').toLowerCase().includes(q)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePermissionSave = (userId, newPerms, newModels) => {
|
||||||
|
setUsers(prev => prev.map(u => u.id === userId ? { ...u, permissions: newPerms, allowed_models: newModels } : u))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelf = (u) => currentUser && (u.id === currentUser.id || u.email === currentUser.email)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1 className="page-title">Users</h1>
|
||||||
|
<p className="page-subtitle">Manage registered users, roles, and invites</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="auth-tab-bar">
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm auth-tab--pill ${activeTab === 'users' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setActiveTab('users')}
|
||||||
|
>
|
||||||
|
<i className="fas fa-users" /> Users
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm auth-tab--pill ${activeTab === 'invites' ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setActiveTab('invites')}
|
||||||
|
>
|
||||||
|
<i className="fas fa-envelope-open-text" /> Invites
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'invites' ? (
|
||||||
|
<InvitesTab addToast={addToast} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="auth-toolbar">
|
||||||
|
<div className="search-field">
|
||||||
|
<i className="fas fa-search search-field-icon" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-secondary btn-sm" onClick={fetchUsers} disabled={loading}>
|
||||||
|
<i className="fas fa-rotate" /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="auth-loading">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state-icon"><i className="fas fa-users" /></div>
|
||||||
|
<h2 className="empty-state-title">{search ? 'No matching users' : 'No users'}</h2>
|
||||||
|
<p className="empty-state-text">{search ? 'Try a different search term.' : 'No registered users found.'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="table-container">
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Provider</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Permissions</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th className="cell-actions">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map(u => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>
|
||||||
|
<div className="user-identity">
|
||||||
|
{u.avatarUrl ? (
|
||||||
|
<img src={u.avatarUrl} alt="" className="user-avatar" />
|
||||||
|
) : (
|
||||||
|
<i className="fas fa-user-circle user-avatar-placeholder" />
|
||||||
|
)}
|
||||||
|
<span className="user-name">{u.name || '(no name)'}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="user-email">{u.email}</td>
|
||||||
|
<td><ProviderBadge provider={u.provider} /></td>
|
||||||
|
<td><RoleBadge role={u.role} /></td>
|
||||||
|
<td>
|
||||||
|
<PermissionSummary
|
||||||
|
user={u}
|
||||||
|
onClick={() => u.role !== 'admin' && setEditingUser(u)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td><StatusBadge status={u.status} /></td>
|
||||||
|
<td className="cell-muted">
|
||||||
|
{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '-'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{!isSelf(u) && (
|
||||||
|
<div className="action-group">
|
||||||
|
{u.status !== 'active' ? (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={() => handleToggleStatus(u)}
|
||||||
|
title="Approve user"
|
||||||
|
>
|
||||||
|
<i className="fas fa-check" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-secondary"
|
||||||
|
onClick={() => handleToggleStatus(u)}
|
||||||
|
title="Disable user"
|
||||||
|
>
|
||||||
|
<i className="fas fa-ban" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${u.role === 'admin' ? 'btn-secondary' : 'btn-primary'}`}
|
||||||
|
onClick={() => handleToggleRole(u)}
|
||||||
|
title={u.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
|
||||||
|
>
|
||||||
|
<i className={`fas fa-${u.role === 'admin' ? 'arrow-down' : 'arrow-up'}`} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-danger"
|
||||||
|
onClick={() => handleDelete(u)}
|
||||||
|
title="Delete user"
|
||||||
|
>
|
||||||
|
<i className="fas fa-trash" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingUser && featureMeta && (
|
||||||
|
<PermissionsModal
|
||||||
|
user={editingUser}
|
||||||
|
featureMeta={featureMeta}
|
||||||
|
availableModels={availableModels}
|
||||||
|
onClose={() => setEditingUser(null)}
|
||||||
|
onSave={handlePermissionSave}
|
||||||
|
addToast={addToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmDialog}
|
||||||
|
title={confirmDialog?.title}
|
||||||
|
message={confirmDialog?.message}
|
||||||
|
confirmLabel={confirmDialog?.confirmLabel}
|
||||||
|
danger={confirmDialog?.danger}
|
||||||
|
onConfirm={confirmDialog?.onConfirm}
|
||||||
|
onCancel={() => setConfirmDialog(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
559
core/http/react-ui/src/pages/auth.css
Normal file
559
core/http/react-ui/src/pages/auth.css
Normal file
|
|
@ -0,0 +1,559 @@
|
||||||
|
/* ─── Shared auth page styles (Login, Users, Account) ─── */
|
||||||
|
|
||||||
|
/* ─── Status / role badges ─── */
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-success {
|
||||||
|
background: var(--color-success, #22c55e)22;
|
||||||
|
color: var(--color-success, #22c55e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-danger {
|
||||||
|
background: var(--color-danger, #ef4444)22;
|
||||||
|
color: var(--color-danger, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge-warning {
|
||||||
|
background: var(--color-warning, #eab308)22;
|
||||||
|
color: var(--color-warning, #eab308);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge-admin {
|
||||||
|
background: var(--color-accent-light);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge-user {
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-tag {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Tab bar ─── */
|
||||||
|
.auth-tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab-bar--flush {
|
||||||
|
gap: 0;
|
||||||
|
border-bottom-color: var(--color-border-default);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
transition: color 150ms, border-color 150ms, font-weight 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab.active {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-primary);
|
||||||
|
border-bottom-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab-icon {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-tab--pill {
|
||||||
|
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Toolbar (search + buttons row) ─── */
|
||||||
|
.auth-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Search field with icon ─── */
|
||||||
|
.search-field {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field .input {
|
||||||
|
padding-left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Centered loading ─── */
|
||||||
|
.auth-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── User row (avatar + name) ─── */
|
||||||
|
.user-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar--lg {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-placeholder {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-avatar-placeholder--lg {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-email {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Table cells ─── */
|
||||||
|
.cell-sm {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-muted {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-actions {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-actions--sm {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Inline action group ─── */
|
||||||
|
.action-group {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Monospace / code text ─── */
|
||||||
|
.mono-text {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono-text--truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Permission summary button ─── */
|
||||||
|
.perm-summary-btn {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-summary-btn i {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-summary-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Permissions modal ─── */
|
||||||
|
.perm-modal-body {
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding-bottom: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-modal-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-section {
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-section-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-section-title i {
|
||||||
|
margin-right: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-grid {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-btn-all-none {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-btn-feature {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 5px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-toggle-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-empty {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Invite link cell ─── */
|
||||||
|
.invite-cell {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-text {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-copy-btn {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Account page ─── */
|
||||||
|
.account-page {
|
||||||
|
max-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-user-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
background: var(--color-bg-tertiary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-avatar-frame {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
border: 2px solid var(--color-primary-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-avatar-icon {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-user-meta {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-user-email {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-user-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-input-sm {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-input-xs {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-avatar-preview {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--color-border-default);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
margin-top: var(--spacing-md);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Empty state icon block ─── */
|
||||||
|
.empty-icon-block {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon-block i {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon-block-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── API key list ─── */
|
||||||
|
.apikey-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apikey-row:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.apikey-icon {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apikey-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apikey-name {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.apikey-details {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apikey-revoke-btn {
|
||||||
|
color: var(--color-error);
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.apikey-revoke-btn i {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── New key banner ─── */
|
||||||
|
.new-key-banner {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border: 1px solid var(--color-warning-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-warning-light);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-key-banner-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-bottom: var(--spacing-xs);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-key-banner-body {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-key-value {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
background: var(--color-bg-primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
word-break: break-all;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Login page ─── */
|
||||||
|
.login-btn-full {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Modal (backdrop + panel) ─── */
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-modal-backdrop);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
animation: fadeIn 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-panel {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
|
animation: slideUp 150ms ease;
|
||||||
|
}
|
||||||
|
|
@ -31,15 +31,29 @@ import BackendLogs from './pages/BackendLogs'
|
||||||
import Explorer from './pages/Explorer'
|
import Explorer from './pages/Explorer'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import NotFound from './pages/NotFound'
|
import NotFound from './pages/NotFound'
|
||||||
|
import Usage from './pages/Usage'
|
||||||
|
import Users from './pages/Users'
|
||||||
|
import Account from './pages/Account'
|
||||||
|
import RequireAdmin from './components/RequireAdmin'
|
||||||
|
import RequireAuth from './components/RequireAuth'
|
||||||
|
import RequireFeature from './components/RequireFeature'
|
||||||
|
|
||||||
function BrowseRedirect() {
|
function BrowseRedirect() {
|
||||||
const { '*': splat } = useParams()
|
const { '*': splat } = useParams()
|
||||||
return <Navigate to={`/app/${splat || ''}`} replace />
|
return <Navigate to={`/app/${splat || ''}`} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Admin({ children }) {
|
||||||
|
return <RequireAdmin>{children}</RequireAdmin>
|
||||||
|
}
|
||||||
|
|
||||||
|
function Feature({ feature, children }) {
|
||||||
|
return <RequireFeature feature={feature}>{children}</RequireFeature>
|
||||||
|
}
|
||||||
|
|
||||||
const appChildren = [
|
const appChildren = [
|
||||||
{ index: true, element: <Home /> },
|
{ index: true, element: <Home /> },
|
||||||
{ path: 'models', element: <Models /> },
|
{ path: 'models', element: <Admin><Models /></Admin> },
|
||||||
{ path: 'chat', element: <Chat /> },
|
{ path: 'chat', element: <Chat /> },
|
||||||
{ path: 'chat/:model', element: <Chat /> },
|
{ path: 'chat/:model', element: <Chat /> },
|
||||||
{ path: 'image', element: <ImageGen /> },
|
{ path: 'image', element: <ImageGen /> },
|
||||||
|
|
@ -51,29 +65,32 @@ const appChildren = [
|
||||||
{ path: 'sound', element: <Sound /> },
|
{ path: 'sound', element: <Sound /> },
|
||||||
{ path: 'sound/:model', element: <Sound /> },
|
{ path: 'sound/:model', element: <Sound /> },
|
||||||
{ path: 'talk', element: <Talk /> },
|
{ path: 'talk', element: <Talk /> },
|
||||||
{ path: 'manage', element: <Manage /> },
|
{ path: 'usage', element: <Usage /> },
|
||||||
{ path: 'backends', element: <Backends /> },
|
{ path: 'account', element: <Account /> },
|
||||||
{ path: 'settings', element: <Settings /> },
|
{ path: 'users', element: <Admin><Users /></Admin> },
|
||||||
{ path: 'traces', element: <Traces /> },
|
{ path: 'manage', element: <Admin><Manage /></Admin> },
|
||||||
{ path: 'backend-logs/:modelId', element: <BackendLogs /> },
|
{ path: 'backends', element: <Admin><Backends /></Admin> },
|
||||||
{ path: 'p2p', element: <P2P /> },
|
{ path: 'settings', element: <Admin><Settings /></Admin> },
|
||||||
{ path: 'agents', element: <Agents /> },
|
{ path: 'traces', element: <Admin><Traces /></Admin> },
|
||||||
{ path: 'agents/new', element: <AgentCreate /> },
|
{ path: 'backend-logs/:modelId', element: <Admin><BackendLogs /></Admin> },
|
||||||
{ path: 'agents/:name/edit', element: <AgentCreate /> },
|
{ path: 'p2p', element: <Admin><P2P /></Admin> },
|
||||||
{ path: 'agents/:name/chat', element: <AgentChat /> },
|
{ path: 'agents', element: <Feature feature="agents"><Agents /></Feature> },
|
||||||
{ path: 'agents/:name/status', element: <AgentStatus /> },
|
{ path: 'agents/new', element: <Feature feature="agents"><AgentCreate /></Feature> },
|
||||||
{ path: 'collections', element: <Collections /> },
|
{ path: 'agents/:name/edit', element: <Feature feature="agents"><AgentCreate /></Feature> },
|
||||||
{ path: 'collections/:name', element: <CollectionDetails /> },
|
{ path: 'agents/:name/chat', element: <Feature feature="agents"><AgentChat /></Feature> },
|
||||||
{ path: 'skills', element: <Skills /> },
|
{ path: 'agents/:name/status', element: <Feature feature="agents"><AgentStatus /></Feature> },
|
||||||
{ path: 'skills/new', element: <SkillEdit /> },
|
{ path: 'collections', element: <Feature feature="collections"><Collections /></Feature> },
|
||||||
{ path: 'skills/edit/:name', element: <SkillEdit /> },
|
{ path: 'collections/:name', element: <Feature feature="collections"><CollectionDetails /></Feature> },
|
||||||
{ path: 'agent-jobs', element: <AgentJobs /> },
|
{ path: 'skills', element: <Feature feature="skills"><Skills /></Feature> },
|
||||||
{ path: 'agent-jobs/tasks/new', element: <AgentTaskDetails /> },
|
{ path: 'skills/new', element: <Feature feature="skills"><SkillEdit /></Feature> },
|
||||||
{ path: 'agent-jobs/tasks/:id', element: <AgentTaskDetails /> },
|
{ path: 'skills/edit/:name', element: <Feature feature="skills"><SkillEdit /></Feature> },
|
||||||
{ path: 'agent-jobs/tasks/:id/edit', element: <AgentTaskDetails /> },
|
{ path: 'agent-jobs', element: <Feature feature="mcp_jobs"><AgentJobs /></Feature> },
|
||||||
{ path: 'agent-jobs/jobs/:id', element: <AgentJobDetails /> },
|
{ path: 'agent-jobs/tasks/new', element: <Feature feature="mcp_jobs"><AgentTaskDetails /></Feature> },
|
||||||
{ path: 'model-editor/:name', element: <ModelEditor /> },
|
{ path: 'agent-jobs/tasks/:id', element: <Feature feature="mcp_jobs"><AgentTaskDetails /></Feature> },
|
||||||
{ path: 'import-model', element: <ImportModel /> },
|
{ path: 'agent-jobs/tasks/:id/edit', element: <Feature feature="mcp_jobs"><AgentTaskDetails /></Feature> },
|
||||||
|
{ path: 'agent-jobs/jobs/:id', element: <Feature feature="mcp_jobs"><AgentJobDetails /></Feature> },
|
||||||
|
{ path: 'model-editor/:name', element: <Admin><ModelEditor /></Admin> },
|
||||||
|
{ path: 'import-model', element: <Admin><ImportModel /></Admin> },
|
||||||
{ path: '*', element: <NotFound /> },
|
{ path: '*', element: <NotFound /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -82,13 +99,17 @@ export const router = createBrowserRouter([
|
||||||
path: '/login',
|
path: '/login',
|
||||||
element: <Login />,
|
element: <Login />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/invite/:code',
|
||||||
|
element: <Login />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/explorer',
|
path: '/explorer',
|
||||||
element: <Explorer />,
|
element: <Explorer />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/app',
|
path: '/app',
|
||||||
element: <App />,
|
element: <RequireAuth><App /></RequireAuth>,
|
||||||
children: appChildren,
|
children: appChildren,
|
||||||
},
|
},
|
||||||
// Backward compatibility: redirect /browse/* to /app/*
|
// Backward compatibility: redirect /browse/* to /app/*
|
||||||
|
|
|
||||||
|
|
@ -6,22 +6,20 @@
|
||||||
--color-bg-tertiary: #222222;
|
--color-bg-tertiary: #222222;
|
||||||
--color-bg-overlay: rgba(18, 18, 18, 0.95);
|
--color-bg-overlay: rgba(18, 18, 18, 0.95);
|
||||||
|
|
||||||
--color-primary: #38BDF8;
|
--color-primary: #3B82F6;
|
||||||
--color-primary-hover: #0EA5E9;
|
--color-primary-hover: #2563EB;
|
||||||
--color-primary-active: #0284C7;
|
--color-primary-active: #1D4ED8;
|
||||||
--color-primary-text: #FFFFFF;
|
--color-primary-text: #FFFFFF;
|
||||||
--color-primary-light: rgba(56, 189, 248, 0.08);
|
--color-primary-light: rgba(59, 130, 246, 0.08);
|
||||||
--color-primary-border: rgba(56, 189, 248, 0.15);
|
--color-primary-border: rgba(59, 130, 246, 0.15);
|
||||||
|
|
||||||
--color-secondary: #14B8A6;
|
--color-secondary: #64748B;
|
||||||
--color-secondary-hover: #0D9488;
|
--color-secondary-hover: #475569;
|
||||||
--color-secondary-light: rgba(20, 184, 166, 0.1);
|
--color-secondary-light: rgba(100, 116, 139, 0.1);
|
||||||
|
|
||||||
--color-accent: #8B5CF6;
|
--color-accent: #F59E0B;
|
||||||
--color-accent-hover: #7C3AED;
|
--color-accent-hover: #D97706;
|
||||||
--color-accent-light: rgba(139, 92, 246, 0.1);
|
--color-accent-light: rgba(245, 158, 11, 0.1);
|
||||||
--color-accent-purple: #A78BFA;
|
|
||||||
--color-accent-teal: #2DD4BF;
|
|
||||||
|
|
||||||
--color-text-primary: #E5E7EB;
|
--color-text-primary: #E5E7EB;
|
||||||
--color-text-secondary: #94A3B8;
|
--color-text-secondary: #94A3B8;
|
||||||
|
|
@ -31,36 +29,31 @@
|
||||||
|
|
||||||
--color-border-subtle: rgba(255, 255, 255, 0.08);
|
--color-border-subtle: rgba(255, 255, 255, 0.08);
|
||||||
--color-border-default: rgba(255, 255, 255, 0.12);
|
--color-border-default: rgba(255, 255, 255, 0.12);
|
||||||
--color-border-strong: rgba(56, 189, 248, 0.3);
|
--color-border-strong: rgba(59, 130, 246, 0.3);
|
||||||
--color-border-divider: rgba(255, 255, 255, 0.05);
|
--color-border-divider: rgba(255, 255, 255, 0.05);
|
||||||
--color-border-primary: rgba(56, 189, 248, 0.2);
|
--color-border-primary: rgba(59, 130, 246, 0.2);
|
||||||
--color-border-focus: rgba(56, 189, 248, 0.4);
|
--color-border-focus: rgba(59, 130, 246, 0.4);
|
||||||
|
|
||||||
--color-success: #14B8A6;
|
--color-success: #22C55E;
|
||||||
--color-success-light: rgba(20, 184, 166, 0.1);
|
--color-success-light: rgba(34, 197, 94, 0.1);
|
||||||
--color-success-border: rgba(20, 184, 166, 0.3);
|
--color-success-border: rgba(34, 197, 94, 0.3);
|
||||||
--color-warning: #F59E0B;
|
--color-warning: #F59E0B;
|
||||||
--color-warning-light: rgba(245, 158, 11, 0.1);
|
--color-warning-light: rgba(245, 158, 11, 0.1);
|
||||||
--color-warning-border: rgba(245, 158, 11, 0.3);
|
--color-warning-border: rgba(245, 158, 11, 0.3);
|
||||||
--color-error: #EF4444;
|
--color-error: #EF4444;
|
||||||
--color-error-light: rgba(239, 68, 68, 0.1);
|
--color-error-light: rgba(239, 68, 68, 0.1);
|
||||||
--color-error-border: rgba(239, 68, 68, 0.3);
|
--color-error-border: rgba(239, 68, 68, 0.3);
|
||||||
--color-info: #38BDF8;
|
--color-info: #3B82F6;
|
||||||
--color-info-light: rgba(56, 189, 248, 0.1);
|
--color-info-light: rgba(59, 130, 246, 0.1);
|
||||||
--color-info-border: rgba(56, 189, 248, 0.3);
|
--color-info-border: rgba(59, 130, 246, 0.3);
|
||||||
--color-accent-border: rgba(139, 92, 246, 0.3);
|
--color-accent-border: rgba(245, 158, 11, 0.3);
|
||||||
--color-modal-backdrop: rgba(0, 0, 0, 0.6);
|
--color-modal-backdrop: rgba(0, 0, 0, 0.6);
|
||||||
|
|
||||||
--gradient-primary: linear-gradient(135deg, #38BDF8 0%, #8B5CF6 50%, #14B8A6 100%);
|
|
||||||
--gradient-hero: linear-gradient(135deg, #121212 0%, #1A1A1A 50%, #121212 100%);
|
|
||||||
--gradient-card: linear-gradient(135deg, rgba(56, 189, 248, 0.04) 0%, rgba(139, 92, 246, 0.04) 100%);
|
|
||||||
--gradient-text: linear-gradient(135deg, #38BDF8 0%, #8B5CF6 50%, #14B8A6 100%);
|
|
||||||
|
|
||||||
--shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.2);
|
--shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25);
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25);
|
||||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
|
||||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.35);
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.35);
|
||||||
--shadow-glow: 0 0 0 1px rgba(56, 189, 248, 0.15), 0 0 12px rgba(56, 189, 248, 0.2);
|
--shadow-glow: 0 0 0 1px rgba(59, 130, 246, 0.15), 0 0 12px rgba(59, 130, 246, 0.2);
|
||||||
--shadow-sidebar: 1px 0 3px rgba(0, 0, 0, 0.25);
|
--shadow-sidebar: 1px 0 3px rgba(0, 0, 0, 0.25);
|
||||||
|
|
||||||
--duration-fast: 150ms;
|
--duration-fast: 150ms;
|
||||||
|
|
@ -91,22 +84,20 @@
|
||||||
--color-bg-tertiary: #FFFFFF;
|
--color-bg-tertiary: #FFFFFF;
|
||||||
--color-bg-overlay: rgba(248, 250, 252, 0.9);
|
--color-bg-overlay: rgba(248, 250, 252, 0.9);
|
||||||
|
|
||||||
--color-primary: #0EA5E9;
|
--color-primary: #2563EB;
|
||||||
--color-primary-hover: #0284C7;
|
--color-primary-hover: #1D4ED8;
|
||||||
--color-primary-active: #0369A1;
|
--color-primary-active: #1E40AF;
|
||||||
--color-primary-text: #FFFFFF;
|
--color-primary-text: #FFFFFF;
|
||||||
--color-primary-light: rgba(14, 165, 233, 0.08);
|
--color-primary-light: rgba(37, 99, 235, 0.08);
|
||||||
--color-primary-border: rgba(14, 165, 233, 0.2);
|
--color-primary-border: rgba(37, 99, 235, 0.2);
|
||||||
|
|
||||||
--color-secondary: #0D9488;
|
--color-secondary: #475569;
|
||||||
--color-secondary-hover: #0F766E;
|
--color-secondary-hover: #334155;
|
||||||
--color-secondary-light: rgba(13, 148, 136, 0.1);
|
--color-secondary-light: rgba(71, 85, 105, 0.1);
|
||||||
|
|
||||||
--color-accent: #7C3AED;
|
--color-accent: #D97706;
|
||||||
--color-accent-hover: #6D28D9;
|
--color-accent-hover: #B45309;
|
||||||
--color-accent-light: rgba(124, 58, 237, 0.1);
|
--color-accent-light: rgba(217, 119, 6, 0.1);
|
||||||
--color-accent-purple: #A78BFA;
|
|
||||||
--color-accent-teal: #2DD4BF;
|
|
||||||
|
|
||||||
--color-text-primary: #1E293B;
|
--color-text-primary: #1E293B;
|
||||||
--color-text-secondary: #64748B;
|
--color-text-secondary: #64748B;
|
||||||
|
|
@ -116,36 +107,31 @@
|
||||||
|
|
||||||
--color-border-subtle: rgba(15, 23, 42, 0.06);
|
--color-border-subtle: rgba(15, 23, 42, 0.06);
|
||||||
--color-border-default: rgba(15, 23, 42, 0.1);
|
--color-border-default: rgba(15, 23, 42, 0.1);
|
||||||
--color-border-strong: rgba(14, 165, 233, 0.3);
|
--color-border-strong: rgba(37, 99, 235, 0.3);
|
||||||
--color-border-divider: rgba(15, 23, 42, 0.04);
|
--color-border-divider: rgba(15, 23, 42, 0.04);
|
||||||
--color-border-primary: rgba(14, 165, 233, 0.2);
|
--color-border-primary: rgba(37, 99, 235, 0.2);
|
||||||
--color-border-focus: rgba(14, 165, 233, 0.4);
|
--color-border-focus: rgba(37, 99, 235, 0.4);
|
||||||
|
|
||||||
--color-success: #0D9488;
|
--color-success: #16A34A;
|
||||||
--color-success-light: rgba(13, 148, 136, 0.1);
|
--color-success-light: rgba(22, 163, 74, 0.1);
|
||||||
--color-success-border: rgba(13, 148, 136, 0.3);
|
--color-success-border: rgba(22, 163, 74, 0.3);
|
||||||
--color-warning: #D97706;
|
--color-warning: #D97706;
|
||||||
--color-warning-light: rgba(217, 119, 6, 0.1);
|
--color-warning-light: rgba(217, 119, 6, 0.1);
|
||||||
--color-warning-border: rgba(217, 119, 6, 0.3);
|
--color-warning-border: rgba(217, 119, 6, 0.3);
|
||||||
--color-error: #DC2626;
|
--color-error: #DC2626;
|
||||||
--color-error-light: rgba(220, 38, 38, 0.1);
|
--color-error-light: rgba(220, 38, 38, 0.1);
|
||||||
--color-error-border: rgba(220, 38, 38, 0.3);
|
--color-error-border: rgba(220, 38, 38, 0.3);
|
||||||
--color-info: #0EA5E9;
|
--color-info: #2563EB;
|
||||||
--color-info-light: rgba(14, 165, 233, 0.1);
|
--color-info-light: rgba(37, 99, 235, 0.1);
|
||||||
--color-info-border: rgba(14, 165, 233, 0.3);
|
--color-info-border: rgba(37, 99, 235, 0.3);
|
||||||
--color-accent-border: rgba(124, 58, 237, 0.3);
|
--color-accent-border: rgba(217, 119, 6, 0.3);
|
||||||
--color-modal-backdrop: rgba(0, 0, 0, 0.5);
|
--color-modal-backdrop: rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
--gradient-primary: linear-gradient(135deg, #0EA5E9 0%, #7C3AED 50%, #0D9488 100%);
|
|
||||||
--gradient-hero: linear-gradient(135deg, #F8FAFC 0%, #FFFFFF 50%, #F8FAFC 100%);
|
|
||||||
--gradient-card: linear-gradient(135deg, rgba(14, 165, 233, 0.03) 0%, rgba(124, 58, 237, 0.03) 100%);
|
|
||||||
--gradient-text: linear-gradient(135deg, #0EA5E9 0%, #7C3AED 50%, #0D9488 100%);
|
|
||||||
|
|
||||||
--shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.05);
|
--shadow-subtle: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
|
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
|
||||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.08);
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.08);
|
||||||
--shadow-glow: 0 0 0 1px rgba(14, 165, 233, 0.15), 0 0 8px rgba(14, 165, 233, 0.2);
|
--shadow-glow: 0 0 0 1px rgba(37, 99, 235, 0.15), 0 0 8px rgba(37, 99, 235, 0.2);
|
||||||
--shadow-sidebar: 1px 0 3px rgba(0, 0, 0, 0.08);
|
--shadow-sidebar: 1px 0 3px rgba(0, 0, 0, 0.08);
|
||||||
--color-toggle-off: #CBD5E1;
|
--color-toggle-off: #CBD5E1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
139
core/http/react-ui/src/utils/api.js
vendored
139
core/http/react-ui/src/utils/api.js
vendored
|
|
@ -1,6 +1,9 @@
|
||||||
import { API_CONFIG } from './config'
|
import { API_CONFIG } from './config'
|
||||||
import { apiUrl } from './basePath'
|
import { apiUrl } from './basePath'
|
||||||
|
|
||||||
|
const enc = encodeURIComponent
|
||||||
|
const userQ = (userId) => userId ? `?user_id=${enc(userId)}` : ''
|
||||||
|
|
||||||
async function handleResponse(response) {
|
async function handleResponse(response) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let errorMessage = `HTTP ${response.status}`
|
let errorMessage = `HTTP ${response.status}`
|
||||||
|
|
@ -169,13 +172,13 @@ export const p2pApi = {
|
||||||
|
|
||||||
// Agent Jobs API
|
// Agent Jobs API
|
||||||
export const agentJobsApi = {
|
export const agentJobsApi = {
|
||||||
listTasks: () => fetchJSON(API_CONFIG.endpoints.agentTasks),
|
listTasks: (allUsers) => fetchJSON(`${API_CONFIG.endpoints.agentTasks}${allUsers ? '?all_users=true' : ''}`),
|
||||||
getTask: (id) => fetchJSON(API_CONFIG.endpoints.agentTask(id)),
|
getTask: (id) => fetchJSON(API_CONFIG.endpoints.agentTask(id)),
|
||||||
createTask: (body) => postJSON(API_CONFIG.endpoints.agentTasks, body),
|
createTask: (body) => postJSON(API_CONFIG.endpoints.agentTasks, body),
|
||||||
updateTask: (id, body) => fetchJSON(API_CONFIG.endpoints.agentTask(id), { method: 'PUT', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }),
|
updateTask: (id, body) => fetchJSON(API_CONFIG.endpoints.agentTask(id), { method: 'PUT', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' } }),
|
||||||
deleteTask: (id) => fetchJSON(API_CONFIG.endpoints.agentTask(id), { method: 'DELETE' }),
|
deleteTask: (id) => fetchJSON(API_CONFIG.endpoints.agentTask(id), { method: 'DELETE' }),
|
||||||
executeTask: (name) => postJSON(API_CONFIG.endpoints.executeAgentTask(name), {}),
|
executeTask: (name) => postJSON(API_CONFIG.endpoints.executeAgentTask(name), {}),
|
||||||
listJobs: () => fetchJSON(API_CONFIG.endpoints.agentJobs),
|
listJobs: (allUsers) => fetchJSON(`${API_CONFIG.endpoints.agentJobs}${allUsers ? '?all_users=true' : ''}`),
|
||||||
getJob: (id) => fetchJSON(API_CONFIG.endpoints.agentJob(id)),
|
getJob: (id) => fetchJSON(API_CONFIG.endpoints.agentJob(id)),
|
||||||
cancelJob: (id) => postJSON(API_CONFIG.endpoints.cancelAgentJob(id), {}),
|
cancelJob: (id) => postJSON(API_CONFIG.endpoints.cancelAgentJob(id), {}),
|
||||||
executeJob: (body) => postJSON(API_CONFIG.endpoints.executeAgentJob, body),
|
executeJob: (body) => postJSON(API_CONFIG.endpoints.executeAgentJob, body),
|
||||||
|
|
@ -264,57 +267,117 @@ export const systemApi = {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const agentsApi = {
|
export const agentsApi = {
|
||||||
list: () => fetchJSON('/api/agents'),
|
list: (allUsers) => fetchJSON(`/api/agents${allUsers ? '?all_users=true' : ''}`),
|
||||||
create: (config) => postJSON('/api/agents', config),
|
create: (config) => postJSON('/api/agents', config),
|
||||||
get: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}`),
|
get: (name, userId) => fetchJSON(`/api/agents/${enc(name)}${userQ(userId)}`),
|
||||||
getConfig: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/config`),
|
getConfig: (name, userId) => fetchJSON(`/api/agents/${enc(name)}/config${userQ(userId)}`),
|
||||||
update: (name, config) => fetchJSON(`/api/agents/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(config), headers: { 'Content-Type': 'application/json' } }),
|
update: (name, config, userId) => fetchJSON(`/api/agents/${enc(name)}${userQ(userId)}`, { method: 'PUT', body: JSON.stringify(config), headers: { 'Content-Type': 'application/json' } }),
|
||||||
delete: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
delete: (name, userId) => fetchJSON(`/api/agents/${enc(name)}${userQ(userId)}`, { method: 'DELETE' }),
|
||||||
pause: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/pause`, { method: 'PUT' }),
|
pause: (name, userId) => fetchJSON(`/api/agents/${enc(name)}/pause${userQ(userId)}`, { method: 'PUT' }),
|
||||||
resume: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/resume`, { method: 'PUT' }),
|
resume: (name, userId) => fetchJSON(`/api/agents/${enc(name)}/resume${userQ(userId)}`, { method: 'PUT' }),
|
||||||
status: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/status`),
|
status: (name, userId) => fetchJSON(`/api/agents/${enc(name)}/status${userQ(userId)}`),
|
||||||
observables: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/observables`),
|
observables: (name, userId) => fetchJSON(`/api/agents/${enc(name)}/observables${userQ(userId)}`),
|
||||||
clearObservables: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/observables`, { method: 'DELETE' }),
|
clearObservables: (name, userId) => fetchJSON(`/api/agents/${enc(name)}/observables${userQ(userId)}`, { method: 'DELETE' }),
|
||||||
chat: (name, message) => postJSON(`/api/agents/${encodeURIComponent(name)}/chat`, { message }),
|
chat: (name, message, userId) => postJSON(`/api/agents/${enc(name)}/chat${userQ(userId)}`, { message }),
|
||||||
export: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/export`),
|
export: (name, userId) => fetchJSON(`/api/agents/${enc(name)}/export${userQ(userId)}`),
|
||||||
import: (formData) => fetch(apiUrl('/api/agents/import'), { method: 'POST', body: formData }).then(handleResponse),
|
import: (formData) => fetch(apiUrl('/api/agents/import'), { method: 'POST', body: formData }).then(handleResponse),
|
||||||
configMeta: () => fetchJSON('/api/agents/config/metadata'),
|
configMeta: () => fetchJSON('/api/agents/config/metadata'),
|
||||||
|
sseUrl: (name, userId) => `/api/agents/${enc(name)}/sse${userQ(userId)}`,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const agentCollectionsApi = {
|
export const agentCollectionsApi = {
|
||||||
list: () => fetchJSON('/api/agents/collections'),
|
list: (allUsers) => fetchJSON(`/api/agents/collections${allUsers ? '?all_users=true' : ''}`),
|
||||||
create: (name) => postJSON('/api/agents/collections', { name }),
|
create: (name) => postJSON('/api/agents/collections', { name }),
|
||||||
upload: (name, formData) => fetch(apiUrl(`/api/agents/collections/${encodeURIComponent(name)}/upload`), { method: 'POST', body: formData }).then(handleResponse),
|
upload: (name, formData, userId) => fetch(apiUrl(`/api/agents/collections/${enc(name)}/upload${userQ(userId)}`), { method: 'POST', body: formData }).then(handleResponse),
|
||||||
entries: (name) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/entries`),
|
entries: (name, userId) => fetchJSON(`/api/agents/collections/${enc(name)}/entries${userQ(userId)}`),
|
||||||
entryContent: (name, entry) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/entries/${encodeURIComponent(entry)}`),
|
entryContent: (name, entry, userId) => fetchJSON(`/api/agents/collections/${enc(name)}/entries/${encodeURIComponent(entry)}${userQ(userId)}`),
|
||||||
search: (name, query, maxResults) => postJSON(`/api/agents/collections/${encodeURIComponent(name)}/search`, { query, max_results: maxResults }),
|
search: (name, query, maxResults, userId) => postJSON(`/api/agents/collections/${enc(name)}/search${userQ(userId)}`, { query, max_results: maxResults }),
|
||||||
reset: (name) => postJSON(`/api/agents/collections/${encodeURIComponent(name)}/reset`),
|
reset: (name, userId) => postJSON(`/api/agents/collections/${enc(name)}/reset${userQ(userId)}`),
|
||||||
deleteEntry: (name, entry) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/entry/delete`, { method: 'DELETE', body: JSON.stringify({ entry }), headers: { 'Content-Type': 'application/json' } }),
|
deleteEntry: (name, entry, userId) => fetchJSON(`/api/agents/collections/${enc(name)}/entry/delete${userQ(userId)}`, { method: 'DELETE', body: JSON.stringify({ entry }), headers: { 'Content-Type': 'application/json' } }),
|
||||||
sources: (name) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/sources`),
|
sources: (name, userId) => fetchJSON(`/api/agents/collections/${enc(name)}/sources${userQ(userId)}`),
|
||||||
addSource: (name, url, interval) => postJSON(`/api/agents/collections/${encodeURIComponent(name)}/sources`, { url, update_interval: interval }),
|
addSource: (name, url, interval, userId) => postJSON(`/api/agents/collections/${enc(name)}/sources${userQ(userId)}`, { url, update_interval: interval }),
|
||||||
removeSource: (name, url) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/sources`, { method: 'DELETE', body: JSON.stringify({ url }), headers: { 'Content-Type': 'application/json' } }),
|
removeSource: (name, url, userId) => fetchJSON(`/api/agents/collections/${enc(name)}/sources${userQ(userId)}`, { method: 'DELETE', body: JSON.stringify({ url }), headers: { 'Content-Type': 'application/json' } }),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skills API
|
// Skills API
|
||||||
export const skillsApi = {
|
export const skillsApi = {
|
||||||
list: () => fetchJSON('/api/agents/skills'),
|
list: (allUsers) => fetchJSON(`/api/agents/skills${allUsers ? '?all_users=true' : ''}`),
|
||||||
search: (q) => fetchJSON(`/api/agents/skills/search?q=${encodeURIComponent(q)}`),
|
search: (q) => fetchJSON(`/api/agents/skills/search?q=${enc(q)}`),
|
||||||
get: (name) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}`),
|
get: (name, userId) => fetchJSON(`/api/agents/skills/${enc(name)}${userQ(userId)}`),
|
||||||
create: (data) => postJSON('/api/agents/skills', data),
|
create: (data) => postJSON('/api/agents/skills', data),
|
||||||
update: (name, data) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json' } }),
|
update: (name, data, userId) => fetchJSON(`/api/agents/skills/${enc(name)}${userQ(userId)}`, { method: 'PUT', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json' } }),
|
||||||
delete: (name) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}`, { method: 'DELETE' }),
|
delete: (name, userId) => fetchJSON(`/api/agents/skills/${enc(name)}${userQ(userId)}`, { method: 'DELETE' }),
|
||||||
import: (file) => { const fd = new FormData(); fd.append('file', file); return fetch(apiUrl('/api/agents/skills/import'), { method: 'POST', body: fd }).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }); },
|
import: (file) => { const fd = new FormData(); fd.append('file', file); return fetch(apiUrl('/api/agents/skills/import'), { method: 'POST', body: fd }).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }); },
|
||||||
exportUrl: (name) => apiUrl(`/api/agents/skills/export/${encodeURIComponent(name)}`),
|
exportUrl: (name, userId) => apiUrl(`/api/agents/skills/export/${enc(name)}${userQ(userId)}`),
|
||||||
listResources: (name) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources`),
|
listResources: (name, userId) => fetchJSON(`/api/agents/skills/${enc(name)}/resources${userQ(userId)}`),
|
||||||
getResource: (name, path, opts) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources/${path}${opts?.json ? '?encoding=base64' : ''}`),
|
getResource: (name, path, opts, userId) => fetchJSON(`/api/agents/skills/${enc(name)}/resources/${path}${opts?.json ? '?encoding=base64' : ''}${userId ? `${opts?.json ? '&' : '?'}user_id=${enc(userId)}` : ''}`),
|
||||||
createResource: (name, path, file) => { const fd = new FormData(); fd.append('file', file); fd.append('path', path); return fetch(apiUrl(`/api/agents/skills/${encodeURIComponent(name)}/resources`), { method: 'POST', body: fd }).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }); },
|
createResource: (name, path, file) => { const fd = new FormData(); fd.append('file', file); fd.append('path', path); return fetch(apiUrl(`/api/agents/skills/${enc(name)}/resources`), { method: 'POST', body: fd }).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }); },
|
||||||
updateResource: (name, path, content) => postJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources/${path}`, { content }),
|
updateResource: (name, path, content) => postJSON(`/api/agents/skills/${enc(name)}/resources/${path}`, { content }),
|
||||||
deleteResource: (name, path) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources/${path}`, { method: 'DELETE' }),
|
deleteResource: (name, path) => fetchJSON(`/api/agents/skills/${enc(name)}/resources/${path}`, { method: 'DELETE' }),
|
||||||
listGitRepos: () => fetchJSON('/api/agents/git-repos'),
|
listGitRepos: () => fetchJSON('/api/agents/git-repos'),
|
||||||
addGitRepo: (url) => postJSON('/api/agents/git-repos', { url }),
|
addGitRepo: (url) => postJSON('/api/agents/git-repos', { url }),
|
||||||
syncGitRepo: (id) => postJSON(`/api/agents/git-repos/${encodeURIComponent(id)}/sync`, {}),
|
syncGitRepo: (id) => postJSON(`/api/agents/git-repos/${enc(id)}/sync`, {}),
|
||||||
toggleGitRepo: (id) => postJSON(`/api/agents/git-repos/${encodeURIComponent(id)}/toggle`, {}),
|
toggleGitRepo: (id) => postJSON(`/api/agents/git-repos/${enc(id)}/toggle`, {}),
|
||||||
deleteGitRepo: (id) => fetchJSON(`/api/agents/git-repos/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
deleteGitRepo: (id) => fetchJSON(`/api/agents/git-repos/${enc(id)}`, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage API
|
||||||
|
export const usageApi = {
|
||||||
|
getMyUsage: (period) => fetchJSON(`/api/auth/usage?period=${period || 'month'}`),
|
||||||
|
getAdminUsage: (period, userId) => {
|
||||||
|
let url = `/api/auth/admin/usage?period=${period || 'month'}`
|
||||||
|
if (userId) url += `&user_id=${encodeURIComponent(userId)}`
|
||||||
|
return fetchJSON(url)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin Users API
|
||||||
|
export const adminUsersApi = {
|
||||||
|
list: () => fetchJSON('/api/auth/admin/users'),
|
||||||
|
setRole: (id, role) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/role`, {
|
||||||
|
method: 'PUT', body: JSON.stringify({ role }), headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
delete: (id) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
|
setStatus: (id, status) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/status`, {
|
||||||
|
method: 'PUT', body: JSON.stringify({ status }), headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
getPermissions: (id) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/permissions`),
|
||||||
|
setPermissions: (id, perms) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/permissions`, {
|
||||||
|
method: 'PUT', body: JSON.stringify(perms), headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
getFeatures: () => fetchJSON('/api/auth/admin/features'),
|
||||||
|
setModels: (id, allowlist) => fetchJSON(`/api/auth/admin/users/${encodeURIComponent(id)}/models`, {
|
||||||
|
method: 'PUT', body: JSON.stringify(allowlist), headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile API
|
||||||
|
export const profileApi = {
|
||||||
|
get: () => fetchJSON('/api/auth/me'),
|
||||||
|
updateName: (name) => fetchJSON('/api/auth/profile', {
|
||||||
|
method: 'PUT', body: JSON.stringify({ name }), headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
updateProfile: (name, avatarUrl) => fetchJSON('/api/auth/profile', {
|
||||||
|
method: 'PUT', body: JSON.stringify({ name, avatar_url: avatarUrl || '' }), headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
changePassword: (currentPassword, newPassword) => fetchJSON('/api/auth/password', {
|
||||||
|
method: 'PUT', body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin Invites API
|
||||||
|
export const adminInvitesApi = {
|
||||||
|
list: () => fetchJSON('/api/auth/admin/invites'),
|
||||||
|
create: (expiresInHours = 168) => postJSON('/api/auth/admin/invites', { expiresInHours }),
|
||||||
|
delete: (id) => fetchJSON(`/api/auth/admin/invites/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Keys
|
||||||
|
export const apiKeysApi = {
|
||||||
|
list: () => fetchJSON('/api/auth/api-keys'),
|
||||||
|
create: (name) => postJSON('/api/auth/api-keys', { name }),
|
||||||
|
revoke: (id) => fetchJSON(`/api/auth/api-keys/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||||
}
|
}
|
||||||
|
|
||||||
// File to base64 helper
|
// File to base64 helper
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@ import (
|
||||||
"github.com/mudler/LocalAI/core/http/endpoints/localai"
|
"github.com/mudler/LocalAI/core/http/endpoints/localai"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterAgentPoolRoutes(e *echo.Echo, app *application.Application) {
|
func RegisterAgentPoolRoutes(e *echo.Echo, app *application.Application,
|
||||||
|
agentsMw, skillsMw, collectionsMw echo.MiddlewareFunc) {
|
||||||
if !app.ApplicationConfig().AgentPool.Enabled {
|
if !app.ApplicationConfig().AgentPool.Enabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group all agent routes behind a middleware that returns 503 while the
|
// Middleware that returns 503 while the agent pool is still initializing.
|
||||||
// agent pool is still initializing (it starts after the HTTP server).
|
poolReadyMw := func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
g := e.Group("/api/agents", func(next echo.HandlerFunc) echo.HandlerFunc {
|
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
if app.AgentPoolService() == nil {
|
if app.AgentPoolService() == nil {
|
||||||
return c.JSON(http.StatusServiceUnavailable, map[string]string{
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||||
|
|
@ -24,67 +24,71 @@ func RegisterAgentPoolRoutes(e *echo.Echo, app *application.Application) {
|
||||||
}
|
}
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
// Agent Management
|
// Agent management routes — require "agents" feature
|
||||||
g.GET("", localai.ListAgentsEndpoint(app))
|
ag := e.Group("/api/agents", poolReadyMw, agentsMw)
|
||||||
g.POST("", localai.CreateAgentEndpoint(app))
|
ag.GET("", localai.ListAgentsEndpoint(app))
|
||||||
g.GET("/config/metadata", localai.GetAgentConfigMetaEndpoint(app))
|
ag.POST("", localai.CreateAgentEndpoint(app))
|
||||||
g.POST("/import", localai.ImportAgentEndpoint(app))
|
ag.GET("/config/metadata", localai.GetAgentConfigMetaEndpoint(app))
|
||||||
g.GET("/:name", localai.GetAgentEndpoint(app))
|
ag.POST("/import", localai.ImportAgentEndpoint(app))
|
||||||
g.PUT("/:name", localai.UpdateAgentEndpoint(app))
|
ag.GET("/:name", localai.GetAgentEndpoint(app))
|
||||||
g.DELETE("/:name", localai.DeleteAgentEndpoint(app))
|
ag.PUT("/:name", localai.UpdateAgentEndpoint(app))
|
||||||
g.GET("/:name/config", localai.GetAgentConfigEndpoint(app))
|
ag.DELETE("/:name", localai.DeleteAgentEndpoint(app))
|
||||||
g.PUT("/:name/pause", localai.PauseAgentEndpoint(app))
|
ag.GET("/:name/config", localai.GetAgentConfigEndpoint(app))
|
||||||
g.PUT("/:name/resume", localai.ResumeAgentEndpoint(app))
|
ag.PUT("/:name/pause", localai.PauseAgentEndpoint(app))
|
||||||
g.GET("/:name/status", localai.GetAgentStatusEndpoint(app))
|
ag.PUT("/:name/resume", localai.ResumeAgentEndpoint(app))
|
||||||
g.GET("/:name/observables", localai.GetAgentObservablesEndpoint(app))
|
ag.GET("/:name/status", localai.GetAgentStatusEndpoint(app))
|
||||||
g.DELETE("/:name/observables", localai.ClearAgentObservablesEndpoint(app))
|
ag.GET("/:name/observables", localai.GetAgentObservablesEndpoint(app))
|
||||||
g.POST("/:name/chat", localai.ChatWithAgentEndpoint(app))
|
ag.DELETE("/:name/observables", localai.ClearAgentObservablesEndpoint(app))
|
||||||
g.GET("/:name/sse", localai.AgentSSEEndpoint(app))
|
ag.POST("/:name/chat", localai.ChatWithAgentEndpoint(app))
|
||||||
g.GET("/:name/export", localai.ExportAgentEndpoint(app))
|
ag.GET("/:name/sse", localai.AgentSSEEndpoint(app))
|
||||||
g.GET("/:name/files", localai.AgentFileEndpoint(app))
|
ag.GET("/:name/export", localai.ExportAgentEndpoint(app))
|
||||||
|
ag.GET("/:name/files", localai.AgentFileEndpoint(app))
|
||||||
|
|
||||||
// Actions
|
// Actions (part of agents feature)
|
||||||
g.GET("/actions", localai.ListActionsEndpoint(app))
|
ag.GET("/actions", localai.ListActionsEndpoint(app))
|
||||||
g.POST("/actions/:name/definition", localai.GetActionDefinitionEndpoint(app))
|
ag.POST("/actions/:name/definition", localai.GetActionDefinitionEndpoint(app))
|
||||||
g.POST("/actions/:name/run", localai.ExecuteActionEndpoint(app))
|
ag.POST("/actions/:name/run", localai.ExecuteActionEndpoint(app))
|
||||||
|
|
||||||
// Skills
|
// Skills routes — require "skills" feature
|
||||||
g.GET("/skills", localai.ListSkillsEndpoint(app))
|
sg := e.Group("/api/agents/skills", poolReadyMw, skillsMw)
|
||||||
g.GET("/skills/config", localai.GetSkillsConfigEndpoint(app))
|
sg.GET("", localai.ListSkillsEndpoint(app))
|
||||||
g.GET("/skills/search", localai.SearchSkillsEndpoint(app))
|
sg.GET("/config", localai.GetSkillsConfigEndpoint(app))
|
||||||
g.POST("/skills", localai.CreateSkillEndpoint(app))
|
sg.GET("/search", localai.SearchSkillsEndpoint(app))
|
||||||
g.GET("/skills/export/*", localai.ExportSkillEndpoint(app))
|
sg.POST("", localai.CreateSkillEndpoint(app))
|
||||||
g.POST("/skills/import", localai.ImportSkillEndpoint(app))
|
sg.GET("/export/*", localai.ExportSkillEndpoint(app))
|
||||||
g.GET("/skills/:name", localai.GetSkillEndpoint(app))
|
sg.POST("/import", localai.ImportSkillEndpoint(app))
|
||||||
g.PUT("/skills/:name", localai.UpdateSkillEndpoint(app))
|
sg.GET("/:name", localai.GetSkillEndpoint(app))
|
||||||
g.DELETE("/skills/:name", localai.DeleteSkillEndpoint(app))
|
sg.PUT("/:name", localai.UpdateSkillEndpoint(app))
|
||||||
g.GET("/skills/:name/resources", localai.ListSkillResourcesEndpoint(app))
|
sg.DELETE("/:name", localai.DeleteSkillEndpoint(app))
|
||||||
g.GET("/skills/:name/resources/*", localai.GetSkillResourceEndpoint(app))
|
sg.GET("/:name/resources", localai.ListSkillResourcesEndpoint(app))
|
||||||
g.POST("/skills/:name/resources", localai.CreateSkillResourceEndpoint(app))
|
sg.GET("/:name/resources/*", localai.GetSkillResourceEndpoint(app))
|
||||||
g.PUT("/skills/:name/resources/*", localai.UpdateSkillResourceEndpoint(app))
|
sg.POST("/:name/resources", localai.CreateSkillResourceEndpoint(app))
|
||||||
g.DELETE("/skills/:name/resources/*", localai.DeleteSkillResourceEndpoint(app))
|
sg.PUT("/:name/resources/*", localai.UpdateSkillResourceEndpoint(app))
|
||||||
|
sg.DELETE("/:name/resources/*", localai.DeleteSkillResourceEndpoint(app))
|
||||||
|
|
||||||
// Git Repos
|
// Git Repos — guarded by skills feature (at original /api/agents/git-repos path)
|
||||||
g.GET("/git-repos", localai.ListGitReposEndpoint(app))
|
gg := e.Group("/api/agents/git-repos", poolReadyMw, skillsMw)
|
||||||
g.POST("/git-repos", localai.AddGitRepoEndpoint(app))
|
gg.GET("", localai.ListGitReposEndpoint(app))
|
||||||
g.PUT("/git-repos/:id", localai.UpdateGitRepoEndpoint(app))
|
gg.POST("", localai.AddGitRepoEndpoint(app))
|
||||||
g.DELETE("/git-repos/:id", localai.DeleteGitRepoEndpoint(app))
|
gg.PUT("/:id", localai.UpdateGitRepoEndpoint(app))
|
||||||
g.POST("/git-repos/:id/sync", localai.SyncGitRepoEndpoint(app))
|
gg.DELETE("/:id", localai.DeleteGitRepoEndpoint(app))
|
||||||
g.POST("/git-repos/:id/toggle", localai.ToggleGitRepoEndpoint(app))
|
gg.POST("/:id/sync", localai.SyncGitRepoEndpoint(app))
|
||||||
|
gg.POST("/:id/toggle", localai.ToggleGitRepoEndpoint(app))
|
||||||
|
|
||||||
// Collections / Knowledge Base
|
// Collections / Knowledge Base — require "collections" feature
|
||||||
g.GET("/collections", localai.ListCollectionsEndpoint(app))
|
cg := e.Group("/api/agents/collections", poolReadyMw, collectionsMw)
|
||||||
g.POST("/collections", localai.CreateCollectionEndpoint(app))
|
cg.GET("", localai.ListCollectionsEndpoint(app))
|
||||||
g.POST("/collections/:name/upload", localai.UploadToCollectionEndpoint(app))
|
cg.POST("", localai.CreateCollectionEndpoint(app))
|
||||||
g.GET("/collections/:name/entries", localai.ListCollectionEntriesEndpoint(app))
|
cg.POST("/:name/upload", localai.UploadToCollectionEndpoint(app))
|
||||||
g.GET("/collections/:name/entries/*", localai.GetCollectionEntryContentEndpoint(app))
|
cg.GET("/:name/entries", localai.ListCollectionEntriesEndpoint(app))
|
||||||
g.GET("/collections/:name/entries-raw/*", localai.GetCollectionEntryRawFileEndpoint(app))
|
cg.GET("/:name/entries/*", localai.GetCollectionEntryContentEndpoint(app))
|
||||||
g.POST("/collections/:name/search", localai.SearchCollectionEndpoint(app))
|
cg.GET("/:name/entries-raw/*", localai.GetCollectionEntryRawFileEndpoint(app))
|
||||||
g.POST("/collections/:name/reset", localai.ResetCollectionEndpoint(app))
|
cg.POST("/:name/search", localai.SearchCollectionEndpoint(app))
|
||||||
g.DELETE("/collections/:name/entry/delete", localai.DeleteCollectionEntryEndpoint(app))
|
cg.POST("/:name/reset", localai.ResetCollectionEndpoint(app))
|
||||||
g.POST("/collections/:name/sources", localai.AddCollectionSourceEndpoint(app))
|
cg.DELETE("/:name/entry/delete", localai.DeleteCollectionEntryEndpoint(app))
|
||||||
g.DELETE("/collections/:name/sources", localai.RemoveCollectionSourceEndpoint(app))
|
cg.POST("/:name/sources", localai.AddCollectionSourceEndpoint(app))
|
||||||
g.GET("/collections/:name/sources", localai.ListCollectionSourcesEndpoint(app))
|
cg.DELETE("/:name/sources", localai.RemoveCollectionSourceEndpoint(app))
|
||||||
|
cg.GET("/:name/sources", localai.ListCollectionSourcesEndpoint(app))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ func RegisterAnthropicRoutes(app *echo.Echo,
|
||||||
)
|
)
|
||||||
|
|
||||||
messagesMiddleware := []echo.MiddlewareFunc{
|
messagesMiddleware := []echo.MiddlewareFunc{
|
||||||
|
middleware.UsageMiddleware(application.AuthDB()),
|
||||||
middleware.TraceMiddleware(application),
|
middleware.TraceMiddleware(application),
|
||||||
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
|
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
|
||||||
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.AnthropicRequest) }),
|
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.AnthropicRequest) }),
|
||||||
|
|
|
||||||
1077
core/http/routes/auth.go
Normal file
1077
core/http/routes/auth.go
Normal file
File diff suppressed because it is too large
Load diff
808
core/http/routes/auth_test.go
Normal file
808
core/http/routes/auth_test.go
Normal file
|
|
@ -0,0 +1,808 @@
|
||||||
|
//go:build auth
|
||||||
|
|
||||||
|
package routes_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
"github.com/mudler/LocalAI/core/config"
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestAuthApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
|
||||||
|
e := echo.New()
|
||||||
|
|
||||||
|
// Apply auth middleware
|
||||||
|
e.Use(auth.Middleware(db, appConfig))
|
||||||
|
|
||||||
|
// We can't use routes.RegisterAuthRoutes directly since it needs *application.Application.
|
||||||
|
// Instead, we register the routes manually for testing.
|
||||||
|
|
||||||
|
// GET /api/auth/status
|
||||||
|
e.GET("/api/auth/status", func(c echo.Context) error {
|
||||||
|
authEnabled := db != nil
|
||||||
|
providers := []string{}
|
||||||
|
hasUsers := false
|
||||||
|
|
||||||
|
if authEnabled {
|
||||||
|
var count int64
|
||||||
|
db.Model(&auth.User{}).Count(&count)
|
||||||
|
hasUsers = count > 0
|
||||||
|
|
||||||
|
providers = append(providers, auth.ProviderLocal)
|
||||||
|
if appConfig.Auth.GitHubClientID != "" {
|
||||||
|
providers = append(providers, auth.ProviderGitHub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]interface{}{
|
||||||
|
"authEnabled": authEnabled,
|
||||||
|
"providers": providers,
|
||||||
|
"hasUsers": hasUsers,
|
||||||
|
}
|
||||||
|
|
||||||
|
user := auth.GetUser(c)
|
||||||
|
if user != nil {
|
||||||
|
resp["user"] = map[string]interface{}{
|
||||||
|
"id": user.ID,
|
||||||
|
"role": user.Role,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resp["user"] = nil
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, resp)
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/auth/register
|
||||||
|
e.POST("/api/auth/register", func(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
body.Email = strings.TrimSpace(body.Email)
|
||||||
|
body.Name = strings.TrimSpace(body.Name)
|
||||||
|
if body.Email == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "email is required"})
|
||||||
|
}
|
||||||
|
if len(body.Password) < 8 {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "password must be at least 8 characters"})
|
||||||
|
}
|
||||||
|
var existing auth.User
|
||||||
|
if err := db.Where("email = ? AND provider = ?", body.Email, auth.ProviderLocal).First(&existing).Error; err == nil {
|
||||||
|
return c.JSON(http.StatusConflict, map[string]string{"error": "an account with this email already exists"})
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(body.Password)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
||||||
|
}
|
||||||
|
role := auth.AssignRole(db, body.Email, appConfig.Auth.AdminEmail)
|
||||||
|
status := auth.StatusActive
|
||||||
|
if appConfig.Auth.RegistrationMode == "approval" && role != auth.RoleAdmin {
|
||||||
|
status = auth.StatusPending
|
||||||
|
}
|
||||||
|
name := body.Name
|
||||||
|
if name == "" {
|
||||||
|
name = body.Email
|
||||||
|
}
|
||||||
|
user := &auth.User{
|
||||||
|
ID: uuid.New().String(), Email: body.Email, Name: name,
|
||||||
|
Provider: auth.ProviderLocal, Subject: body.Email, PasswordHash: hash,
|
||||||
|
Role: role, Status: status,
|
||||||
|
}
|
||||||
|
if err := db.Create(user).Error; err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create user"})
|
||||||
|
}
|
||||||
|
if status == auth.StatusPending {
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"message": "registration successful, awaiting admin approval", "pending": true})
|
||||||
|
}
|
||||||
|
sessionID, err := auth.CreateSession(db, user.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
|
||||||
|
}
|
||||||
|
auth.SetSessionCookie(c, sessionID)
|
||||||
|
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||||
|
"user": map[string]interface{}{"id": user.ID, "email": user.Email, "name": user.Name, "role": user.Role},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/auth/login - inline test handler
|
||||||
|
e.POST("/api/auth/login", func(c echo.Context) error {
|
||||||
|
var body struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
body.Email = strings.TrimSpace(body.Email)
|
||||||
|
if body.Email == "" || body.Password == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "email and password are required"})
|
||||||
|
}
|
||||||
|
var user auth.User
|
||||||
|
if err := db.Where("email = ? AND provider = ?", body.Email, auth.ProviderLocal).First(&user).Error; err != nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
|
||||||
|
}
|
||||||
|
if !auth.CheckPassword(user.PasswordHash, body.Password) {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
|
||||||
|
}
|
||||||
|
if user.Status == auth.StatusPending {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "account pending admin approval"})
|
||||||
|
}
|
||||||
|
auth.MaybePromote(db, &user, appConfig.Auth.AdminEmail)
|
||||||
|
sessionID, err := auth.CreateSession(db, user.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
|
||||||
|
}
|
||||||
|
auth.SetSessionCookie(c, sessionID)
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"user": map[string]interface{}{"id": user.ID, "email": user.Email, "name": user.Name, "role": user.Role},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/auth/logout
|
||||||
|
e.POST("/api/auth/logout", func(c echo.Context) error {
|
||||||
|
user := auth.GetUser(c)
|
||||||
|
if user == nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||||
|
}
|
||||||
|
if cookie, err := c.Cookie("session"); err == nil && cookie.Value != "" {
|
||||||
|
auth.DeleteSession(db, cookie.Value, "")
|
||||||
|
}
|
||||||
|
auth.ClearSessionCookie(c)
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "logged out"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/auth/me
|
||||||
|
e.GET("/api/auth/me", func(c echo.Context) error {
|
||||||
|
user := auth.GetUser(c)
|
||||||
|
if user == nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"id": user.ID,
|
||||||
|
"email": user.Email,
|
||||||
|
"role": user.Role,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/auth/api-keys
|
||||||
|
e.POST("/api/auth/api-keys", func(c echo.Context) error {
|
||||||
|
user := auth.GetUser(c)
|
||||||
|
if user == nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err != nil || body.Name == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||||||
|
}
|
||||||
|
plaintext, record, err := auth.CreateAPIKey(db, user.ID, body.Name, user.Role, "", nil)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create API key"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||||
|
"key": plaintext,
|
||||||
|
"id": record.ID,
|
||||||
|
"name": record.Name,
|
||||||
|
"keyPrefix": record.KeyPrefix,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET /api/auth/api-keys
|
||||||
|
e.GET("/api/auth/api-keys", func(c echo.Context) error {
|
||||||
|
user := auth.GetUser(c)
|
||||||
|
if user == nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||||
|
}
|
||||||
|
keys, err := auth.ListAPIKeys(db, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list API keys"})
|
||||||
|
}
|
||||||
|
result := make([]map[string]interface{}, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
result = append(result, map[string]interface{}{
|
||||||
|
"id": k.ID,
|
||||||
|
"name": k.Name,
|
||||||
|
"keyPrefix": k.KeyPrefix,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"keys": result})
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/auth/api-keys/:id
|
||||||
|
e.DELETE("/api/auth/api-keys/:id", func(c echo.Context) error {
|
||||||
|
user := auth.GetUser(c)
|
||||||
|
if user == nil {
|
||||||
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||||
|
}
|
||||||
|
keyID := c.Param("id")
|
||||||
|
if err := auth.RevokeAPIKey(db, keyID, user.ID); err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "API key not found"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "API key revoked"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Admin: GET /api/auth/admin/users
|
||||||
|
adminMw := auth.RequireAdmin()
|
||||||
|
e.GET("/api/auth/admin/users", func(c echo.Context) error {
|
||||||
|
var users []auth.User
|
||||||
|
db.Order("created_at ASC").Find(&users)
|
||||||
|
result := make([]map[string]interface{}, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
result = append(result, map[string]interface{}{"id": u.ID, "role": u.Role, "email": u.Email})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"users": result})
|
||||||
|
}, adminMw)
|
||||||
|
|
||||||
|
// Admin: PUT /api/auth/admin/users/:id/role
|
||||||
|
e.PUT("/api/auth/admin/users/:id/role", func(c echo.Context) error {
|
||||||
|
currentUser := auth.GetUser(c)
|
||||||
|
targetID := c.Param("id")
|
||||||
|
if currentUser.ID == targetID {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot change your own role"})
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err != nil || (body.Role != auth.RoleAdmin && body.Role != auth.RoleUser) {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "role must be 'admin' or 'user'"})
|
||||||
|
}
|
||||||
|
result := db.Model(&auth.User{}).Where("id = ?", targetID).Update("role", body.Role)
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "role updated"})
|
||||||
|
}, adminMw)
|
||||||
|
|
||||||
|
// Admin: DELETE /api/auth/admin/users/:id
|
||||||
|
e.DELETE("/api/auth/admin/users/:id", func(c echo.Context) error {
|
||||||
|
currentUser := auth.GetUser(c)
|
||||||
|
targetID := c.Param("id")
|
||||||
|
if currentUser.ID == targetID {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete yourself"})
|
||||||
|
}
|
||||||
|
db.Where("user_id = ?", targetID).Delete(&auth.Session{})
|
||||||
|
db.Where("user_id = ?", targetID).Delete(&auth.UserAPIKey{})
|
||||||
|
result := db.Where("id = ?", targetID).Delete(&auth.User{})
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "user deleted"})
|
||||||
|
}, adminMw)
|
||||||
|
|
||||||
|
// Regular API endpoint for testing
|
||||||
|
e.POST("/v1/chat/completions", func(c echo.Context) error {
|
||||||
|
return c.String(http.StatusOK, "ok")
|
||||||
|
})
|
||||||
|
e.GET("/v1/models", func(c echo.Context) error {
|
||||||
|
return c.String(http.StatusOK, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create test user
|
||||||
|
func createRouteTestUser(db *gorm.DB, email, role string) *auth.User {
|
||||||
|
user := &auth.User{
|
||||||
|
ID: "user-" + email,
|
||||||
|
Email: email,
|
||||||
|
Name: "Test " + role,
|
||||||
|
Provider: auth.ProviderGitHub,
|
||||||
|
Subject: "sub-" + email,
|
||||||
|
Role: role,
|
||||||
|
Status: auth.StatusActive,
|
||||||
|
}
|
||||||
|
Expect(db.Create(user).Error).ToNot(HaveOccurred())
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func doAuthRequest(e *echo.Echo, method, path string, body []byte, opts ...func(*http.Request)) *httptest.ResponseRecorder {
|
||||||
|
var req *http.Request
|
||||||
|
if body != nil {
|
||||||
|
req = httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||||
|
} else {
|
||||||
|
req = httptest.NewRequest(method, path, nil)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(req)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func withSession(sessionID string) func(*http.Request) {
|
||||||
|
return func(req *http.Request) {
|
||||||
|
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withBearer(token string) func(*http.Request) {
|
||||||
|
return func(req *http.Request) {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = Describe("Auth Routes", Label("auth"), func() {
|
||||||
|
var (
|
||||||
|
db *gorm.DB
|
||||||
|
appConfig *config.ApplicationConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
var err error
|
||||||
|
db, err = auth.InitDB(":memory:")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
appConfig = config.NewApplicationConfig()
|
||||||
|
appConfig.Auth.Enabled = true
|
||||||
|
appConfig.Auth.GitHubClientID = "test-client-id"
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("GET /api/auth/status", func() {
|
||||||
|
It("returns authEnabled=true and provider list when auth enabled", func() {
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
Expect(resp["authEnabled"]).To(BeTrue())
|
||||||
|
providers := resp["providers"].([]interface{})
|
||||||
|
Expect(providers).To(ContainElement(auth.ProviderGitHub))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns authEnabled=false when auth disabled", func() {
|
||||||
|
app := newTestAuthApp(nil, config.NewApplicationConfig())
|
||||||
|
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
Expect(resp["authEnabled"]).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns user info when authenticated", func() {
|
||||||
|
user := createRouteTestUser(db, "status@test.com", auth.RoleAdmin)
|
||||||
|
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doAuthRequest(app, "GET", "/api/auth/status", nil, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
Expect(resp["user"]).ToNot(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns user=null when not authenticated", func() {
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
Expect(resp["user"]).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns hasUsers=false on fresh DB", func() {
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
Expect(resp["hasUsers"]).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("POST /api/auth/logout", func() {
|
||||||
|
It("deletes session and clears cookie", func() {
|
||||||
|
user := createRouteTestUser(db, "logout@test.com", auth.RoleUser)
|
||||||
|
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/logout", nil, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
// Session should be deleted
|
||||||
|
validatedUser, _ := auth.ValidateSession(db, sessionID, "")
|
||||||
|
Expect(validatedUser).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 401 when not authenticated", func() {
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/logout", nil)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("GET /api/auth/me", func() {
|
||||||
|
It("returns current user profile", func() {
|
||||||
|
user := createRouteTestUser(db, "me@test.com", auth.RoleAdmin)
|
||||||
|
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doAuthRequest(app, "GET", "/api/auth/me", nil, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
Expect(resp["email"]).To(Equal("me@test.com"))
|
||||||
|
Expect(resp["role"]).To(Equal("admin"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 401 when not authenticated", func() {
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
rec := doAuthRequest(app, "GET", "/api/auth/me", nil)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("POST /api/auth/api-keys", func() {
|
||||||
|
It("creates API key and returns plaintext once", func() {
|
||||||
|
user := createRouteTestUser(db, "apikey@test.com", auth.RoleUser)
|
||||||
|
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"name": "my key"})
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/api-keys", body, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusCreated))
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
Expect(resp["key"]).To(HavePrefix("lai-"))
|
||||||
|
Expect(resp["name"]).To(Equal("my key"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("key is usable for authentication", func() {
|
||||||
|
user := createRouteTestUser(db, "apikey2@test.com", auth.RoleUser)
|
||||||
|
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"name": "usable key"})
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/api-keys", body, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusCreated))
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
apiKey := resp["key"].(string)
|
||||||
|
|
||||||
|
// Use the key for API access
|
||||||
|
rec = doAuthRequest(app, "GET", "/v1/models", nil, withBearer(apiKey))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 401 when not authenticated", func() {
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
body, _ := json.Marshal(map[string]string{"name": "test"})
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/api-keys", body)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("GET /api/auth/api-keys", func() {
|
||||||
|
It("lists user's API keys without plaintext", func() {
|
||||||
|
user := createRouteTestUser(db, "list@test.com", auth.RoleUser)
|
||||||
|
auth.CreateAPIKey(db, user.ID, "key1", auth.RoleUser, "", nil)
|
||||||
|
auth.CreateAPIKey(db, user.ID, "key2", auth.RoleUser, "", nil)
|
||||||
|
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doAuthRequest(app, "GET", "/api/auth/api-keys", nil, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
keys := resp["keys"].([]interface{})
|
||||||
|
Expect(keys).To(HaveLen(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not show other users' keys", func() {
|
||||||
|
user1 := createRouteTestUser(db, "user1@test.com", auth.RoleUser)
|
||||||
|
user2 := createRouteTestUser(db, "user2@test.com", auth.RoleUser)
|
||||||
|
auth.CreateAPIKey(db, user1.ID, "user1-key", auth.RoleUser, "", nil)
|
||||||
|
auth.CreateAPIKey(db, user2.ID, "user2-key", auth.RoleUser, "", nil)
|
||||||
|
sessionID, _ := auth.CreateSession(db, user1.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doAuthRequest(app, "GET", "/api/auth/api-keys", nil, withSession(sessionID))
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
keys := resp["keys"].([]interface{})
|
||||||
|
Expect(keys).To(HaveLen(1))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("DELETE /api/auth/api-keys/:id", func() {
|
||||||
|
It("revokes user's own key", func() {
|
||||||
|
user := createRouteTestUser(db, "revoke@test.com", auth.RoleUser)
|
||||||
|
plaintext, record, err := auth.CreateAPIKey(db, user.ID, "to-revoke", auth.RoleUser, "", nil)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doAuthRequest(app, "DELETE", "/api/auth/api-keys/"+record.ID, nil, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
// Key should no longer work
|
||||||
|
rec = doAuthRequest(app, "GET", "/v1/models", nil, withBearer(plaintext))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 404 for another user's key", func() {
|
||||||
|
user1 := createRouteTestUser(db, "owner@test.com", auth.RoleUser)
|
||||||
|
user2 := createRouteTestUser(db, "attacker@test.com", auth.RoleUser)
|
||||||
|
_, record, _ := auth.CreateAPIKey(db, user1.ID, "secret-key", auth.RoleUser, "", nil)
|
||||||
|
sessionID, _ := auth.CreateSession(db, user2.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doAuthRequest(app, "DELETE", "/api/auth/api-keys/"+record.ID, nil, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusNotFound))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("Admin: GET /api/auth/admin/users", func() {
|
||||||
|
It("returns all users for admin", func() {
|
||||||
|
admin := createRouteTestUser(db, "admin@test.com", auth.RoleAdmin)
|
||||||
|
createRouteTestUser(db, "user@test.com", auth.RoleUser)
|
||||||
|
sessionID, _ := auth.CreateSession(db, admin.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doAuthRequest(app, "GET", "/api/auth/admin/users", nil, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
users := resp["users"].([]interface{})
|
||||||
|
Expect(users).To(HaveLen(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 403 for non-admin user", func() {
|
||||||
|
user := createRouteTestUser(db, "nonadmin@test.com", auth.RoleUser)
|
||||||
|
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doAuthRequest(app, "GET", "/api/auth/admin/users", nil, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("Admin: PUT /api/auth/admin/users/:id/role", func() {
|
||||||
|
It("changes user role", func() {
|
||||||
|
admin := createRouteTestUser(db, "admin2@test.com", auth.RoleAdmin)
|
||||||
|
user := createRouteTestUser(db, "promote@test.com", auth.RoleUser)
|
||||||
|
sessionID, _ := auth.CreateSession(db, admin.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"role": "admin"})
|
||||||
|
rec := doAuthRequest(app, "PUT", "/api/auth/admin/users/"+user.ID+"/role", body, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
// Verify in DB
|
||||||
|
var updated auth.User
|
||||||
|
db.First(&updated, "id = ?", user.ID)
|
||||||
|
Expect(updated.Role).To(Equal(auth.RoleAdmin))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("prevents self-demotion", func() {
|
||||||
|
admin := createRouteTestUser(db, "self-demote@test.com", auth.RoleAdmin)
|
||||||
|
sessionID, _ := auth.CreateSession(db, admin.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"role": "user"})
|
||||||
|
rec := doAuthRequest(app, "PUT", "/api/auth/admin/users/"+admin.ID+"/role", body, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 403 for non-admin", func() {
|
||||||
|
user := createRouteTestUser(db, "sneaky@test.com", auth.RoleUser)
|
||||||
|
other := createRouteTestUser(db, "victim@test.com", auth.RoleUser)
|
||||||
|
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"role": "admin"})
|
||||||
|
rec := doAuthRequest(app, "PUT", "/api/auth/admin/users/"+other.ID+"/role", body, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("Admin: DELETE /api/auth/admin/users/:id", func() {
|
||||||
|
It("deletes user and cascades to sessions + API keys", func() {
|
||||||
|
admin := createRouteTestUser(db, "admin3@test.com", auth.RoleAdmin)
|
||||||
|
target := createRouteTestUser(db, "delete-me@test.com", auth.RoleUser)
|
||||||
|
auth.CreateSession(db, target.ID, "")
|
||||||
|
auth.CreateAPIKey(db, target.ID, "target-key", auth.RoleUser, "", nil)
|
||||||
|
sessionID, _ := auth.CreateSession(db, admin.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
// User should be gone
|
||||||
|
var count int64
|
||||||
|
db.Model(&auth.User{}).Where("id = ?", target.ID).Count(&count)
|
||||||
|
Expect(count).To(Equal(int64(0)))
|
||||||
|
|
||||||
|
// Sessions and keys should be gone
|
||||||
|
db.Model(&auth.Session{}).Where("user_id = ?", target.ID).Count(&count)
|
||||||
|
Expect(count).To(Equal(int64(0)))
|
||||||
|
db.Model(&auth.UserAPIKey{}).Where("user_id = ?", target.ID).Count(&count)
|
||||||
|
Expect(count).To(Equal(int64(0)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("prevents self-deletion", func() {
|
||||||
|
admin := createRouteTestUser(db, "admin4@test.com", auth.RoleAdmin)
|
||||||
|
sessionID, _ := auth.CreateSession(db, admin.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+admin.ID, nil, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 403 for non-admin", func() {
|
||||||
|
user := createRouteTestUser(db, "sneak@test.com", auth.RoleUser)
|
||||||
|
target := createRouteTestUser(db, "target2@test.com", auth.RoleUser)
|
||||||
|
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
|
||||||
|
rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID))
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("POST /api/auth/register", func() {
|
||||||
|
It("registers first user as admin", func() {
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": "first@test.com", "password": "password123", "name": "First User"})
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusCreated))
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
user := resp["user"].(map[string]interface{})
|
||||||
|
Expect(user["role"]).To(Equal("admin"))
|
||||||
|
Expect(user["email"]).To(Equal("first@test.com"))
|
||||||
|
|
||||||
|
// Session cookie should be set
|
||||||
|
cookies := rec.Result().Cookies()
|
||||||
|
found := false
|
||||||
|
for _, c := range cookies {
|
||||||
|
if c.Name == "session" && c.Value != "" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expect(found).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("registers second user as regular user", func() {
|
||||||
|
createRouteTestUser(db, "existing@test.com", auth.RoleAdmin)
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": "second@test.com", "password": "password123"})
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusCreated))
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
user := resp["user"].(map[string]interface{})
|
||||||
|
Expect(user["role"]).To(Equal("user"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects duplicate email", func() {
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": "dup@test.com", "password": "password123"})
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusCreated))
|
||||||
|
|
||||||
|
rec = doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusConflict))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects short password", func() {
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": "short@test.com", "password": "1234567"})
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects empty email", func() {
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": "", "password": "password123"})
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns pending when registration mode is approval", func() {
|
||||||
|
createRouteTestUser(db, "admin-existing@test.com", auth.RoleAdmin)
|
||||||
|
appConfig.Auth.RegistrationMode = "approval"
|
||||||
|
defer func() { appConfig.Auth.RegistrationMode = "" }()
|
||||||
|
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": "pending@test.com", "password": "password123"})
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
Expect(resp["pending"]).To(BeTrue())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("POST /api/auth/login", func() {
|
||||||
|
It("logs in with correct credentials", func() {
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
// Register first
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": "login@test.com", "password": "password123"})
|
||||||
|
doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||||
|
|
||||||
|
// Login
|
||||||
|
body, _ = json.Marshal(map[string]string{"email": "login@test.com", "password": "password123"})
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/login", body)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
user := resp["user"].(map[string]interface{})
|
||||||
|
Expect(user["email"]).To(Equal("login@test.com"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects wrong password", func() {
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": "wrong@test.com", "password": "password123"})
|
||||||
|
doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||||
|
|
||||||
|
body, _ = json.Marshal(map[string]string{"email": "wrong@test.com", "password": "wrongpassword"})
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/login", body)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects non-existent user", func() {
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": "nobody@test.com", "password": "password123"})
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/login", body)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects pending user", func() {
|
||||||
|
createRouteTestUser(db, "admin-for-pending@test.com", auth.RoleAdmin)
|
||||||
|
appConfig.Auth.RegistrationMode = "approval"
|
||||||
|
defer func() { appConfig.Auth.RegistrationMode = "" }()
|
||||||
|
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": "pending-login@test.com", "password": "password123"})
|
||||||
|
doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||||
|
|
||||||
|
appConfig.Auth.RegistrationMode = ""
|
||||||
|
body, _ = json.Marshal(map[string]string{"email": "pending-login@test.com", "password": "password123"})
|
||||||
|
rec := doAuthRequest(app, "POST", "/api/auth/login", body)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("GET /api/auth/status providers", func() {
|
||||||
|
It("includes local provider when auth is enabled", func() {
|
||||||
|
app := newTestAuthApp(db, appConfig)
|
||||||
|
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
|
||||||
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
providers := resp["providers"].([]interface{})
|
||||||
|
Expect(providers).To(ContainElement(auth.ProviderLocal))
|
||||||
|
Expect(providers).To(ContainElement(auth.ProviderGitHub))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -22,7 +22,10 @@ func RegisterLocalAIRoutes(router *echo.Echo,
|
||||||
galleryService *services.GalleryService,
|
galleryService *services.GalleryService,
|
||||||
opcache *services.OpCache,
|
opcache *services.OpCache,
|
||||||
evaluator *templates.Evaluator,
|
evaluator *templates.Evaluator,
|
||||||
app *application.Application) {
|
app *application.Application,
|
||||||
|
adminMiddleware echo.MiddlewareFunc,
|
||||||
|
mcpJobsMw echo.MiddlewareFunc,
|
||||||
|
mcpMw echo.MiddlewareFunc) {
|
||||||
|
|
||||||
router.GET("/swagger/*", echoswagger.WrapHandler) // default
|
router.GET("/swagger/*", echoswagger.WrapHandler) // default
|
||||||
|
|
||||||
|
|
@ -36,40 +39,40 @@ func RegisterLocalAIRoutes(router *echo.Echo,
|
||||||
"Version": internal.PrintableVersion(),
|
"Version": internal.PrintableVersion(),
|
||||||
"DisableRuntimeSettings": appConfig.DisableRuntimeSettings,
|
"DisableRuntimeSettings": appConfig.DisableRuntimeSettings,
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
// Edit model page
|
// Edit model page
|
||||||
router.GET("/models/edit/:name", localai.GetEditModelPage(cl, appConfig))
|
router.GET("/models/edit/:name", localai.GetEditModelPage(cl, appConfig), adminMiddleware)
|
||||||
modelGalleryEndpointService := localai.CreateModelGalleryEndpointService(appConfig.Galleries, appConfig.BackendGalleries, appConfig.SystemState, galleryService, cl)
|
modelGalleryEndpointService := localai.CreateModelGalleryEndpointService(appConfig.Galleries, appConfig.BackendGalleries, appConfig.SystemState, galleryService, cl)
|
||||||
router.POST("/models/apply", modelGalleryEndpointService.ApplyModelGalleryEndpoint())
|
router.POST("/models/apply", modelGalleryEndpointService.ApplyModelGalleryEndpoint(), adminMiddleware)
|
||||||
router.POST("/models/delete/:name", modelGalleryEndpointService.DeleteModelGalleryEndpoint())
|
router.POST("/models/delete/:name", modelGalleryEndpointService.DeleteModelGalleryEndpoint(), adminMiddleware)
|
||||||
|
|
||||||
router.GET("/models/available", modelGalleryEndpointService.ListModelFromGalleryEndpoint(appConfig.SystemState))
|
router.GET("/models/available", modelGalleryEndpointService.ListModelFromGalleryEndpoint(appConfig.SystemState), adminMiddleware)
|
||||||
router.GET("/models/galleries", modelGalleryEndpointService.ListModelGalleriesEndpoint())
|
router.GET("/models/galleries", modelGalleryEndpointService.ListModelGalleriesEndpoint(), adminMiddleware)
|
||||||
router.GET("/models/jobs/:uuid", modelGalleryEndpointService.GetOpStatusEndpoint())
|
router.GET("/models/jobs/:uuid", modelGalleryEndpointService.GetOpStatusEndpoint(), adminMiddleware)
|
||||||
router.GET("/models/jobs", modelGalleryEndpointService.GetAllStatusEndpoint())
|
router.GET("/models/jobs", modelGalleryEndpointService.GetAllStatusEndpoint(), adminMiddleware)
|
||||||
|
|
||||||
backendGalleryEndpointService := localai.CreateBackendEndpointService(
|
backendGalleryEndpointService := localai.CreateBackendEndpointService(
|
||||||
appConfig.BackendGalleries,
|
appConfig.BackendGalleries,
|
||||||
appConfig.SystemState,
|
appConfig.SystemState,
|
||||||
galleryService)
|
galleryService)
|
||||||
router.POST("/backends/apply", backendGalleryEndpointService.ApplyBackendEndpoint())
|
router.POST("/backends/apply", backendGalleryEndpointService.ApplyBackendEndpoint(), adminMiddleware)
|
||||||
router.POST("/backends/delete/:name", backendGalleryEndpointService.DeleteBackendEndpoint())
|
router.POST("/backends/delete/:name", backendGalleryEndpointService.DeleteBackendEndpoint(), adminMiddleware)
|
||||||
router.GET("/backends", backendGalleryEndpointService.ListBackendsEndpoint(appConfig.SystemState))
|
router.GET("/backends", backendGalleryEndpointService.ListBackendsEndpoint(appConfig.SystemState), adminMiddleware)
|
||||||
router.GET("/backends/available", backendGalleryEndpointService.ListAvailableBackendsEndpoint(appConfig.SystemState))
|
router.GET("/backends/available", backendGalleryEndpointService.ListAvailableBackendsEndpoint(appConfig.SystemState), adminMiddleware)
|
||||||
router.GET("/backends/galleries", backendGalleryEndpointService.ListBackendGalleriesEndpoint())
|
router.GET("/backends/galleries", backendGalleryEndpointService.ListBackendGalleriesEndpoint(), adminMiddleware)
|
||||||
router.GET("/backends/jobs/:uuid", backendGalleryEndpointService.GetOpStatusEndpoint())
|
router.GET("/backends/jobs/:uuid", backendGalleryEndpointService.GetOpStatusEndpoint(), adminMiddleware)
|
||||||
// Custom model import endpoint
|
// Custom model import endpoint
|
||||||
router.POST("/models/import", localai.ImportModelEndpoint(cl, appConfig))
|
router.POST("/models/import", localai.ImportModelEndpoint(cl, appConfig), adminMiddleware)
|
||||||
|
|
||||||
// URI model import endpoint
|
// URI model import endpoint
|
||||||
router.POST("/models/import-uri", localai.ImportModelURIEndpoint(cl, appConfig, galleryService, opcache))
|
router.POST("/models/import-uri", localai.ImportModelURIEndpoint(cl, appConfig, galleryService, opcache), adminMiddleware)
|
||||||
|
|
||||||
// Custom model edit endpoint
|
// Custom model edit endpoint
|
||||||
router.POST("/models/edit/:name", localai.EditModelEndpoint(cl, ml, appConfig))
|
router.POST("/models/edit/:name", localai.EditModelEndpoint(cl, ml, appConfig), adminMiddleware)
|
||||||
|
|
||||||
// Reload models endpoint
|
// Reload models endpoint
|
||||||
router.POST("/models/reload", localai.ReloadModelsEndpoint(cl, appConfig))
|
router.POST("/models/reload", localai.ReloadModelsEndpoint(cl, appConfig), adminMiddleware)
|
||||||
}
|
}
|
||||||
|
|
||||||
detectionHandler := localai.DetectionEndpoint(cl, ml, appConfig)
|
detectionHandler := localai.DetectionEndpoint(cl, ml, appConfig)
|
||||||
|
|
@ -101,7 +104,7 @@ func RegisterLocalAIRoutes(router *echo.Echo,
|
||||||
router.POST("/stores/find", localai.StoresFindEndpoint(ml, appConfig))
|
router.POST("/stores/find", localai.StoresFindEndpoint(ml, appConfig))
|
||||||
|
|
||||||
if !appConfig.DisableMetrics {
|
if !appConfig.DisableMetrics {
|
||||||
router.GET("/metrics", localai.LocalAIMetricsEndpoint())
|
router.GET("/metrics", localai.LocalAIMetricsEndpoint(), adminMiddleware)
|
||||||
}
|
}
|
||||||
|
|
||||||
videoHandler := localai.VideoEndpoint(cl, ml, appConfig)
|
videoHandler := localai.VideoEndpoint(cl, ml, appConfig)
|
||||||
|
|
@ -113,15 +116,15 @@ func RegisterLocalAIRoutes(router *echo.Echo,
|
||||||
// Backend Statistics Module
|
// Backend Statistics Module
|
||||||
// TODO: Should these use standard middlewares? Refactor later, they are extremely simple.
|
// TODO: Should these use standard middlewares? Refactor later, they are extremely simple.
|
||||||
backendMonitorService := services.NewBackendMonitorService(ml, cl, appConfig) // Split out for now
|
backendMonitorService := services.NewBackendMonitorService(ml, cl, appConfig) // Split out for now
|
||||||
router.GET("/backend/monitor", localai.BackendMonitorEndpoint(backendMonitorService))
|
router.GET("/backend/monitor", localai.BackendMonitorEndpoint(backendMonitorService), adminMiddleware)
|
||||||
router.POST("/backend/shutdown", localai.BackendShutdownEndpoint(backendMonitorService))
|
router.POST("/backend/shutdown", localai.BackendShutdownEndpoint(backendMonitorService), adminMiddleware)
|
||||||
// The v1/* urls are exactly the same as above - makes local e2e testing easier if they are registered.
|
// The v1/* urls are exactly the same as above - makes local e2e testing easier if they are registered.
|
||||||
router.GET("/v1/backend/monitor", localai.BackendMonitorEndpoint(backendMonitorService))
|
router.GET("/v1/backend/monitor", localai.BackendMonitorEndpoint(backendMonitorService), adminMiddleware)
|
||||||
router.POST("/v1/backend/shutdown", localai.BackendShutdownEndpoint(backendMonitorService))
|
router.POST("/v1/backend/shutdown", localai.BackendShutdownEndpoint(backendMonitorService), adminMiddleware)
|
||||||
|
|
||||||
// p2p
|
// p2p
|
||||||
router.GET("/api/p2p", localai.ShowP2PNodes(appConfig))
|
router.GET("/api/p2p", localai.ShowP2PNodes(appConfig), adminMiddleware)
|
||||||
router.GET("/api/p2p/token", localai.ShowP2PToken(appConfig))
|
router.GET("/api/p2p/token", localai.ShowP2PToken(appConfig), adminMiddleware)
|
||||||
|
|
||||||
router.GET("/version", func(c echo.Context) error {
|
router.GET("/version", func(c echo.Context) error {
|
||||||
return c.JSON(200, struct {
|
return c.JSON(200, struct {
|
||||||
|
|
@ -136,7 +139,7 @@ func RegisterLocalAIRoutes(router *echo.Echo,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
router.GET("/system", localai.SystemInformations(ml, appConfig))
|
router.GET("/system", localai.SystemInformations(ml, appConfig), adminMiddleware)
|
||||||
|
|
||||||
// misc
|
// misc
|
||||||
tokenizeHandler := localai.TokenizeEndpoint(cl, ml, appConfig)
|
tokenizeHandler := localai.TokenizeEndpoint(cl, ml, appConfig)
|
||||||
|
|
@ -166,37 +169,37 @@ func RegisterLocalAIRoutes(router *echo.Echo,
|
||||||
router.POST("/mcp/chat/completions", mcpStreamHandler, mcpStreamMiddleware...)
|
router.POST("/mcp/chat/completions", mcpStreamHandler, mcpStreamMiddleware...)
|
||||||
|
|
||||||
// MCP server listing endpoint
|
// MCP server listing endpoint
|
||||||
router.GET("/v1/mcp/servers/:model", localai.MCPServersEndpoint(cl, appConfig))
|
router.GET("/v1/mcp/servers/:model", localai.MCPServersEndpoint(cl, appConfig), mcpMw)
|
||||||
|
|
||||||
// MCP prompts endpoints
|
// MCP prompts endpoints
|
||||||
router.GET("/v1/mcp/prompts/:model", localai.MCPPromptsEndpoint(cl, appConfig))
|
router.GET("/v1/mcp/prompts/:model", localai.MCPPromptsEndpoint(cl, appConfig), mcpMw)
|
||||||
router.POST("/v1/mcp/prompts/:model/:prompt", localai.MCPGetPromptEndpoint(cl, appConfig))
|
router.POST("/v1/mcp/prompts/:model/:prompt", localai.MCPGetPromptEndpoint(cl, appConfig), mcpMw)
|
||||||
|
|
||||||
// MCP resources endpoints
|
// MCP resources endpoints
|
||||||
router.GET("/v1/mcp/resources/:model", localai.MCPResourcesEndpoint(cl, appConfig))
|
router.GET("/v1/mcp/resources/:model", localai.MCPResourcesEndpoint(cl, appConfig), mcpMw)
|
||||||
router.POST("/v1/mcp/resources/:model/read", localai.MCPReadResourceEndpoint(cl, appConfig))
|
router.POST("/v1/mcp/resources/:model/read", localai.MCPReadResourceEndpoint(cl, appConfig), mcpMw)
|
||||||
|
|
||||||
// CORS proxy for client-side MCP connections
|
// CORS proxy for client-side MCP connections
|
||||||
router.GET("/api/cors-proxy", localai.CORSProxyEndpoint(appConfig))
|
router.GET("/api/cors-proxy", localai.CORSProxyEndpoint(appConfig), mcpMw)
|
||||||
router.POST("/api/cors-proxy", localai.CORSProxyEndpoint(appConfig))
|
router.POST("/api/cors-proxy", localai.CORSProxyEndpoint(appConfig), mcpMw)
|
||||||
router.OPTIONS("/api/cors-proxy", localai.CORSProxyOptionsEndpoint())
|
router.OPTIONS("/api/cors-proxy", localai.CORSProxyOptionsEndpoint())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent job routes (MCP CI Jobs — requires MCP to be enabled)
|
// Agent job routes (MCP CI Jobs — requires MCP to be enabled)
|
||||||
if app != nil && app.AgentJobService() != nil && !appConfig.DisableMCP {
|
if app != nil && app.AgentJobService() != nil && !appConfig.DisableMCP {
|
||||||
router.POST("/api/agent/tasks", localai.CreateTaskEndpoint(app))
|
router.POST("/api/agent/tasks", localai.CreateTaskEndpoint(app), mcpJobsMw)
|
||||||
router.PUT("/api/agent/tasks/:id", localai.UpdateTaskEndpoint(app))
|
router.PUT("/api/agent/tasks/:id", localai.UpdateTaskEndpoint(app), mcpJobsMw)
|
||||||
router.DELETE("/api/agent/tasks/:id", localai.DeleteTaskEndpoint(app))
|
router.DELETE("/api/agent/tasks/:id", localai.DeleteTaskEndpoint(app), mcpJobsMw)
|
||||||
router.GET("/api/agent/tasks", localai.ListTasksEndpoint(app))
|
router.GET("/api/agent/tasks", localai.ListTasksEndpoint(app), mcpJobsMw)
|
||||||
router.GET("/api/agent/tasks/:id", localai.GetTaskEndpoint(app))
|
router.GET("/api/agent/tasks/:id", localai.GetTaskEndpoint(app), mcpJobsMw)
|
||||||
|
|
||||||
router.POST("/api/agent/jobs/execute", localai.ExecuteJobEndpoint(app))
|
router.POST("/api/agent/jobs/execute", localai.ExecuteJobEndpoint(app), mcpJobsMw)
|
||||||
router.GET("/api/agent/jobs/:id", localai.GetJobEndpoint(app))
|
router.GET("/api/agent/jobs/:id", localai.GetJobEndpoint(app), mcpJobsMw)
|
||||||
router.GET("/api/agent/jobs", localai.ListJobsEndpoint(app))
|
router.GET("/api/agent/jobs", localai.ListJobsEndpoint(app), mcpJobsMw)
|
||||||
router.POST("/api/agent/jobs/:id/cancel", localai.CancelJobEndpoint(app))
|
router.POST("/api/agent/jobs/:id/cancel", localai.CancelJobEndpoint(app), mcpJobsMw)
|
||||||
router.DELETE("/api/agent/jobs/:id", localai.DeleteJobEndpoint(app))
|
router.DELETE("/api/agent/jobs/:id", localai.DeleteJobEndpoint(app), mcpJobsMw)
|
||||||
|
|
||||||
router.POST("/api/agent/tasks/:name/execute", localai.ExecuteTaskByNameEndpoint(app))
|
router.POST("/api/agent/tasks/:name/execute", localai.ExecuteTaskByNameEndpoint(app), mcpJobsMw)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
|
||||||
application *application.Application) {
|
application *application.Application) {
|
||||||
// openAI compatible API endpoint
|
// openAI compatible API endpoint
|
||||||
traceMiddleware := middleware.TraceMiddleware(application)
|
traceMiddleware := middleware.TraceMiddleware(application)
|
||||||
|
usageMiddleware := middleware.UsageMiddleware(application.AuthDB())
|
||||||
|
|
||||||
// realtime
|
// realtime
|
||||||
// TODO: Modify/disable the API key middleware for this endpoint to allow ephemeral keys created by sessions
|
// TODO: Modify/disable the API key middleware for this endpoint to allow ephemeral keys created by sessions
|
||||||
|
|
@ -26,6 +27,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
|
||||||
// chat
|
// chat
|
||||||
chatHandler := openai.ChatEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig())
|
chatHandler := openai.ChatEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig())
|
||||||
chatMiddleware := []echo.MiddlewareFunc{
|
chatMiddleware := []echo.MiddlewareFunc{
|
||||||
|
usageMiddleware,
|
||||||
traceMiddleware,
|
traceMiddleware,
|
||||||
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
|
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
|
||||||
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }),
|
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }),
|
||||||
|
|
@ -44,6 +46,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
|
||||||
// edit
|
// edit
|
||||||
editHandler := openai.EditEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig())
|
editHandler := openai.EditEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig())
|
||||||
editMiddleware := []echo.MiddlewareFunc{
|
editMiddleware := []echo.MiddlewareFunc{
|
||||||
|
usageMiddleware,
|
||||||
traceMiddleware,
|
traceMiddleware,
|
||||||
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_EDIT)),
|
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_EDIT)),
|
||||||
re.BuildConstantDefaultModelNameMiddleware("gpt-4o"),
|
re.BuildConstantDefaultModelNameMiddleware("gpt-4o"),
|
||||||
|
|
@ -63,6 +66,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
|
||||||
// completion
|
// completion
|
||||||
completionHandler := openai.CompletionEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig())
|
completionHandler := openai.CompletionEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig())
|
||||||
completionMiddleware := []echo.MiddlewareFunc{
|
completionMiddleware := []echo.MiddlewareFunc{
|
||||||
|
usageMiddleware,
|
||||||
traceMiddleware,
|
traceMiddleware,
|
||||||
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_COMPLETION)),
|
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_COMPLETION)),
|
||||||
re.BuildConstantDefaultModelNameMiddleware("gpt-4o"),
|
re.BuildConstantDefaultModelNameMiddleware("gpt-4o"),
|
||||||
|
|
@ -83,6 +87,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
|
||||||
// embeddings
|
// embeddings
|
||||||
embeddingHandler := openai.EmbeddingsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
|
embeddingHandler := openai.EmbeddingsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
|
||||||
embeddingMiddleware := []echo.MiddlewareFunc{
|
embeddingMiddleware := []echo.MiddlewareFunc{
|
||||||
|
usageMiddleware,
|
||||||
traceMiddleware,
|
traceMiddleware,
|
||||||
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_EMBEDDINGS)),
|
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_EMBEDDINGS)),
|
||||||
re.BuildConstantDefaultModelNameMiddleware("gpt-4o"),
|
re.BuildConstantDefaultModelNameMiddleware("gpt-4o"),
|
||||||
|
|
@ -154,6 +159,6 @@ func RegisterOpenAIRoutes(app *echo.Echo,
|
||||||
app.POST("/images/inpainting", inpaintingHandler, imageMiddleware...)
|
app.POST("/images/inpainting", inpaintingHandler, imageMiddleware...)
|
||||||
|
|
||||||
// List models
|
// List models
|
||||||
app.GET("/v1/models", openai.ListModelsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig()))
|
app.GET("/v1/models", openai.ListModelsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.AuthDB()))
|
||||||
app.GET("/models", openai.ListModelsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig()))
|
app.GET("/models", openai.ListModelsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.AuthDB()))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ func RegisterOpenResponsesRoutes(app *echo.Echo,
|
||||||
// Intercept requests where the model name matches an agent — route directly
|
// Intercept requests where the model name matches an agent — route directly
|
||||||
// to the agent pool without going through the model config resolution pipeline.
|
// to the agent pool without going through the model config resolution pipeline.
|
||||||
localai.AgentResponsesInterceptor(application),
|
localai.AgentResponsesInterceptor(application),
|
||||||
|
middleware.UsageMiddleware(application.AuthDB()),
|
||||||
middleware.TraceMiddleware(application),
|
middleware.TraceMiddleware(application),
|
||||||
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
|
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
|
||||||
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenResponsesRequest) }),
|
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenResponsesRequest) }),
|
||||||
|
|
@ -40,8 +41,8 @@ func RegisterOpenResponsesRoutes(app *echo.Echo,
|
||||||
|
|
||||||
// WebSocket mode for Responses API
|
// WebSocket mode for Responses API
|
||||||
wsHandler := openresponses.WebSocketEndpoint(application)
|
wsHandler := openresponses.WebSocketEndpoint(application)
|
||||||
app.GET("/v1/responses", wsHandler)
|
app.GET("/v1/responses", wsHandler, middleware.UsageMiddleware(application.AuthDB()), middleware.TraceMiddleware(application))
|
||||||
app.GET("/responses", wsHandler)
|
app.GET("/responses", wsHandler, middleware.UsageMiddleware(application.AuthDB()), middleware.TraceMiddleware(application))
|
||||||
|
|
||||||
// GET /responses/:id - Retrieve a response (for polling background requests)
|
// GET /responses/:id - Retrieve a response (for polling background requests)
|
||||||
getResponseHandler := openresponses.GetResponseEndpoint()
|
getResponseHandler := openresponses.GetResponseEndpoint()
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ func RegisterUIRoutes(app *echo.Echo,
|
||||||
cl *config.ModelConfigLoader,
|
cl *config.ModelConfigLoader,
|
||||||
ml *model.ModelLoader,
|
ml *model.ModelLoader,
|
||||||
appConfig *config.ApplicationConfig,
|
appConfig *config.ApplicationConfig,
|
||||||
galleryService *services.GalleryService) {
|
galleryService *services.GalleryService,
|
||||||
|
adminMiddleware echo.MiddlewareFunc) {
|
||||||
|
|
||||||
// SPA routes are handled by the 404 fallback in app.go which serves
|
// SPA routes are handled by the 404 fallback in app.go which serves
|
||||||
// index.html for any unmatched HTML request, enabling client-side routing.
|
// index.html for any unmatched HTML request, enabling client-side routing.
|
||||||
|
|
@ -71,36 +72,36 @@ func RegisterUIRoutes(app *echo.Echo,
|
||||||
|
|
||||||
app.GET("/api/traces", func(c echo.Context) error {
|
app.GET("/api/traces", func(c echo.Context) error {
|
||||||
return c.JSON(200, middleware.GetTraces())
|
return c.JSON(200, middleware.GetTraces())
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
app.POST("/api/traces/clear", func(c echo.Context) error {
|
app.POST("/api/traces/clear", func(c echo.Context) error {
|
||||||
middleware.ClearTraces()
|
middleware.ClearTraces()
|
||||||
return c.NoContent(204)
|
return c.NoContent(204)
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
app.GET("/api/backend-traces", func(c echo.Context) error {
|
app.GET("/api/backend-traces", func(c echo.Context) error {
|
||||||
return c.JSON(200, trace.GetBackendTraces())
|
return c.JSON(200, trace.GetBackendTraces())
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
app.POST("/api/backend-traces/clear", func(c echo.Context) error {
|
app.POST("/api/backend-traces/clear", func(c echo.Context) error {
|
||||||
trace.ClearBackendTraces()
|
trace.ClearBackendTraces()
|
||||||
return c.NoContent(204)
|
return c.NoContent(204)
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
// Backend logs REST endpoints
|
// Backend logs REST endpoints
|
||||||
app.GET("/api/backend-logs", func(c echo.Context) error {
|
app.GET("/api/backend-logs", func(c echo.Context) error {
|
||||||
return c.JSON(200, ml.BackendLogs().ListModels())
|
return c.JSON(200, ml.BackendLogs().ListModels())
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
app.GET("/api/backend-logs/:modelId", func(c echo.Context) error {
|
app.GET("/api/backend-logs/:modelId", func(c echo.Context) error {
|
||||||
modelID := c.Param("modelId")
|
modelID := c.Param("modelId")
|
||||||
return c.JSON(200, ml.BackendLogs().GetLines(modelID))
|
return c.JSON(200, ml.BackendLogs().GetLines(modelID))
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
app.POST("/api/backend-logs/:modelId/clear", func(c echo.Context) error {
|
app.POST("/api/backend-logs/:modelId/clear", func(c echo.Context) error {
|
||||||
ml.BackendLogs().Clear(c.Param("modelId"))
|
ml.BackendLogs().Clear(c.Param("modelId"))
|
||||||
return c.NoContent(204)
|
return c.NoContent(204)
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
// Backend logs WebSocket endpoint for real-time streaming
|
// Backend logs WebSocket endpoint for real-time streaming
|
||||||
app.GET("/ws/backend-logs/:modelId", func(c echo.Context) error {
|
app.GET("/ws/backend-logs/:modelId", func(c echo.Context) error {
|
||||||
|
|
@ -177,7 +178,7 @@ func RegisterUIRoutes(app *echo.Echo,
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}, adminMiddleware)
|
||||||
}
|
}
|
||||||
|
|
||||||
// backendLogsConn wraps a websocket connection with a mutex for safe concurrent writes
|
// backendLogsConn wraps a websocket connection with a mutex for safe concurrent writes
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import (
|
||||||
"github.com/mudler/LocalAI/core/application"
|
"github.com/mudler/LocalAI/core/application"
|
||||||
"github.com/mudler/LocalAI/core/config"
|
"github.com/mudler/LocalAI/core/config"
|
||||||
"github.com/mudler/LocalAI/core/gallery"
|
"github.com/mudler/LocalAI/core/gallery"
|
||||||
|
"github.com/mudler/LocalAI/core/http/auth"
|
||||||
"github.com/mudler/LocalAI/core/http/endpoints/localai"
|
"github.com/mudler/LocalAI/core/http/endpoints/localai"
|
||||||
"github.com/mudler/LocalAI/core/http/middleware"
|
"github.com/mudler/LocalAI/core/http/middleware"
|
||||||
"github.com/mudler/LocalAI/core/p2p"
|
"github.com/mudler/LocalAI/core/p2p"
|
||||||
|
|
@ -58,7 +59,7 @@ func getDirectorySize(path string) (int64, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterUIAPIRoutes registers JSON API routes for the web UI
|
// RegisterUIAPIRoutes registers JSON API routes for the web UI
|
||||||
func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache, applicationInstance *application.Application) {
|
func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, galleryService *services.GalleryService, opcache *services.OpCache, applicationInstance *application.Application, adminMiddleware echo.MiddlewareFunc) {
|
||||||
|
|
||||||
// Operations API - Get all current operations (models + backends)
|
// Operations API - Get all current operations (models + backends)
|
||||||
app.GET("/api/operations", func(c echo.Context) error {
|
app.GET("/api/operations", func(c echo.Context) error {
|
||||||
|
|
@ -168,9 +169,9 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
return c.JSON(200, map[string]interface{}{
|
return c.JSON(200, map[string]interface{}{
|
||||||
"operations": operations,
|
"operations": operations,
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
// Cancel operation endpoint
|
// Cancel operation endpoint (admin only)
|
||||||
app.POST("/api/operations/:jobID/cancel", func(c echo.Context) error {
|
app.POST("/api/operations/:jobID/cancel", func(c echo.Context) error {
|
||||||
jobID := c.Param("jobID")
|
jobID := c.Param("jobID")
|
||||||
xlog.Debug("API request to cancel operation", "jobID", jobID)
|
xlog.Debug("API request to cancel operation", "jobID", jobID)
|
||||||
|
|
@ -190,7 +191,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Operation cancelled",
|
"message": "Operation cancelled",
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
// Dismiss a failed operation (acknowledge the error and remove it from the list)
|
// Dismiss a failed operation (acknowledge the error and remove it from the list)
|
||||||
app.POST("/api/operations/:jobID/dismiss", func(c echo.Context) error {
|
app.POST("/api/operations/:jobID/dismiss", func(c echo.Context) error {
|
||||||
|
|
@ -204,9 +205,9 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Operation dismissed",
|
"message": "Operation dismissed",
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
// Model Gallery APIs
|
// Model Gallery APIs (admin only)
|
||||||
app.GET("/api/models", func(c echo.Context) error {
|
app.GET("/api/models", func(c echo.Context) error {
|
||||||
term := c.QueryParam("term")
|
term := c.QueryParam("term")
|
||||||
page := c.QueryParam("page")
|
page := c.QueryParam("page")
|
||||||
|
|
@ -488,7 +489,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
"prevPage": prevPage,
|
"prevPage": prevPage,
|
||||||
"nextPage": nextPage,
|
"nextPage": nextPage,
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
// Returns installed models with their capability flags for UI filtering
|
// Returns installed models with their capability flags for UI filtering
|
||||||
app.GET("/api/models/capabilities", func(c echo.Context) error {
|
app.GET("/api/models/capabilities", func(c echo.Context) error {
|
||||||
|
|
@ -516,6 +517,26 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by user's model allowlist if auth is enabled
|
||||||
|
if authDB := applicationInstance.AuthDB(); authDB != nil {
|
||||||
|
if user := auth.GetUser(c); user != nil && user.Role != auth.RoleAdmin {
|
||||||
|
perm, err := auth.GetCachedUserPermissions(c, authDB, user.ID)
|
||||||
|
if err == nil && perm.AllowedModels.Enabled {
|
||||||
|
allowed := map[string]bool{}
|
||||||
|
for _, m := range perm.AllowedModels.Models {
|
||||||
|
allowed[m] = true
|
||||||
|
}
|
||||||
|
filtered := make([]modelCapability, 0, len(result))
|
||||||
|
for _, mc := range result {
|
||||||
|
if allowed[mc.ID] {
|
||||||
|
filtered = append(filtered, mc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(200, map[string]any{
|
return c.JSON(200, map[string]any{
|
||||||
"data": result,
|
"data": result,
|
||||||
})
|
})
|
||||||
|
|
@ -561,7 +582,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
"jobID": uid,
|
"jobID": uid,
|
||||||
"message": "Installation started",
|
"message": "Installation started",
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
app.POST("/api/models/delete/:id", func(c echo.Context) error {
|
app.POST("/api/models/delete/:id", func(c echo.Context) error {
|
||||||
galleryID := c.Param("id")
|
galleryID := c.Param("id")
|
||||||
|
|
@ -611,7 +632,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
"jobID": uid,
|
"jobID": uid,
|
||||||
"message": "Deletion started",
|
"message": "Deletion started",
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
app.POST("/api/models/config/:id", func(c echo.Context) error {
|
app.POST("/api/models/config/:id", func(c echo.Context) error {
|
||||||
galleryID := c.Param("id")
|
galleryID := c.Param("id")
|
||||||
|
|
@ -655,7 +676,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
return c.JSON(200, map[string]interface{}{
|
return c.JSON(200, map[string]interface{}{
|
||||||
"message": "Configuration file saved",
|
"message": "Configuration file saved",
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
// Get installed model config as JSON (used by frontend for MCP detection, etc.)
|
// Get installed model config as JSON (used by frontend for MCP detection, etc.)
|
||||||
app.GET("/api/models/config-json/:name", func(c echo.Context) error {
|
app.GET("/api/models/config-json/:name", func(c echo.Context) error {
|
||||||
|
|
@ -674,7 +695,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, modelConfig)
|
return c.JSON(http.StatusOK, modelConfig)
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
// Get installed model YAML config for the React model editor
|
// Get installed model YAML config for the React model editor
|
||||||
app.GET("/api/models/edit/:name", func(c echo.Context) error {
|
app.GET("/api/models/edit/:name", func(c echo.Context) error {
|
||||||
|
|
@ -713,7 +734,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
"config": string(configData),
|
"config": string(configData),
|
||||||
"name": modelName,
|
"name": modelName,
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
app.GET("/api/models/job/:uid", func(c echo.Context) error {
|
app.GET("/api/models/job/:uid", func(c echo.Context) error {
|
||||||
jobUID := c.Param("uid")
|
jobUID := c.Param("uid")
|
||||||
|
|
@ -750,7 +771,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(200, response)
|
return c.JSON(200, response)
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
// Backend Gallery APIs
|
// Backend Gallery APIs
|
||||||
app.GET("/api/backends", func(c echo.Context) error {
|
app.GET("/api/backends", func(c echo.Context) error {
|
||||||
|
|
@ -904,7 +925,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
"nextPage": nextPage,
|
"nextPage": nextPage,
|
||||||
"systemCapability": detectedCapability,
|
"systemCapability": detectedCapability,
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
app.POST("/api/backends/install/:id", func(c echo.Context) error {
|
app.POST("/api/backends/install/:id", func(c echo.Context) error {
|
||||||
backendID := c.Param("id")
|
backendID := c.Param("id")
|
||||||
|
|
@ -945,7 +966,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
"jobID": uid,
|
"jobID": uid,
|
||||||
"message": "Backend installation started",
|
"message": "Backend installation started",
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
// Install backend from external source (OCI image, URL, or path)
|
// Install backend from external source (OCI image, URL, or path)
|
||||||
app.POST("/api/backends/install-external", func(c echo.Context) error {
|
app.POST("/api/backends/install-external", func(c echo.Context) error {
|
||||||
|
|
@ -1009,7 +1030,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
"jobID": uid,
|
"jobID": uid,
|
||||||
"message": "External backend installation started",
|
"message": "External backend installation started",
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
app.POST("/api/backends/delete/:id", func(c echo.Context) error {
|
app.POST("/api/backends/delete/:id", func(c echo.Context) error {
|
||||||
backendID := c.Param("id")
|
backendID := c.Param("id")
|
||||||
|
|
@ -1057,7 +1078,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
"jobID": uid,
|
"jobID": uid,
|
||||||
"message": "Backend deletion started",
|
"message": "Backend deletion started",
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
app.GET("/api/backends/job/:uid", func(c echo.Context) error {
|
app.GET("/api/backends/job/:uid", func(c echo.Context) error {
|
||||||
jobUID := c.Param("uid")
|
jobUID := c.Param("uid")
|
||||||
|
|
@ -1094,7 +1115,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(200, response)
|
return c.JSON(200, response)
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
// System Backend Deletion API (for installed backends on index page)
|
// System Backend Deletion API (for installed backends on index page)
|
||||||
app.POST("/api/backends/system/delete/:name", func(c echo.Context) error {
|
app.POST("/api/backends/system/delete/:name", func(c echo.Context) error {
|
||||||
|
|
@ -1120,7 +1141,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "Backend deleted successfully",
|
"message": "Backend deleted successfully",
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
// P2P APIs
|
// P2P APIs
|
||||||
app.GET("/api/p2p/workers", func(c echo.Context) error {
|
app.GET("/api/p2p/workers", func(c echo.Context) error {
|
||||||
|
|
@ -1161,7 +1182,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
// Keep backward-compatible "nodes" key with llama.cpp workers
|
// Keep backward-compatible "nodes" key with llama.cpp workers
|
||||||
"nodes": llamaJSON,
|
"nodes": llamaJSON,
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
app.GET("/api/p2p/federation", func(c echo.Context) error {
|
app.GET("/api/p2p/federation", func(c echo.Context) error {
|
||||||
nodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.FederatedID))
|
nodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.FederatedID))
|
||||||
|
|
@ -1181,7 +1202,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
return c.JSON(200, map[string]interface{}{
|
return c.JSON(200, map[string]interface{}{
|
||||||
"nodes": nodesJSON,
|
"nodes": nodesJSON,
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
app.GET("/api/p2p/stats", func(c echo.Context) error {
|
app.GET("/api/p2p/stats", func(c echo.Context) error {
|
||||||
llamaCPPNodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.LlamaCPPWorkerID))
|
llamaCPPNodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.LlamaCPPWorkerID))
|
||||||
|
|
@ -1223,7 +1244,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
"total": len(mlxWorkerNodes),
|
"total": len(mlxWorkerNodes),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
// Resources API endpoint - unified memory info (GPU if available, otherwise RAM)
|
// Resources API endpoint - unified memory info (GPU if available, otherwise RAM)
|
||||||
app.GET("/api/resources", func(c echo.Context) error {
|
app.GET("/api/resources", func(c echo.Context) error {
|
||||||
|
|
@ -1250,15 +1271,15 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(200, response)
|
return c.JSON(200, response)
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
if !appConfig.DisableRuntimeSettings {
|
if !appConfig.DisableRuntimeSettings {
|
||||||
// Settings API
|
// Settings API
|
||||||
app.GET("/api/settings", localai.GetSettingsEndpoint(applicationInstance))
|
app.GET("/api/settings", localai.GetSettingsEndpoint(applicationInstance), adminMiddleware)
|
||||||
app.POST("/api/settings", localai.UpdateSettingsEndpoint(applicationInstance))
|
app.POST("/api/settings", localai.UpdateSettingsEndpoint(applicationInstance), adminMiddleware)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logs API
|
// Logs API (admin only)
|
||||||
app.GET("/api/traces", func(c echo.Context) error {
|
app.GET("/api/traces", func(c echo.Context) error {
|
||||||
if !appConfig.EnableTracing {
|
if !appConfig.EnableTracing {
|
||||||
return c.JSON(503, map[string]any{
|
return c.JSON(503, map[string]any{
|
||||||
|
|
@ -1269,12 +1290,12 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
|
||||||
return c.JSON(200, map[string]interface{}{
|
return c.JSON(200, map[string]interface{}{
|
||||||
"traces": traces,
|
"traces": traces,
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
|
|
||||||
app.POST("/api/traces/clear", func(c echo.Context) error {
|
app.POST("/api/traces/clear", func(c echo.Context) error {
|
||||||
middleware.ClearTraces()
|
middleware.ClearTraces()
|
||||||
return c.JSON(200, map[string]interface{}{
|
return c.JSON(200, map[string]interface{}{
|
||||||
"message": "Traces cleared",
|
"message": "Traces cleared",
|
||||||
})
|
})
|
||||||
})
|
}, adminMiddleware)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,9 @@ var _ = Describe("Backend API Routes", func() {
|
||||||
|
|
||||||
// Register the API routes for backends
|
// Register the API routes for backends
|
||||||
opcache := services.NewOpCache(galleryService)
|
opcache := services.NewOpCache(galleryService)
|
||||||
routes.RegisterUIAPIRoutes(app, configLoader, modelLoader, appConfig, galleryService, opcache, nil)
|
// Use a no-op admin middleware for tests
|
||||||
|
noopMw := func(next echo.HandlerFunc) echo.HandlerFunc { return next }
|
||||||
|
routes.RegisterUIAPIRoutes(app, configLoader, modelLoader, appConfig, galleryService, opcache, nil, noopMw)
|
||||||
})
|
})
|
||||||
|
|
||||||
AfterEach(func() {
|
AfterEach(func() {
|
||||||
|
|
|
||||||
|
|
@ -88,11 +88,6 @@ func NewAgentJobService(
|
||||||
configLoader *config.ModelConfigLoader,
|
configLoader *config.ModelConfigLoader,
|
||||||
evaluator *templates.Evaluator,
|
evaluator *templates.Evaluator,
|
||||||
) *AgentJobService {
|
) *AgentJobService {
|
||||||
retentionDays := appConfig.AgentJobRetentionDays
|
|
||||||
if retentionDays == 0 {
|
|
||||||
retentionDays = 30 // Default
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine storage directory: DataPath > DynamicConfigsDir
|
// Determine storage directory: DataPath > DynamicConfigsDir
|
||||||
tasksFile := ""
|
tasksFile := ""
|
||||||
jobsFile := ""
|
jobsFile := ""
|
||||||
|
|
@ -105,6 +100,22 @@ func NewAgentJobService(
|
||||||
jobsFile = filepath.Join(dataDir, "agent_jobs.json")
|
jobsFile = filepath.Join(dataDir, "agent_jobs.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return NewAgentJobServiceWithPaths(appConfig, modelLoader, configLoader, evaluator, tasksFile, jobsFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAgentJobServiceWithPaths creates a new AgentJobService with explicit file paths.
|
||||||
|
func NewAgentJobServiceWithPaths(
|
||||||
|
appConfig *config.ApplicationConfig,
|
||||||
|
modelLoader *model.ModelLoader,
|
||||||
|
configLoader *config.ModelConfigLoader,
|
||||||
|
evaluator *templates.Evaluator,
|
||||||
|
tasksFile, jobsFile string,
|
||||||
|
) *AgentJobService {
|
||||||
|
retentionDays := appConfig.AgentJobRetentionDays
|
||||||
|
if retentionDays == 0 {
|
||||||
|
retentionDays = 30 // Default
|
||||||
|
}
|
||||||
|
|
||||||
return &AgentJobService{
|
return &AgentJobService{
|
||||||
appConfig: appConfig,
|
appConfig: appConfig,
|
||||||
modelLoader: modelLoader,
|
modelLoader: modelLoader,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
183
core/services/user_services.go
Normal file
183
core/services/user_services.go
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/mudler/LocalAI/core/config"
|
||||||
|
"github.com/mudler/LocalAI/core/templates"
|
||||||
|
"github.com/mudler/LocalAI/pkg/model"
|
||||||
|
"github.com/mudler/LocalAGI/services/skills"
|
||||||
|
"github.com/mudler/LocalAGI/webui/collections"
|
||||||
|
"github.com/mudler/xlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserServicesManager lazily creates per-user service instances for
|
||||||
|
// collections, skills, and jobs.
|
||||||
|
type UserServicesManager struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
storage *UserScopedStorage
|
||||||
|
appConfig *config.ApplicationConfig
|
||||||
|
modelLoader *model.ModelLoader
|
||||||
|
configLoader *config.ModelConfigLoader
|
||||||
|
evaluator *templates.Evaluator
|
||||||
|
collectionsCache map[string]collections.Backend
|
||||||
|
skillsCache map[string]*skills.Service
|
||||||
|
jobsCache map[string]*AgentJobService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserServicesManager creates a new UserServicesManager.
|
||||||
|
func NewUserServicesManager(
|
||||||
|
storage *UserScopedStorage,
|
||||||
|
appConfig *config.ApplicationConfig,
|
||||||
|
modelLoader *model.ModelLoader,
|
||||||
|
configLoader *config.ModelConfigLoader,
|
||||||
|
evaluator *templates.Evaluator,
|
||||||
|
) *UserServicesManager {
|
||||||
|
return &UserServicesManager{
|
||||||
|
storage: storage,
|
||||||
|
appConfig: appConfig,
|
||||||
|
modelLoader: modelLoader,
|
||||||
|
configLoader: configLoader,
|
||||||
|
evaluator: evaluator,
|
||||||
|
collectionsCache: make(map[string]collections.Backend),
|
||||||
|
skillsCache: make(map[string]*skills.Service),
|
||||||
|
jobsCache: make(map[string]*AgentJobService),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCollections returns the collections backend for a user, creating it lazily.
|
||||||
|
func (m *UserServicesManager) GetCollections(userID string) (collections.Backend, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
if backend, ok := m.collectionsCache[userID]; ok {
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return backend, nil
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
// Double-check after acquiring write lock
|
||||||
|
if backend, ok := m.collectionsCache[userID]; ok {
|
||||||
|
return backend, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.storage.EnsureUserDirs(userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := m.appConfig.AgentPool
|
||||||
|
apiURL := cfg.APIURL
|
||||||
|
if apiURL == "" {
|
||||||
|
apiURL = "http://127.0.0.1:" + getPort(m.appConfig)
|
||||||
|
}
|
||||||
|
apiKey := cfg.APIKey
|
||||||
|
if apiKey == "" && len(m.appConfig.ApiKeys) > 0 {
|
||||||
|
apiKey = m.appConfig.ApiKeys[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
collectionsCfg := &collections.Config{
|
||||||
|
LLMAPIURL: apiURL,
|
||||||
|
LLMAPIKey: apiKey,
|
||||||
|
LLMModel: cfg.DefaultModel,
|
||||||
|
CollectionDBPath: m.storage.CollectionsDir(userID),
|
||||||
|
FileAssets: m.storage.AssetsDir(userID),
|
||||||
|
VectorEngine: cfg.VectorEngine,
|
||||||
|
EmbeddingModel: cfg.EmbeddingModel,
|
||||||
|
MaxChunkingSize: cfg.MaxChunkingSize,
|
||||||
|
ChunkOverlap: cfg.ChunkOverlap,
|
||||||
|
DatabaseURL: cfg.DatabaseURL,
|
||||||
|
}
|
||||||
|
|
||||||
|
backend, _ := collections.NewInProcessBackend(collectionsCfg)
|
||||||
|
m.collectionsCache[userID] = backend
|
||||||
|
return backend, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSkills returns the skills service for a user, creating it lazily.
|
||||||
|
func (m *UserServicesManager) GetSkills(userID string) (*skills.Service, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
if svc, ok := m.skillsCache[userID]; ok {
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if svc, ok := m.skillsCache[userID]; ok {
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.storage.EnsureUserDirs(userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
skillsDir := m.storage.SkillsDir(userID)
|
||||||
|
svc, err := skills.NewService(skillsDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.skillsCache[userID] = svc
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJobs returns the agent job service for a user, creating it lazily.
|
||||||
|
func (m *UserServicesManager) GetJobs(userID string) (*AgentJobService, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
if svc, ok := m.jobsCache[userID]; ok {
|
||||||
|
m.mu.RUnlock()
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if svc, ok := m.jobsCache[userID]; ok {
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.storage.EnsureUserDirs(userID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewAgentJobServiceWithPaths(
|
||||||
|
m.appConfig,
|
||||||
|
m.modelLoader,
|
||||||
|
m.configLoader,
|
||||||
|
m.evaluator,
|
||||||
|
m.storage.TasksFile(userID),
|
||||||
|
m.storage.JobsFile(userID),
|
||||||
|
)
|
||||||
|
m.jobsCache[userID] = svc
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllUserIDs returns all user IDs that have scoped data directories.
|
||||||
|
func (m *UserServicesManager) ListAllUserIDs() ([]string, error) {
|
||||||
|
return m.storage.ListUserDirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPort extracts the port from the API address config.
|
||||||
|
func getPort(appConfig *config.ApplicationConfig) string {
|
||||||
|
addr := appConfig.APIAddress
|
||||||
|
for i := len(addr) - 1; i >= 0; i-- {
|
||||||
|
if addr[i] == ':' {
|
||||||
|
return addr[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopAll stops all cached job services.
|
||||||
|
func (m *UserServicesManager) StopAll() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for _, svc := range m.jobsCache {
|
||||||
|
if err := svc.Stop(); err != nil {
|
||||||
|
xlog.Error("Failed to stop user job service", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
core/services/user_storage.go
Normal file
142
core/services/user_storage.go
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserScopedStorage resolves per-user storage directories.
|
||||||
|
// When userID is empty, paths resolve to root-level (backward compat).
|
||||||
|
// When userID is set, paths resolve to {baseDir}/users/{userID}/...
|
||||||
|
type UserScopedStorage struct {
|
||||||
|
baseDir string // State directory
|
||||||
|
dataDir string // Data directory (for jobs files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserScopedStorage creates a new UserScopedStorage.
|
||||||
|
func NewUserScopedStorage(baseDir, dataDir string) *UserScopedStorage {
|
||||||
|
return &UserScopedStorage{
|
||||||
|
baseDir: baseDir,
|
||||||
|
dataDir: dataDir,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve returns baseDir for empty userID, or baseDir/users/{userID} otherwise.
|
||||||
|
func (s *UserScopedStorage) resolve(userID string) string {
|
||||||
|
if userID == "" {
|
||||||
|
return s.baseDir
|
||||||
|
}
|
||||||
|
return filepath.Join(s.baseDir, "users", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveData returns dataDir for empty userID, or baseDir/users/{userID} otherwise.
|
||||||
|
func (s *UserScopedStorage) resolveData(userID string) string {
|
||||||
|
if userID == "" {
|
||||||
|
return s.dataDir
|
||||||
|
}
|
||||||
|
return filepath.Join(s.baseDir, "users", userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDir returns the root directory for a user's scoped data.
|
||||||
|
func (s *UserScopedStorage) UserDir(userID string) string {
|
||||||
|
return s.resolve(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectionsDir returns the collections directory for a user.
|
||||||
|
func (s *UserScopedStorage) CollectionsDir(userID string) string {
|
||||||
|
return filepath.Join(s.resolve(userID), "collections")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetsDir returns the assets directory for a user.
|
||||||
|
func (s *UserScopedStorage) AssetsDir(userID string) string {
|
||||||
|
return filepath.Join(s.resolve(userID), "assets")
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutputsDir returns the outputs directory for a user.
|
||||||
|
func (s *UserScopedStorage) OutputsDir(userID string) string {
|
||||||
|
return filepath.Join(s.resolve(userID), "outputs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillsDir returns the skills directory for a user.
|
||||||
|
func (s *UserScopedStorage) SkillsDir(userID string) string {
|
||||||
|
return filepath.Join(s.resolve(userID), "skills")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TasksFile returns the path to the agent_tasks.json for a user.
|
||||||
|
func (s *UserScopedStorage) TasksFile(userID string) string {
|
||||||
|
return filepath.Join(s.resolveData(userID), "agent_tasks.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobsFile returns the path to the agent_jobs.json for a user.
|
||||||
|
func (s *UserScopedStorage) JobsFile(userID string) string {
|
||||||
|
return filepath.Join(s.resolveData(userID), "agent_jobs.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureUserDirs creates all subdirectories for a user.
|
||||||
|
func (s *UserScopedStorage) EnsureUserDirs(userID string) error {
|
||||||
|
dirs := []string{
|
||||||
|
s.CollectionsDir(userID),
|
||||||
|
s.AssetsDir(userID),
|
||||||
|
s.OutputsDir(userID),
|
||||||
|
s.SkillsDir(userID),
|
||||||
|
}
|
||||||
|
for _, d := range dirs {
|
||||||
|
if err := os.MkdirAll(d, 0750); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory %s: %w", d, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||||
|
|
||||||
|
// ListUserDirs scans {baseDir}/users/ and returns sorted UUIDs matching uuidRegex.
|
||||||
|
// Returns an empty slice if the directory doesn't exist.
|
||||||
|
func (s *UserScopedStorage) ListUserDirs() ([]string, error) {
|
||||||
|
usersDir := filepath.Join(s.baseDir, "users")
|
||||||
|
entries, err := os.ReadDir(usersDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to read users directory: %w", err)
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() && uuidRegex.MatchString(e.Name()) {
|
||||||
|
ids = append(ids, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(ids)
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateUserID validates that a userID is safe for use in filesystem paths.
|
||||||
|
// Empty string is allowed (maps to root storage). Otherwise must be a valid UUID.
|
||||||
|
func ValidateUserID(id string) error {
|
||||||
|
if id == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(id, "/\\") || strings.Contains(id, "..") {
|
||||||
|
return fmt.Errorf("invalid user ID: contains path traversal characters")
|
||||||
|
}
|
||||||
|
if !uuidRegex.MatchString(id) {
|
||||||
|
return fmt.Errorf("invalid user ID: must be a valid UUID")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAgentName validates that an agent name is safe (no namespace escape or path traversal).
|
||||||
|
func ValidateAgentName(name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("agent name is required")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(name, ":/\\\x00") || strings.Contains(name, "..") {
|
||||||
|
return fmt.Errorf("agent name contains invalid characters (: / \\ .. or null bytes are not allowed)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,8 @@ services:
|
||||||
- models:/models
|
- models:/models
|
||||||
- images:/tmp/generated/images/
|
- images:/tmp/generated/images/
|
||||||
- data:/data
|
- data:/data
|
||||||
|
- backends:/backends
|
||||||
|
- configuration:/configuration
|
||||||
command:
|
command:
|
||||||
# Here we can specify a list of models to run (see quickstart https://localai.io/basics/getting_started/#running-models )
|
# Here we can specify a list of models to run (see quickstart https://localai.io/basics/getting_started/#running-models )
|
||||||
# or an URL pointing to a YAML configuration file, for example:
|
# or an URL pointing to a YAML configuration file, for example:
|
||||||
|
|
@ -64,7 +66,7 @@ services:
|
||||||
# - POSTGRES_USER=localrecall
|
# - POSTGRES_USER=localrecall
|
||||||
# - POSTGRES_PASSWORD=localrecall
|
# - POSTGRES_PASSWORD=localrecall
|
||||||
# volumes:
|
# volumes:
|
||||||
# - postgres_data:/var/lib/postgresql/data
|
# - postgres_data:/var/lib/postgresql
|
||||||
# healthcheck:
|
# healthcheck:
|
||||||
# test: ["CMD-SHELL", "pg_isready -U localrecall"]
|
# test: ["CMD-SHELL", "pg_isready -U localrecall"]
|
||||||
# interval: 10s
|
# interval: 10s
|
||||||
|
|
@ -75,4 +77,6 @@ volumes:
|
||||||
models:
|
models:
|
||||||
images:
|
images:
|
||||||
data:
|
data:
|
||||||
|
configuration:
|
||||||
|
backends:
|
||||||
# postgres_data:
|
# postgres_data:
|
||||||
|
|
|
||||||
355
docs/content/features/authentication.md
Normal file
355
docs/content/features/authentication.md
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
+++
|
||||||
|
disableToc = false
|
||||||
|
title = "🔐 Authentication & Authorization"
|
||||||
|
weight = 26
|
||||||
|
url = '/features/authentication'
|
||||||
|
+++
|
||||||
|
|
||||||
|
LocalAI supports two authentication modes: **legacy API key authentication** (simple shared keys) and a full **user authentication system** with roles, sessions, OAuth, and per-user usage tracking.
|
||||||
|
|
||||||
|
## Legacy API Key Authentication
|
||||||
|
|
||||||
|
The simplest way to protect your LocalAI instance is with API keys. Set one or more keys via environment variable or CLI flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single key
|
||||||
|
LOCALAI_API_KEY=sk-my-secret-key localai run
|
||||||
|
|
||||||
|
# Multiple keys (comma-separated)
|
||||||
|
LOCALAI_API_KEY=key1,key2,key3 localai run
|
||||||
|
```
|
||||||
|
|
||||||
|
Clients provide the key via any of these methods:
|
||||||
|
|
||||||
|
- `Authorization: Bearer <key>` header
|
||||||
|
- `x-api-key: <key>` header
|
||||||
|
- `xi-api-key: <key>` header
|
||||||
|
- `token` cookie
|
||||||
|
|
||||||
|
Legacy API keys grant **full admin access** — there is no role separation. For multi-user deployments with role-based access, use the user authentication system instead.
|
||||||
|
|
||||||
|
API keys can also be managed at runtime through the [Runtime Settings]({{%relref "features/runtime-settings" %}}) interface.
|
||||||
|
|
||||||
|
## User Authentication System
|
||||||
|
|
||||||
|
The user authentication system provides:
|
||||||
|
|
||||||
|
- **User accounts** with email, name, and avatar
|
||||||
|
- **Role-based access control** (admin vs. user)
|
||||||
|
- **Session-based authentication** with secure cookies
|
||||||
|
- **OAuth login** (GitHub) and **OIDC single sign-on** (Keycloak, Google, Okta, Authentik, etc.)
|
||||||
|
- **Per-user API keys** for programmatic access
|
||||||
|
- **Admin route gating** — management endpoints are restricted to admins
|
||||||
|
- **Per-user usage tracking** with token consumption metrics
|
||||||
|
|
||||||
|
### Enabling Authentication
|
||||||
|
|
||||||
|
Set `LOCALAI_AUTH=true` or provide a GitHub OAuth Client ID or OIDC Client ID (which auto-enables auth):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable with SQLite (default, stored at {DataPath}/database.db)
|
||||||
|
LOCALAI_AUTH=true localai run
|
||||||
|
|
||||||
|
# Enable with GitHub OAuth
|
||||||
|
GITHUB_CLIENT_ID=your-client-id \
|
||||||
|
GITHUB_CLIENT_SECRET=your-client-secret \
|
||||||
|
LOCALAI_BASE_URL=http://localhost:8080 \
|
||||||
|
localai run
|
||||||
|
|
||||||
|
# Enable with OIDC provider (e.g. Keycloak)
|
||||||
|
LOCALAI_OIDC_ISSUER=https://keycloak.example.com/realms/myrealm \
|
||||||
|
LOCALAI_OIDC_CLIENT_ID=your-client-id \
|
||||||
|
LOCALAI_OIDC_CLIENT_SECRET=your-client-secret \
|
||||||
|
LOCALAI_BASE_URL=http://localhost:8080 \
|
||||||
|
localai run
|
||||||
|
|
||||||
|
# Enable with PostgreSQL
|
||||||
|
LOCALAI_AUTH=true \
|
||||||
|
LOCALAI_AUTH_DATABASE_URL=postgres://user:pass@host/dbname \
|
||||||
|
localai run
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Reference
|
||||||
|
|
||||||
|
| Environment Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `LOCALAI_AUTH` | `false` | Enable user authentication and authorization |
|
||||||
|
| `LOCALAI_AUTH_DATABASE_URL` | `{DataPath}/database.db` | Database URL — `postgres://...` for PostgreSQL, or a file path for SQLite |
|
||||||
|
| `GITHUB_CLIENT_ID` | | GitHub OAuth App Client ID (auto-enables auth when set) |
|
||||||
|
| `GITHUB_CLIENT_SECRET` | | GitHub OAuth App Client Secret |
|
||||||
|
| `LOCALAI_OIDC_ISSUER` | | OIDC issuer URL for auto-discovery (e.g. `https://accounts.google.com`) |
|
||||||
|
| `LOCALAI_OIDC_CLIENT_ID` | | OIDC Client ID (auto-enables auth when set) |
|
||||||
|
| `LOCALAI_OIDC_CLIENT_SECRET` | | OIDC Client Secret |
|
||||||
|
| `LOCALAI_BASE_URL` | | Base URL for OAuth callbacks (e.g. `http://localhost:8080`) |
|
||||||
|
| `LOCALAI_ADMIN_EMAIL` | | Email address to auto-promote to admin role on login |
|
||||||
|
| `LOCALAI_REGISTRATION_MODE` | `approval` | Registration mode: `open`, `approval`, or `invite` |
|
||||||
|
| `LOCALAI_DISABLE_LOCAL_AUTH` | `false` | Disable local email/password registration and login (for OAuth/OIDC-only deployments) |
|
||||||
|
|
||||||
|
### Disabling Local Authentication
|
||||||
|
|
||||||
|
If you want to enforce OAuth/OIDC-only login and prevent users from registering or logging in with email/password, set `LOCALAI_DISABLE_LOCAL_AUTH=true` (or pass `--disable-local-auth`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OAuth-only setup (no email/password)
|
||||||
|
LOCALAI_DISABLE_LOCAL_AUTH=true \
|
||||||
|
GITHUB_CLIENT_ID=your-client-id \
|
||||||
|
GITHUB_CLIENT_SECRET=your-client-secret \
|
||||||
|
LOCALAI_BASE_URL=http://localhost:8080 \
|
||||||
|
localai run
|
||||||
|
```
|
||||||
|
|
||||||
|
When disabled:
|
||||||
|
- The login page will not show email/password forms (the UI checks the `providers` list from `/api/auth/status`)
|
||||||
|
- `POST /api/auth/register` returns `403 Forbidden`
|
||||||
|
- `POST /api/auth/login` returns `403 Forbidden`
|
||||||
|
- OAuth/OIDC login continues to work normally
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
There are two roles:
|
||||||
|
|
||||||
|
- **Admin**: Full access to all endpoints, including model management, backend configuration, system settings, traces, agents, and user management.
|
||||||
|
- **User**: Access to inference endpoints only — chat completions, embeddings, image/video/audio generation, TTS, MCP chat, and their own usage statistics.
|
||||||
|
|
||||||
|
The **first user** to sign in is automatically assigned the admin role. Additional users can be promoted to admin via the admin user management API or by setting `LOCALAI_ADMIN_EMAIL` to their email address.
|
||||||
|
|
||||||
|
### Registration Modes
|
||||||
|
|
||||||
|
| Mode | Description |
|
||||||
|
|---|---|
|
||||||
|
| `open` | Anyone can register and is immediately active |
|
||||||
|
| `approval` | New users land in "pending" status until an admin approves them. If a valid invite code is provided during registration, the user is activated immediately (skipping the approval wait). **(default)** |
|
||||||
|
| `invite` | Registration requires a valid invite link generated by an admin. Without one, registration is rejected. |
|
||||||
|
|
||||||
|
### Invite Links
|
||||||
|
|
||||||
|
Admins can generate single-use, time-limited invite links from the **Users → Invites** tab in the web UI, or via the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create an invite link (default: expires in 7 days)
|
||||||
|
curl -X POST http://localhost:8080/api/auth/admin/invites \
|
||||||
|
-H "Authorization: Bearer <admin-key>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"expiresInHours": 168}'
|
||||||
|
|
||||||
|
# List all invites
|
||||||
|
curl http://localhost:8080/api/auth/admin/invites \
|
||||||
|
-H "Authorization: Bearer <admin-key>"
|
||||||
|
|
||||||
|
# Revoke an unused invite
|
||||||
|
curl -X DELETE http://localhost:8080/api/auth/admin/invites/<invite-id> \
|
||||||
|
-H "Authorization: Bearer <admin-key>"
|
||||||
|
|
||||||
|
# Check if an invite code is valid (public, no auth required)
|
||||||
|
curl http://localhost:8080/api/auth/invite/<code>/check
|
||||||
|
```
|
||||||
|
|
||||||
|
Share the invite URL (`/invite/<code>`) with the user. When they open it, the registration form is pre-filled with the invite code. Invite codes are single-use — once consumed, they cannot be reused. Expired or used invites are rejected.
|
||||||
|
|
||||||
|
For GitHub OAuth, the invite code is passed as a query parameter to the login URL (`/api/auth/github/login?invite_code=<code>`) and stored in a cookie during the OAuth flow.
|
||||||
|
|
||||||
|
### Admin-Only Endpoints
|
||||||
|
|
||||||
|
When authentication is enabled, the following endpoints require admin role:
|
||||||
|
|
||||||
|
**Model & Backend Management:**
|
||||||
|
- `GET /api/models`, `POST /api/models/install/*`, `POST /api/models/delete/*`
|
||||||
|
- `GET /api/backends`, `POST /api/backends/install/*`, `POST /api/backends/delete/*`
|
||||||
|
- `GET /api/operations`, `POST /api/operations/*/cancel`
|
||||||
|
- `GET /models/available`, `GET /models/galleries`, `GET /models/jobs/*`
|
||||||
|
- `GET /backends`, `GET /backends/available`, `GET /backends/galleries`
|
||||||
|
|
||||||
|
**System & Monitoring:**
|
||||||
|
- `GET /api/traces`, `POST /api/traces/clear`
|
||||||
|
- `GET /api/backend-traces`, `POST /api/backend-traces/clear`
|
||||||
|
- `GET /api/backend-logs/*`, `POST /api/backend-logs/*/clear`
|
||||||
|
- `GET /api/resources`, `GET /api/settings`, `POST /api/settings`
|
||||||
|
- `GET /system`, `GET /backend/monitor`, `POST /backend/shutdown`
|
||||||
|
|
||||||
|
**P2P:**
|
||||||
|
- `GET /api/p2p/*`
|
||||||
|
|
||||||
|
**Agents & Jobs:**
|
||||||
|
- All `/api/agents/*` endpoints
|
||||||
|
- All `/api/agent/tasks/*` and `/api/agent/jobs/*` endpoints
|
||||||
|
|
||||||
|
**User-Accessible Endpoints (all authenticated users):**
|
||||||
|
- `POST /v1/chat/completions`, `POST /v1/embeddings`, `POST /v1/completions`
|
||||||
|
- `POST /v1/images/generations`, `POST /v1/audio/*`, `POST /tts`, `POST /vad`, `POST /video`
|
||||||
|
- `GET /v1/models`, `POST /v1/tokenize`, `POST /v1/detection`
|
||||||
|
- `POST /v1/mcp/chat/completions`, `POST /v1/messages`, `POST /v1/responses`
|
||||||
|
- `POST /stores/*`, `GET /api/cors-proxy`
|
||||||
|
- `GET /version`, `GET /api/features`, `GET /swagger/*`, `GET /metrics`
|
||||||
|
- `GET /api/auth/usage` (own usage data)
|
||||||
|
|
||||||
|
### Web UI Access Control
|
||||||
|
|
||||||
|
When auth is enabled, the React UI sidebar dynamically shows/hides sections based on the user's role:
|
||||||
|
|
||||||
|
- **All users see**: Home, Chat, Images, Video, TTS, Sound, Talk, Usage, API docs link
|
||||||
|
- **Admins also see**: Install Models, Agents section (Agents, Skills, Memory, MCP CI Jobs), System section (Backends, Traces, Swarm, System, Settings)
|
||||||
|
|
||||||
|
Admin-only pages are also protected at the router level — navigating directly to an admin URL redirects non-admin users to the home page.
|
||||||
|
|
||||||
|
### GitHub OAuth Setup
|
||||||
|
|
||||||
|
1. Create a GitHub OAuth App at **Settings → Developer settings → OAuth Apps → New OAuth App**
|
||||||
|
2. Set the **Authorization callback URL** to `{LOCALAI_BASE_URL}/api/auth/github/callback`
|
||||||
|
3. Set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` environment variables
|
||||||
|
4. Set `LOCALAI_BASE_URL` to your publicly-accessible URL
|
||||||
|
|
||||||
|
### OIDC Setup
|
||||||
|
|
||||||
|
Any OIDC-compliant identity provider can be used for single sign-on. This includes Keycloak, Google, Okta, Authentik, Azure AD, and many others.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
1. Create a client/application in your OIDC provider
|
||||||
|
2. Set the redirect URL to `{LOCALAI_BASE_URL}/api/auth/oidc/callback`
|
||||||
|
3. Set the three environment variables: `LOCALAI_OIDC_ISSUER`, `LOCALAI_OIDC_CLIENT_ID`, `LOCALAI_OIDC_CLIENT_SECRET`
|
||||||
|
|
||||||
|
LocalAI uses OIDC auto-discovery (the `/.well-known/openid-configuration` endpoint) and requests the standard scopes: `openid`, `profile`, `email`.
|
||||||
|
|
||||||
|
**Provider examples:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Keycloak
|
||||||
|
LOCALAI_OIDC_ISSUER=https://keycloak.example.com/realms/myrealm
|
||||||
|
|
||||||
|
# Google
|
||||||
|
LOCALAI_OIDC_ISSUER=https://accounts.google.com
|
||||||
|
|
||||||
|
# Authentik
|
||||||
|
LOCALAI_OIDC_ISSUER=https://authentik.example.com/application/o/localai/
|
||||||
|
|
||||||
|
# Okta
|
||||||
|
LOCALAI_OIDC_ISSUER=https://your-org.okta.com
|
||||||
|
```
|
||||||
|
|
||||||
|
For OIDC, invite codes work the same way as GitHub OAuth — the invite code is passed as a query parameter to the login URL (`/api/auth/oidc/login?invite_code=<code>`) and stored in a cookie during the OAuth flow.
|
||||||
|
|
||||||
|
### User API Keys
|
||||||
|
|
||||||
|
Authenticated users can create personal API keys for programmatic access:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create an API key (requires session auth)
|
||||||
|
curl -X POST http://localhost:8080/api/auth/api-keys \
|
||||||
|
-H "Cookie: session=<session-id>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "My Script Key"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
User API keys inherit the creating user's role. Admin keys grant admin access; user keys grant user-level access.
|
||||||
|
|
||||||
|
### Auth API Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description | Auth Required |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET` | `/api/auth/status` | Auth state, current user, providers | No |
|
||||||
|
| `POST` | `/api/auth/logout` | End session | Yes |
|
||||||
|
| `GET` | `/api/auth/me` | Current user info | Yes |
|
||||||
|
| `POST` | `/api/auth/api-keys` | Create API key | Yes |
|
||||||
|
| `GET` | `/api/auth/api-keys` | List user's API keys | Yes |
|
||||||
|
| `DELETE` | `/api/auth/api-keys/:id` | Revoke API key | Yes |
|
||||||
|
| `GET` | `/api/auth/usage` | User's own usage stats | Yes |
|
||||||
|
| `GET` | `/api/auth/admin/users` | List all users | Admin |
|
||||||
|
| `PUT` | `/api/auth/admin/users/:id/role` | Change user role | Admin |
|
||||||
|
| `DELETE` | `/api/auth/admin/users/:id` | Delete user | Admin |
|
||||||
|
| `GET` | `/api/auth/admin/usage` | All users' usage stats | Admin |
|
||||||
|
| `POST` | `/api/auth/admin/invites` | Create invite link | Admin |
|
||||||
|
| `GET` | `/api/auth/admin/invites` | List all invites | Admin |
|
||||||
|
| `DELETE` | `/api/auth/admin/invites/:id` | Revoke unused invite | Admin |
|
||||||
|
| `GET` | `/api/auth/invite/:code/check` | Check if invite code is valid | No |
|
||||||
|
| `GET` | `/api/auth/github/login` | Start GitHub OAuth | No |
|
||||||
|
| `GET` | `/api/auth/github/callback` | GitHub OAuth callback (internal) | No |
|
||||||
|
| `GET` | `/api/auth/oidc/login` | Start OIDC login | No |
|
||||||
|
| `GET` | `/api/auth/oidc/callback` | OIDC callback (internal) | No |
|
||||||
|
|
||||||
|
## Usage Tracking
|
||||||
|
|
||||||
|
When authentication is enabled, LocalAI automatically tracks per-user token usage for inference endpoints. Usage data includes:
|
||||||
|
|
||||||
|
- **Prompt tokens**, **completion tokens**, and **total tokens** per request
|
||||||
|
- **Model** used and **endpoint** called
|
||||||
|
- **Request duration**
|
||||||
|
- **Timestamp** for time-series aggregation
|
||||||
|
|
||||||
|
### Viewing Usage
|
||||||
|
|
||||||
|
Usage is accessible through the **Usage** page in the web UI (visible to all authenticated users) or via the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get your own usage (default: last 30 days)
|
||||||
|
curl http://localhost:8080/api/auth/usage?period=month \
|
||||||
|
-H "Authorization: Bearer <key>"
|
||||||
|
|
||||||
|
# Admin: get all users' usage
|
||||||
|
curl http://localhost:8080/api/auth/admin/usage?period=week \
|
||||||
|
-H "Authorization: Bearer <admin-key>"
|
||||||
|
|
||||||
|
# Admin: filter by specific user
|
||||||
|
curl "http://localhost:8080/api/auth/admin/usage?period=month&user_id=<user-id>" \
|
||||||
|
-H "Authorization: Bearer <admin-key>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Period values:**
|
||||||
|
- `day` — last 24 hours, bucketed by hour
|
||||||
|
- `week` — last 7 days, bucketed by day
|
||||||
|
- `month` — last 30 days, bucketed by day (default)
|
||||||
|
- `all` — all time, bucketed by month
|
||||||
|
|
||||||
|
**Response format:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"usage": [
|
||||||
|
{
|
||||||
|
"bucket": "2026-03-18",
|
||||||
|
"model": "gpt-4",
|
||||||
|
"user_id": "abc-123",
|
||||||
|
"user_name": "Alice",
|
||||||
|
"prompt_tokens": 1500,
|
||||||
|
"completion_tokens": 800,
|
||||||
|
"total_tokens": 2300,
|
||||||
|
"request_count": 12
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"totals": {
|
||||||
|
"prompt_tokens": 1500,
|
||||||
|
"completion_tokens": 800,
|
||||||
|
"total_tokens": 2300,
|
||||||
|
"request_count": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage Dashboard
|
||||||
|
|
||||||
|
The web UI Usage page provides:
|
||||||
|
- **Period selector** — switch between day, week, month, and all-time views
|
||||||
|
- **Summary cards** — total requests, prompt tokens, completion tokens, total tokens
|
||||||
|
- **By Model table** — per-model breakdown with visual usage bars
|
||||||
|
- **By User table** (admin only) — per-user breakdown across all models
|
||||||
|
|
||||||
|
## Combining Auth Modes
|
||||||
|
|
||||||
|
Legacy API keys and user authentication can be used simultaneously. When both are configured:
|
||||||
|
|
||||||
|
1. User sessions and user API keys are checked first
|
||||||
|
2. Legacy API keys are checked as fallback — they grant **admin-level access**
|
||||||
|
3. This allows a gradual migration from shared API keys to per-user accounts
|
||||||
|
|
||||||
|
## Build Requirements
|
||||||
|
|
||||||
|
The user authentication system requires CGO for SQLite support. It is enabled with the `auth` build tag, which is included by default in Docker builds.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Building from source with auth support
|
||||||
|
GO_TAGS=auth make build
|
||||||
|
|
||||||
|
# Or directly with go build
|
||||||
|
go build -tags auth ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
The default Dockerfile includes `GO_TAGS="auth"`, so all Docker images ship with auth support. When building from source without the `auth` tag, setting `LOCALAI_AUTH=true` has no effect — the system operates without authentication.
|
||||||
|
|
@ -63,6 +63,8 @@ You can configure these settings via the web UI or through environment variables
|
||||||
- **CSRF**: Enable CSRF protection middleware
|
- **CSRF**: Enable CSRF protection middleware
|
||||||
- **API Keys**: Manage API keys for authentication (one per line or comma-separated)
|
- **API Keys**: Manage API keys for authentication (one per line or comma-separated)
|
||||||
|
|
||||||
|
For multi-user authentication with roles, OAuth, and usage tracking, see [Authentication & Authorization]({{%relref "features/authentication" %}}).
|
||||||
|
|
||||||
### P2P Settings
|
### P2P Settings
|
||||||
|
|
||||||
Configure peer-to-peer networking for distributed inference:
|
Configure peer-to-peer networking for distributed inference:
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,10 @@ icon = "rocket_launch"
|
||||||
|
|
||||||
**Security considerations**
|
**Security considerations**
|
||||||
|
|
||||||
If you are exposing LocalAI remotely, make sure you protect the API endpoints adequately with a mechanism which allows to protect from the incoming traffic or alternatively, run LocalAI with `API_KEY` to gate the access with an API key. The API key guarantees a total access to the features (there is no role separation), and it is to be considered as likely as an admin role.
|
If you are exposing LocalAI remotely, make sure you protect the API endpoints adequately. You have two options:
|
||||||
|
|
||||||
|
- **Simple API keys**: Run with `LOCALAI_API_KEY=your-key` to gate access. API keys grant full admin access with no role separation.
|
||||||
|
- **User authentication**: Run with `LOCALAI_AUTH=true` for multi-user support with admin/user roles, OAuth login, per-user API keys, and usage tracking. See [Authentication & Authorization]({{%relref "features/authentication" %}}) for details.
|
||||||
|
|
||||||
{{% /notice %}}
|
{{% /notice %}}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,12 +90,17 @@ The `/v1/responses` endpoint returns errors with this structure:
|
||||||
|
|
||||||
### Authentication Errors (401)
|
### Authentication Errors (401)
|
||||||
|
|
||||||
When API keys are configured (via `LOCALAI_API_KEY` or `--api-keys`), all requests must include a valid key. Keys can be provided through:
|
When authentication is enabled — either via API keys (`LOCALAI_API_KEY`) or the user auth system (`LOCALAI_AUTH=true`) — API requests must include valid credentials. Credentials can be provided through:
|
||||||
|
|
||||||
- `Authorization: Bearer <key>` header
|
- `Authorization: Bearer <key>` header (API key, user API key, or session ID)
|
||||||
- `x-api-key: <key>` header
|
- `x-api-key: <key>` header
|
||||||
- `xi-api-key: <key>` header
|
- `xi-api-key: <key>` header
|
||||||
- `token` cookie
|
- `session` cookie (user auth sessions)
|
||||||
|
- `token` cookie (legacy API keys)
|
||||||
|
|
||||||
|
### Authorization Errors (403)
|
||||||
|
|
||||||
|
When user authentication is enabled, admin-only endpoints (model management, system settings, traces, agents, etc.) return 403 if accessed by a non-admin user. See [Authentication & Authorization]({{%relref "features/authentication" %}}) for the full list of admin-only endpoints.
|
||||||
|
|
||||||
**Example request without a key:**
|
**Example request without a key:**
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,24 @@ For more information on VRAM management, see [VRAM and Memory Management]({{%rel
|
||||||
| `--disable-api-key-requirement-for-http-get` | `false` | If true, a valid API key is not required to issue GET requests to portions of the web UI. This should only be enabled in secure testing environments | `$LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET` |
|
| `--disable-api-key-requirement-for-http-get` | `false` | If true, a valid API key is not required to issue GET requests to portions of the web UI. This should only be enabled in secure testing environments | `$LOCALAI_DISABLE_API_KEY_REQUIREMENT_FOR_HTTP_GET` |
|
||||||
| `--http-get-exempted-endpoints` | `^/$,^/app(/.*)?$,^/browse(/.*)?$,^/login/?$,^/explorer/?$,^/assets/.*$,^/static/.*$,^/swagger.*$` | If `--disable-api-key-requirement-for-http-get` is overridden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review | `$LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS` |
|
| `--http-get-exempted-endpoints` | `^/$,^/app(/.*)?$,^/browse(/.*)?$,^/login/?$,^/explorer/?$,^/assets/.*$,^/static/.*$,^/swagger.*$` | If `--disable-api-key-requirement-for-http-get` is overridden to true, this is the list of endpoints to exempt. Only adjust this in case of a security incident or as a result of a personal security posture review | `$LOCALAI_HTTP_GET_EXEMPTED_ENDPOINTS` |
|
||||||
|
|
||||||
|
## Authentication Flags
|
||||||
|
|
||||||
|
| Parameter | Default | Description | Environment Variable |
|
||||||
|
|-----------|---------|-------------|----------------------|
|
||||||
|
| `--auth-enabled` | `false` | Enable user authentication and authorization | `$LOCALAI_AUTH` |
|
||||||
|
| `--auth-database-url` | `{DataPath}/database.db` | Database URL for auth — `postgres://...` for PostgreSQL, or a file path for SQLite | `$LOCALAI_AUTH_DATABASE_URL`, `$DATABASE_URL` |
|
||||||
|
| `--github-client-id` | | GitHub OAuth App Client ID (auto-enables auth when set) | `$GITHUB_CLIENT_ID` |
|
||||||
|
| `--github-client-secret` | | GitHub OAuth App Client Secret | `$GITHUB_CLIENT_SECRET` |
|
||||||
|
| `--oidc-issuer` | | OIDC issuer URL for auto-discovery | `$LOCALAI_OIDC_ISSUER` |
|
||||||
|
| `--oidc-client-id` | | OIDC Client ID (auto-enables auth when set) | `$LOCALAI_OIDC_CLIENT_ID` |
|
||||||
|
| `--oidc-client-secret` | | OIDC Client Secret | `$LOCALAI_OIDC_CLIENT_SECRET` |
|
||||||
|
| `--auth-base-url` | | Base URL for OAuth callbacks (e.g. `http://localhost:8080`) | `$LOCALAI_BASE_URL` |
|
||||||
|
| `--auth-admin-email` | | Email address to auto-promote to admin role on login | `$LOCALAI_ADMIN_EMAIL` |
|
||||||
|
| `--auth-registration-mode` | `open` | Registration mode: `open`, `approval`, or `invite` | `$LOCALAI_REGISTRATION_MODE` |
|
||||||
|
| `--disable-local-auth` | `false` | Disable local email/password registration and login (for OAuth/OIDC-only setups) | `$LOCALAI_DISABLE_LOCAL_AUTH` |
|
||||||
|
|
||||||
|
See [Authentication & Authorization]({{%relref "features/authentication" %}}) for full documentation.
|
||||||
|
|
||||||
## P2P Flags
|
## P2P Flags
|
||||||
|
|
||||||
| Parameter | Default | Description | Environment Variable |
|
| Parameter | Default | Description | Environment Variable |
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue