From 9d7995fd4d23e928d508dbf06d19df3f8951aed8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 13 Nov 2020 00:40:01 -0500 Subject: [PATCH] Redesign UserMenu, now with support for Gravatar --- conf/configuration.go | 2 + core/gravatar/gravatar.go | 25 +++++++ core/gravatar/gravatar_test.go | 36 ++++++++++ server/app/auth.go | 21 +++--- ui/src/authProvider.js | 3 + ui/src/layout/AppBar.js | 2 +- ui/src/layout/UserMenu.js | 116 +++++++++++++++++++++++++++++++++ 7 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 core/gravatar/gravatar.go create mode 100644 core/gravatar/gravatar_test.go create mode 100644 ui/src/layout/UserMenu.js diff --git a/conf/configuration.go b/conf/configuration.go index e2a467e7..2b0a14ff 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -38,6 +38,7 @@ type configOptions struct { CoverArtPriority string CoverJpegQuality int UIWelcomeMessage string + EnableGravatar bool GATrackingID string AuthRequestLimit int AuthWindowLength time.Duration @@ -124,6 +125,7 @@ func init() { viper.SetDefault("coverartpriority", "embedded, cover.*, folder.*, front.*") viper.SetDefault("coverjpegquality", 75) viper.SetDefault("uiwelcomemessage", "") + viper.SetDefault("enablegravatar", false) viper.SetDefault("gatrackingid", "") viper.SetDefault("authrequestlimit", 5) viper.SetDefault("authwindowlength", 20*time.Second) diff --git a/core/gravatar/gravatar.go b/core/gravatar/gravatar.go new file mode 100644 index 00000000..e0ac6cb6 --- /dev/null +++ b/core/gravatar/gravatar.go @@ -0,0 +1,25 @@ +package gravatar + +import ( + "crypto/md5" + "fmt" + "strings" + + "github.com/deluan/navidrome/utils" +) + +const baseUrl = "https://www.gravatar.com/avatar" +const defaultSize = 80 +const maxSize = 2048 + +func Url(email string, size int) string { + email = strings.ToLower(email) + email = strings.TrimSpace(email) + hash := md5.Sum([]byte(email)) + if size < 1 { + size = defaultSize + } + size = utils.MinInt(maxSize, size) + + return fmt.Sprintf("%s/%x?s=%d", baseUrl, hash, size) +} diff --git a/core/gravatar/gravatar_test.go b/core/gravatar/gravatar_test.go new file mode 100644 index 00000000..230f6519 --- /dev/null +++ b/core/gravatar/gravatar_test.go @@ -0,0 +1,36 @@ +package gravatar_test + +import ( + "testing" + + "github.com/deluan/navidrome/core/gravatar" + "github.com/deluan/navidrome/log" + "github.com/deluan/navidrome/tests" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestGravatar(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelCritical) + RegisterFailHandler(Fail) + RunSpecs(t, "Gravatar Test Suite") +} + +var _ = Describe("Gravatar", func() { + It("returns a well formatted gravatar URL", func() { + Expect(gravatar.Url("my@email.com", 100)).To(Equal("https://www.gravatar.com/avatar/4f384e9f3e8e625aae72b52658323d70?s=100")) + }) + It("sets the default size", func() { + Expect(gravatar.Url("my@email.com", 0)).To(Equal("https://www.gravatar.com/avatar/4f384e9f3e8e625aae72b52658323d70?s=80")) + }) + It("caps maximum size", func() { + Expect(gravatar.Url("my@email.com", 3000)).To(Equal("https://www.gravatar.com/avatar/4f384e9f3e8e625aae72b52658323d70?s=2048")) + }) + It("ignores case", func() { + Expect(gravatar.Url("MY@email.com", 0)).To(Equal(gravatar.Url("my@email.com", 0))) + }) + It("ignores spaces", func() { + Expect(gravatar.Url(" my@email.com ", 0)).To(Equal(gravatar.Url("my@email.com", 0))) + }) +}) diff --git a/server/app/auth.go b/server/app/auth.go index 2fe29acc..ebccfba6 100644 --- a/server/app/auth.go +++ b/server/app/auth.go @@ -8,8 +8,10 @@ import ( "strings" "time" + "github.com/deluan/navidrome/conf" "github.com/deluan/navidrome/consts" "github.com/deluan/navidrome/core/auth" + "github.com/deluan/navidrome/core/gravatar" "github.com/deluan/navidrome/log" "github.com/deluan/navidrome/model" "github.com/deluan/navidrome/model/request" @@ -55,14 +57,17 @@ func handleLogin(ds model.DataStore, username string, password string, w http.Re _ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again") return } - _ = rest.RespondWithJSON(w, http.StatusOK, - map[string]interface{}{ - "message": "User '" + username + "' authenticated successfully", - "token": tokenString, - "name": user.Name, - "username": username, - "isAdmin": user.IsAdmin, - }) + payload := map[string]interface{}{ + "message": "User '" + username + "' authenticated successfully", + "token": tokenString, + "name": user.Name, + "username": username, + "isAdmin": user.IsAdmin, + } + if conf.Server.EnableGravatar && user.Email != "" { + payload["avatar"] = gravatar.Url(user.Email, 50) + } + _ = rest.RespondWithJSON(w, http.StatusOK, payload) } func getCredentialsFromBody(r *http.Request) (username string, password string, err error) { diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js index 386d79e5..a1f44949 100644 --- a/ui/src/authProvider.js +++ b/ui/src/authProvider.js @@ -28,6 +28,7 @@ const authProvider = { localStorage.setItem('token', response.token) localStorage.setItem('name', response.name) localStorage.setItem('username', response.username) + response.avatar && localStorage.setItem('avatar', response.avatar) localStorage.setItem('role', response.isAdmin ? 'admin' : 'regular') const salt = generateSubsonicSalt() localStorage.setItem('subsonic-salt', salt) @@ -76,6 +77,7 @@ const authProvider = { return { id: localStorage.getItem('username'), fullName: localStorage.getItem('name'), + avatar: localStorage.getItem('avatar'), } }, } @@ -84,6 +86,7 @@ const removeItems = () => { localStorage.removeItem('token') localStorage.removeItem('name') localStorage.removeItem('username') + localStorage.removeItem('avatar') localStorage.removeItem('role') localStorage.removeItem('subsonic-salt') localStorage.removeItem('subsonic-token') diff --git a/ui/src/layout/AppBar.js b/ui/src/layout/AppBar.js index 7080eb1e..a978b2af 100644 --- a/ui/src/layout/AppBar.js +++ b/ui/src/layout/AppBar.js @@ -1,7 +1,6 @@ import React, { createElement, forwardRef } from 'react' import { AppBar as RAAppBar, - UserMenu, MenuItemLink, useTranslate, usePermissions, @@ -14,6 +13,7 @@ import InfoIcon from '@material-ui/icons/Info' import AboutDialog from './AboutDialog' import PersonalMenu from './PersonalMenu' import ActivityPanel from './ActivityPanel' +import UserMenu from './UserMenu' import config from '../config' const useStyles = makeStyles((theme) => ({ diff --git a/ui/src/layout/UserMenu.js b/ui/src/layout/UserMenu.js new file mode 100644 index 00000000..1fd90fca --- /dev/null +++ b/ui/src/layout/UserMenu.js @@ -0,0 +1,116 @@ +import * as React from 'react' +import { Children, cloneElement, isValidElement, useState } from 'react' +import PropTypes from 'prop-types' +import { useTranslate, useGetIdentity } from 'react-admin' +import { + Tooltip, + IconButton, + Popover, + MenuList, + Button, + Avatar, + Card, + CardContent, + Divider, + Typography, +} from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import AccountCircle from '@material-ui/icons/AccountCircle' + +const useStyles = makeStyles((theme) => ({ + user: {}, + userButton: { + textTransform: 'none', + }, + avatar: { + width: theme.spacing(4), + height: theme.spacing(4), + }, + username: { + marginTop: '-0.5em', + }, +})) + +const UserMenu = (props) => { + const [anchorEl, setAnchorEl] = useState(null) + const translate = useTranslate() + const { loaded, identity } = useGetIdentity() + const classes = useStyles(props) + + const { children, label, icon, logout } = props + if (!logout && !children) return null + const open = Boolean(anchorEl) + + const handleMenu = (event) => setAnchorEl(event.currentTarget) + const handleClose = () => setAnchorEl(null) + + return ( +
+ + + {loaded && identity.avatar ? ( + + ) : ( + icon + )} + + + + + {loaded && ( + + + {identity.fullName} + + + + )} + {Children.map(children, (menuItem) => + isValidElement(menuItem) + ? cloneElement(menuItem, { + onClick: handleClose, + }) + : null + )} + {logout} + + +
+ ) +} + +UserMenu.propTypes = { + children: PropTypes.node, + label: PropTypes.string.isRequired, + logout: PropTypes.element, +} + +UserMenu.defaultProps = { + label: 'ra.auth.user_menu', + icon: , +} + +export default UserMenu