mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +00:00
feat(auth): change password and username (#1295)
* feat(backend): added change password api endpoint. * feat(web): added profile UI to change password. I think we can change the username too, but I don't know if we should for now disabled the username field. * refactor: don't leak username or password. * refactor: protect the route. * generic * feat: add ChangeUsername * fix(tests): speculative fix for TestUserRepo_Update * Revert "feat: add ChangeUsername" This reverts commit d4c1645002883a278aa45dec3c8c19fa1cc75d9b. * refactor into 1 endpoint that handles both * feat: added option to change username as well. :pain: * refactor: frontend * refactor: function names in backend I think this makes it more clear what their function is * fix: change to 2 cols with separator * refactor: update user * fix: test db create user --------- Co-authored-by: Kyle Sanderson <kyle.leet@gmail.com> Co-authored-by: soup <soup@r4tio.dev> Co-authored-by: martylukyy <35452459+martylukyy@users.noreply.github.com> Co-authored-by: ze0s <ze0s@riseup.net>
This commit is contained in:
parent
d898b3cd8d
commit
df2612602b
17 changed files with 390 additions and 57 deletions
|
@ -14,11 +14,12 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"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"
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
"github.com/autobrr/autobrr/internal/logger"
|
"github.com/autobrr/autobrr/internal/logger"
|
||||||
"github.com/autobrr/autobrr/pkg/argon2id"
|
"github.com/autobrr/autobrr/internal/user"
|
||||||
"github.com/autobrr/autobrr/pkg/errors"
|
"github.com/autobrr/autobrr/pkg/errors"
|
||||||
|
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
|
@ -95,6 +96,12 @@ func main() {
|
||||||
log.Fatal("--config required")
|
log.Fatal("--config required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
username := flag.Arg(1)
|
||||||
|
if username == "" {
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// read config
|
// read config
|
||||||
cfg := config.New(configPath, version)
|
cfg := config.New(configPath, version)
|
||||||
|
|
||||||
|
@ -109,34 +116,42 @@ func main() {
|
||||||
|
|
||||||
userRepo := database.NewUserRepo(l, db)
|
userRepo := database.NewUserRepo(l, db)
|
||||||
|
|
||||||
username := flag.Arg(1)
|
userSvc := user.NewService(userRepo)
|
||||||
if username == "" {
|
authSvc := auth.NewService(l, userSvc)
|
||||||
flag.Usage()
|
|
||||||
os.Exit(1)
|
ctx := context.Background()
|
||||||
}
|
|
||||||
|
|
||||||
password, err := readPassword()
|
password, err := readPassword()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to read password: %v", err)
|
log.Fatalf("failed to read password: %v", err)
|
||||||
}
|
}
|
||||||
hashed, err := argon2id.CreateHash(string(password), argon2id.DefaultParams)
|
|
||||||
|
hashed, err := authSvc.CreateHash(string(password))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to hash password: %v", err)
|
log.Fatalf("failed to hash password: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
user := domain.CreateUserRequest{
|
req := domain.CreateUserRequest{
|
||||||
Username: username,
|
Username: username,
|
||||||
Password: hashed,
|
Password: hashed,
|
||||||
}
|
}
|
||||||
if err := userRepo.Store(context.Background(), user); err != nil {
|
|
||||||
|
if err := userRepo.Store(ctx, req); err != nil {
|
||||||
log.Fatalf("failed to create user: %v", err)
|
log.Fatalf("failed to create user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "change-password":
|
case "change-password":
|
||||||
|
|
||||||
if configPath == "" {
|
if configPath == "" {
|
||||||
log.Fatal("--config required")
|
log.Fatal("--config required")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
username := flag.Arg(1)
|
||||||
|
if username == "" {
|
||||||
|
flag.Usage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// read config
|
// read config
|
||||||
cfg := config.New(configPath, version)
|
cfg := config.New(configPath, version)
|
||||||
|
|
||||||
|
@ -151,18 +166,17 @@ func main() {
|
||||||
|
|
||||||
userRepo := database.NewUserRepo(l, db)
|
userRepo := database.NewUserRepo(l, db)
|
||||||
|
|
||||||
username := flag.Arg(1)
|
userSvc := user.NewService(userRepo)
|
||||||
if username == "" {
|
authSvc := auth.NewService(l, userSvc)
|
||||||
flag.Usage()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
user, err := userRepo.FindByUsername(context.Background(), username)
|
ctx := context.Background()
|
||||||
|
|
||||||
|
usr, err := userSvc.FindByUsername(ctx, username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to get user: %v", err)
|
log.Fatalf("failed to get user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if user == nil {
|
if usr == nil {
|
||||||
log.Fatalf("failed to get user: %v", err)
|
log.Fatalf("failed to get user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,15 +184,26 @@ func main() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to read password: %v", err)
|
log.Fatalf("failed to read password: %v", err)
|
||||||
}
|
}
|
||||||
hashed, err := argon2id.CreateHash(string(password), argon2id.DefaultParams)
|
|
||||||
|
hashed, err := authSvc.CreateHash(string(password))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to hash password: %v", err)
|
log.Fatalf("failed to hash password: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
user.Password = hashed
|
usr.Password = hashed
|
||||||
if err := userRepo.Update(context.Background(), *user); err != nil {
|
|
||||||
|
req := domain.UpdateUserRequest{
|
||||||
|
UsernameCurrent: username,
|
||||||
|
PasswordNew: string(password),
|
||||||
|
PasswordNewHash: hashed,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := userSvc.Update(ctx, req); err != nil {
|
||||||
log.Fatalf("failed to create user: %v", err)
|
log.Fatalf("failed to create user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("successfully updated password for user %q", username)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
flag.Usage()
|
flag.Usage()
|
||||||
if cmd != "help" {
|
if cmd != "help" {
|
||||||
|
|
|
@ -19,6 +19,9 @@ type Service interface {
|
||||||
GetUserCount(ctx context.Context) (int, error)
|
GetUserCount(ctx context.Context) (int, error)
|
||||||
Login(ctx context.Context, username, password string) (*domain.User, error)
|
Login(ctx context.Context, username, password string) (*domain.User, error)
|
||||||
CreateUser(ctx context.Context, req domain.CreateUserRequest) error
|
CreateUser(ctx context.Context, req domain.CreateUserRequest) error
|
||||||
|
UpdateUser(ctx context.Context, req domain.UpdateUserRequest) error
|
||||||
|
CreateHash(password string) (hash string, err error)
|
||||||
|
ComparePasswordAndHash(password string, hash string) (match bool, err error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type service struct {
|
type service struct {
|
||||||
|
@ -54,7 +57,7 @@ func (s *service) Login(ctx context.Context, username, password string) (*domain
|
||||||
}
|
}
|
||||||
|
|
||||||
// compare password from request and the saved password
|
// compare password from request and the saved password
|
||||||
match, err := argon2id.ComparePasswordAndHash(password, u.Password)
|
match, err := s.ComparePasswordAndHash(password, u.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("error checking credentials")
|
return nil, errors.New("error checking credentials")
|
||||||
}
|
}
|
||||||
|
@ -83,7 +86,7 @@ func (s *service) CreateUser(ctx context.Context, req domain.CreateUserRequest)
|
||||||
return errors.New("only 1 user account is supported at the moment")
|
return errors.New("only 1 user account is supported at the moment")
|
||||||
}
|
}
|
||||||
|
|
||||||
hashed, err := argon2id.CreateHash(req.Password, argon2id.DefaultParams)
|
hashed, err := s.CreateHash(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.New("failed to hash password")
|
return errors.New("failed to hash password")
|
||||||
}
|
}
|
||||||
|
@ -97,3 +100,59 @@ func (s *service) CreateUser(ctx context.Context, req domain.CreateUserRequest)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *service) UpdateUser(ctx context.Context, req domain.UpdateUserRequest) error {
|
||||||
|
if req.PasswordCurrent == "" {
|
||||||
|
return errors.New("validation error: empty current password supplied")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PasswordNew != "" && req.PasswordCurrent != "" {
|
||||||
|
if req.PasswordNew == req.PasswordCurrent {
|
||||||
|
return errors.New("validation error: new password must be different")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// find user
|
||||||
|
u, err := s.userSvc.FindByUsername(ctx, req.UsernameCurrent)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Trace().Err(err).Msgf("invalid login %v", req.UsernameCurrent)
|
||||||
|
return errors.Wrapf(err, "invalid login: %s", req.UsernameCurrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if u == nil {
|
||||||
|
return errors.Errorf("invalid login: %s", req.UsernameCurrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// compare password from request and the saved password
|
||||||
|
match, err := s.ComparePasswordAndHash(req.PasswordCurrent, u.Password)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("error checking credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !match {
|
||||||
|
s.log.Debug().Msgf("bad credentials: %q | %q", req.UsernameCurrent, req.PasswordCurrent)
|
||||||
|
return errors.Errorf("invalid login: %s", req.UsernameCurrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
hashed, err := s.CreateHash(req.PasswordNew)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("failed to hash password")
|
||||||
|
}
|
||||||
|
|
||||||
|
req.PasswordNewHash = hashed
|
||||||
|
|
||||||
|
if err := s.userSvc.Update(ctx, req); err != nil {
|
||||||
|
s.log.Error().Err(err).Msgf("could not change password for user: %s", req.UsernameCurrent)
|
||||||
|
return errors.New("failed to change password")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) ComparePasswordAndHash(password string, hash string) (match bool, err error) {
|
||||||
|
return argon2id.ComparePasswordAndHash(password, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) CreateHash(password string) (hash string, err error) {
|
||||||
|
return argon2id.CreateHash(password, argon2id.DefaultParams)
|
||||||
|
}
|
||||||
|
|
|
@ -49,7 +49,6 @@ func (r *UserRepo) GetUserCount(ctx context.Context) (int, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepo) FindByUsername(ctx context.Context, username string) (*domain.User, error) {
|
func (r *UserRepo) FindByUsername(ctx context.Context, username string) (*domain.User, error) {
|
||||||
|
|
||||||
queryBuilder := r.db.squirrel.
|
queryBuilder := r.db.squirrel.
|
||||||
Select("id", "username", "password").
|
Select("id", "username", "password").
|
||||||
From("users").
|
From("users").
|
||||||
|
@ -79,9 +78,6 @@ func (r *UserRepo) FindByUsername(ctx context.Context, username string) (*domain
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepo) Store(ctx context.Context, req domain.CreateUserRequest) error {
|
func (r *UserRepo) Store(ctx context.Context, req domain.CreateUserRequest) error {
|
||||||
|
|
||||||
var err error
|
|
||||||
|
|
||||||
queryBuilder := r.db.squirrel.
|
queryBuilder := r.db.squirrel.
|
||||||
Insert("users").
|
Insert("users").
|
||||||
Columns("username", "password").
|
Columns("username", "password").
|
||||||
|
@ -100,15 +96,18 @@ func (r *UserRepo) Store(ctx context.Context, req domain.CreateUserRequest) erro
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepo) Update(ctx context.Context, user domain.User) error {
|
func (r *UserRepo) Update(ctx context.Context, user domain.UpdateUserRequest) error {
|
||||||
|
queryBuilder := r.db.squirrel.Update("users")
|
||||||
|
|
||||||
var err error
|
if user.UsernameNew != "" {
|
||||||
|
queryBuilder = queryBuilder.Set("username", user.UsernameNew)
|
||||||
|
}
|
||||||
|
|
||||||
queryBuilder := r.db.squirrel.
|
if user.PasswordNewHash != "" {
|
||||||
Update("users").
|
queryBuilder = queryBuilder.Set("password", user.PasswordNewHash)
|
||||||
Set("username", user.Username).
|
}
|
||||||
Set("password", user.Password).
|
|
||||||
Where(sq.Eq{"username": user.Username})
|
queryBuilder = queryBuilder.Where(sq.Eq{"username": user.UsernameCurrent})
|
||||||
|
|
||||||
query, args, err := queryBuilder.ToSql()
|
query, args, err := queryBuilder.ToSql()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -120,11 +119,10 @@ func (r *UserRepo) Update(ctx context.Context, user domain.User) error {
|
||||||
return errors.Wrap(err, "error executing query")
|
return errors.Wrap(err, "error executing query")
|
||||||
}
|
}
|
||||||
|
|
||||||
return err
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepo) Delete(ctx context.Context, username string) error {
|
func (r *UserRepo) Delete(ctx context.Context, username string) error {
|
||||||
|
|
||||||
queryBuilder := r.db.squirrel.
|
queryBuilder := r.db.squirrel.
|
||||||
Delete("users").
|
Delete("users").
|
||||||
Where(sq.Eq{"username": username})
|
Where(sq.Eq{"username": username})
|
||||||
|
|
|
@ -55,11 +55,19 @@ func TestUserRepo_Update(t *testing.T) {
|
||||||
})
|
})
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
storedUser, err := repo.FindByUsername(context.Background(), user.Username)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
user.ID = storedUser.ID
|
||||||
|
|
||||||
t.Run(fmt.Sprintf("UpdateUser_Succeeds [%s]", dbType), func(t *testing.T) {
|
t.Run(fmt.Sprintf("UpdateUser_Succeeds [%s]", dbType), func(t *testing.T) {
|
||||||
// Update the user
|
// Update the user
|
||||||
newPassword := "newPassword123"
|
newPassword := "newPassword123"
|
||||||
user.Password = newPassword
|
user.Password = newPassword
|
||||||
err := repo.Update(context.Background(), user)
|
req := domain.UpdateUserRequest{
|
||||||
|
UsernameCurrent: user.Username,
|
||||||
|
PasswordNewHash: newPassword,
|
||||||
|
}
|
||||||
|
err := repo.Update(context.Background(), req)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Verify
|
// Verify
|
||||||
|
@ -68,7 +76,7 @@ func TestUserRepo_Update(t *testing.T) {
|
||||||
assert.Equal(t, newPassword, updatedUser.Password)
|
assert.Equal(t, newPassword, updatedUser.Password)
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
_ = repo.Delete(context.Background(), user.Username)
|
_ = repo.Delete(context.Background(), updatedUser.Username)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ type UserRepo interface {
|
||||||
GetUserCount(ctx context.Context) (int, error)
|
GetUserCount(ctx context.Context) (int, error)
|
||||||
FindByUsername(ctx context.Context, username string) (*User, error)
|
FindByUsername(ctx context.Context, username string) (*User, error)
|
||||||
Store(ctx context.Context, req CreateUserRequest) error
|
Store(ctx context.Context, req CreateUserRequest) error
|
||||||
Update(ctx context.Context, user User) error
|
Update(ctx context.Context, req UpdateUserRequest) error
|
||||||
Delete(ctx context.Context, username string) error
|
Delete(ctx context.Context, username string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,6 +19,14 @@ type User struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateUserRequest struct {
|
||||||
|
UsernameCurrent string `json:"username_username"`
|
||||||
|
UsernameNew string `json:"username_new"`
|
||||||
|
PasswordCurrent string `json:"password_current"`
|
||||||
|
PasswordNew string `json:"password_new"`
|
||||||
|
PasswordNewHash string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
type CreateUserRequest struct {
|
type CreateUserRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
|
|
@ -20,6 +20,7 @@ type authService interface {
|
||||||
GetUserCount(ctx context.Context) (int, error)
|
GetUserCount(ctx context.Context) (int, error)
|
||||||
Login(ctx context.Context, username, password string) (*domain.User, error)
|
Login(ctx context.Context, username, password string) (*domain.User, error)
|
||||||
CreateUser(ctx context.Context, req domain.CreateUserRequest) error
|
CreateUser(ctx context.Context, req domain.CreateUserRequest) error
|
||||||
|
UpdateUser(ctx context.Context, req domain.UpdateUserRequest) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type authHandler struct {
|
type authHandler struct {
|
||||||
|
@ -27,17 +28,19 @@ type authHandler struct {
|
||||||
encoder encoder
|
encoder encoder
|
||||||
config *domain.Config
|
config *domain.Config
|
||||||
service authService
|
service authService
|
||||||
|
server Server
|
||||||
|
|
||||||
cookieStore *sessions.CookieStore
|
cookieStore *sessions.CookieStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAuthHandler(encoder encoder, log zerolog.Logger, config *domain.Config, cookieStore *sessions.CookieStore, service authService) *authHandler {
|
func newAuthHandler(encoder encoder, log zerolog.Logger, config *domain.Config, cookieStore *sessions.CookieStore, service authService, server Server) *authHandler {
|
||||||
return &authHandler{
|
return &authHandler{
|
||||||
log: log,
|
log: log,
|
||||||
encoder: encoder,
|
encoder: encoder,
|
||||||
config: config,
|
config: config,
|
||||||
service: service,
|
service: service,
|
||||||
cookieStore: cookieStore,
|
cookieStore: cookieStore,
|
||||||
|
server: server,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,6 +50,14 @@ func (h authHandler) Routes(r chi.Router) {
|
||||||
r.Post("/onboard", h.onboard)
|
r.Post("/onboard", h.onboard)
|
||||||
r.Get("/onboard", h.canOnboard)
|
r.Get("/onboard", h.canOnboard)
|
||||||
r.Get("/validate", h.validate)
|
r.Get("/validate", h.validate)
|
||||||
|
|
||||||
|
// Group for authenticated routes
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(h.server.IsAuthenticated)
|
||||||
|
|
||||||
|
// Authenticated routes
|
||||||
|
r.Patch("/user/{username}", h.updateUser)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h authHandler) login(w http.ResponseWriter, r *http.Request) {
|
func (h authHandler) login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
@ -177,6 +188,28 @@ func (h authHandler) validate(w http.ResponseWriter, r *http.Request) {
|
||||||
h.encoder.NoContent(w)
|
h.encoder.NoContent(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h authHandler) updateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
ctx = r.Context()
|
||||||
|
data domain.UpdateUserRequest
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||||
|
h.encoder.StatusError(w, http.StatusBadRequest, errors.Wrap(err, "could not decode json"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data.UsernameCurrent = chi.URLParam(r, "username")
|
||||||
|
|
||||||
|
if err := h.service.UpdateUser(ctx, data); err != nil {
|
||||||
|
h.encoder.StatusError(w, http.StatusForbidden, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// send response as ok
|
||||||
|
h.encoder.StatusResponseMessage(w, http.StatusOK, "user successfully updated")
|
||||||
|
}
|
||||||
|
|
||||||
func ReadUserIP(r *http.Request) string {
|
func ReadUserIP(r *http.Request) string {
|
||||||
IPAddress := r.Header.Get("X-Real-Ip")
|
IPAddress := r.Header.Get("X-Real-Ip")
|
||||||
if IPAddress == "" {
|
if IPAddress == "" {
|
||||||
|
|
|
@ -126,7 +126,7 @@ func (s Server) Handler() http.Handler {
|
||||||
encoder := encoder{}
|
encoder := encoder{}
|
||||||
|
|
||||||
r.Route("/api", func(r chi.Router) {
|
r.Route("/api", func(r chi.Router) {
|
||||||
r.Route("/auth", newAuthHandler(encoder, s.log, s.config.Config, s.cookieStore, s.authService).Routes)
|
r.Route("/auth", newAuthHandler(encoder, s.log, s.config.Config, s.cookieStore, s.authService, s).Routes)
|
||||||
r.Route("/healthz", newHealthHandler(encoder, s.db).Routes)
|
r.Route("/healthz", newHealthHandler(encoder, s.db).Routes)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
|
|
|
@ -14,6 +14,7 @@ type Service interface {
|
||||||
GetUserCount(ctx context.Context) (int, error)
|
GetUserCount(ctx context.Context) (int, error)
|
||||||
FindByUsername(ctx context.Context, username string) (*domain.User, error)
|
FindByUsername(ctx context.Context, username string) (*domain.User, error)
|
||||||
CreateUser(ctx context.Context, req domain.CreateUserRequest) error
|
CreateUser(ctx context.Context, req domain.CreateUserRequest) error
|
||||||
|
Update(ctx context.Context, req domain.UpdateUserRequest) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type service struct {
|
type service struct {
|
||||||
|
@ -51,3 +52,7 @@ func (s *service) CreateUser(ctx context.Context, req domain.CreateUserRequest)
|
||||||
|
|
||||||
return s.repo.Store(ctx, req)
|
return s.repo.Store(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *service) Update(ctx context.Context, req domain.UpdateUserRequest) error {
|
||||||
|
return s.repo.Update(ctx, req)
|
||||||
|
}
|
||||||
|
|
|
@ -158,7 +158,9 @@ export const APIClient = {
|
||||||
onboard: (username: string, password: string) => appClient.Post("api/auth/onboard", {
|
onboard: (username: string, password: string) => appClient.Post("api/auth/onboard", {
|
||||||
body: { username, password }
|
body: { username, password }
|
||||||
}),
|
}),
|
||||||
canOnboard: () => appClient.Get("api/auth/onboard")
|
canOnboard: () => appClient.Get("api/auth/onboard"),
|
||||||
|
updateUser: (req: UserUpdate) => appClient.Patch(`api/auth/user/${req.username_current}`,
|
||||||
|
{ body: req })
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
create: (action: Action) => appClient.Post("api/actions", {
|
create: (action: Action) => appClient.Post("api/actions", {
|
||||||
|
|
|
@ -55,6 +55,25 @@ export const RightNav = (props: RightNavProps) => {
|
||||||
static
|
static
|
||||||
className="origin-top-right absolute right-0 mt-2 w-48 z-10 divide-y divide-gray-100 dark:divide-gray-750 rounded-md shadow-lg bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 focus:outline-none"
|
className="origin-top-right absolute right-0 mt-2 w-48 z-10 divide-y divide-gray-100 dark:divide-gray-750 rounded-md shadow-lg bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 focus:outline-none"
|
||||||
>
|
>
|
||||||
|
<Menu.Item>
|
||||||
|
{({ active }) => (
|
||||||
|
<Link
|
||||||
|
to="/settings/account"
|
||||||
|
className={classNames(
|
||||||
|
active
|
||||||
|
? "bg-gray-100 dark:bg-gray-600"
|
||||||
|
: "",
|
||||||
|
"flex items-center transition rounded-t-md px-2 py-2 text-sm text-gray-900 dark:text-gray-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<UserIcon
|
||||||
|
className="w-5 h-5 mr-1 text-gray-700 dark:text-gray-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
Account
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item>
|
<Menu.Item>
|
||||||
{({ active }) => (
|
{({ active }) => (
|
||||||
<Link
|
<Link
|
||||||
|
|
|
@ -79,6 +79,7 @@ export const TextField = ({
|
||||||
)}
|
)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
data-1p-ignore
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{meta.touched && meta.error && (
|
{meta.touched && meta.error && (
|
||||||
|
@ -116,42 +117,42 @@ export const RegexField = ({
|
||||||
disabled
|
disabled
|
||||||
}: RegexFieldProps) => {
|
}: RegexFieldProps) => {
|
||||||
const validRegex = (pattern: string) => {
|
const validRegex = (pattern: string) => {
|
||||||
|
|
||||||
// Check for unsupported lookahead and lookbehind assertions
|
// Check for unsupported lookahead and lookbehind assertions
|
||||||
if (/\(\?<=|\(\?<!|\(\?=|\(\?!/.test(pattern)) {
|
if (/\(\?<=|\(\?<!|\(\?=|\(\?!/.test(pattern)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for unsupported atomic groups
|
// Check for unsupported atomic groups
|
||||||
if (/\(\?>/.test(pattern)) {
|
if (/\(\?>/.test(pattern)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for unsupported recursive patterns
|
// Check for unsupported recursive patterns
|
||||||
if (/\(\?(R|0)\)/.test(pattern)) {
|
if (/\(\?(R|0)\)/.test(pattern)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for unsupported possessive quantifiers
|
// Check for unsupported possessive quantifiers
|
||||||
if (/[*+?]{1}\+|\{[0-9]+,[0-9]*\}\+/.test(pattern)) {
|
if (/[*+?]{1}\+|\{[0-9]+,[0-9]*\}\+/.test(pattern)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for unsupported control verbs
|
// Check for unsupported control verbs
|
||||||
if (/\\g</.test(pattern)) {
|
if (/\\g</.test(pattern)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for unsupported conditionals
|
// Check for unsupported conditionals
|
||||||
if (/\(\?\((\?[=!][^)]*)\)[^)]*\|?[^)]*\)/.test(pattern)) {
|
if (/\(\?\((\?[=!][^)]*)\)[^)]*\|?[^)]*\)/.test(pattern)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for unsupported backreferences
|
// Check for unsupported backreferences
|
||||||
if (/\\k</.test(pattern)) {
|
if (/\\k</.test(pattern)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the pattern is a valid regex
|
// Check if the pattern is a valid regex
|
||||||
try {
|
try {
|
||||||
new RegExp(pattern);
|
new RegExp(pattern);
|
||||||
|
@ -160,7 +161,7 @@ export const RegexField = ({
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const validateRegexp = (val: string) => {
|
const validateRegexp = (val: string) => {
|
||||||
let error = "";
|
let error = "";
|
||||||
|
@ -548,6 +549,7 @@ interface PasswordFieldProps {
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
help?: string;
|
help?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
tooltip?: JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PasswordField = ({
|
export const PasswordField = ({
|
||||||
|
@ -558,6 +560,7 @@ export const PasswordField = ({
|
||||||
columns,
|
columns,
|
||||||
autoComplete,
|
autoComplete,
|
||||||
help,
|
help,
|
||||||
|
tooltip,
|
||||||
required
|
required
|
||||||
}: PasswordFieldProps) => {
|
}: PasswordFieldProps) => {
|
||||||
const [isVisible, toggleVisibility] = useToggle(false);
|
const [isVisible, toggleVisibility] = useToggle(false);
|
||||||
|
@ -570,8 +573,13 @@ export const PasswordField = ({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label && (
|
{label && (
|
||||||
<label htmlFor={name} className="block ml-px text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">
|
<label htmlFor={name} className="flex ml-px text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">
|
||||||
{label} {required && <span className="text-gray-500">*</span>}
|
{tooltip ? (
|
||||||
|
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
{required && <span className="text-red-500">*</span>}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
|
@ -591,7 +599,7 @@ export const PasswordField = ({
|
||||||
meta.touched && meta.error
|
meta.touched && meta.error
|
||||||
? "border-red-500 focus:ring-red-500 focus:border-red-500"
|
? "border-red-500 focus:ring-red-500 focus:border-red-500"
|
||||||
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
|
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
|
||||||
"mt-1 block w-full rounded-md bg-gray-100 dark:bg-gray-850 dark:text-gray-100"
|
"mt-1 block w-full rounded-md bg-gray-100 dark:bg-gray-815 dark:text-gray-100"
|
||||||
)}
|
)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -53,6 +53,7 @@ export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => (
|
||||||
<Route path="notifications" element={<SettingsSubPage.Notification />} />
|
<Route path="notifications" element={<SettingsSubPage.Notification />} />
|
||||||
<Route path="releases" element={<SettingsSubPage.Release />} />
|
<Route path="releases" element={<SettingsSubPage.Release />} />
|
||||||
<Route path="regex-playground" element={<SettingsSubPage.RegexPlayground />} />
|
<Route path="regex-playground" element={<SettingsSubPage.RegexPlayground />} />
|
||||||
|
<Route path="account" element={<SettingsSubPage.Account />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
@ -13,7 +13,8 @@ import {
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
RectangleStackIcon,
|
RectangleStackIcon,
|
||||||
RssIcon,
|
RssIcon,
|
||||||
Square3Stack3DIcon
|
Square3Stack3DIcon,
|
||||||
|
UserCircleIcon
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
import { classNames } from "@utils";
|
import { classNames } from "@utils";
|
||||||
|
@ -34,7 +35,8 @@ const subNavigation: NavTabType[] = [
|
||||||
{ name: "Clients", href: "clients", icon: FolderArrowDownIcon },
|
{ name: "Clients", href: "clients", icon: FolderArrowDownIcon },
|
||||||
{ name: "Notifications", href: "notifications", icon: BellIcon },
|
{ name: "Notifications", href: "notifications", icon: BellIcon },
|
||||||
{ name: "API keys", href: "api-keys", icon: KeyIcon },
|
{ name: "API keys", href: "api-keys", icon: KeyIcon },
|
||||||
{ name: "Releases", href: "releases", icon: RectangleStackIcon }
|
{ name: "Releases", href: "releases", icon: RectangleStackIcon },
|
||||||
|
{ name: "Account", href: "account", icon: UserCircleIcon }
|
||||||
// {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},
|
||||||
];
|
];
|
||||||
|
|
150
web/src/screens/settings/Account.tsx
Normal file
150
web/src/screens/settings/Account.tsx
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { APIClient } from "@api/APIClient";
|
||||||
|
import Toast from "@components/notifications/Toast";
|
||||||
|
import { Section } from "./_components";
|
||||||
|
import { Form, Formik } from "formik";
|
||||||
|
import { PasswordField, TextField } from "@components/inputs";
|
||||||
|
import { AuthContext } from "@utils/Context";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { UserIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
const AccountSettings = () => (
|
||||||
|
<Section
|
||||||
|
title="Account"
|
||||||
|
description="Manage account settings."
|
||||||
|
>
|
||||||
|
<div className="py-0.5">
|
||||||
|
<Credentials />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface InputValues {
|
||||||
|
username: string;
|
||||||
|
newUsername: string;
|
||||||
|
oldPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Credentials() {
|
||||||
|
const [ getAuthContext ] = AuthContext.use();
|
||||||
|
|
||||||
|
|
||||||
|
const validate = (values: InputValues) => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!values.username)
|
||||||
|
errors.username = "Required";
|
||||||
|
|
||||||
|
if (values.newPassword !== values.confirmPassword)
|
||||||
|
errors.confirmPassword = "Passwords don't match!";
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logoutMutation = useMutation({
|
||||||
|
mutationFn: APIClient.auth.logout,
|
||||||
|
onSuccess: () => {
|
||||||
|
AuthContext.reset();
|
||||||
|
toast.custom((t) => (
|
||||||
|
<Toast type="success" body="User updated successfully. Please sign in again!" t={t} />
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUserMutation = useMutation({
|
||||||
|
mutationFn: (data: UserUpdate) => APIClient.auth.updateUser(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
logoutMutation.mutate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const separatorClass = "mb-6";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
title="Change credentials"
|
||||||
|
description="The username and password can be changed either separately or simultaneously. Note that you will be logged out after changing credentials."
|
||||||
|
noLeftPadding
|
||||||
|
>
|
||||||
|
<div className="px-2 pb-6 bg-white dark:bg-gray-800">
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
username: getAuthContext.username,
|
||||||
|
newUsername: "",
|
||||||
|
oldPassword: "",
|
||||||
|
newPassword: "",
|
||||||
|
confirmPassword: ""
|
||||||
|
}}
|
||||||
|
onSubmit={(data) => {
|
||||||
|
updateUserMutation.mutate({
|
||||||
|
username_current: data.username,
|
||||||
|
username_new: data.newUsername,
|
||||||
|
password_current: data.oldPassword,
|
||||||
|
password_new: data.newPassword,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
validate={validate}
|
||||||
|
>
|
||||||
|
{({ values }) => (
|
||||||
|
<Form>
|
||||||
|
<div className="grid grid-cols-2 gap-x-10">
|
||||||
|
<div className={separatorClass}>
|
||||||
|
<TextField name="username" label="Current Username" autoComplete="username" disabled />
|
||||||
|
</div>
|
||||||
|
<div className={separatorClass}>
|
||||||
|
<TextField name="newUsername" label="New Username" tooltip={
|
||||||
|
<div>
|
||||||
|
<p>Optional</p>
|
||||||
|
</div>
|
||||||
|
} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="col-span-2 mb-6 border-t border-gray-300 dark:border-gray-750" />
|
||||||
|
|
||||||
|
<div className={separatorClass}>
|
||||||
|
<PasswordField name="oldPassword" placeholder="Required" label="Current Password" autoComplete="current-password" required tooltip={
|
||||||
|
<div>
|
||||||
|
<p>Required if updating credentials</p>
|
||||||
|
</div>
|
||||||
|
} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className={separatorClass}>
|
||||||
|
<PasswordField name="newPassword" label="New Password" autoComplete="new-password" tooltip={
|
||||||
|
<div>
|
||||||
|
<p>Optional</p>
|
||||||
|
</div>
|
||||||
|
} />
|
||||||
|
</div>
|
||||||
|
{values.newPassword && (
|
||||||
|
<div className={separatorClass}>
|
||||||
|
<PasswordField name="confirmPassword" label="Confirm New Password" autoComplete="new-password" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="mt-4 w-auto flex items-center py-2 px-4 transition rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<UserIcon className="w-4 h-4 mr-1" />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Formik>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AccountSettings;
|
|
@ -11,15 +11,22 @@ type SectionProps = {
|
||||||
description: string | React.ReactNode;
|
description: string | React.ReactNode;
|
||||||
rightSide?: React.ReactNode;
|
rightSide?: React.ReactNode;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
noLeftPadding?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Section = ({
|
export const Section = ({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
rightSide,
|
rightSide,
|
||||||
children
|
children,
|
||||||
|
noLeftPadding = false,
|
||||||
}: SectionProps) => (
|
}: SectionProps) => (
|
||||||
<div className="pb-6 px-4 lg:col-span-9">
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"pb-6 px-4 lg:col-span-9",
|
||||||
|
noLeftPadding ? 'pl-0' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"mt-6 mb-4",
|
"mt-6 mb-4",
|
||||||
|
|
|
@ -13,3 +13,4 @@ export { default as Logs } from "./Logs";
|
||||||
export { default as Notification } from "./Notifications";
|
export { default as Notification } from "./Notifications";
|
||||||
export { default as Release } from "./Releases";
|
export { default as Release } from "./Releases";
|
||||||
export { default as RegexPlayground } from "./RegexPlayground";
|
export { default as RegexPlayground } from "./RegexPlayground";
|
||||||
|
export { default as Account } from "./Account";
|
||||||
|
|
9
web/src/types/API.d.ts
vendored
9
web/src/types/API.d.ts
vendored
|
@ -8,4 +8,11 @@ interface APIKey {
|
||||||
key: string;
|
key: string;
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserUpdate {
|
||||||
|
username_current: string;
|
||||||
|
username_new?: string;
|
||||||
|
password_current?: string;
|
||||||
|
password_new?: string;
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue