auth-ui: rewrite ui

Change-Id: I6f00867015ec77aa7e336e89da4dc1b081e330c6
diff --git a/core/auth/ui/main.go b/core/auth/ui/main.go
index 55dc963..244a9b0 100644
--- a/core/auth/ui/main.go
+++ b/core/auth/ui/main.go
@@ -15,6 +15,7 @@
 	"net/http"
 	"net/http/cookiejar"
 	"net/url"
+	"time"
 
 	"github.com/gorilla/mux"
 	"github.com/itaysk/regogo"
@@ -41,9 +42,9 @@
 	WhoAmI                *template.Template
 	Register              *template.Template
 	Login                 *template.Template
-	Consent               *template.Template
 	ChangePassword        *template.Template
 	ChangePasswordSuccess *template.Template
+	Error                 *template.Template
 }
 
 func ParseTemplates(fs embed.FS) (*Templates, error) {
@@ -70,10 +71,6 @@
 	if err != nil {
 		return nil, err
 	}
-	consent, err := parse("templates/consent.html")
-	if err != nil {
-		return nil, err
-	}
 	changePassword, err := parse("templates/change-password.html")
 	if err != nil {
 		return nil, err
@@ -82,7 +79,83 @@
 	if err != nil {
 		return nil, err
 	}
-	return &Templates{whoami, register, login, consent, changePassword, changePasswordSuccess}, nil
+	errorPage, err := parse("templates/error.html")
+	if err != nil {
+		return nil, err
+	}
+	return &Templates{whoami, register, login, changePassword, changePasswordSuccess, errorPage}, nil
+}
+
+const (
+	invalidLoginMessage              = "Username or password is incorrect."
+	duplicateRegistrationMessage     = "Username is not available."
+	expiredFlowMessage               = "This form expired. Please try again."
+	registrationRejectedMessage      = "Registration could not be completed. Please review your details and try again."
+	passwordChangeRejectedMessage    = "Password could not be changed. Please choose a different password and try again."
+	authenticationUnavailableMessage = "Authentication service is temporarily unavailable. Please try again."
+
+	authNoticeCookieName   = "auth_ui_notice"
+	authNoticeLoginInvalid = "login_invalid"
+	authNoticeFlowExpired  = "flow_expired"
+)
+
+var errFlowExpired = errors.New("self-service flow expired")
+
+type LoginPageData struct {
+	FormAction         string
+	CSRFToken          string
+	EnableRegistration bool
+	GeneralNotice      string
+}
+
+type RegisterPageData struct {
+	FormAction     string
+	CSRFToken      string
+	Username       string
+	UsernameErrors []ValidationError
+	PasswordErrors []ValidationError
+	GeneralError   string
+}
+
+type ChangePasswordPageData struct {
+	Username       string
+	CSRFToken      string
+	FormAction     string
+	PasswordErrors []ValidationError
+	GeneralError   string
+}
+
+type AccountPageData struct {
+	Username string
+}
+
+type ErrorPageData struct {
+	Title        string
+	Message      string
+	Status       int
+	RecoveryHref string
+	RecoveryText string
+}
+
+type oryFlowResponse struct {
+	ID string `json:"id"`
+	UI struct {
+		Nodes []struct {
+			Attributes struct {
+				Name  string          `json:"name"`
+				Value json.RawMessage `json:"value"`
+			} `json:"attributes"`
+		} `json:"nodes"`
+		Messages []struct {
+			ID int64 `json:"id"`
+		} `json:"messages"`
+	} `json:"ui"`
+}
+
+type oryErrorResponse struct {
+	Error struct {
+		ID string `json:"id"`
+	} `json:"error"`
 }
 
 type Server struct {
@@ -113,6 +186,153 @@
 	return &Server{r, serv, kratos, hydra, tmpls, enableRegistration, api, defaultReturnTo}
 }
 
+func executeTemplate(tmpl *template.Template, data any) ([]byte, error) {
+	var page bytes.Buffer
+	if err := tmpl.Execute(&page, data); err != nil {
+		return nil, err
+	}
+	return page.Bytes(), nil
+}
+
+func writeTemplate(w http.ResponseWriter, status int, page []byte) {
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	w.WriteHeader(status)
+	_, _ = w.Write(page)
+}
+
+func renderTemplate(w http.ResponseWriter, tmpl *template.Template, status int, data any) {
+	page, err := executeTemplate(tmpl, data)
+	if err != nil {
+		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+		return
+	}
+	writeTemplate(w, status, page)
+}
+
+func (s *Server) renderDependencyError(w http.ResponseWriter, context string) {
+	data := ErrorPageData{Message: authenticationUnavailableMessage, Status: http.StatusBadGateway}
+	switch context {
+	case "login":
+		data.Title = "Authentication unavailable"
+		data.RecoveryHref = "/login"
+		data.RecoveryText = "Try signing in again"
+	case "registration":
+		data.Title = "Registration unavailable"
+		if s.enableRegistration {
+			data.RecoveryHref = "/register"
+			data.RecoveryText = "Try registration again"
+		} else {
+			data.RecoveryHref = "/login"
+			data.RecoveryText = "Go to sign in"
+		}
+	default:
+		data.Title = "Account unavailable"
+		data.RecoveryHref = "/"
+		data.RecoveryText = "Back to account"
+	}
+	renderTemplate(w, s.tmpls.Error, data.Status, data)
+}
+
+func setAuthNotice(w http.ResponseWriter, code string) {
+	if code != authNoticeLoginInvalid && code != authNoticeFlowExpired {
+		return
+	}
+	http.SetCookie(w, &http.Cookie{
+		Name:     authNoticeCookieName,
+		Value:    code,
+		Path:     "/",
+		MaxAge:   120,
+		Expires:  time.Now().Add(2 * time.Minute),
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+	})
+}
+
+func clearAuthNotice(w http.ResponseWriter) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     authNoticeCookieName,
+		Value:    "",
+		Path:     "/",
+		MaxAge:   -1,
+		HttpOnly: true,
+		SameSite: http.SameSiteLaxMode,
+	})
+}
+
+func pendingAuthNotice(r *http.Request, accepted ...string) (code string, clear bool) {
+	cookie, err := r.Cookie(authNoticeCookieName)
+	if err != nil {
+		return "", false
+	}
+	for _, acceptedCode := range accepted {
+		if cookie.Value == acceptedCode {
+			return cookie.Value, true
+		}
+	}
+	if cookie.Value != authNoticeLoginInvalid && cookie.Value != authNoticeFlowExpired {
+		return "", true
+	}
+	return "", false
+}
+
+func parseOryErrorID(body []byte) string {
+	var response oryErrorResponse
+	if json.Unmarshal(body, &response) != nil {
+		return ""
+	}
+	return response.Error.ID
+}
+
+func parseRetryFlow(body []byte) (flowID, csrfToken string, duplicate bool, err error) {
+	var response oryFlowResponse
+	if json.Unmarshal(body, &response) != nil || response.ID == "" {
+		return "", "", false, errors.New("invalid retry flow")
+	}
+	csrfCount := 0
+	for _, node := range response.UI.Nodes {
+		if node.Attributes.Name != "csrf_token" {
+			continue
+		}
+		csrfCount++
+		if json.Unmarshal(node.Attributes.Value, &csrfToken) != nil || csrfToken == "" {
+			return "", "", false, errors.New("invalid retry csrf token")
+		}
+	}
+	if csrfCount != 1 {
+		return "", "", false, errors.New("invalid retry csrf token count")
+	}
+	for _, message := range response.UI.Messages {
+		if message.ID == 4000007 {
+			duplicate = true
+		}
+	}
+	return response.ID, csrfToken, duplicate, nil
+}
+
+func readResponseBody(resp *http.Response) ([]byte, error) {
+	defer resp.Body.Close()
+	return ioutil.ReadAll(resp.Body)
+}
+
+func localFlowAction(path, flow string) string {
+	return path + "?flow=" + url.QueryEscape(flow)
+}
+
+func (s *Server) restartFlow(w http.ResponseWriter, r *http.Request, flowType string) {
+	setAuthNotice(w, authNoticeFlowExpired)
+	addr := s.kratos + "/self-service/" + flowType + "/browser"
+	if flowType == "login" {
+		returnTo := r.FormValue("return_to")
+		if returnTo == "" && s.defaultReturnTo != "" {
+			returnTo = s.defaultReturnTo
+		}
+		if returnTo != "" {
+			addr += fmt.Sprintf("?return_to=%s", returnTo)
+		}
+	}
+	http.Redirect(w, r, addr, http.StatusSeeOther)
+}
+
 func cacheControlWrapper(h http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		// TODO(giolekva): enable caching
@@ -160,37 +380,60 @@
 	if err != nil {
 		return "", err
 	}
-	respBody, err := ioutil.ReadAll(resp.Body)
+	respBody, err := readResponseBody(resp)
 	if err != nil {
 		return "", err
 	}
-	token, err := regogo.Get(string(respBody), "input.ui.nodes[0].attributes.value")
+	if resp.StatusCode == http.StatusGone && parseOryErrorID(respBody) == "self_service_flow_expired" {
+		return "", errFlowExpired
+	}
+	if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+		return "", errors.New("flow fetch failed")
+	}
+	_, token, _, err := parseRetryFlow(respBody)
 	if err != nil {
 		return "", err
 	}
