diff --git a/account.go b/account.go
index 2a66ecf..a8057d4 100644
--- a/account.go
+++ b/account.go
@@ -1,1070 +1,1087 @@
 /*
  * Copyright © 2018-2019 A Bunch Tell LLC.
  *
  * This file is part of WriteFreely.
  *
  * WriteFreely is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, included
  * in the LICENSE file in this source code package.
  */
 
 package writefreely
 
 import (
 	"encoding/json"
 	"fmt"
 	"html/template"
 	"net/http"
 	"regexp"
 	"strings"
 	"sync"
 	"time"
 
 	"github.com/gorilla/mux"
 	"github.com/gorilla/sessions"
 	"github.com/guregu/null/zero"
 	"github.com/writeas/impart"
 	"github.com/writeas/web-core/auth"
 	"github.com/writeas/web-core/data"
 	"github.com/writeas/web-core/log"
 	"github.com/writeas/writefreely/author"
 	"github.com/writeas/writefreely/config"
 	"github.com/writeas/writefreely/page"
 )
 
 type (
 	userSettings struct {
 		Username string `schema:"username" json:"username"`
 		Email    string `schema:"email" json:"email"`
 		NewPass  string `schema:"new-pass" json:"new_pass"`
 		OldPass  string `schema:"current-pass" json:"current_pass"`
 		IsLogOut bool   `schema:"logout" json:"logout"`
 	}
 
 	UserPage struct {
 		page.StaticPage
 
 		PageTitle string
 		Separator template.HTML
 		IsAdmin   bool
 		CanInvite bool
 	}
 )
 
 func NewUserPage(app *App, r *http.Request, u *User, title string, flashes []string) *UserPage {
 	up := &UserPage{
 		StaticPage: pageForReq(app, r),
 		PageTitle:  title,
 	}
 	up.Username = u.Username
 	up.Flashes = flashes
 	up.Path = r.URL.Path
 	up.IsAdmin = u.IsAdmin()
 	up.CanInvite = canUserInvite(app.cfg, up.IsAdmin)
 	return up
 }
 
 func canUserInvite(cfg *config.Config, isAdmin bool) bool {
 	return cfg.App.UserInvites != "" &&
 		(isAdmin || cfg.App.UserInvites != "admin")
 }
 
 func (up *UserPage) SetMessaging(u *User) {
 	//up.NeedsAuth = app.db.DoesUserNeedAuth(u.ID)
 }
 
 const (
 	loginAttemptExpiration = 3 * time.Second
 )
 
 var actuallyUsernameReg = regexp.MustCompile("username is actually ([a-z0-9\\-]+)\\. Please try that, instead")
 
 func apiSignup(app *App, w http.ResponseWriter, r *http.Request) error {
 	_, err := signup(app, w, r)
 	return err
 }
 
 func signup(app *App, w http.ResponseWriter, r *http.Request) (*AuthUser, error) {
 	reqJSON := IsJSON(r.Header.Get("Content-Type"))
 
 	// Get params
 	var ur userRegistration
 	if reqJSON {
 		decoder := json.NewDecoder(r.Body)
 		err := decoder.Decode(&ur)
 		if err != nil {
 			log.Error("Couldn't parse signup JSON request: %v\n", err)
 			return nil, ErrBadJSON
 		}
 	} else {
 		// Check if user is already logged in
 		u := getUserSession(app, r)
 		if u != nil {
 			return &AuthUser{User: u}, nil
 		}
 
 		err := r.ParseForm()
 		if err != nil {
 			log.Error("Couldn't parse signup form request: %v\n", err)
 			return nil, ErrBadFormData
 		}
 
 		err = app.formDecoder.Decode(&ur, r.PostForm)
 		if err != nil {
 			log.Error("Couldn't decode signup form request: %v\n", err)
 			return nil, ErrBadFormData
 		}
 	}
 
 	return signupWithRegistration(app, ur, w, r)
 }
 
 func signupWithRegistration(app *App, signup userRegistration, w http.ResponseWriter, r *http.Request) (*AuthUser, error) {
 	reqJSON := IsJSON(r.Header.Get("Content-Type"))
 
 	// Validate required params (alias)
 	if signup.Alias == "" {
 		return nil, impart.HTTPError{http.StatusBadRequest, "A username is required."}
 	}
 	if signup.Pass == "" {
 		return nil, impart.HTTPError{http.StatusBadRequest, "A password is required."}
 	}
 	var desiredUsername string
 	if signup.Normalize {
 		// With this option we simply conform the username to what we expect
 		// without complaining. Since they might've done something funny, like
 		// enter: write.as/Way Out There, we'll use their raw input for the new
 		// collection name and sanitize for the slug / username.
 		desiredUsername = signup.Alias
 		signup.Alias = getSlug(signup.Alias, "")
 	}
 	if !author.IsValidUsername(app.cfg, signup.Alias) {
 		// Ensure the username is syntactically correct.
 		return nil, impart.HTTPError{http.StatusPreconditionFailed, "Username is reserved or isn't valid. It must be at least 3 characters long, and can only include letters, numbers, and hyphens."}
 	}
 
 	// Handle empty optional params
 	// TODO: remove this var
 	createdWithPass := true
 	hashedPass, err := auth.HashPass([]byte(signup.Pass))
 	if err != nil {
 		return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
 	}
 
 	// Create struct to insert
 	u := &User{
 		Username:   signup.Alias,
 		HashedPass: hashedPass,
 		HasPass:    createdWithPass,
 		Email:      zero.NewString("", signup.Email != ""),
 		Created:    time.Now().Truncate(time.Second).UTC(),
 	}
 	if signup.Email != "" {
 		encEmail, err := data.Encrypt(app.keys.EmailKey, signup.Email)
 		if err != nil {
 			log.Error("Unable to encrypt email: %s\n", err)
 		} else {
 			u.Email.String = string(encEmail)
 		}
 	}
 
 	// Create actual user
 	if err := app.db.CreateUser(app.cfg, u, desiredUsername); err != nil {
 		return nil, err
 	}
 
 	// Log invite if needed
 	if signup.InviteCode != "" {
 		cu, err := app.db.GetUserForAuth(signup.Alias)
 		if err != nil {
 			return nil, err
 		}
 		err = app.db.CreateInvitedUser(signup.InviteCode, cu.ID)
 		if err != nil {
 			return nil, err
 		}
 	}
 
 	// Add back unencrypted data for response
 	if signup.Email != "" {
 		u.Email.String = signup.Email
 	}
 
 	resUser := &AuthUser{
 		User: u,
 	}
 	if !createdWithPass {
 		resUser.Password = signup.Pass
 	}
 	title := signup.Alias
 	if signup.Normalize {
 		title = desiredUsername
 	}
 	resUser.Collections = &[]Collection{
 		{
 			Alias: signup.Alias,
 			Title: title,
 		},
 	}
 
 	var token string
 	if reqJSON && !signup.Web {
 		token, err = app.db.GetAccessToken(u.ID)
 		if err != nil {
 			return nil, impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."}
 		}
 		resUser.AccessToken = token
 	} else {
 		session, err := app.sessionStore.Get(r, cookieName)
 		if err != nil {
 			// The cookie should still save, even if there's an error.
 			// Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144
 			log.Error("Session: %v; ignoring", err)
 		}
 		session.Values[cookieUserVal] = resUser.User.Cookie()
 		err = session.Save(r, w)
 		if err != nil {
 			log.Error("Couldn't save session: %v", err)
 			return nil, err
 		}
 	}
 	if reqJSON {
 		return resUser, impart.WriteSuccess(w, resUser, http.StatusCreated)
 	}
 
 	return resUser, nil
 }
 
 func viewLogout(app *App, w http.ResponseWriter, r *http.Request) error {
 	session, err := app.sessionStore.Get(r, cookieName)
 	if err != nil {
 		return ErrInternalCookieSession
 	}
 
 	// Ensure user has an email or password set before they go, so they don't
 	// lose access to their account.
 	val := session.Values[cookieUserVal]
 	var u = &User{}
 	var ok bool
 	if u, ok = val.(*User); !ok {
 		log.Error("Error casting user object on logout. Vals: %+v Resetting cookie.", session.Values)
 
 		err = session.Save(r, w)
 		if err != nil {
 			log.Error("Couldn't save session on logout: %v", err)
 			return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."}
 		}
 
 		return impart.HTTPError{http.StatusFound, "/"}
 	}
 
 	u, err = app.db.GetUserByID(u.ID)
 	if err != nil && err != ErrUserNotFound {
 		return impart.HTTPError{http.StatusInternalServerError, "Unable to fetch user information."}
 	}
 
 	session.Options.MaxAge = -1
 
 	err = session.Save(r, w)
 	if err != nil {
 		log.Error("Couldn't save session on logout: %v", err)
 		return impart.HTTPError{http.StatusInternalServerError, "Unable to save cookie session."}
 	}
 
 	return impart.HTTPError{http.StatusFound, "/"}
 }
 
 func handleAPILogout(app *App, w http.ResponseWriter, r *http.Request) error {
 	accessToken := r.Header.Get("Authorization")
 	if accessToken == "" {
 		return ErrNoAccessToken
 	}
 	t := auth.GetToken(accessToken)
 	if len(t) == 0 {
 		return ErrNoAccessToken
 	}
 	err := app.db.DeleteToken(t)
 	if err != nil {
 		return err
 	}
 	return impart.HTTPError{Status: http.StatusNoContent}
 }
 
 func viewLogin(app *App, w http.ResponseWriter, r *http.Request) error {
 	var earlyError string
 	oneTimeToken := r.FormValue("with")
 	if oneTimeToken != "" {
 		log.Info("Calling login with one-time token.")
 		err := login(app, w, r)
 		if err != nil {
 			log.Info("Received error: %v", err)
 			earlyError = fmt.Sprintf("%s", err)
 		}
 	}
 
 	session, err := app.sessionStore.Get(r, cookieName)
 	if err != nil {
 		// Ignore this
 		log.Error("Unable to get session; ignoring: %v", err)
 	}
 
 	p := &struct {
 		page.StaticPage
 		To            string
 		Message       template.HTML
 		Flashes       []template.HTML
 		LoginUsername string
 	}{
 		pageForReq(app, r),
 		r.FormValue("to"),
 		template.HTML(""),
 		[]template.HTML{},
 		getTempInfo(app, "login-user", r, w),
 	}
 
 	if earlyError != "" {
 		p.Flashes = append(p.Flashes, template.HTML(earlyError))
 	}
 
 	// Display any error messages
 	flashes, _ := getSessionFlashes(app, w, r, session)
 	for _, flash := range flashes {
 		p.Flashes = append(p.Flashes, template.HTML(flash))
 	}
 	err = pages["login.tmpl"].ExecuteTemplate(w, "base", p)
 	if err != nil {
 		log.Error("Unable to render login: %v", err)
 		return err
 	}
 	return nil
 }
 
 func webLogin(app *App, w http.ResponseWriter, r *http.Request) error {
 	err := login(app, w, r)
 	if err != nil {
 		username := r.FormValue("alias")
 		// Login request was unsuccessful; save the error in the session and redirect them
 		if err, ok := err.(impart.HTTPError); ok {
 			session, _ := app.sessionStore.Get(r, cookieName)
 			if session != nil {
 				session.AddFlash(err.Message)
 				session.Save(r, w)
 			}
 
 			if m := actuallyUsernameReg.FindStringSubmatch(err.Message); len(m) > 0 {
 				// Retain fixed username recommendation for the login form
 				username = m[1]
 			}
 		}
 
 		// Pass along certain information
 		saveTempInfo(app, "login-user", username, r, w)
 
 		// Retain post-login URL if one was given
 		redirectTo := "/login"
 		postLoginRedirect := r.FormValue("to")
 		if postLoginRedirect != "" {
 			redirectTo += "?to=" + postLoginRedirect
 		}
 
 		log.Error("Unable to login: %v", err)
 		return impart.HTTPError{http.StatusTemporaryRedirect, redirectTo}
 	}
 
 	return nil
 }
 
 var loginAttemptUsers = sync.Map{}
 
 func login(app *App, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r.Header.Get("Content-Type"))
 	oneTimeToken := r.FormValue("with")
 	verbose := r.FormValue("all") == "true" || r.FormValue("verbose") == "1" || r.FormValue("verbose") == "true" || (reqJSON && oneTimeToken != "")
 
 	redirectTo := r.FormValue("to")
 	if redirectTo == "" {
 		if app.cfg.App.SingleUser {
 			redirectTo = "/me/new"
 		} else {
 			redirectTo = "/"
 		}
 	}
 
 	var u *User
 	var err error
 	var signin userCredentials
 
 	// Log in with one-time token if one is given
 	if oneTimeToken != "" {
 		log.Info("Login: Logging user in via token.")
 		userID := app.db.GetUserID(oneTimeToken)
 		if userID == -1 {
 			log.Error("Login: Got user -1 from token")
 			err := ErrBadAccessToken
 			err.Message = "Expired or invalid login code."
 			return err
 		}
 		log.Info("Login: Found user %d.", userID)
 
 		u, err = app.db.GetUserByID(userID)
 		if err != nil {
 			log.Error("Unable to fetch user on one-time token login: %v", err)
 			return impart.HTTPError{http.StatusInternalServerError, "There was an error retrieving the user you want."}
 		}
 		log.Info("Login: Got user via token")
 	} else {
 		// Get params
 		if reqJSON {
 			decoder := json.NewDecoder(r.Body)
 			err := decoder.Decode(&signin)
 			if err != nil {
 				log.Error("Couldn't parse signin JSON request: %v\n", err)
 				return ErrBadJSON
 			}
 		} else {
 			err := r.ParseForm()
 			if err != nil {
 				log.Error("Couldn't parse signin form request: %v\n", err)
 				return ErrBadFormData
 			}
 
 			err = app.formDecoder.Decode(&signin, r.PostForm)
 			if err != nil {
 				log.Error("Couldn't decode signin form request: %v\n", err)
 				return ErrBadFormData
 			}
 		}
 
 		log.Info("Login: Attempting login for '%s'", signin.Alias)
 
 		// Validate required params (all)
 		if signin.Alias == "" {
 			msg := "Parameter `alias` required."
 			if signin.Web {
 				msg = "A username is required."
 			}
 			return impart.HTTPError{http.StatusBadRequest, msg}
 		}
 		if !signin.EmailLogin && signin.Pass == "" {
 			msg := "Parameter `pass` required."
 			if signin.Web {
 				msg = "A password is required."
 			}
 			return impart.HTTPError{http.StatusBadRequest, msg}
 		}
 
 		// Prevent excessive login attempts on the same account
 		// Skip this check in dev environment
 		if !app.cfg.Server.Dev {
 			now := time.Now()
 			attemptExp, att := loginAttemptUsers.LoadOrStore(signin.Alias, now.Add(loginAttemptExpiration))
 			if att {
 				if attemptExpTime, ok := attemptExp.(time.Time); ok {
 					if attemptExpTime.After(now) {
 						// This user attempted previously, and the period hasn't expired yet
 						return impart.HTTPError{http.StatusTooManyRequests, "You're doing that too much."}
 					} else {
 						// This user attempted previously, but the time expired; free up space
 						loginAttemptUsers.Delete(signin.Alias)
 					}
 				} else {
 					log.Error("Unable to cast expiration to time")
 				}
 			}
 		}
 
 		// Retrieve password
 		u, err = app.db.GetUserForAuth(signin.Alias)
 		if err != nil {
 			log.Info("Unable to getUserForAuth on %s: %v", signin.Alias, err)
 			if strings.IndexAny(signin.Alias, "@") > 0 {
 				log.Info("Suggesting: %s", ErrUserNotFoundEmail.Message)
 				return ErrUserNotFoundEmail
 			}
 			return err
 		}
 		// Authenticate
 		if u.Email.String == "" {
 			// User has no email set, so check if they haven't added a password, either,
 			// so we can return a more helpful error message.
 			if hasPass, _ := app.db.IsUserPassSet(u.ID); !hasPass {
 				log.Info("Tried logging in to %s, but no password or email.", signin.Alias)
 				return impart.HTTPError{http.StatusPreconditionFailed, "This user never added a password or email address. Please contact us for help."}
 			}
 		}
 		if !auth.Authenticated(u.HashedPass, []byte(signin.Pass)) {
 			return impart.HTTPError{http.StatusUnauthorized, "Incorrect password."}
 		}
 	}
 
 	if reqJSON && !signin.Web {
 		var token string
 		if r.Header.Get("User-Agent") == "" {
 			// Get last created token when User-Agent is empty
 			token = app.db.FetchLastAccessToken(u.ID)
 			if token == "" {
 				token, err = app.db.GetAccessToken(u.ID)
 			}
 		} else {
 			token, err = app.db.GetAccessToken(u.ID)
 		}
 		if err != nil {
 			log.Error("Login: Unable to create access token: %v", err)
 			return impart.HTTPError{http.StatusInternalServerError, "Could not create access token. Try re-authenticating."}
 		}
 		resUser := getVerboseAuthUser(app, token, u, verbose)
 		return impart.WriteSuccess(w, resUser, http.StatusOK)
 	}
 
 	session, err := app.sessionStore.Get(r, cookieName)
 	if err != nil {
 		// The cookie should still save, even if there's an error.
 		log.Error("Login: Session: %v; ignoring", err)
 	}
 
 	// Remove unwanted data
 	session.Values[cookieUserVal] = u.Cookie()
 	err = session.Save(r, w)
 	if err != nil {
 		log.Error("Login: Couldn't save session: %v", err)
 		// TODO: return error
 	}
 
 	// Send success
 	if reqJSON {
 		return impart.WriteSuccess(w, &AuthUser{User: u}, http.StatusOK)
 	}
 	log.Info("Login: Redirecting to %s", redirectTo)
 	w.Header().Set("Location", redirectTo)
 	w.WriteHeader(http.StatusFound)
 	return nil
 }
 
 func getVerboseAuthUser(app *App, token string, u *User, verbose bool) *AuthUser {
 	resUser := &AuthUser{
 		AccessToken: token,
 		User:        u,
 	}
 
 	// Fetch verbose user data if requested
 	if verbose {
 		posts, err := app.db.GetUserPosts(u)
 		if err != nil {
 			log.Error("Login: Unable to get user posts: %v", err)
 		}
 		colls, err := app.db.GetCollections(u, app.cfg.App.Host)
 		if err != nil {
 			log.Error("Login: Unable to get user collections: %v", err)
 		}
 		passIsSet, err := app.db.IsUserPassSet(u.ID)
 		if err != nil {
 			// TODO: correct error meesage
 			log.Error("Login: Unable to get user collections: %v", err)
 		}
 
 		resUser.Posts = posts
 		resUser.Collections = colls
 		resUser.User.HasPass = passIsSet
 	}
 	return resUser
 }
 
 func viewExportOptions(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	// Fetch extra user data
 	p := NewUserPage(app, r, u, "Export", nil)
 
 	showUserPage(w, "export", p)
 	return nil
 }
 
 func viewExportPosts(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
 	var filename string
 	var u = &User{}
 	reqJSON := IsJSON(r.Header.Get("Content-Type"))
 	if reqJSON {
 		// Use given Authorization header
 		accessToken := r.Header.Get("Authorization")
 		if accessToken == "" {
 			return nil, filename, ErrNoAccessToken
 		}
 
 		userID := app.db.GetUserID(accessToken)
 		if userID == -1 {
 			return nil, filename, ErrBadAccessToken
 		}
 
 		var err error
 		u, err = app.db.GetUserByID(userID)
 		if err != nil {
 			return nil, filename, impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve requested user."}
 		}
 	} else {
 		// Use user cookie
 		session, err := app.sessionStore.Get(r, cookieName)
 		if err != nil {
 			// The cookie should still save, even if there's an error.
 			log.Error("Session: %v; ignoring", err)
 		}
 
 		val := session.Values[cookieUserVal]
 		var ok bool
 		if u, ok = val.(*User); !ok {
 			return nil, filename, ErrNotLoggedIn
 		}
 	}
 
 	filename = u.Username + "-posts-" + time.Now().Truncate(time.Second).UTC().Format("200601021504")
 
 	// Fetch data we're exporting
 	var err error
 	var data []byte
 	posts, err := app.db.GetUserPosts(u)
 	if err != nil {
 		return data, filename, err
 	}
 
 	// Export as CSV
 	if strings.HasSuffix(r.URL.Path, ".csv") {
 		data = exportPostsCSV(u, posts)
 		return data, filename, err
 	}
 	if strings.HasSuffix(r.URL.Path, ".zip") {
 		data = exportPostsZip(u, posts)
 		return data, filename, err
 	}
 
 	if r.FormValue("pretty") == "1" {
 		data, err = json.MarshalIndent(posts, "", "\t")
 	} else {
 		data, err = json.Marshal(posts)
 	}
 	return data, filename, err
 }
 
 func viewExportFull(app *App, w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
 	var err error
 	filename := ""
 	u := getUserSession(app, r)
 	if u == nil {
 		return nil, filename, ErrNotLoggedIn
 	}
 	filename = u.Username + "-" + time.Now().Truncate(time.Second).UTC().Format("200601021504")
 
 	exportUser := compileFullExport(app, u)
 
 	var data []byte
 	if r.FormValue("pretty") == "1" {
 		data, err = json.MarshalIndent(exportUser, "", "\t")
 	} else {
 		data, err = json.Marshal(exportUser)
 	}
 	return data, filename, err
 }
 
 func viewMeAPI(app *App, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r.Header.Get("Content-Type"))
 	uObj := struct {
 		ID       int64  `json:"id,omitempty"`
 		Username string `json:"username,omitempty"`
 	}{}
 	var err error
 
 	if reqJSON {
 		_, uObj.Username, err = app.db.GetUserDataFromToken(r.Header.Get("Authorization"))
 		if err != nil {
 			return err
 		}
 	} else {
 		u := getUserSession(app, r)
 		if u == nil {
 			return impart.WriteSuccess(w, uObj, http.StatusOK)
 		}
 		uObj.Username = u.Username
 	}
 
 	return impart.WriteSuccess(w, uObj, http.StatusOK)
 }
 
 func viewMyPostsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r.Header.Get("Content-Type"))
 	if !reqJSON {
 		return ErrBadRequestedType
 	}
 
 	var err error
 	p := GetPostsCache(u.ID)
 	if p == nil {
 		userPostsCache.Lock()
 		if userPostsCache.users[u.ID].ready == nil {
 			userPostsCache.users[u.ID] = postsCacheItem{ready: make(chan struct{})}
 			userPostsCache.Unlock()
 
 			p, err = app.db.GetUserPosts(u)
 			if err != nil {
 				return err
 			}
 
 			CachePosts(u.ID, p)
 		} else {
 			userPostsCache.Unlock()
 
 			<-userPostsCache.users[u.ID].ready
 			p = GetPostsCache(u.ID)
 		}
 	}
 
 	return impart.WriteSuccess(w, p, http.StatusOK)
 }
 
 func viewMyCollectionsAPI(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r.Header.Get("Content-Type"))
 	if !reqJSON {
 		return ErrBadRequestedType
 	}
 
 	p, err := app.db.GetCollections(u, app.cfg.App.Host)
 	if err != nil {
 		return err
 	}
 
 	return impart.WriteSuccess(w, p, http.StatusOK)
 }
 
 func viewArticles(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	p, err := app.db.GetAnonymousPosts(u)
 	if err != nil {
 		log.Error("unable to fetch anon posts: %v", err)
 	}
 	// nil-out AnonymousPosts slice for easy detection in the template
 	if p != nil && len(*p) == 0 {
 		p = nil
 	}
 
 	f, err := getSessionFlashes(app, w, r, nil)
 	if err != nil {
 		log.Error("unable to fetch flashes: %v", err)
 	}
 
 	c, err := app.db.GetPublishableCollections(u, app.cfg.App.Host)
 	if err != nil {
 		log.Error("unable to fetch collections: %v", err)
 	}
 
 	d := struct {
 		*UserPage
 		AnonymousPosts *[]PublicPost
 		Collections    *[]Collection
 	}{
 		UserPage:       NewUserPage(app, r, u, u.Username+"'s Posts", f),
 		AnonymousPosts: p,
 		Collections:    c,
 	}
 	d.UserPage.SetMessaging(u)
 	w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
 	w.Header().Set("Expires", "Thu, 04 Oct 1990 20:00:00 GMT")
 	showUserPage(w, "articles", d)
 
 	return nil
 }
 
 func viewCollections(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	c, err := app.db.GetCollections(u, app.cfg.App.Host)
 	if err != nil {
 		log.Error("unable to fetch collections: %v", err)
 		return fmt.Errorf("No collections")
 	}
 
 	f, _ := getSessionFlashes(app, w, r, nil)
 
 	uc, _ := app.db.GetUserCollectionCount(u.ID)
 	// TODO: handle any errors
 
 	d := struct {
 		*UserPage
 		Collections *[]Collection
 
 		UsedCollections, TotalCollections int
 
 		NewBlogsDisabled bool
 	}{
 		UserPage:         NewUserPage(app, r, u, u.Username+"'s Blogs", f),
 		Collections:      c,
 		UsedCollections:  int(uc),
 		NewBlogsDisabled: !app.cfg.App.CanCreateBlogs(uc),
 	}
 	d.UserPage.SetMessaging(u)
 	showUserPage(w, "collections", d)
 
 	return nil
 }
 
 func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	vars := mux.Vars(r)
 	c, err := app.db.GetCollection(vars["collection"])
 	if err != nil {
 		return err
 	}
 	if c.OwnerID != u.ID {
 		return ErrCollectionNotFound
 	}
 
 	flashes, _ := getSessionFlashes(app, w, r, nil)
 	obj := struct {
 		*UserPage
 		*Collection
 	}{
 		UserPage:   NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
 		Collection: c,
 	}
 
 	showUserPage(w, "collection", obj)
 	return nil
 }
 
 func updateSettings(app *App, w http.ResponseWriter, r *http.Request) error {
 	reqJSON := IsJSON(r.Header.Get("Content-Type"))
 
 	var s userSettings
 	var u *User
 	var sess *sessions.Session
 	var err error
 	if reqJSON {
 		accessToken := r.Header.Get("Authorization")
 		if accessToken == "" {
 			return ErrNoAccessToken
 		}
 
 		u, err = app.db.GetAPIUser(accessToken)
 		if err != nil {
 			return ErrBadAccessToken
 		}
 
 		decoder := json.NewDecoder(r.Body)
 		err := decoder.Decode(&s)
 		if err != nil {
 			log.Error("Couldn't parse settings JSON request: %v\n", err)
 			return ErrBadJSON
 		}
 
 		// Prevent all username updates
 		// TODO: support changing username via JSON API request
 		s.Username = ""
 	} else {
 		u, sess = getUserAndSession(app, r)
 		if u == nil {
 			return ErrNotLoggedIn
 		}
 
 		err := r.ParseForm()
 		if err != nil {
 			log.Error("Couldn't parse settings form request: %v\n", err)
 			return ErrBadFormData
 		}
 
 		err = app.formDecoder.Decode(&s, r.PostForm)
 		if err != nil {
 			log.Error("Couldn't decode settings form request: %v\n", err)
 			return ErrBadFormData
 		}
 	}
 
 	// Do update
 	postUpdateReturn := r.FormValue("return")
 	redirectTo := "/me/settings"
 	if s.IsLogOut {
 		redirectTo += "?logout=1"
 	} else if postUpdateReturn != "" {
 		redirectTo = postUpdateReturn
 	}
 
 	// Only do updates on values we need
 	if s.Username != "" && s.Username == u.Username {
 		// Username hasn't actually changed; blank it out
 		s.Username = ""
 	}
 	err = app.db.ChangeSettings(app, u, &s)
 	if err != nil {
 		if reqJSON {
 			return err
 		}
 
 		if err, ok := err.(impart.HTTPError); ok {
 			addSessionFlash(app, w, r, err.Message, nil)
 		}
 	} else {
 		// Successful update.
 		if reqJSON {
 			return impart.WriteSuccess(w, u, http.StatusOK)
 		}
 
 		if s.IsLogOut {
 			redirectTo = "/me/logout"
 		} else {
 			sess.Values[cookieUserVal] = u.Cookie()
 			addSessionFlash(app, w, r, "Account updated.", nil)
 		}
 	}
 
 	w.Header().Set("Location", redirectTo)
 	w.WriteHeader(http.StatusFound)
 	return nil
 }
 
 func updatePassphrase(app *App, w http.ResponseWriter, r *http.Request) error {
 	accessToken := r.Header.Get("Authorization")
 	if accessToken == "" {
 		return ErrNoAccessToken
 	}
 
 	curPass := r.FormValue("current")
 	newPass := r.FormValue("new")
 	// Ensure a new password is given (always required)
 	if newPass == "" {
 		return impart.HTTPError{http.StatusBadRequest, "Provide a new password."}
 	}
 
 	userID, sudo := app.db.GetUserIDPrivilege(accessToken)
 	if userID == -1 {
 		return ErrBadAccessToken
 	}
 
 	// Ensure a current password is given if the access token doesn't have sudo
 	// privileges.
 	if !sudo && curPass == "" {
 		return impart.HTTPError{http.StatusBadRequest, "Provide current password."}
 	}
 
 	// Hash the new password
 	hashedPass, err := auth.HashPass([]byte(newPass))
 	if err != nil {
 		return impart.HTTPError{http.StatusInternalServerError, "Could not create password hash."}
 	}
 
 	// Do update
 	err = app.db.ChangePassphrase(userID, sudo, curPass, hashedPass)
 	if err != nil {
 		return err
 	}
 
 	return impart.WriteSuccess(w, struct{}{}, http.StatusOK)
 }
 
 func viewStats(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	var c *Collection
 	var err error
 	vars := mux.Vars(r)
 	alias := vars["collection"]
 	if alias != "" {
 		c, err = app.db.GetCollection(alias)
 		if err != nil {
 			return err
 		}
 		if c.OwnerID != u.ID {
 			return ErrCollectionNotFound
 		}
 	}
 
 	topPosts, err := app.db.GetTopPosts(u, alias)
 	if err != nil {
 		log.Error("Unable to get top posts: %v", err)
 		return err
 	}
 
 	flashes, _ := getSessionFlashes(app, w, r, nil)
 	titleStats := ""
 	if c != nil {
 		titleStats = c.DisplayTitle() + " "
 	}
 
 	obj := struct {
 		*UserPage
 		VisitsBlog  string
 		Collection  *Collection
 		TopPosts    *[]PublicPost
 		APFollowers int
 	}{
 		UserPage:   NewUserPage(app, r, u, titleStats+"Stats", flashes),
 		VisitsBlog: alias,
 		Collection: c,
 		TopPosts:   topPosts,
 	}
 	if app.cfg.App.Federation {
 		folls, err := app.db.GetAPFollowers(c)
 		if err != nil {
 			return err
 		}
 		obj.APFollowers = len(*folls)
 	}
 
 	showUserPage(w, "stats", obj)
 	return nil
 }
 
 func viewSettings(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
 	fullUser, err := app.db.GetUserByID(u.ID)
 	if err != nil {
 		log.Error("Unable to get user for settings: %s", err)
 		return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
 	}
 
 	passIsSet, err := app.db.IsUserPassSet(u.ID)
 	if err != nil {
 		log.Error("Unable to get isUserPassSet for settings: %s", err)
 		return impart.HTTPError{http.StatusInternalServerError, "Unable to retrieve user data. The humans have been alerted."}
 	}
 
 	flashes, _ := getSessionFlashes(app, w, r, nil)
 
 	obj := struct {
 		*UserPage
 		Email    string
 		HasPass  bool
 		IsLogOut bool
 	}{
 		UserPage: NewUserPage(app, r, u, "Account Settings", flashes),
 		Email:    fullUser.EmailClear(app.keys),
 		HasPass:  passIsSet,
 		IsLogOut: r.FormValue("logout") == "1",
 	}
 
 	showUserPage(w, "settings", obj)
 	return nil
 }
 
 func saveTempInfo(app *App, key, val string, r *http.Request, w http.ResponseWriter) error {
 	session, err := app.sessionStore.Get(r, "t")
 	if err != nil {
 		return ErrInternalCookieSession
 	}
 
 	session.Values[key] = val
 	err = session.Save(r, w)
 	if err != nil {
 		log.Error("Couldn't saveTempInfo for key-val (%s:%s): %v", key, val, err)
 	}
 	return err
 }
 
 func getTempInfo(app *App, key string, r *http.Request, w http.ResponseWriter) string {
 	session, err := app.sessionStore.Get(r, "t")
 	if err != nil {
 		return ""
 	}
 
 	// Get the information
 	var s = ""
 	var ok bool
 	if s, ok = session.Values[key].(string); !ok {
 		return ""
 	}
 
 	// Delete cookie
 	session.Options.MaxAge = -1
 	err = session.Save(r, w)
 	if err != nil {
 		log.Error("Couldn't erase temp data for key %s: %v", key, err)
 	}
 
 	// Return value
 	return s
 }
