diff --git a/server/api/api.go b/server/api/api.go index 066d12306..206a6fa2c 100644 --- a/server/api/api.go +++ b/server/api/api.go @@ -42,6 +42,7 @@ func (a *API) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/v1/users/me", a.sessionRequired(a.handleGetMe)).Methods("GET") r.HandleFunc("/api/v1/users/{userID}", a.sessionRequired(a.handleGetUser)).Methods("GET") + r.HandleFunc("/api/v1/users/{userID}/changepassword", a.sessionRequired(a.handleChangePassword)).Methods("POST") r.HandleFunc("/api/v1/login", a.handleLogin).Methods("POST") r.HandleFunc("/api/v1/register", a.handleRegister).Methods("POST") diff --git a/server/api/auth.go b/server/api/auth.go index 223c73fa1..d4d6a146e 100644 --- a/server/api/auth.go +++ b/server/api/auth.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/gorilla/mux" "github.com/mattermost/mattermost-octo-tasks/server/model" "github.com/mattermost/mattermost-octo-tasks/server/services/auth" ) @@ -39,9 +40,38 @@ func (rd *RegisterData) IsValid() error { if !strings.Contains(rd.Email, "@") { return errors.New("Invalid email format") } - if !strings.Contains(rd.Password, "") { + if rd.Password == "" { return errors.New("Password is required") } + if err := isValidPassword(rd.Password); err != nil { + return err + } + return nil +} + +type ChangePasswordData struct { + OldPassword string `json:"oldPassword"` + NewPassword string `json:"newPassword"` +} + +func (rd *ChangePasswordData) IsValid() error { + if rd.OldPassword == "" { + return errors.New("Old password is required") + } + if rd.NewPassword == "" { + return errors.New("New password is required") + } + if err := isValidPassword(rd.NewPassword); err != nil { + return err + } + + return nil +} + +func isValidPassword(password string) error { + if len(password) < 8 { + return errors.New("Password must be at least 8 characters") + } return nil } @@ -131,6 +161,35 @@ func (a *API) handleRegister(w http.ResponseWriter, r *http.Request) { jsonBytesResponse(w, http.StatusOK, nil) } +func (a *API) handleChangePassword(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + userID := vars["userID"] + + requestBody, err := ioutil.ReadAll(r.Body) + if err != nil { + errorResponse(w, http.StatusInternalServerError, nil, err) + return + } + + var requestData ChangePasswordData + if err := json.Unmarshal(requestBody, &requestData); err != nil { + errorResponse(w, http.StatusInternalServerError, nil, err) + return + } + + if err = requestData.IsValid(); err != nil { + errorResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}, err) + return + } + + if err = a.app().ChangePassword(userID, requestData.OldPassword, requestData.NewPassword); err != nil { + errorResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}, err) + return + } + + jsonBytesResponse(w, http.StatusOK, nil) +} + func (a *API) sessionRequired(handler func(w http.ResponseWriter, r *http.Request)) func(w http.ResponseWriter, r *http.Request) { return a.attachSession(handler, true) } diff --git a/server/app/auth.go b/server/app/auth.go index 7bf89b92f..8210cb4dc 100644 --- a/server/app/auth.go +++ b/server/app/auth.go @@ -68,7 +68,7 @@ func (a *App) Login(username string, email string, password string, mfaToken str } if !auth.ComparePassword(user.Password, password) { - log.Printf("Not valid passowrd. %s (%s)\n", password, user.Password) + log.Printf("Invalid password for userID: %s\n", user.ID) return "", errors.New("invalid username or password") } @@ -132,3 +132,30 @@ func (a *App) RegisterUser(username string, email string, password string) error return nil } + +func (a *App) ChangePassword(userID string, oldPassword string, newPassword string) error { + var user *model.User + if userID != "" { + var err error + user, err = a.store.GetUserById(userID) + if err != nil { + return errors.Wrap(err, "invalid username or password") + } + } + + if user == nil { + return errors.New("invalid username or password") + } + + if !auth.ComparePassword(user.Password, oldPassword) { + log.Printf("Invalid password for userID: %s\n", user.ID) + return errors.New("invalid username or password") + } + + err := a.store.UpdateUserPasswordByID(userID, auth.HashPassword(newPassword)) + if err != nil { + return errors.Wrap(err, "unable to update password") + } + + return nil +} diff --git a/server/services/store/sqlstore/user.go b/server/services/store/sqlstore/user.go index dcf141d47..7c7c78d81 100644 --- a/server/services/store/sqlstore/user.go +++ b/server/services/store/sqlstore/user.go @@ -93,3 +93,15 @@ func (s *SQLStore) UpdateUser(user *model.User) error { _, err = query.Exec() return err } + +func (s *SQLStore) UpdateUserPasswordByID(userID string, password string) error { + now := time.Now().Unix() + + query := s.getQueryBuilder().Update("users"). + Set("password", password). + Set("update_at", now). + Where(sq.Eq{"id": userID}) + + _, err := query.Exec() + return err +} diff --git a/server/services/store/store.go b/server/services/store/store.go index 4a70169f8..51405e673 100644 --- a/server/services/store/store.go +++ b/server/services/store/store.go @@ -27,6 +27,7 @@ type Store interface { GetUserByUsername(username string) (*model.User, error) CreateUser(user *model.User) error UpdateUser(user *model.User) error + UpdateUserPasswordByID(userID string, password string) error GetSession(token string, expireTime int64) (*model.Session, error) CreateSession(session *model.Session) error diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index e20fb1fef..04562309a 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -10,6 +10,7 @@ "BoardComponent.no-property": "No {property}", "BoardComponent.no-property-title": "Items with an empty {property} property will go here. This column cannot be removed.", "BoardComponent.show": "Show", + "BoardPage.syncFailed": "Board may be deleted or access revoked.", "CardDetail.add-content": "Add content", "CardDetail.add-icon": "Add icon", "CardDetail.add-property": "+ Add a property", @@ -63,6 +64,7 @@ "RegistrationLink.confirmRegenerateToken": "This will invalidate previously shared links. Continue?", "RegistrationLink.copiedLink": "Copied!", "RegistrationLink.copyLink": "Copy link", + "RegistrationLink.description": "Share this link for others to create accounts:", "RegistrationLink.regenerateToken": "Regenerate token", "RegistrationLink.tokenRegenerated": "Registration link regenerated", "ShareBoard.confirmRegenerateToken": "This will invalidate previously shared links. Continue?", @@ -74,6 +76,7 @@ "ShareBoard.unshare": "Anyone with the link can view this board", "Sidebar.add-board": "+ Add Board", "Sidebar.add-template": "+ New template", + "Sidebar.changePassword": "Change password", "Sidebar.dark-theme": "Dark theme", "Sidebar.default-theme": "Default theme", "Sidebar.delete-board": "Delete board", @@ -86,6 +89,7 @@ "Sidebar.import-archive": "Import archive", "Sidebar.invite-users": "Invite Users", "Sidebar.light-theme": "Light theme", + "Sidebar.logout": "Log out", "Sidebar.no-views-in-board": "No pages inside", "Sidebar.select-a-template": "Select a template", "Sidebar.set-language": "Set language", diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index e9d0a2a94..4c81d5966 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -2,24 +2,21 @@ // See LICENSE.txt for license information. import React from 'react' import {IntlProvider} from 'react-intl' - import { BrowserRouter as Router, - Switch, - Route, Redirect, + Route, + Switch, } from 'react-router-dom' -import client from './octoClient' -import {IUser, UserContext} from './user' - -import {getCurrentLanguage, getMessages, storeLanguage} from './i18n' - import {FlashMessages} from './components/flashMessages' - +import {getCurrentLanguage, getMessages, storeLanguage} from './i18n' +import client from './octoClient' +import BoardPage from './pages/boardPage' +import ChangePasswordPage from './pages/changePasswordPage' import LoginPage from './pages/loginPage' import RegisterPage from './pages/registerPage' -import BoardPage from './pages/boardPage' +import {IUser, UserContext} from './user' type State = { language: string, @@ -65,6 +62,9 @@ export default class App extends React.PureComponent { + + + { window.location.href = '/login' }} /> + { + window.location.href = '/change_password' + }} + /> diff --git a/webapp/src/octoClient.ts b/webapp/src/octoClient.ts index cd0e69ba3..e71cbbd22 100644 --- a/webapp/src/octoClient.ts +++ b/webapp/src/octoClient.ts @@ -70,6 +70,18 @@ class OctoClient { return {code: response.status, json} } + async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<{code: number, json: any}> { + const path = `/api/v1/users/${encodeURIComponent(userId)}/changepassword` + const body = JSON.stringify({oldPassword, newPassword}) + const response = await fetch(this.serverUrl + path, { + method: 'POST', + headers: this.headers(), + body, + }) + const json = (await this.getJson(response)) + return {code: response.status, json} + } + private headers() { return { Accept: 'application/json', diff --git a/webapp/src/pages/changePasswordPage.scss b/webapp/src/pages/changePasswordPage.scss new file mode 100644 index 000000000..c0b0b6cf1 --- /dev/null +++ b/webapp/src/pages/changePasswordPage.scss @@ -0,0 +1,64 @@ +.ChangePasswordPage { + border: 1px solid #cccccc; + border-radius: 15px; + width: 450px; + height: 400px; + margin: 150px auto; + padding: 40px; + display: flex; + align-items: center; + justify-content: flex-start; + flex-direction: column; + box-shadow: rgba(var(--main-fg), 0.1) 0px 0px 0px 1px, rgba(var(--main-fg), 0.3) 0px 4px 8px; + + @media screen and (max-width: 430px) { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + height: 100%; + margin: auto; + padding-top: 10px; + } + + .title { + font-size: 16px; + font-weight: 500; + } + + .oldPassword, .newPassword { + margin-bottom: 10px; + + label { + display: inline-block; + width: 140px; + } + + input { + display: inline-block; + width: 250px; + border: 1px solid #cccccc; + border-radius: 4px; + padding: 7px; + min-height: 44px; + } + } + + > .Button { + margin-top: 10px; + margin-bottom: 20px; + min-height: 38px; + min-width: 250px; + } + + .error { + color: #900000; + } + + .succeeded { + background-color: #ccffcc; + padding: 5px; + } +} diff --git a/webapp/src/pages/changePasswordPage.tsx b/webapp/src/pages/changePasswordPage.tsx new file mode 100644 index 000000000..b722d8472 --- /dev/null +++ b/webapp/src/pages/changePasswordPage.tsx @@ -0,0 +1,104 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react' + +import { + withRouter, + RouteComponentProps, + Link, +} from 'react-router-dom' + +import Button from '../widgets/buttons/button' +import client from '../octoClient' +import './changePasswordPage.scss' +import {UserContext} from '../user' + +type Props = RouteComponentProps + +type State = { + oldPassword: string + newPassword: string + errorMessage?: string + succeeded: boolean +} + +class ChangePasswordPage extends React.PureComponent { + state: State = { + oldPassword: '', + newPassword: '', + succeeded: false, + } + + private handleSubmit = async (userId: string): Promise => { + const response = await client.changePassword(userId, this.state.oldPassword, this.state.newPassword) + if (response.code === 200) { + this.setState({succeeded: true}) + } else { + this.setState({errorMessage: `Change password failed: ${response.json?.error}`}) + } + } + + private closeClicked = () => { + this.props.history.push('/') + } + + render(): React.ReactNode { + return ( +
+
{'Change Password'}
+ + + {(user) => { + if (user) { + return (<> +
+ this.setState({oldPassword: e.target.value, errorMessage: undefined})} + /> +
+
+ this.setState({newPassword: e.target.value, errorMessage: undefined})} + /> +
+ + {this.state.errorMessage && +
+ {this.state.errorMessage} +
+ } + {this.state.succeeded && + {'Password changed, click to continue.'} + } + {!this.state.succeeded && + {'Cancel'} + } + ) + } + return ( + {'Log in first'} + ) + }} +
+
+ ) + } +} + +export default withRouter(ChangePasswordPage) diff --git a/webapp/src/pages/registerPage.tsx b/webapp/src/pages/registerPage.tsx index 79c8ab115..b70d59ebe 100644 --- a/webapp/src/pages/registerPage.tsx +++ b/webapp/src/pages/registerPage.tsx @@ -44,7 +44,7 @@ class RegisterPage extends React.PureComponent { } else if (response.code === 401) { this.setState({errorMessage: 'Invalid registration link, please contact your administrator'}) } else { - this.setState({errorMessage: response.json.error}) + this.setState({errorMessage: response.json?.error}) } }