-	return token.String(), nil
+	return token, nil
 }
 
 func (s *Server) registerInitiate(w http.ResponseWriter, r *http.Request) {
 	if err := r.ParseForm(); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		http.Error(w, "Bad request.", http.StatusBadRequest)
 		return
 	}
-	flow, ok := r.Form["flow"]
-	if !ok {
+	flow := r.FormValue("flow")
+	if flow == "" {
 		http.Redirect(w, r, s.kratos+"/self-service/registration/browser", http.StatusSeeOther)
 		return
 	}
-	csrfToken, err := getCSRFToken("registration", flow[0], r.Cookies())
+	csrfToken, err := getCSRFToken("registration", flow, r.Cookies())
+	if errors.Is(err, errFlowExpired) {
+		s.restartFlow(w, r, "registration")
+		return
+	}
 	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		s.renderDependencyError(w, "registration")
 		return
 	}
-	w.Header().Set("Content-Type", "text/html")
-	if err := s.tmpls.Register.Execute(w, csrfToken); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+	notice := ""
+	noticeCode, clearNotice := pendingAuthNotice(r, authNoticeFlowExpired)
+	if noticeCode == authNoticeFlowExpired {
+		notice = expiredFlowMessage
+	}
+	page, err := executeTemplate(s.tmpls.Register, RegisterPageData{
+		FormAction:   localFlowAction(r.URL.Path, flow),
+		CSRFToken:    csrfToken,
+		GeneralError: notice,
+	})
+	if err != nil {
+		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
 		return
 	}