+
+func handleUserDelete(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
+	confirmUsername := r.PostFormValue("confirm-username")
+	if u.Username != confirmUsername {
+		return impart.HTTPError{http.StatusBadRequest, "Confirmation username must match your username exactly."}
+	}
+
+	// TODO: prevent admin delete themselves?
+	err := app.db.DeleteAccount(u.ID)
+	if err != nil {
+		log.Error("user delete account: %v", err)
+		return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("Could not delete account: %v", err)}
+	}
+
+	_ = addSessionFlash(app, w, r, "Account deleted successfully, sorry to see you go.", nil)
+	return impart.HTTPError{http.StatusFound, "/me/logout"}
+}
diff --git a/routes.go b/routes.go
index 0113e93..ab0a854 100644
--- a/routes.go
+++ b/routes.go
@@ -1,206 +1,207 @@
 /*
  * Copyright © 2018-2019 A Bunch Tell LLC.
  *
  * This file is part of WriteFreely.
  *
  * WriteFreely is free software: you can redistribute it and/or modify
  * it under the terms of the GNU Affero General Public License, included
  * in the LICENSE file in this source code package.
  */
 
 package writefreely
 
 import (
 	"net/http"
 	"path/filepath"
 	"strings"
 
 	"github.com/gorilla/mux"
 	"github.com/writeas/go-webfinger"
 	"github.com/writeas/web-core/log"
 	"github.com/writefreely/go-nodeinfo"
 )
 
 // InitStaticRoutes adds routes for serving static files.
 // TODO: this should just be a func, not method
 func (app *App) InitStaticRoutes(r *mux.Router) {
 	// Handle static files
 	fs := http.FileServer(http.Dir(filepath.Join(app.cfg.Server.StaticParentDir, staticDir)))
 	app.shttp = http.NewServeMux()
 	app.shttp.Handle("/", fs)
 	r.PathPrefix("/").Handler(fs)
 }
 
 // InitRoutes adds dynamic routes for the given mux.Router.
 func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
 	// Create handler
 	handler := NewWFHandler(apper)
 
 	// Set up routes
 	hostSubroute := apper.App().cfg.App.Host[strings.Index(apper.App().cfg.App.Host, "://")+3:]
 	if apper.App().cfg.App.SingleUser {
 		hostSubroute = "{domain}"
 	} else {
 		if strings.HasPrefix(hostSubroute, "localhost") {
 			hostSubroute = "localhost"
 		}
 	}
 
 	if apper.App().cfg.App.SingleUser {
 		log.Info("Adding %s routes (single user)...", hostSubroute)
 	} else {
 		log.Info("Adding %s routes (multi-user)...", hostSubroute)
 	}
 
 	// Primary app routes
 	write := r.PathPrefix("/").Subrouter()
 
 	// Federation endpoint configurations
 	wf := webfinger.Default(wfResolver{apper.App().db, apper.App().cfg})
 	wf.NoTLSHandler = nil
 
 	// Federation endpoints
 	// host-meta
 	write.HandleFunc("/.well-known/host-meta", handler.Web(handleViewHostMeta, UserLevelReader))
 	// webfinger
 	write.HandleFunc(webfinger.WebFingerPath, handler.LogHandlerFunc(http.HandlerFunc(wf.Webfinger)))
 	// nodeinfo
 	niCfg := nodeInfoConfig(apper.App().db, apper.App().cfg)
 	ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{apper.App().cfg, apper.App().db})
 	write.HandleFunc(nodeinfo.NodeInfoPath, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfoDiscover)))
 	write.HandleFunc(niCfg.InfoURL, handler.LogHandlerFunc(http.HandlerFunc(ni.NodeInfo)))
 
 	// Set up dyamic page handlers
 	// Handle auth
 	auth := write.PathPrefix("/api/auth/").Subrouter()
 	if apper.App().cfg.App.OpenRegistration {
 		auth.HandleFunc("/signup", handler.All(apiSignup)).Methods("POST")
 	}
 	auth.HandleFunc("/login", handler.All(login)).Methods("POST")
 	auth.HandleFunc("/read", handler.WebErrors(handleWebCollectionUnlock, UserLevelNone)).Methods("POST")
 	auth.HandleFunc("/me", handler.All(handleAPILogout)).Methods("DELETE")
 
 	// Handle logged in user sections
 	me := write.PathPrefix("/me").Subrouter()
 	me.HandleFunc("/", handler.Redirect("/me", UserLevelUser))
 	me.HandleFunc("/c", handler.Redirect("/me/c/", UserLevelUser)).Methods("GET")
 	me.HandleFunc("/c/", handler.User(viewCollections)).Methods("GET")
 	me.HandleFunc("/c/{collection}", handler.User(viewEditCollection)).Methods("GET")
 	me.HandleFunc("/c/{collection}/stats", handler.User(viewStats)).Methods("GET")
