feat: add ability to create an account via the webui (#223)

* feat: add ability to create an account via the webui without the need for autobrrctl

* refactor redundant code block.

* fix: early return and 0 value
This commit is contained in:
stacksmash76 2022-04-10 18:26:14 +02:00 committed by GitHub
parent 982eddc269
commit 1a4f3cf55d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 337 additions and 109 deletions

View file

@ -10,7 +10,9 @@ import (
) )
type Service interface { type Service interface {
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, username, password string) error
} }
type service struct { type service struct {
@ -23,9 +25,13 @@ func NewService(userSvc user.Service) Service {
} }
} }
func (s *service) GetUserCount(ctx context.Context) (int, error) {
return s.userSvc.GetUserCount(ctx)
}
func (s *service) Login(ctx context.Context, username, password string) (*domain.User, error) { func (s *service) Login(ctx context.Context, username, password string) (*domain.User, error) {
if username == "" || password == "" { if username == "" || password == "" {
return nil, errors.New("bad credentials") return nil, errors.New("empty credentials supplied")
} }
// find user // find user
@ -50,3 +56,33 @@ func (s *service) Login(ctx context.Context, username, password string) (*domain
return u, nil return u, nil
} }
func (s *service) CreateUser(ctx context.Context, username, password string) error {
if username == "" || password == "" {
return errors.New("empty credentials supplied")
}
userCount, err := s.userSvc.GetUserCount(ctx)
if err != nil {
return err
}
if userCount > 0 {
return errors.New("only 1 user account is supported at the moment")
}
hashed, err := argon2id.CreateHash(password, argon2id.DefaultParams)
if err != nil {
return errors.New("failed to hash password")
}
newUser := domain.User{
Username: username,
Password: hashed,
}
if err := s.userSvc.CreateUser(context.Background(), newUser); err != nil {
return errors.New("failed to create new user")
}
return nil
}

View file

@ -15,6 +15,29 @@ func NewUserRepo(db *DB) domain.UserRepo {
return &UserRepo{db: db} return &UserRepo{db: db}
} }
func (r *UserRepo) GetUserCount(ctx context.Context) (int, error) {
queryBuilder := r.db.squirrel.Select("count(*)").From("users")
query, args, err := queryBuilder.ToSql()
if err != nil {
log.Error().Stack().Err(err).Msg("user.store: error building query")
return 0, err
}
row := r.db.handler.QueryRowContext(ctx, query, args...)
if err := row.Err(); err != nil {
return 0, err
}
result := 0
if err := row.Scan(&result); err != nil {
log.Error().Err(err).Msg("could not query number of users")
return 0, err
}
return result, nil
}
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.
@ -66,6 +89,7 @@ func (r *UserRepo) Store(ctx context.Context, user domain.User) error {
return err return err
} }
func (r *UserRepo) Update(ctx context.Context, user domain.User) error { func (r *UserRepo) Update(ctx context.Context, user domain.User) error {
var err error var err error

View file

@ -3,6 +3,7 @@ package domain
import "context" import "context"
type UserRepo interface { type UserRepo interface {
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, user User) error Store(ctx context.Context, user User) error
Update(ctx context.Context, user User) error Update(ctx context.Context, user User) error

View file

@ -12,7 +12,9 @@ import (
) )
type authService interface { type authService interface {
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, username, password string) error
} }
type authHandler struct { type authHandler struct {
@ -35,6 +37,8 @@ func newAuthHandler(encoder encoder, config domain.Config, cookieStore *sessions
func (h authHandler) Routes(r chi.Router) { func (h authHandler) Routes(r chi.Router) {
r.Post("/login", h.login) r.Post("/login", h.login)
r.Post("/logout", h.logout) r.Post("/logout", h.logout)
r.Post("/onboard", h.onboard)
r.Get("/onboard", h.canOnboard)
r.Get("/validate", h.validate) r.Get("/validate", h.validate)
} }
@ -91,6 +95,53 @@ func (h authHandler) logout(w http.ResponseWriter, r *http.Request) {
h.encoder.StatusResponse(ctx, w, nil, http.StatusNoContent) h.encoder.StatusResponse(ctx, w, nil, http.StatusNoContent)
} }
func (h authHandler) onboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session, _ := h.cookieStore.Get(r, "user_session")
// Don't proceed if user is authenticated
if _, ok := session.Values["authenticated"].(bool); ok {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
var data domain.User
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
// encode error
h.encoder.StatusResponse(ctx, w, nil, http.StatusBadRequest)
return
}
err := h.service.CreateUser(ctx, data.Username, data.Password)
if err != nil {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// send empty response as ok
h.encoder.StatusResponse(ctx, w, nil, http.StatusNoContent)
}
func (h authHandler) canOnboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userCount, err := h.service.GetUserCount(ctx)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if userCount > 0 {
// send 503 service onboarding unavailable
http.Error(w, "Onboarding unavailable", http.StatusServiceUnavailable)
return
}
// send empty response as ok
// (client can proceed with redirection to onboarding page)
h.encoder.StatusResponse(ctx, w, nil, http.StatusNoContent)
}
func (h authHandler) validate(w http.ResponseWriter, r *http.Request) { func (h authHandler) validate(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
session, _ := h.cookieStore.Get(r, "user_session") session, _ := h.cookieStore.Get(r, "user_session")

View file

@ -2,11 +2,14 @@ package user
import ( import (
"context" "context"
"errors"
"github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/internal/domain"
) )
type Service interface { type Service interface {
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, user domain.User) error
} }
type service struct { type service struct {
@ -19,6 +22,10 @@ func NewService(repo domain.UserRepo) Service {
} }
} }
func (s *service) GetUserCount(ctx context.Context) (int, error) {
return s.repo.GetUserCount(ctx)
}
func (s *service) FindByUsername(ctx context.Context, username string) (*domain.User, error) { func (s *service) FindByUsername(ctx context.Context, username string) (*domain.User, error) {
user, err := s.repo.FindByUsername(ctx, username) user, err := s.repo.FindByUsername(ctx, username)
if err != nil { if err != nil {
@ -27,3 +34,16 @@ func (s *service) FindByUsername(ctx context.Context, username string) (*domain.
return user, nil return user, nil
} }
func (s *service) CreateUser(ctx context.Context, newUser domain.User) error {
userCount, err := s.repo.GetUserCount(ctx)
if err != nil {
return err
}
if userCount > 0 {
return errors.New("only 1 user account is supported at the moment")
}
return s.repo.Store(ctx, newUser)
}

View file

@ -1,12 +1,13 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { BrowserRouter as Router, Route } from "react-router-dom"; import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "react-query"; import { QueryClient, QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools"; import { ReactQueryDevtools } from "react-query/devtools";
import { Toaster } from "react-hot-toast"; import { Toaster } from "react-hot-toast";
import Base from "./screens/Base"; import Base from "./screens/Base";
import Login from "./screens/auth/login"; import { Login } from "./screens/auth/login";
import Logout from "./screens/auth/logout"; import { Logout } from "./screens/auth/logout";
import { Onboarding } from "./screens/auth/onboarding";
import { baseUrl } from "./utils"; import { baseUrl } from "./utils";
import { AuthContext, SettingsContext } from "./utils/Context"; import { AuthContext, SettingsContext } from "./utils/Context";
@ -29,12 +30,16 @@ export function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Router basename={baseUrl()}> <Router basename={baseUrl()}>
{authContext.isLoggedIn ? (
<Route exact path="/*" component={Protected} />
) :
<Route exact path="/*" component={Login} />
}
<Route exact path="/logout" component={Logout} /> <Route exact path="/logout" component={Logout} />
{authContext.isLoggedIn ? (
<Route component={Protected} />
) : (
<Switch>
<Route exact path="/onboard" component={Onboarding} />
<Route component={Login} />
</Switch>
)}
</Router> </Router>
{settings.debug ? ( {settings.debug ? (
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />

View file

@ -62,6 +62,8 @@ export const APIClient = {
login: (username: string, password: string) => appClient.Post("api/auth/login", { username: username, password: password }), login: (username: string, password: string) => appClient.Post("api/auth/login", { username: username, password: password }),
logout: () => appClient.Post("api/auth/logout", null), logout: () => appClient.Post("api/auth/logout", null),
validate: () => appClient.Get<void>("api/auth/validate"), validate: () => appClient.Get<void>("api/auth/validate"),
onboard: (username: string, password: string) => appClient.Post("api/auth/onboard", { username: username, password: password }),
canOnboard: () => appClient.Get("api/auth/onboard"),
}, },
actions: { actions: {
create: (action: Action) => appClient.Post("api/actions", action), create: (action: Action) => appClient.Post("api/actions", action),

View file

@ -47,12 +47,12 @@ export const TextField = ({
type="text" type="text"
defaultValue={defaultValue} defaultValue={defaultValue}
autoComplete={autoComplete} autoComplete={autoComplete}
className="mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100" className={classNames(meta.touched && meta.error ? "focus:ring-red-500 focus:border-red-500 border-red-500" : "focus:ring-indigo-500 dark:focus:ring-blue-500 focus:border-indigo-500 dark:focus:border-blue-500 border-gray-300 dark:border-gray-700", "mt-2 block w-full dark:bg-gray-800 dark:text-gray-100 rounded-md")}
placeholder={placeholder} placeholder={placeholder}
/> />
{meta.touched && meta.error && ( {meta.touched && meta.error && (
<div className="error">{meta.error}</div> <p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
)} )}
</div> </div>
)} )}
@ -118,7 +118,7 @@ export const PasswordField = ({
)} )}
{meta.touched && meta.error && ( {meta.touched && meta.error && (
<div className="error">{meta.error}</div> <p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
)} )}
</div> </div>
)} )}

View file

@ -7,16 +7,24 @@ import { TextField, PasswordField } from "../../components/inputs";
import logo from "../../logo.png"; import logo from "../../logo.png";
import { AuthContext } from "../../utils/Context"; import { AuthContext } from "../../utils/Context";
import { useEffect } from "react";
interface LoginData { interface LoginData {
username: string; username: string;
password: string; password: string;
} }
function Login() { export const Login = () => {
const history = useHistory(); const history = useHistory();
const [, setAuthContext] = AuthContext.use(); const [, setAuthContext] = AuthContext.use();
useEffect(() => {
// Check if onboarding is available for this instance
// and redirect if needed
APIClient.auth.canOnboard()
.then(() => history.push("/onboard"));
}, [history]);
const mutation = useMutation( const mutation = useMutation(
(data: LoginData) => APIClient.auth.login(data.username, data.password), (data: LoginData) => APIClient.auth.login(data.username, data.password),
{ {
@ -48,7 +56,6 @@ function Login() {
initialValues={{ username: "", password: "" }} initialValues={{ username: "", password: "" }}
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
{() => (
<Form> <Form>
<div className="space-y-6"> <div className="space-y-6">
<TextField name="username" label="Username" columns={6} autoComplete="username" /> <TextField name="username" label="Username" columns={6} autoComplete="username" />
@ -63,12 +70,9 @@ function Login() {
</button> </button>
</div> </div>
</Form> </Form>
)}
</Formik> </Formik>
</div> </div>
</div> </div>
</div> </div>
) );
} }
export default Login;

View file

@ -1,23 +1,25 @@
import {useEffect} from "react"; import { useEffect } from "react";
import {useCookies} from "react-cookie"; import { useCookies } from "react-cookie";
import {useHistory} from "react-router-dom"; import { useHistory } from "react-router-dom";
import { APIClient } from "../../api/APIClient"; import { APIClient } from "../../api/APIClient";
import { AuthContext } from "../../utils/Context"; import { AuthContext } from "../../utils/Context";
function Logout() { export const Logout = () => {
const history = useHistory(); const history = useHistory();
const [, setAuthContext] = AuthContext.use(); const [, setAuthContext] = AuthContext.use();
const [,, removeCookie] = useCookies(['user_session']); const [,, removeCookie] = useCookies(["user_session"]);
useEffect( useEffect(
() => { () => {
APIClient.auth.logout().then(() => { APIClient.auth.logout()
.then(() => {
setAuthContext({ username: "", isLoggedIn: false }); setAuthContext({ username: "", isLoggedIn: false });
removeCookie("user_session"); removeCookie("user_session");
history.push('/login');
}) history.push("/login");
});
}, },
[history, removeCookie, setAuthContext] [history, removeCookie, setAuthContext]
); );
@ -26,7 +28,5 @@ function Logout() {
<div className="min-h-screen bg-gray-50 dark:bg-gray-800 flex flex-col justify-center py-12 sm:px-6 lg:px-8"> <div className="min-h-screen bg-gray-50 dark:bg-gray-800 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<p>Logged out</p> <p>Logged out</p>
</div> </div>
) );
} }
export default Logout;

View file

@ -0,0 +1,85 @@
import { Form, Formik } from "formik";
import { useMutation } from "react-query";
import { useHistory } from "react-router-dom";
import { APIClient } from "../../api/APIClient";
import { TextField, PasswordField } from "../../components/inputs";
interface InputValues {
username: string;
password1: string;
password2: string;
}
export const Onboarding = () => {
const validate = (values: InputValues) => {
const obj: Record<string, string> = {};
if (!values.username)
obj.username = "Required";
if (!values.password1)
obj.password1 = "Required";
if (!values.password2)
obj.password2 = "Required";
if (values.password1 !== values.password2)
obj.password2 = "Passwords don't match!";
return obj;
};
const history = useHistory();
const mutation = useMutation(
(data: InputValues) => APIClient.auth.onboard(data.username, data.password1),
{
onSuccess: () => {
history.push("/login");
},
}
);
return (
<div className="min-h-screen flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md mb-6">
<h1
className="text-3xl font-bold leading-6 text-gray-900 dark:text-gray-200 mt-4"
>
Create a new user
</h1>
</div>
<div className="sm:mx-auto sm:w-full sm:max-w-md shadow-lg">
<div className="bg-white dark:bg-gray-800 py-8 px-4 sm:rounded-lg sm:px-10">
<Formik
initialValues={{
username: "",
password1: "",
password2: ""
}}
onSubmit={(data) => mutation.mutate(data)}
validate={validate}
>
<Form>
<div className="space-y-6">
<TextField name="username" label="Username" columns={6} autoComplete="username" />
<PasswordField name="password1" label="Password" columns={6} autoComplete="current-password" />
<PasswordField name="password2" label="Confirm password" columns={6} autoComplete="current-password" />
</div>
<div className="mt-6">
<button
type="submit"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium 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 an account!
</button>
</div>
</Form>
</Formik>
</div>
</div>
</div>
);
}