Standardise usage of user avatars and colours across the entire platform

This commit is contained in:
Andrew Kingston 2023-05-26 09:24:53 +01:00
parent f246a982db
commit cc7df474c9
22 changed files with 254 additions and 296 deletions

View File

@ -13,10 +13,12 @@
export let url = ""
export let disabled = false
export let initials = "JD"
export let color = null
const DefaultColor = "#3aab87"
$: color = getColor(initials)
$: avatarColor = color || getColor(initials)
$: style = getStyle(size, avatarColor)
const getColor = initials => {
if (!initials?.length) {
@ -26,6 +28,12 @@
const hue = ((code % 26) / 26) * 360
return `hsl(${hue}, 50%, 50%)`
}
const getStyle = (sizeKey, color) => {
const size = `var(${sizes.get(sizeKey)})`
const fontSize = `calc(${size} / 2)`
return `width:${size}; height:${size}; font-size:${fontSize}; background:${color};`
}
</script>
{#if url}
@ -37,13 +45,7 @@
style="width: var({sizes.get(size)}); height: var({sizes.get(size)});"
/>
{:else}
<div
class="spectrum-Avatar"
class:is-disabled={disabled}
style="width: var({sizes.get(size)}); height: var({sizes.get(
size
)}); font-size: calc(var({sizes.get(size)}) / 2); background: {color};"
>
<div class="spectrum-Avatar" class:is-disabled={disabled} {style}>
{initials || ""}
</div>
{/if}

View File

@ -9,11 +9,11 @@ export const getUserStore = () => {
const updateUser = user => {
const $users = get(store)
if (!$users.some(x => x.id === user.id)) {
if (!$users.some(x => x.sessionId === user.sessionId)) {
store.set([...$users, user])
} else {
store.update(state => {
const index = state.findIndex(x => x.id === user.id)
const index = state.findIndex(x => x.sessionId === user.sessionId)
state[index] = user
return state.slice()
})
@ -22,7 +22,7 @@ export const getUserStore = () => {
const removeUser = user => {
store.update(state => {
return state.filter(x => x.id !== user.id)
return state.filter(x => x.sessionId !== user.sessionId)
})
}

View File

@ -1,143 +0,0 @@
<script>
import {
Button,
ButtonGroup,
ModalContent,
Modal,
notifications,
ProgressCircle,
Layout,
Body,
Icon,
} from "@budibase/bbui"
import { auth, apps } from "stores/portal"
import { processStringSync } from "@budibase/string-templates"
import { API } from "api"
export let app
export let buttonSize = "M"
let APP_DEV_LOCK_SECONDS = 600 //common area for this?
let appLockModal
let processing = false
$: lockedBy = app?.lockedBy
$: lockedByYou = $auth.user.email === lockedBy?.email
$: lockIdentifer = `${
lockedBy && lockedBy.firstName ? lockedBy?.firstName : lockedBy?.email
}`
$: lockedByHeading =
lockedBy && lockedByYou ? "Locked by you" : `Locked by ${lockIdentifer}`
const getExpiryDuration = app => {
if (!app?.lockedBy?.lockedAt) {
return -1
}
let expiry =
new Date(app.lockedBy.lockedAt).getTime() + APP_DEV_LOCK_SECONDS * 1000
return expiry - new Date().getTime()
}
const releaseLock = async () => {
processing = true
if (app) {
try {
await API.releaseAppLock(app.devId)
await apps.load()
notifications.success("Lock released successfully")
} catch (err) {
notifications.error("Error releasing lock")
}
} else {
notifications.error("No application is selected")
}
processing = false
}
</script>
{#if lockedBy}
<div class="lock-status">
<Icon
name="User"
hoverable
size={buttonSize}
on:click={e => {
e.stopPropagation()
appLockModal.show()
}}
/>
</div>
{/if}
<Modal bind:this={appLockModal}>
<ModalContent
title={lockedByHeading}
showConfirmButton={false}
showCancelButton={false}
>
<Layout noPadding>
<Body size="S">
Apps are locked to prevent work being lost from overlapping changes
between your team.
</Body>
{#if lockedByYou && getExpiryDuration(app) > 0}
<span class="lock-expiry-body">
{processStringSync(
"This lock will expire in {{ duration time 'millisecond' }} from now.",
{
time: getExpiryDuration(app),
}
)}
</span>
{/if}
<div class="lock-modal-actions">
<ButtonGroup>
<Button
secondary
quiet={lockedBy && lockedByYou}
disabled={processing}
on:click={() => {
appLockModal.hide()
}}
>
<span class="cancel"
>{lockedBy && !lockedByYou ? "Done" : "Cancel"}</span
>
</Button>
{#if lockedByYou}
<Button
cta
disabled={processing}
on:click={() => {
releaseLock()
appLockModal.hide()
}}
>
{#if processing}
<ProgressCircle overBackground={true} size="S" />
{:else}
<span class="unlock">Release Lock</span>
{/if}
</Button>
{/if}
</ButtonGroup>
</div>
</Layout>
</ModalContent>
</Modal>
<style>
.lock-modal-actions {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing-l);
gap: var(--spacing-xl);
}
.lock-status {
display: flex;
gap: var(--spacing-s);
max-width: 175px;
}
</style>

View File

@ -1,13 +1,16 @@
<script>
import { Heading, Body, Button, Icon, notifications } from "@budibase/bbui"
import AppLockModal from "../common/AppLockModal.svelte"
import { Heading, Body, Button, Icon, Tooltip } from "@budibase/bbui"
import { processStringSync } from "@budibase/string-templates"
import { goto } from "@roxi/routify"
import { helpers } from "@budibase/shared-core"
import { UserAvatar } from "@budibase/frontend-core"
export let app
export let lockedAction
$: editing = app?.lockedBy != null
$: initials = helpers.getUserInitials(app?.lockedBy)
const handleDefaultClick = () => {
if (window.innerWidth < 640) {
goToOverview()
@ -38,7 +41,10 @@
</div>
<div class="updated">
{#if app.updatedAt}
{#if editing}
Currently editing
<UserAvatar user={app.lockedBy} />
{:else if app.updatedAt}
{processStringSync("Updated {{ duration time 'millisecond' }} ago", {
time: new Date().getTime() - new Date(app.updatedAt).getTime(),
})}
@ -53,12 +59,12 @@
</div>
<div class="app-row-actions">
<AppLockModal {app} buttonSize="M" />
<Button size="S" secondary on:click={lockedAction || goToOverview}
>Manage</Button
>
<Button size="S" primary on:click={lockedAction || goToBuilder}>Edit</Button
>
<Button size="S" secondary on:click={lockedAction || goToOverview}>
Manage
</Button>
<Button size="S" primary on:click={lockedAction || goToBuilder}>
Edit
</Button>
</div>
</div>
@ -81,6 +87,9 @@
.updated {
color: var(--spectrum-global-color-gray-700);
display: flex;
align-items: center;
gap: 8px;
}
.title,

View File

@ -1,12 +1,22 @@
<script>
import { UserAvatar } from "@budibase/frontend-core"
export let users = []
$: uniqueUsers = unique(users)
const unique = users => {
let uniqueUsers = {}
users?.forEach(user => {
uniqueUsers[user.email] = user
})
return Object.values(uniqueUsers)
}
</script>
<div class="avatars">
{#each users as user}
<div class="avatar" style="background:{user.color};" title={user.email}>
{user.email[0]}
</div>
{#each uniqueUsers as user}
<UserAvatar {user} tooltipDirection="bottom" />
{/each}
</div>
@ -15,19 +25,4 @@
display: flex;
gap: 4px;
}
.avatar {
width: 24px;
height: 24px;
display: grid;
place-items: center;
color: white;
border-radius: 50%;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
line-height: 0;
}
.avatar:hover {
cursor: pointer;
}
</style>

View File

@ -218,7 +218,9 @@
<!-- This should probably be some kind of loading state? -->
<div class="loading" />
{:then _}
<slot />
<div class="body">
<slot />
</div>
{:catch error}
<p>Something went wrong: {error.message}</p>
{/await}
@ -254,6 +256,7 @@
box-sizing: border-box;
align-items: stretch;
border-bottom: var(--border-light);
z-index: 2;
}
.topleftnav {
@ -294,4 +297,11 @@
flex-direction: row;
gap: 8px;
}
.body {
flex: 1 1 auto;
z-index: 1;
display: flex;
flex-direction: column;
}
</style>

View File

@ -5,7 +5,6 @@
Divider,
ActionMenu,
MenuItem,
Avatar,
Page,
Icon,
Body,
@ -22,6 +21,8 @@
import { processStringSync } from "@budibase/string-templates"
import Spaceman from "assets/bb-space-man.svg"
import Logo from "assets/bb-emblem.svg"
import { UserAvatar } from "@budibase/frontend-core"
import { helpers } from "@budibase/shared-core"
let loaded = false
let userInfoModal
@ -96,11 +97,7 @@
<img class="logo" alt="logo" src={$organisation.logoUrl || Logo} />
<ActionMenu align="right">
<div slot="control" class="avatar">
<Avatar
size="M"
initials={$auth.initials}
url={$auth.user.pictureUrl}
/>
<UserAvatar user={$auth.user} showTooltip={false} />
<Icon size="XL" name="ChevronDown" />
</div>
<MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}>
@ -125,7 +122,7 @@
</div>
<Layout noPadding gap="XS">
<Heading size="M">
Hey {$auth.user.firstName || $auth.user.email}
Hey {helpers.getUserLabel($auth.user)}
</Heading>
<Body>
Welcome to the {$organisation.company} portal. Below you'll find the

View File

@ -1,11 +1,12 @@
<script>
import { auth } from "stores/portal"
import { ActionMenu, Avatar, MenuItem, Icon, Modal } from "@budibase/bbui"
import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import ProfileModal from "components/settings/ProfileModal.svelte"
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
import ThemeModal from "components/settings/ThemeModal.svelte"
import APIKeyModal from "components/settings/APIKeyModal.svelte"
import { UserAvatar } from "@budibase/frontend-core"
let themeModal
let profileModal
@ -23,7 +24,7 @@
<ActionMenu align="right">
<div slot="control" class="user-dropdown">
<Avatar size="M" initials={$auth.initials} url={$auth.user.pictureUrl} />
<UserAvatar user={$auth.user} showTooltip={false} />
<Icon size="XL" name="ChevronDown" />
</div>
<MenuItem icon="UserEdit" on:click={() => profileModal.show()}>

View File

@ -1,15 +1,10 @@
<script>
import { Avatar, Tooltip } from "@budibase/bbui"
import { Tooltip } from "@budibase/bbui"
import { UserAvatar } from "@budibase/frontend-core"
export let row
let showTooltip
const getInitials = user => {
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials === "" ? user.email[0] : initials
}
</script>
{#if row?.user?.email}
@ -19,7 +14,7 @@
on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
>
<Avatar size="M" initials={getInitials(row.user)} />
<UserAvatar user={row.user} />
</div>
{#if showTooltip}
<div class="tooltip">

View File

@ -24,7 +24,6 @@
import { AppStatus } from "constants"
import analytics, { Events, EventSource } from "analytics"
import { store } from "builderStore"
import AppLockModal from "components/common/AppLockModal.svelte"
import EditableIcon from "components/common/EditableIcon.svelte"
import { API } from "api"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -128,7 +127,6 @@
/>
</div>
<div slot="buttons">
<AppLockModal {app} />
<span class="desktop">
<Button
size="M"

View File

@ -1,14 +1,11 @@
<script>
import getUserInitials from "helpers/userInitials.js"
import { Avatar } from "@budibase/bbui"
import { UserAvatar } from "@budibase/frontend-core"
export let value
$: initials = getUserInitials(value)
</script>
<div title={value.email} class="cell">
<Avatar size="M" {initials} />
<div class="cell">
<UserAvatar user={value} />
</div>
<style>

View File

@ -15,11 +15,12 @@
import { store } from "builderStore"
import { processStringSync } from "@budibase/string-templates"
import { users, auth, apps, groups, overview } from "stores/portal"
import { fetchData } from "@budibase/frontend-core"
import { fetchData, UserAvatar } from "@budibase/frontend-core"
import { API } from "api"
import GroupIcon from "../../users/groups/_components/GroupIcon.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
import { helpers } from "@budibase/shared-core"
let appEditor
let unpublishModal
@ -56,14 +57,6 @@
appEditor = await users.get(editorId)
}
const getInitials = user => {
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials === "" ? user.email[0] : initials
}
const confirmUnpublishApp = async () => {
try {
await API.unpublishApp(app.prodId)
@ -140,7 +133,7 @@
<div class="last-edited-content">
<div class="updated-by">
{#if appEditor}
<Avatar size="M" initials={getInitials(appEditor)} />
<UserAvatar user={appEditor} showTooltip={false} />
<div class="editor-name">
{appEditor._id === $auth.user._id ? "You" : appEditorText}
</div>
@ -201,7 +194,7 @@
<div class="users">
<div class="list">
{#each appUsers.slice(0, 4) as user}
<Avatar size="M" initials={getInitials(user)} />
<UserAvatar {user} />
{/each}
</div>
<div class="text">

View File

@ -2,7 +2,6 @@
import { goto, url } from "@roxi/routify"
import {
ActionMenu,
Avatar,
Button,
Layout,
Heading,
@ -25,13 +24,14 @@
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import DeleteUserModal from "./_components/DeleteUserModal.svelte"
import GroupIcon from "../groups/_components/GroupIcon.svelte"
import { Constants } from "@budibase/frontend-core"
import { Constants, UserAvatar } from "@budibase/frontend-core"
import { Breadcrumbs, Breadcrumb } from "components/portal/page"
import RemoveGroupTableRenderer from "./_components/RemoveGroupTableRenderer.svelte"
import GroupNameTableRenderer from "../groups/_components/GroupNameTableRenderer.svelte"
import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte"
import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte"
import ScimBanner from "../_components/SCIMBanner.svelte"
import { helpers } from "@budibase/shared-core"
export let userId
@ -91,7 +91,7 @@
$: readonly = !$auth.isAdmin || scimEnabled
$: privileged = user?.admin?.global || user?.builder?.global
$: nameLabel = getNameLabel(user)
$: initials = getInitials(nameLabel)
$: initials = helpers.getUserInitials(user)
$: filteredGroups = getFilteredGroups($groups, searchTerm)
$: availableApps = getAvailableApps($apps, privileged, user?.roles)
$: userGroups = $groups.filter(x => {
@ -150,17 +150,6 @@
return label
}
const getInitials = nameLabel => {
if (!nameLabel) {
return "?"
}
return nameLabel
.split(" ")
.slice(0, 2)
.map(x => x[0])
.join("")
}
async function updateUserFirstName(evt) {
try {
await users.save({ ...user, firstName: evt.target.value })
@ -238,7 +227,7 @@
<div class="title">
<div class="user-info">
<Avatar size="XXL" {initials} />
<UserAvatar size="XXL" {user} showTooltip={false} />
<div class="subtitle">
<Heading size="M">{nameLabel}</Heading>
{#if nameLabel !== user?.email}

View File

@ -0,0 +1,58 @@
<script>
import { Avatar, Tooltip } from "@budibase/bbui"
import { helpers } from "@budibase/shared-core"
export let user
export let size
export let tooltipDirection = "top"
export let showTooltip = true
$: tooltipStyle = getTooltipStyle(tooltipDirection)
const getTooltipStyle = direction => {
if (!direction) {
return ""
}
if (direction === "top") {
return "transform: translateX(-50%) translateY(-100%);"
} else if (direction === "bottom") {
return "transform: translateX(-50%) translateY(100%);"
}
}
</script>
{#if user}
<div class="user-avatar">
<Avatar
{size}
initials={helpers.getUserInitials(user)}
color={helpers.getUserColor(user)}
/>
{#if showTooltip}
<div class="tooltip" style={tooltipStyle}>
<Tooltip
direction={tooltipDirection}
textWrapping
text={user.email}
size="S"
/>
</div>
{/if}
</div>
{/if}
<style>
.user-avatar {
position: relative;
}
.tooltip {
display: none;
position: absolute;
top: 0;
left: 50%;
white-space: nowrap;
}
.user-avatar:hover .tooltip {
display: block;
}
</style>

View File

@ -1,24 +0,0 @@
<script>
export let user
</script>
<div class="user" style="background:{user.color};" title={user.email}>
{user.email[0]}
</div>
<style>
div {
width: 24px;
height: 24px;
display: grid;
place-items: center;
color: white;
border-radius: 50%;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
div:hover {
cursor: pointer;
}
</style>

View File

@ -1,13 +1,23 @@
<script>
import { getContext } from "svelte"
import Avatar from "./Avatar.svelte"
import UserAvatar from "../../UserAvatar.svelte"
const { users } = getContext("grid")
$: uniqueUsers = unique($users)
const unique = users => {
let uniqueUsers = {}
users?.forEach(user => {
uniqueUsers[user.email] = user
})
return Object.values(uniqueUsers)
}
</script>
<div class="users">
{#each $users as user}
<Avatar {user} />
{#each uniqueUsers as user}
<UserAvatar {user} />
{/each}
</div>

View File

@ -1,10 +1,22 @@
import { writable, get, derived } from "svelte/store"
import { helpers } from "@budibase/shared-core"
export const createStores = () => {
const users = writable([])
const enrichedUsers = derived(users, $users => {
return $users.map(user => ({
...user,
color: helpers.getUserColor(user),
label: helpers.getUserLabel(user),
}))
})
return {
users,
users: {
...users,
subscribe: enrichedUsers.subscribe,
},
}
}
@ -28,11 +40,11 @@ export const deriveStores = context => {
const updateUser = user => {
const $users = get(users)
if (!$users.some(x => x.id === user.id)) {
if (!$users.some(x => x.sessionId === user.sessionId)) {
users.set([...$users, user])
} else {
users.update(state => {
const index = state.findIndex(x => x.id === user.id)
const index = state.findIndex(x => x.sessionId === user.sessionId)
state[index] = user
return state.slice()
})
@ -41,7 +53,7 @@ export const deriveStores = context => {
const removeUser = user => {
users.update(state => {
return state.filter(x => x.id !== user.id)
return state.filter(x => x.sessionId !== user.sessionId)
})
}

View File

@ -1,4 +1,5 @@
export { default as SplitPage } from "./SplitPage.svelte"
export { default as TestimonialPage } from "./TestimonialPage.svelte"
export { default as Testimonial } from "./Testimonial.svelte"
export { default as UserAvatar } from "./UserAvatar.svelte"
export { Grid } from "./grid"

View File

@ -5,6 +5,7 @@ import http from "http"
import Koa from "koa"
import { Datasource, Table } from "@budibase/types"
import { gridSocket } from "./index"
import { clearLock } from "../utilities/redis"
export default class BuilderSocket extends Socket {
constructor(app: Koa, server: http.Server) {
@ -15,8 +16,7 @@ export default class BuilderSocket extends Socket {
const user = socket.data.user
const appId = socket.data.appId
socket.join(appId)
socket.to(appId).emit("user-update", socket.data.user)
console.log(`Builder user connected: ${user?.id}`)
socket.to(appId).emit("user-update", user)
// Initial identification of connected spreadsheet
socket.on("get-users", async (payload, callback) => {
@ -27,8 +27,22 @@ export default class BuilderSocket extends Socket {
})
// Disconnection cleanup
socket.on("disconnect", () => {
socket.to(appId).emit("user-disconnect", socket.data.user)
socket.on("disconnect", async () => {
socket.to(appId).emit("user-disconnect", user)
// Remove app lock from this user if they have no other connections
try {
const sockets = await this.io.in(appId).fetchSockets()
const hasOtherConnection = sockets.some(socket => {
const { _id, sessionId } = socket.data.user
return _id === user._id && sessionId !== user.sessionId
})
if (!hasOtherConnection) {
await clearLock(appId, user)
}
} catch (e) {
// This is fine, just means this user didn't hold the lock
}
})
})
}

View File

@ -12,7 +12,6 @@ export default class GridSocket extends Socket {
this.io.on("connection", socket => {
const user = socket.data.user
console.log(`Spreadsheet user connected: ${user?.id}`)
// Socket state
let currentRoom: string
@ -21,14 +20,14 @@ export default class GridSocket extends Socket {
socket.on("select-table", async (tableId, callback) => {
// Leave current room
if (currentRoom) {
socket.to(currentRoom).emit("user-disconnect", socket.data.user)
socket.to(currentRoom).emit("user-disconnect", user)
socket.leave(currentRoom)
}
// Join new room
currentRoom = tableId
socket.join(currentRoom)
socket.to(currentRoom).emit("user-update", socket.data.user)
socket.to(currentRoom).emit("user-update", user)
// Reply with all users in current room
const sockets = await this.io.in(currentRoom).fetchSockets()
@ -41,14 +40,14 @@ export default class GridSocket extends Socket {
socket.on("select-cell", cellId => {
socket.data.user.focusedCellId = cellId
if (currentRoom) {
socket.to(currentRoom).emit("user-update", socket.data.user)
socket.to(currentRoom).emit("user-update", user)
}
})
// Disconnection cleanup
socket.on("disconnect", () => {
if (currentRoom) {
socket.to(currentRoom).emit("user-disconnect", socket.data.user)
socket.to(currentRoom).emit("user-disconnect", user)
}
})
})

View File

@ -7,6 +7,7 @@ import { auth } from "@budibase/backend-core"
import currentApp from "../middleware/currentapp"
import { createAdapter } from "@socket.io/redis-adapter"
import { getSocketPubSubClients } from "../utilities/redis"
import uuid from "uuid"
export default class Socket {
io: Server
@ -64,29 +65,14 @@ export default class Socket {
// Middlewares are finished
// Extract some data from our enriched koa context to persist
// as metadata for the socket
// Add user info, including a deterministic color and friendly
// label
// Add user info, including a deterministic color and label
const { _id, email, firstName, lastName } = ctx.user
let hue = 1
for (let i = 0; i < email.length && i < 5; i++) {
hue *= email.charCodeAt(i + 1)
hue /= 17
}
hue = hue % 360
const color = `hsl(${hue}, 50%, 40%)`
let label = email
if (firstName) {
label = firstName
if (lastName) {
label += ` ${lastName}`
}
}
socket.data.user = {
id: _id,
_id,
email,
color,
label,
firstName,
lastName,
sessionId: uuid.v4(),
}
// Add app ID to help split sockets into rooms

View File

@ -1,3 +1,5 @@
import { User } from "@budibase/types"
/**
* Gets a key within an object. The key supports dot syntax for retrieving deep
* fields - e.g. "a.b.c".
@ -21,3 +23,60 @@ export const deepGet = (obj: { [x: string]: any }, key: string) => {
}
return obj
}
/**
* Gets the initials to show in a user avatar.
* @param user the user
*/
export const getUserInitials = (user: User) => {
if (!user) {
return "?"
}
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials === "" ? user.email[0] : initials
}
/**
* Gets a deterministic colour for a particular user
* @param user the user
*/
export const getUserColor = (user: User) => {
let id = user?._id
if (!id) {
return "var(--spectrum-global-color-blue-400)"
}
// In order to generate the same color for global users as app users, we need
// to remove the app-specific table prefix
id = id.replace("ro_ta_users_", "")
// Generate a hue based on the ID
let hue = 1
for (let i = 0; i < id.length; i++) {
hue += id.charCodeAt(i)
hue = hue % 36
}
return `hsl(${hue * 10}, 50%, 40%)`
}
/**
* Gets a friendly label to describe who a user is.
* @param user the user
*/
export const getUserLabel = (user: User) => {
if (!user) {
return ""
}
const { firstName, lastName, email } = user
if (firstName && lastName) {
return `${firstName} ${lastName}`
} else if (firstName) {
return firstName
} else if (lastName) {
return lastName
} else {
return email
}
}