Admin: Add user page

This commit is contained in:
Tom Wiesing 2023-01-04 16:10:55 +01:00
parent bc0e92bdac
commit d34e85a18f
No known key found for this signature in database
24 changed files with 456 additions and 77 deletions

View file

@ -9,6 +9,7 @@ import (
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter/logger"
"github.com/julienschmidt/httprouter"
"github.com/rs/zerolog"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
@ -48,6 +49,7 @@ func (admin *Admin) HandleRoute(ctx context.Context, route string) (handler http
Handler: admin.serveSocket,
}
handler = admin.Dependencies.Auth.Protect(socket, auth.Admin)
handler = admin.Dependencies.Auth.CSRF()(handler)
}
// handle everything
@ -61,6 +63,26 @@ func (admin *Admin) HandleRoute(ctx context.Context, route string) (handler http
Template: indexTemplate,
})
// add a handler for the user page
router.Handler(http.MethodGet, route+"users", httpx.HTMLHandler[userContext]{
Handler: admin.users,
Template: userTemplate,
})
// add a user create form
{
create := admin.createUser(ctx)
router.Handler(http.MethodGet, route+"users/create", create)
router.Handler(http.MethodPost, route+"users/create", create)
}
// add all the admin actions
router.Handler(http.MethodPost, route+"users/delete", admin.usersDeleteHandler(ctx))
router.Handler(http.MethodPost, route+"users/disable", admin.usersDisableHandler(ctx))
router.Handler(http.MethodPost, route+"users/disabletotp", admin.usersDisableTOTPHandler(ctx))
router.Handler(http.MethodPost, route+"users/password", admin.usersPasswordHandler(ctx))
router.Handler(http.MethodPost, route+"users/toggleadmin", admin.usersToggleAdmin(ctx))
// add a handler for the component page
router.Handler(http.MethodGet, route+"components", httpx.HTMLHandler[componentContext]{
Handler: admin.components,
@ -79,9 +101,19 @@ func (admin *Admin) HandleRoute(ctx context.Context, route string) (handler http
Template: instanceTemplate,
})
router.Handler(http.MethodPost, route+"api/login", httpx.RedirectHandler(func(r *http.Request) (string, int, error) {
// add a router for the login page
router.Handler(http.MethodPost, route+"login", admin.loginHandler(ctx))
return
}
func (admin *Admin) loginHandler(ctx context.Context) http.Handler {
logger := zerolog.Ctx(ctx)
return httpx.RedirectHandler(func(r *http.Request) (string, int, error) {
// parse the form
if err := r.ParseForm(); err != nil {
logger.Err(err).Msg("failed to parse admin login")
return "", 0, err
}
@ -93,10 +125,9 @@ func (admin *Admin) HandleRoute(ctx context.Context, route string) (handler http
target, err := instance.Users().Login(r.Context(), nil, r.PostFormValue("user"))
if err != nil {
logger.Err(err).Msg("failed to admin login")
return "", 0, err
}
return target.String(), http.StatusSeeOther, err
}))
return
})
}

View file

@ -6,7 +6,10 @@
<a class="pure-button pure-button-primary" href="/admin/index">Admin</a>
</p>
<p>
<a class="pure-button" href="/admin/components">Components</a>
<div class="pure-button-group" role="group" aria-label="Actions">
<a class="pure-button" href="/admin/users">Users</a>
<a class="pure-button pure-button-small" href="/admin/components">Components</a>
</div>
</p>
{{ end }}

View file

@ -7,7 +7,9 @@
<a class="pure-button pure-button-primary" href="/admin/instance/{{ .Info.Slug }}">Instance</a>
</p>
<p>
<a class="pure-button" href="/admin/ingredients/{{ .Info.Slug }}">Ingredients</a>
<div class="pure-button-group" role="group" aria-label="Actions">
<a class="pure-button pure-button-small" href="/admin/ingredients/{{ .Info.Slug }}">Ingredients</a>
</div>
</p>
{{ end }}
@ -216,6 +218,7 @@
</thead>
<tbody>
{{ $slug := .Instance.Slug }}
{{ $csrf := .CSRF }}
{{ range $index, $user := .Info.Users }}
<tr {{ if not $user.Status }}style="color:gray"{{ end }}>
<td>
@ -247,10 +250,11 @@
<code class="date">{{ $user.Login.Time.Format "2006-01-02T15:04:05Z07:00" }}</code>
</td>
<td>
<form action="/admin/api/login" method="POST" target="_blank">
<form action="/admin/login" method="POST" target="_blank">
<input type="hidden" name="slug" value="{{ $slug }}">
<input type="hidden" name="user" value="{{ $user.Name }}">
<input type="submit" class="pure-button pure-button-action" value="Login in new window">
{{ $csrf }}
</form>
</td>
</tr>

View file

@ -0,0 +1,11 @@
{{ template "_form.html" . }}
{{ define "form/title" }}Distillery Admin - Create User{{ end }}
{{ define "form/button" }}Create{{ end }}
{{ define "header"}}
<p>
<a class="pure-button" href="/admin/index">Control</a> &gt;
<a class="pure-button" href="/admin/users">Users</a> &gt;
<a class="pure-button pure-button-primary" href="/admin/users/create">Create</a>
</p>
{{ end }}

View file

@ -0,0 +1,95 @@
{{ template "_base.html" . }}
{{ define "title" }}Distillery Admin - Users{{ end }}
{{ define "header"}}
<p>
<a class="pure-button" href="/admin/index">Control</a> &gt;
<a class="pure-button pure-button-primary" href="/admin/users">Users</a>
</p>
<p>
<div class="pure-button-group" role="group" aria-label="Actions">
<a class="pure-button" href="/admin/users/create">Create New</a>
</div>
</p>
{{ end }}
{{ define "content" }}
<div class="pure-u-1">
<div class="padding">
<div class="overflow">
<table class="pure-table pure-table-bordered">
<thead>
<tr>
<th>
Username
</th>
<th>
Enabled
</th>
<th>
Admin
</th>
<th>
Passcode (TOTP)
</th>
<th>
Actions
</th>
</tr>
</thead>
<tbody>
{{ $csrf := .CSRF }}
{{ range .Users }}
<tr {{ if not .User.IsEnabled }}style="color:gray"{{ end }}>
<td>
{{ .User.User }}
</td>
<td>
{{ .User.IsEnabled }}
</td>
<td>
{{ .User.IsAdmin }}
</td>
<td>
{{ .User.IsTOTPEnabled }}
</td>
<td>
<div class="pure-button-group" role="group">
<form action="/admin/users/toggleadmin" method="POST" class="pure-form-group">
<input type="hidden" name="user" value="{{ .User.User }}">
<input type="submit" class="pure-button" value="{{ if .User.IsAdmin }}Remove Admin{{ else }} Make Admin{{ end }}">
{{ $csrf }}
</form>
<form action="/admin/users/password" method="POST" class="pure-form pure-form-group">
<input type="hidden" name="user" value="{{ .User.User }}">
<input type="password" name="password"> &nbsp;
<input type="submit" class="pure-button" value="Update Password">
{{ $csrf }}
</form>
<form action="/admin/users/disable" method="POST" class="pure-form-group">
<input type="hidden" name="user" value="{{ .User.User }}">
<input type="submit" class="pure-button" {{ if (not .User.IsEnabled) }}disabled{{ end }} value="Disable">
{{ $csrf }}
</form>
<form action="/admin/users/disabletotp" method="POST" class="pure-form-group">
<input type="hidden" name="user" value="{{ .User.User }}">
<input type="submit" class="pure-button" {{ if (not .User.IsTOTPEnabled) }}disabled{{ end }} value="Remove Passcode">
{{ $csrf }}
</form>
<form action="/admin/users/delete" method="POST" class="pure-form-group">
<input type="hidden" name="user" value="{{ .User.User }}">
<input type="submit" class="pure-button pure-button-danger" value="Delete">
{{ $csrf }}
</form>
</div>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
{{ end }}

View file

@ -2,6 +2,7 @@ package admin
import (
_ "embed"
"html/template"
"net/http"
"time"
@ -10,6 +11,7 @@ import (
"github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/internal/status"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
"github.com/gorilla/csrf"
"github.com/gorilla/mux"
)
@ -23,11 +25,14 @@ var instanceTemplate = static.AssetsAdmin.MustParseShared(
type instanceContext struct {
Time time.Time
CSRF template.HTML
Instance models.Instance
Info status.WissKI
}
func (admin *Admin) instance(r *http.Request) (is instanceContext, err error) {
is.CSRF = csrf.TemplateField(r)
// find the instance itself!
instance, err := admin.Dependencies.Instances.WissKI(r.Context(), mux.Vars(r)["slug"])
if err == instances.ErrWissKINotFound {

View file

@ -0,0 +1,190 @@
package admin
import (
"context"
"errors"
"html/template"
"net/http"
"time"
_ "embed"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
"github.com/gorilla/csrf"
"github.com/rs/zerolog"
)
//go:embed "html/users.html"
var userTemplateString string
var userTemplate = static.AssetsAdmin.MustParseShared(
"users.html",
userTemplateString,
)
type userContext struct {
Time time.Time
CSRF template.HTML
Users []*auth.AuthUser
}
func (admin *Admin) users(r *http.Request) (uc userContext, err error) {
uc.CSRF = csrf.TemplateField(r)
uc.Time = time.Now()
uc.Users, err = admin.Dependencies.Auth.Users(r.Context())
return
}
//go:embed "html/user_create.html"
var userCreateTemplateString string
var userCreateTemplate = static.AssetsAdmin.MustParseShared(
"user_create.html",
userCreateTemplateString,
)
var (
errCreateInvalidUsername = errors.New("invalid username")
errCreateInvalidPassword = errors.New("invalid password")
)
type createUserResult struct {
User string
Passsword string
Admin bool
}
func (admin *Admin) createUser(ctx context.Context) http.Handler {
return &httpx.Form[createUserResult]{
Fields: []httpx.Field{
{Name: "username", Type: httpx.TextField, Label: "Username"},
{Name: "password", Type: httpx.PasswordField, Label: "Password"},
{Name: "admin", Type: httpx.CheckboxField, Label: "Distillery Administrator"},
},
FieldTemplate: httpx.PureCSSFieldTemplate,
CSRF: admin.Dependencies.Auth.CSRF(),
RenderTemplate: userCreateTemplate,
Validate: func(r *http.Request, values map[string]string) (cu createUserResult, err error) {
cu.User, cu.Passsword, cu.Admin = values["username"], values["password"], values["admin"] == "on"
if cu.User == "" {
return cu, errCreateInvalidUsername
}
if cu.Passsword == "" {
return cu, errCreateInvalidPassword
}
return cu, nil
},
RenderSuccess: func(cu createUserResult, values map[string]string, w http.ResponseWriter, r *http.Request) error {
// create the user
user, err := admin.Dependencies.Auth.CreateUser(r.Context(), cu.User)
if err != nil {
return err
}
// disable the user and setup the admin flag
user.SetAdmin(cu.Admin)
if err := user.Save(r.Context()); err != nil {
return err
}
// set the password!
err = user.SetPassword(r.Context(), []byte(cu.Passsword))
if err != nil {
return err
}
// everything went fine, redirect the user back to the user page!
http.Redirect(w, r, "/admin/users/", http.StatusSeeOther)
return nil
},
}
}
var errNotCurrentUser = httpx.Response{
Body: []byte("attempt to modify current user"),
StatusCode: http.StatusBadRequest,
}
func (admin *Admin) useraction(ctx context.Context, name string, action func(r *http.Request, user *auth.AuthUser) error) http.Handler {
logger := zerolog.Ctx(ctx)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
logger.Err(err).Str("action", name).Msg("failed to parse form")
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
return
}
username := r.PostFormValue("user")
user, err := admin.Dependencies.Auth.User(r.Context(), username)
if err != nil {
logger.Err(err).Str("action", name).Msg("failed to get user")
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
return
}
me, err := admin.Dependencies.Auth.UserOf(r)
if err != nil {
logger.Err(err).Str("action", name).Msg("failed to get current user")
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
return
}
// don't allow the current user
if me.User.User == user.User.User {
errNotCurrentUser.ServeHTTP(w, r)
return
}
if err := action(r, user); err != nil {
logger.Err(err).Str("action", name).Msg("failed to act on user")
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
return
}
http.Redirect(w, r, "/admin/users/", http.StatusSeeOther)
})
}
func (admin *Admin) usersDeleteHandler(ctx context.Context) http.Handler {
return admin.useraction(ctx, "delete user", func(r *http.Request, user *auth.AuthUser) error {
return user.Delete(r.Context())
})
}
func (admin *Admin) usersDisableHandler(ctx context.Context) http.Handler {
return admin.useraction(ctx, "disable user", func(r *http.Request, user *auth.AuthUser) error {
return user.UnsetPassword(r.Context())
})
}
func (admin *Admin) usersDisableTOTPHandler(ctx context.Context) http.Handler {
return admin.useraction(ctx, "disable user totp", func(r *http.Request, user *auth.AuthUser) error {
return user.DisableTOTP(r.Context())
})
}
func (admin *Admin) usersToggleAdmin(ctx context.Context) http.Handler {
return admin.useraction(ctx, "toggle admin", func(r *http.Request, user *auth.AuthUser) error {
if user.IsAdmin() {
return user.MakeRegular(r.Context())
}
return user.MakeAdmin(r.Context())
})
}
func (admin *Admin) usersPasswordHandler(ctx context.Context) http.Handler {
return admin.useraction(ctx, "set password", func(r *http.Request, user *auth.AuthUser) error {
password := r.PostFormValue("password")
if password == "" {
return httpx.ErrBadRequest
}
return user.SetPassword(r.Context(), []byte(password))
})
}

View file

@ -5,17 +5,17 @@ package static
// AssetsHome contains assets for the 'Home' entrypoint.
var AssetsHome = Assets{
Scripts: `<script type="module" src="/static/Home.38d394c2.js"></script><script src="/static/Home.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/Home.38d394c2.js"></script><script src="/static/Home.38d394c2.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/Home.4ec77c43.css"><link rel="stylesheet" href="/static/Home.2353e048.css">`,
Styles: `<link rel="stylesheet" href="/static/Home.9f00501f.css"><link rel="stylesheet" href="/static/Home.2353e048.css">`,
}
// AssetsUser contains assets for the 'User' entrypoint.
var AssetsUser = Assets{
Scripts: `<script type="module" src="/static/Home.38d394c2.js"></script><script src="/static/Home.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/User.4197014b.js"></script><script src="/static/User.30d54198.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/Home.4ec77c43.css"><link rel="stylesheet" href="/static/User.38d394c2.css">`,
Styles: `<link rel="stylesheet" href="/static/Home.9f00501f.css"><link rel="stylesheet" href="/static/User.38d394c2.css">`,
}
// AssetsAdmin contains assets for the 'Admin' entrypoint.
var AssetsAdmin = Assets{
Scripts: `<script nomodule="" defer src="/static/User.30d54198.js"></script><script type="module" src="/static/User.4197014b.js"></script><script type="module" src="/static/Home.38d394c2.js"></script><script src="/static/Home.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/Admin.4ca3cb6f.js"></script><script src="/static/Admin.9750ba9c.js" nomodule="" defer></script>`,
Styles: `<link rel="stylesheet" href="/static/Home.4ec77c43.css"><link rel="stylesheet" href="/static/Admin.6d59e220.css"><link rel="stylesheet" href="/static/User.38d394c2.css"><link rel="stylesheet" href="/static/Admin.6d2ae968.css">`,
Styles: `<link rel="stylesheet" href="/static/Home.9f00501f.css"><link rel="stylesheet" href="/static/Admin.6d59e220.css"><link rel="stylesheet" href="/static/User.38d394c2.css"><link rel="stylesheet" href="/static/Admin.6d2ae968.css">`,
}

View file

@ -21,7 +21,7 @@ footer {
}
.overflow table td,
.overflow table th{
.overflow table th {
padding: .5em .5em;
}
@ -41,14 +41,26 @@ footer {
display: block;
height: 1em;
}
.pure-form-group {
display: inline;
}
.pure-button-action {
background-color: rgb(66, 184, 221) !important;
}
.pure-button-success {
background-color: rgb(28, 184, 65) !important;
}
.pure-button-danger {
background: rgb(202, 60, 60) !important;
}
.pure-button-warning {
background: rgb(223, 117, 20) !important;
}
.pure-button-xsmall {
font-size: 70%;
}
@ -70,4 +82,4 @@ footer {
border: 1px solid red;
padding: 2px;
color: red;
}
}