Redesign UserMenu, now with support for Gravatar

This commit is contained in:
Deluan 2020-11-13 00:40:01 -05:00
parent 7efc32d136
commit 9d7995fd4d
7 changed files with 196 additions and 9 deletions

View File

@ -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)

25
core/gravatar/gravatar.go Normal file
View File

@ -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)
}

View File

@ -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)))
})
})

View File

@ -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) {

View File

@ -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')

View File

@ -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) => ({

116
ui/src/layout/UserMenu.js Normal file
View File

@ -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 (
<div className={classes.user}>
<Tooltip title={label && translate(label, { _: label })}>
<IconButton
aria-label={label && translate(label, { _: label })}
aria-owns={open ? 'menu-appbar' : null}
aria-haspopup={true}
color="inherit"
onClick={handleMenu}
>
{loaded && identity.avatar ? (
<Avatar
className={classes.avatar}
src={identity.avatar}
alt={identity.fullName}
/>
) : (
icon
)}
</IconButton>
</Tooltip>
<Popover
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
open={open}
onClose={handleClose}
>
<MenuList>
{loaded && (
<Card elevation={0} className={classes.username}>
<CardContent>
<Typography variant={'button'}>{identity.fullName}</Typography>
</CardContent>
<Divider />
</Card>
)}
{Children.map(children, (menuItem) =>
isValidElement(menuItem)
? cloneElement(menuItem, {
onClick: handleClose,
})
: null
)}
{logout}
</MenuList>
</Popover>
</div>
)
}
UserMenu.propTypes = {
children: PropTypes.node,
label: PropTypes.string.isRequired,
logout: PropTypes.element,
}
UserMenu.defaultProps = {
label: 'ra.auth.user_menu',
icon: <AccountCircle />,
}
export default UserMenu