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)
}