+	if clearNotice {
+		clearAuthNotice(w)
+	}
+	writeTemplate(w, http.StatusOK, page)
 }
 
 type regReq struct {
@@ -206,36 +449,85 @@
 
 func (s *Server) register(w http.ResponseWriter, r *http.Request) {
 	if err := r.ParseForm(); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		http.Error(w, "Bad request.", http.StatusBadRequest)
 		return
 	}
-	flow, ok := r.Form["flow"]
-	if !ok {
+	flow := r.FormValue("flow")
+	if flow == "" {
 		http.Redirect(w, r, s.kratos+"/self-service/registration/browser", http.StatusSeeOther)
 		return
 	}
+	username := r.FormValue("username")
+	password := r.FormValue("password")
+	usernameErrors := validateUsername(username)
+	passwordErrors := validatePassword(password)
+	if len(usernameErrors)+len(passwordErrors) > 0 {
+		renderTemplate(w, s.tmpls.Register, http.StatusUnprocessableEntity, RegisterPageData{
+			FormAction:     localFlowAction(r.URL.Path, flow),
+			CSRFToken:      r.FormValue("csrf_token"),
+			Username:       username,
+			UsernameErrors: usernameErrors,
+			PasswordErrors: passwordErrors,
+		})
+		return
+	}
 	req := regReq{
 		CSRFToken: r.FormValue("csrf_token"),
 		Method:    "password",
-		Password:  r.FormValue("password"),
+		Password:  password,
 		Traits: regReqTraits{
-			Username: r.FormValue("username"),
+			Username: username,
 		},
 	}
 	var reqBody bytes.Buffer
 	if err := json.NewEncoder(&reqBody).Encode(req); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		s.renderDependencyError(w, "registration")
 		return
 	}
-	if resp, err := postToKratos("registration", flow[0], r.Cookies(), &reqBody); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+	resp, err := postToKratos("registration", flow, r.Cookies(), &reqBody)
+	if err != nil {
+		s.renderDependencyError(w, "registration")
 		return
-	} else {
-		for _, c := range resp.Cookies() {
-			http.SetCookie(w, c)
-		}
+	}
+	for _, cookie := range resp.Cookies() {
+		http.SetCookie(w, cookie)
+	}
+	if resp.StatusCode < http.StatusBadRequest {
+		resp.Body.Close()
 		http.Redirect(w, r, "/", http.StatusSeeOther)
+		return
 	}
+	body, err := readResponseBody(resp)
+	if err != nil {
+		s.renderDependencyError(w, "registration")
+		return
+	}
+	if (resp.StatusCode == http.StatusGone && parseOryErrorID(body) == "self_service_flow_expired") ||
+		(resp.StatusCode == http.StatusForbidden && parseOryErrorID(body) == "security_csrf_violation") {
+		s.restartFlow(w, r, "registration")
+		return
+	}
+	if resp.StatusCode != http.StatusBadRequest {
+		s.renderDependencyError(w, "registration")
+		return
+	}
+	retryFlow, retryCSRF, duplicate, err := parseRetryFlow(body)
+	if err != nil {
+		s.renderDependencyError(w, "registration")
+		return
+	}
+	status := http.StatusUnprocessableEntity
+	generalError := registrationRejectedMessage
+	if duplicate {
+		status = http.StatusConflict
+		generalError = duplicateRegistrationMessage
+	}
+	renderTemplate(w, s.tmpls.Register, status, RegisterPageData{
+		FormAction:   localFlowAction(r.URL.Path, retryFlow),
+		CSRFToken:    retryCSRF,
+		Username:     username,
+		GeneralError: generalError,
+	})
 }
 
 // Login flow
@@ -253,7 +545,7 @@
 
 func (s *Server) loginInitiate(w http.ResponseWriter, r *http.Request) {
 	if err := r.ParseForm(); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		http.Error(w, "Bad request.", http.StatusBadRequest)
 		return
 	}
 	challenge, hasChallenge := r.Form["login_challenge"]
@@ -264,13 +556,13 @@
 	if hasChallenge {
 		_, username, err := getWhoAmIFromKratos(r.Cookies())
 		if err != nil && err != ErrNotLoggedIn {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
+			s.renderDependencyError(w, "login")
 			return
 		}
 		if err == nil {
 			redirectTo, err := s.hydra.LoginAcceptChallenge(challenge[0], username)
 			if err != nil {
-				http.Error(w, err.Error(), http.StatusInternalServerError)
+				s.renderDependencyError(w, "login")
 				return
 			}
 			clearLoginChallengeCookie(w)
@@ -299,18 +591,36 @@
 		return
 	}
 	csrfToken, err := getCSRFToken("login", flow[0], r.Cookies())
+	if errors.Is(err, errFlowExpired) {
+		s.restartFlow(w, r, "login")
+		return
+	}
 	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		s.renderDependencyError(w, "login")
 		return
 	}
-	w.Header().Set("Content-Type", "text/html")
-	if err := s.tmpls.Login.Execute(w, map[string]any{
-		"csrfToken":          csrfToken,
-		"enableRegistration": s.enableRegistration,
-	}); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+	notice := ""
+	noticeCode, clearNotice := pendingAuthNotice(r, authNoticeLoginInvalid, authNoticeFlowExpired)
+	switch noticeCode {
+	case authNoticeLoginInvalid:
+		notice = invalidLoginMessage
+	case authNoticeFlowExpired:
+		notice = expiredFlowMessage
+	}
+	page, err := executeTemplate(s.tmpls.Login, LoginPageData{
+		FormAction:         localFlowAction(r.URL.Path, flow[0]),
+		CSRFToken:          csrfToken,
+		EnableRegistration: s.enableRegistration,
+		GeneralNotice:      notice,
+	})
+	if err != nil {
+		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
 		return
 	}
