mirror of
https://github.com/mudler/LocalAI.git
synced 2026-03-31 21:25:59 -04:00
feat: add users and authentication support (#9061)
* feat(ui): add users and authentication support Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat: allow the admin user to impersonificate users Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: ui improvements, disable 'Users' button in navbar when no auth is configured Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat: add OIDC support Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix: gate models Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: cache requests to optimize speed Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * small UI enhancements Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(ui): style improvements Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix: cover other paths by auth Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: separate local auth, refactor Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * security hardening, approval mode Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix: fix tests and expectations Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore: update localagi/localrecall Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
committed by
GitHub
parent
bbe9067227
commit
aea21951a2
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
121
core/http/auth/apikeys.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
apiKeyPrefix = "lai-"
|
||||
apiKeyRandBytes = 32 // 32 bytes = 64 hex chars
|
||||
keyPrefixLen = 8 // display prefix length (from the random part)
|
||||
)
|
||||
|
||||
// GenerateAPIKey generates a new API key. Returns the plaintext key,
|
||||
// its HMAC-SHA256 hash, and a display prefix.
|
||||
func GenerateAPIKey(hmacSecret string) (plaintext, hash, prefix string, err error) {
|
||||
b := make([]byte, apiKeyRandBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to generate API key: %w", err)
|
||||
}
|
||||
|
||||
randHex := hex.EncodeToString(b)
|
||||
plaintext = apiKeyPrefix + randHex
|
||||
hash = HashAPIKey(plaintext, hmacSecret)
|
||||
prefix = plaintext[:len(apiKeyPrefix)+keyPrefixLen]
|
||||
|
||||
return plaintext, hash, prefix, nil
|
||||
}
|
||||
|
||||
// HashAPIKey returns the HMAC-SHA256 hex digest of the given plaintext key.
|
||||
// If hmacSecret is empty, falls back to plain SHA-256 for backward compatibility.
|
||||
func HashAPIKey(plaintext, hmacSecret string) string {
|
||||
if hmacSecret == "" {
|
||||
h := sha256.Sum256([]byte(plaintext))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(hmacSecret))
|
||||
mac.Write([]byte(plaintext))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// CreateAPIKey generates and stores a new API key for the given user.
|
||||
// Returns the plaintext key (shown once) and the database record.
|
||||
func CreateAPIKey(db *gorm.DB, userID, name, role, hmacSecret string, expiresAt *time.Time) (string, *UserAPIKey, error) {
|
||||
plaintext, hash, prefix, err := GenerateAPIKey(hmacSecret)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
record := &UserAPIKey{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
KeyHash: hash,
|
||||
KeyPrefix: prefix,
|
||||
Role: role,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
if err := db.Create(record).Error; err != nil {
|
||||
return "", nil, fmt.Errorf("failed to store API key: %w", err)
|
||||
}
|
||||
|
||||
return plaintext, record, nil
|
||||
}
|
||||
|
||||
// ValidateAPIKey looks up an API key by hashing the plaintext and searching
|
||||
// the database. Returns the key record if found, or an error.
|
||||
// Updates LastUsed on successful validation.
|
||||
func ValidateAPIKey(db *gorm.DB, plaintext, hmacSecret string) (*UserAPIKey, error) {
|
||||
hash := HashAPIKey(plaintext, hmacSecret)
|
||||
|
||||
var key UserAPIKey
|
||||
if err := db.Preload("User").Where("key_hash = ?", hash).First(&key).Error; err != nil {
|
||||
return nil, fmt.Errorf("invalid API key")
|
||||
}
|
||||
|
||||
if key.ExpiresAt != nil && time.Now().After(*key.ExpiresAt) {
|
||||
return nil, fmt.Errorf("API key expired")
|
||||
}
|
||||
|
||||
if key.User.Status != StatusActive {
|
||||
return nil, fmt.Errorf("user account is not active")
|
||||
}
|
||||
|
||||
// Update LastUsed
|
||||
now := time.Now()
|
||||
db.Model(&key).Update("last_used", now)
|
||||
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// ListAPIKeys returns all API keys for the given user (without plaintext).
|
||||
func ListAPIKeys(db *gorm.DB, userID string) ([]UserAPIKey, error) {
|
||||
var keys []UserAPIKey
|
||||
if err := db.Where("user_id = ?", userID).Order("created_at DESC").Find(&keys).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// RevokeAPIKey deletes an API key. Only the owner can revoke their own key.
|
||||
func RevokeAPIKey(db *gorm.DB, keyID, userID string) error {
|
||||
result := db.Where("id = ? AND user_id = ?", keyID, userID).Delete(&UserAPIKey{})
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("API key not found or not owned by user")
|
||||
}
|
||||
return result.Error
|
||||
}
|
||||
|
||||
// CleanExpiredAPIKeys removes all API keys that have passed their expiry time.
|
||||
func CleanExpiredAPIKeys(db *gorm.DB) error {
|
||||
return db.Where("expires_at IS NOT NULL AND expires_at < ?", time.Now()).Delete(&UserAPIKey{}).Error
|
||||
}
|
||||
212
core/http/auth/apikeys_test.go
Normal file
212
core/http/auth/apikeys_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
//go:build auth
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var _ = Describe("API Keys", func() {
|
||||
var (
|
||||
db *gorm.DB
|
||||
user *auth.User
|
||||
)
|
||||
|
||||
// Use empty HMAC secret for tests (falls back to plain SHA-256)
|
||||
hmacSecret := ""
|
||||
|
||||
BeforeEach(func() {
|
||||
db = testDB()
|
||||
user = createTestUser(db, "apikey@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||
})
|
||||
|
||||
Describe("GenerateAPIKey", func() {
|
||||
It("returns key with 'lai-' prefix", func() {
|
||||
plaintext, _, _, err := auth.GenerateAPIKey(hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(plaintext).To(HavePrefix("lai-"))
|
||||
})
|
||||
|
||||
It("returns consistent hash for same plaintext", func() {
|
||||
plaintext, hash, _, err := auth.GenerateAPIKey(hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(auth.HashAPIKey(plaintext, hmacSecret)).To(Equal(hash))
|
||||
})
|
||||
|
||||
It("returns prefix for display", func() {
|
||||
_, _, prefix, err := auth.GenerateAPIKey(hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(prefix).To(HavePrefix("lai-"))
|
||||
Expect(len(prefix)).To(Equal(12)) // "lai-" + 8 chars
|
||||
})
|
||||
|
||||
It("generates unique keys", func() {
|
||||
key1, _, _, _ := auth.GenerateAPIKey(hmacSecret)
|
||||
key2, _, _, _ := auth.GenerateAPIKey(hmacSecret)
|
||||
Expect(key1).ToNot(Equal(key2))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CreateAPIKey", func() {
|
||||
It("stores hashed key in DB", func() {
|
||||
plaintext, record, err := auth.CreateAPIKey(db, user.ID, "test key", auth.RoleUser, hmacSecret, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(plaintext).To(HavePrefix("lai-"))
|
||||
Expect(record.KeyHash).To(Equal(auth.HashAPIKey(plaintext, hmacSecret)))
|
||||
})
|
||||
|
||||
It("does not store plaintext in DB", func() {
|
||||
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "test key", auth.RoleUser, hmacSecret, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var keys []auth.UserAPIKey
|
||||
db.Find(&keys)
|
||||
for _, k := range keys {
|
||||
Expect(k.KeyHash).ToNot(Equal(plaintext))
|
||||
Expect(strings.Contains(k.KeyHash, "lai-")).To(BeFalse())
|
||||
}
|
||||
})
|
||||
|
||||
It("inherits role from parameter", func() {
|
||||
_, record, err := auth.CreateAPIKey(db, user.ID, "admin key", auth.RoleAdmin, hmacSecret, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(record.Role).To(Equal(auth.RoleAdmin))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ValidateAPIKey", func() {
|
||||
It("returns UserAPIKey for valid key", func() {
|
||||
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "valid key", auth.RoleUser, hmacSecret, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
found, err := auth.ValidateAPIKey(db, plaintext, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(found).ToNot(BeNil())
|
||||
Expect(found.UserID).To(Equal(user.ID))
|
||||
})
|
||||
|
||||
It("returns error for invalid key", func() {
|
||||
_, err := auth.ValidateAPIKey(db, "lai-invalidkey12345678901234567890", hmacSecret)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("updates LastUsed timestamp", func() {
|
||||
plaintext, record, err := auth.CreateAPIKey(db, user.ID, "used key", auth.RoleUser, hmacSecret, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(record.LastUsed).To(BeNil())
|
||||
|
||||
_, err = auth.ValidateAPIKey(db, plaintext, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var updated auth.UserAPIKey
|
||||
db.First(&updated, "id = ?", record.ID)
|
||||
Expect(updated.LastUsed).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("loads associated user", func() {
|
||||
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "with user", auth.RoleUser, hmacSecret, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
found, err := auth.ValidateAPIKey(db, plaintext, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(found.User.ID).To(Equal(user.ID))
|
||||
Expect(found.User.Email).To(Equal("apikey@example.com"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ListAPIKeys", func() {
|
||||
It("returns all keys for the user", func() {
|
||||
auth.CreateAPIKey(db, user.ID, "key1", auth.RoleUser, hmacSecret, nil)
|
||||
auth.CreateAPIKey(db, user.ID, "key2", auth.RoleUser, hmacSecret, nil)
|
||||
|
||||
keys, err := auth.ListAPIKeys(db, user.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(keys).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("does not return other users' keys", func() {
|
||||
other := createTestUser(db, "other@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||
auth.CreateAPIKey(db, user.ID, "my key", auth.RoleUser, hmacSecret, nil)
|
||||
auth.CreateAPIKey(db, other.ID, "other key", auth.RoleUser, hmacSecret, nil)
|
||||
|
||||
keys, err := auth.ListAPIKeys(db, user.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(keys).To(HaveLen(1))
|
||||
Expect(keys[0].Name).To(Equal("my key"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with HMAC secret", func() {
|
||||
hmacSecretVal := "test-hmac-secret-456"
|
||||
|
||||
It("generates different hash than empty secret", func() {
|
||||
plaintext, _, _, err := auth.GenerateAPIKey("")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
hashEmpty := auth.HashAPIKey(plaintext, "")
|
||||
hashHMAC := auth.HashAPIKey(plaintext, hmacSecretVal)
|
||||
Expect(hashEmpty).ToNot(Equal(hashHMAC))
|
||||
})
|
||||
|
||||
It("round-trips CreateAPIKey and ValidateAPIKey with HMAC secret", func() {
|
||||
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "hmac key", auth.RoleUser, hmacSecretVal, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
found, err := auth.ValidateAPIKey(db, plaintext, hmacSecretVal)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(found).ToNot(BeNil())
|
||||
Expect(found.UserID).To(Equal(user.ID))
|
||||
})
|
||||
|
||||
It("does not validate with wrong HMAC secret", func() {
|
||||
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "hmac key2", auth.RoleUser, hmacSecretVal, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = auth.ValidateAPIKey(db, plaintext, "wrong-secret")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("does not validate key created with empty secret using non-empty secret", func() {
|
||||
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "empty-secret key", auth.RoleUser, "", nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = auth.ValidateAPIKey(db, plaintext, hmacSecretVal)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("does not validate key created with non-empty secret using empty secret", func() {
|
||||
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "nonempty-secret key", auth.RoleUser, hmacSecretVal, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = auth.ValidateAPIKey(db, plaintext, "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RevokeAPIKey", func() {
|
||||
It("deletes the key record", func() {
|
||||
plaintext, record, err := auth.CreateAPIKey(db, user.ID, "to revoke", auth.RoleUser, hmacSecret, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = auth.RevokeAPIKey(db, record.ID, user.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = auth.ValidateAPIKey(db, plaintext, hmacSecret)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("only allows owner to revoke their own key", func() {
|
||||
_, record, err := auth.CreateAPIKey(db, user.ID, "mine", auth.RoleUser, hmacSecret, nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
other := createTestUser(db, "attacker@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||
err = auth.RevokeAPIKey(db, record.ID, other.ID)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
15
core/http/auth/auth_suite_test.go
Normal file
15
core/http/auth/auth_suite_test.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build auth
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
func TestAuth(t *testing.T) {
|
||||
RegisterFailHandler(Fail)
|
||||
RunSpecs(t, "Auth Suite")
|
||||
}
|
||||
49
core/http/auth/db.go
Normal file
49
core/http/auth/db.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// InitDB initializes the auth database. If databaseURL starts with "postgres://"
|
||||
// or "postgresql://", it connects to PostgreSQL; otherwise it treats the value
|
||||
// as a SQLite file path (use ":memory:" for in-memory).
|
||||
// SQLite support requires building with the "auth" build tag (CGO).
|
||||
func InitDB(databaseURL string) (*gorm.DB, error) {
|
||||
var dialector gorm.Dialector
|
||||
|
||||
if strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://") {
|
||||
dialector = postgres.Open(databaseURL)
|
||||
} else {
|
||||
d, err := openSQLiteDialector(databaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialector = d
|
||||
}
|
||||
|
||||
db, err := gorm.Open(dialector, &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open auth database: %w", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&User{}, &Session{}, &UserAPIKey{}, &UsageRecord{}, &UserPermission{}, &InviteCode{}); err != nil {
|
||||
return nil, fmt.Errorf("failed to migrate auth tables: %w", err)
|
||||
}
|
||||
|
||||
// Create composite index on users(provider, subject) for fast OAuth lookups
|
||||
if err := db.Exec("CREATE INDEX IF NOT EXISTS idx_users_provider_subject ON users(provider, subject)").Error; err != nil {
|
||||
// Ignore error on postgres if index already exists
|
||||
if !strings.Contains(err.Error(), "already exists") {
|
||||
return nil, fmt.Errorf("failed to create composite index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
13
core/http/auth/db_nosqlite.go
Normal file
13
core/http/auth/db_nosqlite.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !auth
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func openSQLiteDialector(path string) (gorm.Dialector, error) {
|
||||
return nil, fmt.Errorf("SQLite auth database requires building with -tags auth (CGO); use DATABASE_URL with PostgreSQL instead")
|
||||
}
|
||||
12
core/http/auth/db_sqlite.go
Normal file
12
core/http/auth/db_sqlite.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build auth
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func openSQLiteDialector(path string) (gorm.Dialector, error) {
|
||||
return sqlite.Open(path), nil
|
||||
}
|
||||
53
core/http/auth/db_test.go
Normal file
53
core/http/auth/db_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
//go:build auth
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("InitDB", func() {
|
||||
Context("SQLite", func() {
|
||||
It("creates all tables with in-memory SQLite", func() {
|
||||
db, err := auth.InitDB(":memory:")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(db).ToNot(BeNil())
|
||||
|
||||
// Verify tables exist
|
||||
Expect(db.Migrator().HasTable(&auth.User{})).To(BeTrue())
|
||||
Expect(db.Migrator().HasTable(&auth.Session{})).To(BeTrue())
|
||||
Expect(db.Migrator().HasTable(&auth.UserAPIKey{})).To(BeTrue())
|
||||
})
|
||||
|
||||
It("is idempotent - running twice does not error", func() {
|
||||
db, err := auth.InitDB(":memory:")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Re-migrate on same DB should succeed
|
||||
err = db.AutoMigrate(&auth.User{}, &auth.Session{}, &auth.UserAPIKey{})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
|
||||
It("creates composite index on users(provider, subject)", func() {
|
||||
db, err := auth.InitDB(":memory:")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Insert a user to verify the index doesn't prevent normal operations
|
||||
user := &auth.User{
|
||||
ID: "test-1",
|
||||
Provider: auth.ProviderGitHub,
|
||||
Subject: "12345",
|
||||
Role: "admin",
|
||||
Status: auth.StatusActive,
|
||||
}
|
||||
Expect(db.Create(user).Error).ToNot(HaveOccurred())
|
||||
|
||||
// Query using the indexed columns should work
|
||||
var found auth.User
|
||||
Expect(db.Where("provider = ? AND subject = ?", auth.ProviderGitHub, "12345").First(&found).Error).ToNot(HaveOccurred())
|
||||
Expect(found.ID).To(Equal("test-1"))
|
||||
})
|
||||
})
|
||||
})
|
||||
125
core/http/auth/features.go
Normal file
125
core/http/auth/features.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package auth
|
||||
|
||||
// RouteFeature maps a route pattern + HTTP method to a required feature.
|
||||
type RouteFeature struct {
|
||||
Method string // "POST", "GET", "*" (any)
|
||||
Pattern string // Echo route pattern, e.g. "/v1/chat/completions"
|
||||
Feature string // Feature constant, e.g. FeatureChat
|
||||
}
|
||||
|
||||
// RouteFeatureRegistry is the single source of truth for endpoint -> feature mappings.
|
||||
// To gate a new endpoint, add an entry here -- no other file changes needed.
|
||||
var RouteFeatureRegistry = []RouteFeature{
|
||||
// Chat / Completions
|
||||
{"POST", "/v1/chat/completions", FeatureChat},
|
||||
{"POST", "/chat/completions", FeatureChat},
|
||||
{"POST", "/v1/completions", FeatureChat},
|
||||
{"POST", "/completions", FeatureChat},
|
||||
{"POST", "/v1/engines/:model/completions", FeatureChat},
|
||||
{"POST", "/v1/edits", FeatureChat},
|
||||
{"POST", "/edits", FeatureChat},
|
||||
|
||||
// Anthropic
|
||||
{"POST", "/v1/messages", FeatureChat},
|
||||
{"POST", "/messages", FeatureChat},
|
||||
|
||||
// Open Responses
|
||||
{"POST", "/v1/responses", FeatureChat},
|
||||
{"POST", "/responses", FeatureChat},
|
||||
{"GET", "/v1/responses", FeatureChat},
|
||||
{"GET", "/responses", FeatureChat},
|
||||
|
||||
// Embeddings
|
||||
{"POST", "/v1/embeddings", FeatureEmbeddings},
|
||||
{"POST", "/embeddings", FeatureEmbeddings},
|
||||
{"POST", "/v1/engines/:model/embeddings", FeatureEmbeddings},
|
||||
|
||||
// Images
|
||||
{"POST", "/v1/images/generations", FeatureImages},
|
||||
{"POST", "/images/generations", FeatureImages},
|
||||
{"POST", "/v1/images/inpainting", FeatureImages},
|
||||
{"POST", "/images/inpainting", FeatureImages},
|
||||
|
||||
// Audio transcription
|
||||
{"POST", "/v1/audio/transcriptions", FeatureAudioTranscription},
|
||||
{"POST", "/audio/transcriptions", FeatureAudioTranscription},
|
||||
|
||||
// Audio speech / TTS
|
||||
{"POST", "/v1/audio/speech", FeatureAudioSpeech},
|
||||
{"POST", "/audio/speech", FeatureAudioSpeech},
|
||||
{"POST", "/tts", FeatureAudioSpeech},
|
||||
{"POST", "/v1/text-to-speech/:voice-id", FeatureAudioSpeech},
|
||||
|
||||
// VAD
|
||||
{"POST", "/vad", FeatureVAD},
|
||||
{"POST", "/v1/vad", FeatureVAD},
|
||||
|
||||
// Detection
|
||||
{"POST", "/v1/detection", FeatureDetection},
|
||||
|
||||
// Video
|
||||
{"POST", "/video", FeatureVideo},
|
||||
|
||||
// Sound generation
|
||||
{"POST", "/v1/sound-generation", FeatureSound},
|
||||
|
||||
// Realtime
|
||||
{"GET", "/v1/realtime", FeatureRealtime},
|
||||
{"POST", "/v1/realtime/sessions", FeatureRealtime},
|
||||
{"POST", "/v1/realtime/transcription_session", FeatureRealtime},
|
||||
{"POST", "/v1/realtime/calls", FeatureRealtime},
|
||||
|
||||
// MCP
|
||||
{"POST", "/v1/mcp/chat/completions", FeatureMCP},
|
||||
{"POST", "/mcp/v1/chat/completions", FeatureMCP},
|
||||
{"POST", "/mcp/chat/completions", FeatureMCP},
|
||||
|
||||
// Tokenize
|
||||
{"POST", "/v1/tokenize", FeatureTokenize},
|
||||
|
||||
// Rerank
|
||||
{"POST", "/v1/rerank", FeatureRerank},
|
||||
|
||||
// Stores
|
||||
{"POST", "/stores/set", FeatureStores},
|
||||
{"POST", "/stores/delete", FeatureStores},
|
||||
{"POST", "/stores/get", FeatureStores},
|
||||
{"POST", "/stores/find", FeatureStores},
|
||||
}
|
||||
|
||||
// FeatureMeta describes a feature for the admin API/UI.
|
||||
type FeatureMeta struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
DefaultValue bool `json:"default"`
|
||||
}
|
||||
|
||||
// AgentFeatureMetas returns metadata for agent features.
|
||||
func AgentFeatureMetas() []FeatureMeta {
|
||||
return []FeatureMeta{
|
||||
{FeatureAgents, "Agents", false},
|
||||
{FeatureSkills, "Skills", false},
|
||||
{FeatureCollections, "Collections", false},
|
||||
{FeatureMCPJobs, "MCP CI Jobs", false},
|
||||
}
|
||||
}
|
||||
|
||||
// APIFeatureMetas returns metadata for API endpoint features.
|
||||
func APIFeatureMetas() []FeatureMeta {
|
||||
return []FeatureMeta{
|
||||
{FeatureChat, "Chat Completions", true},
|
||||
{FeatureImages, "Image Generation", true},
|
||||
{FeatureAudioSpeech, "Audio Speech / TTS", true},
|
||||
{FeatureAudioTranscription, "Audio Transcription", true},
|
||||
{FeatureVAD, "Voice Activity Detection", true},
|
||||
{FeatureDetection, "Detection", true},
|
||||
{FeatureVideo, "Video Generation", true},
|
||||
{FeatureEmbeddings, "Embeddings", true},
|
||||
{FeatureSound, "Sound Generation", true},
|
||||
{FeatureRealtime, "Realtime", true},
|
||||
{FeatureRerank, "Rerank", true},
|
||||
{FeatureTokenize, "Tokenize", true},
|
||||
{FeatureMCP, "MCP", true},
|
||||
{FeatureStores, "Stores", true},
|
||||
}
|
||||
}
|
||||
155
core/http/auth/helpers_test.go
Normal file
155
core/http/auth/helpers_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
//go:build auth
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
. "github.com/onsi/gomega"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// testDB creates an in-memory SQLite GORM instance with auto-migration.
|
||||
func testDB() *gorm.DB {
|
||||
db, err := auth.InitDB(":memory:")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return db
|
||||
}
|
||||
|
||||
// createTestUser inserts a user directly into the DB for test setup.
|
||||
func createTestUser(db *gorm.DB, email, role, provider string) *auth.User {
|
||||
user := &auth.User{
|
||||
ID: generateTestID(),
|
||||
Email: email,
|
||||
Name: "Test User",
|
||||
Provider: provider,
|
||||
Subject: generateTestID(),
|
||||
Role: role,
|
||||
Status: auth.StatusActive,
|
||||
}
|
||||
err := db.Create(user).Error
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return user
|
||||
}
|
||||
|
||||
// createTestSession creates a session for a user, returns plaintext session token.
|
||||
func createTestSession(db *gorm.DB, userID string) string {
|
||||
sessionID, err := auth.CreateSession(db, userID, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
return sessionID
|
||||
}
|
||||
|
||||
var testIDCounter int
|
||||
|
||||
func generateTestID() string {
|
||||
testIDCounter++
|
||||
return "test-id-" + string(rune('a'+testIDCounter))
|
||||
}
|
||||
|
||||
// ok is a simple handler that returns 200 OK.
|
||||
func ok(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
// newAuthTestApp creates a minimal Echo app with the new auth middleware.
|
||||
func newAuthTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
|
||||
e := echo.New()
|
||||
e.Use(auth.Middleware(db, appConfig))
|
||||
|
||||
// API routes (require auth)
|
||||
e.GET("/v1/models", ok)
|
||||
e.POST("/v1/chat/completions", ok)
|
||||
e.GET("/api/settings", ok)
|
||||
e.POST("/api/settings", ok)
|
||||
|
||||
// Auth routes (exempt)
|
||||
e.GET("/api/auth/status", ok)
|
||||
e.GET("/api/auth/github/login", ok)
|
||||
|
||||
// Static routes
|
||||
e.GET("/app", ok)
|
||||
e.GET("/app/*", ok)
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// newAdminTestApp creates an Echo app with admin-protected routes.
|
||||
func newAdminTestApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
|
||||
e := echo.New()
|
||||
e.Use(auth.Middleware(db, appConfig))
|
||||
|
||||
// Regular routes
|
||||
e.GET("/v1/models", ok)
|
||||
e.POST("/v1/chat/completions", ok)
|
||||
|
||||
// Admin-only routes
|
||||
adminMw := auth.RequireAdmin()
|
||||
e.POST("/api/settings", ok, adminMw)
|
||||
e.POST("/models/apply", ok, adminMw)
|
||||
e.POST("/backends/apply", ok, adminMw)
|
||||
e.GET("/api/agents", ok, adminMw)
|
||||
|
||||
// Trace/log endpoints (admin only)
|
||||
e.GET("/api/traces", ok, adminMw)
|
||||
e.POST("/api/traces/clear", ok, adminMw)
|
||||
e.GET("/api/backend-logs", ok, adminMw)
|
||||
e.GET("/api/backend-logs/:modelId", ok, adminMw)
|
||||
|
||||
// Gallery/management reads (admin only)
|
||||
e.GET("/api/operations", ok, adminMw)
|
||||
e.GET("/api/models", ok, adminMw)
|
||||
e.GET("/api/backends", ok, adminMw)
|
||||
e.GET("/api/resources", ok, adminMw)
|
||||
e.GET("/api/p2p/workers", ok, adminMw)
|
||||
|
||||
// Agent task/job routes (admin only)
|
||||
e.POST("/api/agent/tasks", ok, adminMw)
|
||||
e.GET("/api/agent/tasks", ok, adminMw)
|
||||
e.GET("/api/agent/jobs", ok, adminMw)
|
||||
|
||||
// System info (admin only)
|
||||
e.GET("/system", ok, adminMw)
|
||||
e.GET("/backend/monitor", ok, adminMw)
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// doRequest performs an HTTP request against the given Echo app and returns the recorder.
|
||||
func doRequest(e *echo.Echo, method, path string, opts ...func(*http.Request)) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for _, opt := range opts {
|
||||
opt(req)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func withBearerToken(token string) func(*http.Request) {
|
||||
return func(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
}
|
||||
|
||||
func withXApiKey(key string) func(*http.Request) {
|
||||
return func(req *http.Request) {
|
||||
req.Header.Set("x-api-key", key)
|
||||
}
|
||||
}
|
||||
|
||||
func withSessionCookie(sessionID string) func(*http.Request) {
|
||||
return func(req *http.Request) {
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||
}
|
||||
}
|
||||
|
||||
func withTokenCookie(token string) func(*http.Request) {
|
||||
return func(req *http.Request) {
|
||||
req.AddCookie(&http.Cookie{Name: "token", Value: token})
|
||||
}
|
||||
}
|
||||
522
core/http/auth/middleware.go
Normal file
522
core/http/auth/middleware.go
Normal file
@@ -0,0 +1,522 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/schema"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
contextKeyUser = "auth_user"
|
||||
contextKeyRole = "auth_role"
|
||||
)
|
||||
|
||||
// Middleware returns an Echo middleware that handles authentication.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. If auth not enabled AND no legacy API keys → pass through
|
||||
// 2. Skip auth for exempt paths (PathWithoutAuth + /api/auth/)
|
||||
// 3. If auth enabled (db != nil):
|
||||
// a. Try "session" cookie → DB lookup
|
||||
// b. Try Authorization: Bearer → session ID, then user API key
|
||||
// c. Try x-api-key / xi-api-key → user API key
|
||||
// d. Try "token" cookie → legacy API key check
|
||||
// e. Check all extracted keys against legacy ApiKeys → synthetic admin
|
||||
// 4. If auth not enabled → delegate to legacy API key validation
|
||||
// 5. If no auth found for /api/ or /v1/ paths → 401
|
||||
// 6. Otherwise pass through (static assets, UI pages, etc.)
|
||||
func Middleware(db *gorm.DB, appConfig *config.ApplicationConfig) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
authEnabled := db != nil
|
||||
hasLegacyKeys := len(appConfig.ApiKeys) > 0
|
||||
|
||||
// 1. No auth at all
|
||||
if !authEnabled && !hasLegacyKeys {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
path := c.Request().URL.Path
|
||||
exempt := isExemptPath(path, appConfig)
|
||||
authenticated := false
|
||||
|
||||
// 2. Try to authenticate (populates user in context if possible)
|
||||
if authEnabled {
|
||||
user := tryAuthenticate(c, db, appConfig)
|
||||
if user != nil {
|
||||
c.Set(contextKeyUser, user)
|
||||
c.Set(contextKeyRole, user.Role)
|
||||
authenticated = true
|
||||
|
||||
// Session rotation for cookie-based sessions
|
||||
if session, ok := c.Get("_auth_session").(*Session); ok {
|
||||
MaybeRotateSession(c, db, session, appConfig.Auth.APIKeyHMACSecret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Legacy API key validation (works whether auth is enabled or not)
|
||||
if !authenticated && hasLegacyKeys {
|
||||
key := extractKey(c)
|
||||
if key != "" && isValidLegacyKey(key, appConfig) {
|
||||
syntheticUser := &User{
|
||||
ID: "legacy-api-key",
|
||||
Name: "API Key User",
|
||||
Role: RoleAdmin,
|
||||
}
|
||||
c.Set(contextKeyUser, syntheticUser)
|
||||
c.Set(contextKeyRole, RoleAdmin)
|
||||
authenticated = true
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If authenticated or exempt path, proceed
|
||||
if authenticated || exempt {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// 5. Require auth for API paths
|
||||
if isAPIPath(path) {
|
||||
// Check GET exemptions for legacy keys
|
||||
if hasLegacyKeys && appConfig.DisableApiKeyRequirementForHttpGet && c.Request().Method == http.MethodGet {
|
||||
for _, rx := range appConfig.HttpGetExemptedEndpoints {
|
||||
if rx.MatchString(c.Path()) {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
return authError(c, appConfig)
|
||||
}
|
||||
|
||||
// 6. Non-API paths (UI, static assets) pass through.
|
||||
// The React UI handles login redirects client-side.
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin returns middleware that checks the user has admin role.
|
||||
func RequireAdmin() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
user := GetUser(c)
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: "Authentication required",
|
||||
Code: http.StatusUnauthorized,
|
||||
Type: "authentication_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
if user.Role != RoleAdmin {
|
||||
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: "Admin access required",
|
||||
Code: http.StatusForbidden,
|
||||
Type: "authorization_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NoopMiddleware returns a middleware that does nothing (pass-through).
|
||||
// Used when auth is disabled to satisfy route registration that expects
|
||||
// an admin middleware parameter.
|
||||
func NoopMiddleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return next
|
||||
}
|
||||
}
|
||||
|
||||
// RequireFeature returns middleware that checks the user has access to the given feature.
|
||||
// If no auth DB is provided, it passes through (backward compat).
|
||||
// Admins always pass. Regular users must have the feature enabled in their permissions.
|
||||
func RequireFeature(db *gorm.DB, feature string) echo.MiddlewareFunc {
|
||||
if db == nil {
|
||||
return NoopMiddleware()
|
||||
}
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
user := GetUser(c)
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: "Authentication required",
|
||||
Code: http.StatusUnauthorized,
|
||||
Type: "authentication_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
if user.Role == RoleAdmin {
|
||||
return next(c)
|
||||
}
|
||||
perm, err := GetCachedUserPermissions(c, db, user.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: "feature not enabled for your account",
|
||||
Code: http.StatusForbidden,
|
||||
Type: "authorization_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
val, exists := perm.Permissions[feature]
|
||||
if !exists {
|
||||
if !isDefaultOnFeature(feature) {
|
||||
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: "feature not enabled for your account",
|
||||
Code: http.StatusForbidden,
|
||||
Type: "authorization_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
} else if !val {
|
||||
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: "feature not enabled for your account",
|
||||
Code: http.StatusForbidden,
|
||||
Type: "authorization_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetUser returns the authenticated user from the echo context, or nil.
|
||||
func GetUser(c echo.Context) *User {
|
||||
u, ok := c.Get(contextKeyUser).(*User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// GetUserRole returns the role of the authenticated user, or empty string.
|
||||
func GetUserRole(c echo.Context) string {
|
||||
role, _ := c.Get(contextKeyRole).(string)
|
||||
return role
|
||||
}
|
||||
|
||||
// RequireRouteFeature returns a global middleware that checks the user has access
|
||||
// to the feature required by the matched route. It uses the RouteFeatureRegistry
|
||||
// to look up the required feature for each route pattern + HTTP method.
|
||||
// If no entry matches, the request passes through (no restriction).
|
||||
func RequireRouteFeature(db *gorm.DB) echo.MiddlewareFunc {
|
||||
if db == nil {
|
||||
return NoopMiddleware()
|
||||
}
|
||||
// Pre-build lookup map: "METHOD:pattern" -> feature
|
||||
lookup := map[string]string{}
|
||||
for _, rf := range RouteFeatureRegistry {
|
||||
lookup[rf.Method+":"+rf.Pattern] = rf.Feature
|
||||
}
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
path := c.Path() // Echo route pattern (e.g. "/v1/engines/:model/completions")
|
||||
method := c.Request().Method
|
||||
feature := lookup[method+":"+path]
|
||||
if feature == "" {
|
||||
feature = lookup["*:"+path]
|
||||
}
|
||||
if feature == "" {
|
||||
return next(c) // no restriction for this route
|
||||
}
|
||||
user := GetUser(c)
|
||||
if user == nil {
|
||||
return next(c) // auth middleware handles unauthenticated
|
||||
}
|
||||
if user.Role == RoleAdmin {
|
||||
return next(c)
|
||||
}
|
||||
perm, err := GetCachedUserPermissions(c, db, user.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: "failed to check permissions",
|
||||
Code: http.StatusInternalServerError,
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
val, exists := perm.Permissions[feature]
|
||||
if !exists {
|
||||
if !isDefaultOnFeature(feature) {
|
||||
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: "feature not enabled for your account: " + feature,
|
||||
Code: http.StatusForbidden,
|
||||
Type: "authorization_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
} else if !val {
|
||||
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: "feature not enabled for your account: " + feature,
|
||||
Code: http.StatusForbidden,
|
||||
Type: "authorization_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RequireModelAccess returns a global middleware that checks the user is allowed
|
||||
// to use the resolved model. It extracts the model name directly from the request
|
||||
// (path param, query param, JSON body, or form value) rather than relying on a
|
||||
// context key set by downstream route-specific middleware.
|
||||
func RequireModelAccess(db *gorm.DB) echo.MiddlewareFunc {
|
||||
if db == nil {
|
||||
return NoopMiddleware()
|
||||
}
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
user := GetUser(c)
|
||||
if user == nil {
|
||||
return next(c)
|
||||
}
|
||||
if user.Role == RoleAdmin {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Check if this user even has a model allowlist enabled before
|
||||
// doing the expensive body read. Most users won't have restrictions.
|
||||
// Uses request-scoped cache to avoid duplicate DB hit when
|
||||
// RequireRouteFeature already fetched permissions.
|
||||
perm, err := GetCachedUserPermissions(c, db, user.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: "failed to check permissions",
|
||||
Code: http.StatusInternalServerError,
|
||||
Type: "server_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
allowlist := perm.AllowedModels
|
||||
if !allowlist.Enabled {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
modelName := extractModelFromRequest(c)
|
||||
if modelName == "" {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
for _, m := range allowlist.Models {
|
||||
if m == modelName {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: "access denied to model: " + modelName,
|
||||
Code: http.StatusForbidden,
|
||||
Type: "authorization_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractModelFromRequest extracts the model name from various request sources.
|
||||
// It checks URL path params, query params, JSON body, and form values.
|
||||
// For JSON bodies, it peeks at the body and resets it so downstream handlers
|
||||
// can still read it.
|
||||
func extractModelFromRequest(c echo.Context) string {
|
||||
// 1. URL path param (e.g. /v1/engines/:model/completions)
|
||||
if model := c.Param("model"); model != "" {
|
||||
return model
|
||||
}
|
||||
// 2. Query param
|
||||
if model := c.QueryParam("model"); model != "" {
|
||||
return model
|
||||
}
|
||||
// 3. Peek at JSON body
|
||||
if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "application/json") {
|
||||
body, err := io.ReadAll(c.Request().Body)
|
||||
c.Request().Body = io.NopCloser(bytes.NewReader(body)) // always reset
|
||||
if err == nil && len(body) > 0 {
|
||||
var m struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
if json.Unmarshal(body, &m) == nil && m.Model != "" {
|
||||
return m.Model
|
||||
}
|
||||
}
|
||||
}
|
||||
// 4. Form value (multipart/form-data)
|
||||
if model := c.FormValue("model"); model != "" {
|
||||
return model
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// tryAuthenticate attempts to authenticate the request using the database.
|
||||
func tryAuthenticate(c echo.Context, db *gorm.DB, appConfig *config.ApplicationConfig) *User {
|
||||
hmacSecret := appConfig.Auth.APIKeyHMACSecret
|
||||
|
||||
// a. Session cookie
|
||||
if cookie, err := c.Cookie(sessionCookie); err == nil && cookie.Value != "" {
|
||||
if user, session := ValidateSession(db, cookie.Value, hmacSecret); user != nil {
|
||||
// Store session for rotation check in middleware
|
||||
c.Set("_auth_session", session)
|
||||
return user
|
||||
}
|
||||
}
|
||||
|
||||
// b. Authorization: Bearer token
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Try as session ID first
|
||||
if user, _ := ValidateSession(db, token, hmacSecret); user != nil {
|
||||
return user
|
||||
}
|
||||
|
||||
// Try as user API key
|
||||
if key, err := ValidateAPIKey(db, token, hmacSecret); err == nil {
|
||||
return &key.User
|
||||
}
|
||||
}
|
||||
|
||||
// c. x-api-key / xi-api-key headers
|
||||
for _, header := range []string{"x-api-key", "xi-api-key"} {
|
||||
if key := c.Request().Header.Get(header); key != "" {
|
||||
if apiKey, err := ValidateAPIKey(db, key, hmacSecret); err == nil {
|
||||
return &apiKey.User
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// d. token cookie (legacy)
|
||||
if cookie, err := c.Cookie("token"); err == nil && cookie.Value != "" {
|
||||
// Try as user API key
|
||||
if key, err := ValidateAPIKey(db, cookie.Value, hmacSecret); err == nil {
|
||||
return &key.User
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractKey extracts an API key from the request (all sources).
|
||||
func extractKey(c echo.Context) string {
|
||||
// Authorization header
|
||||
auth := c.Request().Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
return strings.TrimPrefix(auth, "Bearer ")
|
||||
}
|
||||
if auth != "" {
|
||||
return auth
|
||||
}
|
||||
|
||||
// x-api-key
|
||||
if key := c.Request().Header.Get("x-api-key"); key != "" {
|
||||
return key
|
||||
}
|
||||
|
||||
// xi-api-key
|
||||
if key := c.Request().Header.Get("xi-api-key"); key != "" {
|
||||
return key
|
||||
}
|
||||
|
||||
// token cookie
|
||||
if cookie, err := c.Cookie("token"); err == nil && cookie.Value != "" {
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isValidLegacyKey checks if the key matches any configured API key
|
||||
// using constant-time comparison to prevent timing attacks.
|
||||
func isValidLegacyKey(key string, appConfig *config.ApplicationConfig) bool {
|
||||
for _, validKey := range appConfig.ApiKeys {
|
||||
if subtle.ConstantTimeCompare([]byte(key), []byte(validKey)) == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isExemptPath returns true if the path should skip authentication.
|
||||
func isExemptPath(path string, appConfig *config.ApplicationConfig) bool {
|
||||
// Auth endpoints are always public
|
||||
if strings.HasPrefix(path, "/api/auth/") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check configured exempt paths
|
||||
for _, p := range appConfig.PathWithoutAuth {
|
||||
if strings.HasPrefix(path, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isAPIPath returns true for paths that always require authentication.
|
||||
func isAPIPath(path string) bool {
|
||||
return strings.HasPrefix(path, "/api/") ||
|
||||
strings.HasPrefix(path, "/v1/") ||
|
||||
strings.HasPrefix(path, "/models/") ||
|
||||
strings.HasPrefix(path, "/backends/") ||
|
||||
strings.HasPrefix(path, "/backend/") ||
|
||||
strings.HasPrefix(path, "/tts") ||
|
||||
strings.HasPrefix(path, "/vad") ||
|
||||
strings.HasPrefix(path, "/video") ||
|
||||
strings.HasPrefix(path, "/stores/") ||
|
||||
strings.HasPrefix(path, "/system") ||
|
||||
strings.HasPrefix(path, "/ws/") ||
|
||||
strings.HasPrefix(path, "/generated-") ||
|
||||
path == "/metrics"
|
||||
}
|
||||
|
||||
// authError returns an appropriate error response.
|
||||
func authError(c echo.Context, appConfig *config.ApplicationConfig) error {
|
||||
c.Response().Header().Set("WWW-Authenticate", "Bearer")
|
||||
|
||||
if appConfig.OpaqueErrors {
|
||||
return c.NoContent(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
contentType := c.Request().Header.Get("Content-Type")
|
||||
if strings.Contains(contentType, "application/json") {
|
||||
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: "An authentication key is required",
|
||||
Code: http.StatusUnauthorized,
|
||||
Type: "invalid_request_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusUnauthorized, schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: "An authentication key is required",
|
||||
Code: http.StatusUnauthorized,
|
||||
Type: "invalid_request_error",
|
||||
},
|
||||
})
|
||||
}
|
||||
306
core/http/auth/middleware_test.go
Normal file
306
core/http/auth/middleware_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
//go:build auth
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var _ = Describe("Auth Middleware", func() {
|
||||
|
||||
Context("auth disabled, no API keys", func() {
|
||||
var app *echo.Echo
|
||||
|
||||
BeforeEach(func() {
|
||||
appConfig := config.NewApplicationConfig()
|
||||
app = newAuthTestApp(nil, appConfig)
|
||||
})
|
||||
|
||||
It("passes through all requests", func() {
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models")
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("passes through POST requests", func() {
|
||||
rec := doRequest(app, http.MethodPost, "/v1/chat/completions")
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
|
||||
Context("auth disabled, API keys configured", func() {
|
||||
var app *echo.Echo
|
||||
const validKey = "sk-test-key-123"
|
||||
|
||||
BeforeEach(func() {
|
||||
appConfig := config.NewApplicationConfig()
|
||||
appConfig.ApiKeys = []string{validKey}
|
||||
app = newAuthTestApp(nil, appConfig)
|
||||
})
|
||||
|
||||
It("returns 401 for request without key", func() {
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models")
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("passes with valid Bearer token", func() {
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(validKey))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("passes with valid x-api-key header", func() {
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models", withXApiKey(validKey))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("passes with valid token cookie", func() {
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models", withTokenCookie(validKey))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("returns 401 for invalid key", func() {
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken("wrong-key"))
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Context("auth enabled with database", func() {
|
||||
var (
|
||||
db *gorm.DB
|
||||
app *echo.Echo
|
||||
appConfig *config.ApplicationConfig
|
||||
user *auth.User
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
db = testDB()
|
||||
appConfig = config.NewApplicationConfig()
|
||||
app = newAuthTestApp(db, appConfig)
|
||||
user = createTestUser(db, "user@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||
})
|
||||
|
||||
It("allows requests with valid session cookie", func() {
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("allows requests with valid session as Bearer token", func() {
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("allows requests with valid user API key as Bearer token", func() {
|
||||
plaintext, _, err := auth.CreateAPIKey(db, user.ID, "test", auth.RoleUser, "", nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(plaintext))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("allows requests with legacy API_KEY as admin bypass", func() {
|
||||
appConfig.ApiKeys = []string{"legacy-key-123"}
|
||||
app = newAuthTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken("legacy-key-123"))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("returns 401 for expired session", func() {
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
// Manually expire (session ID in DB is the hash)
|
||||
hash := auth.HashAPIKey(sessionID, "")
|
||||
db.Model(&auth.Session{}).Where("id = ?", hash).
|
||||
Update("expires_at", "2020-01-01")
|
||||
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns 401 for invalid session ID", func() {
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models", withSessionCookie("invalid-session-id"))
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns 401 for revoked API key", func() {
|
||||
plaintext, record, err := auth.CreateAPIKey(db, user.ID, "to revoke", auth.RoleUser, "", nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = auth.RevokeAPIKey(db, record.ID, user.ID)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models", withBearerToken(plaintext))
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("skips auth for /api/auth/* paths", func() {
|
||||
rec := doRequest(app, http.MethodGet, "/api/auth/status")
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("skips auth for PathWithoutAuth paths", func() {
|
||||
rec := doRequest(app, http.MethodGet, "/healthz")
|
||||
// healthz is not registered in our test app, so it'll be 404/405 but NOT 401
|
||||
Expect(rec.Code).ToNot(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns 401 for unauthenticated API requests", func() {
|
||||
rec := doRequest(app, http.MethodGet, "/v1/models")
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("allows unauthenticated access to non-API paths when no legacy keys", func() {
|
||||
rec := doRequest(app, http.MethodGet, "/app")
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RequireAdmin", func() {
|
||||
var (
|
||||
db *gorm.DB
|
||||
appConfig *config.ApplicationConfig
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
db = testDB()
|
||||
appConfig = config.NewApplicationConfig()
|
||||
})
|
||||
|
||||
It("passes for admin user", func() {
|
||||
admin := createTestUser(db, "admin@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||
sessionID := createTestSession(db, admin.ID)
|
||||
app := newAdminTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodPost, "/api/settings", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("returns 403 for user role", func() {
|
||||
user := createTestUser(db, "user@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
app := newAdminTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodPost, "/api/settings", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("returns 401 when no user in context", func() {
|
||||
app := newAdminTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodPost, "/api/settings")
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("allows admin to access model management", func() {
|
||||
admin := createTestUser(db, "admin@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||
sessionID := createTestSession(db, admin.ID)
|
||||
app := newAdminTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodPost, "/models/apply", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("blocks user from model management", func() {
|
||||
user := createTestUser(db, "user@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
app := newAdminTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodPost, "/models/apply", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("allows user to access regular inference endpoints", func() {
|
||||
user := createTestUser(db, "user@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
app := newAdminTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodPost, "/v1/chat/completions", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("allows legacy API key (admin bypass) on admin routes", func() {
|
||||
appConfig.ApiKeys = []string{"admin-key"}
|
||||
app := newAdminTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodPost, "/api/settings", withBearerToken("admin-key"))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("allows admin to access trace endpoints", func() {
|
||||
admin := createTestUser(db, "admin2@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||
sessionID := createTestSession(db, admin.ID)
|
||||
app := newAdminTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodGet, "/api/traces", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
rec = doRequest(app, http.MethodGet, "/api/backend-logs", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("blocks non-admin from trace endpoints", func() {
|
||||
user := createTestUser(db, "user2@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
app := newAdminTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodGet, "/api/traces", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||
|
||||
rec = doRequest(app, http.MethodGet, "/api/backend-logs", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("allows admin to access agent job endpoints", func() {
|
||||
admin := createTestUser(db, "admin3@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||
sessionID := createTestSession(db, admin.ID)
|
||||
app := newAdminTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodGet, "/api/agent/tasks", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
rec = doRequest(app, http.MethodGet, "/api/agent/jobs", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("blocks non-admin from agent job endpoints", func() {
|
||||
user := createTestUser(db, "user3@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
app := newAdminTestApp(db, appConfig)
|
||||
|
||||
rec := doRequest(app, http.MethodGet, "/api/agent/tasks", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||
|
||||
rec = doRequest(app, http.MethodGet, "/api/agent/jobs", withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
|
||||
It("blocks non-admin from system/management endpoints", func() {
|
||||
user := createTestUser(db, "user4@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||
sessionID := createTestSession(db, user.ID)
|
||||
app := newAdminTestApp(db, appConfig)
|
||||
|
||||
for _, path := range []string{"/api/operations", "/api/models", "/api/backends", "/api/resources", "/api/p2p/workers", "/system", "/backend/monitor"} {
|
||||
rec := doRequest(app, http.MethodGet, path, withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusForbidden), "expected 403 for path: "+path)
|
||||
}
|
||||
})
|
||||
|
||||
It("allows admin to access system/management endpoints", func() {
|
||||
admin := createTestUser(db, "admin4@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||
sessionID := createTestSession(db, admin.ID)
|
||||
app := newAdminTestApp(db, appConfig)
|
||||
|
||||
for _, path := range []string{"/api/operations", "/api/models", "/api/backends", "/api/resources", "/api/p2p/workers", "/system", "/backend/monitor"} {
|
||||
rec := doRequest(app, http.MethodGet, path, withSessionCookie(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK), "expected 200 for path: "+path)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
148
core/http/auth/models.go
Normal file
148
core/http/auth/models.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Auth provider constants.
|
||||
const (
|
||||
ProviderLocal = "local"
|
||||
ProviderGitHub = "github"
|
||||
ProviderOIDC = "oidc"
|
||||
)
|
||||
|
||||
// User represents an authenticated user.
|
||||
type User struct {
|
||||
ID string `gorm:"primaryKey;size:36"`
|
||||
Email string `gorm:"size:255;index"`
|
||||
Name string `gorm:"size:255"`
|
||||
AvatarURL string `gorm:"size:512"`
|
||||
Provider string `gorm:"size:50"` // ProviderLocal, ProviderGitHub, ProviderOIDC
|
||||
Subject string `gorm:"size:255"` // provider-specific user ID
|
||||
PasswordHash string `json:"-"` // bcrypt hash, empty for OAuth-only users
|
||||
Role string `gorm:"size:20;default:user"`
|
||||
Status string `gorm:"size:20;default:active"` // "active", "pending"
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Session represents a user login session.
|
||||
type Session struct {
|
||||
ID string `gorm:"primaryKey;size:64"` // HMAC-SHA256 hash of session token
|
||||
UserID string `gorm:"size:36;index"`
|
||||
ExpiresAt time.Time
|
||||
RotatedAt time.Time
|
||||
CreatedAt time.Time
|
||||
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
// UserAPIKey represents a user-generated API key for programmatic access.
|
||||
type UserAPIKey struct {
|
||||
ID string `gorm:"primaryKey;size:36"`
|
||||
UserID string `gorm:"size:36;index"`
|
||||
Name string `gorm:"size:255"` // user-provided label
|
||||
KeyHash string `gorm:"size:64;uniqueIndex"`
|
||||
KeyPrefix string `gorm:"size:12"` // first 8 chars of key for display
|
||||
Role string `gorm:"size:20"`
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time `gorm:"index"`
|
||||
LastUsed *time.Time
|
||||
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
// PermissionMap is a flexible map of feature -> enabled, stored as JSON text.
|
||||
// Known features: "agents", "skills", "collections", "mcp_jobs".
|
||||
// New features can be added without schema changes.
|
||||
type PermissionMap map[string]bool
|
||||
|
||||
// Value implements driver.Valuer for GORM JSON serialization.
|
||||
func (p PermissionMap) Value() (driver.Value, error) {
|
||||
if p == nil {
|
||||
return "{}", nil
|
||||
}
|
||||
b, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal PermissionMap: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// Scan implements sql.Scanner for GORM JSON deserialization.
|
||||
func (p *PermissionMap) Scan(value any) error {
|
||||
if value == nil {
|
||||
*p = PermissionMap{}
|
||||
return nil
|
||||
}
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
case []byte:
|
||||
bytes = v
|
||||
default:
|
||||
return fmt.Errorf("cannot scan %T into PermissionMap", value)
|
||||
}
|
||||
return json.Unmarshal(bytes, p)
|
||||
}
|
||||
|
||||
// InviteCode represents an admin-generated invitation for user registration.
|
||||
type InviteCode struct {
|
||||
ID string `gorm:"primaryKey;size:36"`
|
||||
Code string `gorm:"uniqueIndex;not null;size:64"` // HMAC-SHA256 hash of invite code
|
||||
CodePrefix string `gorm:"size:12"` // first 8 chars for admin display
|
||||
CreatedBy string `gorm:"size:36;not null"`
|
||||
UsedBy *string `gorm:"size:36"`
|
||||
UsedAt *time.Time
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time
|
||||
Creator User `gorm:"foreignKey:CreatedBy"`
|
||||
Consumer *User `gorm:"foreignKey:UsedBy"`
|
||||
}
|
||||
|
||||
// ModelAllowlist controls which models a user can access.
|
||||
// When Enabled is false (default), all models are allowed.
|
||||
type ModelAllowlist struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer for GORM JSON serialization.
|
||||
func (m ModelAllowlist) Value() (driver.Value, error) {
|
||||
b, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal ModelAllowlist: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// Scan implements sql.Scanner for GORM JSON deserialization.
|
||||
func (m *ModelAllowlist) Scan(value any) error {
|
||||
if value == nil {
|
||||
*m = ModelAllowlist{}
|
||||
return nil
|
||||
}
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
case []byte:
|
||||
bytes = v
|
||||
default:
|
||||
return fmt.Errorf("cannot scan %T into ModelAllowlist", value)
|
||||
}
|
||||
return json.Unmarshal(bytes, m)
|
||||
}
|
||||
|
||||
// UserPermission stores per-user feature permissions.
|
||||
type UserPermission struct {
|
||||
ID string `gorm:"primaryKey;size:36"`
|
||||
UserID string `gorm:"size:36;uniqueIndex"`
|
||||
Permissions PermissionMap `gorm:"type:text"`
|
||||
AllowedModels ModelAllowlist `gorm:"type:text"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
User User `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
439
core/http/auth/oauth.go
Normal file
439
core/http/auth/oauth.go
Normal file
@@ -0,0 +1,439 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/xlog"
|
||||
"golang.org/x/oauth2"
|
||||
githubOAuth "golang.org/x/oauth2/github"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// providerEntry holds the OAuth2/OIDC config for a single provider.
|
||||
type providerEntry struct {
|
||||
oauth2Config oauth2.Config
|
||||
oidcVerifier *oidc.IDTokenVerifier // nil for GitHub (API-based user info)
|
||||
name string
|
||||
userInfoURL string // only used for GitHub
|
||||
}
|
||||
|
||||
// oauthUserInfo is a provider-agnostic representation of an authenticated user.
|
||||
type oauthUserInfo struct {
|
||||
Subject string
|
||||
Email string
|
||||
Name string
|
||||
AvatarURL string
|
||||
}
|
||||
|
||||
// OAuthManager manages multiple OAuth/OIDC providers.
|
||||
type OAuthManager struct {
|
||||
providers map[string]*providerEntry
|
||||
}
|
||||
|
||||
// OAuthParams groups the parameters needed to create an OAuthManager.
|
||||
type OAuthParams struct {
|
||||
GitHubClientID string
|
||||
GitHubClientSecret string
|
||||
OIDCIssuer string
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
}
|
||||
|
||||
// NewOAuthManager creates an OAuthManager from the given params.
|
||||
func NewOAuthManager(baseURL string, params OAuthParams) (*OAuthManager, error) {
|
||||
m := &OAuthManager{providers: make(map[string]*providerEntry)}
|
||||
|
||||
if params.GitHubClientID != "" {
|
||||
m.providers[ProviderGitHub] = &providerEntry{
|
||||
name: ProviderGitHub,
|
||||
oauth2Config: oauth2.Config{
|
||||
ClientID: params.GitHubClientID,
|
||||
ClientSecret: params.GitHubClientSecret,
|
||||
Endpoint: githubOAuth.Endpoint,
|
||||
RedirectURL: baseURL + "/api/auth/github/callback",
|
||||
Scopes: []string{"user:email", "read:user"},
|
||||
},
|
||||
userInfoURL: "https://api.github.com/user",
|
||||
}
|
||||
}
|
||||
|
||||
if params.OIDCClientID != "" && params.OIDCIssuer != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
provider, err := oidc.NewProvider(ctx, params.OIDCIssuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OIDC discovery failed for %s: %w", params.OIDCIssuer, err)
|
||||
}
|
||||
|
||||
verifier := provider.Verifier(&oidc.Config{ClientID: params.OIDCClientID})
|
||||
|
||||
m.providers[ProviderOIDC] = &providerEntry{
|
||||
name: ProviderOIDC,
|
||||
oauth2Config: oauth2.Config{
|
||||
ClientID: params.OIDCClientID,
|
||||
ClientSecret: params.OIDCClientSecret,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: baseURL + "/api/auth/oidc/callback",
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
},
|
||||
oidcVerifier: verifier,
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Providers returns the list of configured provider names.
|
||||
func (m *OAuthManager) Providers() []string {
|
||||
names := make([]string, 0, len(m.providers))
|
||||
for name := range m.providers {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// LoginHandler redirects the user to the OAuth provider's login page.
|
||||
func (m *OAuthManager) LoginHandler(providerName string) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
provider, ok := m.providers[providerName]
|
||||
if !ok {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "unknown provider"})
|
||||
}
|
||||
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate state"})
|
||||
}
|
||||
|
||||
secure := isSecure(c)
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "oauth_state",
|
||||
Value: state,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 600, // 10 minutes
|
||||
})
|
||||
|
||||
// Store invite code in cookie if provided
|
||||
if inviteCode := c.QueryParam("invite_code"); inviteCode != "" {
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "invite_code",
|
||||
Value: inviteCode,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: 600,
|
||||
})
|
||||
}
|
||||
|
||||
url := provider.oauth2Config.AuthCodeURL(state)
|
||||
return c.Redirect(http.StatusTemporaryRedirect, url)
|
||||
}
|
||||
}
|
||||
|
||||
// CallbackHandler handles the OAuth callback, creates/updates the user, and
|
||||
// creates a session.
|
||||
func (m *OAuthManager) CallbackHandler(providerName string, db *gorm.DB, adminEmail, registrationMode, hmacSecret string) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
provider, ok := m.providers[providerName]
|
||||
if !ok {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "unknown provider"})
|
||||
}
|
||||
|
||||
// Validate state
|
||||
stateCookie, err := c.Cookie("oauth_state")
|
||||
if err != nil || stateCookie.Value == "" || subtle.ConstantTimeCompare([]byte(stateCookie.Value), []byte(c.QueryParam("state"))) != 1 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid OAuth state"})
|
||||
}
|
||||
|
||||
// Clear state cookie
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "oauth_state",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isSecure(c),
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
// Exchange code for token
|
||||
code := c.QueryParam("code")
|
||||
if code == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "missing authorization code"})
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
token, err := provider.oauth2Config.Exchange(ctx, code)
|
||||
if err != nil {
|
||||
xlog.Error("OAuth code exchange failed", "provider", providerName, "error", err)
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "OAuth authentication failed"})
|
||||
}
|
||||
|
||||
// Fetch user info — branch based on provider type
|
||||
var userInfo *oauthUserInfo
|
||||
if provider.oidcVerifier != nil {
|
||||
userInfo, err = extractOIDCUserInfo(ctx, provider.oidcVerifier, token)
|
||||
} else {
|
||||
userInfo, err = fetchGitHubUserInfoAsOAuth(ctx, token.AccessToken)
|
||||
}
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to fetch user info"})
|
||||
}
|
||||
|
||||
// Retrieve invite code from cookie if present
|
||||
var inviteCode string
|
||||
if ic, err := c.Cookie("invite_code"); err == nil && ic.Value != "" {
|
||||
inviteCode = ic.Value
|
||||
// Clear the invite code cookie
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "invite_code",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isSecure(c),
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// Upsert user (with invite code support)
|
||||
user, err := upsertOAuthUser(db, providerName, userInfo, adminEmail, registrationMode)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create user"})
|
||||
}
|
||||
|
||||
// For new users that are pending, check if they have a valid invite
|
||||
if user.Status != StatusActive && inviteCode != "" {
|
||||
if invite, err := ValidateInvite(db, inviteCode, hmacSecret); err == nil {
|
||||
user.Status = StatusActive
|
||||
db.Model(user).Update("status", StatusActive)
|
||||
ConsumeInvite(db, invite, user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if user.Status != StatusActive {
|
||||
if registrationMode == "invite" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "a valid invite code is required to register"})
|
||||
}
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "account pending approval"})
|
||||
}
|
||||
|
||||
// Maybe promote on login
|
||||
MaybePromote(db, user, adminEmail)
|
||||
|
||||
// Create session
|
||||
sessionID, err := CreateSession(db, user.ID, hmacSecret)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
|
||||
}
|
||||
|
||||
SetSessionCookie(c, sessionID)
|
||||
return c.Redirect(http.StatusTemporaryRedirect, "/app")
|
||||
}
|
||||
}
|
||||
|
||||
// extractOIDCUserInfo extracts user info from the OIDC ID token.
|
||||
func extractOIDCUserInfo(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token) (*oauthUserInfo, error) {
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || rawIDToken == "" {
|
||||
return nil, fmt.Errorf("no id_token in token response")
|
||||
}
|
||||
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to verify ID token: %w", err)
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Sub string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse ID token claims: %w", err)
|
||||
}
|
||||
|
||||
return &oauthUserInfo{
|
||||
Subject: claims.Sub,
|
||||
Email: claims.Email,
|
||||
Name: claims.Name,
|
||||
AvatarURL: claims.Picture,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type githubUserInfo struct {
|
||||
ID int `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
type githubEmail struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// fetchGitHubUserInfoAsOAuth fetches GitHub user info and returns it as oauthUserInfo.
|
||||
func fetchGitHubUserInfoAsOAuth(ctx context.Context, accessToken string) (*oauthUserInfo, error) {
|
||||
info, err := fetchGitHubUserInfo(ctx, accessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &oauthUserInfo{
|
||||
Subject: fmt.Sprintf("%d", info.ID),
|
||||
Email: info.Email,
|
||||
Name: info.Name,
|
||||
AvatarURL: info.AvatarURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fetchGitHubUserInfo(ctx context.Context, accessToken string) (*githubUserInfo, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var info githubUserInfo
|
||||
if err := json.Unmarshal(body, &info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If no public email, fetch from /user/emails
|
||||
if info.Email == "" {
|
||||
info.Email, _ = fetchGitHubPrimaryEmail(ctx, accessToken)
|
||||
}
|
||||
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func fetchGitHubPrimaryEmail(ctx context.Context, accessToken string) (string, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user/emails", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var emails []githubEmail
|
||||
if err := json.Unmarshal(body, &emails); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, e := range emails {
|
||||
if e.Primary && e.Verified {
|
||||
return e.Email, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to first verified email
|
||||
for _, e := range emails {
|
||||
if e.Verified {
|
||||
return e.Email, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no verified email found")
|
||||
}
|
||||
|
||||
func upsertOAuthUser(db *gorm.DB, provider string, info *oauthUserInfo, adminEmail, registrationMode string) (*User, error) {
|
||||
// Normalize email from provider (#10)
|
||||
if info.Email != "" {
|
||||
info.Email = strings.ToLower(strings.TrimSpace(info.Email))
|
||||
}
|
||||
|
||||
var user User
|
||||
err := db.Where("provider = ? AND subject = ?", provider, info.Subject).First(&user).Error
|
||||
if err == nil {
|
||||
// Existing user — update profile fields
|
||||
user.Name = info.Name
|
||||
user.AvatarURL = info.AvatarURL
|
||||
if info.Email != "" {
|
||||
user.Email = info.Email
|
||||
}
|
||||
db.Save(&user)
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// New user — empty registration mode defaults to "approval"
|
||||
effectiveMode := registrationMode
|
||||
if effectiveMode == "" {
|
||||
effectiveMode = "approval"
|
||||
}
|
||||
status := StatusActive
|
||||
if effectiveMode == "approval" || effectiveMode == "invite" {
|
||||
status = StatusPending
|
||||
}
|
||||
|
||||
role := AssignRole(db, info.Email, adminEmail)
|
||||
// First user is always active regardless of registration mode
|
||||
if role == RoleAdmin {
|
||||
status = StatusActive
|
||||
}
|
||||
|
||||
user = User{
|
||||
ID: uuid.New().String(),
|
||||
Email: info.Email,
|
||||
Name: info.Name,
|
||||
AvatarURL: info.AvatarURL,
|
||||
Provider: provider,
|
||||
Subject: info.Subject,
|
||||
Role: role,
|
||||
Status: status,
|
||||
}
|
||||
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func generateState() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
14
core/http/auth/password.go
Normal file
14
core/http/auth/password.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package auth
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
// HashPassword returns a bcrypt hash of the given password.
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPassword compares a bcrypt hash with a plaintext password.
|
||||
func CheckPassword(hash, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
211
core/http/auth/permissions.go
Normal file
211
core/http/auth/permissions.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const contextKeyPermissions = "auth_permissions"
|
||||
|
||||
// GetCachedUserPermissions returns the user's permission record, using a
|
||||
// request-scoped cache stored in the echo context. This avoids duplicate
|
||||
// DB lookups when multiple middlewares (RequireRouteFeature, RequireModelAccess)
|
||||
// both need permissions in the same request.
|
||||
func GetCachedUserPermissions(c echo.Context, db *gorm.DB, userID string) (*UserPermission, error) {
|
||||
if perm, ok := c.Get(contextKeyPermissions).(*UserPermission); ok && perm != nil {
|
||||
return perm, nil
|
||||
}
|
||||
perm, err := GetUserPermissions(db, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Set(contextKeyPermissions, perm)
|
||||
return perm, nil
|
||||
}
|
||||
|
||||
// Feature name constants — all code must use these, never bare strings.
|
||||
const (
|
||||
// Agent features (default OFF for new users)
|
||||
FeatureAgents = "agents"
|
||||
FeatureSkills = "skills"
|
||||
FeatureCollections = "collections"
|
||||
FeatureMCPJobs = "mcp_jobs"
|
||||
|
||||
// API features (default ON for new users)
|
||||
FeatureChat = "chat"
|
||||
FeatureImages = "images"
|
||||
FeatureAudioSpeech = "audio_speech"
|
||||
FeatureAudioTranscription = "audio_transcription"
|
||||
FeatureVAD = "vad"
|
||||
FeatureDetection = "detection"
|
||||
FeatureVideo = "video"
|
||||
FeatureEmbeddings = "embeddings"
|
||||
FeatureSound = "sound"
|
||||
FeatureRealtime = "realtime"
|
||||
FeatureRerank = "rerank"
|
||||
FeatureTokenize = "tokenize"
|
||||
FeatureMCP = "mcp"
|
||||
FeatureStores = "stores"
|
||||
)
|
||||
|
||||
// AgentFeatures lists agent-related features (default OFF).
|
||||
var AgentFeatures = []string{FeatureAgents, FeatureSkills, FeatureCollections, FeatureMCPJobs}
|
||||
|
||||
// APIFeatures lists API endpoint features (default ON).
|
||||
var APIFeatures = []string{
|
||||
FeatureChat, FeatureImages, FeatureAudioSpeech, FeatureAudioTranscription,
|
||||
FeatureVAD, FeatureDetection, FeatureVideo, FeatureEmbeddings, FeatureSound,
|
||||
FeatureRealtime, FeatureRerank, FeatureTokenize, FeatureMCP, FeatureStores,
|
||||
}
|
||||
|
||||
// AllFeatures lists all known features (used by UI and validation).
|
||||
var AllFeatures = append(append([]string{}, AgentFeatures...), APIFeatures...)
|
||||
|
||||
// defaultOnFeatures is the set of features that default to ON when absent from a user's permission map.
|
||||
var defaultOnFeatures = func() map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, f := range APIFeatures {
|
||||
m[f] = true
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// isDefaultOnFeature returns true if the feature defaults to ON when not explicitly set.
|
||||
func isDefaultOnFeature(feature string) bool {
|
||||
return defaultOnFeatures[feature]
|
||||
}
|
||||
|
||||
// GetUserPermissions returns the permission record for a user, creating a default
|
||||
// (empty map = all disabled) if none exists.
|
||||
func GetUserPermissions(db *gorm.DB, userID string) (*UserPermission, error) {
|
||||
var perm UserPermission
|
||||
err := db.Where("user_id = ?", userID).First(&perm).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
perm = UserPermission{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Permissions: PermissionMap{},
|
||||
}
|
||||
if err := db.Create(&perm).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &perm, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &perm, nil
|
||||
}
|
||||
|
||||
// UpdateUserPermissions upserts the permission map for a user.
|
||||
func UpdateUserPermissions(db *gorm.DB, userID string, perms PermissionMap) error {
|
||||
var perm UserPermission
|
||||
err := db.Where("user_id = ?", userID).First(&perm).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
perm = UserPermission{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
Permissions: perms,
|
||||
}
|
||||
return db.Create(&perm).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
perm.Permissions = perms
|
||||
return db.Save(&perm).Error
|
||||
}
|
||||
|
||||
// HasFeatureAccess returns true if the user is an admin or has the given feature enabled.
|
||||
// When a feature key is absent from the user's permission map, it checks whether the
|
||||
// feature defaults to ON (API features) or OFF (agent features) for backward compatibility.
|
||||
func HasFeatureAccess(db *gorm.DB, user *User, feature string) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
if user.Role == RoleAdmin {
|
||||
return true
|
||||
}
|
||||
perm, err := GetUserPermissions(db, user.ID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
val, exists := perm.Permissions[feature]
|
||||
if !exists {
|
||||
return isDefaultOnFeature(feature)
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// GetPermissionMapForUser returns the effective permission map for a user.
|
||||
// Admins get all features as true (virtual).
|
||||
// For regular users, absent keys are filled with their defaults so the
|
||||
// UI/API always returns a complete picture.
|
||||
func GetPermissionMapForUser(db *gorm.DB, user *User) PermissionMap {
|
||||
if user == nil {
|
||||
return PermissionMap{}
|
||||
}
|
||||
if user.Role == RoleAdmin {
|
||||
m := PermissionMap{}
|
||||
for _, f := range AllFeatures {
|
||||
m[f] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
perm, err := GetUserPermissions(db, user.ID)
|
||||
if err != nil {
|
||||
return PermissionMap{}
|
||||
}
|
||||
// Fill in defaults for absent keys
|
||||
effective := PermissionMap{}
|
||||
for _, f := range AllFeatures {
|
||||
val, exists := perm.Permissions[f]
|
||||
if exists {
|
||||
effective[f] = val
|
||||
} else {
|
||||
effective[f] = isDefaultOnFeature(f)
|
||||
}
|
||||
}
|
||||
return effective
|
||||
}
|
||||
|
||||
// GetModelAllowlist returns the model allowlist for a user.
|
||||
func GetModelAllowlist(db *gorm.DB, userID string) ModelAllowlist {
|
||||
perm, err := GetUserPermissions(db, userID)
|
||||
if err != nil {
|
||||
return ModelAllowlist{}
|
||||
}
|
||||
return perm.AllowedModels
|
||||
}
|
||||
|
||||
// UpdateModelAllowlist updates the model allowlist for a user.
|
||||
func UpdateModelAllowlist(db *gorm.DB, userID string, allowlist ModelAllowlist) error {
|
||||
perm, err := GetUserPermissions(db, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
perm.AllowedModels = allowlist
|
||||
return db.Save(perm).Error
|
||||
}
|
||||
|
||||
// IsModelAllowed returns true if the user is allowed to use the given model.
|
||||
// Admins always have access. If the allowlist is not enabled, all models are allowed.
|
||||
func IsModelAllowed(db *gorm.DB, user *User, modelName string) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
if user.Role == RoleAdmin {
|
||||
return true
|
||||
}
|
||||
allowlist := GetModelAllowlist(db, user.ID)
|
||||
if !allowlist.Enabled {
|
||||
return true
|
||||
}
|
||||
for _, m := range allowlist.Models {
|
||||
if m == modelName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
103
core/http/auth/roles.go
Normal file
103
core/http/auth/roles.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleAdmin = "admin"
|
||||
RoleUser = "user"
|
||||
|
||||
StatusActive = "active"
|
||||
StatusPending = "pending"
|
||||
StatusDisabled = "disabled"
|
||||
)
|
||||
|
||||
// AssignRole determines the role for a new user.
|
||||
// First user in the database becomes admin. If adminEmail is set and matches,
|
||||
// the user becomes admin. Otherwise, the user gets the "user" role.
|
||||
// Must be called within a transaction that also creates the user to prevent
|
||||
// race conditions on the first-user admin assignment.
|
||||
func AssignRole(tx *gorm.DB, email, adminEmail string) string {
|
||||
var count int64
|
||||
tx.Model(&User{}).Count(&count)
|
||||
if count == 0 {
|
||||
return RoleAdmin
|
||||
}
|
||||
|
||||
if adminEmail != "" && strings.EqualFold(email, adminEmail) {
|
||||
return RoleAdmin
|
||||
}
|
||||
|
||||
return RoleUser
|
||||
}
|
||||
|
||||
// MaybePromote promotes a user to admin on login if their email matches
|
||||
// adminEmail. It does not demote existing admins. Returns true if the user
|
||||
// was promoted.
|
||||
func MaybePromote(db *gorm.DB, user *User, adminEmail string) bool {
|
||||
if user.Role == RoleAdmin {
|
||||
return false
|
||||
}
|
||||
|
||||
if adminEmail != "" && strings.EqualFold(user.Email, adminEmail) {
|
||||
user.Role = RoleAdmin
|
||||
db.Model(user).Update("role", RoleAdmin)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateInvite checks that an invite code exists, is unused, and has not expired.
|
||||
// The code is hashed with HMAC-SHA256 before lookup.
|
||||
func ValidateInvite(db *gorm.DB, code, hmacSecret string) (*InviteCode, error) {
|
||||
hash := HashAPIKey(code, hmacSecret)
|
||||
var invite InviteCode
|
||||
if err := db.Where("code = ?", hash).First(&invite).Error; err != nil {
|
||||
return nil, fmt.Errorf("invite code not found")
|
||||
}
|
||||
if invite.UsedBy != nil {
|
||||
return nil, fmt.Errorf("invite code already used")
|
||||
}
|
||||
if time.Now().After(invite.ExpiresAt) {
|
||||
return nil, fmt.Errorf("invite code expired")
|
||||
}
|
||||
return &invite, nil
|
||||
}
|
||||
|
||||
// ConsumeInvite marks an invite code as used by the given user.
|
||||
func ConsumeInvite(db *gorm.DB, invite *InviteCode, userID string) {
|
||||
now := time.Now()
|
||||
invite.UsedBy = &userID
|
||||
invite.UsedAt = &now
|
||||
db.Save(invite)
|
||||
}
|
||||
|
||||
// NeedsInviteOrApproval returns true if registration gating applies for the given mode.
|
||||
// Admins (first user or matching adminEmail) are never gated.
|
||||
// Must be called within a transaction that also creates the user.
|
||||
func NeedsInviteOrApproval(tx *gorm.DB, email, adminEmail, registrationMode string) bool {
|
||||
// Empty registration mode defaults to "approval"
|
||||
if registrationMode == "" {
|
||||
registrationMode = "approval"
|
||||
}
|
||||
if registrationMode != "approval" && registrationMode != "invite" {
|
||||
return false
|
||||
}
|
||||
// Admin email is never gated
|
||||
if adminEmail != "" && strings.EqualFold(email, adminEmail) {
|
||||
return false
|
||||
}
|
||||
// First user is never gated
|
||||
var count int64
|
||||
tx.Model(&User{}).Count(&count)
|
||||
if count == 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
84
core/http/auth/roles_test.go
Normal file
84
core/http/auth/roles_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
//go:build auth
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var _ = Describe("Roles", func() {
|
||||
var db *gorm.DB
|
||||
|
||||
BeforeEach(func() {
|
||||
db = testDB()
|
||||
})
|
||||
|
||||
Describe("AssignRole", func() {
|
||||
It("returns admin for the first user (empty DB)", func() {
|
||||
role := auth.AssignRole(db, "first@example.com", "")
|
||||
Expect(role).To(Equal(auth.RoleAdmin))
|
||||
})
|
||||
|
||||
It("returns user for the second user", func() {
|
||||
createTestUser(db, "first@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||
|
||||
role := auth.AssignRole(db, "second@example.com", "")
|
||||
Expect(role).To(Equal(auth.RoleUser))
|
||||
})
|
||||
|
||||
It("returns admin when email matches adminEmail", func() {
|
||||
createTestUser(db, "first@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||
|
||||
role := auth.AssignRole(db, "admin@example.com", "admin@example.com")
|
||||
Expect(role).To(Equal(auth.RoleAdmin))
|
||||
})
|
||||
|
||||
It("is case-insensitive for admin email match", func() {
|
||||
createTestUser(db, "first@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||
|
||||
role := auth.AssignRole(db, "Admin@Example.COM", "admin@example.com")
|
||||
Expect(role).To(Equal(auth.RoleAdmin))
|
||||
})
|
||||
|
||||
It("returns user when email does not match adminEmail", func() {
|
||||
createTestUser(db, "first@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||
|
||||
role := auth.AssignRole(db, "other@example.com", "admin@example.com")
|
||||
Expect(role).To(Equal(auth.RoleUser))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("MaybePromote", func() {
|
||||
It("promotes user to admin when email matches", func() {
|
||||
user := createTestUser(db, "promoted@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||
|
||||
promoted := auth.MaybePromote(db, user, "promoted@example.com")
|
||||
Expect(promoted).To(BeTrue())
|
||||
Expect(user.Role).To(Equal(auth.RoleAdmin))
|
||||
|
||||
// Verify in DB
|
||||
var dbUser auth.User
|
||||
db.First(&dbUser, "id = ?", user.ID)
|
||||
Expect(dbUser.Role).To(Equal(auth.RoleAdmin))
|
||||
})
|
||||
|
||||
It("does not promote when email does not match", func() {
|
||||
user := createTestUser(db, "user@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||
|
||||
promoted := auth.MaybePromote(db, user, "admin@example.com")
|
||||
Expect(promoted).To(BeFalse())
|
||||
Expect(user.Role).To(Equal(auth.RoleUser))
|
||||
})
|
||||
|
||||
It("does not demote an existing admin", func() {
|
||||
user := createTestUser(db, "admin@example.com", auth.RoleAdmin, auth.ProviderGitHub)
|
||||
|
||||
promoted := auth.MaybePromote(db, user, "other@example.com")
|
||||
Expect(promoted).To(BeFalse())
|
||||
Expect(user.Role).To(Equal(auth.RoleAdmin))
|
||||
})
|
||||
})
|
||||
})
|
||||
182
core/http/auth/session.go
Normal file
182
core/http/auth/session.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionDuration = 30 * 24 * time.Hour // 30 days
|
||||
sessionIDBytes = 32 // 32 bytes = 64 hex chars
|
||||
sessionCookie = "session"
|
||||
sessionRotationInterval = 1 * time.Hour
|
||||
)
|
||||
|
||||
// CreateSession creates a new session for the given user, returning the
|
||||
// plaintext token (64-char hex string). The stored session ID is the
|
||||
// HMAC-SHA256 hash of the token.
|
||||
func CreateSession(db *gorm.DB, userID, hmacSecret string) (string, error) {
|
||||
b := make([]byte, sessionIDBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("failed to generate session ID: %w", err)
|
||||
}
|
||||
|
||||
plaintext := hex.EncodeToString(b)
|
||||
hash := HashAPIKey(plaintext, hmacSecret)
|
||||
|
||||
now := time.Now()
|
||||
session := Session{
|
||||
ID: hash,
|
||||
UserID: userID,
|
||||
ExpiresAt: now.Add(sessionDuration),
|
||||
RotatedAt: now,
|
||||
}
|
||||
|
||||
if err := db.Create(&session).Error; err != nil {
|
||||
return "", fmt.Errorf("failed to create session: %w", err)
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// ValidateSession hashes the plaintext token and looks up the session.
|
||||
// Returns the associated user and session, or (nil, nil) if not found/expired.
|
||||
func ValidateSession(db *gorm.DB, token, hmacSecret string) (*User, *Session) {
|
||||
hash := HashAPIKey(token, hmacSecret)
|
||||
|
||||
var session Session
|
||||
if err := db.Preload("User").Where("id = ? AND expires_at > ?", hash, time.Now()).First(&session).Error; err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if session.User.Status != StatusActive {
|
||||
return nil, nil
|
||||
}
|
||||
return &session.User, &session
|
||||
}
|
||||
|
||||
// DeleteSession removes a session by hashing the plaintext token.
|
||||
func DeleteSession(db *gorm.DB, token, hmacSecret string) error {
|
||||
hash := HashAPIKey(token, hmacSecret)
|
||||
return db.Where("id = ?", hash).Delete(&Session{}).Error
|
||||
}
|
||||
|
||||
// CleanExpiredSessions removes all sessions that have passed their expiry time.
|
||||
func CleanExpiredSessions(db *gorm.DB) error {
|
||||
return db.Where("expires_at < ?", time.Now()).Delete(&Session{}).Error
|
||||
}
|
||||
|
||||
// DeleteUserSessions removes all sessions for the given user.
|
||||
func DeleteUserSessions(db *gorm.DB, userID string) error {
|
||||
return db.Where("user_id = ?", userID).Delete(&Session{}).Error
|
||||
}
|
||||
|
||||
// RotateSession creates a new session for the same user, deletes the old one,
|
||||
// and returns the new plaintext token.
|
||||
func RotateSession(db *gorm.DB, oldSession *Session, hmacSecret string) (string, error) {
|
||||
b := make([]byte, sessionIDBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("failed to generate session ID: %w", err)
|
||||
}
|
||||
|
||||
plaintext := hex.EncodeToString(b)
|
||||
hash := HashAPIKey(plaintext, hmacSecret)
|
||||
|
||||
now := time.Now()
|
||||
newSession := Session{
|
||||
ID: hash,
|
||||
UserID: oldSession.UserID,
|
||||
ExpiresAt: oldSession.ExpiresAt,
|
||||
RotatedAt: now,
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&newSession).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ?", oldSession.ID).Delete(&Session{}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to rotate session: %w", err)
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// MaybeRotateSession checks if the session should be rotated and does so if needed.
|
||||
// Called from the auth middleware after successful cookie-based authentication.
|
||||
func MaybeRotateSession(c echo.Context, db *gorm.DB, session *Session, hmacSecret string) {
|
||||
if session == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rotatedAt := session.RotatedAt
|
||||
if rotatedAt.IsZero() {
|
||||
rotatedAt = session.CreatedAt
|
||||
}
|
||||
|
||||
if time.Since(rotatedAt) < sessionRotationInterval {
|
||||
return
|
||||
}
|
||||
|
||||
newToken, err := RotateSession(db, session, hmacSecret)
|
||||
if err != nil {
|
||||
// Rotation failure is non-fatal; the old session remains valid
|
||||
return
|
||||
}
|
||||
|
||||
SetSessionCookie(c, newToken)
|
||||
}
|
||||
|
||||
// isSecure returns true when the request arrived over HTTPS, either directly
|
||||
// or via a reverse proxy that sets X-Forwarded-Proto.
|
||||
func isSecure(c echo.Context) bool {
|
||||
return c.Scheme() == "https"
|
||||
}
|
||||
|
||||
// SetSessionCookie sets the session cookie on the response.
|
||||
func SetSessionCookie(c echo.Context, sessionID string) {
|
||||
cookie := &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isSecure(c),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(sessionDuration.Seconds()),
|
||||
}
|
||||
c.SetCookie(cookie)
|
||||
}
|
||||
|
||||
// SetTokenCookie sets an httpOnly "token" cookie for legacy API key auth.
|
||||
func SetTokenCookie(c echo.Context, token string) {
|
||||
cookie := &http.Cookie{
|
||||
Name: "token",
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isSecure(c),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: int(sessionDuration.Seconds()),
|
||||
}
|
||||
c.SetCookie(cookie)
|
||||
}
|
||||
|
||||
// ClearSessionCookie clears the session cookie.
|
||||
func ClearSessionCookie(c echo.Context) {
|
||||
cookie := &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: isSecure(c),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
}
|
||||
c.SetCookie(cookie)
|
||||
}
|
||||
272
core/http/auth/session_test.go
Normal file
272
core/http/auth/session_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
//go:build auth
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var _ = Describe("Sessions", func() {
|
||||
var (
|
||||
db *gorm.DB
|
||||
user *auth.User
|
||||
)
|
||||
|
||||
// Use empty HMAC secret for basic tests
|
||||
hmacSecret := ""
|
||||
|
||||
BeforeEach(func() {
|
||||
db = testDB()
|
||||
user = createTestUser(db, "session@example.com", auth.RoleUser, auth.ProviderGitHub)
|
||||
})
|
||||
|
||||
Describe("CreateSession", func() {
|
||||
It("creates a session and returns 64-char hex plaintext token", func() {
|
||||
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(token).To(HaveLen(64))
|
||||
})
|
||||
|
||||
It("stores the hash (not plaintext) in the DB", func() {
|
||||
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
hash := auth.HashAPIKey(token, hmacSecret)
|
||||
var session auth.Session
|
||||
err = db.First(&session, "id = ?", hash).Error
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(session.UserID).To(Equal(user.ID))
|
||||
// The plaintext token should NOT be stored as the ID
|
||||
Expect(session.ID).ToNot(Equal(token))
|
||||
Expect(session.ID).To(Equal(hash))
|
||||
})
|
||||
|
||||
It("sets expiry to approximately 30 days from now", func() {
|
||||
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
hash := auth.HashAPIKey(token, hmacSecret)
|
||||
var session auth.Session
|
||||
db.First(&session, "id = ?", hash)
|
||||
|
||||
expectedExpiry := time.Now().Add(30 * 24 * time.Hour)
|
||||
Expect(session.ExpiresAt).To(BeTemporally("~", expectedExpiry, time.Minute))
|
||||
})
|
||||
|
||||
It("sets RotatedAt on creation", func() {
|
||||
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
hash := auth.HashAPIKey(token, hmacSecret)
|
||||
var session auth.Session
|
||||
db.First(&session, "id = ?", hash)
|
||||
|
||||
Expect(session.RotatedAt).To(BeTemporally("~", time.Now(), time.Minute))
|
||||
})
|
||||
|
||||
It("associates session with correct user", func() {
|
||||
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
hash := auth.HashAPIKey(token, hmacSecret)
|
||||
var session auth.Session
|
||||
db.First(&session, "id = ?", hash)
|
||||
Expect(session.UserID).To(Equal(user.ID))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("ValidateSession", func() {
|
||||
It("returns user for valid session", func() {
|
||||
token := createTestSession(db, user.ID)
|
||||
|
||||
found, session := auth.ValidateSession(db, token, hmacSecret)
|
||||
Expect(found).ToNot(BeNil())
|
||||
Expect(found.ID).To(Equal(user.ID))
|
||||
Expect(session).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns nil for non-existent session", func() {
|
||||
found, session := auth.ValidateSession(db, "nonexistent-session-id", hmacSecret)
|
||||
Expect(found).To(BeNil())
|
||||
Expect(session).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns nil for expired session", func() {
|
||||
token := createTestSession(db, user.ID)
|
||||
hash := auth.HashAPIKey(token, hmacSecret)
|
||||
|
||||
// Manually expire the session
|
||||
db.Model(&auth.Session{}).Where("id = ?", hash).
|
||||
Update("expires_at", time.Now().Add(-1*time.Hour))
|
||||
|
||||
found, _ := auth.ValidateSession(db, token, hmacSecret)
|
||||
Expect(found).To(BeNil())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("DeleteSession", func() {
|
||||
It("removes the session from DB", func() {
|
||||
token := createTestSession(db, user.ID)
|
||||
|
||||
err := auth.DeleteSession(db, token, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
found, _ := auth.ValidateSession(db, token, hmacSecret)
|
||||
Expect(found).To(BeNil())
|
||||
})
|
||||
|
||||
It("does not error on non-existent session", func() {
|
||||
err := auth.DeleteSession(db, "nonexistent", hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("CleanExpiredSessions", func() {
|
||||
It("removes expired sessions", func() {
|
||||
token := createTestSession(db, user.ID)
|
||||
hash := auth.HashAPIKey(token, hmacSecret)
|
||||
|
||||
// Manually expire the session
|
||||
db.Model(&auth.Session{}).Where("id = ?", hash).
|
||||
Update("expires_at", time.Now().Add(-1*time.Hour))
|
||||
|
||||
err := auth.CleanExpiredSessions(db)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var count int64
|
||||
db.Model(&auth.Session{}).Where("id = ?", hash).Count(&count)
|
||||
Expect(count).To(Equal(int64(0)))
|
||||
})
|
||||
|
||||
It("keeps active sessions", func() {
|
||||
token := createTestSession(db, user.ID)
|
||||
hash := auth.HashAPIKey(token, hmacSecret)
|
||||
|
||||
err := auth.CleanExpiredSessions(db)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
var count int64
|
||||
db.Model(&auth.Session{}).Where("id = ?", hash).Count(&count)
|
||||
Expect(count).To(Equal(int64(1)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("RotateSession", func() {
|
||||
It("creates a new session and deletes the old one", func() {
|
||||
token := createTestSession(db, user.ID)
|
||||
hash := auth.HashAPIKey(token, hmacSecret)
|
||||
|
||||
// Get the old session
|
||||
var oldSession auth.Session
|
||||
db.First(&oldSession, "id = ?", hash)
|
||||
|
||||
newToken, err := auth.RotateSession(db, &oldSession, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(newToken).To(HaveLen(64))
|
||||
Expect(newToken).ToNot(Equal(token))
|
||||
|
||||
// Old session should be gone
|
||||
var count int64
|
||||
db.Model(&auth.Session{}).Where("id = ?", hash).Count(&count)
|
||||
Expect(count).To(Equal(int64(0)))
|
||||
|
||||
// New session should exist and validate
|
||||
found, _ := auth.ValidateSession(db, newToken, hmacSecret)
|
||||
Expect(found).ToNot(BeNil())
|
||||
Expect(found.ID).To(Equal(user.ID))
|
||||
})
|
||||
|
||||
It("preserves user ID and expiry", func() {
|
||||
token := createTestSession(db, user.ID)
|
||||
hash := auth.HashAPIKey(token, hmacSecret)
|
||||
|
||||
var oldSession auth.Session
|
||||
db.First(&oldSession, "id = ?", hash)
|
||||
|
||||
newToken, err := auth.RotateSession(db, &oldSession, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
newHash := auth.HashAPIKey(newToken, hmacSecret)
|
||||
var newSession auth.Session
|
||||
db.First(&newSession, "id = ?", newHash)
|
||||
|
||||
Expect(newSession.UserID).To(Equal(oldSession.UserID))
|
||||
Expect(newSession.ExpiresAt).To(BeTemporally("~", oldSession.ExpiresAt, time.Second))
|
||||
})
|
||||
})
|
||||
|
||||
Context("with HMAC secret", func() {
|
||||
hmacSecret := "test-hmac-secret-123"
|
||||
|
||||
It("creates and validates sessions with HMAC secret", func() {
|
||||
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
found, session := auth.ValidateSession(db, token, hmacSecret)
|
||||
Expect(found).ToNot(BeNil())
|
||||
Expect(found.ID).To(Equal(user.ID))
|
||||
Expect(session).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("does not validate with wrong HMAC secret", func() {
|
||||
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
found, _ := auth.ValidateSession(db, token, "wrong-secret")
|
||||
Expect(found).To(BeNil())
|
||||
})
|
||||
|
||||
It("does not validate with empty HMAC secret", func() {
|
||||
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
found, _ := auth.ValidateSession(db, token, "")
|
||||
Expect(found).To(BeNil())
|
||||
})
|
||||
|
||||
It("session created with empty secret does not validate with non-empty secret", func() {
|
||||
token, err := auth.CreateSession(db, user.ID, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
found, _ := auth.ValidateSession(db, token, hmacSecret)
|
||||
Expect(found).To(BeNil())
|
||||
})
|
||||
|
||||
It("deletes session with correct HMAC secret", func() {
|
||||
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = auth.DeleteSession(db, token, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
found, _ := auth.ValidateSession(db, token, hmacSecret)
|
||||
Expect(found).To(BeNil())
|
||||
})
|
||||
|
||||
It("rotates session with HMAC secret", func() {
|
||||
token, err := auth.CreateSession(db, user.ID, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
hash := auth.HashAPIKey(token, hmacSecret)
|
||||
var oldSession auth.Session
|
||||
db.First(&oldSession, "id = ?", hash)
|
||||
|
||||
newToken, err := auth.RotateSession(db, &oldSession, hmacSecret)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Old token should not validate
|
||||
found, _ := auth.ValidateSession(db, token, hmacSecret)
|
||||
Expect(found).To(BeNil())
|
||||
|
||||
// New token should validate
|
||||
found, _ = auth.ValidateSession(db, newToken, hmacSecret)
|
||||
Expect(found).ToNot(BeNil())
|
||||
Expect(found.ID).To(Equal(user.ID))
|
||||
})
|
||||
})
|
||||
})
|
||||
151
core/http/auth/usage.go
Normal file
151
core/http/auth/usage.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UsageRecord represents a single API request's token usage.
|
||||
type UsageRecord struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
UserID string `gorm:"size:36;index:idx_usage_user_time"`
|
||||
UserName string `gorm:"size:255"`
|
||||
Model string `gorm:"size:255;index"`
|
||||
Endpoint string `gorm:"size:255"`
|
||||
PromptTokens int64
|
||||
CompletionTokens int64
|
||||
TotalTokens int64
|
||||
Duration int64 // milliseconds
|
||||
CreatedAt time.Time `gorm:"index:idx_usage_user_time"`
|
||||
}
|
||||
|
||||
// RecordUsage inserts a usage record.
|
||||
func RecordUsage(db *gorm.DB, record *UsageRecord) error {
|
||||
return db.Create(record).Error
|
||||
}
|
||||
|
||||
// UsageBucket is an aggregated time bucket for the dashboard.
|
||||
type UsageBucket struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Model string `json:"model"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
}
|
||||
|
||||
// UsageTotals is a summary of all usage.
|
||||
type UsageTotals struct {
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
RequestCount int64 `json:"request_count"`
|
||||
}
|
||||
|
||||
// periodToWindow returns the time window and SQL date format for a period.
|
||||
func periodToWindow(period string, isSQLite bool) (time.Time, string) {
|
||||
now := time.Now()
|
||||
var since time.Time
|
||||
var dateFmt string
|
||||
|
||||
switch period {
|
||||
case "day":
|
||||
since = now.Add(-24 * time.Hour)
|
||||
if isSQLite {
|
||||
dateFmt = "strftime('%Y-%m-%d %H:00', created_at)"
|
||||
} else {
|
||||
dateFmt = "to_char(date_trunc('hour', created_at), 'YYYY-MM-DD HH24:00')"
|
||||
}
|
||||
case "week":
|
||||
since = now.Add(-7 * 24 * time.Hour)
|
||||
if isSQLite {
|
||||
dateFmt = "strftime('%Y-%m-%d', created_at)"
|
||||
} else {
|
||||
dateFmt = "to_char(date_trunc('day', created_at), 'YYYY-MM-DD')"
|
||||
}
|
||||
case "all":
|
||||
since = time.Time{} // zero time = no filter
|
||||
if isSQLite {
|
||||
dateFmt = "strftime('%Y-%m', created_at)"
|
||||
} else {
|
||||
dateFmt = "to_char(date_trunc('month', created_at), 'YYYY-MM')"
|
||||
}
|
||||
default: // "month"
|
||||
since = now.Add(-30 * 24 * time.Hour)
|
||||
if isSQLite {
|
||||
dateFmt = "strftime('%Y-%m-%d', created_at)"
|
||||
} else {
|
||||
dateFmt = "to_char(date_trunc('day', created_at), 'YYYY-MM-DD')"
|
||||
}
|
||||
}
|
||||
|
||||
return since, dateFmt
|
||||
}
|
||||
|
||||
func isSQLiteDB(db *gorm.DB) bool {
|
||||
return strings.Contains(db.Dialector.Name(), "sqlite")
|
||||
}
|
||||
|
||||
// GetUserUsage returns aggregated usage for a single user.
|
||||
func GetUserUsage(db *gorm.DB, userID, period string) ([]UsageBucket, error) {
|
||||
sqlite := isSQLiteDB(db)
|
||||
since, dateFmt := periodToWindow(period, sqlite)
|
||||
|
||||
bucketExpr := fmt.Sprintf("%s as bucket", dateFmt)
|
||||
|
||||
query := db.Model(&UsageRecord{}).
|
||||
Select(bucketExpr+", model, "+
|
||||
"SUM(prompt_tokens) as prompt_tokens, "+
|
||||
"SUM(completion_tokens) as completion_tokens, "+
|
||||
"SUM(total_tokens) as total_tokens, "+
|
||||
"COUNT(*) as request_count").
|
||||
Where("user_id = ?", userID).
|
||||
Group("bucket, model").
|
||||
Order("bucket ASC")
|
||||
|
||||
if !since.IsZero() {
|
||||
query = query.Where("created_at >= ?", since)
|
||||
}
|
||||
|
||||
var buckets []UsageBucket
|
||||
if err := query.Find(&buckets).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buckets, nil
|
||||
}
|
||||
|
||||
// GetAllUsage returns aggregated usage for all users (admin). Optional userID filter.
|
||||
func GetAllUsage(db *gorm.DB, period, userID string) ([]UsageBucket, error) {
|
||||
sqlite := isSQLiteDB(db)
|
||||
since, dateFmt := periodToWindow(period, sqlite)
|
||||
|
||||
bucketExpr := fmt.Sprintf("%s as bucket", dateFmt)
|
||||
|
||||
query := db.Model(&UsageRecord{}).
|
||||
Select(bucketExpr+", model, user_id, user_name, "+
|
||||
"SUM(prompt_tokens) as prompt_tokens, "+
|
||||
"SUM(completion_tokens) as completion_tokens, "+
|
||||
"SUM(total_tokens) as total_tokens, "+
|
||||
"COUNT(*) as request_count").
|
||||
Group("bucket, model, user_id, user_name").
|
||||
Order("bucket ASC")
|
||||
|
||||
if !since.IsZero() {
|
||||
query = query.Where("created_at >= ?", since)
|
||||
}
|
||||
|
||||
if userID != "" {
|
||||
query = query.Where("user_id = ?", userID)
|
||||
}
|
||||
|
||||
var buckets []UsageBucket
|
||||
if err := query.Find(&buckets).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buckets, nil
|
||||
}
|
||||
161
core/http/auth/usage_test.go
Normal file
161
core/http/auth/usage_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
//go:build auth
|
||||
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Usage", func() {
|
||||
Describe("RecordUsage", func() {
|
||||
It("inserts a usage record", func() {
|
||||
db := testDB()
|
||||
record := &auth.UsageRecord{
|
||||
UserID: "user-1",
|
||||
UserName: "Test User",
|
||||
Model: "gpt-4",
|
||||
Endpoint: "/v1/chat/completions",
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 50,
|
||||
TotalTokens: 150,
|
||||
Duration: 1200,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
err := auth.RecordUsage(db, record)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(record.ID).ToNot(BeZero())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetUserUsage", func() {
|
||||
It("returns aggregated usage for a specific user", func() {
|
||||
db := testDB()
|
||||
|
||||
// Insert records for two users
|
||||
for i := 0; i < 3; i++ {
|
||||
err := auth.RecordUsage(db, &auth.UsageRecord{
|
||||
UserID: "user-a",
|
||||
UserName: "Alice",
|
||||
Model: "gpt-4",
|
||||
Endpoint: "/v1/chat/completions",
|
||||
PromptTokens: 100,
|
||||
TotalTokens: 150,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
err := auth.RecordUsage(db, &auth.UsageRecord{
|
||||
UserID: "user-b",
|
||||
UserName: "Bob",
|
||||
Model: "gpt-4",
|
||||
PromptTokens: 200,
|
||||
TotalTokens: 300,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
buckets, err := auth.GetUserUsage(db, "user-a", "month")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(buckets).ToNot(BeEmpty())
|
||||
|
||||
// All returned buckets should be for user-a's model
|
||||
totalPrompt := int64(0)
|
||||
for _, b := range buckets {
|
||||
totalPrompt += b.PromptTokens
|
||||
}
|
||||
Expect(totalPrompt).To(Equal(int64(300)))
|
||||
})
|
||||
|
||||
It("filters by period", func() {
|
||||
db := testDB()
|
||||
|
||||
// Record in the past (beyond day window)
|
||||
err := auth.RecordUsage(db, &auth.UsageRecord{
|
||||
UserID: "user-c",
|
||||
UserName: "Carol",
|
||||
Model: "gpt-4",
|
||||
PromptTokens: 100,
|
||||
TotalTokens: 100,
|
||||
CreatedAt: time.Now().Add(-48 * time.Hour),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Record now
|
||||
err = auth.RecordUsage(db, &auth.UsageRecord{
|
||||
UserID: "user-c",
|
||||
UserName: "Carol",
|
||||
Model: "gpt-4",
|
||||
PromptTokens: 200,
|
||||
TotalTokens: 200,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
// Day period should only include recent record
|
||||
buckets, err := auth.GetUserUsage(db, "user-c", "day")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
totalPrompt := int64(0)
|
||||
for _, b := range buckets {
|
||||
totalPrompt += b.PromptTokens
|
||||
}
|
||||
Expect(totalPrompt).To(Equal(int64(200)))
|
||||
|
||||
// Month period should include both
|
||||
buckets, err = auth.GetUserUsage(db, "user-c", "month")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
totalPrompt = 0
|
||||
for _, b := range buckets {
|
||||
totalPrompt += b.PromptTokens
|
||||
}
|
||||
Expect(totalPrompt).To(Equal(int64(300)))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetAllUsage", func() {
|
||||
It("returns usage for all users", func() {
|
||||
db := testDB()
|
||||
|
||||
for _, uid := range []string{"user-x", "user-y"} {
|
||||
err := auth.RecordUsage(db, &auth.UsageRecord{
|
||||
UserID: uid,
|
||||
UserName: uid,
|
||||
Model: "gpt-4",
|
||||
PromptTokens: 100,
|
||||
TotalTokens: 150,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
}
|
||||
|
||||
buckets, err := auth.GetAllUsage(db, "month", "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(len(buckets)).To(BeNumerically(">=", 2))
|
||||
})
|
||||
|
||||
It("filters by user ID when specified", func() {
|
||||
db := testDB()
|
||||
|
||||
err := auth.RecordUsage(db, &auth.UsageRecord{
|
||||
UserID: "user-p", UserName: "Pat", Model: "gpt-4",
|
||||
PromptTokens: 100, TotalTokens: 100, CreatedAt: time.Now(),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
err = auth.RecordUsage(db, &auth.UsageRecord{
|
||||
UserID: "user-q", UserName: "Quinn", Model: "gpt-4",
|
||||
PromptTokens: 200, TotalTokens: 200, CreatedAt: time.Now(),
|
||||
})
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
buckets, err := auth.GetAllUsage(db, "month", "user-p")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
for _, b := range buckets {
|
||||
Expect(b.UserID).To(Equal("user-p"))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -12,27 +12,54 @@ import (
|
||||
func ListCollectionsEndpoint(app *application.Application) echo.HandlerFunc {
|
||||
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()})
|
||||
|
||||
@@ -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(¶ms); 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()})
|
||||
}
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
185
core/http/middleware/usage.go
Normal file
185
core/http/middleware/usage.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
"github.com/mudler/xlog"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
usageFlushInterval = 5 * time.Second
|
||||
usageMaxPending = 5000
|
||||
)
|
||||
|
||||
// usageBatcher accumulates usage records and flushes them to the DB periodically.
|
||||
type usageBatcher struct {
|
||||
mu sync.Mutex
|
||||
pending []*auth.UsageRecord
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (b *usageBatcher) add(r *auth.UsageRecord) {
|
||||
b.mu.Lock()
|
||||
b.pending = append(b.pending, r)
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *usageBatcher) flush() {
|
||||
b.mu.Lock()
|
||||
batch := b.pending
|
||||
b.pending = nil
|
||||
b.mu.Unlock()
|
||||
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := b.db.Create(&batch).Error; err != nil {
|
||||
xlog.Error("Failed to flush usage batch", "count", len(batch), "error", err)
|
||||
// Re-queue failed records with a cap to avoid unbounded growth
|
||||
b.mu.Lock()
|
||||
if len(b.pending) < usageMaxPending {
|
||||
b.pending = append(batch, b.pending...)
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
var batcher *usageBatcher
|
||||
|
||||
// InitUsageRecorder starts a background goroutine that periodically flushes
|
||||
// accumulated usage records to the database.
|
||||
func InitUsageRecorder(db *gorm.DB) {
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
batcher = &usageBatcher{db: db}
|
||||
go func() {
|
||||
ticker := time.NewTicker(usageFlushInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
batcher.flush()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// usageResponseBody is the minimal structure we need from the response JSON.
|
||||
type usageResponseBody struct {
|
||||
Model string `json:"model"`
|
||||
Usage *struct {
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
|
||||
// UsageMiddleware extracts token usage from OpenAI-compatible response JSON
|
||||
// and records it per-user.
|
||||
func UsageMiddleware(db *gorm.DB) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if db == nil || batcher == nil {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// Wrap response writer to capture body
|
||||
resBody := new(bytes.Buffer)
|
||||
origWriter := c.Response().Writer
|
||||
mw := &bodyWriter{
|
||||
ResponseWriter: origWriter,
|
||||
body: resBody,
|
||||
}
|
||||
c.Response().Writer = mw
|
||||
|
||||
handlerErr := next(c)
|
||||
|
||||
// Restore original writer
|
||||
c.Response().Writer = origWriter
|
||||
|
||||
// Only record on successful responses
|
||||
if c.Response().Status < 200 || c.Response().Status >= 300 {
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
// Get authenticated user
|
||||
user := auth.GetUser(c)
|
||||
if user == nil {
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
// Try to parse usage from response
|
||||
responseBytes := resBody.Bytes()
|
||||
if len(responseBytes) == 0 {
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
// Check content type
|
||||
ct := c.Response().Header().Get("Content-Type")
|
||||
isJSON := ct == "" || ct == "application/json" || bytes.HasPrefix([]byte(ct), []byte("application/json"))
|
||||
isSSE := bytes.HasPrefix([]byte(ct), []byte("text/event-stream"))
|
||||
|
||||
if !isJSON && !isSSE {
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
var resp usageResponseBody
|
||||
if isSSE {
|
||||
last, ok := lastSSEData(responseBytes)
|
||||
if !ok {
|
||||
return handlerErr
|
||||
}
|
||||
if err := json.Unmarshal(last, &resp); err != nil {
|
||||
return handlerErr
|
||||
}
|
||||
} else {
|
||||
if err := json.Unmarshal(responseBytes, &resp); err != nil {
|
||||
return handlerErr
|
||||
}
|
||||
}
|
||||
|
||||
if resp.Usage == nil {
|
||||
return handlerErr
|
||||
}
|
||||
|
||||
record := &auth.UsageRecord{
|
||||
UserID: user.ID,
|
||||
UserName: user.Name,
|
||||
Model: resp.Model,
|
||||
Endpoint: c.Request().URL.Path,
|
||||
PromptTokens: resp.Usage.PromptTokens,
|
||||
CompletionTokens: resp.Usage.CompletionTokens,
|
||||
TotalTokens: resp.Usage.TotalTokens,
|
||||
Duration: time.Since(startTime).Milliseconds(),
|
||||
CreatedAt: startTime,
|
||||
}
|
||||
|
||||
batcher.add(record)
|
||||
|
||||
return handlerErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// lastSSEData returns the payload of the last "data: " line whose content is not "[DONE]".
|
||||
func lastSSEData(b []byte) ([]byte, bool) {
|
||||
prefix := []byte("data: ")
|
||||
var last []byte
|
||||
for _, line := range bytes.Split(b, []byte("\n")) {
|
||||
line = bytes.TrimRight(line, "\r")
|
||||
if bytes.HasPrefix(line, prefix) {
|
||||
payload := line[len(prefix):]
|
||||
if !bytes.Equal(payload, []byte("[DONE]")) {
|
||||
last = payload
|
||||
}
|
||||
}
|
||||
}
|
||||
return last, last != nil
|
||||
}
|
||||
@@ -9,7 +9,7 @@ test.describe('Navigation', () => {
|
||||
test('/app shows home page with LocalAI title', async ({ page }) => {
|
||||
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 }) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
90
core/http/react-ui/src/components/ConfirmDialog.jsx
Normal file
90
core/http/react-ui/src/components/ConfirmDialog.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
title = 'Confirm',
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
danger = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) {
|
||||
const dialogRef = useRef(null)
|
||||
const confirmRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
confirmRef.current?.focus()
|
||||
|
||||
const dialog = dialogRef.current
|
||||
if (!dialog) return
|
||||
|
||||
const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
const getFocusable = () => dialog.querySelectorAll(focusableSelector)
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onCancel?.()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const focusable = getFocusable()
|
||||
if (focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, onCancel])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const titleId = 'confirm-dialog-title'
|
||||
const bodyId = 'confirm-dialog-body'
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-backdrop" onClick={onCancel}>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="confirm-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={message ? bodyId : undefined}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="confirm-dialog-header">
|
||||
{danger && <i className="fas fa-exclamation-triangle confirm-dialog-danger-icon" />}
|
||||
<span id={titleId} className="confirm-dialog-title">{title}</span>
|
||||
</div>
|
||||
{message && <div id={bodyId} className="confirm-dialog-body">{message}</div>}
|
||||
<div className="confirm-dialog-actions">
|
||||
<button className="btn btn-secondary btn-sm" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
className={`btn btn-sm ${danger ? 'btn-danger' : 'btn-primary'}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,22 @@
|
||||
import { useState } from 'react'
|
||||
import { apiUrl } from '../utils/basePath'
|
||||
|
||||
export default function LoadingSpinner({ size = 'md', className = '' }) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
10
core/http/react-ui/src/components/RequireAdmin.jsx
Normal file
10
core/http/react-ui/src/components/RequireAdmin.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function RequireAdmin({ children }) {
|
||||
const { isAdmin, authEnabled, user, loading } = useAuth()
|
||||
if (loading) return null
|
||||
if (authEnabled && !user) return <Navigate to="/login" replace />
|
||||
if (!isAdmin) return <Navigate to="/app" replace />
|
||||
return children
|
||||
}
|
||||
9
core/http/react-ui/src/components/RequireAuth.jsx
Normal file
9
core/http/react-ui/src/components/RequireAuth.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function RequireAuth({ children }) {
|
||||
const { authEnabled, user, loading } = useAuth()
|
||||
if (loading) return null
|
||||
if (authEnabled && !user) return <Navigate to="/login" replace />
|
||||
return children
|
||||
}
|
||||
10
core/http/react-ui/src/components/RequireFeature.jsx
Normal file
10
core/http/react-ui/src/components/RequireFeature.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function RequireFeature({ feature, children }) {
|
||||
const { isAdmin, hasFeature, authEnabled, user, loading } = useAuth()
|
||||
if (loading) return null
|
||||
if (authEnabled && !user) return <Navigate to="/login" replace />
|
||||
if (!isAdmin && !hasFeature(feature)) return <Navigate to="/app" replace />
|
||||
return children
|
||||
}
|
||||
@@ -41,7 +41,10 @@ export default function ResourceCards({ metadata, onOpenArtifact, messageIndex,
|
||||
<div
|
||||
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" />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
170
core/http/react-ui/src/components/UserGroupSection.jsx
Normal file
170
core/http/react-ui/src/components/UserGroupSection.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
/**
|
||||
* UserGroupSection — collapsible section showing other users' resources.
|
||||
*
|
||||
* Props:
|
||||
* title — e.g. "Other Users' Agents"
|
||||
* userGroups — { [userId]: { agents: [...], skills: [...], etc } }
|
||||
* userMap — { [userId]: { name, email, avatarUrl } }
|
||||
* currentUserId — current user's ID (excluded from display)
|
||||
* renderGroup — (items, userId) => JSX — renders the items for one user
|
||||
* itemKey — key in the group object to count items (e.g. "agents", "skills")
|
||||
*/
|
||||
export default function UserGroupSection({ title, userGroups, userMap, currentUserId, renderGroup, itemKey }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
if (!userGroups || Object.keys(userGroups).length === 0) return null
|
||||
|
||||
const userIds = Object.keys(userGroups).filter(id => id !== currentUserId)
|
||||
if (userIds.length === 0) return null
|
||||
|
||||
const totalUsers = userIds.length
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 'var(--spacing-lg)' }}>
|
||||
<style>{`
|
||||
.ugs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
padding: var(--spacing-sm) 0;
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
user-select: none;
|
||||
}
|
||||
.ugs-header:hover { opacity: 0.8; }
|
||||
.ugs-chevron {
|
||||
transition: transform 0.2s;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.ugs-chevron.open { transform: rotate(90deg); }
|
||||
.ugs-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.ugs-badge {
|
||||
font-size: 0.75rem;
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.ugs-content {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-md);
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
.ugs-user-section {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
.ugs-user-section:last-child { margin-bottom: 0; }
|
||||
.ugs-user-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.ugs-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ugs-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.ugs-user-name {
|
||||
font-weight: 500;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.ugs-user-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
className="ugs-header"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setOpen(v => !v)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(v => !v) } }}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<i className={`fas fa-chevron-right ugs-chevron ${open ? 'open' : ''}`} />
|
||||
<span className="ugs-title">{title}</span>
|
||||
<span className="ugs-badge">{totalUsers} user{totalUsers !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="ugs-content">
|
||||
{userIds.map(uid => {
|
||||
const user = userMap[uid] || {}
|
||||
const displayName = user.name || user.email || uid.slice(0, 8) + '...'
|
||||
const initials = (displayName[0] || '?').toUpperCase()
|
||||
const group = userGroups[uid]
|
||||
const items = itemKey ? group[itemKey] : group
|
||||
const count = Array.isArray(items) ? items.length : 0
|
||||
|
||||
return (
|
||||
<UserSubSection
|
||||
key={uid}
|
||||
uid={uid}
|
||||
displayName={displayName}
|
||||
initials={initials}
|
||||
avatarUrl={user.avatarUrl}
|
||||
count={count}
|
||||
itemKey={itemKey}
|
||||
>
|
||||
{renderGroup(items, uid)}
|
||||
</UserSubSection>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserSubSection({ uid, displayName, initials, avatarUrl, count, itemKey, children }) {
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
return (
|
||||
<div className="ugs-user-section">
|
||||
<div
|
||||
className="ugs-user-header"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setOpen(v => !v)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(v => !v) } }}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<i className={`fas fa-chevron-right ugs-chevron ${open ? 'open' : ''}`} style={{ fontSize: '0.625rem' }} />
|
||||
<div className="ugs-avatar">
|
||||
{avatarUrl ? <img src={avatarUrl} alt="" /> : initials}
|
||||
</div>
|
||||
<span className="ugs-user-name">{displayName}</span>
|
||||
<span className="ugs-user-count">
|
||||
{count} {itemKey || 'item'}{count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{open && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
core/http/react-ui/src/context/AuthContext.jsx
Normal file
75
core/http/react-ui/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { apiUrl } from '../utils/basePath'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [state, setState] = useState({
|
||||
loading: true,
|
||||
authEnabled: false,
|
||||
user: null,
|
||||
permissions: {},
|
||||
})
|
||||
|
||||
const fetchStatus = () => {
|
||||
return fetch(apiUrl('/api/auth/status'))
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const user = data.user || null
|
||||
const permissions = user?.permissions || {}
|
||||
setState({
|
||||
loading: false,
|
||||
authEnabled: data.authEnabled || false,
|
||||
user,
|
||||
permissions,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
setState({ loading: false, authEnabled: false, user: null, permissions: {} })
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [])
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await fetch(apiUrl('/api/auth/logout'), { method: 'POST' })
|
||||
} catch (_) { /* ignore */ }
|
||||
// Clear cookies
|
||||
document.cookie = 'session=; path=/; max-age=-1'
|
||||
document.cookie = 'token=; path=/; max-age=-1'
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
const refresh = () => fetchStatus()
|
||||
|
||||
const hasFeature = (name) => {
|
||||
if (state.user?.role === 'admin' || !state.authEnabled) return true
|
||||
return !!state.permissions[name]
|
||||
}
|
||||
|
||||
const value = {
|
||||
loading: state.loading,
|
||||
authEnabled: state.authEnabled,
|
||||
user: state.user,
|
||||
permissions: state.permissions,
|
||||
isAdmin: state.user?.role === 'admin' || !state.authEnabled,
|
||||
hasFeature,
|
||||
logout,
|
||||
refresh,
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -2,10 +2,15 @@ import { createContext, useContext, useState, useEffect } from 'react'
|
||||
|
||||
const ThemeContext = createContext()
|
||||
|
||||
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)
|
||||
|
||||
11
core/http/react-ui/src/hooks/useOperations.js
vendored
11
core/http/react-ui/src/hooks/useOperations.js
vendored
@@ -1,16 +1,22 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { 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) => {
|
||||
|
||||
29
core/http/react-ui/src/hooks/useUserMap.js
vendored
Normal file
29
core/http/react-ui/src/hooks/useUserMap.js
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { adminUsersApi } from '../utils/api'
|
||||
|
||||
/**
|
||||
* Hook that fetches all users and returns a map of userId -> { name, email, avatarUrl }.
|
||||
* Only fetches when the current user is admin and auth is enabled.
|
||||
*/
|
||||
export function useUserMap() {
|
||||
const { isAdmin, authEnabled } = useAuth()
|
||||
const [userMap, setUserMap] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin || !authEnabled) return
|
||||
let cancelled = false
|
||||
adminUsersApi.list().then(data => {
|
||||
if (cancelled) return
|
||||
const users = Array.isArray(data) ? data : (data?.users || [])
|
||||
const map = {}
|
||||
for (const u of users) {
|
||||
map[u.id] = { name: u.name || u.email || u.id, email: u.email, avatarUrl: u.avatar_url }
|
||||
}
|
||||
setUserMap(map)
|
||||
}).catch(() => {})
|
||||
return () => { cancelled = true }
|
||||
}, [isAdmin, authEnabled])
|
||||
|
||||
return userMap
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { 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>,
|
||||
)
|
||||
|
||||
448
core/http/react-ui/src/pages/Account.jsx
Normal file
448
core/http/react-ui/src/pages/Account.jsx
Normal file
@@ -0,0 +1,448 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { apiKeysApi, profileApi } from '../utils/api'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import SettingRow from '../components/SettingRow'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import './auth.css'
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return '-'
|
||||
return new Date(d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 'profile', icon: 'fa-user', label: 'Profile' },
|
||||
{ id: 'security', icon: 'fa-lock', label: 'Security' },
|
||||
{ id: 'apikeys', icon: 'fa-key', label: 'API Keys' },
|
||||
]
|
||||
|
||||
function ProfileTab({ addToast }) {
|
||||
const { user, refresh } = useAuth()
|
||||
const [name, setName] = useState(user?.name || '')
|
||||
const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => { if (user?.name) setName(user.name) }, [user?.name])
|
||||
useEffect(() => { setAvatarUrl(user?.avatarUrl || '') }, [user?.avatarUrl])
|
||||
|
||||
const hasChanges = (name.trim() && name.trim() !== user?.name) || (avatarUrl.trim() !== (user?.avatarUrl || ''))
|
||||
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim() || !hasChanges) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await profileApi.updateProfile(name.trim(), avatarUrl.trim())
|
||||
addToast('Profile updated', 'success')
|
||||
refresh()
|
||||
} catch (err) {
|
||||
addToast(`Failed to update profile: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* User info header */}
|
||||
<div className="account-user-header">
|
||||
<div className="account-avatar-frame">
|
||||
{user?.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="" className="user-avatar--lg" />
|
||||
) : (
|
||||
<i className="fas fa-user account-avatar-icon" />
|
||||
)}
|
||||
</div>
|
||||
<div className="account-user-meta">
|
||||
<div className="account-user-email">{user?.email}</div>
|
||||
<div className="account-user-badges">
|
||||
<span className={`role-badge ${user?.role === 'admin' ? 'role-badge-admin' : 'role-badge-user'}`}>
|
||||
{user?.role}
|
||||
</span>
|
||||
<span className="provider-tag">
|
||||
{user?.provider || 'local'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile form */}
|
||||
<form onSubmit={handleSave}>
|
||||
<div className="card">
|
||||
<SettingRow label="Display name" description="Your public display name">
|
||||
<input
|
||||
type="text"
|
||||
className="input account-input-sm"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={saving}
|
||||
maxLength={100}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Avatar URL" description="URL to your profile picture">
|
||||
<div className="account-input-row">
|
||||
<input
|
||||
type="url"
|
||||
className="input account-input-sm"
|
||||
value={avatarUrl}
|
||||
onChange={(e) => setAvatarUrl(e.target.value)}
|
||||
disabled={saving}
|
||||
maxLength={512}
|
||||
placeholder="https://example.com/avatar.png"
|
||||
/>
|
||||
{avatarUrl.trim() && (
|
||||
<img
|
||||
src={avatarUrl.trim()}
|
||||
alt="preview"
|
||||
className="account-avatar-preview"
|
||||
onError={(e) => { e.target.style.display = 'none' }}
|
||||
onLoad={(e) => { e.target.style.display = 'block' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={saving || !name.trim() || !hasChanges}
|
||||
>
|
||||
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> Save</>}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SecurityTab({ addToast }) {
|
||||
const { user } = useAuth()
|
||||
const isLocal = user?.provider === 'local'
|
||||
|
||||
const [currentPw, setCurrentPw] = useState('')
|
||||
const [newPw, setNewPw] = useState('')
|
||||
const [confirmPw, setConfirmPw] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (newPw !== confirmPw) {
|
||||
addToast('Passwords do not match', 'error')
|
||||
return
|
||||
}
|
||||
if (newPw.length < 8) {
|
||||
addToast('New password must be at least 8 characters', 'error')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await profileApi.changePassword(currentPw, newPw)
|
||||
addToast('Password changed', 'success')
|
||||
setCurrentPw('')
|
||||
setNewPw('')
|
||||
setConfirmPw('')
|
||||
} catch (err) {
|
||||
addToast(err.message, 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isLocal) {
|
||||
return (
|
||||
<div className="card empty-icon-block">
|
||||
<i className="fas fa-shield-halved" />
|
||||
<div className="empty-icon-block-text">
|
||||
Password management is not available for {user?.provider || 'OAuth'} accounts.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="card">
|
||||
<SettingRow label="Current password" description="Enter your existing password to verify your identity">
|
||||
<input
|
||||
type="password"
|
||||
className="input account-input-sm"
|
||||
value={currentPw}
|
||||
onChange={(e) => setCurrentPw(e.target.value)}
|
||||
placeholder="Current password"
|
||||
disabled={saving}
|
||||
required
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="New password" description="Must be at least 8 characters">
|
||||
<input
|
||||
type="password"
|
||||
className="input account-input-sm"
|
||||
value={newPw}
|
||||
onChange={(e) => setNewPw(e.target.value)}
|
||||
placeholder="New password"
|
||||
minLength={8}
|
||||
disabled={saving}
|
||||
required
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Confirm password" description="Re-enter your new password">
|
||||
<input
|
||||
type="password"
|
||||
className="input account-input-sm"
|
||||
value={confirmPw}
|
||||
onChange={(e) => setConfirmPw(e.target.value)}
|
||||
placeholder="Confirm new password"
|
||||
disabled={saving}
|
||||
required
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={saving || !currentPw || !newPw || !confirmPw}
|
||||
>
|
||||
{saving ? <><LoadingSpinner size="sm" /> Changing...</> : 'Change password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiKeysTab({ addToast }) {
|
||||
const [keys, setKeys] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [newKeyName, setNewKeyName] = useState('')
|
||||
const [newKeyPlaintext, setNewKeyPlaintext] = useState(null)
|
||||
const [revokingId, setRevokingId] = useState(null)
|
||||
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await apiKeysApi.list()
|
||||
setKeys(data.keys || [])
|
||||
} catch (err) {
|
||||
addToast(`Failed to load API keys: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [addToast])
|
||||
|
||||
useEffect(() => { fetchKeys() }, [fetchKeys])
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!newKeyName.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const data = await apiKeysApi.create(newKeyName.trim())
|
||||
setNewKeyPlaintext(data.key)
|
||||
setNewKeyName('')
|
||||
await fetchKeys()
|
||||
addToast('API key created', 'success')
|
||||
} catch (err) {
|
||||
addToast(`Failed to create API key: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async (id, name) => {
|
||||
setConfirmDialog({
|
||||
title: 'Revoke API Key',
|
||||
message: `Revoke API key "${name}"? This cannot be undone.`,
|
||||
confirmLabel: 'Revoke',
|
||||
danger: true,
|
||||
onConfirm: async () => {
|
||||
setConfirmDialog(null)
|
||||
setRevokingId(id)
|
||||
try {
|
||||
await apiKeysApi.revoke(id)
|
||||
setKeys(prev => prev.filter(k => k.id !== id))
|
||||
addToast('API key revoked', 'success')
|
||||
} catch (err) {
|
||||
addToast(`Failed to revoke API key: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setRevokingId(null)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => addToast('Copied to clipboard', 'success'),
|
||||
() => fallbackCopy(text),
|
||||
)
|
||||
} else {
|
||||
fallbackCopy(text)
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackCopy = (text) => {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
addToast('Copied to clipboard', 'success')
|
||||
} catch (_) {
|
||||
addToast('Failed to copy', 'error')
|
||||
}
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Create key form */}
|
||||
<div className="card" style={{ marginBottom: 'var(--spacing-md)' }}>
|
||||
<form onSubmit={handleCreate}>
|
||||
<SettingRow label="Create API key" description="Generate a key for programmatic access">
|
||||
<div className="account-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="input account-input-xs"
|
||||
placeholder="Key name (e.g. my-app)"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
disabled={creating}
|
||||
maxLength={64}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={creating || !newKeyName.trim()}>
|
||||
{creating ? <LoadingSpinner size="sm" /> : <><i className="fas fa-plus" /> Create</>}
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Newly created key banner */}
|
||||
{newKeyPlaintext && (
|
||||
<div className="new-key-banner">
|
||||
<div className="new-key-banner-header">
|
||||
<i className="fas fa-triangle-exclamation" />
|
||||
Copy now — this key won't be shown again
|
||||
</div>
|
||||
<div className="new-key-banner-body">
|
||||
<code className="new-key-value">
|
||||
{newKeyPlaintext}
|
||||
</code>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => copyToClipboard(newKeyPlaintext)}>
|
||||
<i className="fas fa-copy" />
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setNewKeyPlaintext(null)}>
|
||||
<i className="fas fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keys list */}
|
||||
{loading ? (
|
||||
<div className="auth-loading">
|
||||
<LoadingSpinner size="sm" />
|
||||
</div>
|
||||
) : keys.length === 0 ? (
|
||||
<div className="card empty-icon-block">
|
||||
<i className="fas fa-key" />
|
||||
<div className="empty-icon-block-text">
|
||||
No API keys yet. Create one above to get programmatic access.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
{keys.map((k) => (
|
||||
<div key={k.id} className="apikey-row">
|
||||
<i className="fas fa-key apikey-icon" />
|
||||
<div className="apikey-info">
|
||||
<div className="apikey-name">{k.name}</div>
|
||||
<div className="apikey-details">
|
||||
{k.keyPrefix}... · {formatDate(k.createdAt)}
|
||||
{k.lastUsed && <> · last used {formatDate(k.lastUsed)}</>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm apikey-revoke-btn"
|
||||
onClick={() => handleRevoke(k.id, k.name)}
|
||||
disabled={revokingId === k.id}
|
||||
title="Revoke key"
|
||||
>
|
||||
{revokingId === k.id ? <LoadingSpinner size="sm" /> : <i className="fas fa-trash" />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDialog}
|
||||
title={confirmDialog?.title}
|
||||
message={confirmDialog?.message}
|
||||
confirmLabel={confirmDialog?.confirmLabel}
|
||||
danger={confirmDialog?.danger}
|
||||
onConfirm={confirmDialog?.onConfirm}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Account() {
|
||||
const { addToast } = useOutletContext()
|
||||
const { authEnabled, user } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState('profile')
|
||||
|
||||
if (!authEnabled) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><i className="fas fa-user-gear" /></div>
|
||||
<h2 className="empty-state-title">Account unavailable</h2>
|
||||
<p className="empty-state-text">Authentication must be enabled to manage your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Filter tabs: hide security tab for OAuth-only users
|
||||
const isLocal = user?.provider === 'local'
|
||||
const visibleTabs = isLocal ? TABS : TABS.filter(t => t.id !== 'security')
|
||||
|
||||
return (
|
||||
<div className="page account-page">
|
||||
{/* Header */}
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Account</h1>
|
||||
<p className="page-subtitle">Profile, credentials, and API keys</p>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="auth-tab-bar auth-tab-bar--flush">
|
||||
{visibleTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`auth-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
>
|
||||
<i className={`fas ${tab.icon} auth-tab-icon`} />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'profile' && <ProfileTab addToast={addToast} />}
|
||||
{activeTab === 'security' && <SecurityTab addToast={addToast} />}
|
||||
{activeTab === 'apikeys' && <ApiKeysTab addToast={addToast} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
501
core/http/react-ui/src/pages/Usage.jsx
Normal file
501
core/http/react-ui/src/pages/Usage.jsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { apiUrl } from '../utils/basePath'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
|
||||
const PERIODS = [
|
||||
{ key: 'day', label: 'Day' },
|
||||
{ key: 'week', label: 'Week' },
|
||||
{ key: 'month', label: 'Month' },
|
||||
{ key: 'all', label: 'All' },
|
||||
]
|
||||
|
||||
function formatNumber(n) {
|
||||
if (n == null) return '0'
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value }) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 'var(--spacing-sm) var(--spacing-md)', flex: '1 1 0', minWidth: 120 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||
<i className={icon} style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }} />
|
||||
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.03em' }}>{label}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.375rem', fontWeight: 700, fontFamily: 'JetBrains Mono, monospace', color: 'var(--color-text-primary)' }}>
|
||||
{formatNumber(value)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UsageBar({ value, max }) {
|
||||
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%', height: 6, borderRadius: 3,
|
||||
background: 'var(--color-bg-primary)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
width: `${pct}%`, height: '100%', borderRadius: 3,
|
||||
background: 'var(--color-primary)',
|
||||
transition: 'width 0.3s ease',
|
||||
}} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function aggregateByModel(buckets) {
|
||||
const map = {}
|
||||
for (const b of buckets) {
|
||||
const key = b.model || '(unknown)'
|
||||
if (!map[key]) {
|
||||
map[key] = { model: key, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, request_count: 0 }
|
||||
}
|
||||
map[key].prompt_tokens += b.prompt_tokens
|
||||
map[key].completion_tokens += b.completion_tokens
|
||||
map[key].total_tokens += b.total_tokens
|
||||
map[key].request_count += b.request_count
|
||||
}
|
||||
return Object.values(map).sort((a, b) => b.total_tokens - a.total_tokens)
|
||||
}
|
||||
|
||||
function aggregateByUser(buckets) {
|
||||
const map = {}
|
||||
for (const b of buckets) {
|
||||
const key = b.user_id || '(unknown)'
|
||||
if (!map[key]) {
|
||||
map[key] = { user_id: key, user_name: b.user_name || key, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, request_count: 0 }
|
||||
}
|
||||
map[key].prompt_tokens += b.prompt_tokens
|
||||
map[key].completion_tokens += b.completion_tokens
|
||||
map[key].total_tokens += b.total_tokens
|
||||
map[key].request_count += b.request_count
|
||||
}
|
||||
return Object.values(map).sort((a, b) => b.total_tokens - a.total_tokens)
|
||||
}
|
||||
|
||||
function aggregateByBucket(buckets) {
|
||||
const map = {}
|
||||
for (const b of buckets) {
|
||||
if (!b.bucket) continue
|
||||
if (!map[b.bucket]) {
|
||||
map[b.bucket] = { bucket: b.bucket, prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, request_count: 0 }
|
||||
}
|
||||
map[b.bucket].prompt_tokens += b.prompt_tokens
|
||||
map[b.bucket].completion_tokens += b.completion_tokens
|
||||
map[b.bucket].total_tokens += b.total_tokens
|
||||
map[b.bucket].request_count += b.request_count
|
||||
}
|
||||
return Object.values(map).sort((a, b) => a.bucket.localeCompare(b.bucket))
|
||||
}
|
||||
|
||||
function formatBucket(bucket, period) {
|
||||
if (!bucket) return ''
|
||||
if (period === 'day') {
|
||||
return bucket.split(' ')[1] || bucket
|
||||
}
|
||||
if (period === 'week' || period === 'month') {
|
||||
const d = new Date(bucket + 'T00:00:00')
|
||||
if (!isNaN(d)) return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
return bucket
|
||||
}
|
||||
const [y, m] = bucket.split('-')
|
||||
if (y && m) {
|
||||
const d = new Date(Number(y), Number(m) - 1)
|
||||
if (!isNaN(d)) return d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })
|
||||
}
|
||||
return bucket
|
||||
}
|
||||
|
||||
function formatYLabel(n) {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K'
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function UsageTimeChart({ data, period }) {
|
||||
const containerRef = useRef(null)
|
||||
const [width, setWidth] = useState(600)
|
||||
const [tooltip, setTooltip] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
const observer = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
setWidth(entry.contentRect.width)
|
||||
}
|
||||
})
|
||||
observer.observe(containerRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
const height = 200
|
||||
const margin = { top: 16, right: 16, bottom: 40, left: 56 }
|
||||
const chartW = width - margin.left - margin.right
|
||||
const chartH = height - margin.top - margin.bottom
|
||||
|
||||
const maxVal = Math.max(...data.map(d => d.total_tokens), 1)
|
||||
const barWidth = Math.max(Math.min(chartW / data.length - 2, 40), 4)
|
||||
const barGap = (chartW - barWidth * data.length) / (data.length + 1)
|
||||
|
||||
// Y-axis ticks (4 ticks)
|
||||
const ticks = [0, 1, 2, 3, 4].map(i => Math.round(maxVal * i / 4))
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-sm)' }}>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text-primary)' }}>Tokens over time</span>
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-md)', fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
|
||||
<span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 2, background: 'var(--color-primary)', marginRight: 4, verticalAlign: 'middle' }} />Prompt</span>
|
||||
<span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 2, background: 'var(--color-primary)', opacity: 0.35, marginRight: 4, verticalAlign: 'middle' }} />Completion</span>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={containerRef} style={{ position: 'relative', width: '100%' }}>
|
||||
<svg width={width} height={height} style={{ display: 'block' }}>
|
||||
<g transform={`translate(${margin.left},${margin.top})`}>
|
||||
{/* Grid lines and Y labels */}
|
||||
{ticks.map((t, i) => {
|
||||
const y = chartH - (t / maxVal) * chartH
|
||||
return (
|
||||
<g key={i}>
|
||||
<line x1={0} y1={y} x2={chartW} y2={y} stroke="var(--color-border)" strokeOpacity={0.5} strokeDasharray={i === 0 ? 'none' : '3,3'} />
|
||||
<text x={-8} y={y + 4} textAnchor="end" fontSize="10" fill="var(--color-text-muted)" fontFamily="JetBrains Mono, monospace">
|
||||
{formatYLabel(t)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{/* Bars */}
|
||||
{data.map((d, i) => {
|
||||
const x = barGap + i * (barWidth + barGap)
|
||||
const promptH = (d.prompt_tokens / maxVal) * chartH
|
||||
const compH = (d.completion_tokens / maxVal) * chartH
|
||||
return (
|
||||
<g key={d.bucket}
|
||||
onMouseEnter={(e) => {
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
setTooltip({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
data: d,
|
||||
})
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
setTooltip(prev => prev ? {
|
||||
...prev,
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
} : null)
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
style={{ cursor: 'default' }}
|
||||
>
|
||||
{/* Invisible hit area */}
|
||||
<rect x={x} y={0} width={barWidth} height={chartH} fill="transparent" />
|
||||
{/* Prompt tokens (bottom) */}
|
||||
<rect x={x} y={chartH - promptH - compH} width={barWidth} height={promptH} fill="var(--color-primary)" rx={2} />
|
||||
{/* Completion tokens (top) */}
|
||||
<rect x={x} y={chartH - compH} width={barWidth} height={compH} fill="var(--color-primary)" opacity={0.35} rx={2} />
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{/* X-axis labels */}
|
||||
{data.map((d, i) => {
|
||||
const x = barGap + i * (barWidth + barGap) + barWidth / 2
|
||||
// Skip some labels if too many
|
||||
const skip = data.length > 20 ? Math.ceil(data.length / 12) : 1
|
||||
if (i % skip !== 0) return null
|
||||
return (
|
||||
<text key={d.bucket} x={x} y={chartH + 16} textAnchor="middle" fontSize="10" fill="var(--color-text-secondary)" fontFamily="JetBrains Mono, monospace">
|
||||
{formatBucket(d.bucket, period)}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
{tooltip && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
left: tooltip.x + 12,
|
||||
top: tooltip.y - 8,
|
||||
background: 'var(--color-bg-tertiary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 'var(--spacing-xs) var(--spacing-sm)',
|
||||
fontSize: '0.75rem',
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
color: 'var(--color-text-primary)',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 2 }}>{formatBucket(tooltip.data.bucket, period)}</div>
|
||||
<div><span style={{ color: 'var(--color-primary)' }}>Prompt:</span> {tooltip.data.prompt_tokens.toLocaleString()}</div>
|
||||
<div><span style={{ color: 'var(--color-text-secondary)' }}>Completion:</span> {tooltip.data.completion_tokens.toLocaleString()}</div>
|
||||
<div style={{ color: 'var(--color-text-muted)', borderTop: '1px solid var(--color-border)', marginTop: 2, paddingTop: 2 }}>
|
||||
{tooltip.data.request_count} requests
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelDistChart({ rows }) {
|
||||
if (!rows || rows.length === 0) return null
|
||||
|
||||
const maxVal = Math.max(...rows.map(r => r.total_tokens), 1)
|
||||
const barH = 24
|
||||
const gap = 4
|
||||
const height = rows.length * (barH + gap) + gap
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-sm)' }}>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text-primary)' }}>Token distribution by model</span>
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-md)', fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
|
||||
<span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 2, background: 'var(--color-primary)', marginRight: 4, verticalAlign: 'middle' }} />Prompt</span>
|
||||
<span><span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: 2, background: 'var(--color-primary)', opacity: 0.35, marginRight: 4, verticalAlign: 'middle' }} />Completion</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: gap }}>
|
||||
{rows.map(row => {
|
||||
const promptPct = (row.prompt_tokens / maxVal) * 100
|
||||
const compPct = (row.completion_tokens / maxVal) * 100
|
||||
return (
|
||||
<div key={row.model} style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
|
||||
<div style={{
|
||||
width: 120, minWidth: 120, fontSize: '0.75rem', fontFamily: 'JetBrains Mono, monospace',
|
||||
color: 'var(--color-text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}} title={row.model}>
|
||||
{row.model}
|
||||
</div>
|
||||
<div style={{ flex: 1, height: barH, background: 'var(--color-bg-primary)', borderRadius: 4, overflow: 'hidden', display: 'flex' }}>
|
||||
<div style={{ width: `${promptPct}%`, height: '100%', background: 'var(--color-primary)', transition: 'width 0.3s ease' }} />
|
||||
<div style={{ width: `${compPct}%`, height: '100%', background: 'var(--color-primary)', opacity: 0.35, transition: 'width 0.3s ease' }} />
|
||||
</div>
|
||||
<div style={{
|
||||
minWidth: 60, textAlign: 'right', fontSize: '0.75rem', fontFamily: 'JetBrains Mono, monospace',
|
||||
color: 'var(--color-text-muted)', fontWeight: 600,
|
||||
}}>
|
||||
{formatNumber(row.total_tokens)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Usage() {
|
||||
const { addToast } = useOutletContext()
|
||||
const { isAdmin, authEnabled } = useAuth()
|
||||
const [period, setPeriod] = useState('month')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [usage, setUsage] = useState([])
|
||||
const [totals, setTotals] = useState({})
|
||||
const [adminUsage, setAdminUsage] = useState([])
|
||||
const [adminTotals, setAdminTotals] = useState({})
|
||||
const [activeTab, setActiveTab] = useState('models')
|
||||
|
||||
const fetchUsage = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(apiUrl(`/api/auth/usage?period=${period}`))
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setUsage(data.usage || [])
|
||||
setTotals(data.totals || {})
|
||||
|
||||
if (isAdmin) {
|
||||
const adminRes = await fetch(apiUrl(`/api/auth/admin/usage?period=${period}`))
|
||||
if (adminRes.ok) {
|
||||
const adminData = await adminRes.json()
|
||||
setAdminUsage(adminData.usage || [])
|
||||
setAdminTotals(adminData.totals || {})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
addToast(`Failed to load usage: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period, isAdmin, addToast])
|
||||
|
||||
useEffect(() => {
|
||||
if (authEnabled) fetchUsage()
|
||||
else setLoading(false)
|
||||
}, [fetchUsage, authEnabled])
|
||||
|
||||
if (!authEnabled) {
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><i className="fas fa-chart-bar" /></div>
|
||||
<h2 className="empty-state-title">Usage tracking unavailable</h2>
|
||||
<p className="empty-state-text">Authentication must be enabled to track API usage.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const modelRows = aggregateByModel(isAdmin ? adminUsage : usage)
|
||||
const userRows = isAdmin ? aggregateByUser(adminUsage) : []
|
||||
const maxTokens = modelRows.reduce((max, r) => Math.max(max, r.total_tokens), 0)
|
||||
const maxUserTokens = userRows.reduce((max, r) => Math.max(max, r.total_tokens), 0)
|
||||
|
||||
const displayTotals = isAdmin ? adminTotals : totals
|
||||
const displayUsage = isAdmin ? adminUsage : usage
|
||||
const timeSeries = aggregateByBucket(displayUsage)
|
||||
|
||||
const monoCell = { fontFamily: 'JetBrains Mono, monospace', fontSize: '0.8125rem' }
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header" style={{ marginBottom: 'var(--spacing-sm)' }}>
|
||||
<h1 className="page-title">Usage</h1>
|
||||
<p className="page-subtitle">API token usage statistics</p>
|
||||
</div>
|
||||
|
||||
{/* Period selector + tabs */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', marginBottom: 'var(--spacing-md)', flexWrap: 'wrap' }}>
|
||||
{PERIODS.map(p => (
|
||||
<button
|
||||
key={p.key}
|
||||
className={`btn btn-sm ${period === p.key ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setPeriod(p.key)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div style={{ width: 1, height: 20, background: 'var(--color-border-subtle)', margin: '0 var(--spacing-xs)' }} />
|
||||
<button
|
||||
className={`btn btn-sm ${activeTab === 'models' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setActiveTab('models')}
|
||||
>
|
||||
<i className="fas fa-cube" style={{ fontSize: '0.7rem' }} /> Models
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm ${activeTab === 'users' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setActiveTab('users')}
|
||||
>
|
||||
<i className="fas fa-users" style={{ fontSize: '0.7rem' }} /> Users
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="btn btn-secondary btn-sm" onClick={fetchUsage} disabled={loading} style={{ gap: 4 }}>
|
||||
<i className={`fas fa-rotate${loading ? ' fa-spin' : ''}`} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Summary cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)' }}>
|
||||
<StatCard icon="fas fa-arrow-right-arrow-left" label="Requests" value={displayTotals.request_count} />
|
||||
<StatCard icon="fas fa-arrow-up" label="Prompt" value={displayTotals.prompt_tokens} />
|
||||
<StatCard icon="fas fa-arrow-down" label="Completion" value={displayTotals.completion_tokens} />
|
||||
<StatCard icon="fas fa-coins" label="Total" value={displayTotals.total_tokens} />
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<UsageTimeChart data={timeSeries} period={period} />
|
||||
{activeTab === 'models' && <ModelDistChart rows={modelRows} />}
|
||||
|
||||
{/* Table */}
|
||||
{activeTab === 'models' && (
|
||||
modelRows.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><i className="fas fa-chart-bar" /></div>
|
||||
<h2 className="empty-state-title">No usage data</h2>
|
||||
<p className="empty-state-text">Usage data will appear here as API requests are made.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model</th>
|
||||
<th style={{ width: 90 }}>Requests</th>
|
||||
<th style={{ width: 110 }}>Prompt</th>
|
||||
<th style={{ width: 110 }}>Completion</th>
|
||||
<th style={{ width: 110 }}>Total</th>
|
||||
<th style={{ width: 140 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{modelRows.map(row => (
|
||||
<tr key={row.model}>
|
||||
<td style={monoCell}>{row.model}</td>
|
||||
<td style={monoCell}>{formatNumber(row.request_count)}</td>
|
||||
<td style={monoCell}>{formatNumber(row.prompt_tokens)}</td>
|
||||
<td style={monoCell}>{formatNumber(row.completion_tokens)}</td>
|
||||
<td style={{ ...monoCell, fontWeight: 600 }}>{formatNumber(row.total_tokens)}</td>
|
||||
<td><UsageBar value={row.total_tokens} max={maxTokens} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && isAdmin && (
|
||||
userRows.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><i className="fas fa-users" /></div>
|
||||
<h2 className="empty-state-title">No user usage data</h2>
|
||||
<p className="empty-state-text">Per-user usage data will appear here as users make API requests.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th style={{ width: 90 }}>Requests</th>
|
||||
<th style={{ width: 110 }}>Prompt</th>
|
||||
<th style={{ width: 110 }}>Completion</th>
|
||||
<th style={{ width: 110 }}>Total</th>
|
||||
<th style={{ width: 140 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userRows.map(row => (
|
||||
<tr key={row.user_id}>
|
||||
<td style={{ fontSize: '0.8125rem' }}>{row.user_name}</td>
|
||||
<td style={monoCell}>{formatNumber(row.request_count)}</td>
|
||||
<td style={monoCell}>{formatNumber(row.prompt_tokens)}</td>
|
||||
<td style={monoCell}>{formatNumber(row.completion_tokens)}</td>
|
||||
<td style={{ ...monoCell, fontWeight: 600 }}>{formatNumber(row.total_tokens)}</td>
|
||||
<td><UsageBar value={row.total_tokens} max={maxUserTokens} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
737
core/http/react-ui/src/pages/Users.jsx
Normal file
737
core/http/react-ui/src/pages/Users.jsx
Normal file
@@ -0,0 +1,737 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { adminUsersApi, adminInvitesApi } from '../utils/api'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import Modal from '../components/Modal'
|
||||
import ConfirmDialog from '../components/ConfirmDialog'
|
||||
import './auth.css'
|
||||
|
||||
function RoleBadge({ role }) {
|
||||
const isPrimary = role === 'admin'
|
||||
return (
|
||||
<span className={`badge ${isPrimary ? 'badge-primary' : 'badge-secondary'}`}>
|
||||
{role}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
const variant = status === 'active'
|
||||
? 'success'
|
||||
: status === 'disabled'
|
||||
? 'danger'
|
||||
: 'warning'
|
||||
return (
|
||||
<span className={`status-badge status-badge-${variant}`}>
|
||||
{status || 'unknown'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderBadge({ provider }) {
|
||||
return (
|
||||
<span className="badge badge-secondary" style={{ fontSize: '0.7rem' }}>
|
||||
{provider || 'local'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionSummary({ user, onClick }) {
|
||||
if (user.role === 'admin') {
|
||||
return <span className="perm-summary-text">All (admin)</span>
|
||||
}
|
||||
|
||||
const perms = user.permissions || {}
|
||||
const apiFeatures = ['chat', 'images', 'audio_speech', 'audio_transcription', 'vad', 'detection', 'video', 'embeddings', 'sound']
|
||||
const agentFeatures = ['agents', 'skills', 'collections', 'mcp_jobs']
|
||||
|
||||
const apiOn = apiFeatures.filter(f => perms[f] !== false && (perms[f] === true || perms[f] === undefined)).length
|
||||
const agentOn = agentFeatures.filter(f => perms[f]).length
|
||||
|
||||
const modelRestricted = user.allowed_models?.enabled
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary perm-summary-btn"
|
||||
onClick={onClick}
|
||||
title="Edit permissions"
|
||||
>
|
||||
<i className="fas fa-shield-halved" />
|
||||
{apiOn}/{apiFeatures.length} API, {agentOn}/{agentFeatures.length} Agent
|
||||
{modelRestricted && ' | Models restricted'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PermissionsModal({ user, featureMeta, availableModels, onClose, onSave, addToast }) {
|
||||
const [permissions, setPermissions] = useState({ ...(user.permissions || {}) })
|
||||
const [allowedModels, setAllowedModels] = useState(user.allowed_models || { enabled: false, models: [] })
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const apiFeatures = featureMeta?.api_features || []
|
||||
const agentFeatures = featureMeta?.agent_features || []
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
const toggleFeature = (key) => {
|
||||
setPermissions(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
|
||||
const setAllFeatures = (features, value) => {
|
||||
setPermissions(prev => {
|
||||
const updated = { ...prev }
|
||||
features.forEach(f => { updated[f.key] = value })
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const toggleModel = (model) => {
|
||||
setAllowedModels(prev => {
|
||||
const models = prev.models || []
|
||||
const has = models.includes(model)
|
||||
return {
|
||||
...prev,
|
||||
models: has ? models.filter(m => m !== model) : [...models, model],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const setAllModels = (value) => {
|
||||
if (value) {
|
||||
setAllowedModels(prev => ({ ...prev, models: [...(availableModels || [])] }))
|
||||
} else {
|
||||
setAllowedModels(prev => ({ ...prev, models: [] }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await adminUsersApi.setPermissions(user.id, permissions)
|
||||
await adminUsersApi.setModels(user.id, allowedModels)
|
||||
onSave(user.id, permissions, allowedModels)
|
||||
addToast(`Permissions updated for ${user.name || user.email}`, 'success')
|
||||
onClose()
|
||||
} catch (err) {
|
||||
addToast(`Failed to update permissions: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal onClose={onClose} maxWidth="640px">
|
||||
<div className="perm-modal-body">
|
||||
{/* Header with avatar */}
|
||||
<div className="perm-modal-header">
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt="" className="perm-modal-avatar" />
|
||||
) : (
|
||||
<i className="fas fa-user-circle user-avatar-placeholder--lg" />
|
||||
)}
|
||||
<h3>Permissions for “{user.name || user.email}”</h3>
|
||||
</div>
|
||||
|
||||
{/* API Endpoints */}
|
||||
<div className="perm-section">
|
||||
<div className="perm-section-header">
|
||||
<strong className="perm-section-title">
|
||||
<i className="fas fa-plug" />
|
||||
API Endpoints
|
||||
</strong>
|
||||
<div className="action-group">
|
||||
<button className="btn btn-sm btn-secondary perm-btn-all-none" onClick={() => setAllFeatures(apiFeatures, true)}>All</button>
|
||||
<button className="btn btn-sm btn-secondary perm-btn-all-none" onClick={() => setAllFeatures(apiFeatures, false)}>None</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="perm-grid">
|
||||
{apiFeatures.map(f => (
|
||||
<button
|
||||
key={f.key}
|
||||
className={`btn btn-sm ${permissions[f.key] ? 'btn-primary' : 'btn-secondary'} perm-btn-feature`}
|
||||
onClick={() => toggleFeature(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Features */}
|
||||
<div className="perm-section">
|
||||
<div className="perm-section-header">
|
||||
<strong className="perm-section-title">
|
||||
<i className="fas fa-robot" />
|
||||
Agent Features
|
||||
</strong>
|
||||
<div className="action-group">
|
||||
<button className="btn btn-sm btn-secondary perm-btn-all-none" onClick={() => setAllFeatures(agentFeatures, true)}>All</button>
|
||||
<button className="btn btn-sm btn-secondary perm-btn-all-none" onClick={() => setAllFeatures(agentFeatures, false)}>None</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="perm-grid">
|
||||
{agentFeatures.map(f => (
|
||||
<button
|
||||
key={f.key}
|
||||
className={`btn btn-sm ${permissions[f.key] ? 'btn-primary' : 'btn-secondary'} perm-btn-feature`}
|
||||
onClick={() => toggleFeature(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Access */}
|
||||
<div className="perm-section">
|
||||
<div className="perm-section-header">
|
||||
<strong className="perm-section-title">
|
||||
<i className="fas fa-cubes" />
|
||||
Model Access
|
||||
</strong>
|
||||
</div>
|
||||
<div style={{ marginBottom: 'var(--spacing-sm)' }}>
|
||||
<label className="perm-toggle-label">
|
||||
<label className="toggle" style={{ flexShrink: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowedModels.enabled}
|
||||
onChange={() => setAllowedModels(prev => ({ ...prev, enabled: !prev.enabled }))}
|
||||
/>
|
||||
<span className="toggle-slider" />
|
||||
</label>
|
||||
Restrict to specific models
|
||||
</label>
|
||||
</div>
|
||||
{allowedModels.enabled ? (
|
||||
<>
|
||||
<div className="action-group" style={{ marginBottom: 'var(--spacing-sm)' }}>
|
||||
<button className="btn btn-sm btn-secondary perm-btn-all-none" onClick={() => setAllModels(true)}>All</button>
|
||||
<button className="btn btn-sm btn-secondary perm-btn-all-none" onClick={() => setAllModels(false)}>None</button>
|
||||
</div>
|
||||
<div className="model-list">
|
||||
{(availableModels || []).map(m => {
|
||||
const checked = (allowedModels.models || []).includes(m)
|
||||
return (
|
||||
<label
|
||||
key={m}
|
||||
className={`model-item${checked ? ' model-item-checked' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleModel(m)}
|
||||
/>
|
||||
<span className="model-item-check">
|
||||
{checked && <i className="fas fa-check" />}
|
||||
</span>
|
||||
<span className="model-item-name">{m}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
{(!availableModels || availableModels.length === 0) && (
|
||||
<span className="perm-empty">No models available</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="perm-hint">All models are accessible</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="perm-modal-actions">
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
function InviteStatusBadge({ invite }) {
|
||||
const now = new Date()
|
||||
const expired = new Date(invite.expiresAt) < now
|
||||
const used = !!invite.usedBy
|
||||
|
||||
if (used) {
|
||||
return <StatusBadge status="used" />
|
||||
}
|
||||
if (expired) {
|
||||
return <span className="status-badge status-badge-danger">expired</span>
|
||||
}
|
||||
return <span className="status-badge status-badge-success">available</span>
|
||||
}
|
||||
|
||||
function isInviteAvailable(invite) {
|
||||
return !invite.usedBy && new Date(invite.expiresAt) > new Date()
|
||||
}
|
||||
|
||||
function InvitesTab({ addToast }) {
|
||||
const [invites, setInvites] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||
const [newInviteCodes, setNewInviteCodes] = useState({})
|
||||
|
||||
const fetchInvites = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminInvitesApi.list()
|
||||
setInvites(Array.isArray(data) ? data : data.invites || [])
|
||||
} catch (err) {
|
||||
addToast(`Failed to load invites: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [addToast])
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvites()
|
||||
}, [fetchInvites])
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreating(true)
|
||||
try {
|
||||
const resp = await adminInvitesApi.create(168) // 7 days
|
||||
if (resp && resp.id && resp.code) {
|
||||
setNewInviteCodes(prev => ({ ...prev, [resp.id]: resp.code }))
|
||||
}
|
||||
addToast('Invite link created', 'success')
|
||||
fetchInvites()
|
||||
} catch (err) {
|
||||
addToast(`Failed to create invite: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevoke = async (invite) => {
|
||||
setConfirmDialog({
|
||||
title: 'Revoke Invite',
|
||||
message: 'Revoke this invite link?',
|
||||
confirmLabel: 'Revoke',
|
||||
danger: true,
|
||||
onConfirm: async () => {
|
||||
setConfirmDialog(null)
|
||||
try {
|
||||
await adminInvitesApi.delete(invite.id)
|
||||
setInvites(prev => prev.filter(x => x.id !== invite.id))
|
||||
addToast('Invite revoked', 'success')
|
||||
} catch (err) {
|
||||
addToast(`Failed to revoke invite: ${err.message}`, 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleCopyUrl = (code) => {
|
||||
const url = `${window.location.origin}/invite/${code}`
|
||||
try {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = url
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
addToast('Invite URL copied to clipboard', 'success')
|
||||
} catch {
|
||||
addToast('Failed to copy URL', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="auth-loading">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="auth-toolbar">
|
||||
<button className="btn btn-primary btn-sm" onClick={handleCreate} disabled={creating}>
|
||||
<i className="fas fa-plus" /> {creating ? 'Creating...' : 'Generate Invite Link'}
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={fetchInvites} disabled={loading}>
|
||||
<i className="fas fa-rotate" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{invites.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><i className="fas fa-envelope-open-text" /></div>
|
||||
<h2 className="empty-state-title">No invite links</h2>
|
||||
<p className="empty-state-text">Generate an invite link to let someone register.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invite Link</th>
|
||||
<th>Status</th>
|
||||
<th>Created By</th>
|
||||
<th>Used By</th>
|
||||
<th>Expires</th>
|
||||
<th className="cell-actions--sm">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invites.map(inv => (
|
||||
<tr key={inv.id}>
|
||||
<td className="invite-cell">
|
||||
{(() => {
|
||||
const code = inv.code || newInviteCodes[inv.id]
|
||||
if (isInviteAvailable(inv) && code) {
|
||||
return (
|
||||
<div className="invite-link-row">
|
||||
<span
|
||||
className="invite-link-text"
|
||||
title={`${window.location.origin}/invite/${code}`}
|
||||
>
|
||||
{`${window.location.origin}/invite/${code}`}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-sm btn-secondary invite-copy-btn"
|
||||
onClick={() => handleCopyUrl(code)}
|
||||
title="Copy invite URL"
|
||||
>
|
||||
<i className="fas fa-copy" /> Copy
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="mono-text">
|
||||
{inv.codePrefix || code?.substring(0, 8) || '???'}...
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
<td><InviteStatusBadge invite={inv} /></td>
|
||||
<td className="cell-sm">
|
||||
{inv.createdBy?.name || inv.createdBy?.id || '-'}
|
||||
</td>
|
||||
<td className="cell-sm">
|
||||
{inv.usedBy?.name || inv.usedBy?.id || '\u2014'}
|
||||
</td>
|
||||
<td className="cell-muted">
|
||||
{inv.expiresAt ? new Date(inv.expiresAt).toLocaleString() : '-'}
|
||||
</td>
|
||||
<td>
|
||||
{isInviteAvailable(inv) && (
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleRevoke(inv)}
|
||||
title="Revoke invite"
|
||||
>
|
||||
<i className="fas fa-trash" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDialog}
|
||||
title={confirmDialog?.title}
|
||||
message={confirmDialog?.message}
|
||||
confirmLabel={confirmDialog?.confirmLabel}
|
||||
danger={confirmDialog?.danger}
|
||||
onConfirm={confirmDialog?.onConfirm}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const { addToast } = useOutletContext()
|
||||
const { user: currentUser } = useAuth()
|
||||
const [users, setUsers] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('users')
|
||||
const [editingUser, setEditingUser] = useState(null)
|
||||
const [featureMeta, setFeatureMeta] = useState(null)
|
||||
const [availableModels, setAvailableModels] = useState([])
|
||||
const [confirmDialog, setConfirmDialog] = useState(null)
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminUsersApi.list()
|
||||
setUsers(Array.isArray(data) ? data : data.users || [])
|
||||
} catch (err) {
|
||||
addToast(`Failed to load users: ${err.message}`, 'error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [addToast])
|
||||
|
||||
const fetchFeatures = useCallback(async () => {
|
||||
try {
|
||||
const data = await adminUsersApi.getFeatures()
|
||||
setFeatureMeta(data)
|
||||
setAvailableModels(data.models || [])
|
||||
} catch {
|
||||
// Features endpoint may not be available, use defaults
|
||||
setFeatureMeta({
|
||||
api_features: [
|
||||
{ key: 'chat', label: 'Chat Completions', default: true },
|
||||
{ key: 'images', label: 'Image Generation', default: true },
|
||||
{ key: 'audio_speech', label: 'Audio Speech / TTS', default: true },
|
||||
{ key: 'audio_transcription', label: 'Audio Transcription', default: true },
|
||||
{ key: 'vad', label: 'Voice Activity Detection', default: true },
|
||||
{ key: 'detection', label: 'Detection', default: true },
|
||||
{ key: 'video', label: 'Video Generation', default: true },
|
||||
{ key: 'embeddings', label: 'Embeddings', default: true },
|
||||
{ key: 'sound', label: 'Sound Generation', default: true },
|
||||
],
|
||||
agent_features: [
|
||||
{ key: 'agents', label: 'Agents', default: false },
|
||||
{ key: 'skills', label: 'Skills', default: false },
|
||||
{ key: 'collections', label: 'Collections', default: false },
|
||||
{ key: 'mcp_jobs', label: 'MCP CI Jobs', default: false },
|
||||
],
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
fetchFeatures()
|
||||
}, [fetchUsers, fetchFeatures])
|
||||
|
||||
const handleToggleRole = async (u) => {
|
||||
const newRole = u.role === 'admin' ? 'user' : 'admin'
|
||||
try {
|
||||
await adminUsersApi.setRole(u.id, newRole)
|
||||
setUsers(prev => prev.map(x => x.id === u.id ? { ...x, role: newRole } : x))
|
||||
addToast(`${u.name || u.email} is now ${newRole}`, 'success')
|
||||
} catch (err) {
|
||||
addToast(`Failed to update role: ${err.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (u) => {
|
||||
const newStatus = u.status === 'active' ? 'disabled' : 'active'
|
||||
const action = newStatus === 'active' ? 'Approve' : 'Disable'
|
||||
try {
|
||||
await adminUsersApi.setStatus(u.id, newStatus)
|
||||
setUsers(prev => prev.map(x => x.id === u.id ? { ...x, status: newStatus } : x))
|
||||
addToast(`${action}d ${u.name || u.email}`, 'success')
|
||||
} catch (err) {
|
||||
addToast(`Failed to ${action.toLowerCase()} user: ${err.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (u) => {
|
||||
setConfirmDialog({
|
||||
title: 'Delete User',
|
||||
message: `Delete user "${u.name || u.email}"? This will also remove their sessions and API keys.`,
|
||||
confirmLabel: 'Delete',
|
||||
danger: true,
|
||||
onConfirm: async () => {
|
||||
setConfirmDialog(null)
|
||||
try {
|
||||
await adminUsersApi.delete(u.id)
|
||||
setUsers(prev => prev.filter(x => x.id !== u.id))
|
||||
addToast(`User deleted`, 'success')
|
||||
} catch (err) {
|
||||
addToast(`Failed to delete user: ${err.message}`, 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const filtered = users.filter(u => {
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return (u.name || '').toLowerCase().includes(q) || (u.email || '').toLowerCase().includes(q)
|
||||
})
|
||||
|
||||
const handlePermissionSave = (userId, newPerms, newModels) => {
|
||||
setUsers(prev => prev.map(u => u.id === userId ? { ...u, permissions: newPerms, allowed_models: newModels } : u))
|
||||
}
|
||||
|
||||
const isSelf = (u) => currentUser && (u.id === currentUser.id || u.email === currentUser.email)
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Users</h1>
|
||||
<p className="page-subtitle">Manage registered users, roles, and invites</p>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="auth-tab-bar">
|
||||
<button
|
||||
className={`btn btn-sm auth-tab--pill ${activeTab === 'users' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setActiveTab('users')}
|
||||
>
|
||||
<i className="fas fa-users" /> Users
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm auth-tab--pill ${activeTab === 'invites' ? 'btn-primary' : 'btn-secondary'}`}
|
||||
onClick={() => setActiveTab('invites')}
|
||||
>
|
||||
<i className="fas fa-envelope-open-text" /> Invites
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'invites' ? (
|
||||
<InvitesTab addToast={addToast} />
|
||||
) : (
|
||||
<>
|
||||
<div className="auth-toolbar">
|
||||
<div className="search-field">
|
||||
<i className="fas fa-search search-field-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Search by name or email..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={fetchUsers} disabled={loading}>
|
||||
<i className="fas fa-rotate" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="auth-loading">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-icon"><i className="fas fa-users" /></div>
|
||||
<h2 className="empty-state-title">{search ? 'No matching users' : 'No users'}</h2>
|
||||
<p className="empty-state-text">{search ? 'Try a different search term.' : 'No registered users found.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Email</th>
|
||||
<th>Provider</th>
|
||||
<th>Role</th>
|
||||
<th>Permissions</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th className="cell-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div className="user-identity">
|
||||
{u.avatarUrl ? (
|
||||
<img src={u.avatarUrl} alt="" className="user-avatar" />
|
||||
) : (
|
||||
<i className="fas fa-user-circle user-avatar-placeholder" />
|
||||
)}
|
||||
<span className="user-name">{u.name || '(no name)'}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="user-email">{u.email}</td>
|
||||
<td><ProviderBadge provider={u.provider} /></td>
|
||||
<td><RoleBadge role={u.role} /></td>
|
||||
<td>
|
||||
<PermissionSummary
|
||||
user={u}
|
||||
onClick={() => u.role !== 'admin' && setEditingUser(u)}
|
||||
/>
|
||||
</td>
|
||||
<td><StatusBadge status={u.status} /></td>
|
||||
<td className="cell-muted">
|
||||
{u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '-'}
|
||||
</td>
|
||||
<td>
|
||||
{!isSelf(u) && (
|
||||
<div className="action-group">
|
||||
{u.status !== 'active' ? (
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => handleToggleStatus(u)}
|
||||
title="Approve user"
|
||||
>
|
||||
<i className="fas fa-check" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => handleToggleStatus(u)}
|
||||
title="Disable user"
|
||||
>
|
||||
<i className="fas fa-ban" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`btn btn-sm ${u.role === 'admin' ? 'btn-secondary' : 'btn-primary'}`}
|
||||
onClick={() => handleToggleRole(u)}
|
||||
title={u.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
|
||||
>
|
||||
<i className={`fas fa-${u.role === 'admin' ? 'arrow-down' : 'arrow-up'}`} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDelete(u)}
|
||||
title="Delete user"
|
||||
>
|
||||
<i className="fas fa-trash" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{editingUser && featureMeta && (
|
||||
<PermissionsModal
|
||||
user={editingUser}
|
||||
featureMeta={featureMeta}
|
||||
availableModels={availableModels}
|
||||
onClose={() => setEditingUser(null)}
|
||||
onSave={handlePermissionSave}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={!!confirmDialog}
|
||||
title={confirmDialog?.title}
|
||||
message={confirmDialog?.message}
|
||||
confirmLabel={confirmDialog?.confirmLabel}
|
||||
danger={confirmDialog?.danger}
|
||||
onConfirm={confirmDialog?.onConfirm}
|
||||
onCancel={() => setConfirmDialog(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
559
core/http/react-ui/src/pages/auth.css
Normal file
559
core/http/react-ui/src/pages/auth.css
Normal file
@@ -0,0 +1,559 @@
|
||||
/* ─── Shared auth page styles (Login, Users, Account) ─── */
|
||||
|
||||
/* ─── Status / role badges ─── */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-badge-success {
|
||||
background: var(--color-success, #22c55e)22;
|
||||
color: var(--color-success, #22c55e);
|
||||
}
|
||||
|
||||
.status-badge-danger {
|
||||
background: var(--color-danger, #ef4444)22;
|
||||
color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.status-badge-warning {
|
||||
background: var(--color-warning, #eab308)22;
|
||||
color: var(--color-warning, #eab308);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.role-badge-admin {
|
||||
background: var(--color-accent-light);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.role-badge-user {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.provider-tag {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
/* ─── Tab bar ─── */
|
||||
.auth-tab-bar {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.auth-tab-bar--flush {
|
||||
gap: 0;
|
||||
border-bottom-color: var(--color-border-default);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.auth-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 400;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
transition: color 150ms, border-color 150ms, font-weight 150ms;
|
||||
}
|
||||
|
||||
.auth-tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.auth-tab.active {
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.auth-tab-icon {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.auth-tab--pill {
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
}
|
||||
|
||||
/* ─── Toolbar (search + buttons row) ─── */
|
||||
.auth-toolbar {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ─── Search field with icon ─── */
|
||||
.search-field {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.search-field-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-field .input {
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
/* ─── Centered loading ─── */
|
||||
.auth-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
/* ─── User row (avatar + name) ─── */
|
||||
.user-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-avatar--lg {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.user-avatar-placeholder {
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar-placeholder--lg {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 0.8125rem;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* ─── Table cells ─── */
|
||||
.cell-sm {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.cell-muted {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.cell-actions {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.cell-actions--sm {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
/* ─── Inline action group ─── */
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* ─── Monospace / code text ─── */
|
||||
.mono-text {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.mono-text--truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ─── Permission summary button ─── */
|
||||
.perm-summary-btn {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.perm-summary-btn i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.perm-summary-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ─── Permissions modal ─── */
|
||||
.perm-modal-body {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.perm-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding-bottom: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.perm-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.perm-modal-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.perm-section {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.perm-section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.perm-section-title {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.perm-section-title i {
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.perm-grid {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.perm-btn-all-none {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.perm-btn-feature {
|
||||
font-size: 0.8rem;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
|
||||
.perm-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.perm-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.perm-empty {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
padding: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.perm-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* ─── Invite link cell ─── */
|
||||
.invite-cell {
|
||||
font-size: 0.8rem;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.invite-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.invite-link-text {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.invite-copy-btn {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ─── Account page ─── */
|
||||
.account-page {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.account-user-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.account-avatar-frame {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary-light);
|
||||
border: 2px solid var(--color-primary-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.account-avatar-icon {
|
||||
font-size: 1.125rem;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.account-user-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-user-email {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.account-user-badges {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.account-input-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.account-input-sm {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.account-input-xs {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.account-avatar-preview {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--color-border-default);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: var(--spacing-md);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ─── Empty state icon block ─── */
|
||||
.empty-icon-block {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.empty-icon-block i {
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-icon-block-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ─── API key list ─── */
|
||||
.apikey-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.apikey-row:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.apikey-icon {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.apikey-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.apikey-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.apikey-details {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.apikey-revoke-btn {
|
||||
color: var(--color-error);
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.apikey-revoke-btn i {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
/* ─── New key banner ─── */
|
||||
.new-key-banner {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--color-warning-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-warning-light);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.new-key-banner-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.new-key-banner-body {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.new-key-value {
|
||||
flex: 1;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background: var(--color-bg-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* ─── Login page ─── */
|
||||
.login-btn-full {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ─── Modal (backdrop + panel) ─── */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-modal-backdrop);
|
||||
backdrop-filter: blur(4px);
|
||||
animation: fadeIn 150ms ease;
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
background: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
animation: slideUp 150ms ease;
|
||||
}
|
||||
@@ -31,15 +31,29 @@ import BackendLogs from './pages/BackendLogs'
|
||||
import Explorer from './pages/Explorer'
|
||||
import 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/*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
139
core/http/react-ui/src/utils/api.js
vendored
139
core/http/react-ui/src/utils/api.js
vendored
@@ -1,6 +1,9 @@
|
||||
import { API_CONFIG } from './config'
|
||||
import { 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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
1077
core/http/routes/auth.go
Normal file
File diff suppressed because it is too large
Load Diff
808
core/http/routes/auth_test.go
Normal file
808
core/http/routes/auth_test.go
Normal file
@@ -0,0 +1,808 @@
|
||||
//go:build auth
|
||||
|
||||
package routes_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newTestAuthApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo {
|
||||
e := echo.New()
|
||||
|
||||
// Apply auth middleware
|
||||
e.Use(auth.Middleware(db, appConfig))
|
||||
|
||||
// We can't use routes.RegisterAuthRoutes directly since it needs *application.Application.
|
||||
// Instead, we register the routes manually for testing.
|
||||
|
||||
// GET /api/auth/status
|
||||
e.GET("/api/auth/status", func(c echo.Context) error {
|
||||
authEnabled := db != nil
|
||||
providers := []string{}
|
||||
hasUsers := false
|
||||
|
||||
if authEnabled {
|
||||
var count int64
|
||||
db.Model(&auth.User{}).Count(&count)
|
||||
hasUsers = count > 0
|
||||
|
||||
providers = append(providers, auth.ProviderLocal)
|
||||
if appConfig.Auth.GitHubClientID != "" {
|
||||
providers = append(providers, auth.ProviderGitHub)
|
||||
}
|
||||
}
|
||||
|
||||
resp := map[string]interface{}{
|
||||
"authEnabled": authEnabled,
|
||||
"providers": providers,
|
||||
"hasUsers": hasUsers,
|
||||
}
|
||||
|
||||
user := auth.GetUser(c)
|
||||
if user != nil {
|
||||
resp["user"] = map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"role": user.Role,
|
||||
}
|
||||
} else {
|
||||
resp["user"] = nil
|
||||
}
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
})
|
||||
|
||||
// POST /api/auth/register
|
||||
e.POST("/api/auth/register", func(c echo.Context) error {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
body.Email = strings.TrimSpace(body.Email)
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
if body.Email == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "email is required"})
|
||||
}
|
||||
if len(body.Password) < 8 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "password must be at least 8 characters"})
|
||||
}
|
||||
var existing auth.User
|
||||
if err := db.Where("email = ? AND provider = ?", body.Email, auth.ProviderLocal).First(&existing).Error; err == nil {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "an account with this email already exists"})
|
||||
}
|
||||
hash, err := auth.HashPassword(body.Password)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to hash password"})
|
||||
}
|
||||
role := auth.AssignRole(db, body.Email, appConfig.Auth.AdminEmail)
|
||||
status := auth.StatusActive
|
||||
if appConfig.Auth.RegistrationMode == "approval" && role != auth.RoleAdmin {
|
||||
status = auth.StatusPending
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.Email
|
||||
}
|
||||
user := &auth.User{
|
||||
ID: uuid.New().String(), Email: body.Email, Name: name,
|
||||
Provider: auth.ProviderLocal, Subject: body.Email, PasswordHash: hash,
|
||||
Role: role, Status: status,
|
||||
}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create user"})
|
||||
}
|
||||
if status == auth.StatusPending {
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "registration successful, awaiting admin approval", "pending": true})
|
||||
}
|
||||
sessionID, err := auth.CreateSession(db, user.ID, "")
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
|
||||
}
|
||||
auth.SetSessionCookie(c, sessionID)
|
||||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||
"user": map[string]interface{}{"id": user.ID, "email": user.Email, "name": user.Name, "role": user.Role},
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/auth/login - inline test handler
|
||||
e.POST("/api/auth/login", func(c echo.Context) error {
|
||||
var body struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
body.Email = strings.TrimSpace(body.Email)
|
||||
if body.Email == "" || body.Password == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "email and password are required"})
|
||||
}
|
||||
var user auth.User
|
||||
if err := db.Where("email = ? AND provider = ?", body.Email, auth.ProviderLocal).First(&user).Error; err != nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
|
||||
}
|
||||
if !auth.CheckPassword(user.PasswordHash, body.Password) {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
|
||||
}
|
||||
if user.Status == auth.StatusPending {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "account pending admin approval"})
|
||||
}
|
||||
auth.MaybePromote(db, &user, appConfig.Auth.AdminEmail)
|
||||
sessionID, err := auth.CreateSession(db, user.ID, "")
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
|
||||
}
|
||||
auth.SetSessionCookie(c, sessionID)
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"user": map[string]interface{}{"id": user.ID, "email": user.Email, "name": user.Name, "role": user.Role},
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/auth/logout
|
||||
e.POST("/api/auth/logout", func(c echo.Context) error {
|
||||
user := auth.GetUser(c)
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
}
|
||||
if cookie, err := c.Cookie("session"); err == nil && cookie.Value != "" {
|
||||
auth.DeleteSession(db, cookie.Value, "")
|
||||
}
|
||||
auth.ClearSessionCookie(c)
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "logged out"})
|
||||
})
|
||||
|
||||
// GET /api/auth/me
|
||||
e.GET("/api/auth/me", func(c echo.Context) error {
|
||||
user := auth.GetUser(c)
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"role": user.Role,
|
||||
})
|
||||
})
|
||||
|
||||
// POST /api/auth/api-keys
|
||||
e.POST("/api/auth/api-keys", func(c echo.Context) error {
|
||||
user := auth.GetUser(c)
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil || body.Name == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||||
}
|
||||
plaintext, record, err := auth.CreateAPIKey(db, user.ID, body.Name, user.Role, "", nil)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create API key"})
|
||||
}
|
||||
return c.JSON(http.StatusCreated, map[string]interface{}{
|
||||
"key": plaintext,
|
||||
"id": record.ID,
|
||||
"name": record.Name,
|
||||
"keyPrefix": record.KeyPrefix,
|
||||
})
|
||||
})
|
||||
|
||||
// GET /api/auth/api-keys
|
||||
e.GET("/api/auth/api-keys", func(c echo.Context) error {
|
||||
user := auth.GetUser(c)
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
}
|
||||
keys, err := auth.ListAPIKeys(db, user.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list API keys"})
|
||||
}
|
||||
result := make([]map[string]interface{}, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": k.ID,
|
||||
"name": k.Name,
|
||||
"keyPrefix": k.KeyPrefix,
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"keys": result})
|
||||
})
|
||||
|
||||
// DELETE /api/auth/api-keys/:id
|
||||
e.DELETE("/api/auth/api-keys/:id", func(c echo.Context) error {
|
||||
user := auth.GetUser(c)
|
||||
if user == nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
|
||||
}
|
||||
keyID := c.Param("id")
|
||||
if err := auth.RevokeAPIKey(db, keyID, user.ID); err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "API key not found"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "API key revoked"})
|
||||
})
|
||||
|
||||
// Admin: GET /api/auth/admin/users
|
||||
adminMw := auth.RequireAdmin()
|
||||
e.GET("/api/auth/admin/users", func(c echo.Context) error {
|
||||
var users []auth.User
|
||||
db.Order("created_at ASC").Find(&users)
|
||||
result := make([]map[string]interface{}, 0, len(users))
|
||||
for _, u := range users {
|
||||
result = append(result, map[string]interface{}{"id": u.ID, "role": u.Role, "email": u.Email})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"users": result})
|
||||
}, adminMw)
|
||||
|
||||
// Admin: PUT /api/auth/admin/users/:id/role
|
||||
e.PUT("/api/auth/admin/users/:id/role", func(c echo.Context) error {
|
||||
currentUser := auth.GetUser(c)
|
||||
targetID := c.Param("id")
|
||||
if currentUser.ID == targetID {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot change your own role"})
|
||||
}
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.Bind(&body); err != nil || (body.Role != auth.RoleAdmin && body.Role != auth.RoleUser) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "role must be 'admin' or 'user'"})
|
||||
}
|
||||
result := db.Model(&auth.User{}).Where("id = ?", targetID).Update("role", body.Role)
|
||||
if result.RowsAffected == 0 {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "role updated"})
|
||||
}, adminMw)
|
||||
|
||||
// Admin: DELETE /api/auth/admin/users/:id
|
||||
e.DELETE("/api/auth/admin/users/:id", func(c echo.Context) error {
|
||||
currentUser := auth.GetUser(c)
|
||||
targetID := c.Param("id")
|
||||
if currentUser.ID == targetID {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete yourself"})
|
||||
}
|
||||
db.Where("user_id = ?", targetID).Delete(&auth.Session{})
|
||||
db.Where("user_id = ?", targetID).Delete(&auth.UserAPIKey{})
|
||||
result := db.Where("id = ?", targetID).Delete(&auth.User{})
|
||||
if result.RowsAffected == 0 {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "user deleted"})
|
||||
}, adminMw)
|
||||
|
||||
// Regular API endpoint for testing
|
||||
e.POST("/v1/chat/completions", func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
e.GET("/v1/models", func(c echo.Context) error {
|
||||
return c.String(http.StatusOK, "ok")
|
||||
})
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// Helper to create test user
|
||||
func createRouteTestUser(db *gorm.DB, email, role string) *auth.User {
|
||||
user := &auth.User{
|
||||
ID: "user-" + email,
|
||||
Email: email,
|
||||
Name: "Test " + role,
|
||||
Provider: auth.ProviderGitHub,
|
||||
Subject: "sub-" + email,
|
||||
Role: role,
|
||||
Status: auth.StatusActive,
|
||||
}
|
||||
Expect(db.Create(user).Error).ToNot(HaveOccurred())
|
||||
return user
|
||||
}
|
||||
|
||||
func doAuthRequest(e *echo.Echo, method, path string, body []byte, opts ...func(*http.Request)) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
req = httptest.NewRequest(method, path, bytes.NewReader(body))
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
for _, opt := range opts {
|
||||
opt(req)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
e.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func withSession(sessionID string) func(*http.Request) {
|
||||
return func(req *http.Request) {
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: sessionID})
|
||||
}
|
||||
}
|
||||
|
||||
func withBearer(token string) func(*http.Request) {
|
||||
return func(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = Describe("Auth Routes", Label("auth"), func() {
|
||||
var (
|
||||
db *gorm.DB
|
||||
appConfig *config.ApplicationConfig
|
||||
)
|
||||
|
||||
BeforeEach(func() {
|
||||
var err error
|
||||
db, err = auth.InitDB(":memory:")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
appConfig = config.NewApplicationConfig()
|
||||
appConfig.Auth.Enabled = true
|
||||
appConfig.Auth.GitHubClientID = "test-client-id"
|
||||
})
|
||||
|
||||
Context("GET /api/auth/status", func() {
|
||||
It("returns authEnabled=true and provider list when auth enabled", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
Expect(resp["authEnabled"]).To(BeTrue())
|
||||
providers := resp["providers"].([]interface{})
|
||||
Expect(providers).To(ContainElement(auth.ProviderGitHub))
|
||||
})
|
||||
|
||||
It("returns authEnabled=false when auth disabled", func() {
|
||||
app := newTestAuthApp(nil, config.NewApplicationConfig())
|
||||
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
Expect(resp["authEnabled"]).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns user info when authenticated", func() {
|
||||
user := createRouteTestUser(db, "status@test.com", auth.RoleAdmin)
|
||||
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := doAuthRequest(app, "GET", "/api/auth/status", nil, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
Expect(resp["user"]).ToNot(BeNil())
|
||||
})
|
||||
|
||||
It("returns user=null when not authenticated", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
Expect(resp["user"]).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns hasUsers=false on fresh DB", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
Expect(resp["hasUsers"]).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Context("POST /api/auth/logout", func() {
|
||||
It("deletes session and clears cookie", func() {
|
||||
user := createRouteTestUser(db, "logout@test.com", auth.RoleUser)
|
||||
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/logout", nil, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
// Session should be deleted
|
||||
validatedUser, _ := auth.ValidateSession(db, sessionID, "")
|
||||
Expect(validatedUser).To(BeNil())
|
||||
})
|
||||
|
||||
It("returns 401 when not authenticated", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/logout", nil)
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GET /api/auth/me", func() {
|
||||
It("returns current user profile", func() {
|
||||
user := createRouteTestUser(db, "me@test.com", auth.RoleAdmin)
|
||||
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := doAuthRequest(app, "GET", "/api/auth/me", nil, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
Expect(resp["email"]).To(Equal("me@test.com"))
|
||||
Expect(resp["role"]).To(Equal("admin"))
|
||||
})
|
||||
|
||||
It("returns 401 when not authenticated", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
rec := doAuthRequest(app, "GET", "/api/auth/me", nil)
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Context("POST /api/auth/api-keys", func() {
|
||||
It("creates API key and returns plaintext once", func() {
|
||||
user := createRouteTestUser(db, "apikey@test.com", auth.RoleUser)
|
||||
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"name": "my key"})
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/api-keys", body, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusCreated))
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
Expect(resp["key"]).To(HavePrefix("lai-"))
|
||||
Expect(resp["name"]).To(Equal("my key"))
|
||||
})
|
||||
|
||||
It("key is usable for authentication", func() {
|
||||
user := createRouteTestUser(db, "apikey2@test.com", auth.RoleUser)
|
||||
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"name": "usable key"})
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/api-keys", body, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusCreated))
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
apiKey := resp["key"].(string)
|
||||
|
||||
// Use the key for API access
|
||||
rec = doAuthRequest(app, "GET", "/v1/models", nil, withBearer(apiKey))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
})
|
||||
|
||||
It("returns 401 when not authenticated", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
body, _ := json.Marshal(map[string]string{"name": "test"})
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/api-keys", body)
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GET /api/auth/api-keys", func() {
|
||||
It("lists user's API keys without plaintext", func() {
|
||||
user := createRouteTestUser(db, "list@test.com", auth.RoleUser)
|
||||
auth.CreateAPIKey(db, user.ID, "key1", auth.RoleUser, "", nil)
|
||||
auth.CreateAPIKey(db, user.ID, "key2", auth.RoleUser, "", nil)
|
||||
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := doAuthRequest(app, "GET", "/api/auth/api-keys", nil, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
keys := resp["keys"].([]interface{})
|
||||
Expect(keys).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("does not show other users' keys", func() {
|
||||
user1 := createRouteTestUser(db, "user1@test.com", auth.RoleUser)
|
||||
user2 := createRouteTestUser(db, "user2@test.com", auth.RoleUser)
|
||||
auth.CreateAPIKey(db, user1.ID, "user1-key", auth.RoleUser, "", nil)
|
||||
auth.CreateAPIKey(db, user2.ID, "user2-key", auth.RoleUser, "", nil)
|
||||
sessionID, _ := auth.CreateSession(db, user1.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := doAuthRequest(app, "GET", "/api/auth/api-keys", nil, withSession(sessionID))
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
keys := resp["keys"].([]interface{})
|
||||
Expect(keys).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
|
||||
Context("DELETE /api/auth/api-keys/:id", func() {
|
||||
It("revokes user's own key", func() {
|
||||
user := createRouteTestUser(db, "revoke@test.com", auth.RoleUser)
|
||||
plaintext, record, err := auth.CreateAPIKey(db, user.ID, "to-revoke", auth.RoleUser, "", nil)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := doAuthRequest(app, "DELETE", "/api/auth/api-keys/"+record.ID, nil, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
// Key should no longer work
|
||||
rec = doAuthRequest(app, "GET", "/v1/models", nil, withBearer(plaintext))
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("returns 404 for another user's key", func() {
|
||||
user1 := createRouteTestUser(db, "owner@test.com", auth.RoleUser)
|
||||
user2 := createRouteTestUser(db, "attacker@test.com", auth.RoleUser)
|
||||
_, record, _ := auth.CreateAPIKey(db, user1.ID, "secret-key", auth.RoleUser, "", nil)
|
||||
sessionID, _ := auth.CreateSession(db, user2.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := doAuthRequest(app, "DELETE", "/api/auth/api-keys/"+record.ID, nil, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Admin: GET /api/auth/admin/users", func() {
|
||||
It("returns all users for admin", func() {
|
||||
admin := createRouteTestUser(db, "admin@test.com", auth.RoleAdmin)
|
||||
createRouteTestUser(db, "user@test.com", auth.RoleUser)
|
||||
sessionID, _ := auth.CreateSession(db, admin.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := doAuthRequest(app, "GET", "/api/auth/admin/users", nil, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
users := resp["users"].([]interface{})
|
||||
Expect(users).To(HaveLen(2))
|
||||
})
|
||||
|
||||
It("returns 403 for non-admin user", func() {
|
||||
user := createRouteTestUser(db, "nonadmin@test.com", auth.RoleUser)
|
||||
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := doAuthRequest(app, "GET", "/api/auth/admin/users", nil, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Admin: PUT /api/auth/admin/users/:id/role", func() {
|
||||
It("changes user role", func() {
|
||||
admin := createRouteTestUser(db, "admin2@test.com", auth.RoleAdmin)
|
||||
user := createRouteTestUser(db, "promote@test.com", auth.RoleUser)
|
||||
sessionID, _ := auth.CreateSession(db, admin.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "admin"})
|
||||
rec := doAuthRequest(app, "PUT", "/api/auth/admin/users/"+user.ID+"/role", body, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
// Verify in DB
|
||||
var updated auth.User
|
||||
db.First(&updated, "id = ?", user.ID)
|
||||
Expect(updated.Role).To(Equal(auth.RoleAdmin))
|
||||
})
|
||||
|
||||
It("prevents self-demotion", func() {
|
||||
admin := createRouteTestUser(db, "self-demote@test.com", auth.RoleAdmin)
|
||||
sessionID, _ := auth.CreateSession(db, admin.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "user"})
|
||||
rec := doAuthRequest(app, "PUT", "/api/auth/admin/users/"+admin.ID+"/role", body, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns 403 for non-admin", func() {
|
||||
user := createRouteTestUser(db, "sneaky@test.com", auth.RoleUser)
|
||||
other := createRouteTestUser(db, "victim@test.com", auth.RoleUser)
|
||||
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"role": "admin"})
|
||||
rec := doAuthRequest(app, "PUT", "/api/auth/admin/users/"+other.ID+"/role", body, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Admin: DELETE /api/auth/admin/users/:id", func() {
|
||||
It("deletes user and cascades to sessions + API keys", func() {
|
||||
admin := createRouteTestUser(db, "admin3@test.com", auth.RoleAdmin)
|
||||
target := createRouteTestUser(db, "delete-me@test.com", auth.RoleUser)
|
||||
auth.CreateSession(db, target.ID, "")
|
||||
auth.CreateAPIKey(db, target.ID, "target-key", auth.RoleUser, "", nil)
|
||||
sessionID, _ := auth.CreateSession(db, admin.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
// User should be gone
|
||||
var count int64
|
||||
db.Model(&auth.User{}).Where("id = ?", target.ID).Count(&count)
|
||||
Expect(count).To(Equal(int64(0)))
|
||||
|
||||
// Sessions and keys should be gone
|
||||
db.Model(&auth.Session{}).Where("user_id = ?", target.ID).Count(&count)
|
||||
Expect(count).To(Equal(int64(0)))
|
||||
db.Model(&auth.UserAPIKey{}).Where("user_id = ?", target.ID).Count(&count)
|
||||
Expect(count).To(Equal(int64(0)))
|
||||
})
|
||||
|
||||
It("prevents self-deletion", func() {
|
||||
admin := createRouteTestUser(db, "admin4@test.com", auth.RoleAdmin)
|
||||
sessionID, _ := auth.CreateSession(db, admin.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+admin.ID, nil, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns 403 for non-admin", func() {
|
||||
user := createRouteTestUser(db, "sneak@test.com", auth.RoleUser)
|
||||
target := createRouteTestUser(db, "target2@test.com", auth.RoleUser)
|
||||
sessionID, _ := auth.CreateSession(db, user.ID, "")
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
|
||||
rec := doAuthRequest(app, "DELETE", "/api/auth/admin/users/"+target.ID, nil, withSession(sessionID))
|
||||
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
})
|
||||
|
||||
Context("POST /api/auth/register", func() {
|
||||
It("registers first user as admin", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
body, _ := json.Marshal(map[string]string{"email": "first@test.com", "password": "password123", "name": "First User"})
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||
Expect(rec.Code).To(Equal(http.StatusCreated))
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
user := resp["user"].(map[string]interface{})
|
||||
Expect(user["role"]).To(Equal("admin"))
|
||||
Expect(user["email"]).To(Equal("first@test.com"))
|
||||
|
||||
// Session cookie should be set
|
||||
cookies := rec.Result().Cookies()
|
||||
found := false
|
||||
for _, c := range cookies {
|
||||
if c.Name == "session" && c.Value != "" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
Expect(found).To(BeTrue())
|
||||
})
|
||||
|
||||
It("registers second user as regular user", func() {
|
||||
createRouteTestUser(db, "existing@test.com", auth.RoleAdmin)
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
body, _ := json.Marshal(map[string]string{"email": "second@test.com", "password": "password123"})
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||
Expect(rec.Code).To(Equal(http.StatusCreated))
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
user := resp["user"].(map[string]interface{})
|
||||
Expect(user["role"]).To(Equal("user"))
|
||||
})
|
||||
|
||||
It("rejects duplicate email", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
body, _ := json.Marshal(map[string]string{"email": "dup@test.com", "password": "password123"})
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||
Expect(rec.Code).To(Equal(http.StatusCreated))
|
||||
|
||||
rec = doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||
Expect(rec.Code).To(Equal(http.StatusConflict))
|
||||
})
|
||||
|
||||
It("rejects short password", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
body, _ := json.Marshal(map[string]string{"email": "short@test.com", "password": "1234567"})
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("rejects empty email", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
body, _ := json.Marshal(map[string]string{"email": "", "password": "password123"})
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||
Expect(rec.Code).To(Equal(http.StatusBadRequest))
|
||||
})
|
||||
|
||||
It("returns pending when registration mode is approval", func() {
|
||||
createRouteTestUser(db, "admin-existing@test.com", auth.RoleAdmin)
|
||||
appConfig.Auth.RegistrationMode = "approval"
|
||||
defer func() { appConfig.Auth.RegistrationMode = "" }()
|
||||
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
body, _ := json.Marshal(map[string]string{"email": "pending@test.com", "password": "password123"})
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
Expect(resp["pending"]).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Context("POST /api/auth/login", func() {
|
||||
It("logs in with correct credentials", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
// Register first
|
||||
body, _ := json.Marshal(map[string]string{"email": "login@test.com", "password": "password123"})
|
||||
doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||
|
||||
// Login
|
||||
body, _ = json.Marshal(map[string]string{"email": "login@test.com", "password": "password123"})
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/login", body)
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
user := resp["user"].(map[string]interface{})
|
||||
Expect(user["email"]).To(Equal("login@test.com"))
|
||||
})
|
||||
|
||||
It("rejects wrong password", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
body, _ := json.Marshal(map[string]string{"email": "wrong@test.com", "password": "password123"})
|
||||
doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||
|
||||
body, _ = json.Marshal(map[string]string{"email": "wrong@test.com", "password": "wrongpassword"})
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/login", body)
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("rejects non-existent user", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
body, _ := json.Marshal(map[string]string{"email": "nobody@test.com", "password": "password123"})
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/login", body)
|
||||
Expect(rec.Code).To(Equal(http.StatusUnauthorized))
|
||||
})
|
||||
|
||||
It("rejects pending user", func() {
|
||||
createRouteTestUser(db, "admin-for-pending@test.com", auth.RoleAdmin)
|
||||
appConfig.Auth.RegistrationMode = "approval"
|
||||
defer func() { appConfig.Auth.RegistrationMode = "" }()
|
||||
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
body, _ := json.Marshal(map[string]string{"email": "pending-login@test.com", "password": "password123"})
|
||||
doAuthRequest(app, "POST", "/api/auth/register", body)
|
||||
|
||||
appConfig.Auth.RegistrationMode = ""
|
||||
body, _ = json.Marshal(map[string]string{"email": "pending-login@test.com", "password": "password123"})
|
||||
rec := doAuthRequest(app, "POST", "/api/auth/login", body)
|
||||
Expect(rec.Code).To(Equal(http.StatusForbidden))
|
||||
})
|
||||
})
|
||||
|
||||
Context("GET /api/auth/status providers", func() {
|
||||
It("includes local provider when auth is enabled", func() {
|
||||
app := newTestAuthApp(db, appConfig)
|
||||
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
|
||||
Expect(rec.Code).To(Equal(http.StatusOK))
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||
providers := resp["providers"].([]interface{})
|
||||
Expect(providers).To(ContainElement(auth.ProviderLocal))
|
||||
Expect(providers).To(ContainElement(auth.ProviderGitHub))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -22,7 +22,10 @@ func RegisterLocalAIRoutes(router *echo.Echo,
|
||||
galleryService *services.GalleryService,
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
183
core/services/user_services.go
Normal file
183
core/services/user_services.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/templates"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
"github.com/mudler/LocalAGI/services/skills"
|
||||
"github.com/mudler/LocalAGI/webui/collections"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// UserServicesManager lazily creates per-user service instances for
|
||||
// collections, skills, and jobs.
|
||||
type UserServicesManager struct {
|
||||
mu sync.RWMutex
|
||||
storage *UserScopedStorage
|
||||
appConfig *config.ApplicationConfig
|
||||
modelLoader *model.ModelLoader
|
||||
configLoader *config.ModelConfigLoader
|
||||
evaluator *templates.Evaluator
|
||||
collectionsCache map[string]collections.Backend
|
||||
skillsCache map[string]*skills.Service
|
||||
jobsCache map[string]*AgentJobService
|
||||
}
|
||||
|
||||
// NewUserServicesManager creates a new UserServicesManager.
|
||||
func NewUserServicesManager(
|
||||
storage *UserScopedStorage,
|
||||
appConfig *config.ApplicationConfig,
|
||||
modelLoader *model.ModelLoader,
|
||||
configLoader *config.ModelConfigLoader,
|
||||
evaluator *templates.Evaluator,
|
||||
) *UserServicesManager {
|
||||
return &UserServicesManager{
|
||||
storage: storage,
|
||||
appConfig: appConfig,
|
||||
modelLoader: modelLoader,
|
||||
configLoader: configLoader,
|
||||
evaluator: evaluator,
|
||||
collectionsCache: make(map[string]collections.Backend),
|
||||
skillsCache: make(map[string]*skills.Service),
|
||||
jobsCache: make(map[string]*AgentJobService),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCollections returns the collections backend for a user, creating it lazily.
|
||||
func (m *UserServicesManager) GetCollections(userID string) (collections.Backend, error) {
|
||||
m.mu.RLock()
|
||||
if backend, ok := m.collectionsCache[userID]; ok {
|
||||
m.mu.RUnlock()
|
||||
return backend, nil
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if backend, ok := m.collectionsCache[userID]; ok {
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
if err := m.storage.EnsureUserDirs(userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := m.appConfig.AgentPool
|
||||
apiURL := cfg.APIURL
|
||||
if apiURL == "" {
|
||||
apiURL = "http://127.0.0.1:" + getPort(m.appConfig)
|
||||
}
|
||||
apiKey := cfg.APIKey
|
||||
if apiKey == "" && len(m.appConfig.ApiKeys) > 0 {
|
||||
apiKey = m.appConfig.ApiKeys[0]
|
||||
}
|
||||
|
||||
collectionsCfg := &collections.Config{
|
||||
LLMAPIURL: apiURL,
|
||||
LLMAPIKey: apiKey,
|
||||
LLMModel: cfg.DefaultModel,
|
||||
CollectionDBPath: m.storage.CollectionsDir(userID),
|
||||
FileAssets: m.storage.AssetsDir(userID),
|
||||
VectorEngine: cfg.VectorEngine,
|
||||
EmbeddingModel: cfg.EmbeddingModel,
|
||||
MaxChunkingSize: cfg.MaxChunkingSize,
|
||||
ChunkOverlap: cfg.ChunkOverlap,
|
||||
DatabaseURL: cfg.DatabaseURL,
|
||||
}
|
||||
|
||||
backend, _ := collections.NewInProcessBackend(collectionsCfg)
|
||||
m.collectionsCache[userID] = backend
|
||||
return backend, nil
|
||||
}
|
||||
|
||||
// GetSkills returns the skills service for a user, creating it lazily.
|
||||
func (m *UserServicesManager) GetSkills(userID string) (*skills.Service, error) {
|
||||
m.mu.RLock()
|
||||
if svc, ok := m.skillsCache[userID]; ok {
|
||||
m.mu.RUnlock()
|
||||
return svc, nil
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if svc, ok := m.skillsCache[userID]; ok {
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
if err := m.storage.EnsureUserDirs(userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
skillsDir := m.storage.SkillsDir(userID)
|
||||
svc, err := skills.NewService(skillsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.skillsCache[userID] = svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// GetJobs returns the agent job service for a user, creating it lazily.
|
||||
func (m *UserServicesManager) GetJobs(userID string) (*AgentJobService, error) {
|
||||
m.mu.RLock()
|
||||
if svc, ok := m.jobsCache[userID]; ok {
|
||||
m.mu.RUnlock()
|
||||
return svc, nil
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if svc, ok := m.jobsCache[userID]; ok {
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
if err := m.storage.EnsureUserDirs(userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
svc := NewAgentJobServiceWithPaths(
|
||||
m.appConfig,
|
||||
m.modelLoader,
|
||||
m.configLoader,
|
||||
m.evaluator,
|
||||
m.storage.TasksFile(userID),
|
||||
m.storage.JobsFile(userID),
|
||||
)
|
||||
m.jobsCache[userID] = svc
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// ListAllUserIDs returns all user IDs that have scoped data directories.
|
||||
func (m *UserServicesManager) ListAllUserIDs() ([]string, error) {
|
||||
return m.storage.ListUserDirs()
|
||||
}
|
||||
|
||||
// getPort extracts the port from the API address config.
|
||||
func getPort(appConfig *config.ApplicationConfig) string {
|
||||
addr := appConfig.APIAddress
|
||||
for i := len(addr) - 1; i >= 0; i-- {
|
||||
if addr[i] == ':' {
|
||||
return addr[i+1:]
|
||||
}
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// StopAll stops all cached job services.
|
||||
func (m *UserServicesManager) StopAll() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, svc := range m.jobsCache {
|
||||
if err := svc.Stop(); err != nil {
|
||||
xlog.Error("Failed to stop user job service", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
142
core/services/user_storage.go
Normal file
142
core/services/user_storage.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UserScopedStorage resolves per-user storage directories.
|
||||
// When userID is empty, paths resolve to root-level (backward compat).
|
||||
// When userID is set, paths resolve to {baseDir}/users/{userID}/...
|
||||
type UserScopedStorage struct {
|
||||
baseDir string // State directory
|
||||
dataDir string // Data directory (for jobs files)
|
||||
}
|
||||
|
||||
// NewUserScopedStorage creates a new UserScopedStorage.
|
||||
func NewUserScopedStorage(baseDir, dataDir string) *UserScopedStorage {
|
||||
return &UserScopedStorage{
|
||||
baseDir: baseDir,
|
||||
dataDir: dataDir,
|
||||
}
|
||||
}
|
||||
|
||||
// resolve returns baseDir for empty userID, or baseDir/users/{userID} otherwise.
|
||||
func (s *UserScopedStorage) resolve(userID string) string {
|
||||
if userID == "" {
|
||||
return s.baseDir
|
||||
}
|
||||
return filepath.Join(s.baseDir, "users", userID)
|
||||
}
|
||||
|
||||
// resolveData returns dataDir for empty userID, or baseDir/users/{userID} otherwise.
|
||||
func (s *UserScopedStorage) resolveData(userID string) string {
|
||||
if userID == "" {
|
||||
return s.dataDir
|
||||
}
|
||||
return filepath.Join(s.baseDir, "users", userID)
|
||||
}
|
||||
|
||||
// UserDir returns the root directory for a user's scoped data.
|
||||
func (s *UserScopedStorage) UserDir(userID string) string {
|
||||
return s.resolve(userID)
|
||||
}
|
||||
|
||||
// CollectionsDir returns the collections directory for a user.
|
||||
func (s *UserScopedStorage) CollectionsDir(userID string) string {
|
||||
return filepath.Join(s.resolve(userID), "collections")
|
||||
}
|
||||
|
||||
// AssetsDir returns the assets directory for a user.
|
||||
func (s *UserScopedStorage) AssetsDir(userID string) string {
|
||||
return filepath.Join(s.resolve(userID), "assets")
|
||||
}
|
||||
|
||||
// OutputsDir returns the outputs directory for a user.
|
||||
func (s *UserScopedStorage) OutputsDir(userID string) string {
|
||||
return filepath.Join(s.resolve(userID), "outputs")
|
||||
}
|
||||
|
||||
// SkillsDir returns the skills directory for a user.
|
||||
func (s *UserScopedStorage) SkillsDir(userID string) string {
|
||||
return filepath.Join(s.resolve(userID), "skills")
|
||||
}
|
||||
|
||||
// TasksFile returns the path to the agent_tasks.json for a user.
|
||||
func (s *UserScopedStorage) TasksFile(userID string) string {
|
||||
return filepath.Join(s.resolveData(userID), "agent_tasks.json")
|
||||
}
|
||||
|
||||
// JobsFile returns the path to the agent_jobs.json for a user.
|
||||
func (s *UserScopedStorage) JobsFile(userID string) string {
|
||||
return filepath.Join(s.resolveData(userID), "agent_jobs.json")
|
||||
}
|
||||
|
||||
// EnsureUserDirs creates all subdirectories for a user.
|
||||
func (s *UserScopedStorage) EnsureUserDirs(userID string) error {
|
||||
dirs := []string{
|
||||
s.CollectionsDir(userID),
|
||||
s.AssetsDir(userID),
|
||||
s.OutputsDir(userID),
|
||||
s.SkillsDir(userID),
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d, 0750); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var uuidRegex = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
|
||||
// ListUserDirs scans {baseDir}/users/ and returns sorted UUIDs matching uuidRegex.
|
||||
// Returns an empty slice if the directory doesn't exist.
|
||||
func (s *UserScopedStorage) ListUserDirs() ([]string, error) {
|
||||
usersDir := filepath.Join(s.baseDir, "users")
|
||||
entries, err := os.ReadDir(usersDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read users directory: %w", err)
|
||||
}
|
||||
var ids []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && uuidRegex.MatchString(e.Name()) {
|
||||
ids = append(ids, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ValidateUserID validates that a userID is safe for use in filesystem paths.
|
||||
// Empty string is allowed (maps to root storage). Otherwise must be a valid UUID.
|
||||
func ValidateUserID(id string) error {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.ContainsAny(id, "/\\") || strings.Contains(id, "..") {
|
||||
return fmt.Errorf("invalid user ID: contains path traversal characters")
|
||||
}
|
||||
if !uuidRegex.MatchString(id) {
|
||||
return fmt.Errorf("invalid user ID: must be a valid UUID")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAgentName validates that an agent name is safe (no namespace escape or path traversal).
|
||||
func ValidateAgentName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("agent name is required")
|
||||
}
|
||||
if strings.ContainsAny(name, ":/\\\x00") || strings.Contains(name, "..") {
|
||||
return fmt.Errorf("agent name contains invalid characters (: / \\ .. or null bytes are not allowed)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -32,6 +32,8 @@ services:
|
||||
- models:/models
|
||||
- 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:
|
||||
|
||||
355
docs/content/features/authentication.md
Normal file
355
docs/content/features/authentication.md
Normal file
@@ -0,0 +1,355 @@
|
||||
+++
|
||||
disableToc = false
|
||||
title = "🔐 Authentication & Authorization"
|
||||
weight = 26
|
||||
url = '/features/authentication'
|
||||
+++
|
||||
|
||||
LocalAI supports two authentication modes: **legacy API key authentication** (simple shared keys) and a full **user authentication system** with roles, sessions, OAuth, and per-user usage tracking.
|
||||
|
||||
## Legacy API Key Authentication
|
||||
|
||||
The simplest way to protect your LocalAI instance is with API keys. Set one or more keys via environment variable or CLI flag:
|
||||
|
||||
```bash
|
||||
# Single key
|
||||
LOCALAI_API_KEY=sk-my-secret-key localai run
|
||||
|
||||
# Multiple keys (comma-separated)
|
||||
LOCALAI_API_KEY=key1,key2,key3 localai run
|
||||
```
|
||||
|
||||
Clients provide the key via any of these methods:
|
||||
|
||||
- `Authorization: Bearer <key>` header
|
||||
- `x-api-key: <key>` header
|
||||
- `xi-api-key: <key>` header
|
||||
- `token` cookie
|
||||
|
||||
Legacy API keys grant **full admin access** — there is no role separation. For multi-user deployments with role-based access, use the user authentication system instead.
|
||||
|
||||
API keys can also be managed at runtime through the [Runtime Settings]({{%relref "features/runtime-settings" %}}) interface.
|
||||
|
||||
## User Authentication System
|
||||
|
||||
The user authentication system provides:
|
||||
|
||||
- **User accounts** with email, name, and avatar
|
||||
- **Role-based access control** (admin vs. user)
|
||||
- **Session-based authentication** with secure cookies
|
||||
- **OAuth login** (GitHub) and **OIDC single sign-on** (Keycloak, Google, Okta, Authentik, etc.)
|
||||
- **Per-user API keys** for programmatic access
|
||||
- **Admin route gating** — management endpoints are restricted to admins
|
||||
- **Per-user usage tracking** with token consumption metrics
|
||||
|
||||
### Enabling Authentication
|
||||
|
||||
Set `LOCALAI_AUTH=true` or provide a GitHub OAuth Client ID or OIDC Client ID (which auto-enables auth):
|
||||
|
||||
```bash
|
||||
# Enable with SQLite (default, stored at {DataPath}/database.db)
|
||||
LOCALAI_AUTH=true localai run
|
||||
|
||||
# Enable with GitHub OAuth
|
||||
GITHUB_CLIENT_ID=your-client-id \
|
||||
GITHUB_CLIENT_SECRET=your-client-secret \
|
||||
LOCALAI_BASE_URL=http://localhost:8080 \
|
||||
localai run
|
||||
|
||||
# Enable with OIDC provider (e.g. Keycloak)
|
||||
LOCALAI_OIDC_ISSUER=https://keycloak.example.com/realms/myrealm \
|
||||
LOCALAI_OIDC_CLIENT_ID=your-client-id \
|
||||
LOCALAI_OIDC_CLIENT_SECRET=your-client-secret \
|
||||
LOCALAI_BASE_URL=http://localhost:8080 \
|
||||
localai run
|
||||
|
||||
# Enable with PostgreSQL
|
||||
LOCALAI_AUTH=true \
|
||||
LOCALAI_AUTH_DATABASE_URL=postgres://user:pass@host/dbname \
|
||||
localai run
|
||||
```
|
||||
|
||||
### Configuration Reference
|
||||
|
||||
| Environment Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `LOCALAI_AUTH` | `false` | Enable user authentication and authorization |
|
||||
| `LOCALAI_AUTH_DATABASE_URL` | `{DataPath}/database.db` | Database URL — `postgres://...` for PostgreSQL, or a file path for SQLite |
|
||||
| `GITHUB_CLIENT_ID` | | GitHub OAuth App Client ID (auto-enables auth when set) |
|
||||
| `GITHUB_CLIENT_SECRET` | | GitHub OAuth App Client Secret |
|
||||
| `LOCALAI_OIDC_ISSUER` | | OIDC issuer URL for auto-discovery (e.g. `https://accounts.google.com`) |
|
||||
| `LOCALAI_OIDC_CLIENT_ID` | | OIDC Client ID (auto-enables auth when set) |
|
||||
| `LOCALAI_OIDC_CLIENT_SECRET` | | OIDC Client Secret |
|
||||
| `LOCALAI_BASE_URL` | | Base URL for OAuth callbacks (e.g. `http://localhost:8080`) |
|
||||
| `LOCALAI_ADMIN_EMAIL` | | Email address to auto-promote to admin role on login |
|
||||
| `LOCALAI_REGISTRATION_MODE` | `approval` | Registration mode: `open`, `approval`, or `invite` |
|
||||
| `LOCALAI_DISABLE_LOCAL_AUTH` | `false` | Disable local email/password registration and login (for OAuth/OIDC-only deployments) |
|
||||
|
||||
### Disabling Local Authentication
|
||||
|
||||
If you want to enforce OAuth/OIDC-only login and prevent users from registering or logging in with email/password, set `LOCALAI_DISABLE_LOCAL_AUTH=true` (or pass `--disable-local-auth`):
|
||||
|
||||
```bash
|
||||
# OAuth-only setup (no email/password)
|
||||
LOCALAI_DISABLE_LOCAL_AUTH=true \
|
||||
GITHUB_CLIENT_ID=your-client-id \
|
||||
GITHUB_CLIENT_SECRET=your-client-secret \
|
||||
LOCALAI_BASE_URL=http://localhost:8080 \
|
||||
localai run
|
||||
```
|
||||
|
||||
When disabled:
|
||||
- The login page will not show email/password forms (the UI checks the `providers` list from `/api/auth/status`)
|
||||
- `POST /api/auth/register` returns `403 Forbidden`
|
||||
- `POST /api/auth/login` returns `403 Forbidden`
|
||||
- OAuth/OIDC login continues to work normally
|
||||
|
||||
### Roles
|
||||
|
||||
There are two roles:
|
||||
|
||||
- **Admin**: Full access to all endpoints, including model management, backend configuration, system settings, traces, agents, and user management.
|
||||
- **User**: Access to inference endpoints only — chat completions, embeddings, image/video/audio generation, TTS, MCP chat, and their own usage statistics.
|
||||
|
||||
The **first user** to sign in is automatically assigned the admin role. Additional users can be promoted to admin via the admin user management API or by setting `LOCALAI_ADMIN_EMAIL` to their email address.
|
||||
|
||||
### Registration Modes
|
||||
|
||||
| Mode | Description |
|
||||
|---|---|
|
||||
| `open` | Anyone can register and is immediately active |
|
||||
| `approval` | New users land in "pending" status until an admin approves them. If a valid invite code is provided during registration, the user is activated immediately (skipping the approval wait). **(default)** |
|
||||
| `invite` | Registration requires a valid invite link generated by an admin. Without one, registration is rejected. |
|
||||
|
||||
### Invite Links
|
||||
|
||||
Admins can generate single-use, time-limited invite links from the **Users → Invites** tab in the web UI, or via the API:
|
||||
|
||||
```bash
|
||||
# Create an invite link (default: expires in 7 days)
|
||||
curl -X POST http://localhost:8080/api/auth/admin/invites \
|
||||
-H "Authorization: Bearer <admin-key>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"expiresInHours": 168}'
|
||||
|
||||
# List all invites
|
||||
curl http://localhost:8080/api/auth/admin/invites \
|
||||
-H "Authorization: Bearer <admin-key>"
|
||||
|
||||
# Revoke an unused invite
|
||||
curl -X DELETE http://localhost:8080/api/auth/admin/invites/<invite-id> \
|
||||
-H "Authorization: Bearer <admin-key>"
|
||||
|
||||
# Check if an invite code is valid (public, no auth required)
|
||||
curl http://localhost:8080/api/auth/invite/<code>/check
|
||||
```
|
||||
|
||||
Share the invite URL (`/invite/<code>`) with the user. When they open it, the registration form is pre-filled with the invite code. Invite codes are single-use — once consumed, they cannot be reused. Expired or used invites are rejected.
|
||||
|
||||
For GitHub OAuth, the invite code is passed as a query parameter to the login URL (`/api/auth/github/login?invite_code=<code>`) and stored in a cookie during the OAuth flow.
|
||||
|
||||
### Admin-Only Endpoints
|
||||
|
||||
When authentication is enabled, the following endpoints require admin role:
|
||||
|
||||
**Model & Backend Management:**
|
||||
- `GET /api/models`, `POST /api/models/install/*`, `POST /api/models/delete/*`
|
||||
- `GET /api/backends`, `POST /api/backends/install/*`, `POST /api/backends/delete/*`
|
||||
- `GET /api/operations`, `POST /api/operations/*/cancel`
|
||||
- `GET /models/available`, `GET /models/galleries`, `GET /models/jobs/*`
|
||||
- `GET /backends`, `GET /backends/available`, `GET /backends/galleries`
|
||||
|
||||
**System & Monitoring:**
|
||||
- `GET /api/traces`, `POST /api/traces/clear`
|
||||
- `GET /api/backend-traces`, `POST /api/backend-traces/clear`
|
||||
- `GET /api/backend-logs/*`, `POST /api/backend-logs/*/clear`
|
||||
- `GET /api/resources`, `GET /api/settings`, `POST /api/settings`
|
||||
- `GET /system`, `GET /backend/monitor`, `POST /backend/shutdown`
|
||||
|
||||
**P2P:**
|
||||
- `GET /api/p2p/*`
|
||||
|
||||
**Agents & Jobs:**
|
||||
- All `/api/agents/*` endpoints
|
||||
- All `/api/agent/tasks/*` and `/api/agent/jobs/*` endpoints
|
||||
|
||||
**User-Accessible Endpoints (all authenticated users):**
|
||||
- `POST /v1/chat/completions`, `POST /v1/embeddings`, `POST /v1/completions`
|
||||
- `POST /v1/images/generations`, `POST /v1/audio/*`, `POST /tts`, `POST /vad`, `POST /video`
|
||||
- `GET /v1/models`, `POST /v1/tokenize`, `POST /v1/detection`
|
||||
- `POST /v1/mcp/chat/completions`, `POST /v1/messages`, `POST /v1/responses`
|
||||
- `POST /stores/*`, `GET /api/cors-proxy`
|
||||
- `GET /version`, `GET /api/features`, `GET /swagger/*`, `GET /metrics`
|
||||
- `GET /api/auth/usage` (own usage data)
|
||||
|
||||
### Web UI Access Control
|
||||
|
||||
When auth is enabled, the React UI sidebar dynamically shows/hides sections based on the user's role:
|
||||
|
||||
- **All users see**: Home, Chat, Images, Video, TTS, Sound, Talk, Usage, API docs link
|
||||
- **Admins also see**: Install Models, Agents section (Agents, Skills, Memory, MCP CI Jobs), System section (Backends, Traces, Swarm, System, Settings)
|
||||
|
||||
Admin-only pages are also protected at the router level — navigating directly to an admin URL redirects non-admin users to the home page.
|
||||
|
||||
### GitHub OAuth Setup
|
||||
|
||||
1. Create a GitHub OAuth App at **Settings → Developer settings → OAuth Apps → New OAuth App**
|
||||
2. Set the **Authorization callback URL** to `{LOCALAI_BASE_URL}/api/auth/github/callback`
|
||||
3. Set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` environment variables
|
||||
4. Set `LOCALAI_BASE_URL` to your publicly-accessible URL
|
||||
|
||||
### OIDC Setup
|
||||
|
||||
Any OIDC-compliant identity provider can be used for single sign-on. This includes Keycloak, Google, Okta, Authentik, Azure AD, and many others.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Create a client/application in your OIDC provider
|
||||
2. Set the redirect URL to `{LOCALAI_BASE_URL}/api/auth/oidc/callback`
|
||||
3. Set the three environment variables: `LOCALAI_OIDC_ISSUER`, `LOCALAI_OIDC_CLIENT_ID`, `LOCALAI_OIDC_CLIENT_SECRET`
|
||||
|
||||
LocalAI uses OIDC auto-discovery (the `/.well-known/openid-configuration` endpoint) and requests the standard scopes: `openid`, `profile`, `email`.
|
||||
|
||||
**Provider examples:**
|
||||
|
||||
```bash
|
||||
# Keycloak
|
||||
LOCALAI_OIDC_ISSUER=https://keycloak.example.com/realms/myrealm
|
||||
|
||||
# Google
|
||||
LOCALAI_OIDC_ISSUER=https://accounts.google.com
|
||||
|
||||
# Authentik
|
||||
LOCALAI_OIDC_ISSUER=https://authentik.example.com/application/o/localai/
|
||||
|
||||
# Okta
|
||||
LOCALAI_OIDC_ISSUER=https://your-org.okta.com
|
||||
```
|
||||
|
||||
For OIDC, invite codes work the same way as GitHub OAuth — the invite code is passed as a query parameter to the login URL (`/api/auth/oidc/login?invite_code=<code>`) and stored in a cookie during the OAuth flow.
|
||||
|
||||
### User API Keys
|
||||
|
||||
Authenticated users can create personal API keys for programmatic access:
|
||||
|
||||
```bash
|
||||
# Create an API key (requires session auth)
|
||||
curl -X POST http://localhost:8080/api/auth/api-keys \
|
||||
-H "Cookie: session=<session-id>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "My Script Key"}'
|
||||
```
|
||||
|
||||
User API keys inherit the creating user's role. Admin keys grant admin access; user keys grant user-level access.
|
||||
|
||||
### Auth API Endpoints
|
||||
|
||||
| Method | Endpoint | Description | Auth Required |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/auth/status` | Auth state, current user, providers | No |
|
||||
| `POST` | `/api/auth/logout` | End session | Yes |
|
||||
| `GET` | `/api/auth/me` | Current user info | Yes |
|
||||
| `POST` | `/api/auth/api-keys` | Create API key | Yes |
|
||||
| `GET` | `/api/auth/api-keys` | List user's API keys | Yes |
|
||||
| `DELETE` | `/api/auth/api-keys/:id` | Revoke API key | Yes |
|
||||
| `GET` | `/api/auth/usage` | User's own usage stats | Yes |
|
||||
| `GET` | `/api/auth/admin/users` | List all users | Admin |
|
||||
| `PUT` | `/api/auth/admin/users/:id/role` | Change user role | Admin |
|
||||
| `DELETE` | `/api/auth/admin/users/:id` | Delete user | Admin |
|
||||
| `GET` | `/api/auth/admin/usage` | All users' usage stats | Admin |
|
||||
| `POST` | `/api/auth/admin/invites` | Create invite link | Admin |
|
||||
| `GET` | `/api/auth/admin/invites` | List all invites | Admin |
|
||||
| `DELETE` | `/api/auth/admin/invites/:id` | Revoke unused invite | Admin |
|
||||
| `GET` | `/api/auth/invite/:code/check` | Check if invite code is valid | No |
|
||||
| `GET` | `/api/auth/github/login` | Start GitHub OAuth | No |
|
||||
| `GET` | `/api/auth/github/callback` | GitHub OAuth callback (internal) | No |
|
||||
| `GET` | `/api/auth/oidc/login` | Start OIDC login | No |
|
||||
| `GET` | `/api/auth/oidc/callback` | OIDC callback (internal) | No |
|
||||
|
||||
## Usage Tracking
|
||||
|
||||
When authentication is enabled, LocalAI automatically tracks per-user token usage for inference endpoints. Usage data includes:
|
||||
|
||||
- **Prompt tokens**, **completion tokens**, and **total tokens** per request
|
||||
- **Model** used and **endpoint** called
|
||||
- **Request duration**
|
||||
- **Timestamp** for time-series aggregation
|
||||
|
||||
### Viewing Usage
|
||||
|
||||
Usage is accessible through the **Usage** page in the web UI (visible to all authenticated users) or via the API:
|
||||
|
||||
```bash
|
||||
# Get your own usage (default: last 30 days)
|
||||
curl http://localhost:8080/api/auth/usage?period=month \
|
||||
-H "Authorization: Bearer <key>"
|
||||
|
||||
# Admin: get all users' usage
|
||||
curl http://localhost:8080/api/auth/admin/usage?period=week \
|
||||
-H "Authorization: Bearer <admin-key>"
|
||||
|
||||
# Admin: filter by specific user
|
||||
curl "http://localhost:8080/api/auth/admin/usage?period=month&user_id=<user-id>" \
|
||||
-H "Authorization: Bearer <admin-key>"
|
||||
```
|
||||
|
||||
**Period values:**
|
||||
- `day` — last 24 hours, bucketed by hour
|
||||
- `week` — last 7 days, bucketed by day
|
||||
- `month` — last 30 days, bucketed by day (default)
|
||||
- `all` — all time, bucketed by month
|
||||
|
||||
**Response format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"usage": [
|
||||
{
|
||||
"bucket": "2026-03-18",
|
||||
"model": "gpt-4",
|
||||
"user_id": "abc-123",
|
||||
"user_name": "Alice",
|
||||
"prompt_tokens": 1500,
|
||||
"completion_tokens": 800,
|
||||
"total_tokens": 2300,
|
||||
"request_count": 12
|
||||
}
|
||||
],
|
||||
"totals": {
|
||||
"prompt_tokens": 1500,
|
||||
"completion_tokens": 800,
|
||||
"total_tokens": 2300,
|
||||
"request_count": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Dashboard
|
||||
|
||||
The web UI Usage page provides:
|
||||
- **Period selector** — switch between day, week, month, and all-time views
|
||||
- **Summary cards** — total requests, prompt tokens, completion tokens, total tokens
|
||||
- **By Model table** — per-model breakdown with visual usage bars
|
||||
- **By User table** (admin only) — per-user breakdown across all models
|
||||
|
||||
## Combining Auth Modes
|
||||
|
||||
Legacy API keys and user authentication can be used simultaneously. When both are configured:
|
||||
|
||||
1. User sessions and user API keys are checked first
|
||||
2. Legacy API keys are checked as fallback — they grant **admin-level access**
|
||||
3. This allows a gradual migration from shared API keys to per-user accounts
|
||||
|
||||
## Build Requirements
|
||||
|
||||
The user authentication system requires CGO for SQLite support. It is enabled with the `auth` build tag, which is included by default in Docker builds.
|
||||
|
||||
```bash
|
||||
# Building from source with auth support
|
||||
GO_TAGS=auth make build
|
||||
|
||||
# Or directly with go build
|
||||
go build -tags auth ./...
|
||||
```
|
||||
|
||||
The default Dockerfile includes `GO_TAGS="auth"`, so all Docker images ship with auth support. When building from source without the `auth` tag, setting `LOCALAI_AUTH=true` has no effect — the system operates without authentication.
|
||||
@@ -63,6 +63,8 @@ You can configure these settings via the web UI or through environment variables
|
||||
- **CSRF**: Enable CSRF protection middleware
|
||||
- **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:
|
||||
|
||||
@@ -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 %}}
|
||||
|
||||
|
||||
@@ -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:**
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user