mirror of
https://github.com/idanoo/autobrr
synced 2025-07-22 16:29:12 +00:00
feat(api): add apikey support (#408)
* feat(api): add apikey support * feat(web): api settings crud
This commit is contained in:
parent
9c036033e9
commit
fa20978d58
31 changed files with 834 additions and 70 deletions
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/autobrr/autobrr/internal/action"
|
||||
"github.com/autobrr/autobrr/internal/api"
|
||||
"github.com/autobrr/autobrr/internal/auth"
|
||||
"github.com/autobrr/autobrr/internal/config"
|
||||
"github.com/autobrr/autobrr/internal/database"
|
||||
|
@ -74,6 +75,7 @@ func main() {
|
|||
|
||||
// setup repos
|
||||
var (
|
||||
apikeyRepo = database.NewAPIRepo(log, db)
|
||||
downloadClientRepo = database.NewDownloadClientRepo(log, db)
|
||||
actionRepo = database.NewActionRepo(log, db, downloadClientRepo)
|
||||
filterRepo = database.NewFilterRepo(log, db)
|
||||
|
@ -88,15 +90,16 @@ func main() {
|
|||
|
||||
// setup services
|
||||
var (
|
||||
apiService = api.NewService(log, apikeyRepo)
|
||||
notificationService = notification.NewService(log, notificationRepo)
|
||||
schedulingService = scheduler.NewService(log, version, notificationService)
|
||||
apiService = indexer.NewAPIService(log)
|
||||
indexerAPIService = indexer.NewAPIService(log)
|
||||
userService = user.NewService(userRepo)
|
||||
authService = auth.NewService(log, userService)
|
||||
downloadClientService = download_client.NewService(log, downloadClientRepo)
|
||||
actionService = action.NewService(log, actionRepo, downloadClientService, bus)
|
||||
indexerService = indexer.NewService(log, cfg.Config, indexerRepo, apiService, schedulingService)
|
||||
filterService = filter.NewService(log, filterRepo, actionRepo, apiService, indexerService)
|
||||
indexerService = indexer.NewService(log, cfg.Config, indexerRepo, indexerAPIService, schedulingService)
|
||||
filterService = filter.NewService(log, filterRepo, actionRepo, indexerAPIService, indexerService)
|
||||
releaseService = release.NewService(log, releaseRepo, actionService, filterService)
|
||||
ircService = irc.NewService(log, ircRepo, releaseService, indexerService, notificationService)
|
||||
feedService = feed.NewService(log, feedRepo, feedCacheRepo, releaseService, schedulingService)
|
||||
|
@ -116,6 +119,7 @@ func main() {
|
|||
commit,
|
||||
date,
|
||||
actionService,
|
||||
apiService,
|
||||
authService,
|
||||
downloadClientService,
|
||||
filterService,
|
||||
|
|
4
go.mod
4
go.mod
|
@ -12,7 +12,8 @@ require (
|
|||
github.com/ergochat/irc-go v0.2.0
|
||||
github.com/fsnotify/fsnotify v1.5.4
|
||||
github.com/gdm85/go-libdeluge v0.5.6
|
||||
github.com/go-chi/chi v1.5.4
|
||||
github.com/go-chi/chi/v5 v5.0.7
|
||||
github.com/go-chi/render v1.0.2
|
||||
github.com/gorilla/sessions v1.2.1
|
||||
github.com/gosimple/slug v1.12.0
|
||||
github.com/hashicorp/go-version v1.6.0
|
||||
|
@ -38,6 +39,7 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
github.com/ajg/form v1.5.1 // indirect
|
||||
github.com/anacrolix/dht/v2 v2.18.0 // indirect
|
||||
github.com/anacrolix/missinggo v1.3.0 // indirect
|
||||
github.com/anacrolix/missinggo/v2 v2.7.0 // indirect
|
||||
|
|
8
go.sum
8
go.sum
|
@ -48,6 +48,8 @@ github.com/RoaringBitmap/roaring v0.4.17/go.mod h1:D3qVegWTmfCaX4Bl5CrBE9hfrSrrX
|
|||
github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
|
||||
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
|
||||
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
|
||||
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
|
||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
|
@ -134,8 +136,10 @@ github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod
|
|||
github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||
github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||
github.com/go-chi/chi v1.5.4 h1:QHdzF2szwjqVV4wmByUnTcsbIg7UGaQ0tPF2t5GcAIs=
|
||||
github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg=
|
||||
github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8=
|
||||
github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/render v1.0.2 h1:4ER/udB0+fMWB2Jlf15RV3F4A2FDuYi/9f+lFttR/Lg=
|
||||
github.com/go-chi/render v1.0.2/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
|
|
91
internal/api/service.go
Normal file
91
internal/api/service.go
Normal file
|
@ -0,0 +1,91 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
"github.com/autobrr/autobrr/internal/logger"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
List(ctx context.Context) ([]domain.APIKey, error)
|
||||
Store(ctx context.Context, key *domain.APIKey) error
|
||||
Update(ctx context.Context, key *domain.APIKey) error
|
||||
Delete(ctx context.Context, key string) error
|
||||
ValidateAPIKey(ctx context.Context, token string) bool
|
||||
}
|
||||
|
||||
type service struct {
|
||||
log zerolog.Logger
|
||||
repo domain.APIRepo
|
||||
|
||||
keyCache []domain.APIKey
|
||||
}
|
||||
|
||||
func NewService(log logger.Logger, repo domain.APIRepo) Service {
|
||||
return &service{
|
||||
log: log.With().Str("module", "api").Logger(),
|
||||
repo: repo,
|
||||
keyCache: []domain.APIKey{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) List(ctx context.Context) ([]domain.APIKey, error) {
|
||||
if len(s.keyCache) > 0 {
|
||||
return s.keyCache, nil
|
||||
}
|
||||
|
||||
return s.repo.GetKeys(ctx)
|
||||
}
|
||||
|
||||
func (s *service) Store(ctx context.Context, key *domain.APIKey) error {
|
||||
key.Key = generateSecureToken(16)
|
||||
|
||||
if err := s.repo.Store(ctx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(s.keyCache) > 0 {
|
||||
// set new key
|
||||
s.keyCache = append(s.keyCache, *key)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) Update(ctx context.Context, key *domain.APIKey) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) Delete(ctx context.Context, key string) error {
|
||||
// reset
|
||||
s.keyCache = []domain.APIKey{}
|
||||
|
||||
return s.repo.Delete(ctx, key)
|
||||
}
|
||||
|
||||
func (s *service) ValidateAPIKey(ctx context.Context, key string) bool {
|
||||
keys, err := s.repo.GetKeys(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, k := range keys {
|
||||
if k.Key == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func generateSecureToken(length int) string {
|
||||
b := make([]byte, length)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
114
internal/database/api.go
Normal file
114
internal/database/api.go
Normal file
|
@ -0,0 +1,114 @@
|
|||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
"github.com/autobrr/autobrr/internal/logger"
|
||||
"github.com/autobrr/autobrr/pkg/errors"
|
||||
|
||||
"github.com/lib/pq"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func NewAPIRepo(log logger.Logger, db *DB) domain.APIRepo {
|
||||
return &APIRepo{
|
||||
log: log.With().Str("repo", "api").Logger(),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
type APIRepo struct {
|
||||
log zerolog.Logger
|
||||
db *DB
|
||||
cache map[string]domain.APIKey
|
||||
}
|
||||
|
||||
func (r *APIRepo) Store(ctx context.Context, key *domain.APIKey) error {
|
||||
queryBuilder := r.db.squirrel.
|
||||
Insert("api_key").
|
||||
Columns(
|
||||
"name",
|
||||
"key",
|
||||
"scopes",
|
||||
).
|
||||
Values(
|
||||
key.Name,
|
||||
key.Key,
|
||||
pq.Array(key.Scopes),
|
||||
).
|
||||
Suffix("RETURNING created_at").RunWith(r.db.handler)
|
||||
|
||||
var createdAt time.Time
|
||||
|
||||
if err := queryBuilder.QueryRowContext(ctx).Scan(&createdAt); err != nil {
|
||||
return errors.Wrap(err, "error executing query")
|
||||
}
|
||||
|
||||
key.CreatedAt = createdAt
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APIRepo) Delete(ctx context.Context, key string) error {
|
||||
queryBuilder := r.db.squirrel.
|
||||
Delete("api_key").
|
||||
Where("key = ?", key)
|
||||
|
||||
query, args, err := queryBuilder.ToSql()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error building query")
|
||||
}
|
||||
|
||||
_, err = r.db.handler.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "error executing query")
|
||||
}
|
||||
|
||||
r.log.Debug().Msgf("successfully deleted: %v", key)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APIRepo) GetKeys(ctx context.Context) ([]domain.APIKey, error) {
|
||||
queryBuilder := r.db.squirrel.
|
||||
Select(
|
||||
"name",
|
||||
"key",
|
||||
"scopes",
|
||||
"created_at",
|
||||
).
|
||||
From("api_key")
|
||||
|
||||
query, args, err := queryBuilder.ToSql()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error building query")
|
||||
}
|
||||
|
||||
rows, err := r.db.handler.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "error executing query")
|
||||
}
|
||||
|
||||
defer rows.Close()
|
||||
|
||||
keys := make([]domain.APIKey, 0)
|
||||
for rows.Next() {
|
||||
var a domain.APIKey
|
||||
|
||||
var name sql.NullString
|
||||
|
||||
if err := rows.Scan(&name, &a.Key, pq.Array(&a.Scopes), &a.CreatedAt); err != nil {
|
||||
return nil, errors.Wrap(err, "error scanning row")
|
||||
|
||||
}
|
||||
|
||||
a.Name = name.String
|
||||
|
||||
keys = append(keys, a)
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
|
@ -315,6 +315,14 @@ CREATE TABLE feed_cache
|
|||
value TEXT,
|
||||
ttl TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE api_key
|
||||
(
|
||||
name TEXT,
|
||||
key TEXT PRIMARY KEY,
|
||||
scopes TEXT [] DEFAULT '{}' NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`
|
||||
|
||||
var postgresMigrations = []string{
|
||||
|
@ -541,4 +549,12 @@ CREATE INDEX indexer_identifier_index
|
|||
ALTER TABLE filter
|
||||
ADD COLUMN except_origins TEXT [] DEFAULT '{}';
|
||||
`,
|
||||
`CREATE TABLE api_key
|
||||
(
|
||||
name TEXT,
|
||||
key TEXT PRIMARY KEY,
|
||||
scopes TEXT [] DEFAULT '{}' NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`,
|
||||
}
|
||||
|
|
|
@ -298,6 +298,14 @@ CREATE TABLE feed_cache
|
|||
value TEXT,
|
||||
ttl TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE api_key
|
||||
(
|
||||
name TEXT,
|
||||
key TEXT PRIMARY KEY,
|
||||
scopes TEXT [] DEFAULT '{}' NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`
|
||||
|
||||
var sqliteMigrations = []string{
|
||||
|
@ -861,4 +869,12 @@ CREATE INDEX indexer_identifier_index
|
|||
ALTER TABLE filter
|
||||
ADD COLUMN except_origins TEXT [] DEFAULT '{}';
|
||||
`,
|
||||
`CREATE TABLE api_key
|
||||
(
|
||||
name TEXT,
|
||||
key TEXT PRIMARY KEY,
|
||||
scopes TEXT [] DEFAULT '{}' NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`,
|
||||
}
|
||||
|
|
19
internal/domain/api.go
Normal file
19
internal/domain/api.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
type APIRepo interface {
|
||||
Store(ctx context.Context, key *APIKey) error
|
||||
Delete(ctx context.Context, key string) error
|
||||
GetKeys(ctx context.Context) ([]APIKey, error)
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
Scopes []string `json:"scopes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
|
@ -8,7 +8,7 @@ import (
|
|||
"strconv"
|
||||
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type actionService interface {
|
||||
|
|
78
internal/http/apikey.go
Normal file
78
internal/http/apikey.go
Normal file
|
@ -0,0 +1,78 @@
|
|||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/render"
|
||||
)
|
||||
|
||||
type apikeyService interface {
|
||||
List(ctx context.Context) ([]domain.APIKey, error)
|
||||
Store(ctx context.Context, key *domain.APIKey) error
|
||||
Update(ctx context.Context, key *domain.APIKey) error
|
||||
Delete(ctx context.Context, key string) error
|
||||
ValidateAPIKey(ctx context.Context, token string) bool
|
||||
}
|
||||
|
||||
type apikeyHandler struct {
|
||||
encoder encoder
|
||||
service apikeyService
|
||||
}
|
||||
|
||||
func newAPIKeyHandler(encoder encoder, service apikeyService) *apikeyHandler {
|
||||
return &apikeyHandler{
|
||||
encoder: encoder,
|
||||
service: service,
|
||||
}
|
||||
}
|
||||
|
||||
func (h apikeyHandler) Routes(r chi.Router) {
|
||||
r.Get("/", h.list)
|
||||
r.Post("/", h.store)
|
||||
r.Delete("/{apikey}", h.delete)
|
||||
}
|
||||
|
||||
func (h apikeyHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
keys, err := h.service.List(r.Context())
|
||||
if err != nil {
|
||||
h.encoder.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.JSON(w, r, keys)
|
||||
}
|
||||
|
||||
func (h apikeyHandler) store(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var (
|
||||
ctx = r.Context()
|
||||
data domain.APIKey
|
||||
)
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||
// encode error
|
||||
h.encoder.StatusInternalError(w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Store(ctx, &data); err != nil {
|
||||
// encode error
|
||||
h.encoder.StatusInternalError(w)
|
||||
return
|
||||
}
|
||||
|
||||
h.encoder.StatusResponse(ctx, w, data, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (h apikeyHandler) delete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.service.Delete(r.Context(), chi.URLParam(r, "apikey")); err != nil {
|
||||
h.encoder.StatusInternalError(w)
|
||||
return
|
||||
}
|
||||
h.encoder.NoContent(w)
|
||||
}
|
|
@ -5,7 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/sessions"
|
||||
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
|
|
|
@ -3,7 +3,7 @@ package http
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type configJson struct {
|
||||
|
|
|
@ -7,7 +7,7 @@ import (
|
|||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
)
|
||||
|
|
|
@ -15,7 +15,7 @@ type errorResponse struct {
|
|||
|
||||
func (e encoder) StatusResponse(ctx context.Context, w http.ResponseWriter, response interface{}, status int) {
|
||||
if response != nil {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf=8")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
@ -30,6 +30,15 @@ func (e encoder) StatusCreated(w http.ResponseWriter) {
|
|||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
func (e encoder) StatusCreatedData(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
if err := json.NewEncoder(w).Encode(data); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (e encoder) NoContent(w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
@ -47,7 +56,7 @@ func (e encoder) Error(w http.ResponseWriter, err error) {
|
|||
Message: err.Error(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf=8")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import (
|
|||
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type feedService interface {
|
||||
|
|
|
@ -6,7 +6,7 @@ import (
|
|||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
)
|
||||
|
|
|
@ -5,7 +5,7 @@ import (
|
|||
|
||||
"github.com/autobrr/autobrr/internal/database"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type healthHandler struct {
|
||||
|
|
|
@ -8,7 +8,7 @@ import (
|
|||
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type indexerService interface {
|
||||
|
|
|
@ -6,7 +6,7 @@ import (
|
|||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
)
|
||||
|
|
|
@ -4,14 +4,30 @@ import "net/http"
|
|||
|
||||
func (s Server) IsAuthenticated(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// check session
|
||||
session, _ := s.cookieStore.Get(r, "user_session")
|
||||
if token := r.Header.Get("X-API-Token"); token != "" {
|
||||
// check header
|
||||
if !s.apiService.ValidateAPIKey(r.Context(), token) {
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
if auth, ok := session.Values["authenticated"].(bool); !ok || !auth {
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
} else if key := r.URL.Query().Get("apikey"); key != "" {
|
||||
// check query param lke ?apikey=TOKEN
|
||||
if !s.apiService.ValidateAPIKey(r.Context(), key) {
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// check session
|
||||
session, _ := s.cookieStore.Get(r, "user_session")
|
||||
|
||||
// Check if user is authenticated
|
||||
if auth, ok := session.Values["authenticated"].(bool); !ok || !auth {
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import (
|
|||
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type notificationService interface {
|
||||
|
|
|
@ -7,7 +7,7 @@ import (
|
|||
"strconv"
|
||||
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type releaseService interface {
|
||||
|
|
|
@ -10,7 +10,8 @@ import (
|
|||
"github.com/autobrr/autobrr/internal/domain"
|
||||
"github.com/autobrr/autobrr/web"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/r3labs/sse/v2"
|
||||
"github.com/rs/cors"
|
||||
|
@ -28,6 +29,7 @@ type Server struct {
|
|||
date string
|
||||
|
||||
actionService actionService
|
||||
apiService apikeyService
|
||||
authService authService
|
||||
downloadClientService downloadClientService
|
||||
filterService filterService
|
||||
|
@ -38,7 +40,7 @@ type Server struct {
|
|||
releaseService releaseService
|
||||
}
|
||||
|
||||
func NewServer(config *domain.Config, sse *sse.Server, db *database.DB, version string, commit string, date string, actionService actionService, authService authService, downloadClientSvc downloadClientService, filterSvc filterService, feedSvc feedService, indexerSvc indexerService, ircSvc ircService, notificationSvc notificationService, releaseSvc releaseService) Server {
|
||||
func NewServer(config *domain.Config, sse *sse.Server, db *database.DB, version string, commit string, date string, actionService actionService, apiService apikeyService, authService authService, downloadClientSvc downloadClientService, filterSvc filterService, feedSvc feedService, indexerSvc indexerService, ircSvc ircService, notificationSvc notificationService, releaseSvc releaseService) Server {
|
||||
return Server{
|
||||
config: config,
|
||||
sse: sse,
|
||||
|
@ -50,6 +52,7 @@ func NewServer(config *domain.Config, sse *sse.Server, db *database.DB, version
|
|||
cookieStore: sessions.NewCookieStore([]byte(config.SessionSecret)),
|
||||
|
||||
actionService: actionService,
|
||||
apiService: apiService,
|
||||
authService: authService,
|
||||
downloadClientService: downloadClientSvc,
|
||||
filterService: filterSvc,
|
||||
|
@ -78,6 +81,10 @@ func (s Server) Open() error {
|
|||
func (s Server) Handler() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowCredentials: true,
|
||||
AllowedMethods: []string{"HEAD", "OPTIONS", "GET", "POST", "PUT", "PATCH", "DELETE"},
|
||||
|
@ -116,6 +123,7 @@ func (s Server) Handler() http.Handler {
|
|||
r.Route("/feeds", newFeedHandler(encoder, s.feedService).Routes)
|
||||
r.Route("/irc", newIrcHandler(encoder, s.ircService).Routes)
|
||||
r.Route("/indexer", newIndexerHandler(encoder, s.indexerService, s.ircService).Routes)
|
||||
r.Route("/keys", newAPIKeyHandler(encoder, s.apiService).Routes)
|
||||
r.Route("/notification", newNotificationHandler(encoder, s.notificationService).Routes)
|
||||
r.Route("/release", newReleaseHandler(encoder, s.releaseService).Routes)
|
||||
|
||||
|
|
|
@ -74,6 +74,11 @@ export const APIClient = {
|
|||
delete: (id: number) => appClient.Delete(`api/actions/${id}`),
|
||||
toggleEnable: (id: number) => appClient.Patch(`api/actions/${id}/toggleEnabled`)
|
||||
},
|
||||
apikeys: {
|
||||
getAll: () => appClient.Get<APIKey[]>("api/keys"),
|
||||
create: (key: APIKey) => appClient.Post("api/keys", key),
|
||||
delete: (key: string) => appClient.Delete(`api/keys/${key}`),
|
||||
},
|
||||
config: {
|
||||
get: () => appClient.Get<Config>("api/config")
|
||||
},
|
||||
|
|
77
web/src/components/fields/text.tsx
Normal file
77
web/src/components/fields/text.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { useToggle } from "../../hooks/hooks";
|
||||
import { ClipboardCopyIcon, EyeIcon, EyeOffIcon, CheckIcon } from "@heroicons/react/outline";
|
||||
import { useState } from "react";
|
||||
|
||||
interface KeyFieldProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const KeyField = ({ value }: KeyFieldProps) => {
|
||||
const [isVisible, toggleVisibility] = useToggle(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
async function copyTextToClipboard(text: string) {
|
||||
if ("clipboard" in navigator) {
|
||||
return await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
return document.execCommand("copy", true, text);
|
||||
}
|
||||
}
|
||||
|
||||
// onClick handler function for the copy button
|
||||
const handleCopyClick = () => {
|
||||
// Asynchronously call copyTextToClipboard
|
||||
copyTextToClipboard(value)
|
||||
.then(() => {
|
||||
// If successful, update the isCopied state value
|
||||
setIsCopied(true);
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 1500);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sm:col-span-2 w-full">
|
||||
<div className="flex rounded-md shadow-sm">
|
||||
<div className="relative flex items-stretch flex-grow focus-within:z-10">
|
||||
<input
|
||||
id="keyfield"
|
||||
type={isVisible ? "text" : "password"}
|
||||
value={value}
|
||||
readOnly={true}
|
||||
className="focus:outline-none dark:focus:border-blue-500 focus:border-indigo-500 dark:focus:ring-blue-500 block w-full rounded-none rounded-l-md sm:text-sm border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-300 dark:border-gray-700 hover:bg-gray-100 text-sm font-medium text-gray-700 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
onClick={toggleVisibility}
|
||||
title="show"
|
||||
>
|
||||
{!isVisible ? <EyeIcon className="h-5 w-5 text-gray-400 hover:text-gray-500" aria-hidden="true" /> : <EyeOffIcon className="h-5 w-5 text-gray-400 hover:text-gray-500" aria-hidden="true" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-300 dark:border-gray-700 hover:bg-gray-100 text-sm font-medium rounded-r-md text-gray-700 dark:text-gray-100 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
|
||||
onClick={handleCopyClick}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
{isCopied
|
||||
? <CheckIcon
|
||||
className="text-blue-500 w-5 h-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
: <ClipboardCopyIcon
|
||||
className="text-blue-500 w-5 h-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -17,6 +17,7 @@ import { IrcSettings } from "../screens/settings/Irc";
|
|||
import NotificationSettings from "../screens/settings/Notifications";
|
||||
import { RegexPlayground } from "../screens/settings/RegexPlayground";
|
||||
import ReleaseSettings from "../screens/settings/Releases";
|
||||
import APISettings from "../screens/settings/Api";
|
||||
|
||||
import { baseUrl } from "../utils";
|
||||
|
||||
|
@ -35,6 +36,7 @@ export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => (
|
|||
</Route>
|
||||
<Route path="settings" element={<Settings />}>
|
||||
<Route index element={<ApplicationSettings />} />
|
||||
<Route path="api-keys" element={<APISettings />} />
|
||||
<Route path="indexers" element={<IndexerSettings />} />
|
||||
<Route path="feeds" element={<FeedSettings />} />
|
||||
<Route path="irc" element={<IrcSettings />} />
|
||||
|
|
158
web/src/forms/settings/APIKeyAddForm.tsx
Normal file
158
web/src/forms/settings/APIKeyAddForm.tsx
Normal file
|
@ -0,0 +1,158 @@
|
|||
import { Fragment } from "react";
|
||||
import { useMutation } from "react-query";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { XIcon } from "@heroicons/react/solid";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import type { FieldProps } from "formik";
|
||||
import { Field, Form, Formik, FormikErrors, FormikValues } from "formik";
|
||||
|
||||
import { queryClient } from "../../App";
|
||||
import { APIClient } from "../../api/APIClient";
|
||||
import DEBUG from "../../components/debug";
|
||||
import Toast from "../../components/notifications/Toast";
|
||||
|
||||
interface apiKeyAddFormProps {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
function APIKeyAddForm({ isOpen, toggle }: apiKeyAddFormProps) {
|
||||
const mutation = useMutation(
|
||||
(apikey: APIKey) => APIClient.apikeys.create(apikey),
|
||||
{
|
||||
onSuccess: (_, key) => {
|
||||
queryClient.invalidateQueries("apikeys");
|
||||
toast.custom((t) => <Toast type="success" body={`API key ${key.name} was added`} t={t}/>);
|
||||
|
||||
toggle();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleSubmit = (data: unknown) => mutation.mutate(data as APIKey);
|
||||
const validate = (values: FormikValues) => {
|
||||
const errors = {} as FormikErrors<FormikValues>;
|
||||
if (!values.name) {
|
||||
errors.name = "Required";
|
||||
}
|
||||
return errors;
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<Dialog.Overlay className="absolute inset-0"/>
|
||||
|
||||
<div className="fixed inset-y-0 right-0 pl-10 max-w-full flex sm:pl-16">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transform transition ease-in-out duration-500 sm:duration-700"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<div className="w-screen max-w-2xl border-l dark:border-gray-700">
|
||||
|
||||
<Formik
|
||||
initialValues={{
|
||||
name: "",
|
||||
scopes: []
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
validate={validate}
|
||||
>
|
||||
{({ values }) => (
|
||||
<Form className="h-full flex flex-col bg-white dark:bg-gray-800 shadow-xl overflow-y-scroll">
|
||||
<div className="flex-1">
|
||||
<div className="px-4 py-6 bg-gray-50 dark:bg-gray-900 sm:px-6">
|
||||
<div className="flex items-start justify-between space-x-3">
|
||||
<div className="space-y-1">
|
||||
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">Create API
|
||||
key</Dialog.Title>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Add new API key.
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-7 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="light:bg-white rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-blue-500"
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className="sr-only">Close panel</span>
|
||||
<XIcon className="h-6 w-6" aria-hidden="true"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="py-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
|
||||
<div
|
||||
className="space-y-1 px-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium text-gray-900 dark:text-white sm:mt-px sm:pt-2"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
</div>
|
||||
<Field name="name">
|
||||
{({
|
||||
field,
|
||||
meta
|
||||
}: FieldProps) => (
|
||||
<div className="sm:col-span-2">
|
||||
<input
|
||||
{...field}
|
||||
id="name"
|
||||
type="text"
|
||||
className="block w-full shadow-sm dark:bg-gray-800 border-gray-300 dark:border-gray-700 sm:text-sm dark:text-white focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 rounded-md"
|
||||
/>
|
||||
|
||||
{meta.touched && meta.error &&
|
||||
<span className="block mt-2 text-red-500">{meta.error}</span>}
|
||||
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-shrink-0 px-4 border-t border-gray-200 dark:border-gray-700 py-5 sm:px-6">
|
||||
<div className="space-x-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-white dark:bg-gray-800 py-2 px-4 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-blue-500"
|
||||
onClick={toggle}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 dark:bg-blue-600 hover:bg-indigo-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 dark:focus:ring-blue-500"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<DEBUG values={values}/>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default APIKeyAddForm;
|
|
@ -24,6 +24,7 @@ const subNavigation: NavTabType[] = [
|
|||
{ name: "Feeds", href: "feeds", icon: RssIcon },
|
||||
{ name: "Clients", href: "clients", icon: DownloadIcon },
|
||||
{ name: "Notifications", href: "notifications", icon: BellIcon },
|
||||
{ name: "API keys", href: "api-keys", icon: KeyIcon },
|
||||
{ name: "Releases", href: "releases", icon: CollectionIcon }
|
||||
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
|
||||
// {name: 'Rules', href: 'rules', icon: ClipboardCheckIcon, current: false},
|
||||
|
|
137
web/src/screens/settings/Api.tsx
Normal file
137
web/src/screens/settings/Api.tsx
Normal file
|
@ -0,0 +1,137 @@
|
|||
import { queryClient } from "../../App";
|
||||
import { useRef } from "react";
|
||||
import { useMutation, useQuery } from "react-query";
|
||||
import { KeyField } from "../../components/fields/text";
|
||||
import { DeleteModal } from "../../components/modals";
|
||||
import APIKeyAddForm from "../../forms/settings/APIKeyAddForm";
|
||||
import Toast from "../../components/notifications/Toast";
|
||||
import { APIClient } from "../../api/APIClient";
|
||||
import { useToggle } from "../../hooks/hooks";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { classNames } from "../../utils";
|
||||
import { TrashIcon } from "@heroicons/react/outline";
|
||||
import { EmptySimple } from "../../components/emptystates";
|
||||
|
||||
function APISettings() {
|
||||
const [addFormIsOpen, toggleAddForm] = useToggle(false);
|
||||
|
||||
const { isLoading, data } = useQuery(
|
||||
["apikeys"],
|
||||
() => APIClient.apikeys.getAll(),
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
onError: err => console.log(err)
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9">
|
||||
<div className="pb-6 py-6 px-4 sm:p-6 lg:pb-8">
|
||||
<APIKeyAddForm isOpen={addFormIsOpen} toggle={toggleAddForm}/>
|
||||
|
||||
<div className="-ml-4 -mt-4 flex justify-between items-center flex-wrap sm:flex-nowrap">
|
||||
<div className="ml-4 mt-4">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">API keys</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Manage API keys.
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4 mt-4 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="relative inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 dark:bg-blue-600 hover:bg-indigo-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
onClick={toggleAddForm}
|
||||
>
|
||||
Add new
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && data.length > 0 ?
|
||||
<section className="mt-6 light:bg-white dark:bg-gray-800 light:shadow sm:rounded-md">
|
||||
<ol className="min-w-full relative">
|
||||
<li className="grid grid-cols-12 gap-4 mb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<div
|
||||
className="col-span-5 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Name
|
||||
</div>
|
||||
<div
|
||||
className="col-span-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Key
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{data && data.map((k) => (
|
||||
<APIListItem key={k.key} apikey={k}/>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
: <EmptySimple title="No API keys" subtitle="Create a new" buttonAction={toggleAddForm}
|
||||
buttonText="Create API key"/>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ApiKeyItemProps {
|
||||
apikey: APIKey
|
||||
}
|
||||
|
||||
function APIListItem({ apikey }: ApiKeyItemProps) {
|
||||
const cancelModalButtonRef = useRef(null);
|
||||
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
|
||||
|
||||
const deleteMutation = useMutation(
|
||||
(key: string) => APIClient.apikeys.delete(key),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["apikeys"]);
|
||||
queryClient.invalidateQueries(["apikeys", apikey.key]);
|
||||
|
||||
toast.custom((t) => <Toast type="success" body={`API key ${apikey?.name} was deleted`} t={t}/>);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="text-gray-500 dark:text-gray-400">
|
||||
<DeleteModal
|
||||
isOpen={deleteModalIsOpen}
|
||||
toggle={toggleDeleteModal}
|
||||
buttonRef={cancelModalButtonRef}
|
||||
deleteAction={() => {
|
||||
deleteMutation.mutate(apikey.key);
|
||||
toggleDeleteModal();
|
||||
}}
|
||||
title={`Remove API key: ${apikey.name}`}
|
||||
text="Are you sure you want to remove this API key? This action cannot be undone."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4 items-center py-2">
|
||||
<div className="col-span-5 flex items-center text-sm font-medium text-gray-900 dark:text-white">
|
||||
{apikey.name}
|
||||
</div>
|
||||
<div className="col-span-6 flex items-center text-sm font-medium text-gray-900 dark:text-white">
|
||||
<KeyField value={apikey.key}/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1 flex items-center justify-end text-sm font-medium text-gray-900 dark:text-white">
|
||||
<button
|
||||
className={classNames(
|
||||
"text-gray-900 dark:text-gray-300",
|
||||
"font-medium group flex rounded-md items-center px-2 py-2 text-sm"
|
||||
)}
|
||||
onClick={toggleDeleteModal}
|
||||
title="Delete key"
|
||||
>
|
||||
<TrashIcon
|
||||
className="text-red-500 w-5 h-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default APISettings;
|
|
@ -1,5 +1,4 @@
|
|||
import { useQuery } from "react-query";
|
||||
|
||||
import { APIClient } from "../../api/APIClient";
|
||||
import { Checkbox } from "../../components/Checkbox";
|
||||
import { SettingsContext } from "../../utils/Context";
|
||||
|
@ -18,7 +17,7 @@ function ApplicationSettings() {
|
|||
);
|
||||
|
||||
return (
|
||||
<form className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9" action="#" method="POST">
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9">
|
||||
<div className="py-6 px-4 sm:p-6 lg:pb-8">
|
||||
<div>
|
||||
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Application</h2>
|
||||
|
@ -27,54 +26,56 @@ function ApplicationSettings() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{!isLoading && data && (
|
||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||
<div className="col-span-6 sm:col-span-4">
|
||||
<label htmlFor="host" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
|
||||
<form className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9" action="#" method="POST">
|
||||
{!isLoading && data && (
|
||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||
<div className="col-span-6 sm:col-span-4">
|
||||
<label htmlFor="host" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
|
||||
Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="host"
|
||||
id="host"
|
||||
value={data.host}
|
||||
disabled={true}
|
||||
className="mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="host"
|
||||
id="host"
|
||||
value={data.host}
|
||||
disabled={true}
|
||||
className="mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-6 sm:col-span-4">
|
||||
<label htmlFor="port" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
|
||||
<div className="col-span-6 sm:col-span-4">
|
||||
<label htmlFor="port" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
|
||||
Port
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="port"
|
||||
id="port"
|
||||
value={data.port}
|
||||
disabled={true}
|
||||
className="mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="port"
|
||||
id="port"
|
||||
value={data.port}
|
||||
disabled={true}
|
||||
className="mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-6 sm:col-span-4">
|
||||
<label htmlFor="base_url" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
|
||||
<div className="col-span-6 sm:col-span-4">
|
||||
<label htmlFor="base_url" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
|
||||
Base url
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="base_url"
|
||||
id="base_url"
|
||||
value={data.base_url}
|
||||
disabled={true}
|
||||
className="mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="base_url"
|
||||
id="base_url"
|
||||
value={data.base_url}
|
||||
disabled={true}
|
||||
className="mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="pb-6 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div className="px-4 py-5 sm:p-0">
|
||||
<dl className="sm:divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{data?.version ? (
|
||||
|
@ -124,7 +125,7 @@ function ApplicationSettings() {
|
|||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
6
web/src/types/API.d.ts
vendored
Normal file
6
web/src/types/API.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
interface APIKey {
|
||||
name: string;
|
||||
key: string;
|
||||
scopes: string[];
|
||||
created_at: Date;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue