Commit a616249b authored by Lee Brown's avatar Lee Brown

completed user management

parent 6680064c
......@@ -40,7 +40,6 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
// Construct the web.App which holds all routes as well as common Middleware.
app := web.NewApp(shutdown, log, env, middlewares...)
// Register project management pages.
p := Projects{
MasterDB: masterDB,
......@@ -48,22 +47,23 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
}
app.Handle("GET", "/projects", p.Index, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
// Register user management pages.
us := Users{
MasterDB: masterDB,
Redis: redis,
MasterDB: masterDB,
Redis: redis,
Renderer: renderer,
Authenticator: authenticator,
ProjectRoutes: projectRoutes,
NotifyEmail: notifyEmail,
SecretKey: secretKey,
}
app.Handle("GET", "/users", us.Index, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/users/:user_id/update", us.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/:user_id/update", us.Update, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("POST", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/:user_id", us.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("POST", "/users/create", us.Create, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users/create", us.Create, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/users", us.Index, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
// Register user management and authentication endpoints.
u := User{
......@@ -94,7 +94,6 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
app.Handle("POST", "/user", u.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
app.Handle("GET", "/user", u.View, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth())
// Register account management endpoints.
acc := Account{
MasterDB: masterDB,
......@@ -106,7 +105,6 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
app.Handle("POST", "/account", acc.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
app.Handle("GET", "/account", acc.View, mid.AuthenticateSessionRequired(authenticator), mid.HasRole(auth.RoleAdmin))
// Register user management and authentication endpoints.
s := Signup{
MasterDB: masterDB,
......@@ -125,7 +123,6 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
app.Handle("GET", "/examples/flash-messages", ex.FlashMessages)
app.Handle("GET", "/examples/images", ex.Images)
// Register geo
g := Geo{
MasterDB: masterDB,
......@@ -136,7 +133,6 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
app.Handle("GET", "/geo/geonames/postal_code/:postalCode", g.GeonameByPostalCode)
app.Handle("GET", "/geo/country/:countryCode/timezones", g.CountryTimezones)
// Register root
r := Root{
MasterDB: masterDB,
......@@ -152,7 +148,6 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
app.Handle("GET", "/index.html", r.IndexHtml)
app.Handle("GET", "/robots.txt", r.RobotTxt)
// Register health check endpoint. This route is not authenticated.
check := Check{
MasterDB: masterDB,
......@@ -161,7 +156,6 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
}
app.Handle("GET", "/v1/health", check.Health)
// Handle static files/pages. Render a custom 404 page when file not found.
static := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
err := web.StaticHandler(ctx, w, r, params, staticDir, "")
......
This diff is collapsed.
{{define "title"}}Create User{{end}}
{{define "style"}}
{{end}}
{{define "content"}}
<form class="user" method="post" novalidate>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="inputFirstName">First Name</label>
<input type="text" class="form-control {{ ValidationFieldClass $.validationErrors "UserCreateRequest.FirstName" }}" placeholder="enter first name" name="FirstName" value="{{ .form.FirstName }}" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "UserCreateRequest.FirstName" }}
</div>
<div class="form-group">
<label for="inputLastName">Last Name</label>
<input type="text" class="form-control {{ ValidationFieldClass $.validationErrors "UserCreateRequest.LastName" }}" placeholder="enter last name" name="LastName" value="{{ .form.LastName }}" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "UserCreateRequest.LastName" }}
</div>
<div class="form-group">
<label for="inputEmail">Email</label>
<input type="text" class="form-control {{ ValidationFieldClass $.validationErrors "UserCreateRequest.Email" }}" placeholder="enter email" name="Email" value="{{ .form.Email }}" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "UserCreateRequest.Email" }}
</div>
<div class="form-group">
<label for="inputTimezone">Timezone</label>
<select class="form-control {{ ValidationFieldClass $.validationErrors "UserCreateRequest.Timezone" }}" id="inputTimezone" name="Timezone">
<option value="">Not set</option>
{{ range $idx, $t := .timezones }}
<option value="{{ $t }}" {{ if CmpString $t $.form.Timezone }}selected="selected"{{ end }}>{{ $t }}</option>
{{ end }}
</select>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "UserCreateRequest.Timezone" }}
</div>
<div class="form-group">
<label for="inputPassword">Password</label>
<input type="password" class="form-control {{ ValidationFieldClass $.validationErrors "UserCreateRequest.Password" }}" id="inputPassword" placeholder="" name="Password" value="{{ .form.Password }}" required>
<span class="help-block "><small><a a href="javascript:void(0)" id="btnGeneratePassword"><i class="fal fa-random"></i>Generate random password </a></small></span>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "UserCreateRequest.Password" }}
</div>
<div class="form-group">
<label for="inputPasswordConfirm">Confirm Password</label>
<input type="password" class="form-control {{ ValidationFieldClass $.validationErrors "UserCreateRequest.PasswordConfirm" }}" id="inputPasswordConfirm" placeholder="" name="PasswordConfirm" value="{{ .form.PasswordConfirm }}" required>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "UserCreateRequest.PasswordConfirm" }}
</div>
<div class="form-group">
<label for="inputRoles">Roles</label>
<select class="form-control {{ ValidationFieldClass $.validationErrors "Roles" }}" id="inputRoles" name="Roles" multiple="multiple">
{{ range $r := .roles.Options }}
{{ $selectRole := false }}
{{ range $fr := $.form.Roles }}
{{ if eq $r.Value $fr }}{{ $selectRole = true }}{{ end }}
{{ end }}
<option value="{{ $r.Value }}" {{ if $selectRole }}selected="selected"{{ end }}>{{ $r.Title }}</option>
{{ end }}
</select>
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Roles" }}
</div>
</div>
</div>
<div class="spacer-30"></div>
<div class="row">
<div class="col">
<input id="btnSubmit" type="submit" name="action" value="Save" class="btn btn-primary"/>
</div>
</div>
</form>
{{end}}
{{define "js"}}
<script>
function randomPassword(length) {
var chars = "[email protected]#&*()-+<>ABCDEFGHIJKLMNOP1234567890";
var pass = "";
for (var x = 0; x < length; x++) {
var i = Math.floor(Math.random() * chars.length);
pass += chars.charAt(i);
}
return pass;
}
$(document).ready(function(){
$("#btnGeneratePassword").on("click", function() {
pwd = randomPassword(12);
$("#inputPassword").attr('type', 'text').val(pwd)
$("#inputPasswordConfirm").attr('type', 'text').val(pwd)
return false;
});
});
</script>
{{end}}
{{define "title"}}Users{{end}}
{{define "content"}}
<a href="{{ .urlUsersCreate }}">Create User</a>
<div class="row">
<div class="col">
<form method="post">
......
......@@ -20,7 +20,13 @@
<p class="font-10"><a href="https://gravatar.com" target="_blank">Update Avatar</a></p>
</div>
<div class="col-auto">
<a href="/user/update" class="btn btn-outline-success"><i class="fal fa-edit"></i>Edit Details</a>
<a href="{{ .urlUsersUpdate }}" class="btn btn-outline-success"><i class="fal fa-edit"></i>Edit Details</a>
{{ $ctxUser := ContextUser $._Ctx }}
{{ if $ctxUser }}
{{ if ne .user.ID $ctxUser.ID }}
<form method="post"><input type="hidden" name="action" value="archive" /><input type="submit" value="Archive"></form>
{{ end }}
{{ end }}
</div>
</div>
......
......@@ -28,7 +28,11 @@
serverSide: true,
ordering: true,
searching: true,
ajax: "{{ .datatable.AjaxUrl }}",
ajax: {
"url": "{{ .datatable.AjaxUrl }}",
"contentType": "application/json; charset=utf-8",
"dataType": "json"
},
scrollY: 300,
scroller: {
loadingIndicator: true
......
......@@ -30,9 +30,9 @@ var (
type (
Datatable struct {
ctx context.Context
w http.ResponseWriter
r *http.Request
redis *redis.Client
w http.ResponseWriter
r *http.Request
redis *redis.Client
fields []DisplayField
loadFunc func(ctx context.Context, sorting string, fields []DisplayField) (resp [][]ColumnValue, err error)
stateId string
......@@ -122,8 +122,8 @@ func (r Request) CacheKey() string {
func ParseQueryValues(vals url.Values) (Request, error) {
req := Request{
Columns:make(map[int]Column),
Order: make(map[int]Order) ,
Columns: make(map[int]Column),
Order: make(map[int]Order),
}
var err error
......@@ -182,7 +182,7 @@ func ParseQueryValues(vals url.Values) (Request, error) {
return req, errors.WithMessagef(ErrInvalidColumn, "Unable to map query Column Search %s for %s", svn, kn)
}
default:
return req, errors.WithMessagef(ErrInvalidColumn,"Unable to map query Column %s for %s", sn, kn)
return req, errors.WithMessagef(ErrInvalidColumn, "Unable to map query Column %s for %s", sn, kn)
}
req.Columns[idx] = curCol
case "order":
......@@ -235,7 +235,6 @@ func ParseQueryValues(vals url.Values) (Request, error) {
}
}
case "search":
sn := strings.Split(pts[1], "]")[0]
switch sn {
......@@ -276,11 +275,11 @@ func ParseQueryValues(vals url.Values) (Request, error) {
return req, nil
}
func New(ctx context.Context, w http.ResponseWriter, r *http.Request, redisClient *redis.Client, fields []DisplayField, loadFunc func(ctx context.Context, sorting string, fields []DisplayField) (resp [][]ColumnValue, err error)) (dt *Datatable, err error) {
func New(ctx context.Context, w http.ResponseWriter, r *http.Request, redisClient *redis.Client, fields []DisplayField, loadFunc func(ctx context.Context, sorting string, fields []DisplayField) (resp [][]ColumnValue, err error)) (dt *Datatable, err error) {
dt = &Datatable{
ctx: ctx,
w: w,
r: r,
r: r,
redis: redisClient,
fields: fields,
loadFunc: loadFunc,
......@@ -296,7 +295,7 @@ func New(ctx context.Context, w http.ResponseWriter, r *http.Request, redisClien
}
dt.SetAjaxUrl(r.URL)
if web.RequestIsJson(r) {
if web.RequestIsJson(r) {
dt.handleRequest = true
dt.req, err = ParseQueryValues(r.URL.Query())
......@@ -354,7 +353,7 @@ func New(ctx context.Context, w http.ResponseWriter, r *http.Request, redisClien
}
}
dt.cacheKey = fmt.Sprintf("%x", md5.Sum([]byte(dt.resp.AjaxUrl + dt.req.CacheKey() + dt.stateId)))
dt.cacheKey = fmt.Sprintf("%x", md5.Sum([]byte(dt.resp.AjaxUrl+dt.req.CacheKey()+dt.stateId)))
} else {
//for idx, f := range fields {
......@@ -513,7 +512,6 @@ func (dt *Datatable) Render() (rendered bool, err error) {
match = strings.Contains(l[i].Value, cn.Search.Value)
}
if !match {
//fmt.Println("-> no match")
skip = true
......
......@@ -18,13 +18,10 @@ type ctxKeyTagUniqueName int
const KeyTagUniqueName ctxKeyTagUniqueName = 1
type ctxKeyTagUniqueEmail int
const KeyTagUniqueEmail ctxKeyTagUniqueEmail = 1
// validate holds the settings and caches for validating request struct values.
var validate *validator.Validate
......@@ -70,7 +67,6 @@ func Validator() *validator.Validate {
return validate
}
// Signup performs the steps needed to create a new account, new user and then associate
// both records with a new user_account entry.
func Signup(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req SignupRequest, now time.Time) (*SignupResult, error) {
......
......@@ -647,6 +647,8 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserA
err = CanModifyUser(ctx, claims, dbConn, req.ID)
if err != nil {
return err
} else if claims.Subject != "" && claims.Subject == req.ID {
return errors.WithStack(ErrForbidden)
}
// If now empty set it to the current time.
......@@ -770,6 +772,8 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserDe
err = CanModifyUser(ctx, claims, dbConn, req.ID)
if err != nil {
return err
} else if claims.Subject != "" && claims.Subject == req.ID {
return errors.WithStack(ErrForbidden)
}
// Start a new transaction to handle rollbacks on error.
......
......@@ -257,18 +257,18 @@ func (s UserAccountRoles) Value() (driver.Value, error) {
// User represents someone with access to our system.
type User struct {
ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
Name string `json:"name" validate:"required" example:"Gabi May"`
FirstName string `json:"first_name" validate:"required" example:"Gabi"`
LastName string `json:"last_name" validate:"required" example:"May"`
Email string `json:"email" validate:"required,email,unique" example:"[email protected]"`
Timezone string `json:"timezone" validate:"omitempty" example:"America/Anchorage"`
ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
Name string `json:"name" validate:"required" example:"Gabi May"`
FirstName string `json:"first_name" validate:"required" example:"Gabi"`
LastName string `json:"last_name" validate:"required" example:"May"`
Email string `json:"email" validate:"required,email,unique" example:"[email protected]"`
Timezone string `json:"timezone" validate:"omitempty" example:"America/Anchorage"`
AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Roles UserAccountRoles `json:"roles" validate:"required,dive,oneof=admin user" enums:"admin,user" swaggertype:"array,string" example:"admin"`
Status UserAccountStatus `json:"status" validate:"omitempty,oneof=active invited disabled" enums:"active,invited,disabled" swaggertype:"string" example:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ArchivedAt *pq.NullTime `json:"archived_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ArchivedAt *pq.NullTime `json:"archived_at,omitempty"`
}
// UserResponse represents someone with access to our system that is returned for display.
......@@ -279,9 +279,9 @@ type UserResponse struct {
LastName string `json:"last_name" example:"May"`
Email string `json:"email" example:"[email protected]"`
Timezone string `json:"timezone" example:"America/Anchorage"`
AccountID string `json:"account_id" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Roles UserAccountRoles `json:"roles" validate:"required,dive,oneof=admin user" enums:"admin,user" swaggertype:"array,string" example:"admin"`
Status web.EnumResponse `json:"status"` // Status is enum with values [active, invited, disabled].
AccountID string `json:"account_id" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Roles UserAccountRoles `json:"roles" validate:"required,dive,oneof=admin user" enums:"admin,user" swaggertype:"array,string" example:"admin"`
Status web.EnumResponse `json:"status"` // Status is enum with values [active, invited, disabled].
CreatedAt web.TimeResponse `json:"created_at"` // CreatedAt contains multiple format options for display.
UpdatedAt web.TimeResponse `json:"updated_at"` // UpdatedAt contains multiple format options for display.
ArchivedAt *web.TimeResponse `json:"archived_at,omitempty"` // ArchivedAt contains multiple format options for display.
......@@ -336,8 +336,8 @@ func (m *Users) Response(ctx context.Context) []*UserResponse {
// UserFindByAccountRequest defines the possible options to search for users by account ID.
// By default archived users will be excluded from response.
type UserFindByAccountRequest struct {
AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Where string `json:"where" example:"name = ? and email = ?"`
AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"`
Where string `json:"where" example:"name = ? and email = ?"`
Args []interface{} `json:"args" swaggertype:"array,string" example:"Company Name,[email protected]"`
Order []string `json:"order" example:"created_at desc"`
Limit *uint `json:"limit" example:"10"`
......
......@@ -3,9 +3,11 @@ package user_account
import (
"context"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
"github.com/huandu/go-sqlbuilder"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)
......@@ -22,6 +24,124 @@ func UserFindByAccount(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB,
return nil, err
}
/*
SELECT
id,
first_name,
last_name,
name,
email,
timezone,
account_id,
status,
roles,
created_at,
updated_at,
archived_at FROM (
SELECT
u.id,
u.first_name,
u.last_name,
concat(u.first_name, ' ',u.last_name) as name,
u.email,
u.timezone,
ua.account_id,
ua.status,
ua.roles,
CASE WHEN ua.created_at > u.created_at THEN ua.created_at ELSE u.created_at END AS created_at,
CASE WHEN ua.updated_at > u.updated_at THEN ua.updated_at ELSE u.updated_at END AS updated_at,
CASE WHEN ua.archived_at > u.archived_at THEN ua.archived_at ELSE u.archived_at END AS archived_at
FROM users u
JOIN users_accounts ua
ON u.id = ua.user_id AND ua.account_id = 'df1a8a65-b00b-4640-9a64-66c1a355b17c'
WHERE
(u.archived_at IS NULL AND ua.archived_at IS NULL) AND
account_id IN (SELECT account_id FROM users_accounts WHERE (account_id = ? OR user_id = ?))
) res ORDER BY id asc
*/
subQuery := sqlbuilder.NewSelectBuilder().
Select("u.id,u.first_name,u.last_name,concat(u.first_name, ' ',u.last_name) as name,u.email,u.timezone,ua.account_id,ua.status,ua.roles,"+
"CASE WHEN ua.created_at > u.created_at THEN ua.created_at ELSE u.created_at END AS created_at,"+
"CASE WHEN ua.updated_at > u.updated_at THEN ua.updated_at ELSE u.updated_at END AS updated_at,"+
"CASE WHEN ua.archived_at > u.archived_at THEN ua.archived_at ELSE u.archived_at END AS archived_at").
From(userTableName+" u").
Join(userAccountTableName+" ua", "u.id = ua.user_id", "ua.account_id = '"+req.AccountID+"'")
if !req.IncludeArchived {
subQuery.Where(subQuery.And(
subQuery.IsNull("u.archived_at"),
subQuery.IsNull("ua.archived_at")))
}
if claims.Audience != "" || claims.Subject != "" {
// Build select statement for users_accounts table
authQuery := sqlbuilder.NewSelectBuilder().Select("account_id").From(userAccountTableName)
var or []string
if claims.Audience != "" {
or = append(or, authQuery.Equal("account_id", claims.Audience))
}
if claims.Subject != "" {
or = append(or, authQuery.Equal("user_id", claims.Subject))
}
// Append sub query
if len(or) > 0 {
authQuery.Where(authQuery.Or(or...))
subQuery.Where(subQuery.In("account_id", authQuery))
}
}
subQueryStr, queryArgs := subQuery.Build()
query := sqlbuilder.NewSelectBuilder().
Select("id,first_name,last_name,name,email,timezone,account_id,status,roles,created_at,updated_at,archived_at").
From("(" + subQueryStr + ") res")
if req.Where != "" {
query.Where(query.And(req.Where))
}
if len(req.Order) > 0 {
query.OrderBy(req.Order...)
}
if req.Limit != nil {
query.Limit(int(*req.Limit))
}
if req.Offset != nil {
query.Offset(int(*req.Offset))
}
queryStr, moreQueryArgs := query.Build()
queryStr = dbConn.Rebind(queryStr)
queryArgs = append(queryArgs, moreQueryArgs...)
// fetch all places from the db
rows, err := dbConn.QueryContext(ctx, queryStr, queryArgs...)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
err = errors.WithMessage(err, "find users failed")
return nil, err
}
// iterate over each row
resp := []*User{}
for rows.Next() {
var (
u User
err error
)
err = rows.Scan(&u.ID, &u.FirstName, &u.LastName, &u.Name, &u.Email, &u.Timezone, &u.AccountID, &u.Status,
&u.Roles, &u.CreatedAt, &u.UpdatedAt, &u.ArchivedAt)
if err != nil {
err = errors.Wrapf(err, "query - %s", query.String())
return nil, err
}
resp = append(resp, &u)
}
return nil , nil
return resp, nil
}
package user_account
import (
"testing"
)
// TestUserFindByAccount validates that find users by account works.
func TestUserFindByAccount(t *testing.T) {
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment