Enhance health weight chart with more metrics

Expanded the HealthWeightLineChartComponent to display additional metrics (BMI, body fat, body water, bone mass, muscle mass) alongside weight, each with its own dataset and axis. Improved tooltip formatting and axis labeling. Also refactored and cleaned up UsersListComponent markup for readability, and improved type annotations in UsersListComponent. Backend health_utils.py now includes stricter validation for Garmin data fields to prevent errors from invalid or missing values.
This commit is contained in:
João Vitória Silva
2025-11-28 14:14:18 +00:00
parent b97b295bab
commit 5159d5f246
5 changed files with 234 additions and 211 deletions

View File

@@ -137,6 +137,9 @@ def fetch_and_process_ds_by_dates(
count_processed = 0
# Process steps
for ds in garmin_ds:
if ds["totalSteps"] is None:
continue
health_steps = health_steps_schema.HealthSteps(
user_id=user_id,
date=ds["calendarDate"],
@@ -255,13 +258,20 @@ def fetch_and_process_sleep_by_dates(
# Process sleep stages from sleepLevels array
sleep_stages = []
if "sleepLevels" in garmin_sleep:
if "sleepLevels" in garmin_sleep and garmin_sleep["sleepLevels"]:
for level in garmin_sleep["sleepLevels"]:
activity_level = level.get("activityLevel")
if activity_level is not None:
if (
activity_level is not None
and activity_level in health_sleep_schema.SleepStageType
):
# Map Garmin activity levels to sleep stage types
# 0=deep, 1=light, 2=REM, 3=awake
stage_type = health_sleep_schema.SleepStageType(activity_level)
stage_type = (
health_sleep_schema.SleepStageType(activity_level)
if activity_level
else None
)
start_gmt_str = level.get("startGMT")
end_gmt_str = level.get("endGMT")
@@ -352,6 +362,7 @@ def fetch_and_process_sleep_by_dates(
hrv_status=(
health_sleep_schema.HRVStatus(garmin_sleep.get("hrvStatus"))
if garmin_sleep.get("hrvStatus")
and garmin_sleep.get("hrvStatus") in health_sleep_schema.HRVStatus
else None
),
resting_heart_rate=garmin_sleep.get("restingHeartRate"),
@@ -359,11 +370,15 @@ def fetch_and_process_sleep_by_dates(
awake_count_score=(
health_sleep_schema.SleepScore(awake_count_score.get("qualifierKey"))
if awake_count_score
and awake_count_score.get("qualifierKey")
in health_sleep_schema.SleepScore
else None
),
rem_percentage_score=(
health_sleep_schema.SleepScore(rem_percentage_score.get("qualifierKey"))
if rem_percentage_score
and rem_percentage_score.get("qualifierKey")
in health_sleep_schema.SleepScore
else None
),
deep_percentage_score=(
@@ -371,6 +386,8 @@ def fetch_and_process_sleep_by_dates(
deep_percentage_score.get("qualifierKey")
)
if deep_percentage_score
and deep_percentage_score.get("qualifierKey")
in health_sleep_schema.SleepScore
else None
),
light_percentage_score=(
@@ -378,12 +395,16 @@ def fetch_and_process_sleep_by_dates(
light_percentage_score.get("qualifierKey")
)
if light_percentage_score
and light_percentage_score.get("qualifierKey")
in health_sleep_schema.SleepScore
else None
),
avg_sleep_stress=sleep_dto.get("avgSleepStress"),
sleep_stress_score=(
health_sleep_schema.SleepScore(sleep_stress_score.get("qualifierKey"))
if sleep_stress_score
and sleep_stress_score.get("qualifierKey")
in health_sleep_schema.SleepScore
else None
),
)

12
backend/poetry.lock generated
View File

@@ -1159,14 +1159,14 @@ test = ["pytest", "pytest-cov", "pytest-mpl", "pytest-subtests"]
[[package]]
name = "garminconnect"
version = "0.2.34"
version = "0.2.35"
description = "Python 3 API wrapper for Garmin Connect"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "garminconnect-0.2.34-py3-none-any.whl", hash = "sha256:347fdca2ddcba50c62583b5d64fd1696cf8956e246b4a860641520726eb00874"},
{file = "garminconnect-0.2.34.tar.gz", hash = "sha256:cc58b28b4dd65142f325f061e31afc8ec6b3b1414ad8fbeb47c025608aa16f5f"},
{file = "garminconnect-0.2.35-py3-none-any.whl", hash = "sha256:8ad92b21d0e769cd6d31c190659188e816c1f8cec8117c6c1aaffd8c48898555"},
{file = "garminconnect-0.2.35.tar.gz", hash = "sha256:5623d7d4d0ee68b30db92aa2fd6ed070ecef9ccfbdea663bc0e61f2918b96985"},
]
[package.dependencies]
@@ -2757,14 +2757,14 @@ files = [
[[package]]
name = "pydantic"
version = "2.12.4"
version = "2.12.5"
description = "Data validation using Python type hints"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e"},
{file = "pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac"},
{file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
{file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
]
[package.dependencies]

View File

@@ -1770,9 +1770,9 @@
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.0.17",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.17.tgz",
"integrity": "sha512-LCC++2h8pLUSPY+EsZmrrJ1EOUu+5iClpEiDhhdw3zRJpPbABML/N5lmRuBHjxtKm9VnRcsUzioyD0sekFMF0A==",
"version": "1.0.19",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.19.tgz",
"integrity": "sha512-QW5/SM2ARltEhoKcmRI1LoLf3/C7dHGswwCnfLcoMgqurBT4f8GvwXMgAbK/FwcxthmJRK5MGTtddj0yQn0J9g==",
"dev": true,
"funding": [
{
@@ -2560,13 +2560,13 @@
}
},
"node_modules/@intlify/core-base": {
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.1.tgz",
"integrity": "sha512-2V1A4yaN9ElAnQ6ih3HHEc+jZ+sHV6BlQHjCsnIVlOotL5NCUgJElIxgUFiJs6zV4puoAq3hHuQIfWNp+J+8yQ==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.2.tgz",
"integrity": "sha512-0mCTBOLKIqFUP3BzwuFW23hYEl9g/wby6uY//AC5hTgQfTsM2srCYF2/hYGp+a5DZ/HIFIgKkLJMzXTt30r0JQ==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "11.2.1",
"@intlify/shared": "11.2.1"
"@intlify/message-compiler": "11.2.2",
"@intlify/shared": "11.2.2"
},
"engines": {
"node": ">= 16"
@@ -2576,12 +2576,12 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.1.tgz",
"integrity": "sha512-J2454D3Agg3Kvgaj14gxTleJU8/H06Sisz7C2BwiHF0/i5Soyfb5ySpwn8GCL6yscDbOGj6xM+lUe6gO6BFQyg==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.2.tgz",
"integrity": "sha512-XS2p8Ff5JxWsKhgfld4/MRQzZRQ85drMMPhb7Co6Be4ZOgqJX1DzcZt0IFgGTycgqL8rkYNwgnD443Q+TapOoA==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.2.1",
"@intlify/shared": "11.2.2",
"source-map-js": "^1.0.2"
},
"engines": {
@@ -2592,9 +2592,9 @@
}
},
"node_modules/@intlify/shared": {
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.1.tgz",
"integrity": "sha512-O67LZM4dbfr70WCsZLW+g+pIXdgQ66laLVd/FicW7iYgP/RuH0X1FDGSh+Hr9Gou/8TeldUE6KmTGdLwX2ufIA==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.2.tgz",
"integrity": "sha512-OtCmyFpSXxNu/oET/aN6HtPCbZ01btXVd0f3w00YsHOb13Kverk1jzA2k47pAekM55qbUw421fvPF1yxZ+gicw==",
"license": "MIT",
"engines": {
"node": ">= 16"
@@ -4919,9 +4919,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.260",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.260.tgz",
"integrity": "sha512-ov8rBoOBhVawpzdre+Cmz4FB+y66Eqrk6Gwqd8NGxuhv99GQ8XqMAr351KEkOt7gukXWDg6gJWEMKgL2RLMPtA==",
"version": "1.5.262",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz",
"integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==",
"dev": true,
"license": "ISC"
},
@@ -5261,9 +5261,9 @@
}
},
"node_modules/eslint-plugin-vue": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.6.0.tgz",
"integrity": "sha512-TsoFluWxOpsJlE/l2jJygLQLWBPJ3Qdkesv7tBIunICbTcG0dS1/NBw/Ol4tJw5kHWlAVds4lUmC29/vlPUcEQ==",
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.6.2.tgz",
"integrity": "sha512-nA5yUs/B1KmKzvC42fyD0+l9Yd+LtEpVhWRbXuDj0e+ZURcTtyRbMDWUeJmTAh2wC6jC83raS63anNM2YT3NPw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7563,9 +7563,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7587,9 +7587,9 @@
}
},
"node_modules/prettier": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.1.tgz",
"integrity": "sha512-RWKXE4qB3u5Z6yz7omJkjWwmTfLdcbv44jUVHC5NpfXwFGzvpQM798FGv/6WNK879tc+Cn0AAyherCl1KjbyZQ==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -8572,9 +8572,9 @@
}
},
"node_modules/superjson": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.5.tgz",
"integrity": "sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==",
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
"license": "MIT",
"dependencies": {
"copy-anything": "^4"
@@ -9201,17 +9201,17 @@
}
},
"node_modules/vite-plugin-pwa": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.1.0.tgz",
"integrity": "sha512-VsSpdubPzXhHWVINcSx6uHRMpOHVHQcHsef1QgkOlEoaIDAlssFEW88LBq1a59BuokAhsh2kUDJbaX1bZv4Bjw==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz",
"integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.6",
"pretty-bytes": "^6.1.1",
"tinyglobby": "^0.2.10",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0"
"workbox-build": "^7.4.0",
"workbox-window": "^7.4.0"
},
"engines": {
"node": ">=16.0.0"
@@ -9222,8 +9222,8 @@
"peerDependencies": {
"@vite-pwa/assets-generator": "^1.0.0",
"vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0"
"workbox-build": "^7.4.0",
"workbox-window": "^7.4.0"
},
"peerDependenciesMeta": {
"@vite-pwa/assets-generator": {
@@ -9426,13 +9426,13 @@
}
},
"node_modules/vue-i18n": {
"version": "11.2.1",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.1.tgz",
"integrity": "sha512-cc3Wx4eJZac9WMS8mxhfYiCipm9PBQ2Dz15piWYm7DwNcCehaKRgpolEdiqrjjT27T3Wijz3xJ7NeIc8ofIWAA==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.2.tgz",
"integrity": "sha512-ULIKZyRluUPRCZmihVgUvpq8hJTtOqnbGZuv4Lz+byEKZq4mU0g92og414l6f/4ju+L5mORsiUuEPYrAuX2NJg==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.2.1",
"@intlify/shared": "11.2.1",
"@intlify/core-base": "11.2.2",
"@intlify/shared": "11.2.2",
"@vue/devtools-api": "^6.5.0"
},
"engines": {

View File

@@ -86,33 +86,53 @@ const chartData = computed(() => {
return new Date(a.date) - new Date(b.date)
})
const data = []
const weightData = []
const bmiData = []
const bodyFatData = []
const bodyWaterData = []
const boneMassData = []
const muscleMassData = []
const labels = []
for (const HealthWeight of sortedWeight) {
if (Number(authStore?.user?.units) === 1) {
data.push(HealthWeight.weight)
weightData.push(HealthWeight.weight)
} else {
data.push(kgToLbs(HealthWeight.weight))
weightData.push(kgToLbs(HealthWeight.weight))
}
// Add other metrics
bmiData.push(HealthWeight.bmi || null)
bodyFatData.push(HealthWeight.body_fat || null)
bodyWaterData.push(HealthWeight.body_water || null)
boneMassData.push(HealthWeight.bone_mass || null)
muscleMassData.push(HealthWeight.muscle_mass || null)
const createdAt = new Date(HealthWeight.date)
labels.push(
`${createdAt.getDate()}/${createdAt.getMonth() + 1}/${createdAt.getFullYear()}`
)
}
let label = ''
let weightLabel = ''
let unitsLabel = ''
if (Number(authStore?.user?.units) === 1) {
label = t('generalItems.labelWeightInKg')
weightLabel = t('generalItems.labelWeightInKg')
unitsLabel = t('generalItems.unitsKg')
} else {
label = t('generalItems.labelWeightInLbs')
weightLabel = t('generalItems.labelWeightInLbs')
unitsLabel = t('generalItems.unitsLbs')
}
const BMILabel = t('healthWeightAddEditModalComponent.addWeightBMILabel')
const bodyFatLabel = t('healthWeightAddEditModalComponent.addWeightBodyFatLabel')
const bodyWaterLabel = t('healthWeightAddEditModalComponent.addWeightBodyWaterLabel')
const boneMassLabel = t('healthWeightAddEditModalComponent.addWeightBoneMassLabel')
const muscleMassLabel = t('healthWeightAddEditModalComponent.addWeightMuscleMassLabel')
const datasets = [
{
label: label,
data: data,
label: weightLabel,
data: weightData,
backgroundColor: function (context) {
const chart = context.chart
const { ctx, chartArea } = chart
@@ -121,10 +141,61 @@ const chartData = computed(() => {
}
return createGradient(ctx, chartArea)
},
borderColor: 'rgba(59, 130, 246, 0.8)', // Blue border
borderColor: 'rgba(59, 130, 246, 0.8)', // Blue
fill: true,
pointHoverRadius: 4,
pointHoverBackgroundColor: 'rgba(59, 130, 246, 0.8)'
pointHoverBackgroundColor: 'rgba(59, 130, 246, 0.8)',
yAxisID: 'y'
},
{
label: BMILabel,
data: bmiData,
borderColor: 'rgba(34, 197, 94, 0.8)', // Green
backgroundColor: 'rgba(34, 197, 94, 0.1)',
fill: false,
pointHoverRadius: 4,
pointHoverBackgroundColor: 'rgba(34, 197, 94, 0.8)',
yAxisID: 'y1'
},
{
label: bodyFatLabel + ' (%)',
data: bodyFatData,
borderColor: 'rgba(251, 146, 60, 0.8)', // Orange
backgroundColor: 'rgba(251, 146, 60, 0.1)',
fill: false,
pointHoverRadius: 4,
pointHoverBackgroundColor: 'rgba(251, 146, 60, 0.8)',
yAxisID: 'y1'
},
{
label: bodyWaterLabel + ' (%)',
data: bodyWaterData,
borderColor: 'rgba(14, 165, 233, 0.8)', // Cyan
backgroundColor: 'rgba(14, 165, 233, 0.1)',
fill: false,
pointHoverRadius: 4,
pointHoverBackgroundColor: 'rgba(14, 165, 233, 0.8)',
yAxisID: 'y1'
},
{
label: boneMassLabel + ' (' + unitsLabel + ')',
data: boneMassData,
borderColor: 'rgba(168, 85, 247, 0.8)', // Purple
backgroundColor: 'rgba(168, 85, 247, 0.1)',
fill: false,
pointHoverRadius: 4,
pointHoverBackgroundColor: 'rgba(168, 85, 247, 0.8)',
yAxisID: 'y'
},
{
label: muscleMassLabel + ' (' + unitsLabel + ')',
data: muscleMassData,
borderColor: 'rgba(236, 72, 153, 0.8)', // Pink
backgroundColor: 'rgba(236, 72, 153, 0.1)',
fill: false,
pointHoverRadius: 4,
pointHoverBackgroundColor: 'rgba(236, 72, 153, 0.8)',
yAxisID: 'y'
}
]
@@ -193,11 +264,31 @@ onMounted(() => {
},
scales: {
y: {
type: 'linear',
display: true,
position: 'left',
beginAtZero: false,
grid: {
lineWidth: 1,
drawBorder: true,
borderWidth: 1
},
title: {
display: true,
text: 'Weight / Mass'
}
},
y1: {
type: 'linear',
display: true,
position: 'right',
beginAtZero: false,
grid: {
drawOnChartArea: false
},
title: {
display: true,
text: 'Percentage (%)'
}
},
x: {
@@ -229,8 +320,11 @@ onMounted(() => {
return `${label}: N/A`
}
// Format weight with 1 decimal place
return `${label}: ${value.toFixed(1)}`
// Format values with appropriate decimal places
if (label.includes('%')) {
return `${label}: ${value.toFixed(1)}%`
}
return `${label}: ${value.toFixed(2)}`
}
}
},
@@ -272,6 +366,7 @@ onUnmounted(() => {
<style scoped>
.chart-canvas {
max-height: 300px;
width: 100%; /* Ensures the canvas stretches across the available width */
width: 100%;
/* Ensures the canvas stretches across the available width */
}
</style>

View File

@@ -9,172 +9,97 @@
</div>
<span v-if="user.access_type == 1">{{
$t('usersListComponent.userListAccessTypeOption1')
}}</span>
}}</span>
<span v-else>{{ $t('usersListComponent.userListAccessTypeOption2') }}</span>
</div>
</div>
<div>
<span
class="badge bg-secondary-subtle border border-secondary-subtle text-secondary-emphasis me-2 d-none d-sm-inline"
v-if="user.id == authStore.user.id"
>{{ $t('usersListComponent.userListUserIsMeBadge') }}</span
>
<span
class="badge bg-warning-subtle border border-warning-subtle text-warning-emphasis me-2 d-none d-sm-inline"
v-if="user.access_type == 2"
>{{ $t('usersListComponent.userListUserIsAdminBadge') }}</span
>
<span
class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis me-2 d-none d-sm-inline"
v-if="user.active == false"
>{{ $t('usersListComponent.userListUserIsInactiveBadge') }}</span
>
<span
class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis me-2 d-none d-sm-inline"
v-if="user.email_verified == false"
>{{ $t('usersListComponent.userListUserHasUnverifiedEmailBadge') }}</span
>
<span
class="badge bg-info-subtle border border-info-subtle text-info-emphasis d-none d-sm-inline"
v-if="user.id == authStore.user.id">{{ $t('usersListComponent.userListUserIsMeBadge') }}</span>
<span class="badge bg-warning-subtle border border-warning-subtle text-warning-emphasis me-2 d-none d-sm-inline"
v-if="user.access_type == 2">{{ $t('usersListComponent.userListUserIsAdminBadge') }}</span>
<span class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis me-2 d-none d-sm-inline"
v-if="user.active == false">{{ $t('usersListComponent.userListUserIsInactiveBadge') }}</span>
<span class="badge bg-danger-subtle border border-danger-subtle text-danger-emphasis me-2 d-none d-sm-inline"
v-if="user.email_verified == false">{{ $t('usersListComponent.userListUserHasUnverifiedEmailBadge') }}</span>
<span class="badge bg-info-subtle border border-info-subtle text-info-emphasis d-none d-sm-inline"
v-if="user.external_auth_count && user.external_auth_count > 0"
:aria-label="$t('usersListComponent.userListUserHasExternalAuthBadge')"
>{{ $t('usersListComponent.userListUserHasExternalAuthBadge') }}</span
>
:aria-label="$t('usersListComponent.userListUserHasExternalAuthBadge')">{{
$t('usersListComponent.userListUserHasExternalAuthBadge') }}</span>
<!-- button toggle user details -->
<a
class="btn btn-link btn-lg link-body-emphasis"
data-bs-toggle="collapse"
:href="`#collapseUserDetails${user.id}`"
role="button"
aria-expanded="false"
:aria-controls="`collapseUserDetails${user.id}`"
>
<a class="btn btn-link btn-lg link-body-emphasis" data-bs-toggle="collapse"
:href="`#collapseUserDetails${user.id}`" role="button" aria-expanded="false"
:aria-controls="`collapseUserDetails${user.id}`">
<font-awesome-icon :icon="['fas', 'caret-down']" v-if="!userDetails" />
<font-awesome-icon :icon="['fas', 'caret-up']" v-else />
</a>
<!-- approve sign-up button -->
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#approveSignUpModal${user.id}`"
v-if="user.pending_admin_approval && user.email_verified"
><font-awesome-icon :icon="['fas', 'fa-check']"
/></a>
v-if="user.pending_admin_approval && user.email_verified"><font-awesome-icon
:icon="['fas', 'fa-check']" /></a>
<!-- approve sign up modal -->
<ModalComponent
:modalId="`approveSignUpModal${user.id}`"
<ModalComponent :modalId="`approveSignUpModal${user.id}`"
:title="t('usersListComponent.modalApproveSignUpTitle')"
:body="`${t('usersListComponent.modalApproveSignUpBody')}<b>${user.username}</b>?`"
:actionButtonType="`success`"
:actionButtonText="t('usersListComponent.modalApproveSignUpTitle')"
@submitAction="submitApproveSignUp"
v-if="user.pending_admin_approval && user.email_verified"
/>
:actionButtonType="`success`" :actionButtonText="t('usersListComponent.modalApproveSignUpTitle')"
@submitAction="submitApproveSignUp" v-if="user.pending_admin_approval && user.email_verified" />
<!-- reject sign-up button -->
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#rejectSignUpModal${user.id}`"
v-if="user.pending_admin_approval && user.email_verified"
><font-awesome-icon :icon="['fas', 'fa-xmark']"
/></a>
v-if="user.pending_admin_approval && user.email_verified"><font-awesome-icon
:icon="['fas', 'fa-xmark']" /></a>
<!-- reject sign up modal -->
<ModalComponent
:modalId="`rejectSignUpModal${user.id}`"
:title="t('usersListComponent.modalRejectSignUpTitle')"
<ModalComponent :modalId="`rejectSignUpModal${user.id}`" :title="t('usersListComponent.modalRejectSignUpTitle')"
:body="`${t('usersListComponent.modalRejectSignUpBody1')}<b>${user.username}</b>? ${t('usersListComponent.modalRejectSignUpBody2')}`"
:actionButtonType="`danger`"
:actionButtonText="t('usersListComponent.modalRejectSignUpTitle')"
@submitAction="submitDeleteUser"
v-if="user.pending_admin_approval && user.email_verified"
/>
:actionButtonType="`danger`" :actionButtonText="t('usersListComponent.modalRejectSignUpTitle')"
@submitAction="submitDeleteUser" v-if="user.pending_admin_approval && user.email_verified" />
<!-- change user password button -->
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#editUserPasswordModal${user.id}`"
><font-awesome-icon :icon="['fas', 'fa-key']"
/></a>
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#editUserPasswordModal${user.id}`"><font-awesome-icon :icon="['fas', 'fa-key']" /></a>
<!-- change user password Modal -->
<UsersChangeUserPasswordModalComponent :user="user" />
<!-- edit user button -->
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#editUserModal${user.id}`"
><font-awesome-icon :icon="['fas', 'fa-pen-to-square']"
/></a>
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#editUserModal${user.id}`"><font-awesome-icon :icon="['fas', 'fa-pen-to-square']" /></a>
<!-- edit user modal -->
<UsersAddEditUserModalComponent :action="'edit'" :user="user" @editedUser="editUserList" />
<!-- delete user button -->
<a
class="btn btn-link btn-lg link-body-emphasis"
href="#"
role="button"
data-bs-toggle="modal"
:data-bs-target="`#deleteUserModal${user.id}`"
v-if="authStore.user.id != user.id"
><font-awesome-icon :icon="['fas', 'fa-trash-can']"
/></a>
<a class="btn btn-link btn-lg link-body-emphasis" href="#" role="button" data-bs-toggle="modal"
:data-bs-target="`#deleteUserModal${user.id}`" v-if="authStore.user.id != user.id"><font-awesome-icon
:icon="['fas', 'fa-trash-can']" /></a>
<!-- delete user modal -->
<ModalComponent
:modalId="`deleteUserModal${user.id}`"
:title="t('usersListComponent.modalDeleteUserTitle')"
:body="`${t('usersListComponent.modalDeleteUserBody')}<b>${user.username}</b>?`"
:actionButtonType="`danger`"
:actionButtonText="t('usersListComponent.modalDeleteUserTitle')"
@submitAction="submitDeleteUser"
/>
<ModalComponent :modalId="`deleteUserModal${user.id}`" :title="t('usersListComponent.modalDeleteUserTitle')"
:body="`${t('usersListComponent.modalDeleteUserBody')}<b>${user.username}</b>?`" :actionButtonType="`danger`"
:actionButtonText="t('usersListComponent.modalDeleteUserTitle')" @submitAction="submitDeleteUser" />
</div>
</div>
<div class="collapse" :id="`collapseUserDetails${user.id}`">
<!-- Bootstrap Tabs Navigation -->
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item" role="presentation">
<button
class="nav-link active link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
:id="`sessions-tab-${user.id}`"
data-bs-toggle="tab"
:data-bs-target="`#sessions-${user.id}`"
type="button"
role="tab"
:aria-controls="`sessions-${user.id}`"
aria-selected="true"
>
<button class="nav-link active link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
:id="`sessions-tab-${user.id}`" data-bs-toggle="tab" :data-bs-target="`#sessions-${user.id}`" type="button"
role="tab" :aria-controls="`sessions-${user.id}`" aria-selected="true">
{{ $t('usersListComponent.tabSessions') }}
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
:id="`idps-tab-${user.id}`"
data-bs-toggle="tab"
:data-bs-target="`#idps-${user.id}`"
type="button"
role="tab"
:aria-controls="`idps-${user.id}`"
aria-selected="false"
@click="loadUserIdpsIfNeeded"
>
<button class="nav-link link-body-emphasis link-underline-opacity-0 link-underline-opacity-100-hover"
:id="`idps-tab-${user.id}`" data-bs-toggle="tab" :data-bs-target="`#idps-${user.id}`" type="button"
role="tab" :aria-controls="`idps-${user.id}`" aria-selected="false" @click="loadUserIdpsIfNeeded">
{{ $t('usersListComponent.tabIdentityProviders') }}
</button>
</li>
@@ -183,44 +108,26 @@
<!-- Tab Content -->
<div class="tab-content mt-3">
<!-- Sessions Tab -->
<div
class="tab-pane fade show active"
:id="`sessions-${user.id}`"
role="tabpanel"
:aria-labelledby="`sessions-tab-${user.id}`"
>
<div class="tab-pane fade show active" :id="`sessions-${user.id}`" role="tabpanel"
:aria-labelledby="`sessions-tab-${user.id}`">
<div v-if="isLoadingSessions">
<LoadingComponent />
</div>
<div v-else-if="userSessions && userSessions.length > 0">
<UserSessionsListComponent
v-for="session in userSessions"
:key="session.id"
:session="session"
@sessionDeleted="updateSessionListDeleted"
/>
<UserSessionsListComponent v-for="session in userSessions" :key="session.id" :session="session"
@sessionDeleted="updateSessionListDeleted" />
</div>
<NoItemsFoundComponents :show-shadow="false" v-else />
</div>
<!-- Identity Providers Tab -->
<div
class="tab-pane fade"
:id="`idps-${user.id}`"
role="tabpanel"
:aria-labelledby="`idps-tab-${user.id}`"
>
<div class="tab-pane fade" :id="`idps-${user.id}`" role="tabpanel" :aria-labelledby="`idps-tab-${user.id}`">
<div v-if="isLoadingIdps">
<LoadingComponent />
</div>
<div v-else-if="userIdps && userIdps.length > 0">
<UserIdentityProviderListComponent
v-for="idp in userIdps"
:key="idp.id"
:idp="idp"
:userId="user.id"
@idpDeleted="updateIdpListDeleted"
/>
<UserIdentityProviderListComponent v-for="idp in userIdps" :key="idp.id" :idp="idp" :userId="user.id"
@idpDeleted="updateIdpListDeleted" />
</div>
<NoItemsFoundComponents :show-shadow="false" v-else />
</div>
@@ -230,7 +137,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { users } from '@/services/usersService'
import { useAuthStore } from '@/stores/authStore'
@@ -268,8 +175,8 @@ const emit = defineEmits<{
const { t } = useI18n()
const authStore = useAuthStore()
const userDetails = ref(false)
const userSessions = ref([])
const userIdps = ref([])
const userSessions: Ref<any[]> = ref([])
const userIdps: Ref<UserIdentityProviderEnriched[]> = ref([])
const isLoadingSessions = ref(true)
const isLoadingIdps = ref(false)
const idpsLoaded = ref(false)