diff --git a/admin/accounthttp.go b/admin/accounthttp.go index f0990df..56aa247 100644 --- a/admin/accounthttp.go +++ b/admin/accounthttp.go @@ -3,24 +3,13 @@ package admin import ( "fmt" "net/http" - "os" - "strings" - "time" "arimelody-web/controller" - "arimelody-web/global" "arimelody-web/model" "github.com/jmoiron/sqlx" - "golang.org/x/crypto/bcrypt" ) -type TemplateData struct { - Account *model.Account - Message string - Token string -} - func AccountHandler(db *sqlx.DB) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { account := r.Context().Value("account").(*model.Account) @@ -47,299 +36,3 @@ func AccountHandler(db *sqlx.DB) http.Handler { }) } -func LoginHandler(db *sqlx.DB) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - account, err := controller.GetAccountByRequest(db, r) - if err != nil { - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) - return - } - if account != nil { - http.Redirect(w, r, "/admin", http.StatusFound) - return - } - - err = pages["login"].Execute(w, TemplateData{}) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - return - } - - type LoginResponse struct { - Account *model.Account - Token string - Message string - } - - render := func(data LoginResponse) { - err := pages["login"].Execute(w, data) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - } - - if r.Method != http.MethodPost { - http.NotFound(w, r); - return - } - - err := r.ParseForm() - if err != nil { - render(LoginResponse{ Message: "Malformed request." }) - return - } - - type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` - TOTP string `json:"totp"` - } - credentials := LoginRequest{ - Username: r.Form.Get("username"), - Password: r.Form.Get("password"), - TOTP: r.Form.Get("totp"), - } - - account, err := controller.GetAccount(db, credentials.Username) - if err != nil { - render(LoginResponse{ Message: "Invalid username or password" }) - return - } - if account == nil { - render(LoginResponse{ Message: "Invalid username or password" }) - return - } - - err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) - if err != nil { - render(LoginResponse{ Message: "Invalid username or password" }) - return - } - - totps, err := controller.GetTOTPsForAccount(db, account.ID) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to fetch TOTPs: %v\n", err) - render(LoginResponse{ Message: "Something went wrong. Please try again." }) - return - } - if len(totps) > 0 { - success := false - for _, totp := range totps { - check := controller.GenerateTOTP(totp.Secret, 0) - if check == credentials.TOTP { - success = true - break - } - } - if !success { - render(LoginResponse{ Message: "Invalid TOTP" }) - return - } - } else { - // TODO: user should be prompted to add 2FA method - } - - // login success! - token, err := controller.CreateToken(db, account.ID, r.UserAgent()) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) - render(LoginResponse{ Message: "Something went wrong. Please try again." }) - return - } - - cookie := http.Cookie{} - cookie.Name = global.COOKIE_TOKEN - cookie.Value = token.Token - cookie.Expires = token.ExpiresAt - if strings.HasPrefix(global.Config.BaseUrl, "https") { - cookie.Secure = true - } - cookie.HttpOnly = true - cookie.Path = "/" - http.SetCookie(w, &cookie) - - render(LoginResponse{ Account: account, Token: token.Token }) - }) -} - -func LogoutHandler(db *sqlx.DB) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.NotFound(w, r) - return - } - - tokenStr := controller.GetTokenFromRequest(db, r) - - if len(tokenStr) > 0 { - err := controller.DeleteToken(db, tokenStr) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - } - - cookie := http.Cookie{} - cookie.Name = global.COOKIE_TOKEN - cookie.Value = "" - cookie.Expires = time.Now() - if strings.HasPrefix(global.Config.BaseUrl, "https") { - cookie.Secure = true - } - cookie.HttpOnly = true - cookie.Path = "/" - http.SetCookie(w, &cookie) - http.Redirect(w, r, "/admin/login", http.StatusFound) - }) -} - -func createAccountHandler(db *sqlx.DB) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - checkAccount, err := controller.GetAccountByRequest(db, r) - if err != nil { - fmt.Printf("WARN: Failed to fetch account: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - if checkAccount != nil { - // user is already logged in - http.Redirect(w, r, "/admin", http.StatusFound) - return - } - - type CreateAccountResponse struct { - Account *model.Account - Message string - } - - render := func(data CreateAccountResponse) { - err := pages["create-account"].Execute(w, data) - if err != nil { - fmt.Printf("WARN: Error rendering create account page: %s\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - } - } - - if r.Method == http.MethodGet { - render(CreateAccountResponse{}) - return - } - - if r.Method != http.MethodPost { - http.NotFound(w, r) - return - } - - err = r.ParseForm() - if err != nil { - render(CreateAccountResponse{ - Message: "Malformed data.", - }) - return - } - - type RegisterRequest struct { - Username string `json:"username"` - Email string `json:"email"` - Password string `json:"password"` - Invite string `json:"invite"` - } - credentials := RegisterRequest{ - Username: r.Form.Get("username"), - Email: r.Form.Get("email"), - Password: r.Form.Get("password"), - Invite: r.Form.Get("invite"), - } - - // make sure code exists in DB - invite, err := controller.GetInvite(db, credentials.Invite) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) - render(CreateAccountResponse{ - Message: "Something went wrong. Please try again.", - }) - return - } - if invite == nil || time.Now().After(invite.ExpiresAt) { - if invite != nil { - err := controller.DeleteInvite(db, invite.Code) - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } - } - render(CreateAccountResponse{ - Message: "Invalid invite code.", - }) - return - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) - render(CreateAccountResponse{ - Message: "Something went wrong. Please try again.", - }) - return - } - - account := model.Account{ - Username: credentials.Username, - Password: string(hashedPassword), - Email: credentials.Email, - AvatarURL: "/img/default-avatar.png", - } - err = controller.CreateAccount(db, &account) - if err != nil { - if strings.HasPrefix(err.Error(), "pq: duplicate key") { - render(CreateAccountResponse{ - Message: "An account with that username already exists.", - }) - return - } - fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) - render(CreateAccountResponse{ - Message: "Something went wrong. Please try again.", - }) - return - } - - err = controller.DeleteInvite(db, invite.Code) - if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } - - // registration success! - token, err := controller.CreateToken(db, account.ID, r.UserAgent()) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) - // gracefully redirect user to login page - http.Redirect(w, r, "/admin/login", http.StatusFound) - return - } - - cookie := http.Cookie{} - cookie.Name = global.COOKIE_TOKEN - cookie.Value = token.Token - cookie.Expires = token.ExpiresAt - if strings.HasPrefix(global.Config.BaseUrl, "https") { - cookie.Secure = true - } - cookie.HttpOnly = true - cookie.Path = "/" - http.SetCookie(w, &cookie) - - err = pages["login"].Execute(w, TemplateData{ - Account: &account, - Token: token.Token, - }) - if err != nil { - fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - }) -} diff --git a/admin/http.go b/admin/http.go index d118647..9eae2b7 100644 --- a/admin/http.go +++ b/admin/http.go @@ -6,13 +6,22 @@ import ( "net/http" "os" "path/filepath" + "strings" + "time" "arimelody-web/controller" + "arimelody-web/global" "arimelody-web/model" "github.com/jmoiron/sqlx" + "golang.org/x/crypto/bcrypt" ) +type TemplateData struct { + Account *model.Account + Token string +} + func Handler(db *sqlx.DB) http.Handler { mux := http.NewServeMux() @@ -103,6 +112,275 @@ func RequireAccount(db *sqlx.DB, next http.Handler) http.HandlerFunc { }) } +func LoginHandler(db *sqlx.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + account, err := controller.GetAccountByRequest(db, r) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + fmt.Fprintf(os.Stderr, "WARN: Failed to fetch account: %v\n", err) + return + } + if account != nil { + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + + err = pages["login"].Execute(w, TemplateData{}) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Error rendering admin login page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + return + } + + if r.Method != http.MethodPost { + http.NotFound(w, r); + return + } + + err := r.ParseForm() + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + TOTP string `json:"totp"` + } + credentials := LoginRequest{ + Username: r.Form.Get("username"), + Password: r.Form.Get("password"), + TOTP: r.Form.Get("totp"), + } + + account, err := controller.GetAccount(db, credentials.Username) + if err != nil { + http.Error(w, "Invalid username or password", http.StatusBadRequest) + return + } + if account == nil { + http.Error(w, "Invalid username or password", http.StatusBadRequest) + return + } + + err = bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(credentials.Password)) + if err != nil { + http.Error(w, "Invalid username or password", http.StatusBadRequest) + return + } + + // TODO: check TOTP + + // login success! + token, err := controller.CreateToken(db, account.ID, r.UserAgent()) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + cookie := http.Cookie{} + cookie.Name = global.COOKIE_TOKEN + cookie.Value = token.Token + cookie.Expires = token.ExpiresAt + if strings.HasPrefix(global.Config.BaseUrl, "https") { + cookie.Secure = true + } + cookie.HttpOnly = true + cookie.Path = "/" + http.SetCookie(w, &cookie) + + err = pages["login"].Execute(w, TemplateData{ + Account: account, + Token: token.Token, + }) + if err != nil { + fmt.Printf("Error rendering admin login page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} + +func LogoutHandler(db *sqlx.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.NotFound(w, r) + return + } + + tokenStr := controller.GetTokenFromRequest(db, r) + + if len(tokenStr) > 0 { + err := controller.DeleteToken(db, tokenStr) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to revoke token: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + } + + cookie := http.Cookie{} + cookie.Name = global.COOKIE_TOKEN + cookie.Value = "" + cookie.Expires = time.Now() + if strings.HasPrefix(global.Config.BaseUrl, "https") { + cookie.Secure = true + } + cookie.HttpOnly = true + cookie.Path = "/" + http.SetCookie(w, &cookie) + http.Redirect(w, r, "/admin/login", http.StatusFound) + }) +} + +func createAccountHandler(db *sqlx.DB) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + checkAccount, err := controller.GetAccountByRequest(db, r) + if err != nil { + fmt.Printf("WARN: Failed to fetch account: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if checkAccount != nil { + // user is already logged in + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + + type CreateAccountResponse struct { + Account *model.Account + Message string + } + + render := func(data CreateAccountResponse) { + err := pages["create-account"].Execute(w, data) + if err != nil { + fmt.Printf("WARN: Error rendering create account page: %s\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + } + + if r.Method == http.MethodGet { + render(CreateAccountResponse{}) + return + } + + if r.Method != http.MethodPost { + http.NotFound(w, r) + return + } + + err = r.ParseForm() + if err != nil { + render(CreateAccountResponse{ + Message: "Malformed data.", + }) + return + } + + type RegisterRequest struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Invite string `json:"invite"` + } + credentials := RegisterRequest{ + Username: r.Form.Get("username"), + Email: r.Form.Get("email"), + Password: r.Form.Get("password"), + Invite: r.Form.Get("invite"), + } + + // make sure code exists in DB + invite, err := controller.GetInvite(db, credentials.Invite) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to retrieve invite: %v\n", err) + render(CreateAccountResponse{ + Message: "Something went wrong. Please try again.", + }) + return + } + if invite == nil || time.Now().After(invite.ExpiresAt) { + if invite != nil { + err := controller.DeleteInvite(db, invite.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } + } + render(CreateAccountResponse{ + Message: "Invalid invite code.", + }) + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(credentials.Password), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to generate password hash: %v\n", err) + render(CreateAccountResponse{ + Message: "Something went wrong. Please try again.", + }) + return + } + + account := model.Account{ + Username: credentials.Username, + Password: string(hashedPassword), + Email: credentials.Email, + AvatarURL: "/img/default-avatar.png", + } + err = controller.CreateAccount(db, &account) + if err != nil { + if strings.HasPrefix(err.Error(), "pq: duplicate key") { + render(CreateAccountResponse{ + Message: "An account with that username already exists.", + }) + return + } + fmt.Fprintf(os.Stderr, "WARN: Failed to create account: %v\n", err) + render(CreateAccountResponse{ + Message: "Something went wrong. Please try again.", + }) + return + } + + err = controller.DeleteInvite(db, invite.Code) + if err != nil { fmt.Fprintf(os.Stderr, "WARN: Failed to delete expired invite: %v\n", err) } + + // registration success! + token, err := controller.CreateToken(db, account.ID, r.UserAgent()) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to create token: %v\n", err) + // gracefully redirect user to login page + http.Redirect(w, r, "/admin/login", http.StatusFound) + return + } + + cookie := http.Cookie{} + cookie.Name = global.COOKIE_TOKEN + cookie.Value = token.Token + cookie.Expires = token.ExpiresAt + if strings.HasPrefix(global.Config.BaseUrl, "https") { + cookie.Secure = true + } + cookie.HttpOnly = true + cookie.Path = "/" + http.SetCookie(w, &cookie) + + err = pages["login"].Execute(w, TemplateData{ + Account: &account, + Token: token.Token, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "WARN: Failed to render login page: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + }) +} + func staticHandler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { info, err := os.Stat(filepath.Join("admin", "static", filepath.Clean(r.URL.Path))) diff --git a/admin/static/admin.css b/admin/static/admin.css index 32f69bb..510ee8b 100644 --- a/admin/static/admin.css +++ b/admin/static/admin.css @@ -124,65 +124,3 @@ a img.icon { font-size: 12px; } } - - - -#error { - background: #ffa9b8; - border: 1px solid #dc5959; - padding: 1em; - border-radius: 4px; -} - - - -button, .button { - padding: .5em .8em; - font-family: inherit; - font-size: inherit; - border-radius: .5em; - border: 1px solid #a0a0a0; - background: #f0f0f0; - color: inherit; -} -button:hover, .button:hover { - background: #fff; - border-color: #d0d0d0; -} -button:active, .button:active { - background: #d0d0d0; - border-color: #808080; -} - -button { - color: inherit; -} -button.new { - background: #c4ff6a; - border-color: #84b141; -} -button.save { - background: #6fd7ff; - border-color: #6f9eb0; -} -button.delete { - background: #ff7171; - border-color: #7d3535; -} -button:hover { - background: #fff; - border-color: #d0d0d0; -} -button:active { - background: #d0d0d0; - border-color: #808080; -} -button[disabled] { - background: #d0d0d0 !important; - border-color: #808080 !important; - opacity: .5; - cursor: not-allowed !important; -} -a.delete { - color: #d22828; -} diff --git a/admin/static/edit-account.css b/admin/static/edit-account.css index 625db13..7c75b66 100644 --- a/admin/static/edit-account.css +++ b/admin/static/edit-account.css @@ -39,27 +39,3 @@ input { padding: 1em; border-radius: 4px; } - -.mfa-device { - padding: .75em; - background: #f8f8f8f8; - border: 1px solid #808080; - border-radius: .5em; - margin-bottom: .5em; - display: flex; - justify-content: space-between; -} - -.mfa-device div { - display: flex; - flex-direction: column; - justify-content: center; -} - -.mfa-device p { - margin: 0; -} - -.mfa-device .mfa-device-name { - font-weight: bold; -} diff --git a/admin/static/edit-account.js b/admin/static/edit-account.js deleted file mode 100644 index e69de29..0000000 diff --git a/admin/static/edit-artist.css b/admin/static/edit-artist.css index 793b989..e481b68 100644 --- a/admin/static/edit-artist.css +++ b/admin/static/edit-artist.css @@ -66,6 +66,54 @@ input[type="text"]:focus { border-color: #808080; } +button, .button { + padding: .5em .8em; + font-family: inherit; + font-size: inherit; + border-radius: .5em; + border: 1px solid #a0a0a0; + background: #f0f0f0; + color: inherit; +} +button:hover, .button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active, .button:active { + background: #d0d0d0; + border-color: #808080; +} + +button { + color: inherit; +} +button.save { + background: #6fd7ff; + border-color: #6f9eb0; +} +button.delete { + background: #ff7171; + border-color: #7d3535; +} +button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active { + background: #d0d0d0; + border-color: #808080; +} +button[disabled] { + background: #d0d0d0 !important; + border-color: #808080 !important; + opacity: .5; + cursor: not-allowed !important; +} + +a.delete { + color: #d22828; +} + .artist-actions { margin-top: auto; display: flex; diff --git a/admin/static/edit-release.css b/admin/static/edit-release.css index 10eada3..9feb9ad 100644 --- a/admin/static/edit-release.css +++ b/admin/static/edit-release.css @@ -109,6 +109,58 @@ input[type="text"] { padding: 0; } +button, .button { + padding: .5em .8em; + font-family: inherit; + font-size: inherit; + border-radius: .5em; + border: 1px solid #a0a0a0; + background: #f0f0f0; + color: inherit; +} +button:hover, .button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active, .button:active { + background: #d0d0d0; + border-color: #808080; +} + +button { + color: inherit; +} +button.new { + background: #c4ff6a; + border-color: #84b141; +} +button.save { + background: #6fd7ff; + border-color: #6f9eb0; +} +button.delete { + background: #ff7171; + border-color: #7d3535; +} +button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active { + background: #d0d0d0; + border-color: #808080; +} +button[disabled] { + background: #d0d0d0 !important; + border-color: #808080 !important; + opacity: .5; + cursor: not-allowed !important; +} + +a.delete { + color: #d22828; +} + .release-actions { margin-top: auto; display: flex; diff --git a/admin/static/edit-track.css b/admin/static/edit-track.css index 8a05089..6e87397 100644 --- a/admin/static/edit-track.css +++ b/admin/static/edit-track.css @@ -67,6 +67,54 @@ h1 { border-color: #808080; } +button, .button { + padding: .5em .8em; + font-family: inherit; + font-size: inherit; + border-radius: .5em; + border: 1px solid #a0a0a0; + background: #f0f0f0; + color: inherit; +} +button:hover, .button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active, .button:active { + background: #d0d0d0; + border-color: #808080; +} + +button { + color: inherit; +} +button.save { + background: #6fd7ff; + border-color: #6f9eb0; +} +button.delete { + background: #ff7171; + border-color: #7d3535; +} +button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active { + background: #d0d0d0; + border-color: #808080; +} +button[disabled] { + background: #d0d0d0 !important; + border-color: #808080 !important; + opacity: .5; + cursor: not-allowed !important; +} + +a.delete { + color: #d22828; +} + .track-actions { margin-top: 1em; display: flex; diff --git a/admin/static/index.css b/admin/static/index.css index 9d38940..1411eff 100644 --- a/admin/static/index.css +++ b/admin/static/index.css @@ -98,3 +98,49 @@ .track .empty { opacity: 0.75; } + + + +button, .button { + padding: .5em .8em; + font-family: inherit; + font-size: inherit; + border-radius: .5em; + border: 1px solid #a0a0a0; + background: #f0f0f0; + color: inherit; +} +button:hover, .button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active, .button:active { + background: #d0d0d0; + border-color: #808080; +} + +button { + color: inherit; +} +button.save { + background: #6fd7ff; + border-color: #6f9eb0; +} +button.delete { + background: #ff7171; + border-color: #7d3535; +} +button:hover { + background: #fff; + border-color: #d0d0d0; +} +button:active { + background: #d0d0d0; + border-color: #808080; +} +button[disabled] { + background: #d0d0d0 !important; + border-color: #808080 !important; + opacity: .5; + cursor: not-allowed !important; +} diff --git a/admin/views/create-account.html b/admin/views/create-account.html index 8d59c0f..b0aff03 100644 --- a/admin/views/create-account.html +++ b/admin/views/create-account.html @@ -1,7 +1,7 @@ {{define "head"}}
You have no MFA devices.
{{end}} - + Add MFA Device