+	if clearNotice {
+		clearAuthNotice(w)
+	}
+	writeTemplate(w, http.StatusOK, page)
 }
 
 type loginReq struct {
@@ -442,72 +752,65 @@
 
 }
 
-func extractError(r io.Reader) error {
-	respBody, err := ioutil.ReadAll(r)
-	if err != nil {
-		return err
+func isRejectedLoginRedirect(resp *http.Response) bool {
+	if resp.StatusCode != http.StatusSeeOther {
+		return false
 	}
-	fmt.Printf("++ %s\n", respBody)
-	t, err := regogo.Get(string(respBody), "input.ui.messages[0].type")
-	if err != nil {
-		return err
-	}
-	if t.String() == "error" {
-		message, err := regogo.Get(string(respBody), "input.ui.messages[0].text")
-		if err != nil {
-			return err
-		}
-		return errors.New(message.String())
-	}
-	return nil
+	location, err := url.Parse(resp.Header.Get("Location"))
+	return err == nil && location.Path == "/login" && location.Query().Get("flow") != ""
 }
 
 func (s *Server) login(w http.ResponseWriter, r *http.Request) {
 	if err := r.ParseForm(); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		http.Error(w, "Bad request.", http.StatusBadRequest)
 		return
 	}
-	flow, ok := r.Form["flow"]
-	if !ok {
+	flow := r.FormValue("flow")
+	if flow == "" {
 		http.Redirect(w, r, s.kratos+"/self-service/login/browser", http.StatusSeeOther)
 		return
 	}
 	req := url.Values{
-		"csrf_token": []string{r.FormValue("csrf_token")},
-		"method":     []string{"password"},
-		"password":   []string{r.FormValue("password")},
-		"identifier": []string{r.FormValue("username")},
+		"csrf_token": {r.FormValue("csrf_token")},
+		"method":     {"password"},
+		"password":   {r.FormValue("password")},
+		"identifier": {r.FormValue("username")},
 	}
-	resp, err := postFormToKratos("login", flow[0], r.Cookies(), req)
-	var vv bytes.Buffer
-	io.Copy(&vv, resp.Body)
-	fmt.Println(vv.String())
+	resp, err := postFormToKratos("login", flow, r.Cookies(), req)
 	if err != nil {
-		if challenge, _ := r.Cookie("login_challenge"); challenge != nil {
-			redirectTo, err := s.hydra.LoginRejectChallenge(challenge.Value, err.Error())
-			if err != nil {
-				http.Error(w, err.Error(), http.StatusInternalServerError)
-				return
-			}
-			clearLoginChallengeCookie(w)
-			http.Redirect(w, r, redirectTo, http.StatusSeeOther)
-			return
-		}
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		s.renderDependencyError(w, "login")
 		return
 	}
-	for _, c := range resp.Cookies() {
-		http.SetCookie(w, c)
+	defer resp.Body.Close()
+	var responseBody bytes.Buffer
+	_, _ = io.Copy(&responseBody, resp.Body)
+	fmt.Println(responseBody.String())
+	for _, cookie := range resp.Cookies() {
+		http.SetCookie(w, cookie)
+	}
+	if (resp.StatusCode == http.StatusGone && parseOryErrorID(responseBody.Bytes()) == "self_service_flow_expired") ||
+		(resp.StatusCode == http.StatusForbidden && parseOryErrorID(responseBody.Bytes()) == "security_csrf_violation") {
+		s.restartFlow(w, r, "login")
+		return
+	}
+	if isRejectedLoginRedirect(resp) {
+		setAuthNotice(w, authNoticeLoginInvalid)
+		http.Redirect(w, r, resp.Header.Get("Location"), http.StatusSeeOther)
+		return
+	}
+	if resp.StatusCode != http.StatusSeeOther {
+		s.renderDependencyError(w, "login")
+		return
 	}
 	if challenge, _ := r.Cookie("login_challenge"); challenge != nil {
 		_, username, err := getWhoAmIFromKratos(resp.Cookies())
 		if err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
+			s.renderDependencyError(w, "login")
 			return
 		}
 		redirectTo, err := s.hydra.LoginAcceptChallenge(challenge.Value, username)
 		if err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
+			s.renderDependencyError(w, "login")
 			return
 		}
 		clearLoginChallengeCookie(w)
@@ -523,7 +826,7 @@
 
 func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
 	if logoutURL, err := getLogoutURLFromKratos(r.Cookies()); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		s.renderDependencyError(w, "account")
 		return
 	} else {
 		http.Redirect(w, r, logoutURL, http.StatusSeeOther)
@@ -536,11 +839,9 @@
 			http.Redirect(w, r, "/login", http.StatusSeeOther)
 			return
 		}
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		s.renderDependencyError(w, "account")
 	} else {
-		if err := s.tmpls.WhoAmI.Execute(w, username); err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-		}
+		renderTemplate(w, s.tmpls.WhoAmI, http.StatusOK, AccountPageData{Username: username})
 	}
 }
 
