diff --git a/assets/main.css b/assets/main.css index f562608..445fb33 100644 --- a/assets/main.css +++ b/assets/main.css @@ -364,3 +364,8 @@ section.special { border-radius: 3px; padding: 25px 10px; } + +.loading span.error { + top: 40px; + position: absolute; +} diff --git a/components/app.vue b/components/app.vue index fc61e15..f3bdeb7 100644 --- a/components/app.vue +++ b/components/app.vue @@ -1,10 +1,12 @@ @@ -25,13 +32,75 @@ import NewEstimate from "./new.vue" import Estimates from "./estimates.vue" import Settings from "./settings.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 const fees = [ @@ -60,11 +129,35 @@ function active() { return 4 } else if (/^#sign-out\/?/.exec(this.hash)) { return 5 + } else if (/^#login\/?/.exec(this.hash)) { + return 6 } else { 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 { components: { SideBar, @@ -73,17 +166,36 @@ export default { NewEstimate, Estimates, Settings, - SignOut + SignOut, + Login }, computed: { active }, + methods: { + getCookie, + start, + getUser, + getFees, + refreshToken, + }, data() { return { - loading: true, user: user, hash: window.location.hash, - fees: fees + loading: true, + user: null, + hash: window.location.hash, + fees: [], + loadingError: "", } }, created() { 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() } } diff --git a/components/login.vue b/components/login.vue new file mode 100644 index 0000000..4773bc3 --- /dev/null +++ b/components/login.vue @@ -0,0 +1,44 @@ + + + \ No newline at end of file diff --git a/migrations/0_29092022_setup_tables.sql b/migrations/0_29092022_setup_tables.sql index 9b4a269..fade979 100644 --- a/migrations/0_29092022_setup_tables.sql +++ b/migrations/0_29092022_setup_tables.sql @@ -27,7 +27,7 @@ CREATE TABLE user ( 'Subscribed', 'Branch', 'Admin'), - role ENUM('User', 'Manager', 'Admin'), + role ENUM('User', 'Manager', 'Admin') NOT NULL, PRIMARY KEY (`id`), FOREIGN KEY (branch_id) REFERENCES branch(id) ); diff --git a/migrations/seed.sql b/migrations/seed.sql index a776b8b..6a3eca9 100644 --- a/migrations/seed.sql +++ b/migrations/seed.sql @@ -13,6 +13,7 @@ INSERT IGNORE INTO user ( title, email, verified, + role, status ) VALUES @@ -25,6 +26,7 @@ INSERT IGNORE INTO user ( 'Loan Officer', 'test@example.com', true, + 'User', 'Free' ), @@ -37,6 +39,7 @@ INSERT IGNORE INTO user ( 'Mortgage Broker', 'unverified@example.com', false, + 'User', 'Free' ), @@ -49,6 +52,7 @@ INSERT IGNORE INTO user ( 'Branch Manager', 'manager@example.com', true, + 'Manager', 'Free' ); diff --git a/skouter.go b/skouter.go index 9542148..8f721b6 100644 --- a/skouter.go +++ b/skouter.go @@ -2,6 +2,7 @@ package main import ( "net/http" + "net/mail" "log" "sync" "regexp" @@ -15,10 +16,24 @@ import ( "time" "errors" "strings" - pdf "github.com/SebastiaanKlippert/go-wkhtmltopdf" + // pdf "github.com/SebastiaanKlippert/go-wkhtmltopdf" "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 { Id int `json:"id"` Role string `json:"role"` @@ -292,14 +307,12 @@ func getFees(db *sql.DB, loan int) ([]Fee, error) { 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 - rows, err := db.Query( "SELECT * FROM fee_template " + - "WHERE user_id = ? OR user_id = 0", - user) + "WHERE user_id = ? OR branch_id = ?", + user, branch) if err != nil { 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 } +// 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) { var mi MI @@ -526,11 +551,13 @@ func login(w http.ResponseWriter, db *sql.DB, r *http.Request) { var id int var role string var err error - r.ParseForm() + var user User + json.NewDecoder(r.Body).Decode(&user) row := db.QueryRow( `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) if err != nil { @@ -549,7 +576,7 @@ func login(w http.ResponseWriter, db *sql.DB, r *http.Request) { return } - cookie := http.Cookie{Name: "hound", + cookie := http.Cookie{Name: "skouter", Value: tokenStr, Path: "/", Expires: time.Now().Add(time.Hour * 24)} @@ -575,7 +602,7 @@ func getToken(w http.ResponseWriter, db *sql.DB, r *http.Request) { return } - cookie := http.Cookie{Name: "hound", + cookie := http.Cookie{Name: "skouter", Value: tokenStr, Path: "/", Expires: time.Now().Add(time.Hour * 24)} @@ -640,7 +667,6 @@ func queryUsers(db *sql.DB, id int) ( []User, error ) { u.title, u.status, u.verified, - u.last_login, u.role 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.Status, &user.Verified, - &user.LastLogin, &user.Role, ) err != nil { @@ -748,10 +773,78 @@ func getUser(w http.ResponseWriter, db *sql.DB, r *http.Request) { claims, err := getClaims(r) if err != nil { w.WriteHeader(500); return } 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) } +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) { var user User err := json.NewDecoder(r.Body).Decode(&user) @@ -816,6 +909,10 @@ func api(w http.ResponseWriter, r *http.Request) { r.Method == http.MethodDelete && guard(r, 3): deleteUser(w, db, r) + case match(p, "/api/fees", &args) && + r.Method == http.MethodGet && + guard(r, 1): + getFeesTemp(w, db, r) } db.Close()