Gitfed
bastien-mrq/gitfed / cmd / gitfed-web / handlers_settings.go
package main

import (
	"bytes"
	"html/template"
	"net/http"
	"strings"

	"git.neuromancer.ovh/bastien-mrq/gitfed/internal/i18n"
)

var settingsTpl = newTpl("settings", `
{{.Flash}}
<div class="gf-page-head"><h1>{{t .Lang "settings.title"}}</h1></div>

<div class="gf-tabs">
  <button type="button" data-tab="tab-profile" class="{{if eq .ActiveTab "profile"}}active{{end}}">{{icon "user"}} {{t .Lang "settings.tab_profile"}}</button>
  <button type="button" data-tab="tab-keys" class="{{if eq .ActiveTab "keys"}}active{{end}}">{{icon "key"}} {{t .Lang "settings.tab_keys"}}</button>
  <button type="button" data-tab="tab-password" class="{{if eq .ActiveTab "password"}}active{{end}}">{{icon "lock"}} {{t .Lang "settings.tab_password"}}</button>
  <button type="button" data-tab="tab-preferences" class="{{if eq .ActiveTab "preferences"}}active{{end}}">{{icon "sliders"}} {{t .Lang "settings.tab_preferences"}}</button>
</div>

<div id="tab-profile" class="gf-tabpane {{if eq .ActiveTab "profile"}}active{{end}}">
  <div class="gf-card gf-profile-card">
    <div class="avatar">{{.Initials}}</div>
    <div>
      <div class="who">{{.Username}}{{if .IsAdmin}} — <span class="admin-tag">{{t .Lang "role.admin"}}</span>{{end}}</div>
      <div class="principal">{{.Principal}}</div>
    </div>
  </div>
  <p class="muted">{{t .Lang "settings.profile_note"}}</p>

  <form class="card" method="post" action="/settings/profile">
    <strong>{{t .Lang "settings.bio_title"}}</strong>
    <label>{{t .Lang "settings.bio_label"}}</label>
    <textarea name="bio" rows="8" maxlength="4000" placeholder="{{t .Lang "settings.bio_placeholder"}}">{{.Bio}}</textarea>
    <button type="submit">{{t .Lang "repo.save"}}</button>
  </form>
  <p class="muted">{{t .Lang "settings.public_profile_hint"}} <a href="/u/{{.Username}}">{{t .Lang "settings.view_public_profile"}}</a></p>
</div>

<div id="tab-keys" class="gf-tabpane {{if eq .ActiveTab "keys"}}active{{end}}">
  <div class="gf-card">
  {{range .Keys}}
    <div class="gf-list-row">
      <div class="gf-list-icon">{{icon "key"}}</div>
      <div class="gf-list-main">
        <div class="primary">{{.Short}}</div>
        <div class="meta">{{.Algo}}</div>
      </div>
      <form class="inline" method="post" action="/settings/keys/remove" data-confirm="{{t $.Lang "settings.confirm_remove_key"}}">
        <input type="hidden" name="key" value="{{.Full}}">
        <button class="danger" type="submit">{{t $.Lang "settings.remove"}}</button>
      </form>
    </div>
  {{else}}
    <div class="gf-list-row"><span class="muted">{{t .Lang "settings.no_keys"}}</span></div>
  {{end}}
  </div>

  <form class="card" method="post" action="/settings/keys/add">
    <strong>{{t .Lang "settings.add_key"}}</strong>
    <label>{{t .Lang "settings.pubkey_label"}}</label>
    <input name="pubkey" required placeholder="ssh-ed25519 AAAA... comment">
    <button type="submit">{{t .Lang "settings.add"}}</button>
  </form>
</div>

<div id="tab-password" class="gf-tabpane {{if eq .ActiveTab "password"}}active{{end}}">
  <form class="card" method="post" action="/settings/password">
    <strong>{{t .Lang "settings.change_password"}}</strong>
    <label>{{t .Lang "settings.current_password"}}</label>
    <input name="old_password" type="password" required>
    <label>{{t .Lang "settings.new_password"}}</label>
    <input name="new_password" type="password" required minlength="8">
    <button type="submit">{{t .Lang "settings.change_password"}}</button>
  </form>
</div>

<div id="tab-preferences" class="gf-tabpane {{if eq .ActiveTab "preferences"}}active{{end}}">
  <div class="gf-card" style="padding: 1.3rem;">
    <strong>{{t .Lang "settings.theme_title"}}</strong>
    <p class="muted" style="margin: 0.3rem 0 0.9rem;">{{t .Lang "settings.theme_hint"}}</p>
    <div class="gf-lang">
      <a href="/theme/system?next=%2Fsettings%3Ftab%3Dpreferences"{{if not .Theme}} class="active"{{end}}>{{t .Lang "theme.system"}}</a>
      <a href="/theme/light?next=%2Fsettings%3Ftab%3Dpreferences"{{if eq .Theme "light"}} class="active"{{end}}>{{t .Lang "theme.light"}}</a>
      <a href="/theme/dark?next=%2Fsettings%3Ftab%3Dpreferences"{{if eq .Theme "dark"}} class="active"{{end}}>{{t .Lang "theme.dark"}}</a>
    </div>
  </div>
</div>
`)

