Enhance user profile management and Gravatar integration

- Added email field to user profile API and frontend components for better user identification.
- Implemented PATCH /map/api/me endpoint to update user email, enhancing user experience.
- Introduced useGravatarUrl composable for generating Gravatar URLs based on user email.
- Updated profile and layout components to display user avatars using Gravatar, improving visual consistency.
- Enhanced development documentation to guide testing of navbar and profile features.
This commit is contained in:
2026-03-01 16:48:56 +03:00
parent db0b48774a
commit 6a6977ddff
14 changed files with 311 additions and 28 deletions

View File

@@ -266,6 +266,7 @@ type User struct {
Auths Auths
Tokens []string
OAuthLinks map[string]string `json:"oauth_links,omitempty"`
Email string `json:"email,omitempty"`
}
// Page holds page metadata for rendering.

View File

@@ -33,7 +33,14 @@ func (h *Handlers) APIRouter(rw http.ResponseWriter, req *http.Request) {
h.APILogout(rw, req)
return
case "me":
h.APIMe(rw, req)
switch req.Method {
case http.MethodGet:
h.APIMe(rw, req)
case http.MethodPatch:
h.APIMeUpdate(rw, req)
default:
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
}
return
case "me/tokens":
h.APIMeTokens(rw, req)

View File

@@ -18,12 +18,17 @@ type meResponse struct {
Auths []string `json:"auths"`
Tokens []string `json:"tokens,omitempty"`
Prefix string `json:"prefix,omitempty"`
Email string `json:"email,omitempty"`
}
type passwordRequest struct {
Pass string `json:"pass"`
}
type meUpdateRequest struct {
Email string `json:"email"`
}
// APILogin handles POST /map/api/login.
func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
@@ -105,9 +110,36 @@ func (h *Handlers) APIMe(rw http.ResponseWriter, req *http.Request) {
}
out := meResponse{Username: s.Username, Auths: s.Auths}
out.Tokens, out.Prefix = h.Auth.GetUserTokensAndPrefix(ctx, s.Username)
if u := h.Auth.GetUserByUsername(ctx, s.Username); u != nil {
out.Email = u.Email
}
JSON(rw, http.StatusOK, out)
}
// APIMeUpdate handles PATCH /map/api/me (update current user email).
func (h *Handlers) APIMeUpdate(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPatch {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return
}
ctx := req.Context()
s := h.Auth.GetSession(ctx, req)
if s == nil {
JSONError(rw, http.StatusUnauthorized, "Unauthorized", "UNAUTHORIZED")
return
}
var body meUpdateRequest
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return
}
if err := h.Auth.SetUserEmail(ctx, s.Username, body.Email); err != nil {
JSONError(rw, http.StatusInternalServerError, "internal error", "INTERNAL_ERROR")
return
}
rw.WriteHeader(http.StatusOK)
}
// APIMeTokens handles POST /map/api/me/tokens.
func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {

View File

@@ -287,6 +287,51 @@ func TestAPIMePassword(t *testing.T) {
}
}
func TestAPIMeUpdate_UpdatesEmail(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})
sid := env.loginSession(t, "alice")
patchBody := `{"email":"test@example.com"}`
req := withSession(httptest.NewRequest(http.MethodPatch, "/map/api/me", strings.NewReader(patchBody)), sid)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
env.h.APIMeUpdate(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
}
req2 := withSession(httptest.NewRequest(http.MethodGet, "/map/api/me", nil), sid)
rr2 := httptest.NewRecorder()
env.h.APIMe(rr2, req2)
if rr2.Code != http.StatusOK {
t.Fatalf("expected 200 on GET /me, got %d", rr2.Code)
}
var meResp struct {
Username string `json:"username"`
Email string `json:"email"`
}
if err := json.NewDecoder(rr2.Body).Decode(&meResp); err != nil {
t.Fatal(err)
}
if meResp.Email != "test@example.com" {
t.Fatalf("expected email test@example.com, got %q", meResp.Email)
}
}
func TestAPIRouter_Me_MethodNotAllowed(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})
sid := env.loginSession(t, "alice")
req := withSession(httptest.NewRequest(http.MethodPost, "/map/api/me", nil), sid)
rr := httptest.NewRecorder()
env.h.APIRouter(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected 405 for POST /me, got %d", rr.Code)
}
}
func TestAdminUsers_RequiresAdmin(t *testing.T) {
env := newTestEnv(t)
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})

View File

@@ -264,6 +264,26 @@ func (s *AuthService) SetUserPassword(ctx context.Context, username, pass string
})
}
// SetUserEmail sets email for user (for Gravatar and display).
func (s *AuthService) SetUserEmail(ctx context.Context, username, email string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
if uRaw == nil {
return nil // user not found, no-op
}
var u app.User
if err := json.Unmarshal(uRaw, &u); err != nil {
return err
}
u.Email = email
raw, err := json.Marshal(u)
if err != nil {
return err
}
return s.st.PutUser(tx, username, raw)
})
}
// ValidateClientToken validates a client token and returns the username if valid with upload permission.
func (s *AuthService) ValidateClientToken(ctx context.Context, token string) (string, error) {
var username string
@@ -477,6 +497,9 @@ func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub,
} else {
user.OAuthLinks[provider] = sub
}
if email != "" {
user.Email = email
}
raw, _ = json.Marshal(user)
s.st.PutUser(tx, username, raw)
}
@@ -493,6 +516,7 @@ func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub,
Pass: nil,
Auths: app.Auths{app.AUTH_MAP, app.AUTH_MARKERS, app.AUTH_UPLOAD},
OAuthLinks: map[string]string{provider: sub},
Email: email,
}
raw, _ := json.Marshal(newUser)
return s.st.PutUser(tx, username, raw)