@@ -576,11 +877,6 @@
 	} else {
 		http.Redirect(w, r, redirectTo, http.StatusSeeOther)
 	}
-	// w.Header().Set("Content-Type", "text/html")
-	// if err := s.tmpls.Consent.Execute(w, consent.RequestedScopes); err != nil {
-	// 	http.Error(w, err.Error(), http.StatusInternalServerError)
-	// 	return
-	// }
 }
 
 func (s *Server) processConsent(w http.ResponseWriter, r *http.Request) {
@@ -610,14 +906,6 @@
 	}
 }
 
-type changePasswordData struct {
-	Username       string
-	Password       string
-	CSRFToken      string
-	FormAction     string
-	PasswordErrors []ValidationError
-}
-
 func (s *Server) changePasswordForm(w http.ResponseWriter, r *http.Request) {
 	flow := r.FormValue("flow")
 	if flow == "" {
@@ -626,23 +914,42 @@
 	}
 	_, username, err := getWhoAmIFromKratos(r.Cookies())
 	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		s.renderDependencyError(w, "account")
 		return
 	}
 	csrfToken, err := getCSRFToken("settings", flow, r.Cookies())
+	if errors.Is(err, errFlowExpired) {
+		s.restartFlow(w, r, "settings")
+		return
+	}
 	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		s.renderDependencyError(w, "account")
 		return
 	}
-	if err := s.tmpls.ChangePassword.Execute(w, changePasswordData{Username: username, CSRFToken: csrfToken, FormAction: r.URL.Path + "?flow=" + url.QueryEscape(flow)}); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+	notice := ""
+	noticeCode, clearNotice := pendingAuthNotice(r, authNoticeFlowExpired)
+	if noticeCode == authNoticeFlowExpired {
+		notice = expiredFlowMessage
+	}
+	page, err := executeTemplate(s.tmpls.ChangePassword, ChangePasswordPageData{
+		Username:     username,
+		CSRFToken:    csrfToken,
+		FormAction:   localFlowAction(r.URL.Path, flow),
+		GeneralError: notice,
+	})
+	if err != nil {
+		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
 		return
 	}
