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:
Ettore Di Giacinto
2026-03-19 21:40:51 +01:00
committed by GitHub
parent bbe9067227
commit aea21951a2
102 changed files with 13369 additions and 1421 deletions

View File

@@ -256,7 +256,7 @@ RUN apt-get update && \
FROM build-requirements AS builder-base
ARG GO_TAGS=""
ARG GO_TAGS="auth"
ARG GRPC_BACKENDS
ARG MAKEFLAGS
ARG LD_FLAGS="-s -w"

View File

@@ -11,6 +11,7 @@ import (
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
"gorm.io/gorm"
)
type Application struct {
@@ -22,6 +23,7 @@ type Application struct {
galleryService *services.GalleryService
agentJobService *services.AgentJobService
agentPoolService atomic.Pointer[services.AgentPoolService]
authDB *gorm.DB
watchdogMutex sync.Mutex
watchdogStop chan bool
p2pMutex sync.Mutex
@@ -74,6 +76,11 @@ func (a *Application) AgentPoolService() *services.AgentPoolService {
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)
func (a *Application) StartupConfig() *config.ApplicationConfig {
return a.startupConfig
@@ -118,9 +125,23 @@ func (a *Application) StartAgentPool() {
xlog.Error("Failed to create agent pool service", "error", err)
return
}
if a.authDB != nil {
aps.SetAuthDB(a.authDB)
}
if err := aps.Start(a.applicationConfig.Context); err != nil {
xlog.Error("Failed to start agent pool", "error", err)
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)
}

View File

@@ -207,7 +207,7 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand
envF16 := appConfig.F16 == startupAppConfig.F16
envDebug := appConfig.Debug == startupAppConfig.Debug
envCORS := appConfig.CORS == startupAppConfig.CORS
envCSRF := appConfig.CSRF == startupAppConfig.CSRF
envCSRF := appConfig.DisableCSRF == startupAppConfig.DisableCSRF
envCORSAllowOrigins := appConfig.CORSAllowOrigins == startupAppConfig.CORSAllowOrigins
envP2PToken := appConfig.P2PToken == startupAppConfig.P2PToken
envP2PNetworkID := appConfig.P2PNetworkID == startupAppConfig.P2PNetworkID
@@ -313,7 +313,7 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand
appConfig.CORS = *settings.CORS
}
if settings.CSRF != nil && !envCSRF {
appConfig.CSRF = *settings.CSRF
appConfig.DisableCSRF = *settings.CSRF
}
if settings.CORSAllowOrigins != nil && !envCORSAllowOrigins {
appConfig.CORSAllowOrigins = *settings.CORSAllowOrigins

View File

@@ -1,6 +1,8 @@
package application
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
@@ -10,6 +12,7 @@ import (
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services"
coreStartup "github.com/mudler/LocalAI/core/startup"
"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 {
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
// to the new data directory. Only moves files that exist in src but not in dst.
func migrateDataFiles(srcDir, dstDir string) {

View File

@@ -58,7 +58,7 @@ type RunCMD struct {
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"`
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"`
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"`
@@ -121,6 +121,21 @@ type RunCMD struct {
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"`
// 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
}
@@ -165,7 +180,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
config.WithBackendGalleries(r.BackendGalleries),
config.WithCors(r.CORS),
config.WithCorsAllowOrigins(r.CORSAllowOrigins),
config.WithCsrf(r.CSRF),
config.WithDisableCSRF(r.DisableCSRF),
config.WithThreads(r.Threads),
config.WithUploadLimitMB(r.UploadLimit),
config.WithApiKeys(r.APIKeys),
@@ -311,6 +326,46 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
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 {
opts = append(opts, config.EnableWatchDog)
if idleWatchDog {

View File

@@ -30,7 +30,7 @@ type ApplicationConfig struct {
DynamicConfigsDir string
DynamicConfigsDirPollInterval time.Duration
CORS bool
CSRF bool
DisableCSRF bool
PreloadJSONModels string
PreloadModelsFromPath string
CORSAllowOrigins string
@@ -96,6 +96,26 @@ type ApplicationConfig struct {
// Agent Pool (LocalAGI integration)
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.
@@ -150,6 +170,8 @@ func NewApplicationConfig(o ...AppOption) *ApplicationConfig {
"/favicon.svg",
"/readyz",
"/healthz",
"/api/auth/",
"/assets/",
},
}
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) {
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.
// Some options defined at the application level are going to be passed as defaults for
// all the configuration for the models.
@@ -750,7 +852,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
enableTracing := o.EnableTracing
enableBackendLogging := o.EnableBackendLogging
cors := o.CORS
csrf := o.CSRF
csrf := o.DisableCSRF
corsAllowOrigins := o.CORSAllowOrigins
p2pToken := o.P2PToken
p2pNetworkID := o.P2PNetworkID
@@ -958,7 +1060,7 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
o.CORS = *settings.CORS
}
if settings.CSRF != nil {
o.CSRF = *settings.CSRF
o.DisableCSRF = *settings.CSRF
}
if settings.CORSAllowOrigins != nil {
o.CORSAllowOrigins = *settings.CORSAllowOrigins

View File

@@ -26,7 +26,7 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
F16: true,
Debug: true,
CORS: true,
CSRF: true,
DisableCSRF: true,
CORSAllowOrigins: "https://example.com",
P2PToken: "test-token",
P2PNetworkID: "test-network",
@@ -377,7 +377,7 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
appConfig.ApplyRuntimeSettings(rs)
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"))
})
@@ -463,7 +463,7 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
F16: true,
Debug: false,
CORS: true,
CSRF: false,
DisableCSRF: false,
CORSAllowOrigins: "https://test.com",
P2PToken: "round-trip-token",
P2PNetworkID: "round-trip-network",
@@ -495,7 +495,7 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
Expect(target.F16).To(Equal(original.F16))
Expect(target.Debug).To(Equal(original.Debug))
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.P2PToken).To(Equal(original.P2PToken))
Expect(target.P2PNetworkID).To(Equal(original.P2PNetworkID))

View File

@@ -14,6 +14,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/http/endpoints/localai"
httpMiddleware "github.com/mudler/LocalAI/core/http/middleware"
"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
routes.HealthRoutes(e)
// Get key auth middleware
keyAuthMiddleware, err := httpMiddleware.GetKeyAuthConfig(application.ApplicationConfig())
if err != nil {
return nil, fmt.Errorf("failed to create key auth config: %w", err)
}
// Build auth middleware: use the new auth.Middleware when auth is enabled or
// as a unified replacement for the legacy key-auth middleware.
authMiddleware := auth.Middleware(application.AuthDB(), application.ApplicationConfig())
// Favicon handler
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)
}
// Auth is applied to _all_ endpoints. No exceptions. Filtering out endpoints to bypass is the role of the Skipper property of the KeyAuth Configuration
e.Use(keyAuthMiddleware)
// Initialize usage recording when auth DB is available
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
if application.ApplicationConfig().CORS {
@@ -223,14 +234,63 @@ func API(application *application.Application) (*echo.Echo, error) {
e.Use(middleware.CORS())
}
// CSRF middleware
if application.ApplicationConfig().CSRF {
xlog.Debug("Enabling CSRF middleware. Tokens are now required for state-modifying requests")
e.Use(middleware.CSRF())
// CSRF middleware (enabled by default, disable with LOCALAI_DISABLE_CSRF=true)
//
// Protection relies on Echo's Sec-Fetch-Site header check (supported by all
// 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())
// Register auth routes (login, callback, API keys, user management)
routes.RegisterAuthRoutes(e, application)
routes.RegisterElevenLabsRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
// 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())
}
routes.RegisterLocalAIRoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application.TemplatesEvaluator(), application)
routes.RegisterAgentPoolRoutes(e, application)
mcpMw := auth.RequireFeature(application.AuthDB(), auth.FeatureMCP)
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.RegisterAnthropicRoutes(e, requestExtractor, application)
routes.RegisterOpenResponsesRoutes(e, requestExtractor, application)
if !application.ApplicationConfig().DisableWebUI {
routes.RegisterUIAPIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService(), opcache, application)
routes.RegisterUIRoutes(e, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig(), application.GalleryService())
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(), adminMiddleware)
// Serve React SPA from / with SPA fallback via 404 handler
reactFS, fsErr := fs.Sub(reactUI, "react-ui/dist")

View File

@@ -428,8 +428,10 @@ var _ = Describe("API test", func() {
"X-Forwarded-Prefix": {"/myprefix/"},
})
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(`<div id="root">`), "should serve React SPA")
})
It("Should support reverse-proxy when authenticated", func() {

121
core/http/auth/apikeys.go Normal file
View 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
}

View 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())
})
})
})

View 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
View 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
}

View 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")
}

View 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
View 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
View 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},
}
}

View 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})
}
}

View 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",
},
})
}

View 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
View 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
View 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
}

View 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
}

View 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
View 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
}

View 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
View 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)
}

View 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
View 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
}

View 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"))
}
})
})
})

View File

