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

@@ -5,7 +5,7 @@ The API is available under the `/map/api/` prefix. Requests requiring authentica
## Authentication
- **POST /map/api/login** — sign in. Body: `{"user":"...","pass":"..."}`. On success returns JSON with user data and sets a session cookie. On first run, bootstrap is available: logging in as `admin` with the password from `HNHMAP_BOOTSTRAP_PASSWORD` creates the first admin user. For users created via OAuth (no password), returns 401 with `{"error":"Use OAuth to sign in"}`.
- **GET /map/api/me** — current user (by session). Response: `username`, `auths`, and optionally `tokens`, `prefix`.
- **GET /map/api/me** — current user (by session). Response: `username`, `auths`, and optionally `tokens`, `prefix`, `email` (string, optional — for Gravatar and display).
- **POST /map/api/logout** — sign out (invalidates the session).
- **GET /map/api/setup** — check if initial setup is needed. Response: `{"setupRequired": true|false}`.
@@ -17,6 +17,7 @@ The API is available under the `/map/api/` prefix. Requests requiring authentica
## User account
- **PATCH /map/api/me** — update current user. Body: `{"email": "..."}`. Used to set or change the user's email (for Gravatar and profile display). Requires a valid session.
- **POST /map/api/me/tokens** — generate a new upload token (requires `upload` permission). Response: `{"tokens": ["...", ...]}`.
- **POST /map/api/me/password** — change password. Body: `{"pass":"..."}`.

View File

