mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat: add users permission management to plugin system
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
parent
6ab277bf9e
commit
08280eaea8
@ -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,
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
28
plugins/host/users.go
Normal 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
72
plugins/host/users_gen.go
Normal 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
52
plugins/host_users.go
Normal 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
485
plugins/host_users_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
66
plugins/pdk/go/host/nd_host_users.go
Normal file
66
plugins/pdk/go/host/nd_host_users.go
Normal 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
|
||||
}
|
||||
45
plugins/pdk/go/host/nd_host_users_stub.go
Normal file
45
plugins/pdk/go/host/nd_host_users_stub.go
Normal 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()
|
||||
}
|
||||
@ -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.
|
||||
|
||||
50
plugins/pdk/python/host/nd_host_users.py
Normal file
50
plugins/pdk/python/host/nd_host_users.py
Normal 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)
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
54
plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs
Normal file
54
plugins/pdk/rust/nd-pdk-host/src/nd_host_users.rs
Normal 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)
|
||||
}
|
||||
5
plugins/testdata/Makefile
vendored
5
plugins/testdata/Makefile
vendored
@ -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
16
plugins/testdata/test-users/go.mod
vendored
Normal 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
14
plugins/testdata/test-users/go.sum
vendored
Normal 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
51
plugins/testdata/test-users/main.go
vendored
Normal 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() {}
|
||||
11
plugins/testdata/test-users/manifest.json
vendored
Normal file
11
plugins/testdata/test-users/manifest.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
168
ui/src/plugin/UsersPermissionCard.jsx
Normal file
168
ui/src/plugin/UsersPermissionCard.jsx
Normal 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,
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user