mirror of
https://github.com/navidrome/navidrome.git
synced 2026-05-03 06:51:16 +00:00
Implement OpenSubsonic's API Key authentication
Ref: https://opensubsonic.netlify.app/docs/extensions/apikeyauth/. Add functionality for users to create, manage, and use API keys for authentication. * **Database:** * Added a new `api_key`(`20250415111500_add_api_key_table.go`) table to store API keys associated with users. * **Backend:** * Defined the `APIKey` model. * Implemented `APIKeyRepository` for database operations. * Added `FindByAPIKey` method to `UserRepository` to allow user lookup via API key. * Updated Subsonic API error responses to include API key-related errors and messaging. * Integrated API key authentication into the subsonic authentication middleware. * **UI:** * Added new React components for API Key CRUD operations: `ApiKeyList`, `ApiKeyCreate`, and `ApiKeyEdit`. * The API Key management section can be accessed from the settings menu. # Conflicts: # model/user.go # server/subsonic/middlewares.go # server/subsonic/middlewares_test.go # tests/mock_data_store.go # tests/mock_user_repo.go
This commit is contained in:
parent
871ee730cd
commit
362a8826b9
39
db/migrations/20250415111500_add_api_key_table.go
Normal file
39
db/migrations/20250415111500_add_api_key_table.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
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,
|
||||||
|
user_id text not null,
|
||||||
|
name text not null,
|
||||||
|
key text not null unique,
|
||||||
|
created_at datetime not null,
|
||||||
|
|
||||||
|
foreign key (user_id)
|
||||||
|
references user(id)
|
||||||
|
on delete cascade
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists api_key_key on api_key(key);
|
||||||
|
create index if not exists api_key_user_id on api_key(user_id);
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func downAddApiKeyTable(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
drop table api_key;
|
||||||
|
`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
26
model/api_key.go
Normal file
26
model/api_key.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/deluan/rest"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type APIKey struct {
|
||||||
|
ID string `structs:"id" json:"id"`
|
||||||
|
UserID string `structs:"user_id" json:"userId"`
|
||||||
|
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)
|
||||||
|
Put(*APIKey) error
|
||||||
|
FindByKey(key string) (*APIKey, error)
|
||||||
|
}
|
||||||
@ -38,6 +38,7 @@ type DataStore interface {
|
|||||||
User(ctx context.Context) UserRepository
|
User(ctx context.Context) UserRepository
|
||||||
UserProps(ctx context.Context) UserPropsRepository
|
UserProps(ctx context.Context) UserPropsRepository
|
||||||
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
|
||||||
|
APIKey(ctx context.Context) APIKeyRepository
|
||||||
|
|
||||||
Resource(ctx context.Context, model interface{}) ResourceRepository
|
Resource(ctx context.Context, model interface{}) ResourceRepository
|
||||||
|
|
||||||
|
|||||||
@ -56,4 +56,6 @@ type UserRepository interface {
|
|||||||
// Library association methods
|
// Library association methods
|
||||||
GetUserLibraries(userID string) (Libraries, error)
|
GetUserLibraries(userID string) (Libraries, error)
|
||||||
SetUserLibraries(userID string, libraryIDs []int) error
|
SetUserLibraries(userID string, libraryIDs []int) error
|
||||||
|
// FindByAPIKey finds a user by the provided API key
|
||||||
|
FindByAPIKey(key string) (*User, error)
|
||||||
}
|
}
|
||||||
|
|||||||
158
persistence/api_key_repository.go
Normal file
158
persistence/api_key_repository.go
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
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{"user_id": user.ID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *apiKeyRepository) CountAll(options ...model.QueryOptions) (int64, error) {
|
||||||
|
sq := Select().From(r.tableName).Where(r.userFilter())
|
||||||
|
return r.count(sq, options...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *apiKeyRepository) Get(id string) (*model.APIKey, error) {
|
||||||
|
sel := r.newSelect().Columns("*").Where(And{Eq{"id": id}})
|
||||||
|
var res model.APIKey
|
||||||
|
err := r.queryOne(sel, &res)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *apiKeyRepository) GetAll(options ...model.QueryOptions) (model.APIKeys, error) {
|
||||||
|
sel := r.newSelect(options...).Columns("*").Where(r.userFilter())
|
||||||
|
res := model.APIKeys{}
|
||||||
|
err := r.queryAll(sel, &res)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *apiKeyRepository) Put(ak *model.APIKey) error {
|
||||||
|
if ak.ID == "" {
|
||||||
|
ak.ID = id.NewRandom()
|
||||||
|
}
|
||||||
|
ak.CreatedAt = time.Now()
|
||||||
|
values, err := toSQLArgs(*ak)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
insert := Insert(r.tableName).SetMap(values)
|
||||||
|
_, err = r.executeSQL(insert)
|
||||||
|
return 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) {
|
||||||
|
user := loggedUser(r.ctx)
|
||||||
|
apiKey, err := r.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !user.IsAdmin && apiKey.UserID != user.ID {
|
||||||
|
return nil, rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
user := loggedUser(r.ctx)
|
||||||
|
ak.UserID = user.ID
|
||||||
|
// prefix API keys with nav_
|
||||||
|
ak.Key = "nav_" + id.NewRandom()
|
||||||
|
err := r.Put(ak)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
user := loggedUser(r.ctx)
|
||||||
|
if !user.IsAdmin && current.UserID != user.ID {
|
||||||
|
return rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow updating name
|
||||||
|
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 {
|
||||||
|
user := loggedUser(r.ctx)
|
||||||
|
apiKey, err := r.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !user.IsAdmin && apiKey.UserID != user.ID {
|
||||||
|
return rest.ErrPermissionDenied
|
||||||
|
}
|
||||||
|
return r.delete(Eq{"id": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *apiKeyRepository) FindByKey(key string) (*model.APIKey, error) {
|
||||||
|
sel := r.newSelect().Columns("*").Where(Eq{"key": key})
|
||||||
|
var res model.APIKey
|
||||||
|
err := r.queryOne(sel, &res)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ model.APIKeyRepository = (*apiKeyRepository)(nil)
|
||||||
|
var _ rest.Repository = (*apiKeyRepository)(nil)
|
||||||
|
var _ rest.Persistable = (*apiKeyRepository)(nil)
|
||||||
216
persistence/api_key_repository_test.go
Normal file
216
persistence/api_key_repository_test.go
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("APIKeyRepository", func() {
|
||||||
|
var repo model.APIKeyRepository
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
ctx := log.NewContext(context.TODO())
|
||||||
|
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
|
||||||
|
repo = NewAPIKeyRepository(ctx, GetDBXBuilder())
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Put", func() {
|
||||||
|
It("sets an ID if it is not set", func() {
|
||||||
|
apiKey := &model.APIKey{
|
||||||
|
UserID: "userid",
|
||||||
|
Name: "Test API Key",
|
||||||
|
Key: "test-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Put(apiKey)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(apiKey.ID).ToNot(BeEmpty())
|
||||||
|
Expect(apiKey.CreatedAt).ToNot(BeZero())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("keeps existing values", func() {
|
||||||
|
apiKey := &model.APIKey{
|
||||||
|
ID: "existing-id",
|
||||||
|
UserID: "userid",
|
||||||
|
Name: "Test API Key 2",
|
||||||
|
Key: "test-key-2",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Put(apiKey)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(apiKey.ID).To(Equal("existing-id"))
|
||||||
|
Expect(apiKey.CreatedAt).ToNot(BeZero())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("FindByKey", func() {
|
||||||
|
It("returns the API key with matching key", func() {
|
||||||
|
apiKey := &model.APIKey{
|
||||||
|
UserID: "userid",
|
||||||
|
Name: "Unique API Key",
|
||||||
|
Key: "unique-test-key",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Put(apiKey)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
result, err := repo.FindByKey("unique-test-key")
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(result.ID).To(Equal(apiKey.ID))
|
||||||
|
Expect(result.Key).To(Equal("unique-test-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",
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := repo.Save(apiKey)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(id).ToNot(BeEmpty())
|
||||||
|
Expect(apiKey.Key).To(HavePrefix("nav_"))
|
||||||
|
Expect(apiKey.UserID).To(Equal("userid"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Update", func() {
|
||||||
|
It("only updates the name field", func() {
|
||||||
|
apiKey := &model.APIKey{
|
||||||
|
UserID: "userid",
|
||||||
|
Name: "Original Name",
|
||||||
|
Key: "test-key-for-update",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Put(apiKey)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
updateKey := &model.APIKey{
|
||||||
|
Name: "Updated Name",
|
||||||
|
Key: "should-not-change",
|
||||||
|
UserID: "2222",
|
||||||
|
}
|
||||||
|
|
||||||
|
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("test-key-for-update"))
|
||||||
|
Expect(result.UserID).To(Equal("userid"))
|
||||||
|
})
|
||||||
|
|
||||||
|
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{
|
||||||
|
UserID: "userid",
|
||||||
|
Name: "API Key to Delete",
|
||||||
|
Key: "key-to-delete",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := repo.Put(apiKey)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
err = repo.Delete(apiKey.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
_, err = repo.Get(apiKey.ID)
|
||||||
|
Expect(err).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("User permissions", func() {
|
||||||
|
var nonAdminCtx context.Context
|
||||||
|
var nonAdminRepo model.APIKeyRepository
|
||||||
|
var adminKey model.APIKey
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
nonAdminCtx = log.NewContext(context.TODO())
|
||||||
|
nonAdminCtx = context.WithValue(nonAdminCtx, "user", model.User{ID: "2222", UserName: "user", IsAdmin: false})
|
||||||
|
nonAdminRepo = NewAPIKeyRepository(nonAdminCtx, GetDBXBuilder())
|
||||||
|
|
||||||
|
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{
|
||||||
|
UserID: "userid",
|
||||||
|
Name: "Admin's API Key",
|
||||||
|
Key: "admin-key",
|
||||||
|
}
|
||||||
|
err := repo.Put(tmpAdminKey)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
adminKey = *tmpAdminKey
|
||||||
|
|
||||||
|
userKey := &model.APIKey{
|
||||||
|
UserID: "2222",
|
||||||
|
Name: "User's API Key",
|
||||||
|
Key: "user-key",
|
||||||
|
}
|
||||||
|
err = repo.Put(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.UserID).To(Equal("2222"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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.UserID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Expect(userIds).To(HaveKey("userid"))
|
||||||
|
Expect(userIds).To(HaveKey("2222"))
|
||||||
|
})
|
||||||
|
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -89,6 +89,10 @@ func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepos
|
|||||||
return NewScrobbleBufferRepository(ctx, s.getDBXBuilder())
|
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 {
|
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
|
||||||
switch m.(type) {
|
switch m.(type) {
|
||||||
case model.User:
|
case model.User:
|
||||||
@ -113,6 +117,8 @@ func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRe
|
|||||||
return s.Share(ctx).(model.ResourceRepository)
|
return s.Share(ctx).(model.ResourceRepository)
|
||||||
case model.Tag:
|
case model.Tag:
|
||||||
return s.Tag(ctx).(model.ResourceRepository)
|
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())
|
log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name())
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -193,6 +193,18 @@ func (r *userRepository) FindByUsernameWithPassword(username string) (*model.Use
|
|||||||
return usr, nil
|
return usr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) FindByAPIKey(key string) (*model.User, error) {
|
||||||
|
// find the API key in the database
|
||||||
|
apiKeyRepo := NewAPIKeyRepository(r.ctx, r.db)
|
||||||
|
apiKey, err := apiKeyRepo.FindByKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then get the user associated with this API key
|
||||||
|
return r.Get(apiKey.UserID)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *userRepository) UpdateLastLoginAt(id string) error {
|
func (r *userRepository) UpdateLastLoginAt(id string) error {
|
||||||
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_login_at", time.Now())
|
upd := Update(r.tableName).Where(Eq{"id": id}).Set("last_login_at", time.Now())
|
||||||
_, err := r.executeSQL(upd)
|
_, err := r.executeSQL(upd)
|
||||||
|
|||||||
@ -55,6 +55,7 @@ func (n *Router) routes() http.Handler {
|
|||||||
n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
|
||||||
n.R(r, "/radio", model.Radio{}, true)
|
n.R(r, "/radio", model.Radio{}, true)
|
||||||
n.R(r, "/tag", model.Tag{}, true)
|
n.R(r, "/tag", model.Tag{}, true)
|
||||||
|
n.R(r, "/apikey", model.APIKey{}, true)
|
||||||
if conf.Server.EnableSharing {
|
if conf.Server.EnableSharing {
|
||||||
n.RX(r, "/share", n.share.NewRepository, true)
|
n.RX(r, "/share", n.share.NewRepository, true)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,11 +66,12 @@ func checkRequiredParameters(next http.Handler) http.Handler {
|
|||||||
username, _ := fromInternalOrProxyAuth(r)
|
username, _ := fromInternalOrProxyAuth(r)
|
||||||
if username != "" {
|
if username != "" {
|
||||||
requiredParameters = []string{"v", "c"}
|
requiredParameters = []string{"v", "c"}
|
||||||
|
} else if apiKey, _ := p.String("apiKey"); apiKey != "" {
|
||||||
|
requiredParameters = []string{"v", "c"}
|
||||||
} else {
|
} else {
|
||||||
requiredParameters = []string{"u", "v", "c"}
|
requiredParameters = []string{"u", "v", "c"}
|
||||||
}
|
}
|
||||||
|
|
||||||
p := req.Params(r)
|
|
||||||
for _, param := range requiredParameters {
|
for _, param := range requiredParameters {
|
||||||
if _, err := p.String(param); err != nil {
|
if _, err := p.String(param); err != nil {
|
||||||
log.Warn(r, err)
|
log.Warn(r, err)
|
||||||
@ -123,21 +124,62 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler {
|
|||||||
token, _ := p.String("t")
|
token, _ := p.String("t")
|
||||||
salt, _ := p.String("s")
|
salt, _ := p.String("s")
|
||||||
jwt, _ := p.String("jwt")
|
jwt, _ := p.String("jwt")
|
||||||
|
apiKey, _ := p.String("apiKey")
|
||||||
|
|
||||||
usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
|
// When an API key is provided, username should not be provided
|
||||||
if errors.Is(err, context.Canceled) {
|
if apiKey != "" && username != "" {
|
||||||
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
log.Warn(ctx, "API: Invalid login - username provided with API key", "auth", "subsonic", "remoteAddr", r.RemoteAddr)
|
||||||
|
sendError(w, r, newError(responses.ErrorMultipleAuthMechanismsProvided))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch {
|
|
||||||
case errors.Is(err, model.ErrNotFound):
|
// Check for conflicting authentication mechanisms
|
||||||
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
authMechanismsCount := 0
|
||||||
case err != nil:
|
if apiKey != "" {
|
||||||
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
authMechanismsCount++
|
||||||
default:
|
}
|
||||||
err = validateCredentials(usr, pass, token, salt, jwt)
|
if pass != "" {
|
||||||
if err != nil {
|
authMechanismsCount++
|
||||||
|
}
|
||||||
|
if token != "" && salt != "" {
|
||||||
|
authMechanismsCount++
|
||||||
|
}
|
||||||
|
if jwt != "" {
|
||||||
|
authMechanismsCount++
|
||||||
|
}
|
||||||
|
if authMechanismsCount > 1 {
|
||||||
|
log.Warn(ctx, "API: Invalid login - multiple authentication mechanisms", "auth", "subsonic", "remoteAddr", r.RemoteAddr)
|
||||||
|
sendError(w, r, newError(responses.ErrorMultipleAuthMechanismsProvided))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiKey != "" {
|
||||||
|
usr, err = ds.User(ctx).FindByAPIKey(apiKey)
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic-apikey", "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)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
usr, err = ds.User(ctx).FindByUsernameWithPassword(username)
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, model.ErrNotFound):
|
||||||
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||||
|
case err != nil:
|
||||||
|
log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||||
|
default:
|
||||||
|
err = validateCredentials(usr, pass, token, salt, jwt)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -306,6 +306,65 @@ var _ = Describe("Middlewares", func() {
|
|||||||
Expect(next.called).To(BeFalse())
|
Expect(next.called).To(BeFalse())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
When("using api key authentication", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
|
||||||
|
ur := ds.User(context.TODO())
|
||||||
|
user := &model.User{
|
||||||
|
UserName: "user-api",
|
||||||
|
NewPassword: "wordpass-api",
|
||||||
|
}
|
||||||
|
_ = ur.Put(user)
|
||||||
|
|
||||||
|
ar := ds.APIKey(context.TODO())
|
||||||
|
apiKey := &model.APIKey{
|
||||||
|
ID: "api-key-id",
|
||||||
|
UserID: user.ID,
|
||||||
|
Name: "API Key",
|
||||||
|
Key: "api-key",
|
||||||
|
}
|
||||||
|
_ = ar.Put(apiKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("passes authentication with correct api key", func() {
|
||||||
|
r := newGetRequest("apiKey=api-key")
|
||||||
|
cp := authenticate(ds)(next)
|
||||||
|
cp.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
Expect(next.called).To(BeTrue())
|
||||||
|
user, _ := request.UserFrom(next.req.Context())
|
||||||
|
Expect(user.UserName).To(Equal("user-api"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("fails authentication with invalid api key", func() {
|
||||||
|
r := newGetRequest("apiKey=invalid-api-key")
|
||||||
|
cp := authenticate(ds)(next)
|
||||||
|
cp.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||||
|
Expect(next.called).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("fails authentication with empty api key", func() {
|
||||||
|
r := newGetRequest("apiKey=")
|
||||||
|
cp := authenticate(ds)(next)
|
||||||
|
cp.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
Expect(w.Body.String()).To(ContainSubstring(`code="40"`))
|
||||||
|
Expect(next.called).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("fails authentication if both api key and username are provided", func() {
|
||||||
|
r := newGetRequest("apiKey=api-key", "u=user-api")
|
||||||
|
cp := authenticate(ds)(next)
|
||||||
|
cp.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
Expect(w.Body.String()).To(ContainSubstring(`code="43"`))
|
||||||
|
Expect(next.called).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("GetPlayer", func() {
|
Describe("GetPlayer", func() {
|
||||||
|
|||||||
@ -1,25 +1,31 @@
|
|||||||
package responses
|
package responses
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ErrorGeneric int32 = 0
|
ErrorGeneric int32 = 0
|
||||||
ErrorMissingParameter int32 = 10
|
ErrorMissingParameter int32 = 10
|
||||||
ErrorClientTooOld int32 = 20
|
ErrorClientTooOld int32 = 20
|
||||||
ErrorServerTooOld int32 = 30
|
ErrorServerTooOld int32 = 30
|
||||||
ErrorAuthenticationFail int32 = 40
|
ErrorAuthenticationFail int32 = 40
|
||||||
ErrorAuthorizationFail int32 = 50
|
ErrorTokenAuthNotSupported int32 = 41
|
||||||
ErrorTrialExpired int32 = 60
|
ErrorAuthMechanismNotSupported int32 = 42
|
||||||
ErrorDataNotFound int32 = 70
|
ErrorMultipleAuthMechanismsProvided int32 = 43
|
||||||
|
ErrorAuthorizationFail int32 = 50
|
||||||
|
ErrorTrialExpired int32 = 60
|
||||||
|
ErrorDataNotFound int32 = 70
|
||||||
)
|
)
|
||||||
|
|
||||||
var errors = map[int32]string{
|
var errors = map[int32]string{
|
||||||
ErrorGeneric: "A generic error",
|
ErrorGeneric: "A generic error",
|
||||||
ErrorMissingParameter: "Required parameter is missing",
|
ErrorMissingParameter: "Required parameter is missing",
|
||||||
ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade",
|
ErrorClientTooOld: "Incompatible Subsonic REST protocol version. Client must upgrade",
|
||||||
ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade",
|
ErrorServerTooOld: "Incompatible Subsonic REST protocol version. Server must upgrade",
|
||||||
ErrorAuthenticationFail: "Wrong username or password",
|
ErrorAuthenticationFail: "Wrong username or password or api key",
|
||||||
ErrorAuthorizationFail: "User is not authorized for the given operation",
|
ErrorTokenAuthNotSupported: "Token authentication not supported",
|
||||||
ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details",
|
ErrorAuthMechanismNotSupported: "Provided authentication mechanism not supported",
|
||||||
ErrorDataNotFound: "The requested data was not found",
|
ErrorMultipleAuthMechanismsProvided: "Multiple conflicting authentication mechanisms provided",
|
||||||
|
ErrorAuthorizationFail: "User is not authorized for the given operation",
|
||||||
|
ErrorTrialExpired: "The trial period for the Subsonic server is over. Please upgrade to Subsonic Premium. Visit subsonic.org for details",
|
||||||
|
ErrorDataNotFound: "The requested data was not found",
|
||||||
}
|
}
|
||||||
|
|
||||||
func ErrorMsg(code int32) string {
|
func ErrorMsg(code int32) string {
|
||||||
|
|||||||
57
tests/mock_apikey_repo.go
Normal file
57
tests/mock_apikey_repo.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"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) Put(apiKey *model.APIKey) error {
|
||||||
|
if m.Error != nil {
|
||||||
|
return m.Error
|
||||||
|
}
|
||||||
|
m.Data[strings.ToLower(apiKey.Key)] = apiKey
|
||||||
|
return 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
|
||||||
|
}
|
||||||
@ -28,6 +28,7 @@ type MockDataStore struct {
|
|||||||
MockedRadio model.RadioRepository
|
MockedRadio model.RadioRepository
|
||||||
scrobbleBufferMu sync.Mutex
|
scrobbleBufferMu sync.Mutex
|
||||||
repoMu sync.Mutex
|
repoMu sync.Mutex
|
||||||
|
MockedAPIKey model.APIKeyRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository {
|
func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository {
|
||||||
@ -169,7 +170,8 @@ func (db *MockDataStore) User(ctx context.Context) model.UserRepository {
|
|||||||
if db.RealDS != nil {
|
if db.RealDS != nil {
|
||||||
db.MockedUser = db.RealDS.User(ctx)
|
db.MockedUser = db.RealDS.User(ctx)
|
||||||
} else {
|
} else {
|
||||||
db.MockedUser = CreateMockUserRepo()
|
apiKeyRepo := db.APIKey(ctx).(*MockedAPIKeyRepo)
|
||||||
|
db.MockedUser = CreateMockUserRepo(apiKeyRepo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return db.MockedUser
|
return db.MockedUser
|
||||||
@ -221,6 +223,17 @@ func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository {
|
|||||||
return db.MockedRadio
|
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 {
|
func (db *MockDataStore) WithTx(block func(tx model.DataStore) error, label ...string) error {
|
||||||
return block(db)
|
return block(db)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,10 +10,15 @@ import (
|
|||||||
"github.com/navidrome/navidrome/utils/gg"
|
"github.com/navidrome/navidrome/utils/gg"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateMockUserRepo() *MockedUserRepo {
|
func CreateMockUserRepo(apiKeyRepo ...*MockedAPIKeyRepo) *MockedUserRepo {
|
||||||
|
var repo *MockedAPIKeyRepo
|
||||||
|
if len(apiKeyRepo) > 0 {
|
||||||
|
repo = apiKeyRepo[0]
|
||||||
|
}
|
||||||
return &MockedUserRepo{
|
return &MockedUserRepo{
|
||||||
Data: map[string]*model.User{},
|
Data: map[string]*model.User{},
|
||||||
UserLibraries: map[string][]int{},
|
UserLibraries: map[string][]int{},
|
||||||
|
APIKeyRepo: repo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,9 +27,10 @@ type MockedUserRepo struct {
|
|||||||
Error error
|
Error error
|
||||||
Data map[string]*model.User
|
Data map[string]*model.User
|
||||||
UserLibraries map[string][]int // userID -> libraryIDs
|
UserLibraries map[string][]int // userID -> libraryIDs
|
||||||
|
APIKeyRepo *MockedAPIKeyRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *MockedUserRepo) CountAll(qo ...model.QueryOptions) (int64, error) {
|
func (u *MockedUserRepo) CountAll(_ ...model.QueryOptions) (int64, error) {
|
||||||
if u.Error != nil {
|
if u.Error != nil {
|
||||||
return 0, u.Error
|
return 0, u.Error
|
||||||
}
|
}
|
||||||
@ -123,3 +129,22 @@ func (u *MockedUserRepo) SetUserLibraries(userID string, libraryIDs []int) error
|
|||||||
u.UserLibraries[userID] = libraryIDs
|
u.UserLibraries[userID] = libraryIDs
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, usr := range u.Data {
|
||||||
|
if usr.ID == apiKey.UserID {
|
||||||
|
return usr, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, model.ErrNotFound
|
||||||
|
}
|
||||||
|
|||||||
@ -42,6 +42,7 @@ import SharePlayer from './share/SharePlayer'
|
|||||||
import { HTML5Backend } from 'react-dnd-html5-backend'
|
import { HTML5Backend } from 'react-dnd-html5-backend'
|
||||||
import { DndProvider } from 'react-dnd'
|
import { DndProvider } from 'react-dnd'
|
||||||
import missing from './missing/index.js'
|
import missing from './missing/index.js'
|
||||||
|
import apikey from './apikey/index.js'
|
||||||
|
|
||||||
const history = createHashHistory()
|
const history = createHashHistory()
|
||||||
|
|
||||||
@ -111,6 +112,11 @@ const Admin = (props) => {
|
|||||||
options={{ subMenu: 'playlist' }}
|
options={{ subMenu: 'playlist' }}
|
||||||
/>,
|
/>,
|
||||||
<Resource name="user" {...user} options={{ subMenu: 'settings' }} />,
|
<Resource name="user" {...user} options={{ subMenu: 'settings' }} />,
|
||||||
|
<Resource
|
||||||
|
name="apikey"
|
||||||
|
{...apikey}
|
||||||
|
options={{ subMenu: 'settings' }}
|
||||||
|
/>,
|
||||||
<Resource
|
<Resource
|
||||||
name="player"
|
name="player"
|
||||||
{...player}
|
{...player}
|
||||||
|
|||||||
57
ui/src/apikey/ApiKeyCreate.jsx
Normal file
57
ui/src/apikey/ApiKeyCreate.jsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Create,
|
||||||
|
SimpleForm,
|
||||||
|
TextInput,
|
||||||
|
required,
|
||||||
|
useTranslate,
|
||||||
|
useMutation,
|
||||||
|
useNotify,
|
||||||
|
useRedirect,
|
||||||
|
} from 'react-admin'
|
||||||
|
import { Title } from '../common'
|
||||||
|
|
||||||
|
const ApiKeyCreate = (props) => {
|
||||||
|
const translate = useTranslate()
|
||||||
|
const [mutate] = useMutation()
|
||||||
|
const notify = useNotify()
|
||||||
|
const redirect = useRedirect()
|
||||||
|
const resourceName = translate('resources.apikey.name', { smart_count: 1 })
|
||||||
|
const title = translate('ra.page.create', {
|
||||||
|
name: `${resourceName}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const save = useCallback(
|
||||||
|
async (values) => {
|
||||||
|
try {
|
||||||
|
await mutate(
|
||||||
|
{
|
||||||
|
type: 'create',
|
||||||
|
resource: 'apikey',
|
||||||
|
payload: { data: values },
|
||||||
|
},
|
||||||
|
{ returnPromise: true },
|
||||||
|
)
|
||||||
|
notify('resources.apikey.notifications.created', 'info', {
|
||||||
|
smart_count: 1,
|
||||||
|
})
|
||||||
|
redirect('/apikey')
|
||||||
|
} catch (error) {
|
||||||
|
if (error.body.errors) {
|
||||||
|
return error.body.errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutate, notify, redirect],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Create title={<Title subTitle={title} />} {...props}>
|
||||||
|
<SimpleForm save={save} variant={'outlined'}>
|
||||||
|
<TextInput source="name" validate={[required()]} autoFocus />
|
||||||
|
</SimpleForm>
|
||||||
|
</Create>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiKeyCreate
|
||||||
75
ui/src/apikey/ApiKeyEdit.jsx
Normal file
75
ui/src/apikey/ApiKeyEdit.jsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import React, { useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Edit,
|
||||||
|
SimpleForm,
|
||||||
|
TextInput,
|
||||||
|
required,
|
||||||
|
Toolbar,
|
||||||
|
SaveButton,
|
||||||
|
DeleteButton,
|
||||||
|
useTranslate,
|
||||||
|
useMutation,
|
||||||
|
useNotify,
|
||||||
|
useRefresh,
|
||||||
|
DateField,
|
||||||
|
} from 'react-admin'
|
||||||
|
import { Title } from '../common'
|
||||||
|
|
||||||
|
const ApiKeyTitle = ({ record }) => {
|
||||||
|
const translate = useTranslate()
|
||||||
|
const resourceName = translate('resources.apikey.name', { smart_count: 1 })
|
||||||
|
return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const ApiKeyEditToolbar = (props) => (
|
||||||
|
<Toolbar {...props}>
|
||||||
|
<SaveButton />
|
||||||
|
<DeleteButton />
|
||||||
|
</Toolbar>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ApiKeyEdit = (props) => {
|
||||||
|
const [mutate] = useMutation()
|
||||||
|
const notify = useNotify()
|
||||||
|
const refresh = useRefresh()
|
||||||
|
|
||||||
|
const save = useCallback(
|
||||||
|
async (values) => {
|
||||||
|
try {
|
||||||
|
await mutate(
|
||||||
|
{
|
||||||
|
type: 'update',
|
||||||
|
resource: 'apikey',
|
||||||
|
payload: { id: values.id, data: values },
|
||||||
|
},
|
||||||
|
{ returnPromise: true },
|
||||||
|
)
|
||||||
|
notify('resources.apikey.notifications.updated', 'info', {
|
||||||
|
smart_count: 1,
|
||||||
|
})
|
||||||
|
refresh()
|
||||||
|
} catch (error) {
|
||||||
|
if (error.body.errors) {
|
||||||
|
return error.body.errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mutate, notify, refresh],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Edit title={<ApiKeyTitle />} {...props}>
|
||||||
|
<SimpleForm
|
||||||
|
toolbar={<ApiKeyEditToolbar />}
|
||||||
|
save={save}
|
||||||
|
variant={'outlined'}
|
||||||
|
>
|
||||||
|
<TextInput source="name" validate={[required()]} />
|
||||||
|
<TextInput source="key" disabled fullWidth />
|
||||||
|
<DateField variant="body1" source="createdAt" showTime />
|
||||||
|
</SimpleForm>
|
||||||
|
</Edit>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiKeyEdit
|
||||||
58
ui/src/apikey/ApiKeyList.jsx
Normal file
58
ui/src/apikey/ApiKeyList.jsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
Datagrid,
|
||||||
|
DateField,
|
||||||
|
Filter,
|
||||||
|
List,
|
||||||
|
SearchInput,
|
||||||
|
TextField,
|
||||||
|
useTranslate,
|
||||||
|
} from 'react-admin'
|
||||||
|
import { useMediaQuery } from '@material-ui/core'
|
||||||
|
import { SimpleList } from '../common'
|
||||||
|
import AddIcon from '@material-ui/icons/Add'
|
||||||
|
import { CreateButton } from 'react-admin'
|
||||||
|
|
||||||
|
const ApiKeyFilter = (props) => (
|
||||||
|
<Filter {...props} variant={'outlined'}>
|
||||||
|
<SearchInput id="search" source="name" alwaysOn />
|
||||||
|
</Filter>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ApiKeyList = (props) => {
|
||||||
|
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||||
|
const translate = useTranslate()
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
{...props}
|
||||||
|
actions={
|
||||||
|
<CreateButton
|
||||||
|
basePath="/apikey"
|
||||||
|
icon={<AddIcon />}
|
||||||
|
label={translate('resources.apikey.actions.add')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
sort={{ field: 'createdAt', order: 'DESC' }}
|
||||||
|
exporter={false}
|
||||||
|
bulkActionButtons={false}
|
||||||
|
filters={<ApiKeyFilter />}
|
||||||
|
>
|
||||||
|
{isXsmall ? (
|
||||||
|
<SimpleList
|
||||||
|
primaryText={(r) => r.name}
|
||||||
|
secondaryText={(r) => r.key}
|
||||||
|
tertiaryText={(r) => <DateField record={r} source="createdAt" />}
|
||||||
|
linkType={'edit'}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Datagrid rowClick="edit">
|
||||||
|
<TextField source="name" />
|
||||||
|
<TextField source="key" />
|
||||||
|
<DateField source="createdAt" showTime />
|
||||||
|
</Datagrid>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ApiKeyList
|
||||||
11
ui/src/apikey/index.js
Normal file
11
ui/src/apikey/index.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import VpnKeyIcon from '@material-ui/icons/VpnKey'
|
||||||
|
import ApiKeyList from './ApiKeyList'
|
||||||
|
import ApiKeyCreate from './ApiKeyCreate'
|
||||||
|
import ApiKeyEdit from './ApiKeyEdit'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
list: ApiKeyList,
|
||||||
|
create: ApiKeyCreate,
|
||||||
|
edit: ApiKeyEdit,
|
||||||
|
icon: VpnKeyIcon,
|
||||||
|
}
|
||||||
@ -1,6 +1,22 @@
|
|||||||
{
|
{
|
||||||
"languageName": "English",
|
"languageName": "English",
|
||||||
"resources": {
|
"resources": {
|
||||||
|
"apikey": {
|
||||||
|
"name": "API Key |||| API Keys",
|
||||||
|
"fields": {
|
||||||
|
"name": "Name",
|
||||||
|
"key": "Key",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"key_help_text": "This key will be used for API authentication"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"add": "Add API Key"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"created": "API Key created",
|
||||||
|
"updated": "API Key updated"
|
||||||
|
}
|
||||||
|
},
|
||||||
"song": {
|
"song": {
|
||||||
"name": "Song |||| Songs",
|
"name": "Song |||| Songs",
|
||||||
"fields": {
|
"fields": {
|
||||||
|
|||||||
@ -63,6 +63,7 @@ AboutMenuItem.displayName = 'AboutMenuItem'
|
|||||||
|
|
||||||
const settingsResources = (resource) =>
|
const settingsResources = (resource) =>
|
||||||
resource.name !== 'user' &&
|
resource.name !== 'user' &&
|
||||||
|
resource.name !== 'apikey' &&
|
||||||
resource.hasList &&
|
resource.hasList &&
|
||||||
resource.options &&
|
resource.options &&
|
||||||
resource.options.subMenu === 'settings'
|
resource.options.subMenu === 'settings'
|
||||||
@ -95,6 +96,14 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderApiKeyMenuItemLink = () => {
|
||||||
|
const apiKeyResource = resourceDefinition('apikey')
|
||||||
|
if (!apiKeyResource) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return renderSettingsMenuItemLink(apiKeyResource)
|
||||||
|
}
|
||||||
|
|
||||||
const renderSettingsMenuItemLink = (resource, id) => {
|
const renderSettingsMenuItemLink = (resource, id) => {
|
||||||
const label = translate(`resources.${resource.name}.name`, {
|
const label = translate(`resources.${resource.name}.name`, {
|
||||||
smart_count: id ? 1 : 2,
|
smart_count: id ? 1 : 2,
|
||||||
@ -128,6 +137,7 @@ const CustomUserMenu = ({ onClick, ...rest }) => {
|
|||||||
<PersonalMenu sidebarIsOpen={true} onClick={onClick} />
|
<PersonalMenu sidebarIsOpen={true} onClick={onClick} />
|
||||||
<Divider />
|
<Divider />
|
||||||
{renderUserMenuItemLink()}
|
{renderUserMenuItemLink()}
|
||||||
|
{renderApiKeyMenuItemLink()}
|
||||||
{resources
|
{resources
|
||||||
.filter(settingsResources)
|
.filter(settingsResources)
|
||||||
.map((r) => renderSettingsMenuItemLink(r))}
|
.map((r) => renderSettingsMenuItemLink(r))}
|
||||||
|
|||||||
@ -103,7 +103,10 @@ const Menu = ({ dense = false }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const subItems = (subMenu) => (resource) =>
|
const subItems = (subMenu) => (resource) =>
|
||||||
resource.hasList && resource.options && resource.options.subMenu === subMenu
|
resource.hasList &&
|
||||||
|
resource.options &&
|
||||||
|
resource.options.subMenu === subMenu &&
|
||||||
|
resource.name !== 'apikey'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user