@@ -49,6 +49,10 @@ The dev Compose setup starts two services:
Use [http://localhost:3000/](http://localhost:3000/) as the primary URL for UI development.
Port `3080` is for API and backend endpoints; the root `/` may return `404` in dev mode — this is expected.
**Testing navbar and profile:** With no users in the database, log in as `admin` using the bootstrap password (e.g. `HNHMAP_BOOTSTRAP_PASSWORD=admin` in docker-compose.dev) to create the first admin user. You can then use that account to verify the navbar avatar and profile page. For Gravatar, use OAuth (e.g. Google) or set email later on the profile page once that feature is available.
**Gravatar (avatar by email):** Gravatar URLs are built on the frontend using the `md5` package (client-side MD5 of the user's email). No backend endpoint is used; the frontend composable `useGravatarUrl` (see Phase 5+ of the navbar/avatar plan) will use this dependency.
### Building the image and production Compose
```bash

View File

@@ -0,0 +1,16 @@
import md5 from 'md5'
/**
* Returns Gravatar avatar URL for the given email, or empty string if no email.
* Gravatar expects: trim, lowercase, then MD5 hex. Use empty string to show a placeholder (e.g. initial letter) in the UI.
*
* @param email - User email (optional)
* @param size - Avatar size in pixels (default 64; navbar 32, drawer 40, profile 6480)
*/
export function useGravatarUrl(email: string | undefined, size?: number): string {
const normalized = email?.trim().toLowerCase()
if (!normalized) return ''
const hash = md5(normalized)
const s = size ?? 64
return `https://www.gravatar.com/avatar/${hash}?s=${s}&d=identicon`
}

View File

@@ -99,6 +99,14 @@ export function useMapApi() {
return request<MeResponse>('me')
}
async function meUpdate(body: { email?: string }) {
await request('me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
/** Public: whether first-time setup (no users) is required. */
async function setupRequired(): Promise<{ setupRequired: boolean }> {
const res = await fetch(`${apiBase}/setup`, { credentials: 'include' })
@@ -225,6 +233,7 @@ export function useMapApi() {
login,
logout,
me,
meUpdate,
oauthLoginUrl,
oauthProviders,
setupRequired,

View File

@@ -19,30 +19,39 @@
</NuxtLink>
<div
v-if="me"
class="hidden md:flex items-center gap-2 shrink-0"
class="hidden md:flex items-center gap-2 shrink-0 min-h-9"
>
<div
class="tooltip tooltip-bottom shrink-0"
:data-tip="live ? 'Connected to live updates' : 'Disconnected'"
>
<div class="flex items-center shrink-0 min-h-9">
<span
class="inline-flex items-center gap-1.5 text-xs text-base-content/70"
class="inline-flex items-center gap-1.5 text-xs text-base-content/70 leading-none"
:class="live ? 'text-success' : 'text-base-content/50'"
>
<span
class="size-2 rounded-full"
class="size-2 rounded-full shrink-0"
:class="live ? 'bg-success animate-pulse' : 'bg-base-content/40'"
aria-hidden="true"
/>
{{ live ? 'Live' : 'Offline' }}
</span>
</div>
<div class="dropdown dropdown-end">
<div class="dropdown dropdown-end flex items-center">
<details ref="userDropdownRef" class="dropdown group">
<summary class="btn btn-ghost btn-sm gap-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-8">
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
<summary class="btn btn-ghost btn-sm gap-2 flex items-center min-h-9 h-9 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<div class="avatar">
<div class="rounded-full w-8 h-8 overflow-hidden flex items-center justify-center">
<img
v-if="me.email && !gravatarErrorDesktop"
:src="useGravatarUrl(me.email, 32)"
alt=""
class="w-full h-full object-cover"
@error="gravatarErrorDesktop = true"
>
<div
v-else
class="bg-primary text-primary-content rounded-full w-8 h-8 flex items-center justify-center"
>
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
</div>
</div>
</div>
<span class="max-w-[8rem] truncate font-medium">{{ me.username }}</span>
@@ -119,9 +128,21 @@
<aside class="bg-base-200/95 backdrop-blur-xl min-h-full w-72 p-4 flex flex-col">
<!-- Mobile: user + live when logged in -->
<div v-if="!isLogin && me" class="flex items-center gap-3 pb-4 mb-2 border-b border-base-300/50">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-10">
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
<div class="avatar">
<div class="rounded-full w-10 h-10 overflow-hidden flex items-center justify-center">
<img
v-if="me.email && !gravatarErrorDrawer"
:src="useGravatarUrl(me.email, 40)"
alt=""
class="w-full h-full object-cover"
@error="gravatarErrorDrawer = true"
>
<div
v-else
class="bg-primary text-primary-content rounded-full w-10 h-10 flex items-center justify-center"
>
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
</div>
</div>
</div>
<div class="flex-1 min-w-0">
@@ -185,6 +206,8 @@
</template>
<script setup lang="ts">
import type { MeResponse } from '~/types/api'
const route = useRoute()
const router = useRouter()
const THEME_KEY = 'hnh-map-theme'
@@ -202,7 +225,9 @@ function getInitialDark(): boolean {
const title = ref('HnH Map')
const dark = ref(false)
const live = ref(false)
const me = ref<{ username?: string; auths?: string[] } | null>(null)
const me = useState<MeResponse | null>('me', () => null)
const gravatarErrorDesktop = ref(false)
const gravatarErrorDrawer = ref(false)
const userDropdownRef = ref<HTMLDetailsElement | null>(null)
const drawerCheckboxRef = ref<HTMLInputElement | null>(null)
@@ -248,6 +273,14 @@ watch(
{ immediate: true }
)
watch(
() => me.value?.email,
() => {
gravatarErrorDesktop.value = false
gravatarErrorDrawer.value = false
}
)
function onThemeToggle() {
dark.value = !dark.value
applyTheme()

View File

@@ -7,6 +7,7 @@
"name": "hnh-map-frontend",
"dependencies": {
"leaflet": "^1.9.4",
"md5": "^2.3.0",
"nuxt": "^3.21.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
@@ -5705,6 +5706,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
"engines": {
"node": "*"
}
},
"node_modules/chokidar": {
"version": "5.0.0",
"license": "MIT",
@@ -6035,6 +6044,14 @@
"uncrypto": "^0.1.3"
}
},
"node_modules/crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
"engines": {
"node": "*"
}
},
"node_modules/css-declaration-sorter": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz",
@@ -7924,6 +7941,11 @@
"url": "https://github.com/sponsors/brc-dd"
}
},
"node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
"node_modules/is-builtin-module": {
"version": "5.0.0",
"dev": true,
@@ -8867,6 +8889,16 @@
"source-map-js": "^1.2.1"
}
},
"node_modules/md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"dependencies": {
"charenc": "0.0.2",
"crypt": "0.0.2",
"is-buffer": "~1.1.6"
}
},
"node_modules/mdn-data": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
@@ -8962,8 +8994,9 @@
}
},
"node_modules/minimatch": {
"version": "10.2.2",
"license": "BlueOak-1.0.0",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dependencies": {
"brace-expansion": "^5.0.2"
},

View File