type keyView struct{ Short, Full, Algo string }

func settingsTab(r *http.Request) string {
	switch r.URL.Query().Get("tab") {
	case "keys":
		return "keys"
	case "password":
		return "password"
	case "preferences":
		return "preferences"
	default:
		return "profile"
	}
}

func (s *server) handleSettings(w http.ResponseWriter, r *http.Request) {
	sess, _ := s.currentSession(r)
	lang := s.lang(r)
	user, err := s.ops.GetUser(sess.Username)
	if err != nil {
		s.serverError(w, r, err)
		return
	}

	keys := make([]keyView, len(user.PubKeys))
	for i, k := range user.PubKeys {
		algo, _, ok := strings.Cut(k, " ")
		if !ok {
			algo = "?"
		}
		short := k
		if len(short) > 60 {
			short = short[:60] + "…"
		}
		keys[i] = keyView{Short: short, Full: k, Algo: algo}
	}

	var buf bytes.Buffer
	_ = settingsTpl.Execute(&buf, struct {
		Username, Principal, ActiveTab, Lang, Theme, Initials, Bio string
		IsAdmin                                                    bool
		Keys                                                       []keyView
		Flash                                                      template.HTML
	}{sess.Username, sess.Principal, settingsTab(r), string(lang), s.theme(r), initials(sess.Username), user.Bio, sess.IsAdmin, keys, flash(r)})
	s.render(w, r, i18n.T(lang, "settings.title"), "settings", template.HTML(buf.String()))
}

// bioMaxLen matches mrTextMaxLen (handlers_merge_requests.go) — same
// free-text-field budget used throughout the app. The HTML maxlength on the
// textarea is a UX hint only; this is the real enforcement, since a direct
// POST bypasses the former.
const bioMaxLen = 4000

func (s *server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
	sess, _ := s.currentSession(r)
	lang := s.lang(r)
	bio := r.FormValue("bio")
	if len(bio) > bioMaxLen {
		redirectWithMsg(w, r, "/settings?tab=profile", i18n.T(lang, "settings.msg_bio_too_long"), true)
		return
	}
	if err := s.ops.SetUserBio(sess.Username, bio); err != nil {
		redirectWithMsg(w, r, "/settings?tab=profile", err.Error(), true)
		return
	}
	redirectWithMsg(w, r, "/settings?tab=profile", i18n.T(lang, "settings.msg_bio_saved"), false)
}

func (s *server) handleAddOwnKey(w http.ResponseWriter, r *http.Request) {
	sess, _ := s.currentSession(r)
	lang := s.lang(r)
	if err := s.ops.AddUserKey(sess.Username, r.FormValue("pubkey")); err != nil {
		redirectWithMsg(w, r, "/settings?tab=keys", err.Error(), true)
		return
	}
	redirectWithMsg(w, r, "/settings?tab=keys", i18n.T(lang, "settings.msg_key_added"), false)
}

func (s *server) handleRemoveOwnKey(w http.ResponseWriter, r *http.Request) {
	sess, _ := s.currentSession(r)
	lang := s.lang(r)
	if err := s.ops.RemoveUserKey(sess.Username, r.FormValue("key")); err != nil {
		redirectWithMsg(w, r, "/settings?tab=keys", err.Error(), true)
		return
	}
	redirectWithMsg(w, r, "/settings?tab=keys", i18n.T(lang, "settings.msg_key_removed"), false)
}

func (s *server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
	sess, _ := s.currentSession(r)
	lang := s.lang(r)
	oldPassword := r.FormValue("old_password")
	newPassword := r.FormValue("new_password")

	_, ok, err := s.ops.VerifyPassword(sess.Username, oldPassword)
	if err != nil {
		redirectWithMsg(w, r, "/settings?tab=password", err.Error(), true)
		return
	}
	if !ok {
		redirectWithMsg(w, r, "/settings?tab=password", i18n.T(lang, "settings.msg_wrong_password"), true)
		return
	}
	if err := s.ops.SetPassword(sess.Username, newPassword); err != nil {
		redirectWithMsg(w, r, "/settings?tab=password", err.Error(), true)
		return
	}
	redirectWithMsg(w, r, "/settings?tab=password", i18n.T(lang, "settings.msg_password_changed"), false)
}