Overhaul UI architecture

Learned about the :target CSS selector, which can be used to
replace content depending on the current URL hash. This allows
making a sort of single page app without JavaScript.

Currently experimenting with returning all the pages in a single
request, then switching between them with :target. Seems to be
working quite well so far.
This commit is contained in:
Anders Pitman 2020-10-15 09:50:12 -06:00
parent 770440ef79
commit 4c78059e66
8 changed files with 260 additions and 241 deletions

View File

@ -9,3 +9,6 @@
* Properly pick unused ports for tunnels * Properly pick unused ports for tunnels
* Apparently multiple tunnels can bind to a single server port. Looks like * Apparently multiple tunnels can bind to a single server port. Looks like
maybe only the first one is used to actually tunnel to the clients? maybe only the first one is used to actually tunnel to the clients?
* Maybe add a DNS/Domains page and require users to add domains their before
they can use them for tunnels. This creates a natural place to explain what
is wrong when domain stuff breaks.

View File

@ -22,6 +22,14 @@ type WebUiHandler struct {
menuHtml template.HTML menuHtml template.HTML
} }
type IndexData struct {
Head template.HTML
Menu template.HTML
Tunnels map[string]Tunnel
Tokens map[string]TokenData
Users map[string]User
}
type TunnelsData struct { type TunnelsData struct {
Head template.HTML Head template.HTML
Menu template.HTML Menu template.HTML
@ -138,7 +146,7 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
menuTmpl, err := template.New("menu").Parse(menuTmplStr) menuTmpl, err := template.New("menu").Parse(menuTmplStr)
if err != nil { if err != nil {
w.WriteHeader(500) w.WriteHeader(500)
h.alertDialog(w, r, "Failed to parse menu.tmpl", "/") h.alertDialog(w, r, "Failed to parse menu.tmpl", "/#/tunnels")
return return
} }
@ -152,10 +160,10 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
h.handleLogin(w, r) h.handleLogin(w, r)
case "/users": case "/users":
if user.IsAdmin { if user.IsAdmin {
h.usersPage(w, r) h.handleUsers(w, r)
} else { } else {
w.WriteHeader(403) w.WriteHeader(403)
h.alertDialog(w, r, "Not authorized", "/") h.alertDialog(w, r, "Not authorized", "/#/tunnels")
return return
} }
@ -166,11 +174,53 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
h.deleteUser(w, r) h.deleteUser(w, r)
} else { } else {
w.WriteHeader(403) w.WriteHeader(403)
h.alertDialog(w, r, "Not authorized", "/") h.alertDialog(w, r, "Not authorized", "/#/tunnels")
return return
} }
case "/": case "/":
http.Redirect(w, r, "/tunnels", 302) indexTmplStr, err := h.box.String("index.tmpl")
if err != nil {
w.WriteHeader(500)
h.alertDialog(w, r, "Error reading index.tmpl", "/#/tunnels")
return
}
tmpl, err := template.New("index").Parse(indexTmplStr)
if err != nil {
w.WriteHeader(500)
h.alertDialog(w, r, "Error compiling index.tmpl", "/#/tunnels")
return
}
var tokens map[string]TokenData
var users map[string]User
if user.IsAdmin {
tokens = h.db.GetTokens()
users = h.db.GetUsers()
} else {
tokens = make(map[string]TokenData)
for token, td := range h.db.GetTokens() {
if tokenData.Owner == td.Owner {
tokens[token] = td
}
}
users = make(map[string]User)
users[tokenData.Owner] = user
}
indexData := IndexData{
Head: h.headHtml,
Menu: h.menuHtml,
Tunnels: h.api.GetTunnels(tokenData),
Tokens: tokens,
Users: users,
}
tmpl.Execute(w, indexData)
case "/tunnels": case "/tunnels":
h.handleTunnels(w, r, tokenData) h.handleTunnels(w, r, tokenData)
case "/confirm-delete-tunnel": case "/confirm-delete-tunnel":
@ -195,7 +245,7 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
Head: h.headHtml, Head: h.headHtml,
Message: fmt.Sprintf("Are you sure you want to delete %s?", domain), Message: fmt.Sprintf("Are you sure you want to delete %s?", domain),
ConfirmUrl: fmt.Sprintf("/delete-tunnel?domain=%s", domain), ConfirmUrl: fmt.Sprintf("/delete-tunnel?domain=%s", domain),
CancelUrl: "/", CancelUrl: "/#/tunnels",
} }
tmpl.Execute(w, data) tmpl.Execute(w, data)
@ -207,14 +257,14 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
err := h.api.DeleteTunnel(tokenData, r.Form) err := h.api.DeleteTunnel(tokenData, r.Form)
if err != nil { if err != nil {
w.WriteHeader(400) w.WriteHeader(400)
h.alertDialog(w, r, err.Error(), "/tunnels") h.alertDialog(w, r, err.Error(), "/#/tunnels")
return return
} }
http.Redirect(w, r, "/", 307) http.Redirect(w, r, "/#/tunnels", 307)
case "/tokens": case "/tokens":
h.tokensPage(w, r, user, tokenData) h.handleTokens(w, r, user, tokenData)
case "/confirm-delete-token": case "/confirm-delete-token":
h.confirmDeleteToken(w, r) h.confirmDeleteToken(w, r)
case "/delete-token": case "/delete-token":
@ -222,74 +272,45 @@ func (h *WebUiHandler) handleWebUiRequest(w http.ResponseWriter, r *http.Request
default: default:
w.WriteHeader(404) w.WriteHeader(404)
h.alertDialog(w, r, "Unknown page "+r.URL.Path, "/") h.alertDialog(w, r, "Unknown page "+r.URL.Path, "/#/tunnels")
return return
} }
} }
func (h *WebUiHandler) tokensPage(w http.ResponseWriter, r *http.Request, user User, tokenData TokenData) { func (h *WebUiHandler) handleTokens(w http.ResponseWriter, r *http.Request, user User, tokenData TokenData) {
if r.Method == "POST" { if r.Method != "POST" {
r.ParseForm() w.WriteHeader(405)
h.alertDialog(w, r, "Invalid method for tokens", "/#/tokens")
if len(r.Form["owner"]) != 1 {
w.WriteHeader(400)
h.alertDialog(w, r, "Invalid owner parameter", "/tokens")
return
}
owner := r.Form["owner"][0]
users := h.db.GetUsers()
_, exists := users[owner]
if !exists {
w.WriteHeader(400)
h.alertDialog(w, r, "Owner doesn't exist", "/tokens")
return
}
_, err := h.db.AddToken(owner)
if err != nil {
w.WriteHeader(500)
h.alertDialog(w, r, "Failed creating token", "/tokens")
return
}
http.Redirect(w, r, "/tokens", 303)
}
tmpl, err := h.loadTemplate("tokens.tmpl")
if err != nil {
w.WriteHeader(500)
io.WriteString(w, err.Error())
return return
} }
var tokens map[string]TokenData r.ParseForm()
var users map[string]User
if user.IsAdmin { if len(r.Form["owner"]) != 1 {
tokens = h.db.GetTokens() w.WriteHeader(400)
users = h.db.GetUsers() h.alertDialog(w, r, "Invalid owner parameter", "/#/tokens")
} else { return
tokens = make(map[string]TokenData) }
owner := r.Form["owner"][0]
for token, td := range h.db.GetTokens() { users := h.db.GetUsers()
if tokenData.Owner == td.Owner {
tokens[token] = td
}
}
users = make(map[string]User) _, exists := users[owner]
users[tokenData.Owner] = user if !exists {
w.WriteHeader(400)
h.alertDialog(w, r, "Owner doesn't exist", "/#/tokens")
return
} }
tmpl.Execute(w, TokensData{ _, err := h.db.AddToken(owner)
Head: h.headHtml, if err != nil {
Menu: h.menuHtml, w.WriteHeader(500)
Tokens: tokens, h.alertDialog(w, r, "Failed creating token", "/#/tokens")
Users: users, return
}) }
http.Redirect(w, r, "/#/tokens", 303)
} }
func (h *WebUiHandler) handleLogin(w http.ResponseWriter, r *http.Request) { func (h *WebUiHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
@ -314,7 +335,7 @@ func (h *WebUiHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
if h.auth.Authorized(token) { if h.auth.Authorized(token) {
cookie := &http.Cookie{Name: "access_token", Value: token, Secure: true, HttpOnly: true} cookie := &http.Cookie{Name: "access_token", Value: token, Secure: true, HttpOnly: true}
http.SetCookie(w, cookie) http.SetCookie(w, cookie)
http.Redirect(w, r, "/", 303) http.Redirect(w, r, "/#/tunnels", 303)
} else { } else {
h.sendLoginPage(w, r, 403) h.sendLoginPage(w, r, 403)
return return
@ -324,34 +345,11 @@ func (h *WebUiHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
func (h *WebUiHandler) handleTunnels(w http.ResponseWriter, r *http.Request, tokenData TokenData) { func (h *WebUiHandler) handleTunnels(w http.ResponseWriter, r *http.Request, tokenData TokenData) {
switch r.Method { switch r.Method {
case "GET":
tunnelsTemplate, err := h.box.String("tunnels.tmpl")
if err != nil {
w.WriteHeader(500)
io.WriteString(w, "Error reading tunnels.tmpl")
return
}
tmpl, err := template.New("tunnels").Parse(tunnelsTemplate)
if err != nil {
w.WriteHeader(500)
log.Println(err)
io.WriteString(w, "Error compiling tunnels.tmpl")
return
}
tunnelsData := TunnelsData{
Head: h.headHtml,
Menu: h.menuHtml,
Tunnels: h.api.GetTunnels(tokenData),
}
tmpl.Execute(w, tunnelsData)
case "POST": case "POST":
h.handleCreateTunnel(w, r, tokenData) h.handleCreateTunnel(w, r, tokenData)
default: default:
w.WriteHeader(405) w.WriteHeader(405)
w.Write([]byte("Invalid method for /tunnels")) w.Write([]byte("Invalid method for /#/tunnels"))
return return
} }
} }
@ -363,11 +361,11 @@ func (h *WebUiHandler) handleCreateTunnel(w http.ResponseWriter, r *http.Request
_, err := h.api.CreateTunnel(tokenData, r.Form) _, err := h.api.CreateTunnel(tokenData, r.Form)
if err != nil { if err != nil {
w.WriteHeader(400) w.WriteHeader(400)
h.alertDialog(w, r, err.Error(), "/") h.alertDialog(w, r, err.Error(), "/#/tunnels")
return return
} }
http.Redirect(w, r, "/", 303) http.Redirect(w, r, "/#/tunnels", 303)
} }
func (h *WebUiHandler) sendLoginPage(w http.ResponseWriter, r *http.Request, code int) { func (h *WebUiHandler) sendLoginPage(w http.ResponseWriter, r *http.Request, code int) {
@ -396,50 +394,41 @@ func (h *WebUiHandler) sendLoginPage(w http.ResponseWriter, r *http.Request, cod
loginTemplate.Execute(w, loginData) loginTemplate.Execute(w, loginData)
} }
func (h *WebUiHandler) usersPage(w http.ResponseWriter, r *http.Request) { func (h *WebUiHandler) handleUsers(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" { if r.Method != "POST" {
r.ParseForm() w.WriteHeader(405)
h.alertDialog(w, r, "Invalid method for users", "/#/users")
if len(r.Form["username"]) != 1 {
w.WriteHeader(400)
w.Write([]byte("Invalid username parameter"))
return
}
username := r.Form["username"][0]
minUsernameLen := 6
if len(username) < minUsernameLen {
w.WriteHeader(400)
errStr := fmt.Sprintf("Username must be at least %d characters", minUsernameLen)
h.alertDialog(w, r, errStr, "/users")
return
}
isAdmin := len(r.Form["is-admin"]) == 1 && r.Form["is-admin"][0] == "on"
err := h.db.AddUser(username, isAdmin)
if err != nil {
w.WriteHeader(500)
h.alertDialog(w, r, err.Error(), "/users")
return
}
http.Redirect(w, r, "/users", 303)
}
tmpl, err := h.loadTemplate("users.tmpl")
if err != nil {
w.WriteHeader(500)
io.WriteString(w, err.Error())
return return
} }
tmpl.Execute(w, UsersData{ r.ParseForm()
Head: h.headHtml,
Menu: h.menuHtml, if len(r.Form["username"]) != 1 {
Users: h.db.GetUsers(), w.WriteHeader(400)
}) w.Write([]byte("Invalid username parameter"))
return
}
username := r.Form["username"][0]
minUsernameLen := 6
if len(username) < minUsernameLen {
w.WriteHeader(400)
errStr := fmt.Sprintf("Username must be at least %d characters", minUsernameLen)
h.alertDialog(w, r, errStr, "/#/users")
return
}
isAdmin := len(r.Form["is-admin"]) == 1 && r.Form["is-admin"][0] == "on"
err := h.db.AddUser(username, isAdmin)
if err != nil {
w.WriteHeader(500)
h.alertDialog(w, r, err.Error(), "/#/users")
return
}
http.Redirect(w, r, "/#/users", 303)
} }
func (h *WebUiHandler) confirmDeleteUser(w http.ResponseWriter, r *http.Request) { func (h *WebUiHandler) confirmDeleteUser(w http.ResponseWriter, r *http.Request) {
@ -464,7 +453,7 @@ func (h *WebUiHandler) confirmDeleteUser(w http.ResponseWriter, r *http.Request)
Head: h.headHtml, Head: h.headHtml,
Message: fmt.Sprintf("Are you sure you want to delete user %s?", username), Message: fmt.Sprintf("Are you sure you want to delete user %s?", username),
ConfirmUrl: fmt.Sprintf("/delete-user?username=%s", username), ConfirmUrl: fmt.Sprintf("/delete-user?username=%s", username),
CancelUrl: "/users", CancelUrl: "/#/users",
} }
tmpl.Execute(w, data) tmpl.Execute(w, data)
@ -483,7 +472,7 @@ func (h *WebUiHandler) deleteUser(w http.ResponseWriter, r *http.Request) {
h.db.DeleteUser(username) h.db.DeleteUser(username)
http.Redirect(w, r, "/users", 303) http.Redirect(w, r, "/#/users", 303)
} }
func (h *WebUiHandler) confirmDeleteToken(w http.ResponseWriter, r *http.Request) { func (h *WebUiHandler) confirmDeleteToken(w http.ResponseWriter, r *http.Request) {
@ -508,7 +497,7 @@ func (h *WebUiHandler) confirmDeleteToken(w http.ResponseWriter, r *http.Request
Head: h.headHtml, Head: h.headHtml,
Message: fmt.Sprintf("Are you sure you want to delete token %s?", token), Message: fmt.Sprintf("Are you sure you want to delete token %s?", token),
ConfirmUrl: fmt.Sprintf("/delete-token?token=%s", token), ConfirmUrl: fmt.Sprintf("/delete-token?token=%s", token),
CancelUrl: "/tokens", CancelUrl: "/#/tokens",
} }
tmpl.Execute(w, data) tmpl.Execute(w, data)
@ -526,7 +515,7 @@ func (h *WebUiHandler) deleteToken(w http.ResponseWriter, r *http.Request) {
h.db.DeleteTokenData(token) h.db.DeleteTokenData(token)
http.Redirect(w, r, "/tokens", 303) http.Redirect(w, r, "/#/tokens", 303)
} }

88
webui/index.tmpl Normal file
View File

@ -0,0 +1,88 @@
<!doctype html>
<html>
<head>
{{.Head}}
</head>
<body>
<main>
{{.Menu}}
<div class='content'>
<div class='page' id='/tunnels'>
<div class='list'>
{{range $domain, $tunnel:= .Tunnels}}
<div class='list-item'>
<div>
<a href="https://{{$domain}}">{{$domain}}</a>:{{$tunnel.TunnelPort}} -> {{$tunnel.ClientName}}:{{$tunnel.ClientPort}}
</div>
<a href="/confirm-delete-tunnel?domain={{$domain}}">
<button class='button red-button'>Delete</button>
</a>
</div>
{{end}}
</div>
<div class='tunnel-adder'>
<form action="/tunnels" method="POST">
<label for="domain">Domain:</label>
<input type="text" id="domain" name="domain">
<label for="client-name">Client Name:</label>
<input type="text" id="client-name" name="client-name">
<label for="client-port">Client Port:</label>
<input type="text" id="client-port" name="client-port">
<button class='button green-button' type="submit">Add/Update Tunnel</button>
</form>
</div>
</div>
<div class='page' id='/tokens'>
<div class='list'>
{{range $token, $tokenData := .Tokens}}
<div class='list-item'>
<span class='token'>{{$token}}</span> ({{$tokenData.Owner}})
<a href="/confirm-delete-token?token={{$token}}">
<button class='button red-button'>Delete</button>
</a>
</div>
{{end}}
</div>
<div class='token-adder'>
<form action="/tokens" method="POST">
<label for="owner">Owner:</label>
<select id="owner" name="owner">
{{range $username, $user := .Users}}
<option value="{{$username}}">{{$username}}</option>
{{end}}
</select>
<button class='button green-button' type="submit">Add Token</button>
</form>
</div>
</div>
<div class='page' id='/users'>
<div class='list'>
{{range $username, $user := .Users}}
<div class='list-item'>
{{$username}}
<a href="/confirm-delete-user?username={{$username}}">
<button class='button red-button'>Delete</button>
</a>
</div>
{{end}}
</div>
<div class='user-adder'>
<form action="/users" method="POST">
<label for="username">Username:</label>
<input type="text" id="username" name="username">
<label for="is-admin">Is Admin:</label>
<input type="checkbox" id="is-admin" name="is-admin">
<button class='button green-button' type="submit">Add User</button>
</form>
</div>
</div>
</div>
</main>
</body>
</html>

View File

@ -1,9 +1,9 @@
<label id='menu-label' for='menu-toggle'>Menu</label>
<input type='checkbox' id='menu-toggle'/> <input type='checkbox' id='menu-toggle'/>
<label id='menu-label' for='menu-toggle'>Menu</label>
<div class='menu'> <div class='menu'>
<a class='menu-item' href='/tunnels'>Tunnels</a> <a class='menu-item' href='/#/tunnels'>Tunnels</a>
<a class='menu-item' href='/tokens'>Tokens</a> <a class='menu-item' href='/#/tokens'>Tokens</a>
{{if .IsAdmin}} {{if .IsAdmin}}
<a class='menu-item' href='/users'>Users</a> <a class='menu-item' href='/#/users'>Users</a>
{{end}} {{end}}
</div> </div>

View File

@ -16,7 +16,7 @@ main {
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;
display: block; display: block;
width: 100%; /*width: 100%;*/
border: 1px solid black; border: 1px solid black;
cursor: pointer; cursor: pointer;
padding: 5px; padding: 5px;
@ -32,7 +32,7 @@ main {
position: absolute; position: absolute;
background: #fff; background: #fff;
border: 1px solid black; border: 1px solid black;
min-width: 256px; /*min-width: 256px;*/
} }
.menu-item { .menu-item {
@ -55,6 +55,15 @@ main {
display: flex; display: flex;
} }
#menu-toggle:checked ~ #menu-label {
color: #fff;
background: #000;
}
.content {
border: 1px solid black;
}
.button { .button {
background: #ccc; background: #ccc;
border: none; border: none;
@ -109,3 +118,33 @@ main {
.token { .token {
font-family: Monospace; font-family: Monospace;
} }
.page {
display: none;
}
.content *:target {
display: block;
}
@media (min-width: 900px) {
main {
display: flex;
justify-content: flex-start;
}
#menu-label {
display: none;
}
.menu {
display: flex;
position: static;
}
.content {
width: 900px;
}
}

View File

@ -1,33 +0,0 @@
<!doctype html>
<html>
<head>
{{.Head}}
</head>
<body>
<main>
{{.Menu}}
<div class='list'>
{{range $token, $tokenData := .Tokens}}
<div class='list-item'>
<span class='token'>{{$token}}</span> ({{$tokenData.Owner}})
<a href="/confirm-delete-token?token={{$token}}">
<button class='button red-button'>Delete</button>
</a>
</div>
{{end}}
</div>
<div class='token-adder'>
<form action="/tokens" method="POST">
<label for="owner">Owner:</label>
<select id="owner" name="owner">
{{range $username, $user := .Users}}
<option value="{{$username}}">{{$username}}</option>
{{end}}
</select>
<button class='button green-button' type="submit">Add Token</button>
</form>
</div>
</main>
</body>
</html>

View File

@ -1,36 +0,0 @@
<!doctype html>
<html>
<head>
{{.Head}}
</head>
<body>
<main>
{{.Menu}}
<div class='list'>
{{range $domain, $tunnel:= .Tunnels}}
<div class='list-item'>
<div>
<a href="https://{{$domain}}">{{$domain}}</a>:{{$tunnel.TunnelPort}} -> {{$tunnel.ClientName}}:{{$tunnel.ClientPort}}
</div>
<a href="/confirm-delete-tunnel?domain={{$domain}}">
<button class='button red-button'>Delete</button>
</a>
</div>
{{end}}
<div class='tunnel-adder'>
<form action="/tunnels" method="POST">
<label for="domain">Domain:</label>
<input type="text" id="domain" name="domain">
<label for="client-name">Client Name:</label>
<input type="text" id="client-name" name="client-name">
<label for="client-port">Client Port:</label>
<input type="text" id="client-port" name="client-port">
<button class='button green-button' type="submit">Add/Update Tunnel</button>
</form>
</div>
</div>
</main>
</body>
</html>

View File

@ -1,31 +0,0 @@
<!doctype html>
<html>
<head>
{{.Head}}
</head>
<body>
<main>
{{.Menu}}
<div class='list'>
{{range $username, $user := .Users}}
<div class='list-item'>
{{$username}}
<a href="/confirm-delete-user?username={{$username}}">
<button class='button red-button'>Delete</button>
</a>
</div>
{{end}}
</div>
<div class='user-adder'>
<form action="/users" method="POST">
<label for="username">Username:</label>
<input type="text" id="username" name="username">
<label for="is-admin">Is Admin:</label>
<input type="checkbox" id="is-admin" name="is-admin">
<button class='button green-button' type="submit">Add User</button>
</form>
</div>
</main>
</body>
</html>