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:
KaiserBh 2023-12-27 01:50:57 +11:00 committed by GitHub
parent d898b3cd8d
commit df2612602b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 390 additions and 57 deletions

View file

@ -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" {

View file

@ -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)
}

View file

@ -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})

View file

@ -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)
}) })
} }
} }

View file

@ -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"`

View file

@ -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 == "" {

View file

@ -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) {

View file

@ -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)
}

View file

@ -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", {

View file

@ -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

View file

@ -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 && (
@ -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}
/> />

View file

@ -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>

View file

@ -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},
]; ];

View 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;

View file

@ -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",

View file

@ -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";

View file

@ -9,3 +9,10 @@ interface APIKey {
scopes: string[]; scopes: string[];
created_at: Date; created_at: Date;
} }
interface UserUpdate {
username_current: string;
username_new?: string;
password_current?: string;
password_new?: string;
}