@@ -12,27 +12,54 @@ import (
func ListCollectionsEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
collections, err := svc.ListCollections()
userID := getUserID(c)
cols, err := svc.ListCollectionsForUser(userID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]any{
"collections": collections,
"count": len(collections),
})
resp := map[string]any{
"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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := getUserID(c)
var payload struct {
Name string `json:"name"`
}
if err := c.Bind(&payload); err != nil {
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.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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
file, err := c.FormFile("file")
if err != nil {
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()
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
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") {
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 {
return func(c echo.Context) error {
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 strings.Contains(err.Error(), "not found") {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
entryParam := c.Param("*")
entry, err := url.PathUnescape(entryParam)
if err != nil {
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 strings.Contains(err.Error(), "not found") {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
var payload struct {
Query string `json:"query"`
MaxResults int `json:"max_results"`
@@ -114,7 +142,7 @@ func SearchCollectionEndpoint(app *application.Application) echo.HandlerFunc {
if err := c.Bind(&payload); err != nil {
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 strings.Contains(err.Error(), "not found") {
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 {
return func(c echo.Context) error {
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") {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
var payload struct {
Entry string `json:"entry"`
}
if err := c.Bind(&payload); err != nil {
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 strings.Contains(err.Error(), "not found") {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
var payload struct {
URL string `json:"url"`
UpdateInterval int `json:"update_interval"`
@@ -177,7 +208,7 @@ func AddCollectionSourceEndpoint(app *application.Application) echo.HandlerFunc
if payload.UpdateInterval < 1 {
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") {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
var payload struct {
URL string `json:"url"`
}
if err := c.Bind(&payload); err != nil {
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.StatusOK, map[string]string{"status": "ok"})
@@ -207,12 +239,13 @@ func RemoveCollectionSourceEndpoint(app *application.Application) echo.HandlerFu
func GetCollectionEntryRawFileEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
entryParam := c.Param("*")
entry, err := url.PathUnescape(entryParam)
if err != nil {
entry = entryParam
}
fpath, err := svc.GetCollectionEntryFilePath(c.Param("name"), entry)
fpath, err := svc.GetCollectionEntryFilePathForUser(userID, c.Param("name"), entry)
if err != nil {
if strings.Contains(err.Error(), "not found") {
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 {
return func(c echo.Context) error {
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 strings.Contains(err.Error(), "not found") {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})

View File

@@ -8,19 +8,27 @@ import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services"
)
// CreateTaskEndpoint creates a new agent task
// @Summary Create a new agent task
// @Description Create a new reusable agent task with prompt template and configuration
// @Tags agent-jobs
// @Accept json
// @Produce json
// @Param task body schema.Task true "Task definition"
// @Success 201 {object} map[string]string "Task created"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 500 {object} map[string]string "Internal server error"
// @Router /api/agent/tasks [post]
// getJobService returns the job service for the current user.
// Falls back to the global service when no user is authenticated.
func getJobService(app *application.Application, c echo.Context) *services.AgentJobService {
userID := getUserID(c)
if userID == "" {
return app.AgentJobService()
}
svc := app.AgentPoolService()
if svc == nil {
return app.AgentJobService()
}
jobSvc, err := svc.JobServiceForUser(userID)
if err != nil {
return app.AgentJobService()
}
return jobSvc
}
func CreateTaskEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
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()})
}
id, err := app.AgentJobService().CreateTask(task)
id, err := getJobService(app, c).CreateTask(task)
if err != nil {
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 {
return func(c echo.Context) error {
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()})
}
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 {
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 {
return func(c echo.Context) error {
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 {
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 {
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)
}
}
// 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 {
return func(c echo.Context) error {
id := c.Param("id")
task, err := app.AgentJobService().GetTask(id)
task, err := getJobService(app, c).GetTask(id)
if err != nil {
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 {
return func(c echo.Context) error {
var req schema.JobExecutionRequest
@@ -147,7 +143,6 @@ func ExecuteJobEndpoint(app *application.Application) echo.HandlerFunc {
req.Parameters = make(map[string]string)
}
// Build multimedia struct from request
var multimedia *schema.MultimediaAttachment
if len(req.Images) > 0 || len(req.Videos) > 0 || len(req.Audios) > 0 || len(req.Files) > 0 {
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 {
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 {
return func(c echo.Context) error {
id := c.Param("id")
job, err := app.AgentJobService().GetJob(id)
job, err := getJobService(app, c).GetJob(id)
if err != nil {
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 {
return func(c echo.Context) error {
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)
}
}
// 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 {
return func(c echo.Context) error {
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 {
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 {
return func(c echo.Context) error {
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 {
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 {
return func(c echo.Context) error {
name := c.Param("name")
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 err := c.Bind(&params); err != nil {
// If binding fails, try to read as raw JSON
body := make(map[string]interface{})
if err := c.Bind(&body); err == nil {
// Convert interface{} values to strings
params = make(map[string]string)
for k, v := range body {
if str, ok := v.(string); ok {
params[k] = str
} else {
// Convert non-string values to string
params[k] = fmt.Sprintf("%v", v)
}
}
} else {
// If all binding fails, use empty params
params = make(map[string]string)
}
}
} else {
// No body provided, use empty params
params = make(map[string]string)
}
// Find task by name
tasks := app.AgentJobService().ListTasks()
jobSvc := getJobService(app, c)
tasks := jobSvc.ListTasks()
var task *schema.Task
for _, t := range tasks {
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})
}
jobID, err := app.AgentJobService().ExecuteJob(task.ID, params, "api", nil)
jobID, err := jobSvc.ExecuteJob(task.ID, params, "api", nil)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}

View File

@@ -44,10 +44,38 @@ func skillsToResponses(skills []skilldomain.Skill) []skillResponse {
func ListSkillsEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
skills, err := svc.ListSkills()
userID := getUserID(c)
skills, err := svc.ListSkillsForUser(userID)
if err != nil {
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))
}
}
@@ -55,7 +83,8 @@ func ListSkillsEndpoint(app *application.Application) echo.HandlerFunc {
func GetSkillsConfigEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
cfg := svc.GetSkillsConfig()
userID := getUserID(c)
cfg := svc.GetSkillsConfigForUser(userID)
return c.JSON(http.StatusOK, cfg)
}
}
@@ -63,8 +92,9 @@ func GetSkillsConfigEndpoint(app *application.Application) echo.HandlerFunc {
func SearchSkillsEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := getUserID(c)
query := c.QueryParam("q")
skills, err := svc.SearchSkills(query)
skills, err := svc.SearchSkillsForUser(userID, query)
if err != nil {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := getUserID(c)
var payload struct {
Name string `json:"name"`
Description string `json:"description"`
@@ -87,7 +118,7 @@ func CreateSkillEndpoint(app *application.Application) echo.HandlerFunc {
if err := c.Bind(&payload); err != nil {
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 strings.Contains(err.Error(), "already exists") {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
skill, err := svc.GetSkill(c.Param("name"))
userID := effectiveUserID(c)
skill, err := svc.GetSkillForUser(userID, c.Param("name"))
if err != nil {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
var payload struct {
Description string `json:"description"`
Content string `json:"content"`
@@ -123,7 +156,7 @@ func UpdateSkillEndpoint(app *application.Application) echo.HandlerFunc {
if err := c.Bind(&payload); err != nil {
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 strings.Contains(err.Error(), "not found") {
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 {
return func(c echo.Context) error {
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.StatusOK, map[string]string{"status": "ok"})
@@ -147,9 +181,9 @@ func DeleteSkillEndpoint(app *application.Application) echo.HandlerFunc {
func ExportSkillEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
// The wildcard param captures the path after /export/
userID := effectiveUserID(c)
name := c.Param("*")
data, err := svc.ExportSkill(name)
data, err := svc.ExportSkillForUser(userID, name)
if err != nil {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := getUserID(c)
file, err := c.FormFile("file")
if err != nil {
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 {
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 {
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 {
return func(c echo.Context) error {
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 {
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 {
return func(c echo.Context) error {
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 {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := getUserID(c)
file, err := c.FormFile("file")
if err != nil {
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 {
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.StatusCreated, map[string]string{"path": path})
@@ -272,13 +310,14 @@ func CreateSkillResourceEndpoint(app *application.Application) echo.HandlerFunc
func UpdateSkillResourceEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := getUserID(c)
var payload struct {
Content string `json:"content"`
}
if err := c.Bind(&payload); err != nil {
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.StatusOK, map[string]string{"status": "ok"})
@@ -288,7 +327,8 @@ func UpdateSkillResourceEndpoint(app *application.Application) echo.HandlerFunc
func DeleteSkillResourceEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
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.StatusOK, map[string]string{"status": "ok"})
@@ -300,7 +340,8 @@ func DeleteSkillResourceEndpoint(app *application.Application) echo.HandlerFunc
func ListGitReposEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
repos, err := svc.ListGitRepos()
userID := getUserID(c)
repos, err := svc.ListGitReposForUser(userID)
if err != nil {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := getUserID(c)
var payload struct {
URL string `json:"url"`
}
if err := c.Bind(&payload); err != nil {
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 {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := getUserID(c)
var payload struct {
URL string `json:"url"`
Enabled *bool `json:"enabled"`
@@ -335,7 +378,7 @@ func UpdateGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
if err := c.Bind(&payload); err != nil {
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 strings.Contains(err.Error(), "not found") {
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 {
return func(c echo.Context) error {
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") {
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 {
return func(c echo.Context) error {
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.StatusAccepted, map[string]string{"status": "syncing"})
@@ -372,7 +417,8 @@ func SyncGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
func ToggleGitRepoEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
repo, err := svc.ToggleGitRepo(c.Param("id"))
userID := getUserID(c)
repo, err := svc.ToggleGitRepoForUser(userID, c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/pkg/utils"
"github.com/mudler/LocalAGI/core/state"
@@ -19,10 +20,42 @@ import (
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
statuses := svc.ListAgents()
userID := getUserID(c)
statuses := svc.ListAgentsForUser(userID)
agents := make([]string, 0, len(statuses))
for name := range statuses {
agents = append(agents, name)
@@ -38,6 +71,22 @@ func ListAgentsEndpoint(app *application.Application) echo.HandlerFunc {
if hubURL := svc.AgentHubURL(); 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)
}
}
@@ -45,11 +94,12 @@ func ListAgentsEndpoint(app *application.Application) echo.HandlerFunc {
func CreateAgentEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := getUserID(c)
var cfg state.AgentConfig
if err := c.Bind(&cfg); err != nil {
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.StatusCreated, map[string]string{"status": "ok"})
@@ -59,8 +109,9 @@ func CreateAgentEndpoint(app *application.Application) echo.HandlerFunc {
func GetAgentEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
ag := svc.GetAgent(name)
ag := svc.GetAgentForUser(userID, name)
if ag == nil {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
var cfg state.AgentConfig
if err := c.Bind(&cfg); err != nil {
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") {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
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.StatusOK, map[string]string{"status": "ok"})
@@ -102,8 +155,9 @@ func DeleteAgentEndpoint(app *application.Application) echo.HandlerFunc {
func GetAgentConfigEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
cfg := svc.GetAgentConfig(name)
cfg := svc.GetAgentConfigForUser(userID, name)
if cfg == nil {
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 {
return func(c echo.Context) error {
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.StatusOK, map[string]string{"status": "ok"})
@@ -124,7 +179,8 @@ func PauseAgentEndpoint(app *application.Application) echo.HandlerFunc {
func ResumeAgentEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
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.StatusOK, map[string]string{"status": "ok"})
@@ -134,8 +190,9 @@ func ResumeAgentEndpoint(app *application.Application) echo.HandlerFunc {
func GetAgentStatusEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
history := svc.GetAgentStatus(name)
history := svc.GetAgentStatusForUser(userID, name)
if history == nil {
history = &state.Status{ActionResults: []coreTypes.ActionState{}}
}
@@ -162,8 +219,9 @@ func GetAgentStatusEndpoint(app *application.Application) echo.HandlerFunc {
func GetAgentObservablesEndpoint(app *application.Application) echo.HandlerFunc {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
history, err := svc.GetAgentObservables(name)
history, err := svc.GetAgentObservablesForUser(userID, name)
if err != nil {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
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.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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
var payload struct {
Message string `json:"message"`
@@ -199,7 +259,7 @@ func ChatWithAgentEndpoint(app *application.Application) echo.HandlerFunc {
if message == "" {
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 strings.Contains(err.Error(), "not found") {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
manager := svc.GetSSEManager(name)
manager := svc.GetSSEManagerForUser(userID, name)
if manager == nil {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := effectiveUserID(c)
name := c.Param("name")
data, err := svc.ExportAgent(name)
data, err := svc.ExportAgentForUser(userID, name)
if err != nil {
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 {
return func(c echo.Context) error {
svc := app.AgentPoolService()
userID := getUserID(c)
// Try multipart form file first
file, err := c.FormFile("file")
@@ -269,7 +332,7 @@ func ImportAgentEndpoint(app *application.Application) echo.HandlerFunc {
if err != nil {
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.StatusCreated, map[string]string{"status": "ok"})
@@ -284,7 +347,7 @@ func ImportAgentEndpoint(app *application.Application) echo.HandlerFunc {
if err != nil {
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.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"})
}
// Only serve files from the outputs subdirectory
outputsDir, _ := filepath.EvalSymlinks(filepath.Clean(svc.OutputsDir()))
// Determine the allowed outputs directory — scoped to the user when auth is active
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"})
}

View File

@@ -3,16 +3,22 @@ package openai
import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services"
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
// @Summary List and describe the various models available in the API.
// @Success 200 {object} schema.ModelsDataResponse "Response"
// @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 {
// If blank, no filter is applied.
filter := c.QueryParam("filter")
@@ -36,6 +42,26 @@ func ListModelsEndpoint(bcl *config.ModelConfigLoader, ml *model.ModelLoader, ap
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
dataModels := []schema.OpenAIModel{}
for _, m := range modelNames {

View File

@@ -2,15 +2,16 @@ package middleware
import (
"bytes"
"github.com/emirpasic/gods/v2/queues/circularbuffer"
"io"
"net/http"
"sort"
"sync"
"time"
"github.com/emirpasic/gods/v2/queues/circularbuffer"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/http/auth"
"github.com/mudler/xlog"
)
@@ -33,6 +34,8 @@ type APIExchange struct {
Request APIExchangeRequest `json:"request"`
Response APIExchangeResponse `json:"response"`
Error string `json:"error,omitempty"`
UserID string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
}
var traceBuffer *circularbuffer.Queue[APIExchange]
@@ -147,6 +150,11 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
exchange.Error = handlerErr.Error()
}
if user := auth.GetUser(c); user != nil {
exchange.UserID = user.ID
exchange.UserName = user.Name
}
select {
case logChan <- exchange:
default:

View 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
}

View File

@@ -9,7 +9,7 @@ test.describe('Navigation', () => {
test('/app shows home page with LocalAI title', async ({ page }) => {
await page.goto('/app')
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 }) => {

View File

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,11 @@ export default function App() {
return () => window.removeEventListener('sidebar-collapse', handler)
}, [])
// Scroll to top on route change
useEffect(() => {
window.scrollTo(0, 0)
}, [location.pathname])
const layoutClasses = [
'app-layout',
isChatRoute ? 'app-layout-chat' : '',
@@ -51,7 +56,9 @@ export default function App() {
<span className="mobile-title">LocalAI</span>
</header>
<div className="main-content-inner">
<Outlet context={{ addToast }} />
<div className="page-transition" key={location.pathname}>
<Outlet context={{ addToast }} />
</div>
</div>
{!isChatRoute && (
<footer className="app-footer">

View 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>
)
}

View File

@@ -1,8 +1,22 @@
import { useState } from 'react'
import { apiUrl } from '../utils/basePath'
export default function LoadingSpinner({ size = 'md', className = '' }) {
const sizeClass = size === 'sm' ? 'spinner-sm' : size === 'lg' ? 'spinner-lg' : 'spinner-md'
const [imgFailed, setImgFailed] = useState(false)
return (
<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>
)
}

View File

@@ -1,18 +1,66 @@
import { useEffect, useRef } from 'react'
import '../pages/auth.css'
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 (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--color-modal-backdrop)', backdropFilter: 'blur(4px)',
}} onClick={onClose}>
<div style={{
background: 'var(--color-bg-secondary)',
border: '1px solid var(--color-border-subtle)',
borderRadius: 'var(--radius-lg)',
maxWidth, width: '90%', maxHeight: '80vh',
display: 'flex', flexDirection: 'column',
overflow: 'auto',
}} onClick={e => e.stopPropagation()}>
<div
role="dialog"
aria-modal="true"
className="modal-backdrop"
onClick={onClose}
>
<div
ref={dialogRef}
className="modal-panel"
style={{ maxWidth }}
onClick={e => e.stopPropagation()}
>
{children}
</div>
</div>

View 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
}

View 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
}

View 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
}

View File

@@ -41,7 +41,10 @@ export default function ResourceCards({ metadata, onOpenArtifact, messageIndex,
<div
key={item.id}
className={`resource-card resource-card-${item.type}`}
role="button"
tabIndex={0}
onClick={() => onOpenArtifact && onOpenArtifact(item.id)}
onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && onOpenArtifact) { e.preventDefault(); onOpenArtifact(item.id) } }}
>
{item.type === 'image' ? (
<img src={item.url} alt={item.title} className="resource-card-thumb" />

View File

@@ -89,7 +89,8 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
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;
}
.sms-item {
@@ -115,6 +116,8 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
`}</style>
<input
className="input"
aria-haspopup="listbox"
aria-expanded={open}
value={query}
onChange={(e) => {
setQuery(e.target.value)
@@ -128,7 +131,7 @@ export default function SearchableModelSelect({ value, onChange, capability, pla
placeholder={loading ? 'Loading models...' : placeholder}
/>
{open && !loading && (
<div className="sms-dropdown" ref={listRef}>
<div className="sms-dropdown" ref={listRef} role="listbox">
{filtered.length === 0 ? (
<div className="sms-empty">
{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) => (
<div
key={m.id}
role="option"
aria-selected={m.id === value}
className={`sms-item${i === focusIndex ? ' sms-focused' : ''}${m.id === value ? ' sms-active' : ''}`}
onMouseEnter={() => setFocusIndex(i)}
onMouseDown={(e) => {

View File

@@ -93,6 +93,8 @@ export default function SearchableSelect({
ref={buttonRef}
type="button"
className="input"
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => { if (!disabled) { setOpen(!open); setQuery(''); setFocusIndex(-1) } }}
style={{
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,
minWidth: 200, maxHeight: 260, background: 'var(--color-bg-secondary)',
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)' }}>
<input
@@ -126,9 +129,11 @@ export default function SearchableSelect({
style={{ width: '100%', padding: '4px 8px', fontSize: '0.8125rem' }}
/>
</div>
<div ref={listRef} style={{ overflowY: 'auto', maxHeight: 200 }}>
<div ref={listRef} role="listbox" style={{ overflowY: 'auto', maxHeight: 200 }}>
{allOption && (
<div
role="option"
aria-selected={!value}
onClick={() => select('')}
style={itemStyle(!value, focusIndex === -1 && enterTarget?.type === 'all')}
onMouseEnter={() => setFocusIndex(-1)}
@@ -146,6 +151,8 @@ export default function SearchableSelect({
return (
<div
key={o.value}
role="option"
aria-selected={isActive}
onClick={() => select(o.value)}
style={itemStyle(isActive, isFocused)}
onMouseEnter={() => setFocusIndex(i)}

View File

@@ -1,19 +1,21 @@
import { useState, useEffect } from 'react'
import { NavLink } from 'react-router-dom'
import { NavLink, useNavigate } from 'react-router-dom'
import ThemeToggle from './ThemeToggle'
import { useAuth } from '../context/AuthContext'
import { apiUrl } from '../utils/basePath'
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
const mainItems = [
{ 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/image', icon: 'fas fa-image', label: 'Images' },
{ path: '/app/video', icon: 'fas fa-video', label: 'Video' },
{ path: '/app/tts', icon: 'fas fa-music', label: 'TTS' },
{ path: '/app/sound', icon: 'fas fa-volume-high', label: 'Sound' },
{ path: '/app/talk', icon: 'fas fa-phone', label: 'Talk' },
{ path: '/app/usage', icon: 'fas fa-chart-bar', label: 'Usage', authOnly: true },
]
const agentItems = [
@@ -24,11 +26,12 @@ const agentItems = [
]
const systemItems = [
{ path: '/app/backends', icon: 'fas fa-server', label: 'Backends' },
{ path: '/app/traces', icon: 'fas fa-chart-line', label: 'Traces' },
{ path: '/app/p2p', icon: 'fas fa-circle-nodes', label: 'Swarm' },
{ path: '/app/manage', icon: 'fas fa-desktop', label: 'System' },
{ path: '/app/settings', icon: 'fas fa-cog', label: 'Settings' },
{ path: '/app/users', icon: 'fas fa-users', label: 'Users', adminOnly: true, authOnly: true },
{ path: '/app/backends', icon: 'fas fa-server', label: 'Backends', adminOnly: true },
{ path: '/app/traces', icon: 'fas fa-chart-line', label: 'Traces', adminOnly: true },
{ path: '/app/p2p', icon: 'fas fa-circle-nodes', label: 'Swarm', adminOnly: true },
{ 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 }) {
@@ -53,6 +56,8 @@ export default function Sidebar({ isOpen, onClose }) {
const [collapsed, setCollapsed] = useState(() => {
try { return localStorage.getItem(COLLAPSED_KEY) === 'true' } catch (_) { return false }
})
const { isAdmin, authEnabled, user, logout, hasFeature } = useAuth()
const navigate = useNavigate()
useEffect(() => {
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 (
<>
{isOpen && <div className="sidebar-overlay" onClick={onClose} />}
@@ -89,24 +106,40 @@ export default function Sidebar({ isOpen, onClose }) {
<nav className="sidebar-nav">
{/* Main section */}
<div className="sidebar-section">
{mainItems.map(item => (
{visibleMainItems.map(item => (
<NavItem key={item.path} item={item} onClose={onClose} collapsed={collapsed} />
))}
</div>
{/* Agents section */}
{features.agents !== false && (
<div className="sidebar-section">
<div className="sidebar-section-title">Agents</div>
{agentItems.filter(item => !item.feature || features[item.feature] !== false).map(item => (
<NavItem key={item.path} item={item} onClose={onClose} collapsed={collapsed} />
))}
</div>
)}
{/* Agents section (per-feature permissions) */}
{features.agents !== false && (() => {
const featureMap = {
'/app/agents': 'agents',
'/app/skills': 'skills',
'/app/collections': 'collections',
'/app/agent-jobs': 'mcp_jobs',
}
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 */}
<div className="sidebar-section">
<div className="sidebar-section-title">System</div>
{visibleSystemItems.length > 0 && (
<div className="sidebar-section-title">System</div>
)}
<a
href={apiUrl('/swagger/index.html')}
target="_blank"
@@ -118,7 +151,7 @@ export default function Sidebar({ isOpen, onClose }) {
<span className="nav-label">API</span>
<i className="fas fa-external-link-alt nav-external" />
</a>
{systemItems.map(item => (
{visibleSystemItems.map(item => (
<NavItem key={item.path} item={item} onClose={onClose} collapsed={collapsed} />
))}
</div>
@@ -126,6 +159,25 @@ export default function Sidebar({ isOpen, onClose }) {
{/* 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 />
<button
className="sidebar-collapse-btn"

View File

@@ -10,14 +10,20 @@ export function useToast() {
setToasts(prev => [...prev, { id, message, type }])
if (duration > 0) {
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)
}
return 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 }
@@ -39,7 +45,7 @@ const colorMap = {
export function ToastContainer({ toasts, removeToast }) {
return (
<div className="toast-container">
<div className="toast-container" aria-live="polite" role="status">
{toasts.map(toast => (
<ToastItem key={toast.id} toast={toast} onRemove={removeToast} />
))}
@@ -60,10 +66,10 @@ function ToastItem({ toast, onRemove }) {
}, [])
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'}`} />
<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" />
</button>
</div>

View 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>
)
}

View 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
}

View File

@@ -2,10 +2,15 @@ import { createContext, useContext, useState, useEffect } from 'react'
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 }) {
const [theme, setTheme] = useState(() => {
return localStorage.getItem('localai-theme') || 'dark'
})
const [theme, setTheme] = useState(getInitialTheme)
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)

View File

@@ -1,16 +1,22 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { operationsApi } from '../utils/api'
import { useAuth } from '../context/AuthContext'
export function useOperations(pollInterval = 1000) {
const [operations, setOperations] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const intervalRef = useRef(null)
const { isAdmin } = useAuth()
const previousCountRef = useRef(0)
const onAllCompleteRef = useRef(null)
const fetchOperations = useCallback(async () => {
if (!isAdmin) {
setLoading(false)
return
}
try {
const data = await operationsApi.list()
const ops = data?.operations || (Array.isArray(data) ? data : [])
@@ -32,7 +38,7 @@ export function useOperations(pollInterval = 1000) {
} finally {
setLoading(false)
}
}, [])
}, [isAdmin])
const cancelOperation = useCallback(async (jobID) => {
try {
@@ -57,12 +63,13 @@ export function useOperations(pollInterval = 1000) {
}, [operations, fetchOperations])
useEffect(() => {
if (!isAdmin) return
fetchOperations()
intervalRef.current = setInterval(fetchOperations, pollInterval)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [fetchOperations, pollInterval])
}, [fetchOperations, pollInterval, isAdmin])
// Allow callers to register a callback for when all operations finish
const onAllComplete = useCallback((cb) => {

View 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
}

View File

@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { ThemeProvider } from './contexts/ThemeContext'
import { AuthProvider } from './context/AuthContext'
import { router } from './router'
import '@fortawesome/fontawesome-free/css/all.min.css'
import './index.css'
@@ -11,7 +12,9 @@ import './App.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<ThemeProvider>
<RouterProvider router={router} />
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</ThemeProvider>
</StrictMode>,
)

View 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}... &middot; {formatDate(k.createdAt)}
{k.lastUsed && <> &middot; 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>
)
}

View File

@@ -1,11 +1,12 @@
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 { apiUrl } from '../utils/basePath'
import { renderMarkdown, highlightAll } from '../utils/markdown'
import { extractCodeArtifacts, extractMetadataArtifacts, renderMarkdownWithArtifacts } from '../utils/artifacts'
import CanvasPanel from '../components/CanvasPanel'
import ResourceCards from '../components/ResourceCards'
import ConfirmDialog from '../components/ConfirmDialog'
import { useAgentChat } from '../hooks/useAgentChat'
function relativeTime(ts) {
@@ -86,6 +87,8 @@ export default function AgentChat() {
const { name } = useParams()
const navigate = useNavigate()
const { addToast } = useOutletContext()
const [searchParams] = useSearchParams()
const userId = searchParams.get('user_id') || undefined
const {
conversations, activeConversation, activeId,
@@ -104,6 +107,7 @@ export default function AgentChat() {
const [editingName, setEditingName] = useState(null)
const [editName, setEditName] = useState('')
const [chatSearch, setChatSearch] = useState('')
const [confirmDialog, setConfirmDialog] = useState(null)
const [streamContent, setStreamContent] = useState('')
const [streamReasoning, setStreamReasoning] = useState('')
const [streamToolCalls, setStreamToolCalls] = useState([])
@@ -126,7 +130,7 @@ export default function AgentChat() {
// Connect to SSE endpoint — only reconnect when agent name changes
useEffect(() => {
const url = apiUrl(`/api/agents/${encodeURIComponent(name)}/sse`)
const url = apiUrl(agentsApi.sseUrl(name, userId))
const es = new EventSource(url)
eventSourceRef.current = es
@@ -223,7 +227,7 @@ export default function AgentChat() {
es.close()
eventSourceRef.current = null
}
}, [name, addToast, nextId])
}, [name, userId, addToast, nextId])
// Auto-scroll to bottom
useEffect(() => {
@@ -305,12 +309,12 @@ export default function AgentChat() {
if (textareaRef.current) textareaRef.current.style.height = 'auto'
setProcessingChatId(activeId)
try {
await agentsApi.chat(name, msg)
await agentsApi.chat(name, msg, userId)
} catch (err) {
addToast(`Failed to send message: ${err.message}`, 'error')
setProcessingChatId(null)
}
}, [input, processing, name, activeId, addToast])
}, [input, processing, name, activeId, addToast, userId])
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -363,7 +367,13 @@ export default function AgentChat() {
<button
className="btn btn-secondary btn-sm"
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"
style={{ padding: '6px 8px' }}
@@ -493,7 +503,7 @@ export default function AgentChat() {
<i className="fas fa-layer-group" /> {artifacts.length}
</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
</button>
<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)}
/>
)}
<ConfirmDialog
open={!!confirmDialog}
title={confirmDialog?.title}
message={confirmDialog?.message}
confirmLabel={confirmDialog?.confirmLabel}
danger={confirmDialog?.danger}
onConfirm={confirmDialog?.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
</div>
)
}

View File

@@ -1,5 +1,5 @@
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 SearchableModelSelect from '../components/SearchableModelSelect'
import Toggle from '../components/Toggle'
@@ -269,6 +269,8 @@ export default function AgentCreate() {
const navigate = useNavigate()
const location = useLocation()
const { addToast } = useOutletContext()
const [searchParams] = useSearchParams()
const userId = searchParams.get('user_id') || undefined
const isEdit = !!name
const importedConfig = location.state?.importedConfig || null
@@ -308,7 +310,7 @@ export default function AgentCreate() {
try {
const [metaData, config] = await Promise.all([
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)
@@ -384,7 +386,7 @@ export default function AgentCreate() {
}
if (isEdit) {
await agentsApi.update(name, payload)
await agentsApi.update(name, payload, userId)
addToast(`Agent "${form.name}" updated`, 'success')
} else {
await agentsApi.create(payload)

View File

@@ -2,20 +2,29 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { agentJobsApi, modelsApi } from '../utils/api'
import { useModels } from '../hooks/useModels'
import { useAuth } from '../context/AuthContext'
import { useUserMap } from '../hooks/useUserMap'
import LoadingSpinner from '../components/LoadingSpinner'
import { fileToBase64 } from '../utils/api'
import Modal from '../components/Modal'
import UserGroupSection from '../components/UserGroupSection'
import ConfirmDialog from '../components/ConfirmDialog'
export default function AgentJobs() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const { models } = useModels()
const { isAdmin, authEnabled, user } = useAuth()
const userMap = useUserMap()
const [activeTab, setActiveTab] = useState('tasks')
const [tasks, setTasks] = useState([])
const [jobs, setJobs] = useState([])
const [loading, setLoading] = useState(true)
const [jobFilter, setJobFilter] = useState('all')
const [hasMCPModels, setHasMCPModels] = useState(false)
const [confirmDialog, setConfirmDialog] = useState(null)
const [taskUserGroups, setTaskUserGroups] = useState(null)
const [jobUserGroups, setJobUserGroups] = useState(null)
// Execute modal state
const [executeModal, setExecuteModal] = useState(null)
@@ -27,19 +36,45 @@ export default function AgentJobs() {
const fileTypeRef = useRef('images')
const fetchData = useCallback(async () => {
const allUsers = isAdmin && authEnabled
try {
const [t, j] = await Promise.allSettled([
agentJobsApi.listTasks(),
agentJobsApi.listJobs(),
agentJobsApi.listTasks(allUsers),
agentJobsApi.listJobs(allUsers),
])
if (t.status === 'fulfilled') setTasks(Array.isArray(t.value) ? t.value : [])
if (j.status === 'fulfilled') setJobs(Array.isArray(j.value) ? j.value : [])
if (t.status === 'fulfilled') {
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) {
addToast(`Failed to load: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}, [addToast])
}, [addToast, isAdmin, authEnabled])
useEffect(() => {
fetchData()
@@ -62,14 +97,22 @@ export default function AgentJobs() {
}, [models])
const handleDeleteTask = async (id) => {
if (!confirm('Delete this task?')) return
try {
await agentJobsApi.deleteTask(id)
addToast('Task deleted', 'success')
fetchData()
} catch (err) {
addToast(`Failed to delete: ${err.message}`, 'error')
}
setConfirmDialog({
title: 'Delete Task',
message: 'Delete this task?',
confirmLabel: 'Delete',
danger: true,
onConfirm: async () => {
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) => {
@@ -83,16 +126,24 @@ export default function AgentJobs() {
}
const handleClearHistory = async () => {
if (!confirm('Clear all job history?')) return
try {
// 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')
}
setConfirmDialog({
title: 'Clear Job History',
message: 'Clear all job history?',
confirmLabel: 'Clear',
danger: true,
onConfirm: async () => {
setConfirmDialog(null)
try {
// 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) => {
@@ -256,7 +307,7 @@ export default function AgentJobs() {
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
) : activeTab === 'tasks' ? (
tasks.length === 0 ? (
tasks.length === 0 && !taskUserGroups ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-robot" /></div>
<h2 className="empty-state-title">No tasks defined</h2>
@@ -266,73 +317,82 @@ export default function AgentJobs() {
</button>
</div>
) : (
<div className="table-container">
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Model</th>
<th>Cron</th>
<th>Status</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
</thead>
<tbody>
{tasks.map(task => (
<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>
<>
{taskUserGroups && <h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>Your Tasks</h2>}
{tasks.length === 0 ? (
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-md)' }}>You have no tasks yet.</p>
) : (
<div className="table-container">
<table className="table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Model</th>
<th>Cron</th>
<th>Status</th>
<th style={{ textAlign: 'right' }}>Actions</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{tasks.map(task => (
<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 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
@@ -404,9 +464,86 @@ export default function AgentJobs() {
</table>
</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 */}
{executeModal && (
<Modal onClose={() => setExecuteModal(null)}>

View File

@@ -1,5 +1,5 @@
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 { apiUrl } from '../utils/basePath'
@@ -187,26 +187,28 @@ export default function AgentStatus() {
const { name } = useParams()
const navigate = useNavigate()
const { addToast } = useOutletContext()
const [searchParams] = useSearchParams()
const userId = searchParams.get('user_id') || undefined
const [observables, setObservables] = useState([])
const [status, setStatus] = useState(null)
const [loading, setLoading] = useState(true)
const fetchData = useCallback(async () => {
try {
const obsData = await agentsApi.observables(name)
const obsData = await agentsApi.observables(name, userId)
const history = Array.isArray(obsData) ? obsData : (obsData?.History || [])
setObservables(history)
} catch (err) {
addToast(`Failed to load observables: ${err.message}`, 'error')
}
try {
const statusData = await agentsApi.status(name)
const statusData = await agentsApi.status(name, userId)
setStatus(statusData)
} catch (_) {
// status endpoint may fail if no actions have run yet
}
setLoading(false)
}, [name, addToast])
}, [name, userId, addToast])
useEffect(() => {
fetchData()
@@ -216,7 +218,7 @@ export default function AgentStatus() {
// SSE for real-time observable updates
useEffect(() => {
const url = apiUrl(`/api/agents/${encodeURIComponent(name)}/sse`)
const url = apiUrl(agentsApi.sseUrl(name, userId))
const es = new EventSource(url)
es.addEventListener('observable_update', (e) => {
@@ -243,11 +245,11 @@ export default function AgentStatus() {
es.onerror = () => { /* reconnect handled by browser */ }
return () => es.close()
}, [name])
}, [name, userId])
const handleClear = async () => {
try {
await agentsApi.clearObservables(name)
await agentsApi.clearObservables(name, userId)
setObservables([])
addToast('Observables cleared', 'success')
} catch (err) {
@@ -359,10 +361,10 @@ export default function AgentStatus() {
<p className="page-subtitle">Agent observables and activity history</p>
</div>
<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
</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
</button>
<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>
<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>
<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}
</button>
</div>

View File

@@ -1,21 +1,30 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
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() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const { isAdmin, authEnabled, user } = useAuth()
const userMap = useUserMap()
const [agents, setAgents] = useState([])
const [loading, setLoading] = useState(true)
const [agentHubURL, setAgentHubURL] = useState('')
const [search, setSearch] = useState('')
const [userGroups, setUserGroups] = useState(null)
const [confirmDialog, setConfirmDialog] = useState(null)
const fetchAgents = useCallback(async () => {
try {
const data = await agentsApi.list()
const data = await agentsApi.list(isAdmin && authEnabled)
const names = Array.isArray(data.agents) ? data.agents : []
const statuses = data.statuses || {}
if (data.agent_hub_url) setAgentHubURL(data.agent_hub_url)
setUserGroups(data.user_groups || null)
// Fetch observable counts for each agent
const agentsWithCounts = await Promise.all(
@@ -40,7 +49,7 @@ export default function Agents() {
} finally {
setLoading(false)
}
}, [addToast])
}, [addToast, isAdmin, authEnabled])
useEffect(() => {
fetchAgents()
@@ -54,26 +63,34 @@ export default function Agents() {
return agents.filter(a => a.name.toLowerCase().includes(q))
}, [agents, search])
const handleDelete = async (name) => {
if (!window.confirm(`Delete agent "${name}"? This action cannot be undone.`)) return
try {
await agentsApi.delete(name)
addToast(`Agent "${name}" deleted`, 'success')
fetchAgents()
} catch (err) {
addToast(`Failed to delete agent: ${err.message}`, 'error')
}
const handleDelete = (name, userId) => {
setConfirmDialog({
title: 'Delete Agent',
message: `Delete agent "${name}"? This action cannot be undone.`,
confirmLabel: 'Delete',
danger: true,
onConfirm: async () => {
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 isActive = agent.status === 'active'
const isActive = agent.status === 'active' || agent.active === true
try {
if (isActive) {
await agentsApi.pause(name)
await agentsApi.pause(name, userId)
addToast(`Agent "${name}" paused`, 'success')
} else {
await agentsApi.resume(name)
await agentsApi.resume(name, userId)
addToast(`Agent "${name}" resumed`, 'success')
}
fetchAgents()
@@ -82,9 +99,9 @@ export default function Agents() {
}
}
const handleExport = async (name) => {
const handleExport = async (name, userId) => {
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 url = URL.createObjectURL(blob)
const a = document.createElement('a')
@@ -187,7 +204,7 @@ export default function Agents() {
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-primary)' }} />
</div>
) : agents.length === 0 ? (
) : agents.length === 0 && !userGroups ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-robot" /></div>
<h2 className="empty-state-title">No agents configured</h2>
@@ -214,6 +231,7 @@ export default function Agents() {
</div>
) : (
<>
{userGroups && <h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: 'var(--spacing-md)' }}>Your Agents</h2>}
<div className="agents-toolbar">
<div className="agents-search">
<i className="fas fa-search" />
@@ -314,8 +332,96 @@ export default function Agents() {
</table>
</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>
)
}

View File

@@ -5,6 +5,7 @@ import React from 'react'
import { useOperations } from '../hooks/useOperations'
import LoadingSpinner from '../components/LoadingSpinner'
import { renderMarkdown } from '../utils/markdown'
import ConfirmDialog from '../components/ConfirmDialog'
export default function Backends() {
const { addToast } = useOutletContext()
@@ -22,6 +23,7 @@ export default function Backends() {
const [manualName, setManualName] = useState('')
const [manualAlias, setManualAlias] = useState('')
const [expandedRow, setExpandedRow] = useState(null)
const [confirmDialog, setConfirmDialog] = useState(null)
const debounceRef = useRef(null)
const [allBackends, setAllBackends] = useState([])
@@ -94,14 +96,22 @@ export default function Backends() {
}
const handleDelete = async (id) => {
if (!confirm(`Delete backend ${id}?`)) return
try {
await backendsApi.delete(id)
addToast(`Deleting ${id}...`, 'info')
setTimeout(fetchBackends, 1000)
} catch (err) {
addToast(`Delete failed: ${err.message}`, 'error')
}
setConfirmDialog({
title: 'Delete Backend',
message: `Delete backend ${id}?`,
confirmLabel: 'Delete',
danger: true,
onConfirm: async () => {
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) => {
@@ -400,6 +410,15 @@ export default function Backends() {
</div>
)}
<ConfirmDialog
open={!!confirmDialog}
title={confirmDialog?.title}
message={confirmDialog?.message}
confirmLabel={confirmDialog?.confirmLabel}
danger={confirmDialog?.danger}
onConfirm={confirmDialog?.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
</div>
)
}

View File

@@ -10,6 +10,8 @@ import { useMCPClient } from '../hooks/useMCPClient'
import MCPAppFrame from '../components/MCPAppFrame'
import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
import { loadClientMCPServers } from '../utils/mcpClientStorage'
import ConfirmDialog from '../components/ConfirmDialog'
import { useAuth } from '../context/AuthContext'
function relativeTime(ts) {
if (!ts) return ''
@@ -286,6 +288,7 @@ export default function Chat() {
const { model: urlModel } = useParams()
const { addToast } = useOutletContext()
const navigate = useNavigate()
const { isAdmin } = useAuth()
const {
chats, activeChat, activeChatId, isStreaming, streamingChatId, streamingContent,
streamingReasoning, streamingToolCalls, tokensPerSecond, maxTokensPerSecond,
@@ -316,6 +319,9 @@ export default function Chat() {
const [canvasOpen, setCanvasOpen] = useState(false)
const [selectedArtifactId, setSelectedArtifactId] = useState(null)
const [clientMCPServers, setClientMCPServers] = useState(() => loadClientMCPServers())
const [confirmDialog, setConfirmDialog] = useState(null)
const [completionGlowIdx, setCompletionGlowIdx] = useState(-1)
const prevStreamingRef = useRef(false)
const {
connect: mcpConnect, disconnect: mcpDisconnect, disconnectAll: mcpDisconnectAll,
getToolsForLLM, isClientTool, executeTool, connectionStatuses, getConnectedTools,
@@ -343,10 +349,23 @@ export default function Chat() {
prevArtifactCountRef.current = artifacts.length
}, [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(() => {
const model = activeChat?.model
if (!model) { setMcpAvailable(false); setModelInfo(null); return }
if (!model || !isAdmin) { setMcpAvailable(false); setModelInfo(null); return }
let cancelled = false
modelsApi.getConfigJson(model).then(cfg => {
if (cancelled) return
@@ -361,7 +380,7 @@ export default function Chat() {
}
}).catch(() => { if (!cancelled) { setMcpAvailable(false); setModelInfo(null) } })
return () => { cancelled = true }
}, [activeChat?.model])
}, [activeChat?.model, isAdmin])
const fetchMcpServers = useCallback(async () => {
const model = activeChat?.model
@@ -732,9 +751,13 @@ export default function Chat() {
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => {
if (confirm('Delete all chats? This cannot be undone.')) deleteAllChats()
}}
onClick={() => setConfirmDialog({
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"
style={{ padding: '6px 8px' }}
>
@@ -879,7 +902,7 @@ export default function Chat() {
style={{ flex: '1 1 0', minWidth: 120 }}
/>
<div className="chat-header-actions">
{activeChat.model && (
{activeChat.model && isAdmin && (
<>
<button
className="btn btn-secondary btn-sm"
@@ -1059,7 +1082,18 @@ export default function Chat() {
<i className="fas fa-comments" />
</div>
<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">
<span><i className="fas fa-keyboard" /> Enter to send</span>
<span><i className="fas fa-level-down-alt" /> Shift+Enter for newline</span>
@@ -1089,7 +1123,7 @@ export default function Chat() {
}
flushActivity(i)
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">
<i className={`fas ${msg.role === 'user' ? 'fa-user' : 'fa-robot'}`} />
</div>
@@ -1145,6 +1179,11 @@ export default function Chat() {
<span dangerouslySetInnerHTML={{ __html: renderMarkdown(streamingContent) }} />
<span className="chat-streaming-cursor" />
</div>
{tokensPerSecond !== null && (
<div className="chat-streaming-speed">
<i className="fas fa-tachometer-alt" /> {tokensPerSecond} tok/s
</div>
)}
</div>
</div>
)}
@@ -1154,8 +1193,10 @@ export default function Chat() {
<i className="fas fa-robot" />
</div>
<div className="chat-message-bubble">
<div className="chat-message-content" style={{ color: 'var(--color-text-muted)' }}>
<i className="fas fa-circle-notch fa-spin" /> Thinking...
<div className="chat-message-content chat-thinking-indicator">
<span className="chat-thinking-dots">
<span /><span /><span />
</span>
</div>
</div>
</div>
@@ -1220,7 +1261,7 @@ export default function Chat() {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
placeholder="Message..."
rows={1}
disabled={isStreaming}
/>
@@ -1249,6 +1290,15 @@ export default function Chat() {
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>
)
}

View File

@@ -1,12 +1,16 @@
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 ConfirmDialog from '../components/ConfirmDialog'
export default function CollectionDetails() {
const { name } = useParams()
const { addToast } = useOutletContext()
const [searchParams] = useSearchParams()
const userId = searchParams.get('user_id') || undefined
const [activeTab, setActiveTab] = useState('entries')
const [loading, setLoading] = useState(true)
const [confirmDialog, setConfirmDialog] = useState(null)
// Entries tab state
const [entries, setEntries] = useState([])
@@ -32,21 +36,21 @@ export default function CollectionDetails() {
const fetchEntries = useCallback(async () => {
try {
const data = await agentCollectionsApi.entries(name)
const data = await agentCollectionsApi.entries(name, userId)
setEntries(Array.isArray(data.entries) ? data.entries : [])
} catch (err) {
addToast(`Failed to load entries: ${err.message}`, 'error')
}
}, [name, addToast])
}, [name, addToast, userId])
const fetchSources = useCallback(async () => {
try {
const data = await agentCollectionsApi.sources(name)
const data = await agentCollectionsApi.sources(name, userId)
setSources(Array.isArray(data.sources) ? data.sources : [])
} catch (err) {
addToast(`Failed to load sources: ${err.message}`, 'error')
}
}, [name, addToast])
}, [name, addToast, userId])
useEffect(() => {
const load = async () => {
@@ -62,7 +66,7 @@ export default function CollectionDetails() {
setViewContent(null)
setViewLoading(true)
try {
const data = await agentCollectionsApi.entryContent(name, entry)
const data = await agentCollectionsApi.entryContent(name, entry, userId)
setViewContent(data)
} catch (err) {
addToast(`Failed to load entry content: ${err.message}`, 'error')
@@ -73,14 +77,22 @@ export default function CollectionDetails() {
}
const handleDeleteEntry = async (entry) => {
if (!window.confirm('Are you sure you want to delete this entry?')) return
try {
await agentCollectionsApi.deleteEntry(name, entry)
addToast('Entry deleted', 'success')
fetchEntries()
} catch (err) {
addToast(`Failed to delete entry: ${err.message}`, 'error')
}
setConfirmDialog({
title: 'Delete Entry',
message: 'Are you sure you want to delete this entry?',
confirmLabel: 'Delete',
danger: true,
onConfirm: async () => {
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) => {
@@ -90,7 +102,7 @@ export default function CollectionDetails() {
try {
const formData = new FormData()
formData.append('file', uploadFile)
await agentCollectionsApi.upload(name, formData)
await agentCollectionsApi.upload(name, formData, userId)
addToast('File uploaded successfully', 'success')
setUploadFile(null)
fetchEntries()
@@ -106,7 +118,7 @@ export default function CollectionDetails() {
if (!searchQuery.trim()) return
setSearching(true)
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 : [])
} catch (err) {
addToast(`Search failed: ${err.message}`, 'error')
@@ -120,7 +132,7 @@ export default function CollectionDetails() {
if (!newSourceUrl.trim()) return
setAddingSource(true)
try {
await agentCollectionsApi.addSource(name, newSourceUrl, newSourceInterval || undefined)
await agentCollectionsApi.addSource(name, newSourceUrl, newSourceInterval || undefined, userId)
addToast('Source added', 'success')
setNewSourceUrl('')
setNewSourceInterval('')
@@ -133,14 +145,22 @@ export default function CollectionDetails() {
}
const handleRemoveSource = async (url) => {
if (!window.confirm('Are you sure you want to remove this source?')) return
try {
await agentCollectionsApi.removeSource(name, url)
addToast('Source removed', 'success')
fetchSources()
} catch (err) {
addToast(`Failed to remove source: ${err.message}`, 'error')
}
setConfirmDialog({
title: 'Remove Source',
message: 'Are you sure you want to remove this source?',
confirmLabel: 'Remove',
danger: true,
onConfirm: async () => {
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 (
@@ -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 */}
{viewEntry && (
<div className="collection-detail-modal-overlay" onClick={() => setViewEntry(null)}>

View File

@@ -1,25 +1,34 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
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() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const { isAdmin, authEnabled, user } = useAuth()
const userMap = useUserMap()
const [collections, setCollections] = useState([])
const [loading, setLoading] = useState(true)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [userGroups, setUserGroups] = useState(null)
const [confirmDialog, setConfirmDialog] = useState(null)
const fetchCollections = useCallback(async () => {
try {
const data = await agentCollectionsApi.list()
const data = await agentCollectionsApi.list(isAdmin && authEnabled)
setCollections(Array.isArray(data.collections) ? data.collections : [])
setUserGroups(data.user_groups || null)
} catch (err) {
addToast(`Failed to load collections: ${err.message}`, 'error')
} finally {
setLoading(false)
}
}, [addToast])
}, [addToast, isAdmin, authEnabled])
useEffect(() => {
fetchCollections()
@@ -41,26 +50,42 @@ export default function Collections() {
}
}
const handleDelete = async (name) => {
if (!window.confirm(`Delete collection "${name}"? This will remove all entries and cannot be undone.`)) return
try {
await agentCollectionsApi.reset(name)
addToast(`Collection "${name}" deleted`, 'success')
fetchCollections()
} catch (err) {
addToast(`Failed to delete collection: ${err.message}`, 'error')
}
const handleDelete = (name, userId) => {
setConfirmDialog({
title: 'Delete Collection',
message: `Delete collection "${name}"? This will remove all entries and cannot be undone.`,
confirmLabel: 'Delete',
danger: true,
onConfirm: async () => {
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) => {
if (!window.confirm(`Reset collection "${name}"? This will remove all entries but keep the collection.`)) return
try {
await agentCollectionsApi.reset(name)
addToast(`Collection "${name}" reset`, 'success')
fetchCollections()
} catch (err) {
addToast(`Failed to reset collection: ${err.message}`, 'error')
}
const handleReset = (name, userId) => {
setConfirmDialog({
title: 'Reset Collection',
message: `Reset collection "${name}"? This will remove all entries but keep the collection.`,
confirmLabel: 'Reset',
danger: true,
onConfirm: async () => {
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 (
@@ -115,13 +140,21 @@ export default function Collections() {
<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)' }} />
</div>
) : collections.length === 0 ? (
) : collections.length === 0 && !userGroups ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-database" /></div>
<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>
) : (
<>
{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">
{collections.map((collection) => {
const name = typeof collection === 'string' ? collection : collection.name
@@ -146,7 +179,55 @@ export default function Collections() {
)
})}
</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>
)
}

View File

@@ -1,30 +1,18 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { apiUrl } from '../utils/basePath'
import { useAuth } from '../context/AuthContext'
import ModelSelector from '../components/ModelSelector'
import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
import ConfirmDialog from '../components/ConfirmDialog'
import { useResources } from '../hooks/useResources'
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi } from '../utils/api'
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() {
const navigate = useNavigate()
const { addToast } = useOutletContext()
const { isAdmin } = useAuth()
const { resources } = useResources()
const [configuredModels, setConfiguredModels] = useState(null)
const configuredModelsRef = useRef(configuredModels)
@@ -42,8 +30,7 @@ export default function Home() {
const [mcpServerCache, setMcpServerCache] = useState({})
const [mcpSelectedServers, setMcpSelectedServers] = useState([])
const [clientMCPSelectedIds, setClientMCPSelectedIds] = useState([])
const [placeholderIdx, setPlaceholderIdx] = useState(0)
const [placeholderText, setPlaceholderText] = useState('')
const [confirmDialog, setConfirmDialog] = useState(null)
const imageInputRef = useRef(null)
const audioInputRef = useRef(null)
const fileInputRef = useRef(null)
@@ -103,25 +90,6 @@ export default function Home() {
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 newFiles = []
for (const file of fileList) {
@@ -164,7 +132,7 @@ export default function Home() {
}, [])
const doSubmit = useCallback(() => {
const text = message.trim() || placeholderText
const text = message.trim()
if (!text && allFiles.length === 0) return
if (!selectedModel) {
addToast('Please select a model first', 'warning')
@@ -182,7 +150,7 @@ export default function Home() {
}
localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData))
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) => {
if (e) e.preventDefault()
@@ -190,26 +158,41 @@ export default function Home() {
}
const handleStopModel = async (modelName) => {
if (!confirm(`Stop model ${modelName}?`)) return
try {
await backendControlApi.shutdown({ model: modelName })
addToast(`Stopped ${modelName}`, 'success')
// Refresh loaded models list after a short delay
setTimeout(fetchSystemInfo, 500)
} catch (err) {
addToast(`Failed to stop: ${err.message}`, 'error')
}
setConfirmDialog({
title: 'Stop Model',
message: `Stop model ${modelName}?`,
confirmLabel: `Stop ${modelName}`,
danger: true,
onConfirm: async () => {
setConfirmDialog(null)
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 () => {
if (!confirm('Stop all loaded models?')) return
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')
}
setConfirmDialog({
title: 'Stop All Models',
message: `Stop all ${loadedModels.length} loaded models?`,
confirmLabel: 'Stop all',
danger: true,
onConfirm: async () => {
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
@@ -228,10 +211,27 @@ export default function Home() {
{/* Hero with logo */}
<div className="home-hero">
<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>
{/* 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 */}
<div className="home-chat-card">
<form onSubmit={handleSubmit}>
@@ -274,13 +274,13 @@ export default function Home() {
</div>
)}
{/* Textarea with attach buttons */}
<div className="home-input-area">
{/* Input container with inline send */}
<div className="home-input-container">
<textarea
className="home-textarea"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={placeholderText}
placeholder="Message..."
rows={3}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -289,65 +289,55 @@ export default function Home() {
}
}}
/>
<div className="home-attach-buttons">
<button type="button" className="home-attach-btn" onClick={() => imageInputRef.current?.click()} title="Attach image">
<i className="fas fa-image" />
</button>
<button type="button" className="home-attach-btn" onClick={() => audioInputRef.current?.click()} title="Attach audio">
<i className="fas fa-microphone" />
</button>
<button type="button" className="home-attach-btn" onClick={() => fileInputRef.current?.click()} title="Attach file">
<i className="fas fa-file" />
<div className="home-input-footer">
<div className="home-attach-buttons">
<button type="button" className="home-attach-btn" onClick={() => imageInputRef.current?.click()} title="Attach image">
<i className="fas fa-image" />
</button>
<button type="button" className="home-attach-btn" onClick={() => audioInputRef.current?.click()} title="Attach audio">
<i className="fas fa-microphone" />
</button>
<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>
</div>
<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={fileInputRef} type="file" multiple accept=".txt,.md,.pdf" style={{ display: 'none' }} onChange={(e) => addFiles(e.target.files, setTextFiles)} />
</div>
<button
type="submit"
className="home-send-btn"
disabled={!selectedModel}
>
<i className="fas fa-paper-plane" /> Send
</button>
</form>
</div>
{/* Quick links */}
<div className="home-quick-links">
<button className="home-link-btn" onClick={() => navigate('/app/manage')}>
<i className="fas fa-desktop" /> Installed Models and Backends
</button>
<button className="home-link-btn" onClick={() => navigate('/app/models')}>
<i className="fas fa-download" /> Browse Gallery
</button>
<button className="home-link-btn" onClick={() => navigate('/app/import-model')}>
<i className="fas fa-upload" /> Import Model
</button>
{isAdmin && (
<>
<button className="home-link-btn" onClick={() => navigate('/app/manage')}>
<i className="fas fa-desktop" /> Installed Models
</button>
<button className="home-link-btn" onClick={() => navigate('/app/models')}>
<i className="fas fa-download" /> Browse Gallery
</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">
<i className="fas fa-book" /> Documentation
</a>
</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 */}
{loadedCount > 0 && (
<div className="home-loaded-models">
@@ -371,66 +361,39 @@ export default function Home() {
</div>
)}
</>
) : (
/* No models installed wizard */
) : isAdmin ? (
/* No models installed - compact getting started */
<div className="home-wizard">
<div className="home-wizard-hero">
<h1>No Models Installed</h1>
<p>Get started with LocalAI by installing your first model. Browse our gallery of open-source AI models.</p>
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="home-logo" />
<h1>Get started with LocalAI</h1>
<p>Install your first model to begin. Browse the gallery or import your own.</p>
</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">
<h2>How to Get Started</h2>
<div className="home-wizard-step">
<div className="home-wizard-step-num">1</div>
<div>
<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 className="home-wizard-step">
<div className="home-wizard-step-num">2</div>
<div>
<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 className="home-wizard-step">
<div className="home-wizard-step-num">3</div>
<div>
<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>
{/* Action buttons */}
<div className="home-wizard-actions">
<button className="btn btn-primary" onClick={() => navigate('/app/models')}>
<i className="fas fa-store" /> Browse Model Gallery
@@ -439,357 +402,35 @@ export default function Home() {
<i className="fas fa-upload" /> Import Model
</button>
<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>
</div>
</div>
)}
<style>{`
.home-page {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
max-width: 48rem;
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>
<ConfirmDialog
open={!!confirmDialog}
title={confirmDialog?.title}
message={confirmDialog?.message}
confirmLabel={confirmDialog?.confirmLabel}
danger={confirmDialog?.danger}
onConfirm={confirmDialog?.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
</div>
)
}

View File

@@ -1,58 +1,368 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { apiUrl } from '../utils/basePath'
import './auth.css'
export default function Login() {
const navigate = useNavigate()
const [token, setToken] = useState('')
const [error, setError] = useState('')
const { code: urlInviteCode } = useParams()
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()
if (!token.trim()) {
setError('Please enter a token')
return
}
// Set token as cookie
document.cookie = `token=${encodeURIComponent(token.trim())}; path=/; SameSite=Strict`
navigate('/app')
setError('')
setSubmitting(true)
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 (
<div style={{
minHeight: '100vh',
background: 'var(--color-bg-primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 'var(--spacing-xl)',
}}>
<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 className="login-page">
<div className="card login-card">
<div className="login-header">
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="login-logo" />
<p className="login-subtitle">
{!hasUsers ? 'Create your admin account' : mode === 'register' ? 'Create an account' : 'Sign in to continue'}
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">API Token</label>
<input
className="input"
type="password"
value={token}
onChange={(e) => { setToken(e.target.value); setError('') }}
placeholder="Enter token..."
autoFocus
/>
{error && <p style={{ color: 'var(--color-error)', fontSize: '0.8125rem', marginTop: 'var(--spacing-xs)' }}>{error}</p>}
</div>
<button type="submit" className="btn btn-primary" style={{ width: '100%' }}>
<i className="fas fa-sign-in-alt" /> Login
{error && (
<div className="login-alert login-alert-error">{error}</div>
)}
{message && (
<div className="login-alert login-alert-success">{message}</div>
)}
{hasGitHub && (
<a
href={githubLoginUrl}
className="btn btn-primary login-btn-full"
style={{ marginBottom: hasOIDC ? '0.5rem' : undefined }}
>
<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>
</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>
)

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
import ResourceMonitor from '../components/ResourceMonitor'
import ConfirmDialog from '../components/ConfirmDialog'
import { useModels } from '../hooks/useModels'
import { backendControlApi, modelsApi, backendsApi, systemApi } from '../utils/api'
@@ -21,6 +22,7 @@ export default function Manage() {
const [backendsLoading, setBackendsLoading] = useState(true)
const [reloading, setReloading] = useState(false)
const [reinstallingBackends, setReinstallingBackends] = useState(new Set())
const [confirmDialog, setConfirmDialog] = useState(null)
const handleTabChange = (tab) => {
setActiveTab(tab)
@@ -55,27 +57,43 @@ export default function Manage() {
fetchBackends()
}, [fetchLoadedModels, fetchBackends])
const handleStopModel = async (modelName) => {
if (!confirm(`Stop model ${modelName}?`)) return
try {
await backendControlApi.shutdown({ model: modelName })
addToast(`Stopped ${modelName}`, 'success')
setTimeout(fetchLoadedModels, 500)
} catch (err) {
addToast(`Failed to stop: ${err.message}`, 'error')
}
const handleStopModel = (modelName) => {
setConfirmDialog({
title: 'Stop Model',
message: `Stop model ${modelName}?`,
confirmLabel: 'Stop',
danger: true,
onConfirm: async () => {
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) => {
if (!confirm(`Delete model ${modelName}? This cannot be undone.`)) return
try {
await modelsApi.deleteByName(modelName)
addToast(`Deleted ${modelName}`, 'success')
refetchModels()
fetchLoadedModels()
} catch (err) {
addToast(`Failed to delete: ${err.message}`, 'error')
}
const handleDeleteModel = (modelName) => {
setConfirmDialog({
title: 'Delete Model',
message: `Delete model ${modelName}? This cannot be undone.`,
confirmLabel: 'Delete',
danger: true,
onConfirm: async () => {
setConfirmDialog(null)
try {
await modelsApi.deleteByName(modelName)
addToast(`Deleted ${modelName}`, 'success')
refetchModels()
fetchLoadedModels()
} catch (err) {
addToast(`Failed to delete: ${err.message}`, 'error')
}
},
})
}
const handleReload = async () => {
@@ -106,15 +124,23 @@ export default function Manage() {
}
}
const handleDeleteBackend = async (name) => {
if (!confirm(`Delete backend ${name}?`)) return
try {
await backendsApi.deleteInstalled(name)
addToast(`Deleted backend ${name}`, 'success')
fetchBackends()
} catch (err) {
addToast(`Failed to delete backend: ${err.message}`, 'error')
}
const handleDeleteBackend = (name) => {
setConfirmDialog({
title: 'Delete Backend',
message: `Delete backend ${name}?`,
confirmLabel: 'Delete',
danger: true,
onConfirm: async () => {
setConfirmDialog(null)
try {
await backendsApi.deleteInstalled(name)
addToast(`Deleted backend ${name}`, 'success')
fetchBackends()
} catch (err) {
addToast(`Failed to delete backend: ${err.message}`, 'error')
}
},
})
}
return (
@@ -379,6 +405,16 @@ export default function Manage() {
)}
</div>
)}
<ConfirmDialog
open={!!confirmDialog}
title={confirmDialog?.title}
message={confirmDialog?.message}
confirmLabel={confirmDialog?.confirmLabel}
danger={confirmDialog?.danger}
onConfirm={confirmDialog?.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
</div>
)
}

View File

@@ -4,40 +4,16 @@ import { modelsApi } from '../utils/api'
import { useOperations } from '../hooks/useOperations'
import { useResources } from '../hooks/useResources'
import SearchableSelect from '../components/SearchableSelect'
import ConfirmDialog from '../components/ConfirmDialog'
import React from 'react'
const LOADING_PHRASES = [
{ text: 'Rounding up the neural networks...', icon: 'fa-brain' },
{ text: 'Asking the models to line up nicely...', icon: 'fa-people-line' },
{ text: 'Convincing transformers to transform...', icon: 'fa-wand-magic-sparkles' },
{ text: 'Herding digital llamas...', icon: 'fa-horse' },
{ text: 'Downloading more RAM... just kidding', icon: 'fa-memory' },
{ 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' },
{ text: 'Loading models...', icon: 'fa-brain' },
{ text: 'Fetching gallery...', icon: 'fa-download' },
{ text: 'Checking availability...', icon: 'fa-circle-check' },
{ text: 'Almost ready...', icon: 'fa-hourglass-half' },
{ text: 'Preparing gallery...', icon: 'fa-store' },
]
function GalleryLoader() {
@@ -142,6 +118,7 @@ export default function Models() {
const [backendFilter, setBackendFilter] = useState('')
const [allBackends, setAllBackends] = useState([])
const debounceRef = useRef(null)
const [confirmDialog, setConfirmDialog] = useState(null)
// Total GPU memory for "fits" check
const totalGpuMemory = resources?.aggregate?.total_memory || 0
@@ -216,15 +193,24 @@ export default function Models() {
}
}
const handleDelete = async (modelId) => {
if (!confirm(`Delete model ${modelId}?`)) return
try {
await modelsApi.delete(modelId)
addToast(`Deleting ${modelId}...`, 'info')
fetchModels()
} catch (err) {
addToast(`Failed to delete: ${err.message}`, 'error')
}
const handleDelete = (modelId) => {
setConfirmDialog({
title: 'Delete Model',
message: `Delete model ${modelId}?`,
confirmLabel: `Delete ${modelId}`,
danger: true,
onConfirm: async () => {
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)
@@ -332,7 +318,19 @@ export default function Models() {
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-search" /></div>
<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 className="table-container" style={{ background: 'var(--color-bg-secondary)', borderRadius: 'var(--radius-lg)', overflow: 'hidden' }}>
@@ -535,6 +533,15 @@ export default function Models() {
</div>
)}
<ConfirmDialog
open={!!confirmDialog}
title={confirmDialog?.title}
message={confirmDialog?.message}
confirmLabel={confirmDialog?.confirmLabel}
danger={confirmDialog?.danger}
onConfirm={confirmDialog?.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
</div>
)
}

View File

@@ -9,7 +9,7 @@ export default function NotFound() {
<div className="empty-state-icon"><i className="fas fa-compass" /></div>
<h1 className="empty-state-title" style={{ fontSize: '3rem' }}>404</h1>
<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')}>
<i className="fas fa-home" /> Go Home
</button>

View File

@@ -1,5 +1,5 @@
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'
const RESOURCE_PREFIXES = ['scripts/', 'references/', 'assets/']
@@ -265,6 +265,8 @@ export default function SkillEdit() {
const name = nameParam ? decodeURIComponent(nameParam) : undefined
const navigate = useNavigate()
const { addToast } = useOutletContext()
const [searchParams] = useSearchParams()
const userId = searchParams.get('user_id') || undefined
const [loading, setLoading] = useState(!isNew)
const [saving, setSaving] = useState(false)
const [activeSection, setActiveSection] = useState('basic')
@@ -284,7 +286,7 @@ export default function SkillEdit() {
return
}
if (name) {
skillsApi.get(name)
skillsApi.get(name, userId)
.then((data) => {
setForm({
name: data.name || '',
@@ -329,7 +331,7 @@ export default function SkillEdit() {
await skillsApi.create(payload)
addToast('Skill created', 'success')
} else {
await skillsApi.update(name, { ...payload, name: undefined })
await skillsApi.update(name, { ...payload, name: undefined }, userId)
addToast('Skill updated', 'success')
}
navigate('/app/skills')

View File

@@ -1,10 +1,16 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
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() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
const { isAdmin, authEnabled, user } = useAuth()
const userMap = useUserMap()
const [skills, setSkills] = useState([])
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(true)
@@ -15,6 +21,8 @@ export default function Skills() {
const [gitRepoUrl, setGitRepoUrl] = useState('')
const [gitReposLoading, setGitReposLoading] = useState(false)
const [gitReposAction, setGitReposAction] = useState(null)
const [userGroups, setUserGroups] = useState(null)
const [confirmDialog, setConfirmDialog] = useState(null)
const fetchSkills = useCallback(async () => {
setLoading(true)
@@ -31,9 +39,17 @@ export default function Skills() {
if (searchQuery.trim()) {
const data = await withTimeout(skillsApi.search(searchQuery.trim()))
setSkills(Array.isArray(data) ? data : [])
setUserGroups(null)
} else {
const data = await withTimeout(skillsApi.list())
setSkills(Array.isArray(data) ? data : [])
const data = await withTimeout(skillsApi.list(isAdmin && authEnabled))
// 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) {
if (err.message?.includes('503') || err.message?.includes('skills')) {
@@ -46,26 +62,34 @@ export default function Skills() {
} finally {
setLoading(false)
}
}, [searchQuery, addToast])
}, [searchQuery, addToast, isAdmin, authEnabled])
useEffect(() => {
fetchSkills()
}, [fetchSkills])
const deleteSkill = async (name) => {
if (!window.confirm(`Delete skill "${name}"? This action cannot be undone.`)) return
try {
await skillsApi.delete(name)
addToast(`Skill "${name}" deleted`, 'success')
fetchSkills()
} catch (err) {
addToast(err.message || 'Failed to delete skill', 'error')
}
const deleteSkill = async (name, userId) => {
setConfirmDialog({
title: 'Delete Skill',
message: `Delete skill "${name}"? This action cannot be undone.`,
confirmLabel: 'Delete',
danger: true,
onConfirm: async () => {
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 {
const url = skillsApi.exportUrl(name)
const url = skillsApi.exportUrl(name, userId)
const res = await fetch(url, { credentials: 'same-origin' })
if (!res.ok) throw new Error(res.statusText || 'Export failed')
const blob = await res.blob()
@@ -159,15 +183,23 @@ export default function Skills() {
}
const deleteGitRepo = async (id) => {
if (!window.confirm('Remove this Git repository? Skills from it will no longer be available.')) return
try {
await skillsApi.deleteGitRepo(id)
await loadGitRepos()
fetchSkills()
addToast('Repo removed', 'success')
} catch (err) {
addToast(err.message || 'Remove failed', 'error')
}
setConfirmDialog({
title: 'Remove Git Repository',
message: 'Remove this Git repository? Skills from it will no longer be available.',
confirmLabel: 'Remove',
danger: true,
onConfirm: async () => {
setConfirmDialog(null)
try {
await skillsApi.deleteGitRepo(id)
await loadGitRepos()
fetchSkills()
addToast('Repo removed', 'success')
} catch (err) {
addToast(err.message || 'Remove failed', 'error')
}
},
})
}
if (unavailable) {
@@ -384,7 +416,7 @@ export default function Skills() {
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-primary)' }} />
</div>
) : skills.length === 0 ? (
) : skills.length === 0 && !userGroups ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-book" /></div>
<h2 className="empty-state-title">No skills found</h2>
@@ -406,6 +438,11 @@ export default function Skills() {
</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">
{skills.map((s) => (
<div key={s.name} className="card">
@@ -446,6 +483,68 @@ export default function Skills() {
</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>
)

View 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>
)
}

View 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 &ldquo;{user.name || user.email}&rdquo;</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>
)
}

View 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;
}

View File

@@ -31,15 +31,29 @@ import BackendLogs from './pages/BackendLogs'
import Explorer from './pages/Explorer'
import Login from './pages/Login'
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() {
const { '*': splat } = useParams()
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 = [
{ index: true, element: <Home /> },
{ path: 'models', element: <Models /> },
{ path: 'models', element: <Admin><Models /></Admin> },
{ path: 'chat', element: <Chat /> },
{ path: 'chat/:model', element: <Chat /> },
{ path: 'image', element: <ImageGen /> },
@@ -51,29 +65,32 @@ const appChildren = [
{ path: 'sound', element: <Sound /> },
{ path: 'sound/:model', element: <Sound /> },
{ path: 'talk', element: <Talk /> },
{ path: 'manage', element: <Manage /> },
{ path: 'backends', element: <Backends /> },
{ path: 'settings', element: <Settings /> },
{ path: 'traces', element: <Traces /> },
{ path: 'backend-logs/:modelId', element: <BackendLogs /> },
{ path: 'p2p', element: <P2P /> },
{ path: 'agents', element: <Agents /> },
{ path: 'agents/new', element: <AgentCreate /> },
{ path: 'agents/:name/edit', element: <AgentCreate /> },
{ path: 'agents/:name/chat', element: <AgentChat /> },
{ path: 'agents/:name/status', element: <AgentStatus /> },
{ path: 'collections', element: <Collections /> },
{ path: 'collections/:name', element: <CollectionDetails /> },
{ path: 'skills', element: <Skills /> },
{ path: 'skills/new', element: <SkillEdit /> },
{ path: 'skills/edit/:name', element: <SkillEdit /> },
{ path: 'agent-jobs', element: <AgentJobs /> },
{ path: 'agent-jobs/tasks/new', element: <AgentTaskDetails /> },
{ path: 'agent-jobs/tasks/:id', element: <AgentTaskDetails /> },
{ path: 'agent-jobs/tasks/:id/edit', element: <AgentTaskDetails /> },
{ path: 'agent-jobs/jobs/:id', element: <AgentJobDetails /> },
{ path: 'model-editor/:name', element: <ModelEditor /> },
{ path: 'import-model', element: <ImportModel /> },
{ path: 'usage', element: <Usage /> },
{ path: 'account', element: <Account /> },
{ path: 'users', element: <Admin><Users /></Admin> },
{ path: 'manage', element: <Admin><Manage /></Admin> },
{ path: 'backends', element: <Admin><Backends /></Admin> },
{ path: 'settings', element: <Admin><Settings /></Admin> },
{ path: 'traces', element: <Admin><Traces /></Admin> },
{ path: 'backend-logs/:modelId', element: <Admin><BackendLogs /></Admin> },
{ path: 'p2p', element: <Admin><P2P /></Admin> },
{ path: 'agents', element: <Feature feature="agents"><Agents /></Feature> },
{ path: 'agents/new', element: <Feature feature="agents"><AgentCreate /></Feature> },
{ path: 'agents/:name/edit', element: <Feature feature="agents"><AgentCreate /></Feature> },
{ path: 'agents/:name/chat', element: <Feature feature="agents"><AgentChat /></Feature> },
{ path: 'agents/:name/status', element: <Feature feature="agents"><AgentStatus /></Feature> },
{ path: 'collections', element: <Feature feature="collections"><Collections /></Feature> },
{ path: 'collections/:name', element: <Feature feature="collections"><CollectionDetails /></Feature> },
{ path: 'skills', element: <Feature feature="skills"><Skills /></Feature> },
{ path: 'skills/new', element: <Feature feature="skills"><SkillEdit /></Feature> },
{ path: 'skills/edit/:name', element: <Feature feature="skills"><SkillEdit /></Feature> },
{ path: 'agent-jobs', element: <Feature feature="mcp_jobs"><AgentJobs /></Feature> },
{ path: 'agent-jobs/tasks/new', element: <Feature feature="mcp_jobs"><AgentTaskDetails /></Feature> },
{ path: 'agent-jobs/tasks/:id', element: <Feature feature="mcp_jobs"><AgentTaskDetails /></Feature> },
{ 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 /> },
]
@@ -82,13 +99,17 @@ export const router = createBrowserRouter([
path: '/login',
element: <Login />,
},
{
path: '/invite/:code',
element: <Login />,
},
{
path: '/explorer',
element: <Explorer />,
},
{
path: '/app',
element: <App />,
element: <RequireAuth><App /></RequireAuth>,
children: appChildren,
},
// Backward compatibility: redirect /browse/* to /app/*

View File

@@ -6,22 +6,20 @@
--color-bg-tertiary: #222222;
--color-bg-overlay: rgba(18, 18, 18, 0.95);
--color-primary: #38BDF8;
--color-primary-hover: #0EA5E9;
--color-primary-active: #0284C7;
--color-primary: #3B82F6;
--color-primary-hover: #2563EB;
--color-primary-active: #1D4ED8;
--color-primary-text: #FFFFFF;
--color-primary-light: rgba(56, 189, 248, 0.08);
--color-primary-border: rgba(56, 189, 248, 0.15);
--color-primary-light: rgba(59, 130, 246, 0.08);
--color-primary-border: rgba(59, 130, 246, 0.15);
--color-secondary: #14B8A6;
--color-secondary-hover: #0D9488;
--color-secondary-light: rgba(20, 184, 166, 0.1);
--color-secondary: #64748B;
--color-secondary-hover: #475569;
--color-secondary-light: rgba(100, 116, 139, 0.1);
--color-accent: #8B5CF6;
--color-accent-hover: #7C3AED;
--color-accent-light: rgba(139, 92, 246, 0.1);
--color-accent-purple: #A78BFA;
--color-accent-teal: #2DD4BF;
--color-accent: #F59E0B;
--color-accent-hover: #D97706;
--color-accent-light: rgba(245, 158, 11, 0.1);
--color-text-primary: #E5E7EB;
--color-text-secondary: #94A3B8;
@@ -31,36 +29,31 @@
--color-border-subtle: rgba(255, 255, 255, 0.08);
--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-primary: rgba(56, 189, 248, 0.2);
--color-border-focus: rgba(56, 189, 248, 0.4);
--color-border-primary: rgba(59, 130, 246, 0.2);
--color-border-focus: rgba(59, 130, 246, 0.4);
--color-success: #14B8A6;
--color-success-light: rgba(20, 184, 166, 0.1);
--color-success-border: rgba(20, 184, 166, 0.3);
--color-success: #22C55E;
--color-success-light: rgba(34, 197, 94, 0.1);
--color-success-border: rgba(34, 197, 94, 0.3);
--color-warning: #F59E0B;
--color-warning-light: rgba(245, 158, 11, 0.1);
--color-warning-border: rgba(245, 158, 11, 0.3);
--color-error: #EF4444;
--color-error-light: rgba(239, 68, 68, 0.1);
--color-error-border: rgba(239, 68, 68, 0.3);
--color-info: #38BDF8;
--color-info-light: rgba(56, 189, 248, 0.1);
--color-info-border: rgba(56, 189, 248, 0.3);
--color-accent-border: rgba(139, 92, 246, 0.3);
--color-info: #3B82F6;
--color-info-light: rgba(59, 130, 246, 0.1);
--color-info-border: rgba(59, 130, 246, 0.3);
--color-accent-border: rgba(245, 158, 11, 0.3);
--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-sm: 0 1px 3px rgba(0, 0, 0, 0.25);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
--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);
--duration-fast: 150ms;
@@ -91,22 +84,20 @@
--color-bg-tertiary: #FFFFFF;
--color-bg-overlay: rgba(248, 250, 252, 0.9);
--color-primary: #0EA5E9;
--color-primary-hover: #0284C7;
--color-primary-active: #0369A1;
--color-primary: #2563EB;
--color-primary-hover: #1D4ED8;
--color-primary-active: #1E40AF;
--color-primary-text: #FFFFFF;
--color-primary-light: rgba(14, 165, 233, 0.08);
--color-primary-border: rgba(14, 165, 233, 0.2);
--color-primary-light: rgba(37, 99, 235, 0.08);
--color-primary-border: rgba(37, 99, 235, 0.2);
--color-secondary: #0D9488;
--color-secondary-hover: #0F766E;
--color-secondary-light: rgba(13, 148, 136, 0.1);
--color-secondary: #475569;
--color-secondary-hover: #334155;
--color-secondary-light: rgba(71, 85, 105, 0.1);
--color-accent: #7C3AED;
--color-accent-hover: #6D28D9;
--color-accent-light: rgba(124, 58, 237, 0.1);
--color-accent-purple: #A78BFA;
--color-accent-teal: #2DD4BF;
--color-accent: #D97706;
--color-accent-hover: #B45309;
--color-accent-light: rgba(217, 119, 6, 0.1);
--color-text-primary: #1E293B;
--color-text-secondary: #64748B;
@@ -116,36 +107,31 @@
--color-border-subtle: rgba(15, 23, 42, 0.06);
--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-primary: rgba(14, 165, 233, 0.2);
--color-border-focus: rgba(14, 165, 233, 0.4);
--color-border-primary: rgba(37, 99, 235, 0.2);
--color-border-focus: rgba(37, 99, 235, 0.4);
--color-success: #0D9488;
--color-success-light: rgba(13, 148, 136, 0.1);
--color-success-border: rgba(13, 148, 136, 0.3);
--color-success: #16A34A;
--color-success-light: rgba(22, 163, 74, 0.1);
--color-success-border: rgba(22, 163, 74, 0.3);
--color-warning: #D97706;
--color-warning-light: rgba(217, 119, 6, 0.1);
--color-warning-border: rgba(217, 119, 6, 0.3);
--color-error: #DC2626;
--color-error-light: rgba(220, 38, 38, 0.1);
--color-error-border: rgba(220, 38, 38, 0.3);
--color-info: #0EA5E9;
--color-info-light: rgba(14, 165, 233, 0.1);
--color-info-border: rgba(14, 165, 233, 0.3);
--color-accent-border: rgba(124, 58, 237, 0.3);
--color-info: #2563EB;
--color-info-light: rgba(37, 99, 235, 0.1);
--color-info-border: rgba(37, 99, 235, 0.3);
--color-accent-border: rgba(217, 119, 6, 0.3);
--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-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
--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);
--color-toggle-off: #CBD5E1;
}

View File

@@ -1,6 +1,9 @@
import { API_CONFIG } from './config'
import { apiUrl } from './basePath'
const enc = encodeURIComponent
const userQ = (userId) => userId ? `?user_id=${enc(userId)}` : ''
async function handleResponse(response) {
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`
@@ -169,13 +172,13 @@ export const p2pApi = {
// Agent Jobs API
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)),
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' } }),
deleteTask: (id) => fetchJSON(API_CONFIG.endpoints.agentTask(id), { method: 'DELETE' }),
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)),
cancelJob: (id) => postJSON(API_CONFIG.endpoints.cancelAgentJob(id), {}),
executeJob: (body) => postJSON(API_CONFIG.endpoints.executeAgentJob, body),
@@ -264,57 +267,117 @@ export const systemApi = {
}
export const agentsApi = {
list: () => fetchJSON('/api/agents'),
list: (allUsers) => fetchJSON(`/api/agents${allUsers ? '?all_users=true' : ''}`),
create: (config) => postJSON('/api/agents', config),
get: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}`),
getConfig: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/config`),
update: (name, config) => fetchJSON(`/api/agents/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify(config), headers: { 'Content-Type': 'application/json' } }),
delete: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}`, { method: 'DELETE' }),
pause: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/pause`, { method: 'PUT' }),
resume: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/resume`, { method: 'PUT' }),
status: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/status`),
observables: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/observables`),
clearObservables: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/observables`, { method: 'DELETE' }),
chat: (name, message) => postJSON(`/api/agents/${encodeURIComponent(name)}/chat`, { message }),
export: (name) => fetchJSON(`/api/agents/${encodeURIComponent(name)}/export`),
get: (name, userId) => fetchJSON(`/api/agents/${enc(name)}${userQ(userId)}`),
getConfig: (name, userId) => fetchJSON(`/api/agents/${enc(name)}/config${userQ(userId)}`),
update: (name, config, userId) => fetchJSON(`/api/agents/${enc(name)}${userQ(userId)}`, { method: 'PUT', body: JSON.stringify(config), headers: { 'Content-Type': 'application/json' } }),
delete: (name, userId) => fetchJSON(`/api/agents/${enc(name)}${userQ(userId)}`, { method: 'DELETE' }),
pause: (name, userId) => fetchJSON(`/api/agents/${enc(name)}/pause${userQ(userId)}`, { method: 'PUT' }),
resume: (name, userId) => fetchJSON(`/api/agents/${enc(name)}/resume${userQ(userId)}`, { method: 'PUT' }),
status: (name, userId) => fetchJSON(`/api/agents/${enc(name)}/status${userQ(userId)}`),
observables: (name, userId) => fetchJSON(`/api/agents/${enc(name)}/observables${userQ(userId)}`),
clearObservables: (name, userId) => fetchJSON(`/api/agents/${enc(name)}/observables${userQ(userId)}`, { method: 'DELETE' }),
chat: (name, message, userId) => postJSON(`/api/agents/${enc(name)}/chat${userQ(userId)}`, { message }),
export: (name, userId) => fetchJSON(`/api/agents/${enc(name)}/export${userQ(userId)}`),
import: (formData) => fetch(apiUrl('/api/agents/import'), { method: 'POST', body: formData }).then(handleResponse),
configMeta: () => fetchJSON('/api/agents/config/metadata'),
sseUrl: (name, userId) => `/api/agents/${enc(name)}/sse${userQ(userId)}`,
}
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 }),
upload: (name, formData) => fetch(apiUrl(`/api/agents/collections/${encodeURIComponent(name)}/upload`), { method: 'POST', body: formData }).then(handleResponse),
entries: (name) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/entries`),
entryContent: (name, entry) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/entries/${encodeURIComponent(entry)}`),
search: (name, query, maxResults) => postJSON(`/api/agents/collections/${encodeURIComponent(name)}/search`, { query, max_results: maxResults }),
reset: (name) => postJSON(`/api/agents/collections/${encodeURIComponent(name)}/reset`),
deleteEntry: (name, entry) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/entry/delete`, { method: 'DELETE', body: JSON.stringify({ entry }), headers: { 'Content-Type': 'application/json' } }),
sources: (name) => fetchJSON(`/api/agents/collections/${encodeURIComponent(name)}/sources`),
addSource: (name, url, interval) => postJSON(`/api/agents/collections/${encodeURIComponent(name)}/sources`, { 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' } }),
upload: (name, formData, userId) => fetch(apiUrl(`/api/agents/collections/${enc(name)}/upload${userQ(userId)}`), { method: 'POST', body: formData }).then(handleResponse),
entries: (name, userId) => fetchJSON(`/api/agents/collections/${enc(name)}/entries${userQ(userId)}`),
entryContent: (name, entry, userId) => fetchJSON(`/api/agents/collections/${enc(name)}/entries/${encodeURIComponent(entry)}${userQ(userId)}`),
search: (name, query, maxResults, userId) => postJSON(`/api/agents/collections/${enc(name)}/search${userQ(userId)}`, { query, max_results: maxResults }),
reset: (name, userId) => postJSON(`/api/agents/collections/${enc(name)}/reset${userQ(userId)}`),
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, userId) => fetchJSON(`/api/agents/collections/${enc(name)}/sources${userQ(userId)}`),
addSource: (name, url, interval, userId) => postJSON(`/api/agents/collections/${enc(name)}/sources${userQ(userId)}`, { url, update_interval: interval }),
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
export const skillsApi = {
list: () => fetchJSON('/api/agents/skills'),
search: (q) => fetchJSON(`/api/agents/skills/search?q=${encodeURIComponent(q)}`),
get: (name) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}`),
list: (allUsers) => fetchJSON(`/api/agents/skills${allUsers ? '?all_users=true' : ''}`),
search: (q) => fetchJSON(`/api/agents/skills/search?q=${enc(q)}`),
get: (name, userId) => fetchJSON(`/api/agents/skills/${enc(name)}${userQ(userId)}`),
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' } }),
delete: (name) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}`, { method: 'DELETE' }),
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, 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(); }); },
exportUrl: (name) => apiUrl(`/api/agents/skills/export/${encodeURIComponent(name)}`),
listResources: (name) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources`),
getResource: (name, path, opts) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources/${path}${opts?.json ? '?encoding=base64' : ''}`),
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(); }); },
updateResource: (name, path, content) => postJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources/${path}`, { content }),
deleteResource: (name, path) => fetchJSON(`/api/agents/skills/${encodeURIComponent(name)}/resources/${path}`, { method: 'DELETE' }),
exportUrl: (name, userId) => apiUrl(`/api/agents/skills/export/${enc(name)}${userQ(userId)}`),
listResources: (name, userId) => fetchJSON(`/api/agents/skills/${enc(name)}/resources${userQ(userId)}`),
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/${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/${enc(name)}/resources/${path}`, { content }),
deleteResource: (name, path) => fetchJSON(`/api/agents/skills/${enc(name)}/resources/${path}`, { method: 'DELETE' }),
listGitRepos: () => fetchJSON('/api/agents/git-repos'),
addGitRepo: (url) => postJSON('/api/agents/git-repos', { url }),
syncGitRepo: (id) => postJSON(`/api/agents/git-repos/${encodeURIComponent(id)}/sync`, {}),
toggleGitRepo: (id) => postJSON(`/api/agents/git-repos/${encodeURIComponent(id)}/toggle`, {}),
deleteGitRepo: (id) => fetchJSON(`/api/agents/git-repos/${encodeURIComponent(id)}`, { method: 'DELETE' }),
syncGitRepo: (id) => postJSON(`/api/agents/git-repos/${enc(id)}/sync`, {}),
toggleGitRepo: (id) => postJSON(`/api/agents/git-repos/${enc(id)}/toggle`, {}),
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

View File

@@ -8,14 +8,14 @@ import (
"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 {
return
}
// Group all agent routes behind a middleware that returns 503 while the
// agent pool is still initializing (it starts after the HTTP server).
g := e.Group("/api/agents", func(next echo.HandlerFunc) echo.HandlerFunc {
// Middleware that returns 503 while the agent pool is still initializing.
poolReadyMw := func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if app.AgentPoolService() == nil {
return c.JSON(http.StatusServiceUnavailable, map[string]string{
@@ -24,67 +24,71 @@ func RegisterAgentPoolRoutes(e *echo.Echo, app *application.Application) {
}
return next(c)
}
})
}
// Agent Management
g.GET("", localai.ListAgentsEndpoint(app))
g.POST("", localai.CreateAgentEndpoint(app))
g.GET("/config/metadata", localai.GetAgentConfigMetaEndpoint(app))
g.POST("/import", localai.ImportAgentEndpoint(app))
g.GET("/:name", localai.GetAgentEndpoint(app))
g.PUT("/:name", localai.UpdateAgentEndpoint(app))
g.DELETE("/:name", localai.DeleteAgentEndpoint(app))
g.GET("/:name/config", localai.GetAgentConfigEndpoint(app))
g.PUT("/:name/pause", localai.PauseAgentEndpoint(app))
g.PUT("/:name/resume", localai.ResumeAgentEndpoint(app))
g.GET("/:name/status", localai.GetAgentStatusEndpoint(app))
g.GET("/:name/observables", localai.GetAgentObservablesEndpoint(app))
g.DELETE("/:name/observables", localai.ClearAgentObservablesEndpoint(app))
g.POST("/:name/chat", localai.ChatWithAgentEndpoint(app))
g.GET("/:name/sse", localai.AgentSSEEndpoint(app))
g.GET("/:name/export", localai.ExportAgentEndpoint(app))
g.GET("/:name/files", localai.AgentFileEndpoint(app))
// Agent management routes — require "agents" feature
ag := e.Group("/api/agents", poolReadyMw, agentsMw)
ag.GET("", localai.ListAgentsEndpoint(app))
ag.POST("", localai.CreateAgentEndpoint(app))
ag.GET("/config/metadata", localai.GetAgentConfigMetaEndpoint(app))
ag.POST("/import", localai.ImportAgentEndpoint(app))
ag.GET("/:name", localai.GetAgentEndpoint(app))
ag.PUT("/:name", localai.UpdateAgentEndpoint(app))
ag.DELETE("/:name", localai.DeleteAgentEndpoint(app))
ag.GET("/:name/config", localai.GetAgentConfigEndpoint(app))
ag.PUT("/:name/pause", localai.PauseAgentEndpoint(app))
ag.PUT("/:name/resume", localai.ResumeAgentEndpoint(app))
ag.GET("/:name/status", localai.GetAgentStatusEndpoint(app))
ag.GET("/:name/observables", localai.GetAgentObservablesEndpoint(app))
ag.DELETE("/:name/observables", localai.ClearAgentObservablesEndpoint(app))
ag.POST("/:name/chat", localai.ChatWithAgentEndpoint(app))
ag.GET("/:name/sse", localai.AgentSSEEndpoint(app))
ag.GET("/:name/export", localai.ExportAgentEndpoint(app))
ag.GET("/:name/files", localai.AgentFileEndpoint(app))
// Actions
g.GET("/actions", localai.ListActionsEndpoint(app))
g.POST("/actions/:name/definition", localai.GetActionDefinitionEndpoint(app))
g.POST("/actions/:name/run", localai.ExecuteActionEndpoint(app))
// Actions (part of agents feature)
ag.GET("/actions", localai.ListActionsEndpoint(app))
ag.POST("/actions/:name/definition", localai.GetActionDefinitionEndpoint(app))
ag.POST("/actions/:name/run", localai.ExecuteActionEndpoint(app))
// Skills
g.GET("/skills", localai.ListSkillsEndpoint(app))
g.GET("/skills/config", localai.GetSkillsConfigEndpoint(app))
g.GET("/skills/search", localai.SearchSkillsEndpoint(app))
g.POST("/skills", localai.CreateSkillEndpoint(app))
g.GET("/skills/export/*", localai.ExportSkillEndpoint(app))
g.POST("/skills/import", localai.ImportSkillEndpoint(app))
g.GET("/skills/:name", localai.GetSkillEndpoint(app))
g.PUT("/skills/:name", localai.UpdateSkillEndpoint(app))
g.DELETE("/skills/:name", localai.DeleteSkillEndpoint(app))
g.GET("/skills/:name/resources", localai.ListSkillResourcesEndpoint(app))
g.GET("/skills/:name/resources/*", localai.GetSkillResourceEndpoint(app))
g.POST("/skills/:name/resources", localai.CreateSkillResourceEndpoint(app))
g.PUT("/skills/:name/resources/*", localai.UpdateSkillResourceEndpoint(app))
g.DELETE("/skills/:name/resources/*", localai.DeleteSkillResourceEndpoint(app))
// Skills routes — require "skills" feature
sg := e.Group("/api/agents/skills", poolReadyMw, skillsMw)
sg.GET("", localai.ListSkillsEndpoint(app))
sg.GET("/config", localai.GetSkillsConfigEndpoint(app))
sg.GET("/search", localai.SearchSkillsEndpoint(app))
sg.POST("", localai.CreateSkillEndpoint(app))
sg.GET("/export/*", localai.ExportSkillEndpoint(app))
sg.POST("/import", localai.ImportSkillEndpoint(app))
sg.GET("/:name", localai.GetSkillEndpoint(app))
sg.PUT("/:name", localai.UpdateSkillEndpoint(app))
sg.DELETE("/:name", localai.DeleteSkillEndpoint(app))
sg.GET("/:name/resources", localai.ListSkillResourcesEndpoint(app))
sg.GET("/:name/resources/*", localai.GetSkillResourceEndpoint(app))
sg.POST("/:name/resources", localai.CreateSkillResourceEndpoint(app))
sg.PUT("/:name/resources/*", localai.UpdateSkillResourceEndpoint(app))
sg.DELETE("/:name/resources/*", localai.DeleteSkillResourceEndpoint(app))
// Git Repos
g.GET("/git-repos", localai.ListGitReposEndpoint(app))
g.POST("/git-repos", localai.AddGitRepoEndpoint(app))
g.PUT("/git-repos/:id", localai.UpdateGitRepoEndpoint(app))
g.DELETE("/git-repos/:id", localai.DeleteGitRepoEndpoint(app))
g.POST("/git-repos/:id/sync", localai.SyncGitRepoEndpoint(app))
g.POST("/git-repos/:id/toggle", localai.ToggleGitRepoEndpoint(app))
// Git Repos — guarded by skills feature (at original /api/agents/git-repos path)
gg := e.Group("/api/agents/git-repos", poolReadyMw, skillsMw)
gg.GET("", localai.ListGitReposEndpoint(app))
gg.POST("", localai.AddGitRepoEndpoint(app))
gg.PUT("/:id", localai.UpdateGitRepoEndpoint(app))
gg.DELETE("/:id", localai.DeleteGitRepoEndpoint(app))
gg.POST("/:id/sync", localai.SyncGitRepoEndpoint(app))
gg.POST("/:id/toggle", localai.ToggleGitRepoEndpoint(app))
// Collections / Knowledge Base
g.GET("/collections", localai.ListCollectionsEndpoint(app))
g.POST("/collections", localai.CreateCollectionEndpoint(app))
g.POST("/collections/:name/upload", localai.UploadToCollectionEndpoint(app))
g.GET("/collections/:name/entries", localai.ListCollectionEntriesEndpoint(app))
g.GET("/collections/:name/entries/*", localai.GetCollectionEntryContentEndpoint(app))
g.GET("/collections/:name/entries-raw/*", localai.GetCollectionEntryRawFileEndpoint(app))
g.POST("/collections/:name/search", localai.SearchCollectionEndpoint(app))
g.POST("/collections/:name/reset", localai.ResetCollectionEndpoint(app))
g.DELETE("/collections/:name/entry/delete", localai.DeleteCollectionEntryEndpoint(app))
g.POST("/collections/:name/sources", localai.AddCollectionSourceEndpoint(app))
g.DELETE("/collections/:name/sources", localai.RemoveCollectionSourceEndpoint(app))
g.GET("/collections/:name/sources", localai.ListCollectionSourcesEndpoint(app))
// Collections / Knowledge Base — require "collections" feature
cg := e.Group("/api/agents/collections", poolReadyMw, collectionsMw)
cg.GET("", localai.ListCollectionsEndpoint(app))
cg.POST("", localai.CreateCollectionEndpoint(app))
cg.POST("/:name/upload", localai.UploadToCollectionEndpoint(app))
cg.GET("/:name/entries", localai.ListCollectionEntriesEndpoint(app))
cg.GET("/:name/entries/*", localai.GetCollectionEntryContentEndpoint(app))
cg.GET("/:name/entries-raw/*", localai.GetCollectionEntryRawFileEndpoint(app))
cg.POST("/:name/search", localai.SearchCollectionEndpoint(app))
cg.POST("/:name/reset", localai.ResetCollectionEndpoint(app))
cg.DELETE("/:name/entry/delete", localai.DeleteCollectionEntryEndpoint(app))
cg.POST("/:name/sources", localai.AddCollectionSourceEndpoint(app))
cg.DELETE("/:name/sources", localai.RemoveCollectionSourceEndpoint(app))
cg.GET("/:name/sources", localai.ListCollectionSourcesEndpoint(app))
}

View File

@@ -28,6 +28,7 @@ func RegisterAnthropicRoutes(app *echo.Echo,
)
messagesMiddleware := []echo.MiddlewareFunc{
middleware.UsageMiddleware(application.AuthDB()),
middleware.TraceMiddleware(application),
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.AnthropicRequest) }),

1077
core/http/routes/auth.go Normal file
View File

File diff suppressed because it is too large Load Diff

View 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))
})
})
})

View File

@@ -22,7 +22,10 @@ func RegisterLocalAIRoutes(router *echo.Echo,
galleryService *services.GalleryService,
opcache *services.OpCache,
evaluator *templates.Evaluator,
app *application.Application) {
app *application.Application,
adminMiddleware echo.MiddlewareFunc,
mcpJobsMw echo.MiddlewareFunc,
mcpMw echo.MiddlewareFunc) {
router.GET("/swagger/*", echoswagger.WrapHandler) // default
@@ -36,40 +39,40 @@ func RegisterLocalAIRoutes(router *echo.Echo,
"Version": internal.PrintableVersion(),
"DisableRuntimeSettings": appConfig.DisableRuntimeSettings,
})
})
}, adminMiddleware)
// 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)
router.POST("/models/apply", modelGalleryEndpointService.ApplyModelGalleryEndpoint())
router.POST("/models/delete/:name", modelGalleryEndpointService.DeleteModelGalleryEndpoint())
router.POST("/models/apply", modelGalleryEndpointService.ApplyModelGalleryEndpoint(), adminMiddleware)
router.POST("/models/delete/:name", modelGalleryEndpointService.DeleteModelGalleryEndpoint(), adminMiddleware)
router.GET("/models/available", modelGalleryEndpointService.ListModelFromGalleryEndpoint(appConfig.SystemState))
router.GET("/models/galleries", modelGalleryEndpointService.ListModelGalleriesEndpoint())
router.GET("/models/jobs/:uuid", modelGalleryEndpointService.GetOpStatusEndpoint())
router.GET("/models/jobs", modelGalleryEndpointService.GetAllStatusEndpoint())
router.GET("/models/available", modelGalleryEndpointService.ListModelFromGalleryEndpoint(appConfig.SystemState), adminMiddleware)
router.GET("/models/galleries", modelGalleryEndpointService.ListModelGalleriesEndpoint(), adminMiddleware)
router.GET("/models/jobs/:uuid", modelGalleryEndpointService.GetOpStatusEndpoint(), adminMiddleware)
router.GET("/models/jobs", modelGalleryEndpointService.GetAllStatusEndpoint(), adminMiddleware)
backendGalleryEndpointService := localai.CreateBackendEndpointService(
appConfig.BackendGalleries,
appConfig.SystemState,
galleryService)
router.POST("/backends/apply", backendGalleryEndpointService.ApplyBackendEndpoint())
router.POST("/backends/delete/:name", backendGalleryEndpointService.DeleteBackendEndpoint())
router.GET("/backends", backendGalleryEndpointService.ListBackendsEndpoint(appConfig.SystemState))
router.GET("/backends/available", backendGalleryEndpointService.ListAvailableBackendsEndpoint(appConfig.SystemState))
router.GET("/backends/galleries", backendGalleryEndpointService.ListBackendGalleriesEndpoint())
router.GET("/backends/jobs/:uuid", backendGalleryEndpointService.GetOpStatusEndpoint())
router.POST("/backends/apply", backendGalleryEndpointService.ApplyBackendEndpoint(), adminMiddleware)
router.POST("/backends/delete/:name", backendGalleryEndpointService.DeleteBackendEndpoint(), adminMiddleware)
router.GET("/backends", backendGalleryEndpointService.ListBackendsEndpoint(appConfig.SystemState), adminMiddleware)
router.GET("/backends/available", backendGalleryEndpointService.ListAvailableBackendsEndpoint(appConfig.SystemState), adminMiddleware)
router.GET("/backends/galleries", backendGalleryEndpointService.ListBackendGalleriesEndpoint(), adminMiddleware)
router.GET("/backends/jobs/:uuid", backendGalleryEndpointService.GetOpStatusEndpoint(), adminMiddleware)
// 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
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
router.POST("/models/edit/:name", localai.EditModelEndpoint(cl, ml, appConfig))
router.POST("/models/edit/:name", localai.EditModelEndpoint(cl, ml, appConfig), adminMiddleware)
// 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)
@@ -101,7 +104,7 @@ func RegisterLocalAIRoutes(router *echo.Echo,
router.POST("/stores/find", localai.StoresFindEndpoint(ml, appConfig))
if !appConfig.DisableMetrics {
router.GET("/metrics", localai.LocalAIMetricsEndpoint())
router.GET("/metrics", localai.LocalAIMetricsEndpoint(), adminMiddleware)
}
videoHandler := localai.VideoEndpoint(cl, ml, appConfig)
@@ -113,15 +116,15 @@ func RegisterLocalAIRoutes(router *echo.Echo,
// Backend Statistics Module
// TODO: Should these use standard middlewares? Refactor later, they are extremely simple.
backendMonitorService := services.NewBackendMonitorService(ml, cl, appConfig) // Split out for now
router.GET("/backend/monitor", localai.BackendMonitorEndpoint(backendMonitorService))
router.POST("/backend/shutdown", localai.BackendShutdownEndpoint(backendMonitorService))
router.GET("/backend/monitor", localai.BackendMonitorEndpoint(backendMonitorService), adminMiddleware)
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.
router.GET("/v1/backend/monitor", localai.BackendMonitorEndpoint(backendMonitorService))
router.POST("/v1/backend/shutdown", localai.BackendShutdownEndpoint(backendMonitorService))
router.GET("/v1/backend/monitor", localai.BackendMonitorEndpoint(backendMonitorService), adminMiddleware)
router.POST("/v1/backend/shutdown", localai.BackendShutdownEndpoint(backendMonitorService), adminMiddleware)
// p2p
router.GET("/api/p2p", localai.ShowP2PNodes(appConfig))
router.GET("/api/p2p/token", localai.ShowP2PToken(appConfig))
router.GET("/api/p2p", localai.ShowP2PNodes(appConfig), adminMiddleware)
router.GET("/api/p2p/token", localai.ShowP2PToken(appConfig), adminMiddleware)
router.GET("/version", func(c echo.Context) error {
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
tokenizeHandler := localai.TokenizeEndpoint(cl, ml, appConfig)
@@ -166,37 +169,37 @@ func RegisterLocalAIRoutes(router *echo.Echo,
router.POST("/mcp/chat/completions", mcpStreamHandler, mcpStreamMiddleware...)
// 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
router.GET("/v1/mcp/prompts/:model", localai.MCPPromptsEndpoint(cl, appConfig))
router.POST("/v1/mcp/prompts/:model/:prompt", localai.MCPGetPromptEndpoint(cl, appConfig))
router.GET("/v1/mcp/prompts/:model", localai.MCPPromptsEndpoint(cl, appConfig), mcpMw)
router.POST("/v1/mcp/prompts/:model/:prompt", localai.MCPGetPromptEndpoint(cl, appConfig), mcpMw)
// MCP resources endpoints
router.GET("/v1/mcp/resources/:model", localai.MCPResourcesEndpoint(cl, appConfig))
router.POST("/v1/mcp/resources/:model/read", localai.MCPReadResourceEndpoint(cl, appConfig))
router.GET("/v1/mcp/resources/:model", localai.MCPResourcesEndpoint(cl, appConfig), mcpMw)
router.POST("/v1/mcp/resources/:model/read", localai.MCPReadResourceEndpoint(cl, appConfig), mcpMw)
// CORS proxy for client-side MCP connections
router.GET("/api/cors-proxy", localai.CORSProxyEndpoint(appConfig))
router.POST("/api/cors-proxy", localai.CORSProxyEndpoint(appConfig))
router.GET("/api/cors-proxy", localai.CORSProxyEndpoint(appConfig), mcpMw)
router.POST("/api/cors-proxy", localai.CORSProxyEndpoint(appConfig), mcpMw)
router.OPTIONS("/api/cors-proxy", localai.CORSProxyOptionsEndpoint())
}
// Agent job routes (MCP CI Jobs — requires MCP to be enabled)
if app != nil && app.AgentJobService() != nil && !appConfig.DisableMCP {
router.POST("/api/agent/tasks", localai.CreateTaskEndpoint(app))
router.PUT("/api/agent/tasks/:id", localai.UpdateTaskEndpoint(app))
router.DELETE("/api/agent/tasks/:id", localai.DeleteTaskEndpoint(app))
router.GET("/api/agent/tasks", localai.ListTasksEndpoint(app))
router.GET("/api/agent/tasks/:id", localai.GetTaskEndpoint(app))
router.POST("/api/agent/tasks", localai.CreateTaskEndpoint(app), mcpJobsMw)
router.PUT("/api/agent/tasks/:id", localai.UpdateTaskEndpoint(app), mcpJobsMw)
router.DELETE("/api/agent/tasks/:id", localai.DeleteTaskEndpoint(app), mcpJobsMw)
router.GET("/api/agent/tasks", localai.ListTasksEndpoint(app), mcpJobsMw)
router.GET("/api/agent/tasks/:id", localai.GetTaskEndpoint(app), mcpJobsMw)
router.POST("/api/agent/jobs/execute", localai.ExecuteJobEndpoint(app))
router.GET("/api/agent/jobs/:id", localai.GetJobEndpoint(app))
router.GET("/api/agent/jobs", localai.ListJobsEndpoint(app))
router.POST("/api/agent/jobs/:id/cancel", localai.CancelJobEndpoint(app))
router.DELETE("/api/agent/jobs/:id", localai.DeleteJobEndpoint(app))
router.POST("/api/agent/jobs/execute", localai.ExecuteJobEndpoint(app), mcpJobsMw)
router.GET("/api/agent/jobs/:id", localai.GetJobEndpoint(app), mcpJobsMw)
router.GET("/api/agent/jobs", localai.ListJobsEndpoint(app), mcpJobsMw)
router.POST("/api/agent/jobs/:id/cancel", localai.CancelJobEndpoint(app), mcpJobsMw)
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)
}
}

View File

@@ -15,6 +15,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
application *application.Application) {
// openAI compatible API endpoint
traceMiddleware := middleware.TraceMiddleware(application)
usageMiddleware := middleware.UsageMiddleware(application.AuthDB())
// realtime
// 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
chatHandler := openai.ChatEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig())
chatMiddleware := []echo.MiddlewareFunc{
usageMiddleware,
traceMiddleware,
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }),
@@ -44,6 +46,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
// edit
editHandler := openai.EditEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig())
editMiddleware := []echo.MiddlewareFunc{
usageMiddleware,
traceMiddleware,
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_EDIT)),
re.BuildConstantDefaultModelNameMiddleware("gpt-4o"),
@@ -63,6 +66,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
// completion
completionHandler := openai.CompletionEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.TemplatesEvaluator(), application.ApplicationConfig())
completionMiddleware := []echo.MiddlewareFunc{
usageMiddleware,
traceMiddleware,
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_COMPLETION)),
re.BuildConstantDefaultModelNameMiddleware("gpt-4o"),
@@ -83,6 +87,7 @@ func RegisterOpenAIRoutes(app *echo.Echo,
// embeddings
embeddingHandler := openai.EmbeddingsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
embeddingMiddleware := []echo.MiddlewareFunc{
usageMiddleware,
traceMiddleware,
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_EMBEDDINGS)),
re.BuildConstantDefaultModelNameMiddleware("gpt-4o"),
@@ -154,6 +159,6 @@ func RegisterOpenAIRoutes(app *echo.Echo,
app.POST("/images/inpainting", inpaintingHandler, imageMiddleware...)
// List models
app.GET("/v1/models", openai.ListModelsEndpoint(application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig()))
app.GET("/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(), application.AuthDB()))
}

View File

@@ -26,6 +26,7 @@ func RegisterOpenResponsesRoutes(app *echo.Echo,
// Intercept requests where the model name matches an agent — route directly
// to the agent pool without going through the model config resolution pipeline.
localai.AgentResponsesInterceptor(application),
middleware.UsageMiddleware(application.AuthDB()),
middleware.TraceMiddleware(application),
re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)),
re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenResponsesRequest) }),
@@ -40,8 +41,8 @@ func RegisterOpenResponsesRoutes(app *echo.Echo,
// WebSocket mode for Responses API
wsHandler := openresponses.WebSocketEndpoint(application)
app.GET("/v1/responses", wsHandler)
app.GET("/responses", wsHandler)
app.GET("/v1/responses", wsHandler, middleware.UsageMiddleware(application.AuthDB()), middleware.TraceMiddleware(application))
app.GET("/responses", wsHandler, middleware.UsageMiddleware(application.AuthDB()), middleware.TraceMiddleware(application))
// GET /responses/:id - Retrieve a response (for polling background requests)
getResponseHandler := openresponses.GetResponseEndpoint()

View File

@@ -29,7 +29,8 @@ func RegisterUIRoutes(app *echo.Echo,
cl *config.ModelConfigLoader,
ml *model.ModelLoader,
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
// 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 {
return c.JSON(200, middleware.GetTraces())
})
}, adminMiddleware)
app.POST("/api/traces/clear", func(c echo.Context) error {
middleware.ClearTraces()
return c.NoContent(204)
})
}, adminMiddleware)
app.GET("/api/backend-traces", func(c echo.Context) error {
return c.JSON(200, trace.GetBackendTraces())
})
}, adminMiddleware)
app.POST("/api/backend-traces/clear", func(c echo.Context) error {
trace.ClearBackendTraces()
return c.NoContent(204)
})
}, adminMiddleware)
// Backend logs REST endpoints
app.GET("/api/backend-logs", func(c echo.Context) error {
return c.JSON(200, ml.BackendLogs().ListModels())
})
}, adminMiddleware)
app.GET("/api/backend-logs/:modelId", func(c echo.Context) error {
modelID := c.Param("modelId")
return c.JSON(200, ml.BackendLogs().GetLines(modelID))
})
}, adminMiddleware)
app.POST("/api/backend-logs/:modelId/clear", func(c echo.Context) error {
ml.BackendLogs().Clear(c.Param("modelId"))
return c.NoContent(204)
})
}, adminMiddleware)
// Backend logs WebSocket endpoint for real-time streaming
app.GET("/ws/backend-logs/:modelId", func(c echo.Context) error {
@@ -177,7 +178,7 @@ func RegisterUIRoutes(app *echo.Echo,
return nil
}
}
})
}, adminMiddleware)
}
// backendLogsConn wraps a websocket connection with a mutex for safe concurrent writes

View File

@@ -20,6 +20,7 @@ import (
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"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/middleware"
"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
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)
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{}{
"operations": operations,
})
})
}, adminMiddleware)
// Cancel operation endpoint
// Cancel operation endpoint (admin only)
app.POST("/api/operations/:jobID/cancel", func(c echo.Context) error {
jobID := c.Param("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,
"message": "Operation cancelled",
})
})
}, adminMiddleware)
// Dismiss a failed operation (acknowledge the error and remove it from the list)
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,
"message": "Operation dismissed",
})
})
}, adminMiddleware)
// Model Gallery APIs
// Model Gallery APIs (admin only)
app.GET("/api/models", func(c echo.Context) error {
term := c.QueryParam("term")
page := c.QueryParam("page")
@@ -488,7 +489,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
"prevPage": prevPage,
"nextPage": nextPage,
})
})
}, adminMiddleware)
// Returns installed models with their capability flags for UI filtering
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{
"data": result,
})
@@ -561,7 +582,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
"jobID": uid,
"message": "Installation started",
})
})
}, adminMiddleware)
app.POST("/api/models/delete/:id", func(c echo.Context) error {
galleryID := c.Param("id")
@@ -611,7 +632,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
"jobID": uid,
"message": "Deletion started",
})
})
}, adminMiddleware)
app.POST("/api/models/config/:id", func(c echo.Context) error {
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{}{
"message": "Configuration file saved",
})
})
}, adminMiddleware)
// 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 {
@@ -674,7 +695,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
}
return c.JSON(http.StatusOK, modelConfig)
})
}, adminMiddleware)
// Get installed model YAML config for the React model editor
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),
"name": modelName,
})
})
}, adminMiddleware)
app.GET("/api/models/job/:uid", func(c echo.Context) error {
jobUID := c.Param("uid")
@@ -750,7 +771,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
}
return c.JSON(200, response)
})
}, adminMiddleware)
// Backend Gallery APIs
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,
"systemCapability": detectedCapability,
})
})
}, adminMiddleware)
app.POST("/api/backends/install/:id", func(c echo.Context) error {
backendID := c.Param("id")
@@ -945,7 +966,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
"jobID": uid,
"message": "Backend installation started",
})
})
}, adminMiddleware)
// Install backend from external source (OCI image, URL, or path)
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,
"message": "External backend installation started",
})
})
}, adminMiddleware)
app.POST("/api/backends/delete/:id", func(c echo.Context) error {
backendID := c.Param("id")
@@ -1057,7 +1078,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
"jobID": uid,
"message": "Backend deletion started",
})
})
}, adminMiddleware)
app.GET("/api/backends/job/:uid", func(c echo.Context) error {
jobUID := c.Param("uid")
@@ -1094,7 +1115,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
}
return c.JSON(200, response)
})
}, adminMiddleware)
// System Backend Deletion API (for installed backends on index page)
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,
"message": "Backend deleted successfully",
})
})
}, adminMiddleware)
// P2P APIs
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
"nodes": llamaJSON,
})
})
}, adminMiddleware)
app.GET("/api/p2p/federation", func(c echo.Context) error {
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{}{
"nodes": nodesJSON,
})
})
}, adminMiddleware)
app.GET("/api/p2p/stats", func(c echo.Context) error {
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),
},
})
})
}, adminMiddleware)
// Resources API endpoint - unified memory info (GPU if available, otherwise RAM)
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)
})
}, adminMiddleware)
if !appConfig.DisableRuntimeSettings {
// Settings API
app.GET("/api/settings", localai.GetSettingsEndpoint(applicationInstance))
app.POST("/api/settings", localai.UpdateSettingsEndpoint(applicationInstance))
app.GET("/api/settings", localai.GetSettingsEndpoint(applicationInstance), adminMiddleware)
app.POST("/api/settings", localai.UpdateSettingsEndpoint(applicationInstance), adminMiddleware)
}
// Logs API
// Logs API (admin only)
app.GET("/api/traces", func(c echo.Context) error {
if !appConfig.EnableTracing {
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{}{
"traces": traces,
})
})
}, adminMiddleware)
app.POST("/api/traces/clear", func(c echo.Context) error {
middleware.ClearTraces()
return c.JSON(200, map[string]interface{}{
"message": "Traces cleared",
})
})
}, adminMiddleware)
}

View File

@@ -73,7 +73,9 @@ var _ = Describe("Backend API Routes", func() {
// Register the API routes for backends
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() {

View File

@@ -88,11 +88,6 @@ func NewAgentJobService(
configLoader *config.ModelConfigLoader,
evaluator *templates.Evaluator,
) *AgentJobService {
retentionDays := appConfig.AgentJobRetentionDays
if retentionDays == 0 {
retentionDays = 30 // Default
}
// Determine storage directory: DataPath > DynamicConfigsDir
tasksFile := ""
jobsFile := ""
@@ -105,6 +100,22 @@ func NewAgentJobService(
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{
appConfig: appConfig,
modelLoader: modelLoader,

View File

File diff suppressed because it is too large Load Diff

View 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)
}
}
}

View 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
}

View File

@@ -32,6 +32,8 @@ services:
- models:/models
- images:/tmp/generated/images/
- data:/data
- backends:/backends
- configuration:/configuration
command:
# 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:
@@ -64,7 +66,7 @@ services:
# - POSTGRES_USER=localrecall
# - POSTGRES_PASSWORD=localrecall
# volumes:
# - postgres_data:/var/lib/postgresql/data
# - postgres_data:/var/lib/postgresql
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U localrecall"]
# interval: 10s
@@ -75,4 +77,6 @@ volumes:
models:
images:
data:
configuration:
backends:
# postgres_data:

View 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.

View File

@@ -63,6 +63,8 @@ You can configure these settings via the web UI or through environment variables
- **CSRF**: Enable CSRF protection middleware
- **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
Configure peer-to-peer networking for distributed inference:

View File

@@ -12,7 +12,10 @@ icon = "rocket_launch"
**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 %}}

View File

@@ -90,12 +90,17 @@ The `/v1/responses` endpoint returns errors with this structure:
### 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
- `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:**

View File

@@ -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` |
| `--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
| Parameter | Default | Description | Environment Variable |

Some files were not shown because too many files have changed in this diff Show More