+	me.HandleFunc("/delete", handler.User(handleUserDelete)).Methods("POST")
 	me.HandleFunc("/posts", handler.Redirect("/me/posts/", UserLevelUser)).Methods("GET")
 	me.HandleFunc("/posts/", handler.User(viewArticles)).Methods("GET")
 	me.HandleFunc("/posts/export.csv", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
 	me.HandleFunc("/posts/export.zip", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
 	me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
 	me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET")
 	me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET")
 	me.HandleFunc("/settings", handler.User(viewSettings)).Methods("GET")
 	me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET")
 	me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET")
 
 	write.HandleFunc("/api/me", handler.All(viewMeAPI)).Methods("GET")
 	apiMe := write.PathPrefix("/api/me/").Subrouter()
 	apiMe.HandleFunc("/", handler.All(viewMeAPI)).Methods("GET")
 	apiMe.HandleFunc("/posts", handler.UserAPI(viewMyPostsAPI)).Methods("GET")
 	apiMe.HandleFunc("/collections", handler.UserAPI(viewMyCollectionsAPI)).Methods("GET")
 	apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST")
 	apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST")
 	apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST")
 
 	// Sign up validation
 	write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
 
 	// Handle collections
 	write.HandleFunc("/api/collections", handler.All(newCollection)).Methods("POST")
 	apiColls := write.PathPrefix("/api/collections/").Subrouter()
 	apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.AllReader(fetchCollection)).Methods("GET")
 	apiColls.HandleFunc("/{alias:[0-9a-zA-Z\\-]+}", handler.All(existingCollection)).Methods("POST", "DELETE")
 	apiColls.HandleFunc("/{alias}/posts", handler.AllReader(fetchCollectionPosts)).Methods("GET")
 	apiColls.HandleFunc("/{alias}/posts", handler.All(newPost)).Methods("POST")
 	apiColls.HandleFunc("/{alias}/posts/{post}", handler.AllReader(fetchPost)).Methods("GET")
 	apiColls.HandleFunc("/{alias}/posts/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST")
 	apiColls.HandleFunc("/{alias}/posts/{post}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
 	apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST")
 	apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST")
 	apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST")
 	apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST")
 	apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET")
 	apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET")
 	apiColls.HandleFunc("/{alias}/followers", handler.AllReader(handleFetchCollectionFollowers)).Methods("GET")
 
 	// Handle posts
 	write.HandleFunc("/api/posts", handler.All(newPost)).Methods("POST")
 	posts := write.PathPrefix("/api/posts/").Subrouter()
 	posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.AllReader(fetchPost)).Methods("GET")
 	posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(existingPost)).Methods("POST", "PUT")
 	posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}", handler.All(deletePost)).Methods("DELETE")
 	posts.HandleFunc("/{post:[a-zA-Z0-9]{10}}/{property}", handler.AllReader(fetchPostProperty)).Methods("GET")
 	posts.HandleFunc("/claim", handler.All(addPost)).Methods("POST")
 	posts.HandleFunc("/disperse", handler.All(dispersePost)).Methods("POST")
 
 	write.HandleFunc("/auth/signup", handler.Web(handleWebSignup, UserLevelNoneRequired)).Methods("POST")
 	write.HandleFunc("/auth/login", handler.Web(webLogin, UserLevelNoneRequired)).Methods("POST")
 
 	write.HandleFunc("/admin", handler.Admin(handleViewAdminDash)).Methods("GET")
 	write.HandleFunc("/admin/users", handler.Admin(handleViewAdminUsers)).Methods("GET")
 	write.HandleFunc("/admin/user/{username}", handler.Admin(handleViewAdminUser)).Methods("GET")
 	write.HandleFunc("/admin/pages", handler.Admin(handleViewAdminPages)).Methods("GET")
 	write.HandleFunc("/admin/page/{slug}", handler.Admin(handleViewAdminPage)).Methods("GET")
 	write.HandleFunc("/admin/update/config", handler.AdminApper(handleAdminUpdateConfig)).Methods("POST")
 	write.HandleFunc("/admin/update/{page}", handler.Admin(handleAdminUpdateSite)).Methods("POST")
 
 	// Handle special pages first
 	write.HandleFunc("/login", handler.Web(viewLogin, UserLevelNoneRequired))
 	write.HandleFunc("/signup", handler.Web(handleViewLanding, UserLevelNoneRequired))
 	write.HandleFunc("/invite/{code}", handler.Web(handleViewInvite, UserLevelOptional)).Methods("GET")
 	// TODO: show a reader-specific 404 page if the function is disabled
 	write.HandleFunc("/read", handler.Web(viewLocalTimeline, UserLevelReader))
 	RouteRead(handler, UserLevelReader, write.PathPrefix("/read").Subrouter())
 
 	draftEditPrefix := ""
 	if apper.App().cfg.App.SingleUser {
 		draftEditPrefix = "/d"
 		write.HandleFunc("/me/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
 	} else {
 		write.HandleFunc("/new", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
 	}
 
 	// All the existing stuff
 	write.HandleFunc(draftEditPrefix+"/{action}/edit", handler.Web(handleViewPad, UserLevelOptional)).Methods("GET")
 	write.HandleFunc(draftEditPrefix+"/{action}/meta", handler.Web(handleViewMeta, UserLevelOptional)).Methods("GET")
 	// Collections
 	if apper.App().cfg.App.SingleUser {
 		RouteCollections(handler, write.PathPrefix("/").Subrouter())
 	} else {
 		write.HandleFunc("/{prefix:[@~$!\\-+]}{collection}", handler.Web(handleViewCollection, UserLevelReader))
 		write.HandleFunc("/{collection}/", handler.Web(handleViewCollection, UserLevelReader))
 		RouteCollections(handler, write.PathPrefix("/{prefix:[@~$!\\-+]?}{collection}").Subrouter())
 		// Posts
 	}
 	write.HandleFunc(draftEditPrefix+"/{post}", handler.Web(handleViewPost, UserLevelOptional))
 	write.HandleFunc("/", handler.Web(handleViewHome, UserLevelOptional))
 	return r
 }
 
 func RouteCollections(handler *Handler, r *mux.Router) {
 	r.HandleFunc("/page/{page:[0-9]+}", handler.Web(handleViewCollection, UserLevelReader))
 	r.HandleFunc("/tag:{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
 	r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader))
 	r.HandleFunc("/tags/{tag}", handler.Web(handleViewCollectionTag, UserLevelReader))
 	r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap))
 	r.HandleFunc("/feed/", handler.AllReader(ViewFeed))
 	r.HandleFunc("/{slug}", handler.CollectionPostOrStatic)
 	r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser))
 	r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser))
 	r.HandleFunc("/{slug}/", handler.Web(handleCollectionPostRedirect, UserLevelReader)).Methods("GET")
 }
 
 func RouteRead(handler *Handler, readPerm UserLevelFunc, r *mux.Router) {
 	r.HandleFunc("/api/posts", handler.Web(viewLocalTimelineAPI, readPerm))
 	r.HandleFunc("/p/{page}", handler.Web(viewLocalTimeline, readPerm))
 	r.HandleFunc("/feed/", handler.Web(viewLocalTimelineFeed, readPerm))
 	r.HandleFunc("/t/{tag}", handler.Web(viewLocalTimeline, readPerm))
 	r.HandleFunc("/a/{post}", handler.Web(handlePostIDRedirect, readPerm))
 	r.HandleFunc("/{author}", handler.Web(viewLocalTimeline, readPerm))
 	r.HandleFunc("/", handler.Web(viewLocalTimeline, readPerm))
 }
