mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +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/spf13/pflag"
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/action"
|
"github.com/autobrr/autobrr/internal/action"
|
||||||
|
"github.com/autobrr/autobrr/internal/api"
|
||||||
"github.com/autobrr/autobrr/internal/auth"
|
"github.com/autobrr/autobrr/internal/auth"
|
||||||
"github.com/autobrr/autobrr/internal/config"
|
"github.com/autobrr/autobrr/internal/config"
|
||||||
"github.com/autobrr/autobrr/internal/database"
|
"github.com/autobrr/autobrr/internal/database"
|
||||||
|
@ -74,6 +75,7 @@ func main() {
|
||||||
|
|
||||||
// setup repos
|
// setup repos
|
||||||
var (
|
var (
|
||||||
|
apikeyRepo = database.NewAPIRepo(log, db)
|
||||||
downloadClientRepo = database.NewDownloadClientRepo(log, db)
|
downloadClientRepo = database.NewDownloadClientRepo(log, db)
|
||||||
actionRepo = database.NewActionRepo(log, db, downloadClientRepo)
|
actionRepo = database.NewActionRepo(log, db, downloadClientRepo)
|
||||||
filterRepo = database.NewFilterRepo(log, db)
|
filterRepo = database.NewFilterRepo(log, db)
|
||||||
|
@ -88,15 +90,16 @@ func main() {
|
||||||
|
|
||||||
// setup services
|
// setup services
|
||||||
var (
|
var (
|
||||||
|
apiService = api.NewService(log, apikeyRepo)
|
||||||
notificationService = notification.NewService(log, notificationRepo)
|
notificationService = notification.NewService(log, notificationRepo)
|
||||||
schedulingService = scheduler.NewService(log, version, notificationService)
|
schedulingService = scheduler.NewService(log, version, notificationService)
|
||||||
apiService = indexer.NewAPIService(log)
|
indexerAPIService = indexer.NewAPIService(log)
|
||||||
userService = user.NewService(userRepo)
|
userService = user.NewService(userRepo)
|
||||||
authService = auth.NewService(log, userService)
|
authService = auth.NewService(log, userService)
|
||||||
downloadClientService = download_client.NewService(log, downloadClientRepo)
|
downloadClientService = download_client.NewService(log, downloadClientRepo)
|
||||||
actionService = action.NewService(log, actionRepo, downloadClientService, bus)
|
actionService = action.NewService(log, actionRepo, downloadClientService, bus)
|
||||||
indexerService = indexer.NewService(log, cfg.Config, indexerRepo, apiService, schedulingService)
|
indexerService = indexer.NewService(log, cfg.Config, indexerRepo, indexerAPIService, schedulingService)
|
||||||
filterService = filter.NewService(log, filterRepo, actionRepo, apiService, indexerService)
|
filterService = filter.NewService(log, filterRepo, actionRepo, indexerAPIService, indexerService)
|
||||||
releaseService = release.NewService(log, releaseRepo, actionService, filterService)
|
releaseService = release.NewService(log, releaseRepo, actionService, filterService)
|
||||||
ircService = irc.NewService(log, ircRepo, releaseService, indexerService, notificationService)
|
ircService = irc.NewService(log, ircRepo, releaseService, indexerService, notificationService)
|
||||||
feedService = feed.NewService(log, feedRepo, feedCacheRepo, releaseService, schedulingService)
|
feedService = feed.NewService(log, feedRepo, feedCacheRepo, releaseService, schedulingService)
|
||||||
|
@ -116,6 +119,7 @@ func main() {
|
||||||
commit,
|
commit,
|
||||||
date,
|
date,
|
||||||
actionService,
|
actionService,
|
||||||
|
apiService,
|
||||||
authService,
|
authService,
|
||||||
downloadClientService,
|
downloadClientService,
|
||||||
filterService,
|
filterService,
|
||||||
|
|
4
go.mod
4
go.mod
|
@ -12,7 +12,8 @@ require (
|
||||||
github.com/ergochat/irc-go v0.2.0
|
github.com/ergochat/irc-go v0.2.0
|
||||||
github.com/fsnotify/fsnotify v1.5.4
|
github.com/fsnotify/fsnotify v1.5.4
|
||||||
github.com/gdm85/go-libdeluge v0.5.6
|
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/gorilla/sessions v1.2.1
|
||||||
github.com/gosimple/slug v1.12.0
|
github.com/gosimple/slug v1.12.0
|
||||||
github.com/hashicorp/go-version v1.6.0
|
github.com/hashicorp/go-version v1.6.0
|
||||||
|
@ -38,6 +39,7 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/ajg/form v1.5.1 // indirect
|
||||||
github.com/anacrolix/dht/v2 v2.18.0 // indirect
|
github.com/anacrolix/dht/v2 v2.18.0 // indirect
|
||||||
github.com/anacrolix/missinggo v1.3.0 // indirect
|
github.com/anacrolix/missinggo v1.3.0 // indirect
|
||||||
github.com/anacrolix/missinggo/v2 v2.7.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/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
|
||||||
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
|
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/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-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/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=
|
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-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-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||||
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/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/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8=
|
||||||
github.com/go-chi/chi v1.5.4/go.mod h1:uaf8YgoFazUOkPBG7fxPftUylNumIev9awIWOENIuEg=
|
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 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-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/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,
|
value TEXT,
|
||||||
ttl TIMESTAMP
|
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{
|
var postgresMigrations = []string{
|
||||||
|
@ -541,4 +549,12 @@ CREATE INDEX indexer_identifier_index
|
||||||
ALTER TABLE filter
|
ALTER TABLE filter
|
||||||
ADD COLUMN except_origins TEXT [] DEFAULT '{}';
|
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,
|
value TEXT,
|
||||||
ttl TIMESTAMP
|
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{
|
var sqliteMigrations = []string{
|
||||||
|
@ -861,4 +869,12 @@ CREATE INDEX indexer_identifier_index
|
||||||
ALTER TABLE filter
|
ALTER TABLE filter
|
||||||
ADD COLUMN except_origins TEXT [] DEFAULT '{}';
|
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"
|
"strconv"
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type actionService interface {
|
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"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
|
|
|
@ -3,7 +3,7 @@ package http
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type configJson struct {
|
type configJson struct {
|
||||||
|
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"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) {
|
func (e encoder) StatusResponse(ctx context.Context, w http.ResponseWriter, response interface{}, status int) {
|
||||||
if response != nil {
|
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)
|
w.WriteHeader(status)
|
||||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
@ -30,6 +30,15 @@ func (e encoder) StatusCreated(w http.ResponseWriter) {
|
||||||
w.WriteHeader(http.StatusCreated)
|
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) {
|
func (e encoder) NoContent(w http.ResponseWriter) {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
@ -47,7 +56,7 @@ func (e encoder) Error(w http.ResponseWriter, err error) {
|
||||||
Message: 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)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
json.NewEncoder(w).Encode(res)
|
json.NewEncoder(w).Encode(res)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import (
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type feedService interface {
|
type feedService interface {
|
||||||
|
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,7 +5,7 @@ import (
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/database"
|
"github.com/autobrr/autobrr/internal/database"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type healthHandler struct {
|
type healthHandler struct {
|
||||||
|
|
|
@ -8,7 +8,7 @@ import (
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type indexerService interface {
|
type indexerService interface {
|
||||||
|
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,14 +4,30 @@ import "net/http"
|
||||||
|
|
||||||
func (s Server) IsAuthenticated(next http.Handler) http.Handler {
|
func (s Server) IsAuthenticated(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
// check session
|
if token := r.Header.Get("X-API-Token"); token != "" {
|
||||||
session, _ := s.cookieStore.Get(r, "user_session")
|
// check header
|
||||||
|
if !s.apiService.ValidateAPIKey(r.Context(), token) {
|
||||||
|
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user is authenticated
|
} else if key := r.URL.Query().Get("apikey"); key != "" {
|
||||||
if auth, ok := session.Values["authenticated"].(bool); !ok || !auth {
|
// check query param lke ?apikey=TOKEN
|
||||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
if !s.apiService.ValidateAPIKey(r.Context(), key) {
|
||||||
return
|
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)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import (
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type notificationService interface {
|
type notificationService interface {
|
||||||
|
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type releaseService interface {
|
type releaseService interface {
|
||||||
|
|
|
@ -10,7 +10,8 @@ import (
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
"github.com/autobrr/autobrr/web"
|
"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/gorilla/sessions"
|
||||||
"github.com/r3labs/sse/v2"
|
"github.com/r3labs/sse/v2"
|
||||||
"github.com/rs/cors"
|
"github.com/rs/cors"
|
||||||
|
@ -28,6 +29,7 @@ type Server struct {
|
||||||
date string
|
date string
|
||||||
|
|
||||||
actionService actionService
|
actionService actionService
|
||||||
|
apiService apikeyService
|
||||||
authService authService
|
authService authService
|
||||||
downloadClientService downloadClientService
|
downloadClientService downloadClientService
|
||||||
filterService filterService
|
filterService filterService
|
||||||
|
@ -38,7 +40,7 @@ type Server struct {
|
||||||
releaseService releaseService
|
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{
|
return Server{
|
||||||
config: config,
|
config: config,
|
||||||
sse: sse,
|
sse: sse,
|
||||||
|
@ -50,6 +52,7 @@ func NewServer(config *domain.Config, sse *sse.Server, db *database.DB, version
|
||||||
cookieStore: sessions.NewCookieStore([]byte(config.SessionSecret)),
|
cookieStore: sessions.NewCookieStore([]byte(config.SessionSecret)),
|
||||||
|
|
||||||
actionService: actionService,
|
actionService: actionService,
|
||||||
|
apiService: apiService,
|
||||||
authService: authService,
|
authService: authService,
|
||||||
downloadClientService: downloadClientSvc,
|
downloadClientService: downloadClientSvc,
|
||||||
filterService: filterSvc,
|
filterService: filterSvc,
|
||||||
|
@ -78,6 +81,10 @@ func (s Server) Open() error {
|
||||||
func (s Server) Handler() http.Handler {
|
func (s Server) Handler() http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(middleware.RequestID)
|
||||||
|
r.Use(middleware.RealIP)
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
c := cors.New(cors.Options{
|
c := cors.New(cors.Options{
|
||||||
AllowCredentials: true,
|
AllowCredentials: true,
|
||||||
AllowedMethods: []string{"HEAD", "OPTIONS", "GET", "POST", "PUT", "PATCH", "DELETE"},
|
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("/feeds", newFeedHandler(encoder, s.feedService).Routes)
|
||||||
r.Route("/irc", newIrcHandler(encoder, s.ircService).Routes)
|
r.Route("/irc", newIrcHandler(encoder, s.ircService).Routes)
|
||||||
r.Route("/indexer", newIndexerHandler(encoder, s.indexerService, 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("/notification", newNotificationHandler(encoder, s.notificationService).Routes)
|
||||||
r.Route("/release", newReleaseHandler(encoder, s.releaseService).Routes)
|
r.Route("/release", newReleaseHandler(encoder, s.releaseService).Routes)
|
||||||
|
|
||||||
|
|
|
@ -74,6 +74,11 @@ export const APIClient = {
|
||||||
delete: (id: number) => appClient.Delete(`api/actions/${id}`),
|
delete: (id: number) => appClient.Delete(`api/actions/${id}`),
|
||||||
toggleEnable: (id: number) => appClient.Patch(`api/actions/${id}/toggleEnabled`)
|
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: {
|
config: {
|
||||||
get: () => appClient.Get<Config>("api/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 NotificationSettings from "../screens/settings/Notifications";
|
||||||
import { RegexPlayground } from "../screens/settings/RegexPlayground";
|
import { RegexPlayground } from "../screens/settings/RegexPlayground";
|
||||||
import ReleaseSettings from "../screens/settings/Releases";
|
import ReleaseSettings from "../screens/settings/Releases";
|
||||||
|
import APISettings from "../screens/settings/Api";
|
||||||
|
|
||||||
import { baseUrl } from "../utils";
|
import { baseUrl } from "../utils";
|
||||||
|
|
||||||
|
@ -35,6 +36,7 @@ export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => (
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="settings" element={<Settings />}>
|
<Route path="settings" element={<Settings />}>
|
||||||
<Route index element={<ApplicationSettings />} />
|
<Route index element={<ApplicationSettings />} />
|
||||||
|
<Route path="api-keys" element={<APISettings />} />
|
||||||
<Route path="indexers" element={<IndexerSettings />} />
|
<Route path="indexers" element={<IndexerSettings />} />
|
||||||
<Route path="feeds" element={<FeedSettings />} />
|
<Route path="feeds" element={<FeedSettings />} />
|
||||||
<Route path="irc" element={<IrcSettings />} />
|
<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: "Feeds", href: "feeds", icon: RssIcon },
|
||||||
{ name: "Clients", href: "clients", icon: DownloadIcon },
|
{ name: "Clients", href: "clients", icon: DownloadIcon },
|
||||||
{ name: "Notifications", href: "notifications", icon: BellIcon },
|
{ name: "Notifications", href: "notifications", icon: BellIcon },
|
||||||
|
{ name: "API keys", href: "api-keys", icon: KeyIcon },
|
||||||
{ name: "Releases", href: "releases", icon: CollectionIcon }
|
{ name: "Releases", href: "releases", icon: CollectionIcon }
|
||||||
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
|
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
|
||||||
// {name: 'Rules', href: 'rules', icon: ClipboardCheckIcon, 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 { useQuery } from "react-query";
|
||||||
|
|
||||||
import { APIClient } from "../../api/APIClient";
|
import { APIClient } from "../../api/APIClient";
|
||||||
import { Checkbox } from "../../components/Checkbox";
|
import { Checkbox } from "../../components/Checkbox";
|
||||||
import { SettingsContext } from "../../utils/Context";
|
import { SettingsContext } from "../../utils/Context";
|
||||||
|
@ -18,7 +17,7 @@ function ApplicationSettings() {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
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 className="py-6 px-4 sm:p-6 lg:pb-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Application</h2>
|
<h2 className="text-lg leading-6 font-medium text-gray-900 dark:text-white">Application</h2>
|
||||||
|
@ -27,54 +26,56 @@ function ApplicationSettings() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLoading && data && (
|
<form className="divide-y divide-gray-200 dark:divide-gray-700 lg:col-span-9" action="#" method="POST">
|
||||||
<div className="mt-6 grid grid-cols-12 gap-6">
|
{!isLoading && data && (
|
||||||
<div className="col-span-6 sm:col-span-4">
|
<div className="mt-6 grid grid-cols-12 gap-6">
|
||||||
<label htmlFor="host" 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="host" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
|
||||||
Host
|
Host
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="host"
|
name="host"
|
||||||
id="host"
|
id="host"
|
||||||
value={data.host}
|
value={data.host}
|
||||||
disabled={true}
|
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"
|
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 className="col-span-6 sm:col-span-4">
|
<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">
|
<label htmlFor="port" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
|
||||||
Port
|
Port
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="port"
|
name="port"
|
||||||
id="port"
|
id="port"
|
||||||
value={data.port}
|
value={data.port}
|
||||||
disabled={true}
|
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"
|
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 className="col-span-6 sm:col-span-4">
|
<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">
|
<label htmlFor="base_url" className="block text-xs font-bold text-gray-700 dark:text-gray-200 uppercase tracking-wide">
|
||||||
Base url
|
Base url
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="base_url"
|
name="base_url"
|
||||||
id="base_url"
|
id="base_url"
|
||||||
value={data.base_url}
|
value={data.base_url}
|
||||||
disabled={true}
|
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"
|
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>
|
||||||
</div>
|
)}
|
||||||
)}
|
</form>
|
||||||
</div>
|
</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">
|
<div className="px-4 py-5 sm:p-0">
|
||||||
<dl className="sm:divide-y divide-gray-200 dark:divide-gray-700">
|
<dl className="sm:divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{data?.version ? (
|
{data?.version ? (
|
||||||
|
@ -124,7 +125,7 @@ function ApplicationSettings() {
|
||||||
</div>
|
</div>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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