Browse Source

Add user settings feature to web client and add the supporting API functionality

master
Devin Dooley 1 year ago
parent
commit
2615616c6b
8 changed files with 371 additions and 11 deletions
  1. +59
    -7
      api/api.go
  2. +71
    -0
      api/user.go
  3. +100
    -0
      clients/shared/store/slices/settingsSlice.js
  4. +2
    -0
      clients/shared/store/store.js
  5. +3
    -0
      clients/web/src/features/App.js
  6. +13
    -0
      clients/web/src/features/settings/Settings.js
  7. +122
    -0
      clients/web/src/features/settings/UserSettings.js
  8. +1
    -4
      config/config.go

+ 59
- 7
api/api.go View File

@ -4,7 +4,6 @@ import (
"github.com/appleboy/gin-jwt/v2"
"github.com/dooleydevin/me/config"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/contrib/static"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v4"
"github.com/plaid/plaid-go/plaid"
@ -79,12 +78,6 @@ func getRouter() (*gin.Engine, error) {
CORSConfig.AllowHeaders = []string{"Origin", "Content-Type", "Authorization"}
router.Use(cors.New(CORSConfig))
// Empty route and undeclared routes serve the web client
router.Use(static.Serve("/", static.LocalFile(conf.Me.WebDir, true)))
router.NoRoute(func(c *gin.Context) {
c.File(conf.Me.WebDir + "/index.html")
})
authRoutes := router.Group("/api/auth")
{
authRoutes.GET("/refresh_token", auth.RefreshHandler)
@ -93,6 +86,14 @@ func getRouter() (*gin.Engine, error) {
authRoutes.POST("/logout", auth.LogoutHandler)
}
userRoutes := router.Group("/api/user")
userRoutes.Use(auth.MiddlewareFunc())
{
userRoutes.GET("/settings", getUserSettingsHandler)
userRoutes.POST("/settings", updateUserSettingsHandler)
userRoutes.POST("/password", updateUserPasswordHandler)
}
bankingRoutes := router.Group("/api/banking")
bankingRoutes.Use(auth.MiddlewareFunc())
{
@ -150,6 +151,57 @@ func signUpHandler(c *gin.Context) {
c.Writer.WriteHeader(http.StatusNoContent)
}
// User handlers
func getUserSettingsHandler(c *gin.Context) {
userId := int64(jwt.ExtractClaims(c)["id"].(float64))
userSettings, err := getUserSettings(userId)
if err != nil {
c.JSON(http.StatusInternalServerError, err.Error())
return
}
c.JSON(http.StatusOK, userSettings)
}
func updateUserSettingsHandler(c *gin.Context) {
userId := int64(jwt.ExtractClaims(c)["id"].(float64))
var userSettings UserSettings
err := c.ShouldBindJSON(&userSettings)
if err != nil {
c.JSON(http.StatusInternalServerError, err.Error())
return
}
err = updateUserSettings(userId, userSettings)
if err != nil {
c.JSON(http.StatusInternalServerError, err.Error())
return
}
c.Writer.WriteHeader(http.StatusNoContent)
}
func updateUserPasswordHandler(c *gin.Context) {
userId := int64(jwt.ExtractClaims(c)["id"].(float64))
var userPassword UserPassword
err := c.ShouldBindJSON(&userPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, err.Error())
return
}
err = updateUserPassword(userId, userPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, err.Error())
return
}
c.Writer.WriteHeader(http.StatusNoContent)
}
// Banking handlers
// Creates a Plaid Item given a public_token


+ 71
- 0
api/user.go View File

@ -0,0 +1,71 @@
package api
import (
"context"
)
// HomeLatitude and HomeLongitude are actually numeric types in the database
// We pass them around as strings to avoid rounding issues
type UserSettings struct {
Email string `json:"email" validate:"email"`
HomeLatitude string `json:"homeLatitude"`
HomeLongitude string `json:"homeLongitude"`
}
type UserPassword struct {
Password string `json:"password"`
}
func getUserSettings(userId int64) (UserSettings, error) {
var userSettings UserSettings
err := conn.QueryRow(
context.Background(),
"SELECT email, home_latitude::text, home_longitude::text FROM users WHERE id=$1",
userId,
).Scan(&userSettings.Email, &userSettings.HomeLatitude, &userSettings.HomeLongitude)
if err != nil {
return userSettings, err
}
return userSettings, nil
}
func updateUserSettings(userId int64, userSettings UserSettings) error {
_, err := conn.Exec(
context.Background(),
"UPDATE users SET email=$2, home_latitude=$3, home_longitude=$4 WHERE id=$1",
userId,
userSettings.Email,
userSettings.HomeLatitude,
userSettings.HomeLongitude,
)
if err != nil {
return err
}
return nil
}
// userPassword should be a plaintext string as entered by the user
func updateUserPassword(userId int64, userPassword UserPassword) error {
hashedPassword, err := hashPassword(userPassword.Password)
if err != nil {
return err
}
_, err = conn.Exec(
context.Background(),
"UPDATE users SET password=$2 WHERE id=$1",
userId,
hashedPassword,
)
if err != nil {
return err
}
return nil
}

+ 100
- 0
clients/shared/store/slices/settingsSlice.js View File

@ -0,0 +1,100 @@
import { createSlice } from '@reduxjs/toolkit';
import api from 'shared/api';
export const selectUserSettings = state => state.settings.user;
export const selectPasswordSettings = state => state.settings.password;
export const settingsSlice= createSlice({
name: 'settings',
initialState: {
user: {
email: '',
homeLatitude: '',
homeLongitude: '',
error: '',
fetched: false,
updated: false,
},
password: {
error: '',
updated: false,
}
},
reducers: {
setUserSettings: (state, action) => {
const { email, lat, lon, error, fetched, updated } = action.payload;
state.user.email= email;
state.user.homeLatitude = lat;
state.user.homeLongitude = lon;
state.user.error = error;
state.user.fetched = fetched;
state.user.updated = updated;
},
setPassword: (state, action) => {
const { error, updated } = action.payload;
state.password.error = error;
state.password.updated = updated;
},
},
});
export const fetchUserSettings = () => dispatch => {
api.get('/user/settings')
.then((resp) => {
dispatch(settingsSlice.actions.setUserSettings({
email: resp.email,
lat: resp.homeLatitude,
lon: resp.homeLongitude,
error: '',
fetched: true,
updated: false,
}));
})
.catch((err) => {
dispatch(settingsSlice.actions.setUserSettings({
email: '',
lat: '',
lon: '',
error: err,
fetched: true,
updated: false,
}));
});
};
export const updateUserSettings = (email, lat, lon) => dispatch => {
api.post('/user/settings', {email: email, homeLatitude: lat, homeLongitude: lon})
.then((resp) => {
dispatch(settingsSlice.actions.setUserSettings({
email: email,
lat: lat,
lon: lon,
error: '',
fetched: true,
updated: true,
}));
})
.catch((err) => {
dispatch(settingsSlice.actions.setUserSettings({
email: email,
lat: lat,
lon: lon,
error: err,
fetched: true,
updated: false,
}));
});
};
export const updatePassword = (password) => dispatch => {
api.post('/user/password', {password: password})
.then((resp) => {
dispatch(settingsSlice.actions.setPassword({ error: '', updated: true }));
})
.catch((err) => {
dispatch(settingsSlice.actions.setPassword({ error: err, updated: false}));
});
};
export default settingsSlice.reducer;

+ 2
- 0
clients/shared/store/store.js View File

@ -5,6 +5,7 @@ import thunk from 'redux-thunk';
import authSlice from 'shared/store/slices/authSlice';
import bankingSlice from 'shared/store/slices/bankingSlice';
import settingsSlice from 'shared/store/slices/settingsSlice';
import weatherSlice from 'shared/store/slices/weatherSlice';
let store = null;
@ -19,6 +20,7 @@ function initStore(storage) {
const rootReducer = combineReducers({
auth: persistReducer(persistAuthConfig, authSlice),
banking: bankingSlice,
settings: settingsSlice,
weather: weatherSlice,
});


+ 3
- 0
clients/web/src/features/App.js View File

@ -4,6 +4,7 @@ import { Nav, Navbar, Button } from 'react-bootstrap';
import { useSelector, useDispatch } from 'react-redux';
import Home from './home/Home';
import Settings from './settings/Settings';
import Banking from './banking/Banking';
import Weather from './weather/Weather';
import Login from './auth/Login';
@ -16,6 +17,7 @@ function authenticatedSwitch() {
<Route exact path="/" component={Home} />
<Route path="/weather" component={Weather} />
<Route path="/banking" component={Banking} />
<Route path="/settings" component={Settings} />
</Switch>
);
}
@ -35,6 +37,7 @@ function authenticatedNav() {
<Nav className="mr-auto">
<Nav.Link as={NavLink} to="/weather">Weather</Nav.Link>
<Nav.Link as={NavLink} to="/banking">Banking</Nav.Link>
<Nav.Link as={NavLink} to="/settings">Settings</Nav.Link>
</Nav>
);
}


+ 13
- 0
clients/web/src/features/settings/Settings.js View File

@ -0,0 +1,13 @@
import React from 'react';
import UserSettings from './UserSettings';
function Settings(props) {
return (
<div className="col-6">
<UserSettings />
</div>
)
}
export default Settings;

+ 122
- 0
clients/web/src/features/settings/UserSettings.js View File

@ -0,0 +1,122 @@
import React, { useEffect } from 'react';
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Form, Button, Spinner } from 'react-bootstrap';
import {
selectUserSettings,
selectPasswordSettings,
fetchUserSettings,
updateUserSettings,
updatePassword,
} from 'shared/store/slices/settingsSlice';
function UserSettings(props) {
const dispatch = useDispatch();
useEffect(() => dispatch(fetchUserSettings()), [dispatch]);
const userSettings = useSelector(selectUserSettings);
const passwordSettings = useSelector(selectPasswordSettings);
const [email, setEmail] = useState(userSettings.email);
const [homeLat, setHomeLat] = useState(userSettings.homeLatitude);
const [homeLon, setHomeLon] = useState(userSettings.homeLongitude);
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [notMatching, setNotMatching] = useState(false);
function handleSubmit(event) {
if (password) {
if (password !== confirmPassword) {
setNotMatching(true);
} else {
setNotMatching(false);
dispatch(updatePassword(password));
}
}
dispatch(updateUserSettings(email, homeLat, homeLon));
event.preventDefault();
}
if (!userSettings.fetched) {
return (
<div className="col-md-3 mx-auto">
<Spinner animation="border" />
</div>
)
}
return (
<div className="col-md-6 mx-auto">
<Form onSubmit={handleSubmit}>
<Form.Group controlId="formEmail">
<Form.Label>Email</Form.Label>
<Form.Control
type="email"
value={email}
required
onChange={(e) => setEmail(e.target.value)}
placeholder="email"
/>
</Form.Group>
<Form.Group controlId="formHomeLat">
<Form.Label>Home Latitude</Form.Label>
<Form.Control
type="text"
value={homeLat}
onChange={(e) => setHomeLat(e.target.value)}
placeholder="latitude"
/>
</Form.Group>
<Form.Group controlId="formHomeLon">
<Form.Label>Home Longitude</Form.Label>
<Form.Control
type="text"
value={homeLon}
onChange={(e) => setHomeLon(e.target.value)}
placeholder="longitude"
/>
</Form.Group>
<Form.Group>
<Form.Label>Password</Form.Label>
<Form.Control
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="password"
/>
</Form.Group>
<Form.Group>
<Form.Label>Confirm Password</Form.Label>
<Form.Control
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="confirm password"
/>
</Form.Group>
{ userSettings.updated && <p>User settings updated!</p> }
{ passwordSettings.updated && <p>Password updated!</p> }
<div style={{ textAlign: "center" }}>
<Button variant="light" type="submit">Update User Settings</Button>
</div>
</Form>
<br />
{ userSettings.error && <p>{ userSettings.error }</p> }
{ passwordSettings.error && <p>{ passwordSettings.error }</p> }
{ notMatching && <p>Passwords must match</p> }
</div>
)
}
export default UserSettings;

+ 1
- 4
config/config.go View File

@ -17,13 +17,10 @@ type Config struct {
type Me struct {
Port string `yaml:"port"`
Environment string `yaml:"environment"`
WebDir string `yaml:"web_dir"`
}
type DarkSky struct {
SecretKey string `yaml:"secret_key"`
HomeLatitude string `yaml:"home_latitude"`
HomeLongitude string `yaml:"home_longitude"`
SecretKey string `yaml:"secret_key"`
}
type DB struct {


Loading…
Cancel
Save