feat: redesign project sharing & initial registration

redesigned the project sharing popup to be a multi select dropdown
that populates the options by using the input as a fuzzy search filter
on the current users & invited users.

users can now also be directly invited by email from the project share
window. if invited this way, then the user will receive an email
that sends them to a registration page, then a confirmation page.

the initial registration was always redone so that it uses a similar
system to the above in that it now will accept the first registered
user if there are no other accounts (besides 'system').
This commit is contained in:
Jordan Knott
2020-10-20 18:52:09 -05:00
parent 6c7203a4aa
commit 7b6624ecc3
75 changed files with 5041 additions and 859 deletions

View File

@ -31,15 +31,33 @@ type NewUserAccount struct {
Email string
}
// RegisterUserRequestData is the request data for registering a new user (duh)
type RegisterUserRequestData struct {
User NewUserAccount
}
type RegisteredUserResponseData struct {
Setup bool `json:"setup"`
}
// ConfirmUserRequestData is the request data for upgrading an invited user to a normal user
type ConfirmUserRequestData struct {
ConfirmToken string
}
// InstallRequestData is the request data for installing new Taskcafe app
type InstallRequestData struct {
User NewUserAccount
}
type Setup struct {
ConfirmToken string `json:"confirmToken"`
}
// LoginResponseData is the response data for when a user logs in
type LoginResponseData struct {
AccessToken string `json:"accessToken"`
IsInstalled bool `json:"isInstalled"`
Setup bool `json:"setup"`
}
// LogoutResponseData is the response data for when a user logs out
@ -60,30 +78,24 @@ type AvatarUploadResponseData struct {
// RefreshTokenHandler handles when a user attempts to refresh token
func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
_, err := h.repo.GetSystemOptionByKey(r.Context(), "is_installed")
if err == sql.ErrNoRows {
user, err := h.repo.GetUserAccountByUsername(r.Context(), "system")
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.InstallOnly, user.RoleCode, h.jwtKey)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
w.Header().Set("Content-type", "application/json")
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: false})
userExists, err := h.repo.HasAnyUser(r.Context())
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.WithError(err).Error("issue while fetching if user accounts exist")
return
} else if err != nil {
log.WithError(err).Error("get system option")
w.WriteHeader(http.StatusBadRequest)
}
log.WithField("userExists", userExists).Info("checking if setup")
if !userExists {
w.Header().Set("Content-type", "application/json")
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: "", Setup: true})
return
}
c, err := r.Cookie("refreshToken")
if err != nil {
if err == http.ErrNoCookie {
log.Warn("no cookie")
w.WriteHeader(http.StatusBadRequest)
return
}
@ -112,6 +124,14 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
return
}
if !user.Active {
log.WithFields(log.Fields{
"username": user.Username,
}).Warn("attempt to refresh token with inactive user")
w.WriteHeader(http.StatusUnauthorized)
return
}
refreshCreatedAt := time.Now().UTC()
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{token.UserID, refreshCreatedAt, refreshExpiresAt})
@ -119,13 +139,17 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
err = h.repo.DeleteRefreshTokenByID(r.Context(), token.TokenID)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
log.Info("here 1")
accessTokenString, err := auth.NewAccessToken(token.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
log.Info("here 2")
w.Header().Set("Content-type", "application/json")
http.SetCookie(w, &http.Cookie{
Name: "refreshToken",
@ -133,7 +157,7 @@ func (h *TaskcafeHandler) RefreshTokenHandler(w http.ResponseWriter, r *http.Req
Expires: refreshExpiresAt,
HttpOnly: true,
})
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, IsInstalled: true})
json.NewEncoder(w).Encode(LoginResponseData{AccessToken: accessTokenString, Setup: false})
}
// LogoutHandler removes all refresh tokens to log out user
@ -175,6 +199,14 @@ func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
return
}
if !user.Active {
log.WithFields(log.Fields{
"username": requestData.Username,
}).Warn("attempt to login with inactive user")
w.WriteHeader(http.StatusUnauthorized)
return
}
err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(requestData.Password))
if err != nil {
log.WithFields(log.Fields{
@ -203,6 +235,7 @@ func (h *TaskcafeHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
}
// TODO: remove
// InstallHandler creates first user on fresh install
func (h *TaskcafeHandler) InstallHandler(w http.ResponseWriter, r *http.Request) {
if restricted, ok := r.Context().Value("restricted_mode").(auth.RestrictedMode); ok {
@ -266,6 +299,172 @@ func (h *TaskcafeHandler) InstallHandler(w http.ResponseWriter, r *http.Request)
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
}
func (h *TaskcafeHandler) ConfirmUser(w http.ResponseWriter, r *http.Request) {
usersExist, err := h.repo.HasActiveUser(r.Context())
if err != nil {
log.WithError(err).Error("issue checking if user accounts exist")
w.WriteHeader(http.StatusInternalServerError)
return
}
var user db.UserAccount
if !usersExist {
log.Info("setting first inactive user to active")
user, err = h.repo.SetFirstUserActive(r.Context())
if err != nil {
log.WithError(err).Error("issue checking if user accounts exist")
w.WriteHeader(http.StatusInternalServerError)
return
}
} else {
var requestData ConfirmUserRequestData
err = json.NewDecoder(r.Body).Decode(&requestData)
if err != nil {
log.WithError(err).Error("issue decoding request data")
w.WriteHeader(http.StatusBadRequest)
return
}
confirmTokenID, err := uuid.Parse(requestData.ConfirmToken)
if err != nil {
log.WithError(err).Error("issue parsing confirm token")
w.WriteHeader(http.StatusBadRequest)
return
}
confirmToken, err := h.repo.GetConfirmTokenByID(r.Context(), confirmTokenID)
if err != nil {
log.WithError(err).Error("issue getting token by id")
w.WriteHeader(http.StatusBadRequest)
return
}
user, err = h.repo.SetUserActiveByEmail(r.Context(), confirmToken.Email)
if err != nil {
log.WithError(err).Error("issue getting account by email")
w.WriteHeader(http.StatusBadRequest)
return
}
}
now := time.Now().UTC()
projects, err := h.repo.GetProjectsForInvitedMember(r.Context(), user.Email)
for _, project := range projects {
member, err := h.repo.CreateProjectMember(r.Context(),
db.CreateProjectMemberParams{
ProjectID: project,
UserID: user.UserID,
AddedAt: now,
RoleCode: "member",
},
)
if err != nil {
log.WithError(err).Error("issue creating project member")
w.WriteHeader(http.StatusInternalServerError)
return
}
log.WithField("memberID", member.ProjectMemberID).Info("creating project member")
err = h.repo.DeleteProjectMemberInvitedForEmail(r.Context(), user.Email)
if err != nil {
log.WithError(err).Error("issue deleting project member invited")
w.WriteHeader(http.StatusInternalServerError)
return
}
err = h.repo.DeleteUserAccountInvitedForEmail(r.Context(), user.Email)
if err != nil {
log.WithError(err).Error("issue deleting user account invited")
w.WriteHeader(http.StatusInternalServerError)
return
}
err = h.repo.DeleteConfirmTokenForEmail(r.Context(), user.Email)
if err != nil {
log.WithError(err).Error("issue deleting confirm token")
w.WriteHeader(http.StatusInternalServerError)
return
}
}
refreshCreatedAt := time.Now().UTC()
refreshExpiresAt := refreshCreatedAt.AddDate(0, 0, 1)
refreshTokenString, err := h.repo.CreateRefreshToken(r.Context(), db.CreateRefreshTokenParams{user.UserID, refreshCreatedAt, refreshExpiresAt})
accessTokenString, err := auth.NewAccessToken(user.UserID.String(), auth.Unrestricted, user.RoleCode, h.jwtKey)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
w.Header().Set("Content-type", "application/json")
http.SetCookie(w, &http.Cookie{
Name: "refreshToken",
Value: refreshTokenString.TokenID.String(),
Expires: refreshExpiresAt,
HttpOnly: true,
})
json.NewEncoder(w).Encode(LoginResponseData{accessTokenString, false})
}
func (h *TaskcafeHandler) RegisterUser(w http.ResponseWriter, r *http.Request) {
userExists, err := h.repo.HasAnyUser(r.Context())
if err != nil {
log.WithError(err).Error("issue checking if user accounts exist")
w.WriteHeader(http.StatusInternalServerError)
return
}
var requestData RegisterUserRequestData
err = json.NewDecoder(r.Body).Decode(&requestData)
if err != nil {
log.WithError(err).Error("issue decoding register user request data")
w.WriteHeader(http.StatusBadRequest)
return
}
if userExists {
_, err := h.repo.GetInvitedUserByEmail(r.Context(), requestData.User.Email)
if err != nil {
if err == sql.ErrNoRows {
hasActiveUser, err := h.repo.HasActiveUser(r.Context())
if err != nil {
log.WithError(err).Error("error checking for active user")
w.WriteHeader(http.StatusInternalServerError)
}
if !hasActiveUser {
json.NewEncoder(w).Encode(RegisteredUserResponseData{Setup: true})
return
}
} else {
log.WithError(err).Error("error while retrieving invited user by email")
w.WriteHeader(http.StatusForbidden)
return
}
}
}
// TODO: accept user if public registration is enabled
createdAt := time.Now().UTC()
hashedPwd, err := bcrypt.GenerateFromPassword([]byte(requestData.User.Password), 14)
if err != nil {
log.Error("issue generating passoed")
w.WriteHeader(http.StatusInternalServerError)
return
}
user, err := h.repo.CreateUserAccount(r.Context(), db.CreateUserAccountParams{
FullName: requestData.User.FullName,
Username: requestData.User.Username,
Initials: requestData.User.Initials,
Email: requestData.User.Email,
PasswordHash: string(hashedPwd),
CreatedAt: createdAt,
RoleCode: "admin",
Active: false,
})
if err != nil {
log.Error("issue registering user account")
w.WriteHeader(http.StatusInternalServerError)
return
}
log.WithField("username", user.UserID).Info("registered new user account")
json.NewEncoder(w).Encode(RegisteredUserResponseData{Setup: !userExists})
}
// Routes registers all authentication routes
func (rs authResource) Routes(taskcafeHandler TaskcafeHandler) chi.Router {
r := chi.NewRouter()

View File

@ -19,6 +19,7 @@ type AuthenticationMiddleware struct {
// Middleware returns the middleware handler
func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := uuid.New()
bearerTokenRaw := r.Header.Get("Authorization")
splitToken := strings.Split(bearerTokenRaw, "Bearer")
if len(splitToken) != 2 {
@ -61,6 +62,7 @@ func (m *AuthenticationMiddleware) Middleware(next http.Handler) http.Handler {
ctx := context.WithValue(r.Context(), utils.UserIDKey, userID)
ctx = context.WithValue(ctx, utils.RestrictedModeKey, accessClaims.Restricted)
ctx = context.WithValue(ctx, utils.OrgRoleKey, accessClaims.OrgRole)
ctx = context.WithValue(ctx, utils.ReqIDKey, requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})

View File

@ -87,13 +87,13 @@ func NewRouter(dbConnection *sqlx.DB, jwtKey []byte) (chi.Router, error) {
mux.Mount("/auth", authResource{}.Routes(taskcafeHandler))
mux.Handle("/__graphql", graph.NewPlaygroundHandler("/graphql"))
mux.Mount("/uploads/", http.StripPrefix("/uploads/", imgServer))
mux.Post("/auth/confirm", taskcafeHandler.ConfirmUser)
mux.Post("/auth/register", taskcafeHandler.RegisterUser)
})
auth := AuthenticationMiddleware{jwtKey}
r.Group(func(mux chi.Router) {
mux.Use(auth.Middleware)
mux.Post("/users/me/avatar", taskcafeHandler.ProfileImageUpload)
mux.Post("/auth/install", taskcafeHandler.InstallHandler)
mux.Handle("/graphql", graph.NewHandler(*repository))
})