+	if clearNotice {
+		clearAuthNotice(w)
+	}
+	writeTemplate(w, http.StatusOK, page)
 }
 
 func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
 	if err := r.ParseForm(); err != nil {
-		http.Error(w, err.Error(), http.StatusBadRequest)
+		http.Error(w, "Bad request.", http.StatusBadRequest)
 		return
 	}
 	flow := r.FormValue("flow")
@@ -653,13 +960,16 @@
 	password := r.FormValue("password")
 	_, username, err := getWhoAmIFromKratos(r.Cookies())
 	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		s.renderDependencyError(w, "account")
 		return
 	}
-	if verr := validatePassword(password); len(verr) > 0 {
-		if err := s.tmpls.ChangePassword.Execute(w, changePasswordData{Username: username, Password: password, CSRFToken: r.FormValue("csrf_token"), FormAction: r.URL.Path + "?flow=" + url.QueryEscape(flow), PasswordErrors: verr}); err != nil {
-			http.Error(w, err.Error(), http.StatusInternalServerError)
-		}
+	if passwordErrors := validatePassword(password); len(passwordErrors) > 0 {
+		renderTemplate(w, s.tmpls.ChangePassword, http.StatusUnprocessableEntity, ChangePasswordPageData{
+			Username:       username,
+			CSRFToken:      r.FormValue("csrf_token"),
+			FormAction:     localFlowAction(r.URL.Path, flow),
+			PasswordErrors: passwordErrors,
+		})
 		return
 	}
 	resp, err := postFormToKratos("settings", flow, r.Cookies(), url.Values{
@@ -668,24 +978,39 @@
 		"password":   {password},
 	})
 	if err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+		s.renderDependencyError(w, "account")
 		return
 	}
-	defer resp.Body.Close()
-	if resp.StatusCode >= http.StatusBadRequest {
-		if err := extractError(resp.Body); err != nil {
-			if renderErr := s.tmpls.ChangePassword.Execute(w, changePasswordData{Username: username, Password: password, CSRFToken: r.FormValue("csrf_token"), FormAction: r.URL.Path + "?flow=" + url.QueryEscape(flow), PasswordErrors: []ValidationError{{Field: "password", Message: err.Error()}}}); renderErr != nil {
-				http.Error(w, renderErr.Error(), http.StatusInternalServerError)
-			}
-			return
-		}
-		http.Error(w, "password change failed", resp.StatusCode)
+	if resp.StatusCode < http.StatusBadRequest {
+		resp.Body.Close()
+		renderTemplate(w, s.tmpls.ChangePasswordSuccess, http.StatusOK, nil)
 		return
 	}
-	if err := s.tmpls.ChangePasswordSuccess.Execute(w, nil); err != nil {
-		http.Error(w, err.Error(), http.StatusInternalServerError)
+	body, err := readResponseBody(resp)
+	if err != nil {
+		s.renderDependencyError(w, "account")
 		return
 	}
+	if (resp.StatusCode == http.StatusGone && parseOryErrorID(body) == "self_service_flow_expired") ||
+		(resp.StatusCode == http.StatusForbidden && parseOryErrorID(body) == "security_csrf_violation") {
+		s.restartFlow(w, r, "settings")
+		return
+	}
+	if resp.StatusCode != http.StatusBadRequest {
+		s.renderDependencyError(w, "account")
+		return
+	}
+	retryFlow, retryCSRF, _, err := parseRetryFlow(body)
+	if err != nil {
+		s.renderDependencyError(w, "account")
+		return
+	}
+	renderTemplate(w, s.tmpls.ChangePassword, http.StatusUnprocessableEntity, ChangePasswordPageData{
+		Username:     username,
+		CSRFToken:    retryCSRF,
+		FormAction:   localFlowAction(r.URL.Path, retryFlow),
+		GeneralError: passwordChangeRejectedMessage,
+	})
 }
 
 func main() {