accounts and email

This commit is contained in:
2026-06-04 08:50:34 -04:00
parent 73d505ac7e
commit ddc7d5cbf5
46 changed files with 2685 additions and 309 deletions

View File

@@ -57,6 +57,11 @@ type changePasswordRequest struct {
NewPassword string `json:"newPassword"`
}
type passwordResetRequest struct {
Token string `json:"token"`
NewPassword string `json:"newPassword"`
}
type deleteAccountRequest struct {
CurrentPassword string `json:"currentPassword"`
}
@@ -79,14 +84,15 @@ func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, http.StatusInternalServerError, "load registration settings")
return
}
s.renderTemplate(w, http.StatusOK, "login.gohtml", layoutData{
s.renderTemplate(w, r, http.StatusOK, "login.gohtml", layoutData{
Title: "Sign in",
BodyClass: "page-auth",
BodyTemplate: "login_content",
Data: struct {
Next string
SignupsEnabled bool
}{Next: nextPath, SignupsEnabled: settings.SignupsEnabled},
DevMode bool
}{Next: nextPath, SignupsEnabled: settings.SignupsEnabled, DevMode: s.config.DevMode},
})
}
@@ -100,13 +106,24 @@ func (s *Server) handleSetupPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.renderTemplate(w, http.StatusOK, "setup.gohtml", layoutData{
s.renderTemplate(w, r, http.StatusOK, "setup.gohtml", layoutData{
Title: "Initial setup",
BodyClass: "page-auth",
BodyTemplate: "setup_content",
})
}
func (s *Server) handlePasswordResetPage(w http.ResponseWriter, r *http.Request) {
s.renderTemplate(w, r, http.StatusOK, "password_reset.gohtml", layoutData{
Title: "Reset password",
BodyClass: "page-auth",
BodyTemplate: "password_reset_content",
Data: struct {
Token string
}{Token: strings.TrimSpace(r.URL.Query().Get("token"))},
})
}
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
@@ -135,7 +152,7 @@ func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.renderTemplate(w, http.StatusOK, "account.gohtml", layoutData{
s.renderTemplate(w, r, http.StatusOK, "account.gohtml", layoutData{
Title: "Account",
BodyClass: "page-account",
BodyTemplate: "account_content",
@@ -143,6 +160,20 @@ func (s *Server) handleAccountPage(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) handleTokenCreatePage(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
s.renderTemplate(w, r, http.StatusOK, "token_create.gohtml", layoutData{
Title: "Create access token",
BodyClass: "page-account",
BodyTemplate: "token_create_content",
Data: principal,
})
}
func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request) {
principal, ok := requirePrincipal(r)
if !ok {
@@ -193,9 +224,26 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
_ = s.auth.RevokeSession(r.Context(), principal.SessionID)
}
clearSessionCookie(w)
if s.config.DevMode {
setDevLogoutCookie(w, r)
}
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
}
func (s *Server) handleDevLogin(w http.ResponseWriter, r *http.Request) {
if !s.config.DevMode || s.devUserID == "" {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "dev login is not available"})
return
}
principal, err := s.auth.PrincipalForUser(r.Context(), s.devUserID)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
clearDevLogoutCookie(w)
writeJSON(w, http.StatusOK, map[string]any{"principal": principal})
}
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
@@ -213,6 +261,22 @@ func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "password_changed"})
}
func (s *Server) handlePasswordReset(w http.ResponseWriter, r *http.Request) {
if !s.allowAuthAttempt(w, r) {
return
}
var req passwordResetRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
if err := s.auth.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset"})
}
func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if !requireSessionPrincipal(w, principal) {
@@ -425,7 +489,7 @@ func (s *Server) handleDeviceVerify(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleDeviceVerifyPage(w http.ResponseWriter, r *http.Request) {
userCode := strings.TrimSpace(r.URL.Query().Get("user_code"))
s.renderTemplate(w, http.StatusOK, "device_verify.gohtml", layoutData{
s.renderTemplate(w, r, http.StatusOK, "device_verify.gohtml", layoutData{
Title: "Approve device",
BodyClass: "page-auth",
BodyTemplate: "device_verify_content",
@@ -464,6 +528,36 @@ func (s *Server) handleAdminAuthUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"users": public})
}
func (s *Server) handleAdminUpdateAuthUsers(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
var req struct {
Users []auth.UserAccessUpdate `json:"users"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
users, err := s.auth.UpdateUserAccess(r.Context(), principal, req.Users)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
public := make([]map[string]any, 0, len(users))
for _, user := range users {
public = append(public, publicUser(user))
}
writeJSON(w, http.StatusOK, map[string]any{"users": public})
}
func (s *Server) handleAdminSendPasswordReset(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if err := s.auth.SendPasswordReset(r.Context(), principal, chi.URLParam(r, "id")); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "password_reset_sent"})
}
func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool {
if s.authLimiter.Allow(authRateLimitKey(r)) {
return true
@@ -473,6 +567,7 @@ func (s *Server) allowAuthAttempt(w http.ResponseWriter, r *http.Request) bool {
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expires time.Time) {
clearDevLogoutCookie(w)
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
Value: token,
@@ -484,6 +579,29 @@ func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expi
})
}
func setDevLogoutCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: devLogoutCookieName,
Value: "1",
Path: "/",
MaxAge: int((24 * time.Hour).Seconds()),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: isSecureRequest(r),
})
}
func clearDevLogoutCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: devLogoutCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: auth.SessionCookieName,
@@ -501,6 +619,7 @@ func publicUser(user auth.User) map[string]any {
"email": user.Email,
"displayName": user.DisplayName,
"role": user.Role,
"disabled": user.Disabled,
"createdAt": user.CreatedAt,
"lastSeenAt": user.LastSeenAt,
}