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:
@@ -5,7 +5,7 @@ The API is available under the `/map/api/` prefix. Requests requiring authentica
|
|||||||
## Authentication
|
## 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"}`.
|
- **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).
|
- **POST /map/api/logout** — sign out (invalidates the session).
|
||||||
- **GET /map/api/setup** — check if initial setup is needed. Response: `{"setupRequired": true|false}`.
|
- **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
|
## 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/tokens** — generate a new upload token (requires `upload` permission). Response: `{"tokens": ["...", ...]}`.
|
||||||
- **POST /map/api/me/password** — change password. Body: `{"pass":"..."}`.
|
- **POST /map/api/me/password** — change password. Body: `{"pass":"..."}`.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
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
|
### Building the image and production Compose
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
16
frontend-nuxt/composables/useGravatarUrl.ts
Normal file
16
frontend-nuxt/composables/useGravatarUrl.ts
Normal 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 64–80)
|
||||||
|
*/
|
||||||
|
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`
|
||||||
|
}
|
||||||
@@ -99,6 +99,14 @@ export function useMapApi() {
|
|||||||
return request<MeResponse>('me')
|
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. */
|
/** Public: whether first-time setup (no users) is required. */
|
||||||
async function setupRequired(): Promise<{ setupRequired: boolean }> {
|
async function setupRequired(): Promise<{ setupRequired: boolean }> {
|
||||||
const res = await fetch(`${apiBase}/setup`, { credentials: 'include' })
|
const res = await fetch(`${apiBase}/setup`, { credentials: 'include' })
|
||||||
@@ -225,6 +233,7 @@ export function useMapApi() {
|
|||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
me,
|
me,
|
||||||
|
meUpdate,
|
||||||
oauthLoginUrl,
|
oauthLoginUrl,
|
||||||
oauthProviders,
|
oauthProviders,
|
||||||
setupRequired,
|
setupRequired,
|
||||||
|
|||||||
@@ -19,30 +19,39 @@
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div
|
<div
|
||||||
v-if="me"
|
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
|
<div class="flex items-center shrink-0 min-h-9">
|
||||||
class="tooltip tooltip-bottom shrink-0"
|
|
||||||
:data-tip="live ? 'Connected to live updates' : 'Disconnected'"
|
|
||||||
>
|
|
||||||
<span
|
<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'"
|
:class="live ? 'text-success' : 'text-base-content/50'"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="size-2 rounded-full"
|
class="size-2 rounded-full shrink-0"
|
||||||
:class="live ? 'bg-success animate-pulse' : 'bg-base-content/40'"
|
:class="live ? 'bg-success animate-pulse' : 'bg-base-content/40'"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{{ live ? 'Live' : 'Offline' }}
|
{{ live ? 'Live' : 'Offline' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dropdown dropdown-end">
|
<div class="dropdown dropdown-end flex items-center">
|
||||||
<details ref="userDropdownRef" class="dropdown group">
|
<details ref="userDropdownRef" class="dropdown group">
|
||||||
<summary class="btn btn-ghost btn-sm gap-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
|
<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 placeholder">
|
<div class="avatar">
|
||||||
<div class="bg-primary text-primary-content rounded-full w-8">
|
<div class="rounded-full w-8 h-8 overflow-hidden flex items-center justify-center">
|
||||||
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<span class="max-w-[8rem] truncate font-medium">{{ me.username }}</span>
|
<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">
|
<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 -->
|
<!-- 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 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="avatar">
|
||||||
<div class="bg-primary text-primary-content rounded-full w-10">
|
<div class="rounded-full w-10 h-10 overflow-hidden flex items-center justify-center">
|
||||||
<span class="text-sm font-medium">{{ (me.username || '?')[0].toUpperCase() }}</span>
|
<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>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
@@ -185,6 +206,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { MeResponse } from '~/types/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const THEME_KEY = 'hnh-map-theme'
|
const THEME_KEY = 'hnh-map-theme'
|
||||||
@@ -202,7 +225,9 @@ function getInitialDark(): boolean {
|
|||||||
const title = ref('HnH Map')
|
const title = ref('HnH Map')
|
||||||
const dark = ref(false)
|
const dark = ref(false)
|
||||||
const live = 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 userDropdownRef = ref<HTMLDetailsElement | null>(null)
|
||||||
const drawerCheckboxRef = ref<HTMLInputElement | null>(null)
|
const drawerCheckboxRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
@@ -248,6 +273,14 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => me.value?.email,
|
||||||
|
() => {
|
||||||
|
gravatarErrorDesktop.value = false
|
||||||
|
gravatarErrorDrawer.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
function onThemeToggle() {
|
function onThemeToggle() {
|
||||||
dark.value = !dark.value
|
dark.value = !dark.value
|
||||||
applyTheme()
|
applyTheme()
|
||||||
|
|||||||
37
frontend-nuxt/package-lock.json
generated
37
frontend-nuxt/package-lock.json
generated
@@ -7,6 +7,7 @@
|
|||||||
"name": "hnh-map-frontend",
|
"name": "hnh-map-frontend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"md5": "^2.3.0",
|
||||||
"nuxt": "^3.21.1",
|
"nuxt": "^3.21.1",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.5.0"
|
||||||
@@ -5705,6 +5706,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -6035,6 +6044,14 @@
|
|||||||
"uncrypto": "^0.1.3"
|
"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": {
|
"node_modules/css-declaration-sorter": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.3.1.tgz",
|
"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"
|
"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": {
|
"node_modules/is-builtin-module": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -8867,6 +8889,16 @@
|
|||||||
"source-map-js": "^1.2.1"
|
"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": {
|
"node_modules/mdn-data": {
|
||||||
"version": "2.12.2",
|
"version": "2.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
|
||||||
@@ -8962,8 +8994,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.2",
|
"version": "10.2.4",
|
||||||
"license": "BlueOak-1.0.0",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||||
|
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^5.0.2"
|
"brace-expansion": "^5.0.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"md5": "^2.3.0",
|
||||||
"nuxt": "^3.21.1",
|
"nuxt": "^3.21.1",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.5.0"
|
||||||
|
|||||||
@@ -16,9 +16,21 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="me">
|
<template v-else-if="me">
|
||||||
<div class="flex flex-wrap items-center gap-4">
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
<div class="avatar placeholder">
|
<div class="avatar">
|
||||||
<div class="bg-primary text-primary-content rounded-full w-14">
|
<div class="rounded-full w-14 h-14 overflow-hidden flex items-center justify-center">
|
||||||
<span class="text-2xl font-semibold">{{ (me.username || '?')[0].toUpperCase() }}</span>
|
<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>
|
</div>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
@@ -33,6 +45,34 @@
|
|||||||
</span>
|
</span>
|
||||||
<span v-if="!me.auths?.length" class="text-sm text-base-content/60">No roles</span>
|
<span v-if="!me.auths?.length" class="text-sm text-base-content/60">No roles</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -104,10 +144,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { MeResponse } from '~/types/api'
|
||||||
|
|
||||||
const api = useMapApi()
|
const api = useMapApi()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const initialLoad = ref(true)
|
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 tokens = ref<string[]>([])
|
||||||
const uploadPrefix = ref('')
|
const uploadPrefix = ref('')
|
||||||
const newPass = ref('')
|
const newPass = ref('')
|
||||||
@@ -117,8 +159,41 @@ const passMsg = ref('')
|
|||||||
const passOk = ref(false)
|
const passOk = ref(false)
|
||||||
const tokenError = ref('')
|
const tokenError = ref('')
|
||||||
const copiedToken = ref<string | null>(null)
|
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
|
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 {
|
function uploadTokenDisplay(token: string): string {
|
||||||
const base = (uploadPrefix.value ?? '').replace(/\/+$/, '')
|
const base = (uploadPrefix.value ?? '').replace(/\/+$/, '')
|
||||||
return base ? `${base}/client/${token}` : `client/${token}`
|
return base ? `${base}/client/${token}` : `client/${token}`
|
||||||
@@ -142,7 +217,7 @@ async function copyToken(token: string) {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.me()
|
const data = await api.me()
|
||||||
me.value = { username: data.username, auths: data.auths }
|
me.value = data
|
||||||
tokens.value = data.tokens ?? []
|
tokens.value = data.tokens ?? []
|
||||||
uploadPrefix.value = data.prefix ?? ''
|
uploadPrefix.value = data.prefix ?? ''
|
||||||
} catch {
|
} catch {
|
||||||
@@ -159,9 +234,10 @@ async function generateToken() {
|
|||||||
loadingTokens.value = true
|
loadingTokens.value = true
|
||||||
try {
|
try {
|
||||||
await api.meTokens()
|
await api.meTokens()
|
||||||
const me = await api.me()
|
const data = await api.me()
|
||||||
tokens.value = me.tokens ?? []
|
me.value = data
|
||||||
uploadPrefix.value = me.prefix ?? ''
|
tokens.value = data.tokens ?? []
|
||||||
|
uploadPrefix.value = data.prefix ?? ''
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : ''
|
const msg = e instanceof Error ? e.message : ''
|
||||||
tokenError.value = msg === 'Forbidden'
|
tokenError.value = msg === 'Forbidden'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface MeResponse {
|
export interface MeResponse {
|
||||||
username: string
|
username: string
|
||||||
auths: string[]
|
auths: string[]
|
||||||
|
email?: string
|
||||||
tokens?: string[]
|
tokens?: string[]
|
||||||
prefix?: string
|
prefix?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,7 @@ type User struct {
|
|||||||
Auths Auths
|
Auths Auths
|
||||||
Tokens []string
|
Tokens []string
|
||||||
OAuthLinks map[string]string `json:"oauth_links,omitempty"`
|
OAuthLinks map[string]string `json:"oauth_links,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page holds page metadata for rendering.
|
// Page holds page metadata for rendering.
|
||||||
|
|||||||
@@ -33,7 +33,14 @@ func (h *Handlers) APIRouter(rw http.ResponseWriter, req *http.Request) {
|
|||||||
h.APILogout(rw, req)
|
h.APILogout(rw, req)
|
||||||
return
|
return
|
||||||
case "me":
|
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
|
return
|
||||||
case "me/tokens":
|
case "me/tokens":
|
||||||
h.APIMeTokens(rw, req)
|
h.APIMeTokens(rw, req)
|
||||||
|
|||||||
@@ -18,12 +18,17 @@ type meResponse struct {
|
|||||||
Auths []string `json:"auths"`
|
Auths []string `json:"auths"`
|
||||||
Tokens []string `json:"tokens,omitempty"`
|
Tokens []string `json:"tokens,omitempty"`
|
||||||
Prefix string `json:"prefix,omitempty"`
|
Prefix string `json:"prefix,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type passwordRequest struct {
|
type passwordRequest struct {
|
||||||
Pass string `json:"pass"`
|
Pass string `json:"pass"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type meUpdateRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
// APILogin handles POST /map/api/login.
|
// APILogin handles POST /map/api/login.
|
||||||
func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) {
|
func (h *Handlers) APILogin(rw http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodPost {
|
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 := meResponse{Username: s.Username, Auths: s.Auths}
|
||||||
out.Tokens, out.Prefix = h.Auth.GetUserTokensAndPrefix(ctx, s.Username)
|
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)
|
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.
|
// APIMeTokens handles POST /map/api/me/tokens.
|
||||||
func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) {
|
func (h *Handlers) APIMeTokens(rw http.ResponseWriter, req *http.Request) {
|
||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
|
|||||||
@@ -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) {
|
func TestAdminUsers_RequiresAdmin(t *testing.T) {
|
||||||
env := newTestEnv(t)
|
env := newTestEnv(t)
|
||||||
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})
|
env.createUser(t, "alice", "pass", app.Auths{app.AUTH_MAP})
|
||||||
|
|||||||
@@ -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.
|
// 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) {
|
func (s *AuthService) ValidateClientToken(ctx context.Context, token string) (string, error) {
|
||||||
var username string
|
var username string
|
||||||
@@ -477,6 +497,9 @@ func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub,
|
|||||||
} else {
|
} else {
|
||||||
user.OAuthLinks[provider] = sub
|
user.OAuthLinks[provider] = sub
|
||||||
}
|
}
|
||||||
|
if email != "" {
|
||||||
|
user.Email = email
|
||||||
|
}
|
||||||
raw, _ = json.Marshal(user)
|
raw, _ = json.Marshal(user)
|
||||||
s.st.PutUser(tx, username, raw)
|
s.st.PutUser(tx, username, raw)
|
||||||
}
|
}
|
||||||
@@ -493,6 +516,7 @@ func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub,
|
|||||||
Pass: nil,
|
Pass: nil,
|
||||||
Auths: app.Auths{app.AUTH_MAP, app.AUTH_MARKERS, app.AUTH_UPLOAD},
|
Auths: app.Auths{app.AUTH_MAP, app.AUTH_MARKERS, app.AUTH_UPLOAD},
|
||||||
OAuthLinks: map[string]string{provider: sub},
|
OAuthLinks: map[string]string{provider: sub},
|
||||||
|
Email: email,
|
||||||
}
|
}
|
||||||
raw, _ := json.Marshal(newUser)
|
raw, _ := json.Marshal(newUser)
|
||||||
return s.st.PutUser(tx, username, raw)
|
return s.st.PutUser(tx, username, raw)
|
||||||
|
|||||||
Reference in New Issue
Block a user