diff --git a/db/migrations/20250415111500_add_api_key_table.go b/db/migrations/20250415111500_add_api_key_table.go deleted file mode 100644 index a931d1827..000000000 --- a/db/migrations/20250415111500_add_api_key_table.go +++ /dev/null @@ -1,39 +0,0 @@ -package migrations - -import ( - "context" - "database/sql" - - "github.com/pressly/goose/v3" -) - -func init() { - goose.AddMigrationContext(upAddApiKeyTable, downAddApiKeyTable) -} - -func upAddApiKeyTable(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` - create table if not exists api_key ( - id text not null primary key, - player_id text not null, - name text not null, - key text not null unique, - created_at datetime not null, - - foreign key (player_id) - references player(id) - on delete cascade - ); - - create index if not exists api_key_key on api_key(key); - create index if not exists api_key_player_id on api_key(player_id); -`) - return err -} - -func downAddApiKeyTable(ctx context.Context, tx *sql.Tx) error { - _, err := tx.ExecContext(ctx, ` - drop table api_key; -`) - return err -} diff --git a/db/migrations/20250730104020_add_api_key_to_player_table.go b/db/migrations/20250730104020_add_api_key_to_player_table.go new file mode 100644 index 000000000..34b887d54 --- /dev/null +++ b/db/migrations/20250730104020_add_api_key_to_player_table.go @@ -0,0 +1,34 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddAPIKeyToPlayer, downDropAPIKeyFromPlayer) +} + +func upAddAPIKeyToPlayer(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +-- Add nullable api_key column to player table +ALTER TABLE player ADD COLUMN api_key VARCHAR(255); + +-- Add index on api_key for faster lookups +CREATE INDEX IF NOT EXISTS player_api_key ON player(api_key); +`) + return err +} + +func downDropAPIKeyFromPlayer(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +-- Drop the index first +DROP INDEX IF EXISTS player_api_key; + +-- Then drop the column +ALTER TABLE player DROP COLUMN api_key; +`) + return err +} diff --git a/model/api_key.go b/model/api_key.go deleted file mode 100644 index 63486c2fc..000000000 --- a/model/api_key.go +++ /dev/null @@ -1,26 +0,0 @@ -package model - -import ( - "github.com/deluan/rest" - "time" -) - -type APIKey struct { - ID string `structs:"id" json:"id"` - PlayerID string `structs:"player_id" json:"playerId"` - Name string `structs:"name" json:"name"` - Key string `structs:"key" json:"key"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` -} - -type APIKeys []APIKey - -type APIKeyRepository interface { - ResourceRepository - rest.Persistable - CountAll(...QueryOptions) (int64, error) - Get(id string) (*APIKey, error) - GetAll(options ...QueryOptions) (APIKeys, error) - FindByKey(key string) (*APIKey, error) - RefreshKey(id string) (string, error) -} diff --git a/model/datastore.go b/model/datastore.go index ebd99e25b..4290e2134 100644 --- a/model/datastore.go +++ b/model/datastore.go @@ -38,7 +38,6 @@ type DataStore interface { User(ctx context.Context) UserRepository UserProps(ctx context.Context) UserPropsRepository ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository - APIKey(ctx context.Context) APIKeyRepository Resource(ctx context.Context, model interface{}) ResourceRepository diff --git a/model/player.go b/model/player.go index 39ea99d1a..f896ebd25 100644 --- a/model/player.go +++ b/model/player.go @@ -18,6 +18,7 @@ type Player struct { MaxBitRate int `structs:"max_bit_rate" json:"maxBitRate"` ReportRealPath bool `structs:"report_real_path" json:"reportRealPath"` ScrobbleEnabled bool `structs:"scrobble_enabled" json:"scrobbleEnabled"` + APIKey string `structs:"api_key" json:"apiKey" db:"api_key"` } type Players []Player @@ -28,4 +29,6 @@ type PlayerRepository interface { Put(p *Player) error CountAll(...QueryOptions) (int64, error) CountByClient(...QueryOptions) (map[string]int64, error) + FindByAPIKey(key string) (*Player, error) + GenerateAPIKey(playerId string) (string, error) } diff --git a/model/user.go b/model/user.go index 042ac8d60..aabedc096 100644 --- a/model/user.go +++ b/model/user.go @@ -56,6 +56,4 @@ type UserRepository interface { // Library association methods GetUserLibraries(userID string) (Libraries, error) SetUserLibraries(userID string, libraryIDs []int) error - // FindByAPIKey finds a user by the provided API key - FindByAPIKey(key string) (*User, error) } diff --git a/persistence/api_key_repository.go b/persistence/api_key_repository.go deleted file mode 100644 index be8e9d715..000000000 --- a/persistence/api_key_repository.go +++ /dev/null @@ -1,203 +0,0 @@ -package persistence - -import ( - "context" - "time" - - "github.com/deluan/rest" - - . "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "github.com/pocketbase/dbx" -) - -type apiKeyRepository struct { - sqlRepository -} - -func NewAPIKeyRepository(ctx context.Context, db dbx.Builder) model.APIKeyRepository { - r := &apiKeyRepository{} - r.ctx = ctx - r.db = db - r.registerModel(&model.APIKey{}, nil) - return r -} - -func (r *apiKeyRepository) userFilter() Sqlizer { - user := loggedUser(r.ctx) - if user.IsAdmin { - return And{} - } - return Eq{"p.user_id": user.ID} -} - -func (r *apiKeyRepository) CountAll(options ...model.QueryOptions) (int64, error) { - sq := r.selectAPIKey(options...).Where(r.userFilter()) - return r.count(sq, options...) -} - -func (r *apiKeyRepository) Get(id string) (*model.APIKey, error) { - sel := r.selectAPIKey().Where(And{Eq{"ak.id": id}}) - var res model.APIKey - err := r.queryOne(sel, &res) - if err != nil { - return nil, err - } - return &res, err -} - -func (r *apiKeyRepository) selectAPIKey(options ...model.QueryOptions) SelectBuilder { - return r.newSelect(options...). - From("api_key ak"). - LeftJoin("player p ON ak.player_id = p.id"). - Columns("ak.*") -} - -func (r *apiKeyRepository) GetAll(options ...model.QueryOptions) (model.APIKeys, error) { - sel := r.selectAPIKey().Where(r.userFilter()) - res := model.APIKeys{} - err := r.queryAll(sel, &res) - if err != nil { - return nil, err - } - return res, err -} - -func (r *apiKeyRepository) Count(options ...rest.QueryOptions) (int64, error) { - return r.CountAll(r.parseRestOptions(r.ctx, options...)) -} - -func (r *apiKeyRepository) Read(id string) (interface{}, error) { - apiKey, err := r.Get(id) - if err != nil { - return nil, err - } - if err := r.VerifyPlayerAccess(apiKey.PlayerID); err != nil { - return nil, err - } - return apiKey, err -} - -func (r *apiKeyRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { - return r.GetAll(r.parseRestOptions(r.ctx, options...)) -} - -func (r *apiKeyRepository) EntityName() string { - return "apikey" -} - -func (r *apiKeyRepository) NewInstance() interface{} { - return &model.APIKey{} -} - -func (r *apiKeyRepository) Save(entity interface{}) (string, error) { - ak := entity.(*model.APIKey) - if err := r.VerifyPlayerAccess(ak.PlayerID); err != nil { - return "", err - } - - if ak.ID == "" { - ak.ID = id.NewRandom() - } - ak.Key = generateAPIKey() - ak.CreatedAt = time.Now() - values, err := toSQLArgs(*ak) - if err != nil { - return "", err - } - - insert := Insert(r.tableName).SetMap(values) - _, err = r.executeSQL(insert) - return ak.ID, err -} - -func (r *apiKeyRepository) Update(id string, entity interface{}, _ ...string) error { - ak := entity.(*model.APIKey) - current, err := r.Get(id) - if err != nil { - return err - } - - if err := r.VerifyPlayerAccess(current.PlayerID); err != nil { - return err - } - - update := Update(r.tableName). - Set("name", ak.Name). - Where(Eq{"id": id}) - _, err = r.executeSQL(update) - return err -} - -func (r *apiKeyRepository) Delete(id string) error { - apiKey, err := r.Get(id) - if err != nil { - return err - } - - if err := r.VerifyPlayerAccess(apiKey.PlayerID); err != nil { - return err - } - - return r.delete(Eq{"id": id}) -} - -func (r *apiKeyRepository) FindByKey(key string) (*model.APIKey, error) { - sel := r.selectAPIKey().Where(And{Eq{"ak.key": key}}) - var res model.APIKey - err := r.queryOne(sel, &res) - if err != nil { - return nil, err - } - return &res, err -} - -func (r *apiKeyRepository) RefreshKey(id string) (string, error) { - apiKey, err := r.Get(id) - if err != nil { - return "", err - } - if err := r.VerifyPlayerAccess(apiKey.PlayerID); err != nil { - return "", err - } - - newKey := generateAPIKey() - update := Update(r.tableName). - Set("key", newKey). - Where(Eq{"id": id}) - _, err = r.executeSQL(update) - - if err != nil { - return "", err - } - - return newKey, nil -} - -func (r *apiKeyRepository) VerifyPlayerAccess(playerID string) error { - if playerID == "" { - return model.ErrNotFound - } - - playerRepo := NewPlayerRepository(r.ctx, r.db) - player, err := playerRepo.Get(playerID) - if err != nil { - return err - } - - user := loggedUser(r.ctx) - if !user.IsAdmin && player.UserId != user.ID { - return rest.ErrPermissionDenied - } - - return nil -} - -func generateAPIKey() string { - return "nav_" + id.NewRandom() -} - -var _ model.APIKeyRepository = (*apiKeyRepository)(nil) -var _ rest.Repository = (*apiKeyRepository)(nil) -var _ rest.Persistable = (*apiKeyRepository)(nil) diff --git a/persistence/api_key_repository_test.go b/persistence/api_key_repository_test.go deleted file mode 100644 index d7df1504b..000000000 --- a/persistence/api_key_repository_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package persistence - -import ( - "context" - "github.com/deluan/rest" - "github.com/navidrome/navidrome/log" - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/request" - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" -) - -var _ = Describe("APIKeyRepository", func() { - var repo model.APIKeyRepository - var playerRepo model.PlayerRepository - var database *dbx.DB - - var ( - adminPlayer = model.Player{ID: "1", Name: "NavidromeUI [Firefox/Linux]", UserAgent: "Firefox/Linux", UserId: adminUser.ID, Username: adminUser.UserName, Client: "NavidromeUI", IP: "127.0.0.1", ReportRealPath: true, ScrobbleEnabled: true} - regularPlayer = model.Player{ID: "3", Name: "NavidromeUI [Safari/macOS]", UserAgent: "Safari/macOS", UserId: regularUser.ID, Username: regularUser.UserName, Client: "NavidromeUI", ReportRealPath: true, ScrobbleEnabled: false} - - players = model.Players{adminPlayer, regularPlayer} - ) - - BeforeEach(func() { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, adminUser) - database = GetDBXBuilder() - - playerRepo = NewPlayerRepository(ctx, database) - for idx := range players { - err := playerRepo.Put(&players[idx]) - Expect(err).To(BeNil()) - } - repo = NewAPIKeyRepository(ctx, database) - }) - - Describe("FindByKey", func() { - It("returns the API key with matching key", func() { - apiKey := &model.APIKey{ - PlayerID: adminPlayer.ID, - Name: "Unique API Key", - } - apiKeyId, err := repo.Save(apiKey) - Expect(err).ToNot(HaveOccurred()) - apiKey, err = repo.Get(apiKeyId) - Expect(err).ToNot(HaveOccurred()) - - result, err := repo.FindByKey(apiKey.Key) - Expect(err).ToNot(HaveOccurred()) - Expect(result.ID).To(Equal(apiKey.ID)) - Expect(result.Key).To(Equal(apiKey.Key)) - }) - - It("returns error when key not found", func() { - _, err := repo.FindByKey("non-existent-key") - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("Save", func() { - It("creates a new API key with a generated key", func() { - apiKey := &model.APIKey{ - Name: "Test API Key Save", - PlayerID: adminPlayer.ID, - } - - id, err := repo.Save(apiKey) - - Expect(err).ToNot(HaveOccurred()) - Expect(id).ToNot(BeEmpty()) - Expect(apiKey.Key).To(HavePrefix("nav_")) - Expect(apiKey.PlayerID).To(Equal(adminPlayer.ID)) - Expect(apiKey.Name).To(Equal("Test API Key Save")) - }) - }) - - Describe("Update", func() { - It("only updates the name field", func() { - apiKey := &model.APIKey{ - PlayerID: adminPlayer.ID, - Name: "Original Name", - } - - _, err := repo.Save(apiKey) - Expect(err).ToNot(HaveOccurred()) - - updateKey := &model.APIKey{ - Name: "Updated Name", - PlayerID: regularPlayer.ID, - } - - err = repo.Update(apiKey.ID, updateKey) - Expect(err).ToNot(HaveOccurred()) - - result, err := repo.Get(apiKey.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(result.Name).To(Equal("Updated Name")) - Expect(result.Key).To(Equal(apiKey.Key)) - Expect(result.PlayerID).To(Equal(adminPlayer.ID)) - }) - - It("returns error when attempting to update non-existent key", func() { - err := repo.Update("non-existent-id", &model.APIKey{Name: "Updated Name"}) - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("Delete", func() { - It("deletes an existing API key", func() { - apiKey := &model.APIKey{ - PlayerID: adminPlayer.ID, - Name: "API Key to Delete", - } - - _, err := repo.Save(apiKey) - Expect(err).ToNot(HaveOccurred()) - - err = repo.Delete(apiKey.ID) - Expect(err).ToNot(HaveOccurred()) - - _, err = repo.Get(apiKey.ID) - Expect(err).To(HaveOccurred()) - }) - }) - - Describe("RefreshKey", func() { - It("generates a new key for an existing API key", func() { - apiKey := &model.APIKey{ - PlayerID: adminPlayer.ID, - Name: "Test Refresh", - } - _, err := repo.Save(apiKey) - Expect(err).ToNot(HaveOccurred()) - - originalKey := apiKey.Key - - newKey, err := repo.RefreshKey(apiKey.ID) - - Expect(err).ToNot(HaveOccurred()) - Expect(newKey).ToNot(BeEmpty()) - Expect(newKey).ToNot(Equal(originalKey)) - Expect(newKey).To(HavePrefix("nav_")) - - refreshed, err := repo.Get(apiKey.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(refreshed.Key).To(Equal(newKey)) - }) - - It("returns an error for non-existent API key", func() { - _, err := repo.RefreshKey("non-existent-id") - Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(rest.ErrNotFound)) - }) - - It("enforces user permissions", func() { - apiKey := &model.APIKey{ - PlayerID: adminPlayer.ID, - Name: "Test Permission", - } - _, err := repo.Save(apiKey) - Expect(err).ToNot(HaveOccurred()) - - nonAdminCtx := log.NewContext(context.TODO()) - nonAdminCtx = request.WithUser(nonAdminCtx, regularUser) - nonAdminRepo := NewAPIKeyRepository(nonAdminCtx, database) - - _, err = nonAdminRepo.RefreshKey(apiKey.ID) - Expect(err).To(HaveOccurred()) - Expect(err).To(MatchError(rest.ErrPermissionDenied)) - }) - }) - - Describe("User permissions", func() { - var nonAdminCtx context.Context - var nonAdminRepo model.APIKeyRepository - var adminKey model.APIKey - - BeforeEach(func() { - nonAdminCtx = log.NewContext(context.TODO()) - nonAdminCtx = request.WithUser(nonAdminCtx, regularUser) - nonAdminRepo = NewAPIKeyRepository(nonAdminCtx, database) - - cleanupKeys := func(key string) { - foundKey, err := repo.FindByKey(key) - if err == nil { - _ = repo.Delete(foundKey.ID) - } - } - cleanupKeys("admin-key") - cleanupKeys("user-key") - - tmpAdminKey := &model.APIKey{ - PlayerID: adminPlayer.ID, - Name: "Admin's API Key", - } - _, err := repo.Save(tmpAdminKey) - Expect(err).ToNot(HaveOccurred()) - adminKey = *tmpAdminKey - - userKey := &model.APIKey{ - PlayerID: regularPlayer.ID, - Name: "User's API Key", - } - _, err = repo.Save(userKey) - Expect(err).ToNot(HaveOccurred()) - }) - - It("non-admin users can only see their own API keys", func() { - results, err := nonAdminRepo.GetAll() - Expect(err).ToNot(HaveOccurred()) - - for _, key := range results { - Expect(key.PlayerID).To(Equal(regularPlayer.ID)) - } - }) - - It("admin users can see all API keys", func() { - results, err := repo.GetAll() - Expect(err).ToNot(HaveOccurred()) - - userIds := make(map[string]bool) - for _, key := range results { - userIds[key.PlayerID] = true - } - - Expect(userIds).To(HaveKey(adminPlayer.ID)) - Expect(userIds).To(HaveKey(regularPlayer.ID)) - }) - - It("a user cannot view/delete/update another user's key", func() { - result, err := nonAdminRepo.Read(adminKey.ID) - Expect(result).To(BeNil()) - Expect(err).To(MatchError(rest.ErrPermissionDenied)) - - updatedKey := &model.APIKey{Name: "new admin key name"} - err = nonAdminRepo.Update(adminKey.ID, updatedKey) - Expect(err).To(MatchError(rest.ErrPermissionDenied)) - - err = nonAdminRepo.Delete(adminKey.ID) - Expect(err).To(MatchError(rest.ErrPermissionDenied)) - }) - }) -}) diff --git a/persistence/persistence.go b/persistence/persistence.go index 76f89e7ec..ac607f85f 100644 --- a/persistence/persistence.go +++ b/persistence/persistence.go @@ -89,10 +89,6 @@ func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepos return NewScrobbleBufferRepository(ctx, s.getDBXBuilder()) } -func (s *SQLStore) APIKey(ctx context.Context) model.APIKeyRepository { - return NewAPIKeyRepository(ctx, s.getDBXBuilder()) -} - func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository { switch m.(type) { case model.User: @@ -117,8 +113,6 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe return s.Share(ctx).(model.ResourceRepository) case model.Tag: return s.Tag(ctx).(model.ResourceRepository) - case model.APIKey: - return s.APIKey(ctx).(model.ResourceRepository) } log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name()) return nil diff --git a/persistence/player_repository.go b/persistence/player_repository.go index 73c820753..00c5d1fee 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -7,6 +7,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" "github.com/pocketbase/dbx" ) @@ -57,6 +58,19 @@ func (r *playerRepository) FindMatch(userId, client, userAgent string) (*model.P return &res, err } +func (r *playerRepository) FindByAPIKey(key string) (*model.Player, error) { + if key == "" { + return nil, model.ErrNotFound + } + sel := r.selectPlayer().Where(Eq{"api_key": key}) + var res model.Player + err := r.queryOne(sel, &res) + if err != nil { + return nil, err + } + return &res, nil +} + func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder { s := r.selectPlayer(options...) return s.Where(r.addRestriction()) @@ -130,25 +144,37 @@ func (r *playerRepository) isPermitted(p *model.Player) bool { return u.IsAdmin || p.UserId == u.ID } +func (r *playerRepository) preparePlayerForSave(p *model.Player) *model.Player { + playerCopy := *p + if playerCopy.UserId == "" { + user := loggedUser(r.ctx) + playerCopy.UserId = user.ID + } + playerCopy.APIKey = "" + return &playerCopy +} + func (r *playerRepository) Save(entity interface{}) (string, error) { t := entity.(*model.Player) - if !r.isPermitted(t) { + playerToSave := r.preparePlayerForSave(t) + if !r.isPermitted(playerToSave) { return "", rest.ErrPermissionDenied } - id, err := r.put(t.ID, t) + playerId, err := r.put(playerToSave.ID, playerToSave) if errors.Is(err, model.ErrNotFound) { return "", rest.ErrNotFound } - return id, err + return playerId, err } func (r *playerRepository) Update(id string, entity interface{}, cols ...string) error { t := entity.(*model.Player) t.ID = id - if !r.isPermitted(t) { + playerToUpdate := r.preparePlayerForSave(t) + if !r.isPermitted(playerToUpdate) { return rest.ErrPermissionDenied } - _, err := r.put(id, t, cols...) + _, err := r.put(id, playerToUpdate, cols...) if errors.Is(err, model.ErrNotFound) { return rest.ErrNotFound } @@ -164,6 +190,33 @@ func (r *playerRepository) Delete(id string) error { return err } +// GenerateAPIKey generates a new API key for the specified player +func (r *playerRepository) GenerateAPIKey(playerID string) (string, error) { + player, err := r.Get(playerID) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } + if err != nil { + return "", err + } + + if !r.isPermitted(player) { + return "", rest.ErrPermissionDenied + } + + player.APIKey = generateAPIKey() + err = r.Put(player) + if err != nil { + return "", err + } + return player.APIKey, nil +} + +// generateAPIKey creates a new random API key +func generateAPIKey() string { + return "nav_" + id.NewRandom() +} + var _ model.PlayerRepository = (*playerRepository)(nil) var _ rest.Repository = (*playerRepository)(nil) var _ rest.Persistable = (*playerRepository)(nil) diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index f6c669493..f7e71f6b4 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -13,8 +13,12 @@ import ( ) var _ = Describe("PlayerRepository", func() { - var adminRepo *playerRepository - var database *dbx.DB + var ( + adminRepo *playerRepository + database *dbx.DB + adminCtx context.Context + regularCtx context.Context + ) var ( adminPlayer1 = model.Player{ID: "1", Name: "NavidromeUI [Firefox/Linux]", UserAgent: "Firefox/Linux", UserId: adminUser.ID, Username: adminUser.UserName, Client: "NavidromeUI", IP: "127.0.0.1", ReportRealPath: true, ScrobbleEnabled: true} @@ -25,11 +29,14 @@ var _ = Describe("PlayerRepository", func() { ) BeforeEach(func() { - ctx := log.NewContext(context.TODO()) - ctx = request.WithUser(ctx, adminUser) + adminCtx = log.NewContext(context.TODO()) + adminCtx = request.WithUser(adminCtx, adminUser) + + regularCtx = log.NewContext(context.TODO()) + regularCtx = request.WithUser(regularCtx, regularUser) database = GetDBXBuilder() - adminRepo = NewPlayerRepository(ctx, database).(*playerRepository) + adminRepo = NewPlayerRepository(adminCtx, database).(*playerRepository) for idx := range players { err := adminRepo.Put(&players[idx]) @@ -86,6 +93,28 @@ var _ = Describe("PlayerRepository", func() { }) }) + Describe("Save", func() { + var repo *playerRepository + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, regularUser) + repo = NewPlayerRepository(ctx, database).(*playerRepository) + }) + It("fails if different user id is set", func() { + newPlayer := model.Player{ + Name: "Test Player", + Client: "TestClient", + UserAgent: "TestAgent", + UserId: adminUser.ID, + } + id, err := repo.Save(&newPlayer) + Expect(err).To(Equal(rest.ErrPermissionDenied)) + + _, err = repo.Get(id) + Expect(err).To(Equal(rest.ErrNotFound)) + }) + }) + DescribeTableSubtree("per context", func(admin bool, players model.Players, userPlayer model.Player, otherPlayer model.Player) { var repo *playerRepository @@ -171,41 +200,37 @@ var _ = Describe("PlayerRepository", func() { }) Describe("Save", func() { - DescribeTable("item type", func(player model.Player) { - clone := player - clone.ID = "" - clone.IP = "192.168.1.1" - id, err := repo.Save(&clone) - - if clone.UserId == "" { - Expect(err).To(HaveOccurred()) - } else if !admin && player.Username == adminPlayer1.Username { - Expect(err).To(Equal(rest.ErrPermissionDenied)) - clone.UserId = "" - } else { - Expect(err).To(BeNil()) - Expect(id).ToNot(BeEmpty()) + It("excludes API key from being saved", func() { + newPlayer := model.Player{ + Name: "Test Player", + Client: "TestClient", + UserAgent: "TestAgent", + APIKey: "should-not-be-saved", } - count, err := repo.Count() + id, err := repo.Save(&newPlayer) Expect(err).To(BeNil()) - clone.ID = id - newItem, err := repo.Get(id) + savedPlayer, err := repo.Get(id) + Expect(err).To(BeNil()) + Expect(savedPlayer.APIKey).To(BeEmpty()) + }) - if clone.UserId == "" { - Expect(count).To(Equal(baseCount)) - Expect(err).To(Equal(model.ErrNotFound)) - } else { - Expect(count).To(Equal(baseCount + 1)) - Expect(err).To(BeNil()) - Expect(*newItem).To(Equal(clone)) + It("sets user ID from context", func() { + repo := NewPlayerRepository(regularCtx, database).(*playerRepository) + newPlayer := model.Player{ + Name: "Test Player", + Client: "TestClient", + UserAgent: "TestAgent", } - }, - Entry("same user", userPlayer), - Entry("other item", otherPlayer), - Entry("fake item", model.Player{}), - ) + + id, err := repo.Save(&newPlayer) + Expect(err).To(BeNil()) + + savedPlayer, err := repo.Get(id) + Expect(err).To(BeNil()) + Expect(savedPlayer.UserId).To(Equal(regularUser.ID)) + }) }) Describe("Update", func() { @@ -215,9 +240,7 @@ var _ = Describe("PlayerRepository", func() { clone.MaxBitRate = 10000 err := repo.Update(clone.ID, &clone, "ip") - if clone.UserId == "" { - Expect(err).To(HaveOccurred()) - } else if !admin && player.Username == adminPlayer1.Username { + if !admin && player.Username == adminPlayer1.Username { Expect(err).To(Equal(rest.ErrPermissionDenied)) clone.IP = player.IP } else { diff --git a/persistence/user_repository.go b/persistence/user_repository.go index ee057e373..a7181b1a7 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -193,21 +193,6 @@ func (r *userRepository) FindByUsernameWithPassword(username string) (*model.Use return usr, nil } -func (r *userRepository) FindByAPIKey(key string) (*model.User, error) { - // find the API key in the database - playerRepo := NewPlayerRepository(r.ctx, r.db) - apiKeyRepo := NewAPIKeyRepository(r.ctx, r.db) - apiKey, err := apiKeyRepo.FindByKey(key) - if err != nil { - return nil, err - } - player, err := playerRepo.Get(apiKey.PlayerID) - if err != nil { - return nil, err - } - return r.Get(player.UserId) -} - func (r *userRepository) UpdateLastLoginAt(id string) error { upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_login_at", time.Now()) _, err := r.executeSQL(upd) diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 615c336f5..7c0707ecd 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -212,7 +212,7 @@ var _ = Describe("UserRepository", func() { var existingUser *model.User BeforeEach(func() { existingUser = &model.User{ID: "1", UserName: "johndoe"} - repo = tests.CreateMockUserRepo(nil, nil) + repo = tests.CreateMockUserRepo() err := repo.Put(existingUser) Expect(err).ToNot(HaveOccurred()) }) diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index cab45fd6a..97509657e 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -3,8 +3,7 @@ package nativeapi import ( "context" "encoding/json" - "fmt" - "github.com/navidrome/navidrome/utils/req" + "errors" "html" "net/http" "strconv" @@ -53,11 +52,10 @@ func (n *Router) routes() http.Handler { n.R(r, "/album", model.Album{}, false) n.R(r, "/artist", model.Artist{}, false) n.R(r, "/genre", model.Genre{}, false) - n.R(r, "/player", model.Player{}, true) + n.addPlayerRoute(r) n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) n.R(r, "/radio", model.Radio{}, true) n.R(r, "/tag", model.Tag{}, true) - n.R(r, "/apikey", model.APIKey{}, true) if conf.Server.EnableSharing { n.RX(r, "/share", n.share.NewRepository, true) } @@ -76,7 +74,6 @@ func (n *Router) routes() http.Handler { n.addUserLibraryRoute(r) n.RX(r, "/library", n.libs.NewRepository, true) }) - n.addRefreshApiKeyRoute(r) }) return r @@ -106,6 +103,25 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository }) } +func (n *Router) addPlayerRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return n.ds.Resource(ctx, model.Player{}) + } + + r.Route("/player", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Post("/", rest.Post(constructor)) + + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Put("/", rest.Put(constructor)) + r.Delete("/", rest.Delete(constructor)) + r.Post("/apiKey", generatePlayerApiKey(n.ds)) + }) + }) +} + func (n *Router) addPlaylistRoute(r chi.Router) { constructor := func(ctx context.Context) rest.Repository { return n.ds.Resource(ctx, model.Playlist{}) @@ -202,6 +218,28 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin } } +func generatePlayerApiKey(ds model.DataStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + playerId := chi.URLParam(r, "id") + err := ds.WithTxImmediate(func(tx model.DataStore) error { + repo := tx.Player(ctx) + apiKey, err := repo.GenerateAPIKey(playerId) + if err == nil { + resp := []byte(`{"apiKey":"` + html.EscapeString(apiKey) + `"}`) + _, err = w.Write(resp) + } + return err + }) + if errors.Is(err, model.ErrNotFound) { + http.Error(w, "not found", http.StatusNotFound) + } else if err != nil { + log.Error(ctx, "Error retrieving player from DB", "id", playerId, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + func (n *Router) addInspectRoute(r chi.Router) { if conf.Server.Inspect.Enabled { r.Group(func(r chi.Router) { @@ -250,37 +288,3 @@ func adminOnlyMiddleware(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } - -func (n *Router) addRefreshApiKeyRoute(r chi.Router) { - r.With(server.URLParamsMiddleware).Post("/apikey/{id}/refresh", func(w http.ResponseWriter, r *http.Request) { - p := req.Params(r) - id, err := p.String(":id") - if err != nil { - msg := fmt.Sprintf("api key id could not be parsed: %s", id) - log.Warn(msg) - http.Error(w, "not found", http.StatusNotFound) - return - } - - repo := n.ds.APIKey(r.Context()) - _, err = repo.RefreshKey(id) - if err != nil { - log.Error(r.Context(), "error refreshing api key", "id", id, err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - updatedKey, err := repo.Get(id) - if err != nil { - log.Error(r.Context(), "error retrieving refreshed api key", "id", id, err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - err = rest.RespondWithJSON(w, http.StatusOK, updatedKey) - if err != nil { - log.Error(r.Context(), "error marshaling refreshed api key", "id", id, err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - }) -} diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index a39063273..3ea68cd9c 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -63,6 +63,7 @@ func checkRequiredParameters(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var requiredParameters []string + p := req.Params(r) username, _ := fromInternalOrProxyAuth(r) if username != "" { requiredParameters = []string{"v", "c"} @@ -154,15 +155,22 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { } if apiKey != "" { - usr, err = ds.User(ctx).FindByAPIKey(apiKey) + var player *model.Player + player, err = ds.Player(ctx).FindByAPIKey(apiKey) if errors.Is(err, context.Canceled) { - log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic-apikey", "remoteAddr", r.RemoteAddr, err) + log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "remoteAddr", r.RemoteAddr, err) return } - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "API: Invalid login - API key not found", "auth", "subsonic-apikey", "remoteAddr", r.RemoteAddr) - } else if err != nil { - log.Error(ctx, "API: Error authenticating with API key", "auth", "subsonic-apikey", "remoteAddr", r.RemoteAddr, err) + switch { + case errors.Is(err, model.ErrNotFound): + log.Warn(ctx, "API: Invalid login - API key not found", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err) + case err != nil: + log.Error(ctx, "API: Error authenticating with API key", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err) + default: + usr, err = ds.User(ctx).Get(player.UserId) + if err != nil { + log.Error(ctx, "API: Error retrieving user from API key", "auth", "subsonic", "apikey", apiKey, "remoteAddr", r.RemoteAddr, err) + } } } else { usr, err = ds.User(ctx).FindByUsernameWithPassword(username) diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index ee3879c09..33c38404d 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -308,7 +308,6 @@ var _ = Describe("Middlewares", func() { }) When("using api key authentication", func() { - var apiKey *model.APIKey BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -333,22 +332,13 @@ var _ = Describe("Middlewares", func() { MaxBitRate: 320, ReportRealPath: false, ScrobbleEnabled: true, + APIKey: "nav_12345", } _ = pr.Put(player) - - ar := ds.APIKey(context.TODO()) - newApiKey := &model.APIKey{ - ID: "api-key-id", - Name: "API Key", - PlayerID: player.ID, - } - apiKeyId, _ := ar.Save(newApiKey) - newApiKey, _ = ar.Get(apiKeyId) - apiKey = newApiKey }) It("passes authentication with correct api key", func() { - r := newGetRequest("apiKey=" + apiKey.Key) + r := newGetRequest("apiKey=nav_12345") cp := authenticate(ds)(next) cp.ServeHTTP(w, r) diff --git a/tests/mock_apikey_repo.go b/tests/mock_apikey_repo.go deleted file mode 100644 index b99f2316c..000000000 --- a/tests/mock_apikey_repo.go +++ /dev/null @@ -1,60 +0,0 @@ -package tests - -import ( - "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/id" - "strings" -) - -func CreateMockApiKeyRepo() *MockedAPIKeyRepo { - return &MockedAPIKeyRepo{ - Data: map[string]*model.APIKey{}, - } -} - -type MockedAPIKeyRepo struct { - model.APIKeyRepository - Error error - Data map[string]*model.APIKey -} - -func (m *MockedAPIKeyRepo) CountAll(_ ...model.QueryOptions) (int64, error) { - if m.Error != nil { - return 0, m.Error - } - return int64(len(m.Data)), nil -} - -func (m *MockedAPIKeyRepo) Save(entity interface{}) (string, error) { - if m.Error != nil { - return "", m.Error - } - apiKey := entity.(*model.APIKey) - apiKey.Key = "nav_" + id.NewRandom() - m.Data[strings.ToLower(apiKey.Key)] = apiKey - return apiKey.ID, nil -} - -func (m *MockedAPIKeyRepo) FindByKey(key string) (*model.APIKey, error) { - if m.Error != nil { - return nil, m.Error - } - apiKey, exists := m.Data[strings.ToLower(key)] - if !exists { - return nil, model.ErrNotFound - } - return apiKey, nil -} - -func (m *MockedAPIKeyRepo) Get(id string) (*model.APIKey, error) { - if m.Error != nil { - return nil, m.Error - } - - for _, apiKey := range m.Data { - if apiKey.ID == id { - return apiKey, nil - } - } - return nil, model.ErrNotFound -} diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go index 5945c7faa..d05795d70 100644 --- a/tests/mock_data_store.go +++ b/tests/mock_data_store.go @@ -28,7 +28,6 @@ type MockDataStore struct { MockedRadio model.RadioRepository scrobbleBufferMu sync.Mutex repoMu sync.Mutex - MockedAPIKey model.APIKeyRepository } func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { @@ -170,9 +169,7 @@ func (db *MockDataStore) User(ctx context.Context) model.UserRepository { if db.RealDS != nil { db.MockedUser = db.RealDS.User(ctx) } else { - playerRepo := db.Player(ctx).(*MockedPlayerRepo) - apiKeyRepo := db.APIKey(ctx).(*MockedAPIKeyRepo) - db.MockedUser = CreateMockUserRepo(playerRepo, apiKeyRepo) + db.MockedUser = CreateMockUserRepo() } } return db.MockedUser @@ -224,17 +221,6 @@ func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { return db.MockedRadio } -func (db *MockDataStore) APIKey(ctx context.Context) model.APIKeyRepository { - if db.MockedAPIKey == nil { - if db.RealDS != nil { - db.MockedAPIKey = db.RealDS.APIKey(ctx) - } else { - db.MockedAPIKey = CreateMockApiKeyRepo() - } - } - return db.MockedAPIKey -} - func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error { return block(db) } diff --git a/tests/mock_player_repo.go b/tests/mock_player_repo.go index b84cc2663..7d5c6dd1e 100644 --- a/tests/mock_player_repo.go +++ b/tests/mock_player_repo.go @@ -2,6 +2,7 @@ package tests import ( "encoding/base64" + "github.com/navidrome/navidrome/model" ) @@ -72,3 +73,16 @@ func (m *MockedPlayerRepo) CountByClient(_ ...model.QueryOptions) (map[string]in } return result, nil } + +func (m *MockedPlayerRepo) FindByAPIKey(key string) (*model.Player, error) { + if m.Error != nil { + return nil, m.Error + } + + for _, player := range m.Data { + if player.APIKey == key { + return player, nil + } + } + return nil, model.ErrNotFound +} diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index bc88fc04c..ef4ad8508 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -10,17 +10,10 @@ import ( "github.com/navidrome/navidrome/utils/gg" ) -func CreateMockUserRepo(apiKeyRepo ...*MockedAPIKeyRepo) *MockedUserRepo { - var repo *MockedAPIKeyRepo - if len(apiKeyRepo) > 0 { - repo = apiKeyRepo[0] - } else { - repo = CreateMockApiKeyRepo() - } +func CreateMockUserRepo() *MockedUserRepo { return &MockedUserRepo{ Data: map[string]*model.User{}, UserLibraries: map[string][]int{}, - APIKeyRepo: repo, } } @@ -29,8 +22,6 @@ type MockedUserRepo struct { Error error Data map[string]*model.User UserLibraries map[string][]int // userID -> libraryIDs - APIKeyRepo *MockedAPIKeyRepo - PlayerRepo *MockedPlayerRepo } func (u *MockedUserRepo) CountAll(_ ...model.QueryOptions) (int64, error) { @@ -132,27 +123,3 @@ func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error u.UserLibraries[userID] = libraryIDs return nil } - -func (u *MockedUserRepo) FindByAPIKey(key string) (*model.User, error) { - if u.Error != nil { - return nil, u.Error - } - - apiKey, err := u.APIKeyRepo.FindByKey(key) - if err != nil { - return nil, err - } - - player, err := u.PlayerRepo.Get(apiKey.PlayerID) - if err != nil { - return nil, err - } - - for _, usr := range u.Data { - if usr.ID == player.UserId { - return usr, nil - } - } - - return nil, model.ErrNotFound -} diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 7a5899045..dc4fe9b53 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -42,7 +42,6 @@ import SharePlayer from './share/SharePlayer' import { HTML5Backend } from 'react-dnd-html5-backend' import { DndProvider } from 'react-dnd' import missing from './missing/index.js' -import apikey from './apikey/index.js' const history = createHashHistory() @@ -112,11 +111,6 @@ const Admin = (props) => { options={{ subMenu: 'playlist' }} />, , - , { + const translate = useTranslate() + const resourceName = translate('resources.player.name', { smart_count: 1 }) + return +} + +const PlayerCreate = (props) => { + return ( + <Create title={<PlayerCreateTitle />} {...props}> + <SimpleForm variant="outlined"> + <TextInput source="name" validate={[required()]} /> + <ReferenceInput + source="transcodingId" + reference="transcoding" + sort={{ field: 'name', order: 'ASC' }} + > + <SelectInput source="name" resettable /> + </ReferenceInput> + <SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} /> + <BooleanInput source="reportRealPath" fullWidth /> + {(config.lastFMEnabled || config.listenBrainzEnabled) && ( + <BooleanInput source="scrobbleEnabled" fullWidth /> + )} + </SimpleForm> + </Create> + ) +} + +export default PlayerCreate diff --git a/ui/src/player/PlayerEdit.jsx b/ui/src/player/PlayerEdit.jsx index 1826500bd..6154ff5bf 100644 --- a/ui/src/player/PlayerEdit.jsx +++ b/ui/src/player/PlayerEdit.jsx @@ -1,3 +1,4 @@ +import React, { useState, useCallback } from 'react' import { TextInput, BooleanInput, @@ -8,10 +9,25 @@ import { SelectInput, ReferenceInput, useTranslate, + useNotify, + Button, + useRecordContext, } from 'react-admin' +import FileCopyIcon from '@material-ui/icons/FileCopy' +import VpnKeyIcon from '@material-ui/icons/VpnKey' +import VisibilityIcon from '@material-ui/icons/Visibility' +import VisibilityOffIcon from '@material-ui/icons/VisibilityOff' import { Title } from '../common' -import config from '../config' -import { BITRATE_CHOICES } from '../consts' +import { BITRATE_CHOICES, REST_URL } from '../consts' +import { makeStyles } from '@material-ui/core/styles' +import { + IconButton, + InputAdornment, + Tooltip, + TextField as MuiTextField, +} from '@material-ui/core' +import httpClient from '../dataProvider/httpClient.js' +import config from '../config.js' const PlayerTitle = ({ record }) => { const translate = useTranslate() @@ -19,26 +35,148 @@ const PlayerTitle = ({ record }) => { return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} /> } -const PlayerEdit = (props) => ( - <Edit title={<PlayerTitle />} {...props}> - <SimpleForm variant={'outlined'}> - <TextInput source="name" validate={[required()]} /> - <ReferenceInput - source="transcodingId" - reference="transcoding" - sort={{ field: 'name', order: 'ASC' }} - > - <SelectInput source="name" resettable /> - </ReferenceInput> - <SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} /> - <BooleanInput source="reportRealPath" fullWidth /> - {(config.lastFMEnabled || config.listenBrainzEnabled) && ( - <BooleanInput source="scrobbleEnabled" fullWidth /> +const useStyles = makeStyles({ + apiKeyField: { + marginTop: '16px', + marginBottom: '8px', + }, + generateButton: { + marginTop: '16px', + marginBottom: '8px', + }, + copyButton: { + padding: 4, + }, +}) + +const ApiKeySection = () => { + const record = useRecordContext() + const recordId = record ? record.id : null + const initialApiKey = record ? record.apiKey : null + + const classes = useStyles() + const notify = useNotify() + const [showApiKey, setShowApiKey] = useState(false) + const [loading, setLoading] = useState(false) + const [apiKey, setApiKey] = useState(initialApiKey) + + const generateApiKey = useCallback(async () => { + if (!recordId) { + notify('Player ID not available', 'error') + return + } + + try { + setLoading(true) + const { json } = await httpClient( + `${REST_URL}/player/${recordId}/apiKey`, + { + method: 'POST', + }, + ) + setApiKey(json.apiKey) + setShowApiKey(true) + notify('message.apiKeyGenerated', 'info') + } catch (error) { + notify(error.message || 'Error generating API key', 'error') + } finally { + setLoading(false) + } + }, [recordId, notify]) + + const copyToClipboard = () => { + if (apiKey) { + navigator.clipboard.writeText(apiKey) + notify('API key copied to clipboard', 'info') + } + } + + if (!recordId) { + return <></> + } + + return ( + <div style={{ display: 'flex', flexDirection: 'column', marginTop: 16 }}> + {apiKey ? ( + <MuiTextField + label="API Key" + value={showApiKey ? apiKey : '*'.repeat(apiKey.length)} + InputProps={{ + readOnly: true, + disableUnderline: true, + endAdornment: ( + <InputAdornment position="end"> + <Tooltip title={showApiKey ? 'Hide' : 'Show'}> + <IconButton + aria-label="toggle api key visibility" + onClick={() => setShowApiKey(!showApiKey)} + size="small" + > + {showApiKey ? <VisibilityOffIcon /> : <VisibilityIcon />} + </IconButton> + </Tooltip> + <Tooltip title="Copy to clipboard"> + <IconButton + aria-label="copy api key" + onClick={copyToClipboard} + className={classes.copyButton} + size="small" + > + <FileCopyIcon /> + </IconButton> + </Tooltip> + </InputAdornment> + ), + }} + style={{ width: 320, color: 'black' }} + /> + ) : ( + !loading && ( + <MuiTextField + label="API Key" + value="No API Key Found" + disabled + style={{ width: 320 }} + /> + ) )} - <TextField source="client" /> - <TextField source="userName" /> - </SimpleForm> - </Edit> -) + + <Button + style={{ marginTop: 12, alignSelf: 'flex-start' }} + onClick={generateApiKey} + label="Generate New API Key" + startIcon={<VpnKeyIcon />} + variant="outlined" + disabled={loading} + /> + </div> + ) +} + +const PlayerEdit = (props) => { + return ( + <Edit title={<PlayerTitle />} {...props}> + <SimpleForm variant={'outlined'}> + <TextInput source="name" validate={[required()]} /> + <ReferenceInput + source="transcodingId" + reference="transcoding" + sort={{ field: 'name', order: 'ASC' }} + > + <SelectInput source="name" resettable /> + </ReferenceInput> + <SelectInput source="maxBitRate" resettable choices={BITRATE_CHOICES} /> + <BooleanInput source="reportRealPath" fullWidth /> + {(config.lastFMEnabled || config.listenBrainzEnabled) && ( + <BooleanInput source="scrobbleEnabled" fullWidth /> + )} + <TextField source="client" /> + <TextField source="userName" /> + + <ApiKeySection /> + </SimpleForm> + </Edit> + ) +} export default PlayerEdit diff --git a/ui/src/player/index.js b/ui/src/player/index.js index aaa3d58d7..6e356266e 100644 --- a/ui/src/player/index.js +++ b/ui/src/player/index.js @@ -1,8 +1,10 @@ import { BsFillMusicPlayerFill } from 'react-icons/bs' import PlayerList from './PlayerList' import PlayerEdit from './PlayerEdit' +import PlayerCreate from './PlayerCreate.jsx' export default { + create: PlayerCreate, list: PlayerList, edit: PlayerEdit, icon: BsFillMusicPlayerFill,