mirror of
https://github.com/idanoo/autobrr
synced 2025-07-22 16:29: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 {
|
||||
GetUserCount(ctx context.Context) (int, error)
|
||||
Login(ctx context.Context, username, password string) (*domain.User, error)
|
||||
CreateUser(ctx context.Context, username, password string) error
|
||||
}
|
||||
|
||||
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) {
|
||||
if username == "" || password == "" {
|
||||
return nil, errors.New("bad credentials")
|
||||
return nil, errors.New("empty credentials supplied")
|
||||
}
|
||||
|
||||
// find user
|
||||
|
@ -50,3 +56,33 @@ func (s *service) Login(ctx context.Context, username, password string) (*domain
|
|||
|
||||
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}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
queryBuilder := r.db.squirrel.
|
||||
|
@ -66,6 +89,7 @@ func (r *UserRepo) Store(ctx context.Context, user domain.User) error {
|
|||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *UserRepo) Update(ctx context.Context, user domain.User) error {
|
||||
|
||||
var err error
|
||||
|
|
|
@ -3,6 +3,7 @@ package domain
|
|||
import "context"
|
||||
|
||||
type UserRepo interface {
|
||||
GetUserCount(ctx context.Context) (int, error)
|
||||
FindByUsername(ctx context.Context, username string) (*User, error)
|
||||
Store(ctx context.Context, user User) error
|
||||
Update(ctx context.Context, user User) error
|
||||
|
|
|
@ -12,7 +12,9 @@ import (
|
|||
)
|
||||
|
||||
type authService interface {
|
||||
GetUserCount(ctx context.Context) (int, error)
|
||||
Login(ctx context.Context, username, password string) (*domain.User, error)
|
||||
CreateUser(ctx context.Context, username, password string) error
|
||||
}
|
||||
|
||||
type authHandler struct {
|
||||
|
@ -35,6 +37,8 @@ func newAuthHandler(encoder encoder, config domain.Config, cookieStore *sessions
|
|||
func (h authHandler) Routes(r chi.Router) {
|
||||
r.Post("/login", h.login)
|
||||
r.Post("/logout", h.logout)
|
||||
r.Post("/onboard", h.onboard)
|
||||
r.Get("/onboard", h.canOnboard)
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
ctx := r.Context()
|
||||
session, _ := h.cookieStore.Get(r, "user_session")
|
||||
|
|
|
@ -2,11 +2,14 @@ package user
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/autobrr/autobrr/internal/domain"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
GetUserCount(ctx context.Context) (int, error)
|
||||
FindByUsername(ctx context.Context, username string) (*domain.User, error)
|
||||
CreateUser(ctx context.Context, user domain.User) error
|
||||
}
|
||||
|
||||
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) {
|
||||
user, err := s.repo.FindByUsername(ctx, username)
|
||||
if err != nil {
|
||||
|
@ -27,3 +34,16 @@ func (s *service) FindByUsername(ctx context.Context, username string) (*domain.
|
|||
|
||||
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,44 +1,49 @@
|
|||
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 { ReactQueryDevtools } from "react-query/devtools";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
|
||||
import Base from "./screens/Base";
|
||||
import Login from "./screens/auth/login";
|
||||
import Logout from "./screens/auth/logout";
|
||||
import { Login } from "./screens/auth/login";
|
||||
import { Logout } from "./screens/auth/logout";
|
||||
import { Onboarding } from "./screens/auth/onboarding";
|
||||
import { baseUrl } from "./utils";
|
||||
|
||||
import { AuthContext, SettingsContext } from "./utils/Context";
|
||||
|
||||
function Protected() {
|
||||
return (
|
||||
<Fragment>
|
||||
<Toaster position="top-right" />
|
||||
<Base />
|
||||
</Fragment>
|
||||
)
|
||||
return (
|
||||
<Fragment>
|
||||
<Toaster position="top-right" />
|
||||
<Base />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
|
||||
export function App() {
|
||||
const authContext = AuthContext.useValue();
|
||||
const settings = SettingsContext.useValue();
|
||||
const authContext = AuthContext.useValue();
|
||||
const settings = SettingsContext.useValue();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router basename={baseUrl()}>
|
||||
{authContext.isLoggedIn ? (
|
||||
<Route exact path="/*" component={Protected} />
|
||||
) :
|
||||
<Route exact path="/*" component={Login} />
|
||||
}
|
||||
<Route exact path="/logout" component={Logout} />
|
||||
</Router>
|
||||
{settings.debug ? (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
) : null}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Router basename={baseUrl()}>
|
||||
<Route exact path="/logout" component={Logout} />
|
||||
|
||||
{authContext.isLoggedIn ? (
|
||||
<Route component={Protected} />
|
||||
) : (
|
||||
<Switch>
|
||||
<Route exact path="/onboard" component={Onboarding} />
|
||||
<Route component={Login} />
|
||||
</Switch>
|
||||
)}
|
||||
</Router>
|
||||
{settings.debug ? (
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
) : null}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
|
@ -62,6 +62,8 @@ export const APIClient = {
|
|||
login: (username: string, password: string) => appClient.Post("api/auth/login", { username: username, password: password }),
|
||||
logout: () => appClient.Post("api/auth/logout", null),
|
||||
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: {
|
||||
create: (action: Action) => appClient.Post("api/actions", action),
|
||||
|
|
|
@ -47,12 +47,12 @@ export const TextField = ({
|
|||
type="text"
|
||||
defaultValue={defaultValue}
|
||||
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}
|
||||
/>
|
||||
|
||||
{meta.touched && meta.error && (
|
||||
<div className="error">{meta.error}</div>
|
||||
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
@ -118,7 +118,7 @@ export const PasswordField = ({
|
|||
)}
|
||||
|
||||
{meta.touched && meta.error && (
|
||||
<div className="error">{meta.error}</div>
|
||||
<p className="error text-sm text-red-600 mt-1">* {meta.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -7,68 +7,72 @@ import { TextField, PasswordField } from "../../components/inputs";
|
|||
|
||||
import logo from "../../logo.png";
|
||||
import { AuthContext } from "../../utils/Context";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface LoginData {
|
||||
username: string;
|
||||
password: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
function Login() {
|
||||
const history = useHistory();
|
||||
const [, setAuthContext] = AuthContext.use();
|
||||
export const Login = () => {
|
||||
const history = useHistory();
|
||||
const [, setAuthContext] = AuthContext.use();
|
||||
|
||||
const mutation = useMutation(
|
||||
(data: LoginData) => APIClient.auth.login(data.username, data.password),
|
||||
{
|
||||
onSuccess: (_, variables: LoginData) => {
|
||||
setAuthContext({
|
||||
username: variables.username,
|
||||
isLoggedIn: true
|
||||
});
|
||||
history.push("/");
|
||||
},
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
// Check if onboarding is available for this instance
|
||||
// and redirect if needed
|
||||
APIClient.auth.canOnboard()
|
||||
.then(() => history.push("/onboard"));
|
||||
}, [history]);
|
||||
|
||||
const handleSubmit = (data: any) => mutation.mutate(data);
|
||||
const mutation = useMutation(
|
||||
(data: LoginData) => APIClient.auth.login(data.username, data.password),
|
||||
{
|
||||
onSuccess: (_, variables: LoginData) => {
|
||||
setAuthContext({
|
||||
username: variables.username,
|
||||
isLoggedIn: true
|
||||
});
|
||||
history.push("/");
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
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">
|
||||
<img
|
||||
className="mx-auto h-12 w-auto"
|
||||
src={logo}
|
||||
alt="logo"
|
||||
/>
|
||||
</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">
|
||||
const handleSubmit = (data: any) => mutation.mutate(data);
|
||||
|
||||
<Formik
|
||||
initialValues={{ username: "", password: "" }}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{() => (
|
||||
<Form>
|
||||
<div className="space-y-6">
|
||||
<TextField name="username" label="Username" columns={6} autoComplete="username" />
|
||||
<PasswordField name="password" label="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"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</div>
|
||||
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">
|
||||
<img
|
||||
className="mx-auto h-12 w-auto"
|
||||
src={logo}
|
||||
alt="logo"
|
||||
/>
|
||||
</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: "", password: "" }}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<Form>
|
||||
<div className="space-y-6">
|
||||
<TextField name="username" label="Username" columns={6} autoComplete="username" />
|
||||
<PasswordField name="password" label="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"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
</Formik>
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
|
|
|
@ -1,32 +1,32 @@
|
|||
import {useEffect} from "react";
|
||||
import {useCookies} from "react-cookie";
|
||||
import {useHistory} from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { useCookies } from "react-cookie";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import { APIClient } from "../../api/APIClient";
|
||||
import { AuthContext } from "../../utils/Context";
|
||||
|
||||
function Logout() {
|
||||
const history = useHistory();
|
||||
export const Logout = () => {
|
||||
const history = useHistory();
|
||||
|
||||
const [, setAuthContext] = AuthContext.use();
|
||||
const [,, removeCookie] = useCookies(['user_session']);
|
||||
const [, setAuthContext] = AuthContext.use();
|
||||
const [,, removeCookie] = useCookies(["user_session"]);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
APIClient.auth.logout().then(() => {
|
||||
setAuthContext({ username: "", isLoggedIn: false });
|
||||
removeCookie("user_session");
|
||||
history.push('/login');
|
||||
})
|
||||
},
|
||||
[history, removeCookie, setAuthContext]
|
||||
);
|
||||
useEffect(
|
||||
() => {
|
||||
APIClient.auth.logout()
|
||||
.then(() => {
|
||||
setAuthContext({ username: "", isLoggedIn: false });
|
||||
removeCookie("user_session");
|
||||
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
history.push("/login");
|
||||
});
|
||||
},
|
||||
[history, removeCookie, setAuthContext]
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
</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