mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 16:59:12 +00:00
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:
parent
982eddc269
commit
1a4f3cf55d
11 changed files with 337 additions and 109 deletions
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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} />
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
|
@ -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;
|
|
85
web/src/screens/auth/onboarding.tsx
Normal file
85
web/src/screens/auth/onboarding.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue