diff --git a/cypress/e2e/user/user-list.cy.ts b/cypress/e2e/user/user-list.cy.ts index 503bd23f..82117023 100644 --- a/cypress/e2e/user/user-list.cy.ts +++ b/cypress/e2e/user/user-list.cy.ts @@ -1,5 +1,5 @@ const testUser = { - displayName: 'Test User', + username: 'Test User', emailAddress: 'test@seeerr.dev', password: 'test1234', }; @@ -32,7 +32,7 @@ describe('User List', () => { cy.get('[data-testid=modal-title]').should('contain', 'Create Local User'); - cy.get('#displayName').type(testUser.displayName); + cy.get('#username').type(testUser.username); cy.get('#email').type(testUser.emailAddress); cy.get('#password').type(testUser.password); diff --git a/server/api/externalapi.ts b/server/api/externalapi.ts index 752601bf..10355800 100644 --- a/server/api/externalapi.ts +++ b/server/api/externalapi.ts @@ -68,7 +68,10 @@ class ExternalAPI { if (!response.ok) { const text = await response.text(); throw new Error( - `${response.status} ${response.statusText}${text ? ': ' + text : ''}` + `${response.status} ${response.statusText}${text ? ': ' + text : ''}`, + { + cause: response, + } ); } const data = await this.getDataFromResponse(response); @@ -106,6 +109,15 @@ class ExternalAPI { }, body: JSON.stringify(data), }); + if (!response.ok) { + const text = await response.text(); + throw new Error( + `${response.status} ${response.statusText}${text ? ': ' + text : ''}`, + { + cause: response, + } + ); + } const resData = await this.getDataFromResponse(response); if (this.cache) { @@ -141,6 +153,15 @@ class ExternalAPI { }, body: JSON.stringify(data), }); + if (!response.ok) { + const text = await response.text(); + throw new Error( + `${response.status} ${response.statusText}${text ? ': ' + text : ''}`, + { + cause: response, + } + ); + } const resData = await this.getDataFromResponse(response); if (this.cache) { @@ -163,6 +184,15 @@ class ExternalAPI { ...config?.headers, }, }); + if (!response.ok) { + const text = await response.text(); + throw new Error( + `${response.status} ${response.statusText}${text ? ': ' + text : ''}`, + { + cause: response, + } + ); + } const data = await this.getDataFromResponse(response); return data; @@ -197,6 +227,17 @@ class ExternalAPI { ...config?.headers, }, }).then(async (response) => { + if (!response.ok) { + const text = await response.text(); + throw new Error( + `${response.status} ${response.statusText}${ + text ? ': ' + text : '' + }`, + { + cause: response, + } + ); + } const data = await this.getDataFromResponse(response); this.cache?.set(cacheKey, data, ttl ?? DEFAULT_TTL); }); @@ -212,6 +253,15 @@ class ExternalAPI { ...config?.headers, }, }); + if (!response.ok) { + const text = await response.text(); + throw new Error( + `${response.status} ${response.statusText}${text ? ': ' + text : ''}`, + { + cause: response, + } + ); + } const data = await this.getDataFromResponse(response); if (this.cache) { @@ -250,13 +300,13 @@ class ExternalAPI { } private async getDataFromResponse(response: Response) { - const contentType = response.headers.get('Content-Type')?.split(';')[0]; - if (contentType === 'application/json') { + const contentType = response.headers.get('Content-Type'); + if (contentType?.includes('application/json')) { return await response.json(); } else if ( - contentType === 'application/xml' || - contentType === 'text/html' || - contentType === 'text/plain' + contentType?.includes('application/xml') || + contentType?.includes('text/html') || + contentType?.includes('text/plain') ) { return await response.text(); } else { diff --git a/server/api/jellyfin.ts b/server/api/jellyfin.ts index 42864045..ff5144ce 100644 --- a/server/api/jellyfin.ts +++ b/server/api/jellyfin.ts @@ -152,7 +152,7 @@ class JellyfinAPI extends ExternalAPI { try { return await authenticate(false); } catch (e) { - const status = e.response?.status; + const status = e.cause?.status; const networkErrorCodes = new Set([ 'ECONNREFUSED', @@ -190,7 +190,7 @@ class JellyfinAPI extends ExternalAPI { return systemInfoResponse; } catch (e) { - throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); } } @@ -207,7 +207,7 @@ class JellyfinAPI extends ExternalAPI { { label: 'Jellyfin API' } ); - throw new ApiError(e.response?.status, ApiErrorCode.Unknown); + throw new ApiError(e.cause?.status, ApiErrorCode.Unknown); } } @@ -222,7 +222,7 @@ class JellyfinAPI extends ExternalAPI { { label: 'Jellyfin API' } ); - throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); } } @@ -238,7 +238,7 @@ class JellyfinAPI extends ExternalAPI { { label: 'Jellyfin API' } ); - throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); } } @@ -317,7 +317,7 @@ class JellyfinAPI extends ExternalAPI { { label: 'Jellyfin API' } ); - throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); } } @@ -338,7 +338,7 @@ class JellyfinAPI extends ExternalAPI { { label: 'Jellyfin API' } ); - throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); } } @@ -353,7 +353,7 @@ class JellyfinAPI extends ExternalAPI { return itemResponse; } catch (e) { if (availabilitySync.running) { - if (e.response && e.response.status === 500) { + if (e.cause?.status === 500) { return undefined; } } @@ -362,7 +362,7 @@ class JellyfinAPI extends ExternalAPI { `Something went wrong while getting library content from the Jellyfin server: ${e.message}`, { label: 'Jellyfin API' } ); - throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); } } @@ -377,7 +377,7 @@ class JellyfinAPI extends ExternalAPI { { label: 'Jellyfin API' } ); - throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); } } @@ -402,7 +402,7 @@ class JellyfinAPI extends ExternalAPI { { label: 'Jellyfin API' } ); - throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken); + throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken); } } } diff --git a/server/api/servarr/radarr.ts b/server/api/servarr/radarr.ts index e05d4071..51d30037 100644 --- a/server/api/servarr/radarr.ts +++ b/server/api/servarr/radarr.ts @@ -179,13 +179,20 @@ class RadarrAPI extends ServarrBase<{ movieId: number }> { } return data; } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error( 'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.', { label: 'Radarr', errorMessage: e.message, options, - response: e?.response?.data, + response: errorData, } ); throw new Error('Failed to add movie to Radarr'); diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 1650b6d0..67c9dd2a 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -257,11 +257,18 @@ class SonarrAPI extends ServarrBase<{ return createdSeriesData; } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Something went wrong while adding a series to Sonarr.', { label: 'Sonarr API', errorMessage: e.message, options, - response: e?.response?.data, + response: errorData, }); throw new Error('Failed to add series'); } diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 1585af5a..e949e3e1 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -291,7 +291,7 @@ class DiscordAgent } } - await fetch(settings.options.webhookUrl, { + const response = await fetch(settings.options.webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -305,15 +305,25 @@ class DiscordAgent content: userMentions.join(' '), } as DiscordWebhookPayload), }); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } return true; } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error sending Discord notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return false; diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts index c390fbf8..299effe4 100644 --- a/server/lib/notifications/agents/gotify.ts +++ b/server/lib/notifications/agents/gotify.ts @@ -132,22 +132,32 @@ class GotifyAgent const endpoint = `${settings.options.url}/message?token=${settings.options.token}`; const notificationPayload = this.getNotificationPayload(type, payload); - await fetch(endpoint, { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(notificationPayload), }); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } return true; } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error sending Gotify notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return false; diff --git a/server/lib/notifications/agents/lunasea.ts b/server/lib/notifications/agents/lunasea.ts index 1e8e3efa..b8e47384 100644 --- a/server/lib/notifications/agents/lunasea.ts +++ b/server/lib/notifications/agents/lunasea.ts @@ -100,7 +100,7 @@ class LunaSeaAgent }); try { - await fetch(settings.options.webhookUrl, { + const response = await fetch(settings.options.webhookUrl, { method: 'POST', headers: settings.options.profileName ? { @@ -114,15 +114,25 @@ class LunaSeaAgent }, body: JSON.stringify(this.buildPayload(type, payload)), }); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } return true; } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error sending LunaSea notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return false; diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts index 68aa911f..882e8276 100644 --- a/server/lib/notifications/agents/pushbullet.ts +++ b/server/lib/notifications/agents/pushbullet.ts @@ -122,7 +122,7 @@ class PushbulletAgent }); try { - await fetch(endpoint, { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -133,13 +133,23 @@ class PushbulletAgent channel_tag: settings.options.channelTag, }), }); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error sending Pushbullet notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return false; @@ -164,7 +174,7 @@ class PushbulletAgent }); try { - await fetch(endpoint, { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -172,14 +182,24 @@ class PushbulletAgent }, body: JSON.stringify(notificationPayload), }); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error sending Pushbullet notification', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return false; @@ -215,7 +235,7 @@ class PushbulletAgent }); try { - await fetch(endpoint, { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -223,14 +243,24 @@ class PushbulletAgent }, body: JSON.stringify(notificationPayload), }); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error sending Pushbullet notification', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return false; diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index e2a0650e..abdb78f2 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -52,6 +52,9 @@ class PushoverAgent ): Promise> { try { const response = await fetch(imageUrl); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } const arrayBuffer = await response.arrayBuffer(); const base64 = Buffer.from(arrayBuffer).toString('base64'); const contentType = ( @@ -64,10 +67,17 @@ class PushoverAgent attachment_type: contentType, }; } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error getting image payload', { label: 'Notifications', errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return {}; } @@ -200,7 +210,7 @@ class PushoverAgent }); try { - await fetch(endpoint, { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -212,13 +222,23 @@ class PushoverAgent sound: settings.options.sound, } as PushoverPayload), }); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error sending Pushover notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return false; @@ -246,7 +266,7 @@ class PushoverAgent }); try { - await fetch(endpoint, { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -258,14 +278,24 @@ class PushoverAgent sound: payload.notifyUser.settings.pushoverSound, } as PushoverPayload), }); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error sending Pushover notification', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return false; @@ -302,7 +332,7 @@ class PushoverAgent }); try { - await fetch(endpoint, { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -313,14 +343,24 @@ class PushoverAgent user: user.settings.pushoverUserKey, } as PushoverPayload), }); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error sending Pushover notification', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return false; diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index 175f38a5..7a3b9790 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -237,22 +237,32 @@ class SlackAgent subject: payload.subject, }); try { - await fetch(settings.options.webhookUrl, { + const response = await fetch(settings.options.webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(this.buildEmbed(type, payload)), }); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } return true; } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error sending Slack notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return false; diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index 50cea9c0..a66f9710 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -174,7 +174,7 @@ class TelegramAgent }); try { - await fetch(endpoint, { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -185,13 +185,23 @@ class TelegramAgent disable_notification: !!settings.options.sendSilently, } as TelegramMessagePayload | TelegramPhotoPayload), }); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error sending Telegram notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return false; @@ -215,7 +225,7 @@ class TelegramAgent }); try { - await fetch(endpoint, { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -227,14 +237,24 @@ class TelegramAgent !!payload.notifyUser.settings.telegramSendSilently, } as TelegramMessagePayload | TelegramPhotoPayload), }); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error sending Telegram notification', { label: 'Notifications', recipient: payload.notifyUser.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return false; @@ -268,7 +288,7 @@ class TelegramAgent }); try { - await fetch(endpoint, { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -279,14 +299,24 @@ class TelegramAgent disable_notification: !!user.settings?.telegramSendSilently, } as TelegramMessagePayload | TelegramPhotoPayload), }); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error sending Telegram notification', { label: 'Notifications', recipient: user.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return false; diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index 7382f1d0..d91683be 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -177,7 +177,7 @@ class WebhookAgent }); try { - await fetch(settings.options.webhookUrl, { + const response = await fetch(settings.options.webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -187,15 +187,25 @@ class WebhookAgent }, body: JSON.stringify(this.buildPayload(type, payload)), }); + if (!response.ok) { + throw new Error(response.statusText, { cause: response }); + } return true; } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } logger.error('Error sending webhook notification', { label: 'Notifications', type: Notification[type], subject: payload.subject, errorMessage: e.message, - response: e.response?.data, + response: errorData, }); return false; diff --git a/server/routes/auth.ts b/server/routes/auth.ts index a6f2f2f5..9a79bf45 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -328,7 +328,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { case MediaServerType.EMBY: settings.main.mediaServerType = MediaServerType.EMBY; user = new User({ - email: body.email, + email: body.email || account.User.Name, jellyfinUsername: account.User.Name, jellyfinUserId: account.User.Id, jellyfinDeviceId: deviceId, @@ -336,14 +336,17 @@ authRoutes.post('/jellyfin', async (req, res, next) => { permissions: Permission.ADMIN, avatar: account.User.PrimaryImageTag ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` - : gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }), + : gravatarUrl(body.email || account.User.Name, { + default: 'mm', + size: 200, + }), userType: UserType.EMBY, }); break; case MediaServerType.JELLYFIN: settings.main.mediaServerType = MediaServerType.JELLYFIN; user = new User({ - email: body.email, + email: body.email || account.User.Name, jellyfinUsername: account.User.Name, jellyfinUserId: account.User.Id, jellyfinDeviceId: deviceId, @@ -351,7 +354,10 @@ authRoutes.post('/jellyfin', async (req, res, next) => { permissions: Permission.ADMIN, avatar: account.User.PrimaryImageTag ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` - : gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }), + : gravatarUrl(body.email || account.User.Name, { + default: 'mm', + size: 200, + }), userType: UserType.JELLYFIN, }); break; @@ -398,7 +404,7 @@ authRoutes.post('/jellyfin', async (req, res, next) => { if (account.User.PrimaryImageTag) { user.avatar = `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90`; } else { - user.avatar = gravatarUrl(body.email ?? '', { + user.avatar = gravatarUrl(user.email || account.User.Name, { default: 'mm', size: 200, }); @@ -440,10 +446,6 @@ authRoutes.post('/jellyfin', async (req, res, next) => { } ); - if (!body.email) { - throw new Error('add_email'); - } - user = new User({ email: body.email, jellyfinUsername: account.User.Name, @@ -453,7 +455,10 @@ authRoutes.post('/jellyfin', async (req, res, next) => { permissions: settings.main.defaultPermissions, avatar: account.User.PrimaryImageTag ? `${jellyfinHost}/Users/${account.User.Id}/Images/Primary/?tag=${account.User.PrimaryImageTag}&quality=90` - : gravatarUrl(body.email ?? '', { default: 'mm', size: 200 }), + : gravatarUrl(body.email || account.User.Name, { + default: 'mm', + size: 200, + }), userType: settings.main.mediaServerType === MediaServerType.JELLYFIN ? UserType.JELLYFIN diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index 22ebcaa8..016709c6 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -41,7 +41,19 @@ router.get('/', async (req, res, next) => { break; case 'displayname': query = query.orderBy( - "(CASE WHEN (user.username IS NULL OR user.username = '') THEN (CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN user.email ELSE LOWER(user.plexUsername) END) ELSE LOWER(user.username) END)", + `CASE WHEN (user.username IS NULL OR user.username = '') THEN ( + CASE WHEN (user.plexUsername IS NULL OR user.plexUsername = '') THEN ( + CASE WHEN (user.jellyfinUsername IS NULL OR user.jellyfinUsername = '') THEN + user.email + ELSE + LOWER(user.jellyfinUsername) + END) + ELSE + LOWER(user.jellyfinUsername) + END) + ELSE + LOWER(user.username) + END`, 'ASC' ); break; @@ -90,12 +102,13 @@ router.post( const settings = getSettings(); const body = req.body; + const email = body.email || body.username; const userRepository = getRepository(User); const existingUser = await userRepository .createQueryBuilder('user') .where('user.email = :email', { - email: body.email.toLowerCase(), + email: email.toLowerCase(), }) .getOne(); @@ -108,7 +121,7 @@ router.post( } const passedExplicitPassword = body.password && body.password.length > 0; - const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 }); + const avatar = gravatarUrl(email, { default: 'mm', size: 200 }); if ( !passedExplicitPassword && @@ -118,9 +131,9 @@ router.post( } const user = new User({ + email, avatar: body.avatar ?? avatar, username: body.username, - email: body.email, password: body.password, permissions: settings.main.defaultPermissions, plexToken: '', diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 53eed9ef..9669cb18 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -98,7 +98,9 @@ userSettingsRoutes.post< } user.username = req.body.username; - user.email = req.body.email ?? user.email; + if (user.jellyfinUsername) { + user.email = req.body.email || user.jellyfinUsername || user.email; + } // Update quota values only if the user has the correct permissions if ( diff --git a/server/utils/rateLimit.ts b/server/utils/rateLimit.ts index 3d69d6c5..0ecdec5c 100644 --- a/server/utils/rateLimit.ts +++ b/server/utils/rateLimit.ts @@ -7,9 +7,10 @@ type RateLimiteState) => Promise, U> = { queue: { args: Parameters; resolve: (value: U) => void; + reject: (reason?: unknown) => void; }[]; - activeRequests: number; - timer: NodeJS.Timeout | null; + lastTimestamps: number[]; + timeout: ReturnType; }; const rateLimitById: Record = {}; @@ -27,46 +28,40 @@ export default function rateLimit< >(fn: T, options: RateLimitOptions): (...args: Parameters) => Promise { const state: RateLimiteState = (rateLimitById[ options.id || '' - ] as RateLimiteState) || { queue: [], activeRequests: 0, timer: null }; + ] as RateLimiteState) || { queue: [], lastTimestamps: [] }; if (options.id) { rateLimitById[options.id] = state; } const processQueue = () => { - if (state.queue.length === 0) { - if (state.timer) { - clearInterval(state.timer); - state.timer = null; - } - return; - } + // remove old timestamps + state.lastTimestamps = state.lastTimestamps.filter( + (timestamp) => Date.now() - timestamp < 1000 + ); - while (state.activeRequests < options.maxRPS) { - state.activeRequests++; + if (state.lastTimestamps.length < options.maxRPS) { + // process requests if RPS not exceeded const item = state.queue.shift(); - if (!item) break; - const { args, resolve } = item; + if (!item) return; + state.lastTimestamps.push(Date.now()); + const { args, resolve, reject } = item; fn(...args) .then(resolve) - .finally(() => { - state.activeRequests--; - if (state.queue.length > 0) { - if (!state.timer) { - state.timer = setInterval(processQueue, 1000); - } - } else { - if (state.timer) { - clearInterval(state.timer); - state.timer = null; - } - } - }); + .catch(reject); + processQueue(); + } else { + // rerun once the oldest item in queue is older than 1s + if (state.timeout) clearTimeout(state.timeout); + state.timeout = setTimeout( + processQueue, + 1000 - (Date.now() - state.lastTimestamps[0]) + ); } }; return (...args: Parameters): Promise => { - return new Promise((resolve) => { - state.queue.push({ args, resolve }); + return new Promise((resolve, reject) => { + state.queue.push({ args, resolve, reject }); processQueue(); }); }; diff --git a/src/components/Discover/DiscoverNetwork/index.tsx b/src/components/Discover/DiscoverNetwork/index.tsx index af3f9ed5..00e28930 100644 --- a/src/components/Discover/DiscoverNetwork/index.tsx +++ b/src/components/Discover/DiscoverNetwork/index.tsx @@ -48,11 +48,11 @@ const DiscoverTvNetwork = () => {
{firstResultData?.network.logoPath ? ( -
+
{firstResultData.network.name}
diff --git a/src/components/Discover/DiscoverStudio/index.tsx b/src/components/Discover/DiscoverStudio/index.tsx index 23cb2130..b4a6dfae 100644 --- a/src/components/Discover/DiscoverStudio/index.tsx +++ b/src/components/Discover/DiscoverStudio/index.tsx @@ -48,11 +48,11 @@ const DiscoverMovieStudio = () => {
{firstResultData?.studio.logoPath ? ( -
+
{firstResultData.studio.name}
diff --git a/src/components/Layout/UserDropdown/index.tsx b/src/components/Layout/UserDropdown/index.tsx index 4abe04e3..8620f606 100644 --- a/src/components/Layout/UserDropdown/index.tsx +++ b/src/components/Layout/UserDropdown/index.tsx @@ -90,9 +90,11 @@ const UserDropdown = () => { {user?.displayName} - - {user?.email} - + {user?.displayName?.toLowerCase() !== user?.email && ( + + {user?.email} + + )}
{user && } diff --git a/src/components/Login/JellyfinLogin.tsx b/src/components/Login/JellyfinLogin.tsx index 7743daae..ba59d11b 100644 --- a/src/components/Login/JellyfinLogin.tsx +++ b/src/components/Login/JellyfinLogin.tsx @@ -130,10 +130,17 @@ const JellyfinLogin: React.FC = ({ serverType: serverType, }), }); - if (!res.ok) throw new Error(); + if (!res.ok) throw new Error(res.statusText, { cause: res }); } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } let errorMessage = null; - switch (e.response?.data?.message) { + switch (errorData?.message) { case ApiErrorCode.InvalidUrl: errorMessage = messages.invalidurlerror; break; diff --git a/src/components/Login/index.tsx b/src/components/Login/index.tsx index 7bd5a99e..7b95b9fc 100644 --- a/src/components/Login/index.tsx +++ b/src/components/Login/index.tsx @@ -48,14 +48,21 @@ const Login = () => { }, body: JSON.stringify({ authToken }), }); - if (!res.ok) throw new Error(); + if (!res.ok) throw new Error(res.statusText, { cause: res }); const data = await res.json(); if (data?.id) { revalidate(); } } catch (e) { - setError(e.response.data.message); + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } + setError(errorData?.message); setAuthToken(undefined); setProcessing(false); } diff --git a/src/components/Settings/SettingsJellyfin.tsx b/src/components/Settings/SettingsJellyfin.tsx index 389bbeaa..55ca59d1 100644 --- a/src/components/Settings/SettingsJellyfin.tsx +++ b/src/components/Settings/SettingsJellyfin.tsx @@ -174,11 +174,18 @@ const SettingsJellyfin: React.FC = ({ const res = await fetch( `/api/v1/settings/jellyfin/library?${searchParams.toString()}` ); - if (!res.ok) throw new Error(); + if (!res.ok) throw new Error(res.statusText, { cause: res }); setIsSyncing(false); revalidate(); } catch (e) { - if (e.response.data.message === 'SYNC_ERROR_GROUPED_FOLDERS') { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } + if (errorData?.message === 'SYNC_ERROR_GROUPED_FOLDERS') { toasts.addToast( intl.formatMessage( messages.jellyfinSyncFailedAutomaticGroupedFolders @@ -188,7 +195,7 @@ const SettingsJellyfin: React.FC = ({ appearance: 'warning', } ); - } else if (e.response.data.message === 'SYNC_ERROR_NO_LIBRARIES') { + } else if (errorData?.message === 'SYNC_ERROR_NO_LIBRARIES') { toasts.addToast( intl.formatMessage(messages.jellyfinSyncFailedNoLibrariesFound), { @@ -480,7 +487,7 @@ const SettingsJellyfin: React.FC = ({ jellyfinForgotPasswordUrl: values.jellyfinForgotPasswordUrl, } as JellyfinSettings), }); - if (!res.ok) throw new Error(); + if (!res.ok) throw new Error(res.statusText, { cause: res }); addToast( intl.formatMessage(messages.jellyfinSettingsSuccess, { @@ -496,7 +503,14 @@ const SettingsJellyfin: React.FC = ({ } ); } catch (e) { - if (e.response?.data?.message === ApiErrorCode.InvalidUrl) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } + if (errorData?.message === ApiErrorCode.InvalidUrl) { addToast( intl.formatMessage( messages.invalidurlerror, diff --git a/src/components/UserList/JellyfinImportModal.tsx b/src/components/UserList/JellyfinImportModal.tsx index a8e73d3c..e3ed19a6 100644 --- a/src/components/UserList/JellyfinImportModal.tsx +++ b/src/components/UserList/JellyfinImportModal.tsx @@ -77,7 +77,7 @@ const JellyfinImportModal: React.FC = ({ }), }); if (!res.ok) throw new Error(); - const { data: createdUsers } = await res.json(); + const createdUsers = await res.json(); if (!createdUsers.length) { throw new Error('No users were imported from Jellyfin.'); diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 70807366..7a91a103 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -67,14 +67,15 @@ const messages = defineMessages('components.UserList', { usercreatedfailedexisting: 'The provided email address is already in use by another user.', usercreatedsuccess: 'User created successfully!', - displayName: 'Display Name', + username: 'Username', email: 'Email Address', password: 'Password', passwordinfodescription: 'Configure an application URL and enable email notifications to allow automatic password generation.', autogeneratepassword: 'Automatically Generate Password', autogeneratepasswordTip: 'Email a server-generated password to the user', - validationEmail: 'You must provide a valid email address', + validationUsername: 'You must provide an username', + validationEmail: 'Email required', sortCreated: 'Join Date', sortDisplayName: 'Display Name', sortRequests: 'Request Count', @@ -206,9 +207,10 @@ const UserList = () => { } const CreateUserSchema = Yup.object().shape({ - email: Yup.string() - .required(intl.formatMessage(messages.validationEmail)) - .email(intl.formatMessage(messages.validationEmail)), + username: Yup.string().required( + intl.formatMessage(messages.validationUsername) + ), + email: Yup.string().email(intl.formatMessage(messages.validationEmail)), password: Yup.lazy((value) => !value ? Yup.string() @@ -256,7 +258,7 @@ const UserList = () => { setDeleteModal({ isOpen: false, user: deleteModal.user }) } title={intl.formatMessage(messages.deleteuser)} - subTitle={deleteModal.user?.displayName} + subTitle={deleteModal.user?.username} > {intl.formatMessage(messages.deleteconfirm)} @@ -274,7 +276,7 @@ const UserList = () => { > { 'Content-Type': 'application/json', }, body: JSON.stringify({ - username: values.displayName, + username: values.username, email: values.email, password: values.genpassword ? null : values.password, }), }); - if (!res.ok) throw new Error(); + if (!res.ok) throw new Error(res.statusText, { cause: res }); addToast(intl.formatMessage(messages.usercreatedsuccess), { appearance: 'success', autoDismiss: true, }); setCreateModal({ isOpen: false }); } catch (e) { + let errorData; + try { + errorData = await e.cause?.text(); + errorData = JSON.parse(errorData); + } catch { + /* empty */ + } addToast( intl.formatMessage( - e.response.data.errors?.includes('USER_EXISTS') + errorData.errors?.includes('USER_EXISTS') ? messages.usercreatedfailedexisting : messages.usercreatedfailed ), @@ -361,23 +370,24 @@ const UserList = () => { )}
-
@@ -637,9 +647,16 @@ const UserList = () => { className="text-base font-bold leading-5 transition duration-300 hover:underline" data-testid="user-list-username-link" > - {user.displayName} + {user.username || + user.jellyfinUsername || + user.plexUsername || + user.email} - {user.displayName.toLowerCase() !== user.email && ( + {( + user.username || + user.jellyfinUsername || + user.plexUsername + )?.toLowerCase() !== user.email && (
{user.email}
diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index 85770629..15d96071 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -91,9 +91,14 @@ const UserGeneralSettings = () => { ); const UserGeneralSettingsSchema = Yup.object().shape({ - email: Yup.string() - .email(intl.formatMessage(messages.validationemailformat)) - .required(intl.formatMessage(messages.validationemailrequired)), + email: + user?.id === 1 + ? Yup.string() + .email(intl.formatMessage(messages.validationemailformat)) + .required(intl.formatMessage(messages.validationemailrequired)) + : Yup.string().email( + intl.formatMessage(messages.validationemailformat) + ), discordId: Yup.string() .nullable() .matches(/^\d{17,19}$/, intl.formatMessage(messages.validationDiscordId)), @@ -132,7 +137,7 @@ const UserGeneralSettings = () => { { }, body: JSON.stringify({ username: values.displayName, - email: values.email, + email: + values.email || user?.jellyfinUsername || user?.plexUsername, discordId: values.discordId, locale: values.locale, region: values.region, @@ -262,7 +268,9 @@ const UserGeneralSettings = () => { name="displayName" type="text" placeholder={ - user?.plexUsername ? user.plexUsername : user?.email + user?.username || + user?.jellyfinUsername || + user?.plexUsername } />
@@ -287,6 +295,7 @@ const UserGeneralSettings = () => { name="email" type="text" placeholder="example@domain.com" + disabled={user?.plexUsername} className={ user?.warnings.find((w) => w === 'userEmailRequired') ? 'border-2 border-red-400 focus:border-blue-600' diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index fa034437..f1e83006 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -12,6 +12,7 @@ export interface User { id: number; warnings: string[]; plexUsername?: string; + jellyfinUsername?: string; username?: string; displayName: string; email: string; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index f4228630..004c6db9 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1121,7 +1121,6 @@ "components.UserList.creating": "Creating…", "components.UserList.deleteconfirm": "Are you sure you want to delete this user? All of their request data will be permanently removed.", "components.UserList.deleteuser": "Delete User", - "components.UserList.displayName": "Display Name", "components.UserList.edituser": "Edit User Permissions", "components.UserList.email": "Email Address", "components.UserList.importedfromJellyfin": "{userCount} {mediaServerName} {userCount, plural, one {user} other {users}} imported successfully!", @@ -1155,9 +1154,11 @@ "components.UserList.userdeleteerror": "Something went wrong while deleting the user.", "components.UserList.userfail": "Something went wrong while saving user permissions.", "components.UserList.userlist": "User List", + "components.UserList.username": "Username", "components.UserList.users": "Users", "components.UserList.userssaved": "User permissions saved successfully!", - "components.UserList.validationEmail": "You must provide a valid email address", + "components.UserList.validationEmail": "Email required", + "components.UserList.validationUsername": "You must provide an username", "components.UserList.validationpasswordminchars": "Password is too short; should be a minimum of 8 characters", "components.UserProfile.ProfileHeader.joindate": "Joined {joindate}", "components.UserProfile.ProfileHeader.profile": "View Profile",