diff --git a/templates/user/settings.tmpl b/templates/user/settings.tmpl
index fd204a5..cd45179 100644
--- a/templates/user/settings.tmpl
+++ b/templates/user/settings.tmpl
@@ -1,83 +1,95 @@
 {{define "settings"}}
 {{template "header" .}}
 
 <style type="text/css">
 .option { margin: 2em 0em; }
 h3 { font-weight: normal; }
 .section > *:not(input) { font-size: 0.86em; }
 </style>
 <div class="content-container snug regular">
 	<h2>{{if .IsLogOut}}Before you go...{{else}}Account Settings {{if .IsAdmin}}<a href="/admin">admin settings</a>{{end}}{{end}}</h2>
 	{{if .Flashes}}<ul class="errors">
 		{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
 	</ul>{{end}}
 
 	{{ if .IsLogOut }}
 	<div class="alert info">
 		<p class="introduction">Please add an <strong>email address</strong> and/or <strong>passphrase</strong> so you can log in again later.</p>
 	</div>
 	{{ else }}
 	<div class="option">
 		<p>Change your account settings here.</p>
 	</div>
 
 	<form method="post" action="/api/me/self" autocomplete="false">
 		<div class="option">
 			<h3>Username</h3>
 			<div class="section">
 				<input type="text" name="username" value="{{.Username}}" tabindex="1" />
 				<input type="submit" value="Update" style="margin-left: 1em;" />
 			</div>
 		</div>
 	</form>
 	{{ end }}
 
 	<form method="post" action="/api/me/self" autocomplete="false">
 		<input type="hidden" name="logout" value="{{.IsLogOut}}" />
 		<div class="option">
 			<h3>Passphrase</h3>
 			<div class="section">
 				{{if and (not .HasPass) (not .IsLogOut)}}<div class="alert info"><p>Add a passphrase to easily log in to your account.</p></div>{{end}}
 				{{if .HasPass}}<p>Current passphrase</p>
 				<input type="password" name="current-pass" placeholder="Current passphrase" tabindex="1" /> <input class="show" type="checkbox" id="show-cur-pass" /><label for="show-cur-pass"> Show</label>
 				<p>New passphrase</p>
 				{{end}}
 				{{if .IsLogOut}}<input type="text" value="{{.Username}}" style="display:none" />{{end}}
 				<input type="password" name="new-pass" autocomplete="new-password" placeholder="New passphrase" tabindex="{{if .IsLogOut}}1{{else}}2{{end}}" /> <input class="show" type="checkbox" id="show-new-pass" /><label for="show-new-pass"> Show</label>
 			</div>
 		</div>
 
 		<div class="option">
 			<h3>Email</h3>
 			<div class="section">
 				{{if and (not .Email) (not .IsLogOut)}}<div class="alert info"><p>Add your email to get:</p>
 				<ul>
 					<li>No-passphrase login</li>
 					<li>Account recovery if you forget your passphrase</li>
 				</ul></div>{{end}}
 				<input type="email" name="email" style="letter-spacing: 1px" placeholder="Email address" value="{{.Email}}" size="40" tabindex="{{if .IsLogOut}}2{{else}}3{{end}}" />
 			</div>
 		</div>
 
 		<div class="option" style="text-align: center; margin-top: 4em;">
 			<input type="submit" value="Save changes" tabindex="4" />
 		</div>
 	</form>
+
+	{{ if not .IsAdmin }}
+	<hr/>
+	<h2>Delete Account</h2>
+	<h3><strong>Danger Zone - This cannot be undone</strong></h3>
+	<p>This will delete your account and all your blogs and posts. Before continuing make sure to <a href="/me/export">export your data</a>.</p>
+	<form action="/me/delete" method="post">
+		<p>Type your username to confirm deletion.<p>
+		<input name="confirm-username" type="text" title="confirm username to delete" placeholder="confirm username">
+		<input class="danger" type="submit" value="DELETE">
+	</form>
+	{{end}}
 </div>
 
 <script>
 var showChecks = document.querySelectorAll('input.show');
 for (var i=0; i<showChecks.length; i++) {
 	showChecks[i].addEventListener('click', function() {
 		var prevEl = this.previousElementSibling;
 		if (prevEl.type == "password") {
 			prevEl.type = "text";
 		} else {
 			prevEl.type = "password";
 		}
 	});
 }
 </script>
 
 {{template "footer" .}}
 {{end}}