feat: add users permission management to plugin system

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2026-01-03 14:45:55 -05:00
parent 6ab277bf9e
commit 08280eaea8
40 changed files with 1674 additions and 91 deletions

View File

@ -4,6 +4,8 @@ CREATE TABLE IF NOT EXISTS plugin (
path TEXT NOT NULL,
manifest TEXT NOT NULL,
config TEXT,
users TEXT,
all_users INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
sha256 TEXT NOT NULL,

View File

@ -7,6 +7,8 @@ type Plugin struct {
Path string `structs:"path" json:"path"`
Manifest string `structs:"manifest" json:"manifest"`
Config string `structs:"config" json:"config,omitempty"`
Users string `structs:"users" json:"users,omitempty"`
AllUsers bool `structs:"all_users" json:"allUsers,omitempty"`
Enabled bool `structs:"enabled" json:"enabled"`
LastError string `structs:"last_error" json:"lastError,omitempty"`
SHA256 string `structs:"sha256" json:"sha256"`

View File

@ -46,6 +46,7 @@ type UserRepository interface {
CountAll(...QueryOptions) (int64, error)
Delete(id string) error
Get(id string) (*User, error)
GetAll(options ...QueryOptions) (Users, error)
Put(*User) error
UpdateLastLoginAt(id string) error
UpdateLastAccessAt(id string) error

View File

@ -79,12 +79,14 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error {
// Upsert using INSERT ... ON CONFLICT for atomic operation
_, err := r.db.NewQuery(`
INSERT INTO plugin (id, path, manifest, config, enabled, last_error, sha256, created_at, updated_at)
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
INSERT INTO plugin (id, path, manifest, config, users, all_users, enabled, last_error, sha256, created_at, updated_at)
VALUES ({:id}, {:path}, {:manifest}, {:config}, {:users}, {:all_users}, {:enabled}, {:last_error}, {:sha256}, {:created_at}, {:updated_at})
ON CONFLICT(id) DO UPDATE SET
path = excluded.path,
manifest = excluded.manifest,
config = excluded.config,
users = excluded.users,
all_users = excluded.all_users,
enabled = excluded.enabled,
last_error = excluded.last_error,
sha256 = excluded.sha256,
@ -94,6 +96,8 @@ func (r *pluginRepository) Put(plugin *model.Plugin) error {
"path": plugin.Path,
"manifest": plugin.Manifest,
"config": plugin.Config,
"users": plugin.Users,
"all_users": plugin.AllUsers,
"enabled": plugin.Enabled,
"last_error": plugin.LastError,
"sha256": plugin.SHA256,

View File

@ -12,6 +12,11 @@ RUST_PLUGINS := $(patsubst %/Cargo.toml,%,$(wildcard */Cargo.toml))
TINYGO := $(shell command -v tinygo 2> /dev/null)
EXTISM_PY := $(shell command -v extism-py 2> /dev/null)
# PDK source files that trigger rebuild when changed (recursive)
PDK_GO_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null)
PDK_PY_SOURCES := $(shell find ../pdk/python -name '*.py' 2>/dev/null)
PDK_RS_SOURCES := $(shell find ../pdk/rust -name '*.rs' 2>/dev/null)
# Allow building plugins without .ndp extension (e.g., make minimal instead of make minimal.ndp)
.PHONY: $(PLUGINS) $(PYTHON_PLUGINS) $(RUST_PLUGINS)
$(PLUGINS): %: %.ndp
@ -66,7 +71,7 @@ clean:
# Use secondary expansion to properly track all Go source files
.SECONDEXPANSION:
$(PLUGINS:%=%.wasm): %.wasm: $$(shell find % -name '*.go' 2>/dev/null) %/go.mod
$(PLUGINS:%=%.wasm): %.wasm: $$(shell find % -name '*.go' 2>/dev/null) %/go.mod $(PDK_GO_SOURCES)
ifdef TINYGO
cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ .
else
@ -76,7 +81,7 @@ endif
# Python plugin builds (generic rule for any folder with plugin/__init__.py)
# Use secondary expansion to get all .py files in the plugin directory as dependencies
.SECONDEXPANSION:
$(PYTHON_PLUGINS:%=%.wasm): %.wasm: $$(wildcard %/plugin/*.py)
$(PYTHON_PLUGINS:%=%.wasm): %.wasm: $$(wildcard %/plugin/*.py) $(PDK_PY_SOURCES)
ifndef EXTISM_PY
$(error extism-py is not installed. Install from https://github.com/extism/python-pdk)
endif
@ -88,6 +93,6 @@ endif
RUST_TARGET := wasm32-wasip1
RUSTUP_CARGO := $(shell rustup which cargo 2>/dev/null || echo cargo)
RUSTUP_RUSTC := $(shell rustup which rustc 2>/dev/null)
$(RUST_PLUGINS:%=%.wasm): %.wasm: %/Cargo.toml $$(wildcard %/src/*.rs)
$(RUST_PLUGINS:%=%.wasm): %.wasm: %/Cargo.toml $$(wildcard %/src/*.rs) $(PDK_RS_SOURCES)
cd $* && CARGO_BUILD_RUSTC=$(RUSTUP_RUSTC) $(RUSTUP_CARGO) build --release --target $(RUST_TARGET)
cp $*/target/$(RUST_TARGET)/release/$(subst -,_,$*).wasm $@

28
plugins/host/users.go Normal file
View File

@ -0,0 +1,28 @@
package host
import "context"
// User represents a Navidrome user with minimal information exposed to plugins.
// Sensitive fields like password, email, and internal IDs are intentionally excluded.
type User struct {
UserName string `json:"userName"`
Name string `json:"name"`
IsAdmin bool `json:"isAdmin"`
}
// UsersService provides access to user information for plugins.
//
// This service allows plugins to query information about users that the plugin
// has been granted access to. Access is controlled by the administrator who
// configures which users each plugin can see.
//
//nd:hostservice name=Users permission=users
type UsersService interface {
// GetUsers returns all users the plugin has been granted access to.
// Only minimal user information (userName, name, isAdmin) is returned.
// Sensitive fields like password and email are never exposed.
//
// Returns a slice of users the plugin can access, or an empty slice if none configured.
//nd:hostfunc
GetUsers(ctx context.Context) ([]User, error)
}

72
plugins/host/users_gen.go Normal file
View File

@ -0,0 +1,72 @@
// Code generated by ndpgen. DO NOT EDIT.
package host
import (
"context"
"encoding/json"
extism "github.com/extism/go-sdk"
)
// UsersGetUsersResponse is the response type for Users.GetUsers.
type UsersGetUsersResponse struct {
Result []User `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// RegisterUsersHostFunctions registers Users service host functions.
// The returned host functions should be added to the plugin's configuration.
func RegisterUsersHostFunctions(service UsersService) []extism.HostFunction {
return []extism.HostFunction{
newUsersGetUsersHostFunction(service),
}
}
func newUsersGetUsersHostFunction(service UsersService) extism.HostFunction {
return extism.NewHostFunctionWithStack(
"users_getusers",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
// Call the service method
result, svcErr := service.GetUsers(ctx)
if svcErr != nil {
usersWriteError(p, stack, svcErr)
return
}
// Write JSON response to plugin memory
resp := UsersGetUsersResponse{
Result: result,
}
usersWriteResponse(p, stack, resp)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
}
// usersWriteResponse writes a JSON response to plugin memory.
func usersWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) {
respBytes, err := json.Marshal(resp)
if err != nil {
usersWriteError(p, stack, err)
return
}
respPtr, err := p.WriteBytes(respBytes)
if err != nil {
stack[0] = 0
return
}
stack[0] = respPtr
}
// usersWriteError writes an error response to plugin memory.
func usersWriteError(p *extism.CurrentPlugin, stack []uint64, err error) {
errResp := struct {
Error string `json:"error"`
}{Error: err.Error()}
respBytes, _ := json.Marshal(errResp)
respPtr, _ := p.WriteBytes(respBytes)
stack[0] = respPtr
}

52
plugins/host_users.go Normal file
View File

@ -0,0 +1,52 @@
package plugins
import (
"context"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins/host"
)
type usersServiceImpl struct {
ds model.DataStore
allowedUsers []string // User IDs this plugin can access
allUsers bool // If true, plugin can access all users
}
func newUsersService(ds model.DataStore, allowedUsers []string, allUsers bool) host.UsersService {
return &usersServiceImpl{
ds: ds,
allowedUsers: allowedUsers,
allUsers: allUsers,
}
}
func (s *usersServiceImpl) GetUsers(ctx context.Context) ([]host.User, error) {
users, err := s.ds.User(ctx).GetAll()
if err != nil {
return nil, err
}
// Build allowed users map for efficient lookup
allowedMap := make(map[string]bool, len(s.allowedUsers))
for _, id := range s.allowedUsers {
allowedMap[id] = true
}
var result []host.User
for _, u := range users {
// If allUsers is true, include all users
// Otherwise, only include users in the allowed list
if s.allUsers || allowedMap[u.ID] {
result = append(result, host.User{
UserName: u.UserName,
Name: u.Name,
IsAdmin: u.IsAdmin,
})
}
}
return result, nil
}
var _ host.UsersService = (*usersServiceImpl)(nil)

485
plugins/host_users_test.go Normal file
View File

@ -0,0 +1,485 @@
//go:build !windows
package plugins
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("UsersService", Ordered, func() {
var (
ctx context.Context
ds model.DataStore
service host.UsersService
)
BeforeEach(func() {
ctx = context.Background()
ds = &tests.MockDataStore{}
})
Describe("GetUsers", func() {
var mockUserRepo *tests.MockedUserRepo
BeforeEach(func() {
mockUserRepo = ds.User(ctx).(*tests.MockedUserRepo)
// Add test users
_ = mockUserRepo.Put(&model.User{
ID: "user1",
UserName: "alice",
Name: "Alice Admin",
IsAdmin: true,
})
_ = mockUserRepo.Put(&model.User{
ID: "user2",
UserName: "bob",
Name: "Bob User",
IsAdmin: false,
})
_ = mockUserRepo.Put(&model.User{
ID: "user3",
UserName: "charlie",
Name: "Charlie User",
IsAdmin: false,
})
})
Context("with allUsers=true", func() {
BeforeEach(func() {
service = newUsersService(ds, nil, true)
})
It("should return all users", func() {
users, err := service.GetUsers(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(users).To(HaveLen(3))
// Verify that the correct fields are returned
userNames := make([]string, len(users))
for i, u := range users {
userNames[i] = u.UserName
}
Expect(userNames).To(ContainElements("alice", "bob", "charlie"))
})
It("should return correct user properties", func() {
users, err := service.GetUsers(ctx)
Expect(err).ToNot(HaveOccurred())
// Find alice
var alice *host.User
for i := range users {
if users[i].UserName == "alice" {
alice = &users[i]
break
}
}
Expect(alice).ToNot(BeNil())
Expect(alice.UserName).To(Equal("alice"))
Expect(alice.Name).To(Equal("Alice Admin"))
Expect(alice.IsAdmin).To(BeTrue())
})
})
Context("with specific allowed users", func() {
BeforeEach(func() {
// Only allow access to user1 and user3
service = newUsersService(ds, []string{"user1", "user3"}, false)
})
It("should return only allowed users", func() {
users, err := service.GetUsers(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(users).To(HaveLen(2))
userNames := make([]string, len(users))
for i, u := range users {
userNames[i] = u.UserName
}
Expect(userNames).To(ContainElements("alice", "charlie"))
Expect(userNames).ToNot(ContainElement("bob"))
})
})
Context("with empty allowed users and allUsers=false", func() {
BeforeEach(func() {
service = newUsersService(ds, []string{}, false)
})
It("should return no users", func() {
users, err := service.GetUsers(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(users).To(BeEmpty())
})
})
Context("when datastore returns error", func() {
BeforeEach(func() {
mockUserRepo.Error = model.ErrNotFound
service = newUsersService(ds, nil, true)
})
It("should propagate the error", func() {
_, err := service.GetUsers(ctx)
Expect(err).To(HaveOccurred())
})
})
})
})
var _ = Describe("UsersService Integration", Ordered, func() {
var (
manager *Manager
tmpDir string
)
BeforeAll(func() {
var err error
tmpDir, err = os.MkdirTemp("", "users-integration-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy the test-users plugin
srcPath := filepath.Join(testdataDir, "test-users"+PackageExtension)
destPath := filepath.Join(tmpDir, "test-users"+PackageExtension)
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Compute SHA256 for the plugin
hash := sha256.Sum256(data)
hashHex := hex.EncodeToString(hash[:])
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tmpDir
conf.Server.Plugins.AutoReload = false
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
// Setup mock DataStore with pre-enabled plugin and users
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(model.Plugins{{
ID: "test-users",
Path: destPath,
SHA256: hashHex,
Enabled: true,
AllUsers: true, // Allow all users
}})
mockUserRepo := tests.CreateMockUserRepo()
_ = mockUserRepo.Put(&model.User{
ID: "user1",
UserName: "alice",
Name: "Alice Admin",
IsAdmin: true,
})
_ = mockUserRepo.Put(&model.User{
ID: "user2",
UserName: "bob",
Name: "Bob User",
IsAdmin: false,
})
_ = mockUserRepo.Put(&model.User{
ID: "user3",
UserName: "charlie",
Name: "Charlie User",
IsAdmin: false,
})
dataStore := &tests.MockDataStore{
MockedPlugin: mockPluginRepo,
MockedUser: mockUserRepo,
}
// Create and start manager
manager = &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
subsonicRouter: http.NotFoundHandler(),
}
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
})
Describe("Plugin Loading", func() {
It("should load plugin with users permission", func() {
manager.mu.RLock()
p, ok := manager.plugins["test-users"]
manager.mu.RUnlock()
Expect(ok).To(BeTrue())
Expect(p.manifest.Permissions).ToNot(BeNil())
Expect(p.manifest.Permissions.Users).ToNot(BeNil())
})
})
Describe("Users Operations via Plugin", func() {
type testUsersInput struct {
Operation string `json:"operation"`
}
type user struct {
UserName string `json:"userName"`
Name string `json:"name"`
IsAdmin bool `json:"isAdmin"`
}
type testUsersOutput struct {
Users []user `json:"users,omitempty"`
Error *string `json:"error,omitempty"`
}
callTestUsers := func(ctx context.Context, input testUsersInput) (*testUsersOutput, error) {
manager.mu.RLock()
p := manager.plugins["test-users"]
manager.mu.RUnlock()
instance, err := p.instance(ctx)
if err != nil {
return nil, err
}
defer instance.Close(ctx)
inputBytes, _ := json.Marshal(input)
_, outputBytes, err := instance.Call("nd_test_users", inputBytes)
if err != nil {
return nil, err
}
var output testUsersOutput
if err := json.Unmarshal(outputBytes, &output); err != nil {
return nil, err
}
if output.Error != nil {
return nil, errors.New(*output.Error)
}
return &output, nil
}
It("should get all users when allUsers is true", func() {
ctx := GinkgoT().Context()
output, err := callTestUsers(ctx, testUsersInput{
Operation: "get_users",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Users).To(HaveLen(3))
// Verify user names
userNames := make([]string, len(output.Users))
for i, u := range output.Users {
userNames[i] = u.UserName
}
Expect(userNames).To(ContainElements("alice", "bob", "charlie"))
})
It("should return correct user properties", func() {
ctx := GinkgoT().Context()
output, err := callTestUsers(ctx, testUsersInput{
Operation: "get_users",
})
Expect(err).ToNot(HaveOccurred())
// Find alice
var alice *user
for i := range output.Users {
if output.Users[i].UserName == "alice" {
alice = &output.Users[i]
break
}
}
Expect(alice).ToNot(BeNil())
Expect(alice.UserName).To(Equal("alice"))
Expect(alice.Name).To(Equal("Alice Admin"))
Expect(alice.IsAdmin).To(BeTrue())
})
It("should return non-admin user correctly", func() {
ctx := GinkgoT().Context()
output, err := callTestUsers(ctx, testUsersInput{
Operation: "get_users",
})
Expect(err).ToNot(HaveOccurred())
// Find bob
var bob *user
for i := range output.Users {
if output.Users[i].UserName == "bob" {
bob = &output.Users[i]
break
}
}
Expect(bob).ToNot(BeNil())
Expect(bob.UserName).To(Equal("bob"))
Expect(bob.Name).To(Equal("Bob User"))
Expect(bob.IsAdmin).To(BeFalse())
})
})
})
var _ = Describe("UsersService Integration with Specific Users", Ordered, func() {
var (
manager *Manager
tmpDir string
)
BeforeAll(func() {
var err error
tmpDir, err = os.MkdirTemp("", "users-specific-test-*")
Expect(err).ToNot(HaveOccurred())
// Copy the test-users plugin
srcPath := filepath.Join(testdataDir, "test-users"+PackageExtension)
destPath := filepath.Join(tmpDir, "test-users"+PackageExtension)
data, err := os.ReadFile(srcPath)
Expect(err).ToNot(HaveOccurred())
err = os.WriteFile(destPath, data, 0600)
Expect(err).ToNot(HaveOccurred())
// Compute SHA256 for the plugin
hash := sha256.Sum256(data)
hashHex := hex.EncodeToString(hash[:])
// Setup config
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = tmpDir
conf.Server.Plugins.AutoReload = false
conf.Server.CacheFolder = filepath.Join(tmpDir, "cache")
// Setup mock DataStore with specific allowed users (only user1 and user3)
mockPluginRepo := tests.CreateMockPluginRepo()
mockPluginRepo.Permitted = true
mockPluginRepo.SetData(model.Plugins{{
ID: "test-users",
Path: destPath,
SHA256: hashHex,
Enabled: true,
AllUsers: false,
Users: `["user1", "user3"]`, // Only allow alice and charlie
}})
mockUserRepo := tests.CreateMockUserRepo()
_ = mockUserRepo.Put(&model.User{
ID: "user1",
UserName: "alice",
Name: "Alice Admin",
IsAdmin: true,
})
_ = mockUserRepo.Put(&model.User{
ID: "user2",
UserName: "bob",
Name: "Bob User",
IsAdmin: false,
})
_ = mockUserRepo.Put(&model.User{
ID: "user3",
UserName: "charlie",
Name: "Charlie User",
IsAdmin: false,
})
dataStore := &tests.MockDataStore{
MockedPlugin: mockPluginRepo,
MockedUser: mockUserRepo,
}
// Create and start manager
manager = &Manager{
plugins: make(map[string]*plugin),
ds: dataStore,
subsonicRouter: http.NotFoundHandler(),
}
err = manager.Start(GinkgoT().Context())
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
_ = manager.Stop()
_ = os.RemoveAll(tmpDir)
})
})
Describe("Users Operations with Specific Allowed Users", func() {
type testUsersInput struct {
Operation string `json:"operation"`
}
type user struct {
UserName string `json:"userName"`
Name string `json:"name"`
IsAdmin bool `json:"isAdmin"`
}
type testUsersOutput struct {
Users []user `json:"users,omitempty"`
Error *string `json:"error,omitempty"`
}
callTestUsers := func(ctx context.Context, input testUsersInput) (*testUsersOutput, error) {
manager.mu.RLock()
p := manager.plugins["test-users"]
manager.mu.RUnlock()
instance, err := p.instance(ctx)
if err != nil {
return nil, err
}
defer instance.Close(ctx)
inputBytes, _ := json.Marshal(input)
_, outputBytes, err := instance.Call("nd_test_users", inputBytes)
if err != nil {
return nil, err
}
var output testUsersOutput
if err := json.Unmarshal(outputBytes, &output); err != nil {
return nil, err
}
if output.Error != nil {
return nil, errors.New(*output.Error)
}
return &output, nil
}
It("should only return allowed users", func() {
ctx := GinkgoT().Context()
output, err := callTestUsers(ctx, testUsersInput{
Operation: "get_users",
})
Expect(err).ToNot(HaveOccurred())
Expect(output.Users).To(HaveLen(2))
// Verify only alice and charlie are returned, not bob
userNames := make([]string, len(output.Users))
for i, u := range output.Users {
userNames[i] = u.UserName
}
Expect(userNames).To(ContainElements("alice", "charlie"))
Expect(userNames).ToNot(ContainElement("bob"))
})
})
})

View File

@ -2,6 +2,7 @@ package plugins
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
@ -305,8 +306,13 @@ func (m *Manager) EnablePlugin(ctx context.Context, id string) error {
return nil // Already enabled
}
// Check permission gates before enabling
if err := m.checkPermissionGates(plugin); err != nil {
return err
}
// Try to load the plugin
if err := m.loadPluginWithConfig(plugin.ID, plugin.Path, plugin.Config); err != nil {
if err := m.loadPluginWithConfig(plugin); err != nil {
// Store error and return
plugin.LastError = err.Error()
plugin.UpdatedAt = time.Now()
@ -394,7 +400,7 @@ func (m *Manager) UpdatePluginConfig(ctx context.Context, id, configJSON string)
if err := m.unloadPlugin(id); err != nil {
log.Debug(ctx, "Plugin was not loaded", "plugin", id)
}
if err := m.loadPluginWithConfig(plugin.ID, plugin.Path, configJSON); err != nil {
if err := m.loadPluginWithConfig(plugin); err != nil {
plugin.LastError = err.Error()
plugin.Enabled = false
_ = repo.Put(plugin)
@ -407,6 +413,85 @@ func (m *Manager) UpdatePluginConfig(ctx context.Context, id, configJSON string)
return nil
}
// UpdatePluginUsers updates the users permission settings for a plugin.
// If the plugin is enabled, it will be reloaded with the new settings.
// If the plugin requires users permission and no users are configured (and allUsers is false),
// the plugin will be automatically disabled.
func (m *Manager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error {
if m.ds == nil {
return fmt.Errorf("datastore not configured")
}
adminCtx := adminContext(ctx)
repo := m.ds.Plugin(adminCtx)
plugin, err := repo.Get(id)
if err != nil {
return fmt.Errorf("getting plugin from DB: %w", err)
}
wasEnabled := plugin.Enabled
// Update users in DB
plugin.Users = usersJSON
plugin.AllUsers = allUsers
plugin.UpdatedAt = time.Now()
// Check if plugin requires users permission and if the new settings are valid
shouldDisable := false
if wasEnabled {
manifest, err := readManifest(plugin.Path)
if err == nil && manifest.Permissions != nil && manifest.Permissions.Users != nil {
// Plugin requires users permission - check if it's still satisfied
if !allUsers {
if usersJSON == "" {
shouldDisable = true
} else {
var users []string
if err := json.Unmarshal([]byte(usersJSON), &users); err != nil || len(users) == 0 {
shouldDisable = true
}
}
}
}
}
if shouldDisable {
// Disable the plugin since users permission is no longer satisfied
if err := m.unloadPlugin(id); err != nil {
log.Debug(ctx, "Plugin was not loaded", "plugin", id)
}
plugin.Enabled = false
if err := repo.Put(plugin); err != nil {
return fmt.Errorf("updating plugin users in DB: %w", err)
}
log.Info(ctx, "Disabled plugin due to users permission removal", "plugin", id)
m.sendPluginRefreshEvent(ctx, id)
return nil
}
if err := repo.Put(plugin); err != nil {
return fmt.Errorf("updating plugin users in DB: %w", err)
}
// Reload if enabled
if wasEnabled {
if err := m.unloadPlugin(id); err != nil {
log.Debug(ctx, "Plugin was not loaded", "plugin", id)
}
if err := m.loadPluginWithConfig(plugin); err != nil {
plugin.LastError = err.Error()
plugin.Enabled = false
_ = repo.Put(plugin)
return fmt.Errorf("reloading plugin with new users config: %w", err)
}
}
log.Info(ctx, "Updated plugin users", "plugin", id)
m.sendPluginRefreshEvent(ctx, id)
return nil
}
// unloadPlugin removes a plugin from the manager and closes its resources.
// Returns an error if the plugin is not found.
func (m *Manager) unloadPlugin(name string) error {
@ -440,3 +525,32 @@ func (m *Manager) unloadPlugin(name string) error {
log.Info(m.ctx, "Unloaded plugin", "plugin", name)
return nil
}
// checkPermissionGates validates that all permission-based requirements are met
// before a plugin can be enabled. Returns an error if any gate condition fails.
func (m *Manager) checkPermissionGates(p *model.Plugin) error {
// Parse manifest to check permissions
manifest, err := readManifest(p.Path)
if err != nil {
return fmt.Errorf("reading manifest: %w", err)
}
// Check users permission gate
if manifest.Permissions != nil && manifest.Permissions.Users != nil {
if !p.AllUsers && p.Users == "" {
return fmt.Errorf("users permission requires configuration: select users or enable 'all users' access")
}
// Also check that Users JSON array is not empty if AllUsers is false
if !p.AllUsers {
var users []string
if err := json.Unmarshal([]byte(p.Users), &users); err != nil {
return fmt.Errorf("invalid users configuration: %w", err)
}
if len(users) == 0 {
return fmt.Errorf("users permission requires configuration: select at least one user or enable 'all users' access")
}
}
}
return nil
}

View File

@ -9,6 +9,7 @@ import (
extism "github.com/extism/go-sdk"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/plugins/host"
"github.com/navidrome/navidrome/scheduler"
"github.com/tetratelabs/wazero"
@ -19,10 +20,12 @@ import (
// serviceContext provides dependencies needed by host service factories.
type serviceContext struct {
pluginName string
manager *Manager
permissions *Permissions
config map[string]string
pluginName string
manager *Manager
permissions *Permissions
config map[string]string
allowedUsers []string // User IDs this plugin can access
allUsers bool // If true, plugin can access all users
}
// hostServiceEntry defines a host service for table-driven registration.
@ -107,6 +110,14 @@ var hostServices = []hostServiceEntry{
return host.RegisterKVStoreHostFunctions(service), service
},
},
{
name: "Users",
hasPermission: func(p *Permissions) bool { return p != nil && p.Users != nil },
create: func(ctx *serviceContext) ([]extism.HostFunction, io.Closer) {
service := newUsersService(ctx.manager.ds, ctx.allowedUsers, ctx.allUsers)
return host.RegisterUsersHostFunctions(service), nil
},
},
}
// extractManifest reads manifest from an .ndp package and computes its SHA-256 hash.
@ -167,7 +178,7 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error {
}
}()
if err := m.loadPluginWithConfig(plugin.ID, plugin.Path, plugin.Config); err != nil {
if err := m.loadPluginWithConfig(&plugin); err != nil {
// Store error in DB
plugin.LastError = err.Error()
plugin.Enabled = false
@ -203,8 +214,8 @@ func (m *Manager) loadEnabledPlugins(ctx context.Context) error {
}
// loadPluginWithConfig loads a plugin with configuration from DB.
// The ndpPath should point to an .ndp package file.
func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error {
// The p.Path should point to an .ndp package file.
func (m *Manager) loadPluginWithConfig(p *model.Plugin) error {
if m.stopped.Load() {
return fmt.Errorf("manager is stopped")
}
@ -219,14 +230,22 @@ func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error {
// Parse config from JSON
var pluginConfig map[string]string
if configJSON != "" {
if err := json.Unmarshal([]byte(configJSON), &pluginConfig); err != nil {
if p.Config != "" {
if err := json.Unmarshal([]byte(p.Config), &pluginConfig); err != nil {
return fmt.Errorf("parsing plugin config: %w", err)
}
}
// Parse users from JSON
var allowedUsers []string
if p.Users != "" {
if err := json.Unmarshal([]byte(p.Users), &allowedUsers); err != nil {
return fmt.Errorf("parsing plugin users: %w", err)
}
}
// Open the .ndp package to get manifest and wasm bytes
pkg, err := openPackage(ndpPath)
pkg, err := openPackage(p.Path)
if err != nil {
return fmt.Errorf("opening package: %w", err)
}
@ -264,10 +283,12 @@ func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error {
var closers []io.Closer
svcCtx := &serviceContext{
pluginName: name,
manager: m,
permissions: pkg.Manifest.Permissions,
config: pluginConfig,
pluginName: p.ID,
manager: m,
permissions: pkg.Manifest.Permissions,
config: pluginConfig,
allowedUsers: allowedUsers,
allUsers: p.AllUsers,
}
for _, entry := range hostServices {
if entry.hasPermission(pkg.Manifest.Permissions) {
@ -287,7 +308,7 @@ func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error {
// Enable experimental threads if requested in manifest
if pkg.Manifest.HasExperimentalThreads() {
runtimeConfig = runtimeConfig.WithCoreFeatures(api.CoreFeaturesV2 | experimental.CoreFeaturesThreads)
log.Debug(m.ctx, "Enabling experimental threads support", "plugin", name)
log.Debug(m.ctx, "Enabling experimental threads support", "plugin", p.ID)
}
extismConfig := extism.PluginConfig{
@ -305,14 +326,14 @@ func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error {
compiled.Close(m.ctx)
return fmt.Errorf("creating instance: %w", err)
}
instance.SetLogger(extismLogger(name))
instance.SetLogger(extismLogger(p.ID))
capabilities := detectCapabilities(instance)
instance.Close(m.ctx)
m.mu.Lock()
m.plugins[name] = &plugin{
name: name,
path: ndpPath,
m.plugins[p.ID] = &plugin{
name: p.ID,
path: p.Path,
manifest: pkg.Manifest,
compiled: compiled,
capabilities: capabilities,
@ -322,7 +343,7 @@ func (m *Manager) loadPluginWithConfig(name, ndpPath, configJSON string) error {
m.mu.Unlock()
// Call plugin init function
callPluginInit(m.ctx, m.plugins[name])
callPluginInit(m.ctx, m.plugins[p.ID])
return nil
}

View File

@ -88,6 +88,9 @@
},
"kvstore": {
"$ref": "#/$defs/KVStorePermission"
},
"users": {
"$ref": "#/$defs/UsersPermission"
}
}
},
@ -224,6 +227,17 @@
"description": "Maximum storage size (e.g., '1MB', '500KB'). Default: 1MB"
}
}
},
"UsersPermission": {
"type": "object",
"description": "Users service permissions for accessing user information",
"additionalProperties": false,
"properties": {
"reason": {
"type": "string",
"description": "Explanation for why users access is needed"
}
}
}
}
}

View File

@ -156,6 +156,9 @@ type Permissions struct {
// Subsonicapi corresponds to the JSON schema field "subsonicapi".
Subsonicapi *SubsonicAPIPermission `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"`
// Users corresponds to the JSON schema field "users".
Users *UsersPermission `json:"users,omitempty" yaml:"users,omitempty" mapstructure:"users,omitempty"`
// Websocket corresponds to the JSON schema field "websocket".
Websocket *WebSocketPermission `json:"websocket,omitempty" yaml:"websocket,omitempty" mapstructure:"websocket,omitempty"`
}
@ -202,6 +205,12 @@ type ThreadsFeature struct {
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// Users service permissions for accessing user information
type UsersPermission struct {
// Explanation for why users access is needed
Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"`
}
// WebSocket service permissions for establishing WebSocket connections
type WebSocketPermission struct {
// List of allowed host patterns for WebSocket connections (e.g.,

View File

@ -42,6 +42,7 @@ The following host services are available:
- Library: provides access to music library metadata for plugins.
- Scheduler: provides task scheduling capabilities for plugins.
- SubsonicAPI: provides access to Navidrome's Subsonic API from plugins.
- Users: provides access to user information for plugins.
- WebSocket: provides WebSocket communication capabilities for plugins.
# Building Plugins

View File

@ -126,7 +126,7 @@ func ConfigGetInt(key string) (int64, bool) {
}
// ConfigKeys calls the config_keys host function.
// List returns configuration keys matching the given prefix.
// Keys returns configuration keys matching the given prefix.
//
// Parameters:
// - prefix: Key prefix to filter by. If empty, returns all keys.

View File

@ -61,7 +61,7 @@ func (m *mockConfigService) Keys(prefix string) []string {
}
// ConfigKeys delegates to the mock instance.
// List returns configuration keys matching the given prefix.
// Keys returns configuration keys matching the given prefix.
//
// Parameters:
// - prefix: Key prefix to filter by. If empty, returns all keys.

View File

@ -0,0 +1,66 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains client wrappers for the Users host service.
// It is intended for use in Navidrome plugins built with TinyGo.
//
//go:build wasip1
package host
import (
"encoding/json"
"errors"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
// User represents the User data structure.
// User represents a Navidrome user with minimal information exposed to plugins.
// Sensitive fields like password, email, and internal IDs are intentionally excluded.
type User struct {
UserName string `json:"userName"`
Name string `json:"name"`
IsAdmin bool `json:"isAdmin"`
}
// users_getusers is the host function provided by Navidrome.
//
//go:wasmimport extism:host/user users_getusers
func users_getusers(uint64) uint64
type usersGetUsersResponse struct {
Result []User `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
// UsersGetUsers calls the users_getusers host function.
// GetUsers returns all users the plugin has been granted access to.
// Only minimal user information (userName, name, isAdmin) is returned.
// Sensitive fields like password and email are never exposed.
//
// Returns a slice of users the plugin can access, or an empty slice if none configured.
func UsersGetUsers() ([]User, error) {
// No parameters - allocate empty JSON object
reqMem := pdk.AllocateBytes([]byte("{}"))
defer reqMem.Free()
// Call the host function
responsePtr := users_getusers(reqMem.Offset())
// Read the response from memory
responseMem := pdk.FindMemory(responsePtr)
responseBytes := responseMem.ReadBytes()
// Parse the response
var response usersGetUsersResponse
if err := json.Unmarshal(responseBytes, &response); err != nil {
return nil, err
}
// Convert Error field to Go error
if response.Error != "" {
return nil, errors.New(response.Error)
}
return response.Result, nil
}

View File

@ -0,0 +1,45 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains mock implementations for non-WASM builds.
// These mocks allow IDE support, compilation, and unit testing on non-WASM platforms.
// Plugin authors can use the exported mock instances to set expectations in tests.
//
//go:build !wasip1
package host
import "github.com/stretchr/testify/mock"
// User represents the User data structure.
// User represents a Navidrome user with minimal information exposed to plugins.
// Sensitive fields like password, email, and internal IDs are intentionally excluded.
type User struct {
UserName string `json:"userName"`
Name string `json:"name"`
IsAdmin bool `json:"isAdmin"`
}
// mockUsersService is the mock implementation for testing.
type mockUsersService struct {
mock.Mock
}
// UsersMock is the auto-instantiated mock instance for testing.
// Use this to set expectations: host.UsersMock.On("MethodName", args...).Return(values...)
var UsersMock = &mockUsersService{}
// GetUsers is the mock method for UsersGetUsers.
func (m *mockUsersService) GetUsers() ([]User, error) {
args := m.Called()
return args.Get(0).([]User), args.Error(1)
}
// UsersGetUsers delegates to the mock instance.
// GetUsers returns all users the plugin has been granted access to.
// Only minimal user information (userName, name, isAdmin) is returned.
// Sensitive fields like password and email are never exposed.
//
// Returns a slice of users the plugin can access, or an empty slice if none configured.
func UsersGetUsers() ([]User, error) {
return UsersMock.GetUsers()
}

View File

@ -117,7 +117,7 @@ value cannot be parsed as an integer, exists will be false.
def config_keys(prefix: str) -> Any:
"""List returns configuration keys matching the given prefix.
"""Keys returns configuration keys matching the given prefix.
Parameters:
- prefix: Key prefix to filter by. If empty, returns all keys.

View File

@ -0,0 +1,50 @@
# Code generated by ndpgen. DO NOT EDIT.
#
# This file contains client wrappers for the Users host service.
# It is intended for use in Navidrome plugins built with extism-py.
#
# IMPORTANT: Due to a limitation in extism-py, you cannot import this file directly.
# The @extism.import_fn decorators are only detected when defined in the plugin's
# main __init__.py file. Copy the needed functions from this file into your plugin.
from dataclasses import dataclass
from typing import Any
import extism
import json
class HostFunctionError(Exception):
"""Raised when a host function returns an error."""
pass
@extism.import_fn("extism:host/user", "users_getusers")
def _users_getusers(offset: int) -> int:
"""Raw host function - do not call directly."""
...
def users_get_users() -> Any:
"""GetUsers returns all users the plugin has been granted access to.
Only minimal user information (userName, name, isAdmin) is returned.
Sensitive fields like password and email are never exposed.
Returns a slice of users the plugin can access, or an empty slice if none configured.
Returns:
Any: The result value.
Raises:
HostFunctionError: If the host function returns an error.
"""
request_bytes = b"{}"
request_mem = extism.memory.alloc(request_bytes)
response_offset = _users_getusers(request_mem.offset)
response_mem = extism.memory.find(response_offset)
response = json.loads(extism.memory.string(response_mem))
if response.get("error"):
raise HostFunctionError(response["error"])
return response.get("result", None)

View File

@ -39,6 +39,7 @@
//! - [`library`] - provides access to music library metadata for plugins.
//! - [`scheduler`] - provides task scheduling capabilities for plugins.
//! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins.
//! - [`users`] - provides access to user information for plugins.
//! - [`websocket`] - provides WebSocket communication capabilities for plugins.
#[doc(hidden)]
@ -90,6 +91,13 @@ pub mod subsonicapi {
pub use super::nd_host_subsonicapi::*;
}
#[doc(hidden)]
mod nd_host_users;
/// provides access to user information for plugins.
pub mod users {
pub use super::nd_host_users::*;
}
#[doc(hidden)]
mod nd_host_websocket;
/// provides WebSocket communication capabilities for plugins.

View File

@ -107,7 +107,7 @@ pub fn get_int(key: &str) -> Result<(i64, bool), Error> {
Ok((response.0.value, response.0.exists))
}
/// List returns configuration keys matching the given prefix.
/// Keys returns configuration keys matching the given prefix.
///
/// Parameters:
/// - prefix: Key prefix to filter by. If empty, returns all keys.

View File

@ -0,0 +1,54 @@
// Code generated by ndpgen. DO NOT EDIT.
//
// This file contains client wrappers for the Users host service.
// It is intended for use in Navidrome plugins built with extism-pdk.
use extism_pdk::*;
use serde::{Deserialize, Serialize};
/// User represents a Navidrome user with minimal information exposed to plugins.
/// Sensitive fields like password, email, and internal IDs are intentionally excluded.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub user_name: String,
pub name: String,
pub is_admin: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UsersGetUsersResponse {
#[serde(default)]
result: Vec<User>,
#[serde(default)]
error: Option<String>,
}
#[host_fn]
extern "ExtismHost" {
fn users_getusers(input: Json<serde_json::Value>) -> Json<UsersGetUsersResponse>;
}
/// GetUsers returns all users the plugin has been granted access to.
/// Only minimal user information (userName, name, isAdmin) is returned.
/// Sensitive fields like password and email are never exposed.
///
/// Returns a slice of users the plugin can access, or an empty slice if none configured.
///
/// # Returns
/// The result value.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn get_users() -> Result<Vec<User>, Error> {
let response = unsafe {
users_getusers(Json(serde_json::json!({})))?
};
if let Some(err) = response.0.error {
return Err(Error::msg(err));
}
Ok(response.0.result)
}

View File

@ -11,6 +11,9 @@ all: $(PLUGINS:%=%.ndp)
clean:
rm -f $(PLUGINS:%=%.ndp) $(PLUGINS:%=%.wasm)
# PDK source files that trigger rebuild when changed (recursive)
PDK_SOURCES := $(shell find ../pdk/go -name '*.go' 2>/dev/null)
# Build the .ndp package (zip containing manifest.json + plugin.wasm)
%.ndp: %.wasm %/manifest.json
@rm -f $@
@ -20,7 +23,7 @@ clean:
@mv $< $<.tmp && mv $<.tmp $< # Touch wasm to ensure it's older than ndp
# Build the wasm binary
%.wasm: %/*.go %/go.mod
%.wasm: %/*.go %/go.mod $(PDK_SOURCES)
ifdef TINYGO
cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ .
else

16
plugins/testdata/test-users/go.mod vendored Normal file
View File

@ -0,0 +1,16 @@
module test-users
go 1.25
require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/extism/go-pdk v1.1.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go

14
plugins/testdata/test-users/go.sum vendored Normal file
View File

@ -0,0 +1,14 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ=
github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

51
plugins/testdata/test-users/main.go vendored Normal file
View File

@ -0,0 +1,51 @@
// Test Users plugin for Navidrome plugin system integration tests.
// This plugin tests user metadata access via the Users host service.
// Build with: tinygo build -o ../test-users.wasm -target wasip1 -buildmode=c-shared .
package main
import (
"github.com/navidrome/navidrome/plugins/pdk/go/host"
"github.com/navidrome/navidrome/plugins/pdk/go/pdk"
)
// TestUsersInput is the input for nd_test_users callback.
type TestUsersInput struct {
Operation string `json:"operation"` // "get_users"
}
// TestUsersOutput is the output from nd_test_users callback.
type TestUsersOutput struct {
Users []host.User `json:"users,omitempty"`
Error *string `json:"error,omitempty"`
}
// nd_test_users is the test callback that tests the users host functions.
//
//go:wasmexport nd_test_users
func ndTestUsers() int32 {
var input TestUsersInput
if err := pdk.InputJSON(&input); err != nil {
errStr := err.Error()
pdk.OutputJSON(TestUsersOutput{Error: &errStr})
return 0
}
switch input.Operation {
case "get_users":
users, err := host.UsersGetUsers()
if err != nil {
errStr := err.Error()
pdk.OutputJSON(TestUsersOutput{Error: &errStr})
return 0
}
pdk.OutputJSON(TestUsersOutput{Users: users})
return 0
default:
errStr := "unknown operation: " + input.Operation
pdk.OutputJSON(TestUsersOutput{Error: &errStr})
return 0
}
}
func main() {}

View File

@ -0,0 +1,11 @@
{
"name": "Test Users Plugin",
"author": "Navidrome Test",
"version": "1.0.0",
"description": "A test users plugin for integration testing",
"permissions": {
"users": {
"reason": "For testing user metadata access"
}
}
}

View File

@ -26,6 +26,7 @@ type PluginManager interface {
EnablePlugin(ctx context.Context, id string) error
DisablePlugin(ctx context.Context, id string) error
UpdatePluginConfig(ctx context.Context, id, configJSON string) error
UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error
}
type Router struct {

View File

@ -43,8 +43,10 @@ func pluginsEnabledMiddleware(next http.Handler) http.Handler {
// PluginUpdateRequest represents the fields that can be updated via the API
type PluginUpdateRequest struct {
Enabled *bool `json:"enabled,omitempty"`
Config *string `json:"config,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Config *string `json:"config,omitempty"`
Users *string `json:"users,omitempty"`
AllUsers *bool `json:"allUsers,omitempty"`
}
func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
@ -89,6 +91,38 @@ func (api *Router) updatePlugin(w http.ResponseWriter, r *http.Request) {
}
}
// Handle users permission update (if provided)
if req.Users != nil || req.AllUsers != nil {
// Get current values if not provided in request
plugin, err := repo.Get(id)
if err != nil {
log.Error(ctx, "Error getting plugin for users update", "id", id, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
usersJSON := plugin.Users
allUsers := plugin.AllUsers
if req.Users != nil {
// Validate users JSON if not empty
if *req.Users != "" && !isValidJSON(*req.Users) {
http.Error(w, "Invalid JSON in users field", http.StatusBadRequest)
return
}
usersJSON = *req.Users
}
if req.AllUsers != nil {
allUsers = *req.AllUsers
}
if err := api.pluginManager.UpdatePluginUsers(ctx, id, usersJSON, allUsers); err != nil {
log.Error(ctx, "Error updating plugin users", "id", id, err)
http.Error(w, "Error updating plugin users: "+err.Error(), http.StatusInternalServerError)
return
}
}
// Handle enable/disable
if req.Enabled != nil {
if *req.Enabled {

View File

@ -246,6 +246,129 @@ var _ = Describe("Plugin API", func() {
Expect(plugin.Config).To(Equal(""))
})
It("updates users field", func() {
// Configure mock to update the repo when UpdatePluginUsers is called
mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error {
adminCtx := request.WithUser(ctx, adminUser)
p, _ := ds.Plugin(adminCtx).Get(id)
p.Users = usersJSON
p.AllUsers = allUsers
return ds.Plugin(adminCtx).Put(p)
}
body := bytes.NewBufferString(`{"users":"[\"user1\",\"user2\"]"}`)
req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var plugin model.Plugin
err := json.Unmarshal(w.Body.Bytes(), &plugin)
Expect(err).ToNot(HaveOccurred())
Expect(plugin.Users).To(Equal(`["user1","user2"]`))
Expect(mockManager.UpdatePluginUsersCalls).To(HaveLen(1))
Expect(mockManager.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["user1","user2"]`))
})
It("updates allUsers field", func() {
// Configure mock to update the repo when UpdatePluginUsers is called
mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error {
adminCtx := request.WithUser(ctx, adminUser)
p, _ := ds.Plugin(adminCtx).Get(id)
p.Users = usersJSON
p.AllUsers = allUsers
return ds.Plugin(adminCtx).Put(p)
}
body := bytes.NewBufferString(`{"allUsers":true}`)
req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var plugin model.Plugin
err := json.Unmarshal(w.Body.Bytes(), &plugin)
Expect(err).ToNot(HaveOccurred())
Expect(plugin.AllUsers).To(BeTrue())
Expect(mockManager.UpdatePluginUsersCalls).To(HaveLen(1))
Expect(mockManager.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue())
})
It("updates both users and allUsers fields together", func() {
// Configure mock to update the repo when UpdatePluginUsers is called
mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error {
adminCtx := request.WithUser(ctx, adminUser)
p, _ := ds.Plugin(adminCtx).Get(id)
p.Users = usersJSON
p.AllUsers = allUsers
return ds.Plugin(adminCtx).Put(p)
}
body := bytes.NewBufferString(`{"users":"[\"user1\"]","allUsers":false}`)
req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var plugin model.Plugin
err := json.Unmarshal(w.Body.Bytes(), &plugin)
Expect(err).ToNot(HaveOccurred())
Expect(plugin.Users).To(Equal(`["user1"]`))
Expect(plugin.AllUsers).To(BeFalse())
Expect(mockManager.UpdatePluginUsersCalls).To(HaveLen(1))
})
It("rejects invalid JSON in users field", func() {
body := bytes.NewBufferString(`{"users":"not valid json"}`)
req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusBadRequest))
Expect(w.Body.String()).To(ContainSubstring("Invalid JSON"))
})
It("allows empty users", func() {
// Configure mock to update the repo when UpdatePluginUsers is called
mockManager.UpdatePluginUsersFn = func(ctx context.Context, id, usersJSON string, allUsers bool) error {
adminCtx := request.WithUser(ctx, adminUser)
p, _ := ds.Plugin(adminCtx).Get(id)
p.Users = usersJSON
p.AllUsers = allUsers
return ds.Plugin(adminCtx).Put(p)
}
body := bytes.NewBufferString(`{"users":""}`)
req := httptest.NewRequest("PUT", "/plugin/test-plugin-1", body)
req.Header.Set(consts.UIAuthorizationHeader, "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
Expect(w.Code).To(Equal(http.StatusOK))
var plugin model.Plugin
err := json.Unmarshal(w.Body.Bytes(), &plugin)
Expect(err).ToNot(HaveOccurred())
Expect(plugin.Users).To(Equal(""))
})
It("returns 404 for non-existent plugin", func() {
body := bytes.NewBufferString(`{"enabled":true}`)
req := httptest.NewRequest("PUT", "/plugin/non-existent", body)

View File

@ -5,7 +5,7 @@ import (
)
// MockPluginManager is a mock implementation of plugins.PluginManager for testing.
// It implements EnablePlugin, DisablePlugin, and UpdatePluginConfig methods.
// It implements EnablePlugin, DisablePlugin, UpdatePluginConfig, and UpdatePluginUsers methods.
type MockPluginManager struct {
// EnablePluginFn is called when EnablePlugin is invoked. If nil, returns EnableError.
EnablePluginFn func(ctx context.Context, id string) error
@ -13,11 +13,14 @@ type MockPluginManager struct {
DisablePluginFn func(ctx context.Context, id string) error
// UpdatePluginConfigFn is called when UpdatePluginConfig is invoked. If nil, returns ConfigError.
UpdatePluginConfigFn func(ctx context.Context, id, configJSON string) error
// UpdatePluginUsersFn is called when UpdatePluginUsers is invoked. If nil, returns UsersError.
UpdatePluginUsersFn func(ctx context.Context, id, usersJSON string, allUsers bool) error
// Default errors to return when Fn callbacks are not set
EnableError error
DisableError error
ConfigError error
UsersError error
// Track calls for assertions
EnablePluginCalls []string
@ -26,6 +29,11 @@ type MockPluginManager struct {
ID string
ConfigJSON string
}
UpdatePluginUsersCalls []struct {
ID string
UsersJSON string
AllUsers bool
}
}
func (m *MockPluginManager) EnablePlugin(ctx context.Context, id string) error {
@ -54,3 +62,15 @@ func (m *MockPluginManager) UpdatePluginConfig(ctx context.Context, id, configJS
}
return m.ConfigError
}
func (m *MockPluginManager) UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error {
m.UpdatePluginUsersCalls = append(m.UpdatePluginUsersCalls, struct {
ID string
UsersJSON string
AllUsers bool
}{ID: id, UsersJSON: usersJSON, AllUsers: allUsers})
if m.UpdatePluginUsersFn != nil {
return m.UpdatePluginUsersFn(ctx, id, usersJSON, allUsers)
}
return m.UsersError
}

View File

@ -70,6 +70,17 @@ func (u *MockedUserRepo) Get(id string) (*model.User, error) {
return nil, model.ErrNotFound
}
func (u *MockedUserRepo) GetAll(options ...model.QueryOptions) (model.Users, error) {
if u.Error != nil {
return nil, u.Error
}
var users model.Users
for _, usr := range u.Data {
users = append(users, *usr)
}
return users, nil
}
func (u *MockedUserRepo) UpdateLastLoginAt(id string) error {
for _, usr := range u.Data {
if usr.ID == id {

View File

@ -349,13 +349,16 @@
"updatedAt": "Updated",
"createdAt": "Installed",
"configKey": "Key",
"configValue": "Value"
"configValue": "Value",
"allUsers": "Allow all users",
"selectedUsers": "Selected users"
},
"sections": {
"status": "Status",
"info": "Plugin Information",
"configuration": "Configuration",
"manifest": "Manifest"
"manifest": "Manifest",
"usersPermission": "Users Permission"
},
"status": {
"enabled": "Enabled",
@ -365,6 +368,7 @@
"enable": "Enable",
"disable": "Disable",
"disabledDueToError": "Fix the error before enabling",
"disabledUsersRequired": "Select users before enabling",
"addConfig": "Add Configuration"
},
"notifications": {
@ -379,7 +383,11 @@
"messages": {
"configHelp": "Configure the plugin using key-value pairs. Leave empty if the plugin requires no configuration.",
"clickPermissions": "Click a permission for details",
"noConfig": "No configuration set"
"noConfig": "No configuration set",
"allUsersHelp": "When enabled, the plugin will have access to all users, including those created in the future.",
"noUsers": "No users selected",
"permissionReason": "Reason",
"usersRequired": "This plugin requires access to user information. Select which users the plugin can access, or enable 'Allow all users'."
},
"placeholders": {
"configKey": "key",

View File

@ -3,7 +3,6 @@ import {
Card,
CardContent,
Typography,
Box,
TextField as MuiTextField,
Table,
TableBody,
@ -13,18 +12,14 @@ import {
TableRow,
IconButton,
Paper,
Button,
} from '@material-ui/core'
import { MdSave, MdDelete } from 'react-icons/md'
import { MdDelete } from 'react-icons/md'
export const ConfigCard = ({
configPairs,
onConfigPairsChange,
isDirty,
loading,
classes,
translate,
onSave,
}) => {
const handleKeyChange = useCallback(
(index, newKey) => {
@ -145,19 +140,6 @@ export const ConfigCard = ({
</TableBody>
</Table>
</TableContainer>
<Box display="flex" justifyContent="flex-end">
<Button
variant="contained"
color="primary"
startIcon={<MdSave />}
onClick={onSave}
disabled={!isDirty || loading}
className={classes.saveButton}
>
{translate('ra.action.save')}
</Button>
</Box>
</CardContent>
</Card>
)

View File

@ -23,10 +23,23 @@ const useStyles = makeStyles((theme) => ({
},
}))
const useManifest = () => {
const record = useRecordContext()
return useMemo(() => {
if (!record?.manifest) return null
try {
return JSON.parse(record.manifest)
} catch {
return null
}
}, [record?.manifest])
}
const EnabledOrErrorField = () => {
const record = useRecordContext()
const translate = useTranslate()
const classes = useStyles()
const manifest = useManifest()
if (record.lastError) {
return (
@ -41,19 +54,7 @@ const EnabledOrErrorField = () => {
)
}
return <ToggleEnabledSwitch source={'enabled'} />
}
const useManifest = () => {
const record = useRecordContext()
return useMemo(() => {
if (!record?.manifest) return null
try {
return JSON.parse(record.manifest)
} catch {
return null
}
}, [record?.manifest])
return <ToggleEnabledSwitch source={'enabled'} manifest={manifest} />
}
const ManifestField = ({ source }) => {

View File

@ -10,7 +10,8 @@ import {
Title as RaTitle,
Loading,
} from 'react-admin'
import { Box, useMediaQuery } from '@material-ui/core'
import { Box, useMediaQuery, Button } from '@material-ui/core'
import { MdSave } from 'react-icons/md'
import Alert from '@material-ui/lab/Alert'
import { Title, useResourceRefresh } from '../common'
import { usePluginShowStyles } from './styles.js'
@ -19,6 +20,7 @@ import { StatusCard } from './StatusCard'
import { InfoCard } from './InfoCard'
import { ManifestSection } from './ManifestSection'
import { ConfigCard } from './ConfigCard'
import { UsersPermissionCard } from './UsersPermissionCard'
// Main show layout component
const PluginShowLayout = () => {
@ -34,6 +36,12 @@ const PluginShowLayout = () => {
const [isDirty, setIsDirty] = useState(false)
const [lastRecordConfig, setLastRecordConfig] = useState(null)
// Users permission state
const [selectedUsers, setSelectedUsers] = useState([])
const [allUsers, setAllUsers] = useState(false)
const [lastRecordUsers, setLastRecordUsers] = useState(null)
const [lastRecordAllUsers, setLastRecordAllUsers] = useState(null)
// Convert JSON config to key-value pairs
const jsonToPairs = useCallback((jsonString) => {
if (!jsonString || jsonString.trim() === '') return []
@ -70,15 +78,42 @@ const PluginShowLayout = () => {
}
}, [record, lastRecordConfig, isDirty, jsonToPairs])
const handleConfigPairsChange = useCallback(
(newPairs) => {
setConfigPairs(newPairs)
const newJson = pairsToJson(newPairs)
const originalJson = record?.config || ''
setIsDirty(newJson !== originalJson)
},
[record?.config, pairsToJson],
)
// Initialize/update users permission state when record loads or changes
React.useEffect(() => {
if (record && !isDirty) {
const recordUsers = record.users || ''
const recordAllUsers = record.allUsers || false
if (
recordUsers !== lastRecordUsers ||
recordAllUsers !== lastRecordAllUsers
) {
try {
setSelectedUsers(recordUsers ? JSON.parse(recordUsers) : [])
} catch {
setSelectedUsers([])
}
setAllUsers(recordAllUsers)
setLastRecordUsers(recordUsers)
setLastRecordAllUsers(recordAllUsers)
}
}
}, [record, lastRecordUsers, lastRecordAllUsers, isDirty])
const handleConfigPairsChange = useCallback((newPairs) => {
setConfigPairs(newPairs)
setIsDirty(true)
}, [])
const handleSelectedUsersChange = useCallback((newSelectedUsers) => {
setSelectedUsers(newSelectedUsers)
setIsDirty(true)
}, [])
const handleAllUsersChange = useCallback((newAllUsers) => {
setAllUsers(newAllUsers)
setIsDirty(true)
}, [])
const [updatePlugin, { loading }] = useUpdate(
'plugin',
@ -91,6 +126,8 @@ const PluginShowLayout = () => {
refresh()
setIsDirty(false)
setLastRecordConfig(null) // Reset to reinitialize from server
setLastRecordUsers(null)
setLastRecordAllUsers(null)
notify('resources.plugin.notifications.updated', 'info')
},
onFailure: (err) => {
@ -105,8 +142,17 @@ const PluginShowLayout = () => {
const handleSaveConfig = useCallback(() => {
if (!record) return
const config = pairsToJson(configPairs)
updatePlugin('plugin', record.id, { config }, record)
}, [updatePlugin, record, configPairs, pairsToJson])
const data = { config }
// Include users data if users permission is present
const manifest = record.manifest ? JSON.parse(record.manifest) : null
if (manifest?.permissions?.users) {
data.users = JSON.stringify(selectedUsers)
data.allUsers = allUsers
}
updatePlugin('plugin', record.id, data, record)
}, [updatePlugin, record, configPairs, pairsToJson, selectedUsers, allUsers])
// Parse manifest
const { manifest, manifestJson } = useMemo(() => {
@ -148,7 +194,11 @@ const PluginShowLayout = () => {
<Box className={classes.root}>
<ErrorSection error={record.lastError} translate={translate} />
<StatusCard classes={classes} translate={translate} />
<StatusCard
classes={classes}
translate={translate}
manifest={manifest}
/>
<InfoCard
record={record}
@ -167,12 +217,31 @@ const PluginShowLayout = () => {
<ConfigCard
configPairs={configPairs}
onConfigPairsChange={handleConfigPairsChange}
isDirty={isDirty}
loading={loading}
classes={classes}
translate={translate}
onSave={handleSaveConfig}
/>
<UsersPermissionCard
manifest={manifest}
classes={classes}
selectedUsers={selectedUsers}
allUsers={allUsers}
onSelectedUsersChange={handleSelectedUsersChange}
onAllUsersChange={handleAllUsersChange}
/>
<Box display="flex" justifyContent="flex-end">
<Button
variant="contained"
color="primary"
startIcon={<MdSave />}
onClick={handleSaveConfig}
disabled={!isDirty || loading}
className={classes.saveButton}
>
{translate('ra.action.save')}
</Button>
</Box>
</Box>
</>
)

View File

@ -1,16 +1,23 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Card, CardContent, Typography } from '@material-ui/core'
import ToggleEnabledSwitch from './ToggleEnabledSwitch'
export const StatusCard = ({ classes, translate }) => {
export const StatusCard = ({ classes, translate, manifest }) => {
return (
<Card className={classes.section}>
<CardContent>
<Typography variant="h6" className={classes.sectionTitle}>
{translate('resources.plugin.sections.status')}
</Typography>
<ToggleEnabledSwitch showLabel size="medium" />
<ToggleEnabledSwitch showLabel size="medium" manifest={manifest} />
</CardContent>
</Card>
)
}
StatusCard.propTypes = {
classes: PropTypes.object.isRequired,
translate: PropTypes.func.isRequired,
manifest: PropTypes.object,
}

View File

@ -10,6 +10,7 @@ import {
import Switch from '@material-ui/core/Switch'
import { makeStyles } from '@material-ui/core/styles'
import { Tooltip, FormControlLabel } from '@material-ui/core'
import PropTypes from 'prop-types'
const useStyles = makeStyles((theme) => ({
enabledSwitch: {
@ -30,8 +31,13 @@ const useStyles = makeStyles((theme) => ({
* @param {Object} props
* @param {boolean} [props.showLabel=false] - Whether to show the enable/disable label
* @param {string} [props.size='small'] - Switch size ('small' or 'medium')
* @param {Object} [props.manifest=null] - Parsed manifest object for permission checking
*/
const ToggleEnabledSwitch = ({ showLabel = false, size = 'small' }) => {
const ToggleEnabledSwitch = ({
showLabel = false,
size = 'small',
manifest = null,
}) => {
const resource = useResourceContext()
const record = useRecordContext()
const notify = useNotify()
@ -73,12 +79,31 @@ const ToggleEnabledSwitch = ({ showLabel = false, size = 'small' }) => {
)
const hasError = !!record?.lastError
const isDisabled = loading || hasError
// Check if users permission is required but not configured
const usersPermissionRequired = useMemo(() => {
if (!manifest?.permissions?.users) return false
if (record?.allUsers) return false
// Check if users array is empty or not set
if (!record?.users) return true
try {
const users = JSON.parse(record.users)
return users.length === 0
} catch {
return true
}
}, [manifest, record?.allUsers, record?.users])
const isDisabled =
loading || hasError || (usersPermissionRequired && !record?.enabled)
const tooltipTitle = useMemo(() => {
if (hasError) {
return translate('resources.plugin.actions.disabledDueToError')
}
if (usersPermissionRequired && !record?.enabled) {
return translate('resources.plugin.actions.disabledUsersRequired')
}
if (!showLabel) {
return translate(
record?.enabled
@ -87,7 +112,7 @@ const ToggleEnabledSwitch = ({ showLabel = false, size = 'small' }) => {
)
}
return ''
}, [hasError, showLabel, record?.enabled, translate])
}, [hasError, usersPermissionRequired, showLabel, record?.enabled, translate])
const switchElement = (
<Switch
@ -126,4 +151,10 @@ const ToggleEnabledSwitch = ({ showLabel = false, size = 'small' }) => {
)
}
ToggleEnabledSwitch.propTypes = {
showLabel: PropTypes.bool,
size: PropTypes.oneOf(['small', 'medium']),
manifest: PropTypes.object,
}
export default ToggleEnabledSwitch

View File

@ -0,0 +1,168 @@
import React from 'react'
import {
Card,
CardContent,
Typography,
Box,
FormControlLabel,
Switch,
List,
ListItem,
ListItemIcon,
ListItemText,
Checkbox,
} from '@material-ui/core'
import CheckBoxIcon from '@material-ui/icons/CheckBox'
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'
import Alert from '@material-ui/lab/Alert'
import { useGetList, useTranslate } from 'react-admin'
import PropTypes from 'prop-types'
export const UsersPermissionCard = ({
manifest,
classes,
selectedUsers,
allUsers,
onSelectedUsersChange,
onAllUsersChange,
}) => {
const translate = useTranslate()
// Fetch all users
const { data: usersData, loading: usersLoading } = useGetList('user', {
pagination: { page: 1, perPage: 1000 },
sort: { field: 'userName', order: 'ASC' },
})
const users = React.useMemo(() => {
return usersData ? Object.values(usersData) : []
}, [usersData])
const handleToggleUser = React.useCallback(
(userId) => {
const newSelected = selectedUsers.includes(userId)
? selectedUsers.filter((id) => id !== userId)
: [...selectedUsers, userId]
onSelectedUsersChange(newSelected)
},
[selectedUsers, onSelectedUsersChange],
)
const handleAllUsersToggle = React.useCallback(
(event) => {
onAllUsersChange(event.target.checked)
},
[onAllUsersChange],
)
// Get permission reason from manifest
const usersPermission = manifest?.permissions?.users
const reason = usersPermission?.reason
// Check if permission is required but not configured
const isConfigurationRequired =
usersPermission && !allUsers && selectedUsers.length === 0
if (!usersPermission) {
return null
}
return (
<Card className={classes.section}>
<CardContent>
<Typography variant="h6" className={classes.sectionTitle}>
{translate('resources.plugin.sections.usersPermission')}
</Typography>
{reason && (
<Typography variant="body2" color="textSecondary" gutterBottom>
{translate('resources.plugin.messages.permissionReason')}: {reason}
</Typography>
)}
{isConfigurationRequired && (
<Box mb={2}>
<Alert severity="warning">
{translate('resources.plugin.messages.usersRequired')}
</Alert>
</Box>
)}
<Box mb={2}>
<FormControlLabel
control={
<Switch
checked={allUsers}
onChange={handleAllUsersToggle}
color="primary"
/>
}
label={translate('resources.plugin.fields.allUsers')}
/>
<Typography variant="body2" color="textSecondary">
{translate('resources.plugin.messages.allUsersHelp')}
</Typography>
</Box>
{!allUsers && (
<Box className={classes.usersList}>
<Typography variant="subtitle2" gutterBottom>
{translate('resources.plugin.fields.selectedUsers')}
</Typography>
{usersLoading ? (
<Typography variant="body2" color="textSecondary">
{translate('ra.message.loading')}
</Typography>
) : users.length === 0 ? (
<Typography variant="body2" color="textSecondary">
{translate('resources.plugin.messages.noUsers')}
</Typography>
) : (
<List
dense
style={{
maxHeight: 200,
overflow: 'auto',
border: '1px solid rgba(0, 0, 0, 0.12)',
borderRadius: 4,
}}
>
{users.map((user) => (
<ListItem
key={user.id}
button
onClick={() => handleToggleUser(user.id)}
dense
>
<ListItemIcon>
<Checkbox
icon={<CheckBoxOutlineBlankIcon fontSize="small" />}
checkedIcon={<CheckBoxIcon fontSize="small" />}
checked={selectedUsers.includes(user.id)}
tabIndex={-1}
disableRipple
/>
</ListItemIcon>
<ListItemText
primary={user.name || user.userName}
secondary={user.name ? user.userName : null}
/>
</ListItem>
))}
</List>
)}
</Box>
)}
</CardContent>
</Card>
)
}
UsersPermissionCard.propTypes = {
manifest: PropTypes.object,
classes: PropTypes.object.isRequired,
selectedUsers: PropTypes.array.isRequired,
allUsers: PropTypes.bool.isRequired,
onSelectedUsersChange: PropTypes.func.isRequired,
onAllUsersChange: PropTypes.func.isRequired,
}