navidrome/ui/src/layout/Login.jsx
Deluan Quintão 8e2052ff95
feat(Insights): add anonymous usage data collection (#3543)
* feat(insights): initial code (WIP)

* feat(insights): add more info

* feat(insights): add fs info

* feat(insights): export insights.Data

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(insights): more config info

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(insights): move data struct to its own package

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(insights): omit some attrs if empty

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(insights): send insights to server, add option to disable

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): remove info about anonymous login

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(insights): fix lint

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): disable collector if EnableExternalServices is false

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): fix type casting for 32bit platforms

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): remove EnableExternalServices from the collection (as it will always be false)

Signed-off-by: Deluan <deluan@navidrome.org>

* chore(insights): fix lint

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor(insights): rename function for consistency

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(insights): log the data sent to the collector server

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(insights): add last collection timestamp to the "about" dialog.

Also add opt-out info to the SignUp form

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(insights): only sends the initial data collection after an admin user is created

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(insights): remove dangling comment

Signed-off-by: Deluan <deluan@navidrome.org>

* feat(insights): Translate insights messages

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(insights): reporting empty library

Signed-off-by: Deluan <deluan@navidrome.org>

* refactor: move URL to consts.js

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2024-12-17 17:10:55 -05:00

432 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useCallback, useEffect } from 'react'
import PropTypes from 'prop-types'
import { Field, Form } from 'react-final-form'
import { useDispatch } from 'react-redux'
import Button from '@material-ui/core/Button'
import Card from '@material-ui/core/Card'
import CardActions from '@material-ui/core/CardActions'
import CircularProgress from '@material-ui/core/CircularProgress'
import Link from '@material-ui/core/Link'
import TextField from '@material-ui/core/TextField'
import { ThemeProvider, makeStyles } from '@material-ui/core/styles'
import {
createMuiTheme,
useLogin,
useNotify,
useRefresh,
useSetLocale,
useTranslate,
useVersion,
} from 'react-admin'
import Logo from '../icons/android-icon-192x192.png'
import Notification from './Notification'
import useCurrentTheme from '../themes/useCurrentTheme'
import config from '../config'
import { clearQueue } from '../actions'
import { retrieveTranslation } from '../i18n'
import { INSIGHTS_DOC_URL } from '../consts.js'
const useStyles = makeStyles(
(theme) => ({
main: {
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
alignItems: 'center',
justifyContent: 'flex-start',
background: `url(${config.loginBackgroundURL})`,
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
backgroundPosition: 'center',
},
card: {
minWidth: 300,
marginTop: '6em',
overflow: 'visible',
},
avatar: {
margin: '1em',
display: 'flex',
justifyContent: 'center',
marginTop: '-3em',
},
icon: {
backgroundColor: 'transparent',
width: '6.3em',
height: '6.3em',
},
systemName: {
marginTop: '1em',
display: 'flex',
justifyContent: 'center',
color: '#3f51b5', //theme.palette.grey[500]
},
welcome: {
marginTop: '1em',
padding: '0 1em 1em 1em',
display: 'flex',
justifyContent: 'center',
flexWrap: 'wrap',
color: '#3f51b5', //theme.palette.grey[500]
},
form: {
padding: '0 1em 1em 1em',
},
input: {
marginTop: '1em',
},
actions: {
padding: '0 1em 1em 1em',
},
button: {},
systemNameLink: {
textDecoration: 'none',
},
message: {
marginTop: '1em',
padding: '0 1em 1em 1em',
textAlign: 'center',
wordBreak: 'break-word',
fontSize: '0.875em',
},
}),
{ name: 'NDLogin' },
)
const renderInput = ({
meta: { touched, error } = {},
input: { ...inputProps },
...props
}) => (
<TextField
error={!!(touched && error)}
helperText={touched && error}
{...inputProps}
{...props}
fullWidth
/>
)
const FormLogin = ({ loading, handleSubmit, validate }) => {
const translate = useTranslate()
const classes = useStyles()
return (
<Form
onSubmit={handleSubmit}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit} noValidate>
<div className={classes.main}>
<Card className={classes.card}>
<div className={classes.avatar}>
<img src={Logo} className={classes.icon} alt={'logo'} />
</div>
<div className={classes.systemName}>
<a
href="https://www.navidrome.org"
target="_blank"
rel="noopener noreferrer"
className={classes.systemNameLink}
>
Navidrome
</a>
</div>
{config.welcomeMessage && (
<div
className={classes.welcome}
dangerouslySetInnerHTML={{ __html: config.welcomeMessage }}
/>
)}
<div className={classes.form}>
<div className={classes.input}>
<Field
autoFocus
name="username"
component={renderInput}
label={translate('ra.auth.username')}
disabled={loading}
spellCheck={false}
/>
</div>
<div className={classes.input}>
<Field
name="password"
component={renderInput}
label={translate('ra.auth.password')}
type="password"
disabled={loading}
/>
</div>
</div>
<CardActions className={classes.actions}>
<Button
variant="contained"
type="submit"
color="primary"
disabled={loading}
className={classes.button}
fullWidth
>
{loading && <CircularProgress size={25} thickness={2} />}
{translate('ra.auth.sign_in')}
</Button>
</CardActions>
</Card>
<Notification />
</div>
</form>
)}
/>
)
}
const InsightsNotice = ({ url }) => {
const translate = useTranslate()
const classes = useStyles()
const anchorRegex = /\[(.+?)]/g
const originalMsg = translate('ra.auth.insightsCollectionNote')
// Split the entire message on newlines
const lines = originalMsg.split('\n')
const renderedLines = lines.map((line, lineIndex) => {
const segments = []
let lastIndex = 0
let match
// Find bracketed text in each line
while ((match = anchorRegex.exec(line)) !== null) {
// match.index is where "[something]" starts
// match[1] is the text inside the brackets
const bracketText = match[1]
// Push the text before the bracket
segments.push(line.slice(lastIndex, match.index))
// Push the <Link> component
segments.push(
<Link
href={url}
target="_blank"
rel="noopener noreferrer"
key={`${lineIndex}-${match.index}`}
style={{ cursor: 'pointer' }}
>
{bracketText}
</Link>,
)
// Update lastIndex to the character right after the bracketed text
lastIndex = match.index + match[0].length
}
// Push the remaining text after the last bracket
segments.push(line.slice(lastIndex))
// Return this lines parts, plus a <br/> if not the last line
return (
<React.Fragment key={lineIndex}>
{segments}
{lineIndex < lines.length - 1 && <br />}
</React.Fragment>
)
})
return <div className={classes.message}>{renderedLines}</div>
}
const FormSignUp = ({ loading, handleSubmit, validate }) => {
const translate = useTranslate()
const classes = useStyles()
return (
<Form
onSubmit={handleSubmit}
validate={validate}
render={({ handleSubmit }) => (
<form onSubmit={handleSubmit} noValidate>
<div className={classes.main}>
<Card className={classes.card}>
<div className={classes.avatar}>
<img src={Logo} className={classes.icon} alt={'logo'} />
</div>
<div className={classes.welcome}>
{translate('ra.auth.welcome1')}
</div>
<div className={classes.welcome}>
{translate('ra.auth.welcome2')}
</div>
<div className={classes.form}>
<div className={classes.input}>
<Field
autoFocus
name="username"
component={renderInput}
label={translate('ra.auth.username')}
disabled={loading}
spellCheck={false}
/>
</div>
<div className={classes.input}>
<Field
name="password"
component={renderInput}
label={translate('ra.auth.password')}
type="password"
disabled={loading}
/>
</div>
<div className={classes.input}>
<Field
name="confirmPassword"
component={renderInput}
label={translate('ra.auth.confirmPassword')}
type="password"
disabled={loading}
/>
</div>
</div>
<CardActions className={classes.actions}>
<Button
variant="contained"
type="submit"
color="primary"
disabled={loading}
className={classes.button}
fullWidth
>
{loading && <CircularProgress size={25} thickness={2} />}
{translate('ra.auth.buttonCreateAdmin')}
</Button>
</CardActions>
<InsightsNotice url={INSIGHTS_DOC_URL} />
</Card>
<Notification />
</div>
</form>
)}
/>
)
}
const Login = ({ location }) => {
const [loading, setLoading] = useState(false)
const translate = useTranslate()
const notify = useNotify()
const login = useLogin()
const dispatch = useDispatch()
const handleSubmit = useCallback(
(auth) => {
setLoading(true)
dispatch(clearQueue())
login(auth, location.state ? location.state.nextPathname : '/').catch(
(error) => {
setLoading(false)
notify(
typeof error === 'string'
? error
: typeof error === 'undefined' || !error.message
? 'ra.auth.sign_in_error'
: error.message,
'warning',
)
},
)
},
[dispatch, login, notify, setLoading, location],
)
const validateLogin = useCallback(
(values) => {
const errors = {}
if (!values.username) {
errors.username = translate('ra.validation.required')
}
if (!values.password) {
errors.password = translate('ra.validation.required')
}
return errors
},
[translate],
)
const validateSignup = useCallback(
(values) => {
const errors = validateLogin(values)
const regex = /^\w+$/g
if (values.username && !values.username.match(regex)) {
errors.username = translate('ra.validation.invalidChars')
}
if (!values.confirmPassword) {
errors.confirmPassword = translate('ra.validation.required')
}
if (values.confirmPassword !== values.password) {
errors.confirmPassword = translate('ra.validation.passwordDoesNotMatch')
}
return errors
},
[translate, validateLogin],
)
if (config.firstTime) {
return (
<FormSignUp
handleSubmit={handleSubmit}
validate={validateSignup}
loading={loading}
/>
)
}
return (
<FormLogin
handleSubmit={handleSubmit}
validate={validateLogin}
loading={loading}
/>
)
}
Login.propTypes = {
authProvider: PropTypes.func,
previousRoute: PropTypes.string,
}
// We need to put the ThemeProvider decoration in another component
// Because otherwise the useStyles() hook used in Login won't get
// the right theme
const LoginWithTheme = (props) => {
const theme = useCurrentTheme()
const setLocale = useSetLocale()
const refresh = useRefresh()
const version = useVersion()
useEffect(() => {
if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) {
retrieveTranslation(config.defaultLanguage)
.then(() => {
setLocale(config.defaultLanguage).then(() => {
localStorage.setItem('locale', config.defaultLanguage)
})
refresh(true)
})
.catch((e) => {
throw new Error(
'Cannot load language "' + config.defaultLanguage + '": ' + e,
)
})
}
}, [refresh, setLocale])
return (
<ThemeProvider theme={createMuiTheme(theme)}>
<Login key={version} {...props} />
</ThemeProvider>
)
}
export default LoginWithTheme