@@ -19,6 +19,7 @@
},
"dependencies": {
"leaflet": "^1.9.4",
"md5": "^2.3.0",
"nuxt": "^3.21.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"

View File

@@ -16,9 +16,21 @@
</template>
<template v-else-if="me">
<div class="flex flex-wrap items-center gap-4">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-14">
<span class="text-2xl font-semibold">{{ (me.username || '?')[0].toUpperCase() }}</span>
<div class="avatar">
<div class="rounded-full w-14 h-14 overflow-hidden flex items-center justify-center">
<img
v-if="me.email && !gravatarError"
:src="useGravatarUrl(me.email, 80)"
alt=""
class="w-full h-full object-cover"
@error="gravatarError = true"
>
<div
v-else
class="bg-primary text-primary-content rounded-full w-14 h-14 flex items-center justify-center"
>
<span class="text-2xl font-semibold">{{ (me.username || '?')[0].toUpperCase() }}</span>
</div>
</div>
</div>
<div class="flex flex-col gap-1">
@@ -33,6 +45,34 @@
</span>
<span v-if="!me.auths?.length" class="text-sm text-base-content/60">No roles</span>
</div>
<p class="text-sm text-base-content/80 mt-1">
Email: {{ me.email || 'Not set' }}
</p>
<div v-if="!emailEditing" class="flex items-center gap-2 mt-1">
<button
type="button"
class="btn btn-ghost btn-xs"
@click="startEditEmail"
>
{{ me.email ? 'Edit' : 'Set' }} email
</button>
</div>
<form v-else class="flex flex-wrap items-center gap-2 mt-2" @submit.prevent="saveEmail">
<input
v-model="emailEdit"
type="email"
placeholder="email@example.com"
class="input input-bordered input-sm w-full max-w-xs"
autocomplete="email"
>
<button type="submit" class="btn btn-primary btn-sm" :disabled="loadingEmail">
{{ loadingEmail ? '…' : 'Save' }}
</button>
<button type="button" class="btn btn-ghost btn-sm" :disabled="loadingEmail" @click="cancelEditEmail">
Cancel
</button>
<p v-if="emailError" class="text-error text-sm w-full">{{ emailError }}</p>
</form>
</div>
</div>
</template>
@@ -104,10 +144,12 @@
</template>
<script setup lang="ts">
import type { MeResponse } from '~/types/api'
const api = useMapApi()
const toast = useToast()
const initialLoad = ref(true)
const me = ref<{ username?: string; auths?: string[] } | null>(null)
const me = useState<MeResponse | null>('me', () => null)
const tokens = ref<string[]>([])
const uploadPrefix = ref('')
const newPass = ref('')
@@ -117,8 +159,41 @@ const passMsg = ref('')
const passOk = ref(false)
const tokenError = ref('')
const copiedToken = ref<string | null>(null)
const gravatarError = ref(false)
const emailEditing = ref(false)
const emailEdit = ref('')
const loadingEmail = ref(false)
const emailError = ref('')
let copiedTimeout: ReturnType<typeof setTimeout> | null = null
function startEditEmail() {
emailError.value = ''
emailEdit.value = me.value?.email ?? ''
emailEditing.value = true
}
function cancelEditEmail() {
emailEditing.value = false
emailError.value = ''
}
async function saveEmail() {
emailError.value = ''
loadingEmail.value = true
try {
await api.meUpdate({ email: emailEdit.value.trim() || undefined })
const data = await api.me()
me.value = data
emailEditing.value = false
gravatarError.value = false
toast.success('Email updated')
} catch (e: unknown) {
emailError.value = e instanceof Error ? e.message : 'Failed to update email'
} finally {
loadingEmail.value = false
}
}
function uploadTokenDisplay(token: string): string {
const base = (uploadPrefix.value ?? '').replace(/\/+$/, '')
return base ? `${base}/client/${token}` : `client/${token}`
@@ -142,7 +217,7 @@ async function copyToken(token: string) {
onMounted(async () => {
try {
const data = await api.me()
me.value = { username: data.username, auths: data.auths }
me.value = data
tokens.value = data.tokens ?? []
uploadPrefix.value = data.prefix ?? ''
} catch {
@@ -159,9 +234,10 @@ async function generateToken() {
loadingTokens.value = true
try {
await api.meTokens()
const me = await api.me()
tokens.value = me.tokens ?? []
uploadPrefix.value = me.prefix ?? ''
const data = await api.me()
me.value = data
tokens.value = data.tokens ?? []
uploadPrefix.value = data.prefix ?? ''
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : ''
tokenError.value = msg === 'Forbidden'

View File

@@ -1,6 +1,7 @@
export interface MeResponse {
username: string
auths: string[]
email?: string
tokens?: string[]
prefix?: string
}

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)