@@ -364,3 +364,8 @@ section.special { | |||||
border-radius: 3px; | border-radius: 3px; | ||||
padding: 25px 10px; | padding: 25px 10px; | ||||
} | } | ||||
.loading span.error { | |||||
top: 40px; | |||||
position: absolute; | |||||
} |
@@ -1,10 +1,12 @@ | |||||
<template> | <template> | ||||
<div class="panel"> | <div class="panel"> | ||||
<side-bar :role="user.status" :active="active"> | |||||
<template v-if="user"> | |||||
<side-bar v-if="user" :role="user && user.status" :active="active"> | |||||
</side-bar> | </side-bar> | ||||
<div v-if="loading" class="page loading"> | <div v-if="loading" class="page loading"> | ||||
<span class="error" >{{loadingError}}</span> | |||||
<spinner></spinner> | <spinner></spinner> | ||||
</div> | </div> | ||||
@@ -13,6 +15,11 @@ | |||||
<estimates :user="user" :fees="fees" v-else-if="active == 3" /> | <estimates :user="user" :fees="fees" v-else-if="active == 3" /> | ||||
<settings :user="user" v-else-if="active == 4" /> | <settings :user="user" v-else-if="active == 4" /> | ||||
<sign-out :user="user" v-else-if="active == 5" /> | <sign-out :user="user" v-else-if="active == 5" /> | ||||
</template> | |||||
<template v-if="!user && active == 6"> | |||||
<login @login="start" /> | |||||
</template> | |||||
</div> | </div> | ||||
</template> | </template> | ||||
@@ -25,13 +32,75 @@ import NewEstimate from "./new.vue" | |||||
import Estimates from "./estimates.vue" | import Estimates from "./estimates.vue" | ||||
import Settings from "./settings.vue" | import Settings from "./settings.vue" | ||||
import SignOut from "./sign-out.vue" | import SignOut from "./sign-out.vue" | ||||
import Login from "./login.vue" | |||||
function getCookie(name) { | |||||
var re = new RegExp(name + "=([^;]+)") | |||||
var value = re.exec(document.cookie) | |||||
return (value != null) ? unescape(value[1]) : null | |||||
} | |||||
function refreshToken() { | |||||
const token = getCookie("skouter") | |||||
const user = { | |||||
firstName: "test", | |||||
lastName: "user", | |||||
id: 12, | |||||
status: 1, | |||||
} | |||||
fetch(`/api/token`, | |||||
{method: 'GET', | |||||
headers: { | |||||
"Accept": "application/json", | |||||
"Authorization": `Bearer ${token}`, | |||||
}, | |||||
}).then(response => { | |||||
if (!response.ok) { | |||||
console.log("Error refreshing token.") | |||||
} | |||||
}) | |||||
// Recursive refresh | |||||
setTimeout(this.refreshToken, 1000*60*25) | |||||
} | |||||
function getUser() { | |||||
const token = getCookie("skouter") | |||||
return fetch(`/api/user`, | |||||
{method: 'GET', | |||||
headers: { | |||||
"Accept": "application/json", | |||||
"Authorization": `Bearer ${token}`, | |||||
}, | |||||
}).then(response => { | |||||
if (response.ok) { | |||||
return response.json() | |||||
} else { | |||||
// Redirect to login if starting token is invalid | |||||
window.location.hash = '#login' | |||||
} | |||||
}).then (result => { | |||||
if (!result || !result.length) return // Exit if token is invalid | |||||
this.user = result[0] | |||||
}) | |||||
} | |||||
function getFees() { | |||||
const token = getCookie("skouter") | |||||
return fetch(`/api/fees`, | |||||
{method: 'GET', | |||||
headers: { | |||||
"Accept": "application/json", | |||||
"Authorization": `Bearer ${token}`, | |||||
}, | |||||
}).then(response => { | |||||
if (response.ok) { return response.json() } | |||||
}).then (result => { | |||||
if (!result || !result.length) return // Exit if token is invalid or no fees are saved | |||||
this.fees = result | |||||
console.log("the result %O", result) | |||||
}) | |||||
} | |||||
// The default fees of a new loan. Percentage values take precedent over amounts | // The default fees of a new loan. Percentage values take precedent over amounts | ||||
const fees = [ | const fees = [ | ||||
@@ -60,11 +129,35 @@ function active() { | |||||
return 4 | return 4 | ||||
} else if (/^#sign-out\/?/.exec(this.hash)) { | } else if (/^#sign-out\/?/.exec(this.hash)) { | ||||
return 5 | return 5 | ||||
} else if (/^#login\/?/.exec(this.hash)) { | |||||
return 6 | |||||
} else { | } else { | ||||
return 0 | return 0 | ||||
} | } | ||||
} | } | ||||
// Fetch data before showing UI. If requests fail, assume token is expired. | |||||
function start() { | |||||
this.loading = true | |||||
let loaders = [] | |||||
loaders.push(this.getUser()) | |||||
loaders.push(this.getFees()) | |||||
Promise.all(loaders).then((a, b) => { | |||||
this.loading = false | |||||
if (!b) { | |||||
// Time untill token expiration may have elapsed before the page | |||||
// reloaded | |||||
this.refreshToken() | |||||
} | |||||
}).catch(error => { | |||||
console.log("An error occured %O", error) | |||||
this.loadingError = "Could not initialize app." | |||||
}) | |||||
} | |||||
export default { | export default { | ||||
components: { | components: { | ||||
SideBar, | SideBar, | ||||
@@ -73,17 +166,36 @@ export default { | |||||
NewEstimate, | NewEstimate, | ||||
Estimates, | Estimates, | ||||
Settings, | Settings, | ||||
SignOut | |||||
SignOut, | |||||
Login | |||||
}, | }, | ||||
computed: { active }, | computed: { active }, | ||||
methods: { | |||||
getCookie, | |||||
start, | |||||
getUser, | |||||
getFees, | |||||
refreshToken, | |||||
}, | |||||
data() { | data() { | ||||
return { | return { | ||||
loading: true, user: user, hash: window.location.hash, | |||||
fees: fees | |||||
loading: true, | |||||
user: null, | |||||
hash: window.location.hash, | |||||
fees: [], | |||||
loadingError: "", | |||||
} | } | ||||
}, | }, | ||||
created() { | created() { | ||||
window.onhashchange = () => this.hash = window.location.hash | window.onhashchange = () => this.hash = window.location.hash | ||||
this.token = this.getCookie("skouter") | |||||
if (!this.token) { | |||||
window.location.hash = 'login' | |||||
this.loading = false | |||||
return | |||||
} | |||||
this.start() | |||||
} | } | ||||
} | } | ||||
</script> | </script> |
@@ -0,0 +1,44 @@ | |||||
<template> | |||||
<div class="page"> | |||||
<section class="form inputs"> | |||||
<h3>Login</h3> | |||||
<label>Email</label> | |||||
<input v-model="email" required> | |||||
<label>Password</label> | |||||
<input v-model="password" type="password" required> | |||||
<button @click="login">Login</button> | |||||
<div class="error"> {{error}} </div> | |||||
</section> | |||||
</div> | |||||
</template> | |||||
<script> | |||||
function login() { | |||||
this.error = "" | |||||
return fetch(`/api/login`, | |||||
{method: 'POST', | |||||
body: JSON.stringify( {email: this.email, password: this.password} ), | |||||
}).then(response => { | |||||
if (response.ok) { | |||||
return response.text() | |||||
} else { | |||||
this.error = "Invalid credentials" | |||||
} | |||||
}).then(result => { | |||||
if (!result || !result.length) return // Exit if there is no token | |||||
this.$emit('login') | |||||
window.location.hash = '' | |||||
}) | |||||
} | |||||
export default { | |||||
emits: [ 'login' ], | |||||
methods: { login }, | |||||
data() { | |||||
return { email: "", password: "", error: ""} | |||||
}, | |||||
} | |||||
</script> |
@@ -27,7 +27,7 @@ CREATE TABLE user ( | |||||
'Subscribed', | 'Subscribed', | ||||
'Branch', | 'Branch', | ||||
'Admin'), | 'Admin'), | ||||
role ENUM('User', 'Manager', 'Admin'), | |||||
role ENUM('User', 'Manager', 'Admin') NOT NULL, | |||||
PRIMARY KEY (`id`), | PRIMARY KEY (`id`), | ||||
FOREIGN KEY (branch_id) REFERENCES branch(id) | FOREIGN KEY (branch_id) REFERENCES branch(id) | ||||
); | ); | ||||
@@ -13,6 +13,7 @@ INSERT IGNORE INTO user ( | |||||
title, | title, | ||||
email, | email, | ||||
verified, | verified, | ||||
role, | |||||
status | status | ||||
) VALUES | ) VALUES | ||||
@@ -25,6 +26,7 @@ INSERT IGNORE INTO user ( | |||||
'Loan Officer', | 'Loan Officer', | ||||
'test@example.com', | 'test@example.com', | ||||
true, | true, | ||||
'User', | |||||
'Free' | 'Free' | ||||
), | ), | ||||
@@ -37,6 +39,7 @@ INSERT IGNORE INTO user ( | |||||
'Mortgage Broker', | 'Mortgage Broker', | ||||
'unverified@example.com', | 'unverified@example.com', | ||||
false, | false, | ||||
'User', | |||||
'Free' | 'Free' | ||||
), | ), | ||||
@@ -49,6 +52,7 @@ INSERT IGNORE INTO user ( | |||||
'Branch Manager', | 'Branch Manager', | ||||
'manager@example.com', | 'manager@example.com', | ||||
true, | true, | ||||
'Manager', | |||||
'Free' | 'Free' | ||||
); | ); | ||||
@@ -2,6 +2,7 @@ package main | |||||
import ( | import ( | ||||
"net/http" | "net/http" | ||||
"net/mail" | |||||
"log" | "log" | ||||
"sync" | "sync" | ||||
"regexp" | "regexp" | ||||
@@ -15,10 +16,24 @@ import ( | |||||
"time" | "time" | ||||
"errors" | "errors" | ||||
"strings" | "strings" | ||||
pdf "github.com/SebastiaanKlippert/go-wkhtmltopdf" | |||||
// pdf "github.com/SebastiaanKlippert/go-wkhtmltopdf" | |||||
"github.com/golang-jwt/jwt/v4" | "github.com/golang-jwt/jwt/v4" | ||||
) | ) | ||||
type User struct { | |||||
Id int `json:"id"` | |||||
Email string `json:"email"` | |||||
FirstName string `json:"firstName"` | |||||
LastName string `json:"lastName"` | |||||
BranchId int `json:"branchId"` | |||||
Status string `json:"status"` | |||||
Country string `json:"country"` | |||||
Title string `json:"title"` | |||||
Verified bool `json:"verified"` | |||||
Role string `json:"role"` | |||||
Password string `json:"password,omitempty"` | |||||
} | |||||
type UserClaims struct { | type UserClaims struct { | ||||
Id int `json:"id"` | Id int `json:"id"` | ||||
Role string `json:"role"` | Role string `json:"role"` | ||||
@@ -292,14 +307,12 @@ func getFees(db *sql.DB, loan int) ([]Fee, error) { | |||||
return fees, nil | return fees, nil | ||||
} | } | ||||
// Fetch fees from the database | |||||
func getFeesTemp(db *sql.DB, user int) ([]FeeTemplate, error) { | |||||
func fetchFeesTemp(db *sql.DB, user int, branch int) ([]FeeTemplate, error) { | |||||
var fees []FeeTemplate | var fees []FeeTemplate | ||||
rows, err := db.Query( | rows, err := db.Query( | ||||
"SELECT * FROM fee_template " + | "SELECT * FROM fee_template " + | ||||
"WHERE user_id = ? OR user_id = 0", | |||||
user) | |||||
"WHERE user_id = ? OR branch_id = ?", | |||||
user, branch) | |||||
if err != nil { | if err != nil { | ||||
return nil, fmt.Errorf("Fee template query error %v", err) | return nil, fmt.Errorf("Fee template query error %v", err) | ||||
@@ -331,6 +344,18 @@ func getFeesTemp(db *sql.DB, user int) ([]FeeTemplate, error) { | |||||
return fees, nil | return fees, nil | ||||
} | } | ||||
// Fetch fees from the database | |||||
func getFeesTemp(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||||
var fees []FeeTemplate | |||||
claims, err := getClaims(r) | |||||
if err != nil { w.WriteHeader(500); return } | |||||
users, err := queryUsers(db, claims.Id) | |||||
if err != nil { w.WriteHeader(422); return } | |||||
fees, err = fetchFeesTemp(db, claims.Id, users[0].BranchId) | |||||
json.NewEncoder(w).Encode(fees) | |||||
} | |||||
func getMi(db *sql.DB, loan int) (MI, error) { | func getMi(db *sql.DB, loan int) (MI, error) { | ||||
var mi MI | var mi MI | ||||
@@ -526,11 +551,13 @@ func login(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||||
var id int | var id int | ||||
var role string | var role string | ||||
var err error | var err error | ||||
r.ParseForm() | |||||
var user User | |||||
json.NewDecoder(r.Body).Decode(&user) | |||||
row := db.QueryRow( | row := db.QueryRow( | ||||
`SELECT id, role FROM user WHERE email = ? AND password = sha2(?, 256)`, | `SELECT id, role FROM user WHERE email = ? AND password = sha2(?, 256)`, | ||||
r.PostFormValue("email"), r.PostFormValue("password")) | |||||
user.Email, user.Password, | |||||
) | |||||
err = row.Scan(&id, &role) | err = row.Scan(&id, &role) | ||||
if err != nil { | if err != nil { | ||||
@@ -549,7 +576,7 @@ func login(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||||
return | return | ||||
} | } | ||||
cookie := http.Cookie{Name: "hound", | |||||
cookie := http.Cookie{Name: "skouter", | |||||
Value: tokenStr, | Value: tokenStr, | ||||
Path: "/", | Path: "/", | ||||
Expires: time.Now().Add(time.Hour * 24)} | Expires: time.Now().Add(time.Hour * 24)} | ||||
@@ -575,7 +602,7 @@ func getToken(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||||
return | return | ||||
} | } | ||||
cookie := http.Cookie{Name: "hound", | |||||
cookie := http.Cookie{Name: "skouter", | |||||
Value: tokenStr, | Value: tokenStr, | ||||
Path: "/", | Path: "/", | ||||
Expires: time.Now().Add(time.Hour * 24)} | Expires: time.Now().Add(time.Hour * 24)} | ||||
@@ -640,7 +667,6 @@ func queryUsers(db *sql.DB, id int) ( []User, error ) { | |||||
u.title, | u.title, | ||||
u.status, | u.status, | ||||
u.verified, | u.verified, | ||||
u.last_login, | |||||
u.role | u.role | ||||
FROM user u WHERE u.id = CASE @e := ? WHEN 0 THEN u.id ELSE @e END | FROM user u WHERE u.id = CASE @e := ? WHEN 0 THEN u.id ELSE @e END | ||||
` | ` | ||||
@@ -666,7 +692,6 @@ func queryUsers(db *sql.DB, id int) ( []User, error ) { | |||||
&user.Title, | &user.Title, | ||||
&user.Status, | &user.Status, | ||||
&user.Verified, | &user.Verified, | ||||
&user.LastLogin, | |||||
&user.Role, | &user.Role, | ||||
) | ) | ||||
err != nil { | err != nil { | ||||
@@ -748,10 +773,78 @@ func getUser(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||||
claims, err := getClaims(r) | claims, err := getClaims(r) | ||||
if err != nil { w.WriteHeader(500); return } | if err != nil { w.WriteHeader(500); return } | ||||
users, err := queryUsers(db, claims.Id) | users, err := queryUsers(db, claims.Id) | ||||
if err != nil { w.WriteHeader(422); return } | |||||
if err != nil { w.WriteHeader(422); log.Println(err); return } | |||||
json.NewEncoder(w).Encode(users) | json.NewEncoder(w).Encode(users) | ||||
} | } | ||||
func getUsers(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||||
users, err := queryUsers(db, 0) | |||||
if err != nil { | |||||
w.WriteHeader(http.StatusInternalServerError) | |||||
return | |||||
} | |||||
json.NewEncoder(w).Encode(users) | |||||
} | |||||
// Updates a user using only specified values in the JSON body | |||||
func patchUser(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||||
var user User | |||||
err := json.NewDecoder(r.Body).Decode(&user) | |||||
_, err = mail.ParseAddress(user.Email) | |||||
if err != nil { http.Error(w, "Invalid email.", 422); return } | |||||
if roles[user.Role] == 0 { | |||||
http.Error(w, "Invalid role.", 422) | |||||
return | |||||
} | |||||
err = updateUser(user, db) | |||||
if err != nil { http.Error(w, "Bad form values.", 422); return } | |||||
users, err := queryUsers(db, user.Id) | |||||
if err != nil { http.Error(w, "Bad form values.", 422); return } | |||||
json.NewEncoder(w).Encode(users[0]) | |||||
} | |||||
// Update specified fields of the user specified in the claim | |||||
func patchSelf(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||||
claim, err := getClaims(r) | |||||
var user User | |||||
json.NewDecoder(r.Body).Decode(&user) | |||||
// First check that the target user to be updated is the same as the claim id, and | |||||
// their role is unchanged. | |||||
if err != nil || claim.Id != user.Id { | |||||
http.Error(w, "Target user's id does not match claim.", 401) | |||||
return | |||||
} | |||||
if claim.Role != user.Role && user.Role != "" { | |||||
http.Error(w, "Administrator required to escalate role.", 401) | |||||
return | |||||
} | |||||
patchUser(w, db, r) | |||||
} | |||||
func deleteUser(w http.ResponseWriter, db *sql.DB, r *http.Request) { | |||||
var user User | |||||
err := json.NewDecoder(r.Body).Decode(&user) | |||||
if err != nil { | |||||
http.Error(w, "Invalid fields.", 422) | |||||
return | |||||
} | |||||
query := `DELETE FROM user WHERE id = ?` | |||||
_, err = db.Exec(query, user.Id) | |||||
if err != nil { | |||||
http.Error(w, "Could not delete.", 500) | |||||
} | |||||
} | |||||
func createUser(w http.ResponseWriter, db *sql.DB, r *http.Request) { | func createUser(w http.ResponseWriter, db *sql.DB, r *http.Request) { | ||||
var user User | var user User | ||||
err := json.NewDecoder(r.Body).Decode(&user) | err := json.NewDecoder(r.Body).Decode(&user) | ||||
@@ -816,6 +909,10 @@ func api(w http.ResponseWriter, r *http.Request) { | |||||
r.Method == http.MethodDelete && | r.Method == http.MethodDelete && | ||||
guard(r, 3): | guard(r, 3): | ||||
deleteUser(w, db, r) | deleteUser(w, db, r) | ||||
case match(p, "/api/fees", &args) && | |||||
r.Method == http.MethodGet && | |||||
guard(r, 1): | |||||
getFeesTemp(w, db, r) | |||||
} | } | ||||
db.Close() | db.Close() | ||||