From bbb683e637386ad8bbeb44dca97aac9cdaf11349 Mon Sep 17 00:00:00 2001 From: sct Date: Thu, 18 Feb 2021 11:38:24 +0900 Subject: [PATCH] feat: user profile/settings pages (#958) --- .../using-overseerr/notifications/webhooks.md | 1 + overseerr-api.yml | 267 +++++++++++++ server/entity/User.ts | 26 +- server/entity/UserSettings.ts | 28 ++ server/interfaces/api/userInterfaces.ts | 6 + .../interfaces/api/userSettingsInterfaces.ts | 4 + server/lib/notifications/agents/agent.ts | 2 +- server/lib/notifications/agents/discord.ts | 22 ++ server/lib/notifications/agents/email.ts | 5 +- server/lib/notifications/agents/webhook.ts | 1 + server/lib/notifications/index.ts | 2 +- .../1613615266968-CreateUserSettings.ts | 35 ++ server/routes/auth.ts | 2 +- server/routes/index.ts | 2 +- server/routes/user.ts | 289 -------------- server/routes/user/index.ts | 358 ++++++++++++++++++ server/routes/user/usersettings.ts | 280 ++++++++++++++ src/components/Common/Button/index.tsx | 31 +- src/components/Layout/UserDropdown/index.tsx | 38 +- src/components/Layout/index.tsx | 3 + src/components/Login/LocalLogin.tsx | 9 +- src/components/RequestCard/index.tsx | 33 +- .../RequestList/RequestItem/index.tsx | 22 +- .../ResetPassword/RequestResetLink.tsx | 15 +- src/components/ResetPassword/index.tsx | 17 +- src/components/UserEdit/index.tsx | 198 ---------- src/components/UserList/BulkEditModal.tsx | 14 +- src/components/UserList/index.tsx | 32 +- .../UserProfile/ProfileHeader/index.tsx | 120 ++++++ .../UserGeneralSettings/index.tsx | 135 +++++++ .../UserNotificationSettings/index.tsx | 134 +++++++ .../UserSettings/UserPasswordChange/index.tsx | 192 ++++++++++ .../UserSettings/UserPermissions/index.tsx | 122 ++++++ .../UserProfile/UserSettings/index.tsx | 172 +++++++++ src/components/UserProfile/index.tsx | 105 +++++ src/hooks/useUser.ts | 11 +- src/i18n/locale/en.json | 66 +++- src/pages/profile/index.tsx | 9 + src/pages/profile/settings/index.tsx | 14 + src/pages/profile/settings/main.tsx | 14 + src/pages/profile/settings/notifications.tsx | 14 + src/pages/profile/settings/password.tsx | 14 + src/pages/profile/settings/permissions.tsx | 14 + src/pages/users/[userId]/edit.tsx | 12 - src/pages/users/[userId]/index.tsx | 9 + src/pages/users/[userId]/settings/index.tsx | 17 + src/pages/users/[userId]/settings/main.tsx | 17 + .../users/[userId]/settings/notifications.tsx | 17 + .../users/[userId]/settings/password.tsx | 17 + .../users/[userId]/settings/permissions.tsx | 17 + 50 files changed, 2388 insertions(+), 596 deletions(-) create mode 100644 server/entity/UserSettings.ts create mode 100644 server/interfaces/api/userInterfaces.ts create mode 100644 server/interfaces/api/userSettingsInterfaces.ts create mode 100644 server/migration/1613615266968-CreateUserSettings.ts delete mode 100644 server/routes/user.ts create mode 100644 server/routes/user/index.ts create mode 100644 server/routes/user/usersettings.ts delete mode 100644 src/components/UserEdit/index.tsx create mode 100644 src/components/UserProfile/ProfileHeader/index.tsx create mode 100644 src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx create mode 100644 src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx create mode 100644 src/components/UserProfile/UserSettings/UserPasswordChange/index.tsx create mode 100644 src/components/UserProfile/UserSettings/UserPermissions/index.tsx create mode 100644 src/components/UserProfile/UserSettings/index.tsx create mode 100644 src/components/UserProfile/index.tsx create mode 100644 src/pages/profile/index.tsx create mode 100644 src/pages/profile/settings/index.tsx create mode 100644 src/pages/profile/settings/main.tsx create mode 100644 src/pages/profile/settings/notifications.tsx create mode 100644 src/pages/profile/settings/password.tsx create mode 100644 src/pages/profile/settings/permissions.tsx delete mode 100644 src/pages/users/[userId]/edit.tsx create mode 100644 src/pages/users/[userId]/index.tsx create mode 100644 src/pages/users/[userId]/settings/index.tsx create mode 100644 src/pages/users/[userId]/settings/main.tsx create mode 100644 src/pages/users/[userId]/settings/notifications.tsx create mode 100644 src/pages/users/[userId]/settings/password.tsx create mode 100644 src/pages/users/[userId]/settings/permissions.tsx diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md index b6c909743..16f1b828f 100644 --- a/docs/using-overseerr/notifications/webhooks.md +++ b/docs/using-overseerr/notifications/webhooks.md @@ -36,6 +36,7 @@ These variables are usually the target user of the notification. - `{{notifyuser_username}}` Target user's username. - `{{notifyuser_email}}` Target user's email. - `{{notifyuser_avatar}}` Target user's avatar. +- `{{notifyuser_settings_discordId}}` Target user's discord ID (if one is set). ### Media diff --git a/overseerr-api.yml b/overseerr-api.yml index e3b2d1ce6..b06eb16a7 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -82,11 +82,23 @@ components: readOnly: true items: $ref: '#/components/schemas/MediaRequest' + settings: + $ref: '#/components/schemas/UserSettings' required: - id - email - createdAt - updatedAt + UserSettings: + type: object + properties: + enableNotifications: + type: boolean + default: true + discordId: + type: string + required: + - enableNotifications MainSettings: type: object properties: @@ -1514,6 +1526,17 @@ components: searchForMissingEpisodes: type: boolean nullable: true + UserSettingsNotifications: + type: object + properties: + enableNotifications: + type: boolean + default: true + discordId: + type: string + nullable: true + required: + - enableNotifications securitySchemes: cookieAuth: type: apiKey @@ -2876,6 +2899,250 @@ paths: application/json: schema: $ref: '#/components/schemas/User' + /user/{userId}/requests: + get: + summary: Get user by ID + description: | + Retrieves a user's requests in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + - in: query + name: take + schema: + type: number + nullable: true + example: 20 + - in: query + name: skip + schema: + type: number + nullable: true + example: 0 + responses: + '200': + description: User's requests returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/MediaRequest' + /user/{userId}/settings/main: + get: + summary: Get general settings for a user + description: Returns general settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: User general settings returned + content: + application/json: + schema: + type: object + properties: + username: + type: string + example: 'Mr User' + post: + summary: Update general settings for a user + description: Updates and returns general settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + nullable: true + responses: + '200': + description: Updated user general settings returned + content: + application/json: + schema: + type: object + properties: + username: + type: string + example: 'Mr User' + /user/{userId}/settings/password: + get: + summary: Get password page informatiom + description: Returns important data for the password page to function correctly. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: User password page information returned + content: + application/json: + schema: + type: object + properties: + hasPassword: + type: boolean + example: true + post: + summary: Update password for a user + description: Updates a user's password. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + currentPassword: + type: string + nullable: true + newPassword: + type: string + required: + - newPassword + responses: + '204': + description: User password updated + /user/{userId}/settings/notifications: + get: + summary: Get notification settings for a user + description: Returns notification settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: User notification settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + post: + summary: Update notification settings for a user + description: Updates and returns notification settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + responses: + '200': + description: Updated user notification settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + /user/{userId}/settings/permissions: + get: + summary: Get permission settings for a user + description: Returns permission settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: User permission settings returned + content: + application/json: + schema: + type: object + properties: + permissions: + type: number + example: 2 + post: + summary: Update permission settings for a user + description: Updates and returns permission settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + permissions: + type: number + required: + - permissions + responses: + '200': + description: Updated user general settings returned + content: + application/json: + schema: + type: object + properties: + permissions: + type: number + example: 2 /search: get: summary: Search for movies, TV shows, or people diff --git a/server/entity/User.ts b/server/entity/User.ts index 31b2c4770..75b55776f 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -7,6 +7,7 @@ import { OneToMany, RelationCount, AfterLoad, + OneToOne, } from 'typeorm'; import { Permission, @@ -22,18 +23,18 @@ import { getSettings } from '../lib/settings'; import { default as generatePassword } from 'secure-random-password'; import { UserType } from '../constants/user'; import { v4 as uuid } from 'uuid'; +import { UserSettings } from './UserSettings'; @Entity() export class User { - public static filterMany(users: User[]): Partial[] { - return users.map((u) => u.filter()); + public static filterMany( + users: User[], + showFiltered?: boolean + ): Partial[] { + return users.map((u) => u.filter(showFiltered)); } - static readonly filteredFields: string[] = [ - 'plexToken', - 'password', - 'resetPasswordGuid', - ]; + static readonly filteredFields: string[] = ['email']; public displayName: string; @@ -79,6 +80,13 @@ export class User { @OneToMany(() => MediaRequest, (request) => request.requestedBy) public requests: MediaRequest[]; + @OneToOne(() => UserSettings, (settings) => settings.user, { + cascade: true, + eager: true, + onDelete: 'CASCADE', + }) + public settings?: UserSettings; + @CreateDateColumn() public createdAt: Date; @@ -89,11 +97,11 @@ export class User { Object.assign(this, init); } - public filter(): Partial { + public filter(showFiltered?: boolean): Partial { const filtered: Partial = Object.assign( {}, ...(Object.keys(this) as (keyof User)[]) - .filter((k) => !User.filteredFields.includes(k)) + .filter((k) => showFiltered || !User.filteredFields.includes(k)) .map((k) => ({ [k]: this[k] })) ); diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts new file mode 100644 index 000000000..48fa53283 --- /dev/null +++ b/server/entity/UserSettings.ts @@ -0,0 +1,28 @@ +import { + Column, + Entity, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { User } from './User'; + +@Entity() +export class UserSettings { + constructor(init?: Partial) { + Object.assign(this, init); + } + + @PrimaryGeneratedColumn() + public id: number; + + @OneToOne(() => User, (user) => user.settings, { onDelete: 'CASCADE' }) + @JoinColumn() + public user: User; + + @Column({ default: true }) + public enableNotifications: boolean; + + @Column({ nullable: true }) + public discordId?: string; +} diff --git a/server/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts new file mode 100644 index 000000000..dc87c76d6 --- /dev/null +++ b/server/interfaces/api/userInterfaces.ts @@ -0,0 +1,6 @@ +import { MediaRequest } from '../../entity/MediaRequest'; +import { PaginatedResponse } from './common'; + +export interface UserRequestsResponse extends PaginatedResponse { + results: MediaRequest[]; +} diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts new file mode 100644 index 000000000..7c9cfbbe0 --- /dev/null +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -0,0 +1,4 @@ +export interface UserSettingsNotificationsResponse { + enableNotifications: boolean; + discordId?: string; +} diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index 12933f8e0..4db8966a0 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -24,6 +24,6 @@ export abstract class BaseAgent { } export interface NotificationAgent { - shouldSend(type: Notification): boolean; + shouldSend(type: Notification, payload: NotificationPayload): boolean; send(type: Notification, payload: NotificationPayload): Promise; } diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 86803727b..fc6e5bbbf 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -74,6 +74,12 @@ interface DiscordWebhookPayload { username: string; avatar_url?: string; tts: boolean; + content?: string; + allowed_mentions?: { + parse?: ('users' | 'roles' | 'everyone')[]; + roles?: string[]; + users?: string[]; + }; } class DiscordAgent @@ -204,9 +210,24 @@ class DiscordAgent return false; } + const mentionedUsers: string[] = []; + let content = undefined; + + if ( + payload.notifyUser.settings?.enableNotifications && + payload.notifyUser.settings?.discordId + ) { + mentionedUsers.push(payload.notifyUser.settings.discordId); + content = `<@${payload.notifyUser.settings.discordId}>`; + } + await axios.post(webhookUrl, { username: settings.main.applicationTitle, embeds: [this.buildEmbed(type, payload)], + content, + allowed_mentions: { + users: mentionedUsers, + }, } as DiscordWebhookPayload); return true; @@ -214,6 +235,7 @@ class DiscordAgent logger.error('Error sending Discord notification', { label: 'Notifications', message: e.message, + response: e.response.data, }); return false; } diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index c5c2fe83d..750aaf685 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -21,12 +21,13 @@ class EmailAgent return settings.notifications.agents.email; } - public shouldSend(type: Notification): boolean { + public shouldSend(type: Notification, payload: NotificationPayload): boolean { const settings = this.getSettings(); if ( settings.enabled && - hasNotificationType(type, this.getSettings().types) + hasNotificationType(type, this.getSettings().types) && + (payload.notifyUser.settings?.enableNotifications ?? true) ) { return true; } diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index 36df0c949..6186be49e 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -19,6 +19,7 @@ const KeyMap: Record = { notifyuser_username: 'notifyUser.displayName', notifyuser_email: 'notifyUser.email', notifyuser_avatar: 'notifyUser.avatar', + notifyuser_settings_discordId: 'notifyUser.settings.discordId', media_tmdbid: 'media.tmdbId', media_imdbid: 'media.imdbId', media_tvdbid: 'media.tvdbId', diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index d43412177..a50a2932c 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -49,7 +49,7 @@ class NotificationManager { label: 'Notifications', }); this.activeAgents.forEach((agent) => { - if (settings.enabled && agent.shouldSend(type)) { + if (settings.enabled && agent.shouldSend(type, payload)) { agent.send(type, payload); } }); diff --git a/server/migration/1613615266968-CreateUserSettings.ts b/server/migration/1613615266968-CreateUserSettings.ts new file mode 100644 index 000000000..4d4a973e9 --- /dev/null +++ b/server/migration/1613615266968-CreateUserSettings.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserSettings1613615266968 implements MigrationInterface { + name = 'CreateUserSettings1613615266968'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"))` + ); + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"))` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "enableNotifications", "discordId", "userId") SELECT "id", "enableNotifications", "discordId", "userId" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + await queryRunner.query(`DROP TABLE "user_settings"`); + } +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts index b8faabd0a..f3943ce2f 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -23,7 +23,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => { where: { id: req.user.id }, }); - return res.status(200).json(user.filter()); + return res.status(200).json(user); }); authRoutes.post('/login', async (req, res, next) => { diff --git a/server/routes/index.ts b/server/routes/index.ts index 78324025f..1a22d1f9e 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -35,7 +35,7 @@ router.get('/status/appdata', (_req, res) => { }); }); -router.use('/user', isAuthenticated(Permission.MANAGE_USERS), user); +router.use('/user', user); router.get('/settings/public', (_req, res) => { const settings = getSettings(); diff --git a/server/routes/user.ts b/server/routes/user.ts deleted file mode 100644 index 04e2c4c77..000000000 --- a/server/routes/user.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { Router } from 'express'; -import { getRepository, Not } from 'typeorm'; -import PlexTvAPI from '../api/plextv'; -import { MediaRequest } from '../entity/MediaRequest'; -import { User } from '../entity/User'; -import { hasPermission, Permission } from '../lib/permissions'; -import { getSettings } from '../lib/settings'; -import logger from '../logger'; -import gravatarUrl from 'gravatar-url'; -import { UserType } from '../constants/user'; - -const router = Router(); - -router.get('/', async (req, res) => { - let query = getRepository(User).createQueryBuilder('user'); - - switch (req.query.sort) { - case 'updated': - query = query.orderBy('user.updatedAt', 'DESC'); - break; - case 'displayname': - query = query.orderBy( - '(CASE WHEN user.username IS NULL THEN user.plexUsername ELSE user.username END)', - 'ASC' - ); - break; - case 'requests': - query = query - .addSelect((subQuery) => { - return subQuery - .select('COUNT(request.id)', 'requestCount') - .from(MediaRequest, 'request') - .where('request.requestedBy.id = user.id'); - }, 'requestCount') - .orderBy('requestCount', 'DESC'); - break; - default: - query = query.orderBy('user.id', 'ASC'); - break; - } - - const users = await query.getMany(); - - return res.status(200).json(User.filterMany(users)); -}); - -router.post('/', async (req, res, next) => { - try { - const settings = getSettings(); - - const body = req.body; - const userRepository = getRepository(User); - - const passedExplicitPassword = body.password && body.password.length > 0; - const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 }); - - if (!passedExplicitPassword && !settings.notifications.agents.email) { - throw new Error('Email notifications must be enabled'); - } - - const user = new User({ - avatar: body.avatar ?? avatar, - username: body.username ?? body.email, - email: body.email, - password: body.password, - permissions: settings.main.defaultPermissions, - plexToken: '', - userType: UserType.LOCAL, - }); - - if (passedExplicitPassword) { - await user?.setPassword(body.password); - } else { - await user?.generatePassword(); - } - - await userRepository.save(user); - return res.status(201).json(user.filter()); - } catch (e) { - next({ status: 500, message: e.message }); - } -}); - -router.get<{ id: string }>('/:id', async (req, res, next) => { - try { - const userRepository = getRepository(User); - - const user = await userRepository.findOneOrFail({ - where: { id: Number(req.params.id) }, - }); - - return res.status(200).json(user.filter()); - } catch (e) { - next({ status: 404, message: 'User not found' }); - } -}); - -const canMakePermissionsChange = (permissions: number, user?: User) => - // Only let the owner grant admin privileges - !(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) || - // Only let users with the manage settings permission, grant the same permission - !( - hasPermission(Permission.MANAGE_SETTINGS, permissions) && - !hasPermission(Permission.MANAGE_SETTINGS, user?.permissions ?? 0) - ); - -router.put< - Record, - Partial[], - { ids: string[]; permissions: number } ->('/', async (req, res, next) => { - try { - const isOwner = req.user?.id === 1; - - if (!canMakePermissionsChange(req.body.permissions, req.user)) { - return next({ - status: 403, - message: 'You do not have permission to grant this level of access', - }); - } - - const userRepository = getRepository(User); - - const users = await userRepository.findByIds(req.body.ids, { - ...(!isOwner ? { id: Not(1) } : {}), - }); - - const updatedUsers = await Promise.all( - users.map(async (user) => { - return userRepository.save({ - ...user, - ...{ permissions: req.body.permissions }, - }); - }) - ); - - return res.status(200).json(updatedUsers); - } catch (e) { - next({ status: 500, message: e.message }); - } -}); - -router.put<{ id: string }>('/:id', async (req, res, next) => { - try { - const userRepository = getRepository(User); - - const user = await userRepository.findOneOrFail({ - where: { id: Number(req.params.id) }, - }); - - // Only let the owner user modify themselves - if (user.id === 1 && req.user?.id !== 1) { - return next({ - status: 403, - message: 'You do not have permission to modify this user', - }); - } - - if (!canMakePermissionsChange(req.body.permissions, req.user)) { - return next({ - status: 403, - message: 'You do not have permission to grant this level of access', - }); - } - - Object.assign(user, { - username: req.body.username, - permissions: req.body.permissions, - }); - - await userRepository.save(user); - - return res.status(200).json(user.filter()); - } catch (e) { - next({ status: 404, message: 'User not found' }); - } -}); - -router.delete<{ id: string }>('/:id', async (req, res, next) => { - try { - const userRepository = getRepository(User); - - const user = await userRepository.findOne({ - where: { id: Number(req.params.id) }, - relations: ['requests'], - }); - - if (!user) { - return next({ status: 404, message: 'User not found' }); - } - - if (user.id === 1) { - return next({ status: 405, message: 'This account cannot be deleted.' }); - } - - if (user.hasPermission(Permission.ADMIN)) { - return next({ - status: 405, - message: 'You cannot delete users with administrative privileges.', - }); - } - - const requestRepository = getRepository(MediaRequest); - - /** - * Requests are usually deleted through a cascade constraint. Those however, do - * not trigger the removal event so listeners to not run and the parent Media - * will not be updated back to unknown for titles that were still pending. So - * we manually remove all requests from the user here so the parent media's - * properly reflect the change. - */ - await requestRepository.remove(user.requests); - - await userRepository.delete(user.id); - return res.status(200).json(user.filter()); - } catch (e) { - logger.error('Something went wrong deleting a user', { - label: 'API', - userId: req.params.id, - errorMessage: e.message, - }); - return next({ - status: 500, - message: 'Something went wrong deleting the user', - }); - } -}); - -router.post('/import-from-plex', async (req, res, next) => { - try { - const settings = getSettings(); - const userRepository = getRepository(User); - - // taken from auth.ts - const mainUser = await userRepository.findOneOrFail({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, - }); - const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); - - const plexUsersResponse = await mainPlexTv.getUsers(); - const createdUsers: User[] = []; - for (const rawUser of plexUsersResponse.MediaContainer.User) { - const account = rawUser.$; - - const user = await userRepository.findOne({ - where: [{ plexId: account.id }, { email: account.email }], - }); - - if (user) { - // Update the users avatar with their plex thumbnail (incase it changed) - user.avatar = account.thumb; - user.email = account.email; - user.plexUsername = account.username; - - // in-case the user was previously a local account - if (user.userType === UserType.LOCAL) { - user.userType = UserType.PLEX; - user.plexId = parseInt(account.id); - - if (user.username === account.username) { - user.username = ''; - } - } - await userRepository.save(user); - } else { - // Check to make sure it's a real account - if (account.email && account.username) { - const newUser = new User({ - plexUsername: account.username, - email: account.email, - permissions: settings.main.defaultPermissions, - plexId: parseInt(account.id), - plexToken: '', - avatar: account.thumb, - userType: UserType.PLEX, - }); - await userRepository.save(newUser); - createdUsers.push(newUser); - } - } - } - return res.status(201).json(User.filterMany(createdUsers)); - } catch (e) { - next({ status: 500, message: e.message }); - } -}); - -export default router; diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts new file mode 100644 index 000000000..b60924134 --- /dev/null +++ b/server/routes/user/index.ts @@ -0,0 +1,358 @@ +import { Router } from 'express'; +import { getRepository, Not } from 'typeorm'; +import PlexTvAPI from '../../api/plextv'; +import { MediaRequest } from '../../entity/MediaRequest'; +import { User } from '../../entity/User'; +import { hasPermission, Permission } from '../../lib/permissions'; +import { getSettings } from '../../lib/settings'; +import logger from '../../logger'; +import gravatarUrl from 'gravatar-url'; +import { UserType } from '../../constants/user'; +import { isAuthenticated } from '../../middleware/auth'; +import { UserRequestsResponse } from '../../interfaces/api/userInterfaces'; +import userSettingsRoutes from './usersettings'; + +const router = Router(); + +router.get('/', async (req, res) => { + let query = getRepository(User).createQueryBuilder('user'); + + switch (req.query.sort) { + case 'updated': + query = query.orderBy('user.updatedAt', 'DESC'); + break; + case 'displayname': + query = query.orderBy( + '(CASE WHEN user.username IS NULL THEN user.plexUsername ELSE user.username END)', + 'ASC' + ); + break; + case 'requests': + query = query + .addSelect((subQuery) => { + return subQuery + .select('COUNT(request.id)', 'requestCount') + .from(MediaRequest, 'request') + .where('request.requestedBy.id = user.id'); + }, 'requestCount') + .orderBy('requestCount', 'DESC'); + break; + default: + query = query.orderBy('user.id', 'ASC'); + break; + } + + const users = await query.getMany(); + + return res + .status(200) + .json( + User.filterMany(users, req.user?.hasPermission(Permission.MANAGE_USERS)) + ); +}); + +router.post( + '/', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + try { + const settings = getSettings(); + + const body = req.body; + const userRepository = getRepository(User); + + const passedExplicitPassword = body.password && body.password.length > 0; + const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 }); + + if (!passedExplicitPassword && !settings.notifications.agents.email) { + throw new Error('Email notifications must be enabled'); + } + + const user = new User({ + avatar: body.avatar ?? avatar, + username: body.username ?? body.email, + email: body.email, + password: body.password, + permissions: settings.main.defaultPermissions, + plexToken: '', + userType: UserType.LOCAL, + }); + + if (passedExplicitPassword) { + await user?.setPassword(body.password); + } else { + await user?.generatePassword(); + } + + await userRepository.save(user); + return res.status(201).json(user.filter()); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +router.get<{ id: string }>('/:id', async (req, res, next) => { + try { + const userRepository = getRepository(User); + + const user = await userRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, + }); + + return res + .status(200) + .json(user.filter(req.user?.hasPermission(Permission.MANAGE_USERS))); + } catch (e) { + next({ status: 404, message: 'User not found.' }); + } +}); + +router.use('/:id/settings', userSettingsRoutes); + +router.get<{ id: string }, UserRequestsResponse>( + '/:id/requests', + async (req, res, next) => { + const userRepository = getRepository(User); + const requestRepository = getRepository(MediaRequest); + + const pageSize = req.query.take ? Number(req.query.take) : 20; + const skip = req.query.skip ? Number(req.query.skip) : 0; + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + const [requests, requestCount] = await requestRepository.findAndCount({ + where: { requestedBy: user }, + take: pageSize, + skip, + }); + + return res.status(200).json({ + pageInfo: { + pages: Math.ceil(requestCount / pageSize), + pageSize, + results: requestCount, + page: Math.ceil(skip / pageSize) + 1, + }, + results: requests, + }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +const canMakePermissionsChange = (permissions: number, user?: User) => + // Only let the owner grant admin privileges + !(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) || + // Only let users with the manage settings permission, grant the same permission + !( + hasPermission(Permission.MANAGE_SETTINGS, permissions) && + !hasPermission(Permission.MANAGE_SETTINGS, user?.permissions ?? 0) + ); + +router.put< + Record, + Partial[], + { ids: string[]; permissions: number } +>('/', isAuthenticated(Permission.MANAGE_USERS), async (req, res, next) => { + try { + const isOwner = req.user?.id === 1; + + if (!canMakePermissionsChange(req.body.permissions, req.user)) { + return next({ + status: 403, + message: 'You do not have permission to grant this level of access', + }); + } + + const userRepository = getRepository(User); + + const users = await userRepository.findByIds(req.body.ids, { + ...(!isOwner ? { id: Not(1) } : {}), + }); + + const updatedUsers = await Promise.all( + users.map(async (user) => { + return userRepository.save({ + ...user, + ...{ permissions: req.body.permissions }, + }); + }) + ); + + return res.status(200).json(updatedUsers); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +router.put<{ id: string }>( + '/:id', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + try { + const userRepository = getRepository(User); + + const user = await userRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, + }); + + // Only let the owner user modify themselves + if (user.id === 1 && req.user?.id !== 1) { + return next({ + status: 403, + message: 'You do not have permission to modify this user', + }); + } + + if (!canMakePermissionsChange(req.body.permissions, req.user)) { + return next({ + status: 403, + message: 'You do not have permission to grant this level of access', + }); + } + + Object.assign(user, { + username: req.body.username, + permissions: req.body.permissions, + }); + + await userRepository.save(user); + + return res.status(200).json(user.filter()); + } catch (e) { + next({ status: 404, message: 'User not found.' }); + } + } +); + +router.delete<{ id: string }>( + '/:id', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + try { + const userRepository = getRepository(User); + + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + relations: ['requests'], + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + if (user.id === 1) { + return next({ + status: 405, + message: 'This account cannot be deleted.', + }); + } + + if (user.hasPermission(Permission.ADMIN)) { + return next({ + status: 405, + message: 'You cannot delete users with administrative privileges.', + }); + } + + const requestRepository = getRepository(MediaRequest); + + /** + * Requests are usually deleted through a cascade constraint. Those however, do + * not trigger the removal event so listeners to not run and the parent Media + * will not be updated back to unknown for titles that were still pending. So + * we manually remove all requests from the user here so the parent media's + * properly reflect the change. + */ + await requestRepository.remove(user.requests); + + await userRepository.delete(user.id); + return res.status(200).json(user.filter()); + } catch (e) { + logger.error('Something went wrong deleting a user', { + label: 'API', + userId: req.params.id, + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Something went wrong deleting the user', + }); + } + } +); + +router.post( + '/import-from-plex', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + try { + const settings = getSettings(); + const userRepository = getRepository(User); + + // taken from auth.ts + const mainUser = await userRepository.findOneOrFail({ + select: ['id', 'plexToken'], + order: { id: 'ASC' }, + }); + const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? ''); + + const plexUsersResponse = await mainPlexTv.getUsers(); + const createdUsers: User[] = []; + for (const rawUser of plexUsersResponse.MediaContainer.User) { + const account = rawUser.$; + + const user = await userRepository.findOne({ + where: [{ plexId: account.id }, { email: account.email }], + }); + + if (user) { + // Update the users avatar with their plex thumbnail (incase it changed) + user.avatar = account.thumb; + user.email = account.email; + user.plexUsername = account.username; + + // in-case the user was previously a local account + if (user.userType === UserType.LOCAL) { + user.userType = UserType.PLEX; + user.plexId = parseInt(account.id); + + if (user.username === account.username) { + user.username = ''; + } + } + await userRepository.save(user); + } else { + // Check to make sure it's a real account + if (account.email && account.username) { + const newUser = new User({ + plexUsername: account.username, + email: account.email, + permissions: settings.main.defaultPermissions, + plexId: parseInt(account.id), + plexToken: '', + avatar: account.thumb, + userType: UserType.PLEX, + }); + await userRepository.save(newUser); + createdUsers.push(newUser); + } + } + } + return res.status(201).json(User.filterMany(createdUsers)); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +export default router; diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts new file mode 100644 index 000000000..011f1eb64 --- /dev/null +++ b/server/routes/user/usersettings.ts @@ -0,0 +1,280 @@ +import { Router } from 'express'; +import { getRepository } from 'typeorm'; +import { User } from '../../entity/User'; +import { UserSettings } from '../../entity/UserSettings'; +import { UserSettingsNotificationsResponse } from '../../interfaces/api/userSettingsInterfaces'; +import { Permission } from '../../lib/permissions'; +import logger from '../../logger'; +import { isAuthenticated } from '../../middleware/auth'; + +const isOwnProfileOrAdmin = (): Middleware => { + const authMiddleware: Middleware = (req, res, next) => { + if ( + !req.user?.hasPermission(Permission.MANAGE_USERS) && + req.user?.id !== Number(req.params.id) + ) { + return next({ + status: 403, + message: "You do not have permission to view this user's settings.", + }); + } + next(); + }; + return authMiddleware; +}; + +const userSettingsRoutes = Router({ mergeParams: true }); + +userSettingsRoutes.get<{ id: string }, { username?: string }>( + '/main', + isOwnProfileOrAdmin(), + async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + return res.status(200).json({ username: user.username }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +userSettingsRoutes.post< + { id: string }, + { username?: string }, + { username?: string } +>('/main', isOwnProfileOrAdmin(), async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + user.username = req.body.username; + + await userRepository.save(user); + + return res.status(200).json({ username: user.username }); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +userSettingsRoutes.get<{ id: string }, { hasPassword: boolean }>( + '/password', + isOwnProfileOrAdmin(), + async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + select: ['id', 'password'], + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + return res.status(200).json({ hasPassword: !!user.password }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +userSettingsRoutes.post< + { id: string }, + null, + { currentPassword?: string; newPassword: string } +>('/password', isOwnProfileOrAdmin(), async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + const userWithPassword = await userRepository.findOne({ + select: ['id', 'password'], + where: { id: Number(req.params.id) }, + }); + + if (!user || !userWithPassword) { + return next({ status: 404, message: 'User not found.' }); + } + + if (req.body.newPassword.length < 8) { + return next({ + status: 400, + message: 'Password must be at least 8 characters', + }); + } + + // If the user has the permission to manage users and they are not + // editing themselves, we will just set the new password + if ( + req.user?.hasPermission(Permission.MANAGE_USERS) && + req.user?.id !== user.id + ) { + await user.setPassword(req.body.newPassword); + await userRepository.save(user); + logger.debug('Password overriden by user.', { + label: 'User Settings', + userEmail: user.email, + changingUser: req.user.email, + }); + return res.status(204).send(); + } + + // If the user has a password, we need to check the currentPassword is correct + if ( + user.password && + (!req.body.currentPassword || + !(await userWithPassword.passwordMatch(req.body.currentPassword))) + ) { + logger.debug( + 'Attempt to change password for user failed. Invalid current password provided.', + { label: 'User Settings', userEmail: user.email } + ); + return next({ status: 403, message: 'Current password is invalid.' }); + } + + await user.setPassword(req.body.newPassword); + await userRepository.save(user); + + return res.status(204).send(); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( + '/notifications', + isOwnProfileOrAdmin(), + async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + return res.status(200).json({ + enableNotifications: user.settings?.enableNotifications ?? true, + discordId: user.settings?.discordId, + }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +userSettingsRoutes.post< + { id: string }, + UserSettingsNotificationsResponse, + UserSettingsNotificationsResponse +>('/notifications', isOwnProfileOrAdmin(), async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + if (!user.settings) { + user.settings = new UserSettings({ + user: req.user, + enableNotifications: req.body.enableNotifications, + discordId: req.body.discordId, + }); + } else { + user.settings.enableNotifications = req.body.enableNotifications; + user.settings.discordId = req.body.discordId; + } + + userRepository.save(user); + + return res.status(200).json({ + enableNotifications: user.settings.enableNotifications, + discordId: user.settings.discordId, + }); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + +userSettingsRoutes.get<{ id: string }, { permissions?: number }>( + '/permissions', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + return res.status(200).json({ permissions: user.permissions }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +userSettingsRoutes.post< + { id: string }, + { permissions?: number }, + { permissions: number } +>( + '/permissions', + isAuthenticated(Permission.MANAGE_USERS), + async (req, res, next) => { + const userRepository = getRepository(User); + + try { + const user = await userRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!user) { + return next({ status: 404, message: 'User not found.' }); + } + + user.permissions = req.body.permissions; + + await userRepository.save(user); + + return res.status(200).json({ permissions: user.permissions }); + } catch (e) { + next({ status: 500, message: e.message }); + } + } +); + +export default userSettingsRoutes; diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index 7207fbebe..bd5e467b6 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ForwardedRef } from 'react'; export type ButtonType = | 'default' @@ -16,6 +16,10 @@ type MergeElementProps< type ElementTypes = 'button' | 'a'; +type Element

= P extends 'a' + ? HTMLAnchorElement + : HTMLButtonElement; + type BaseProps

= { buttonType?: ButtonType; buttonSize?: 'default' | 'lg' | 'md' | 'sm'; @@ -29,16 +33,19 @@ type ButtonProps

= { as?: P; } & MergeElementProps>; -function Button

({ - buttonType = 'default', - buttonSize = 'default', - as, - children, - className, - ...props -}: ButtonProps

): JSX.Element { +function Button

( + { + buttonType = 'default', + buttonSize = 'default', + as, + children, + className, + ...props + }: ButtonProps

, + ref?: React.Ref> +): JSX.Element { const buttonStyle = [ - 'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150', + 'inline-flex items-center justify-center border border-transparent leading-5 font-medium rounded-md focus:outline-none transition ease-in-out duration-150 cursor-pointer', ]; switch (buttonType) { case 'primary': @@ -93,6 +100,7 @@ function Button

({ )} + ref={ref as ForwardedRef} > {children} @@ -102,6 +110,7 @@ function Button

({ @@ -109,4 +118,4 @@ function Button

({ } } -export default Button; +export default React.forwardRef(Button) as typeof Button; diff --git a/src/components/Layout/UserDropdown/index.tsx b/src/components/Layout/UserDropdown/index.tsx index 302574a78..04bd946f7 100644 --- a/src/components/Layout/UserDropdown/index.tsx +++ b/src/components/Layout/UserDropdown/index.tsx @@ -3,13 +3,17 @@ import Transition from '../../Transition'; import { useUser } from '../../../hooks/useUser'; import axios from 'axios'; import useClickOutside from '../../../hooks/useClickOutside'; -import { defineMessages, FormattedMessage } from 'react-intl'; +import { defineMessages, useIntl } from 'react-intl'; +import Link from 'next/link'; const messages = defineMessages({ + myprofile: 'Profile', + settings: 'Settings', signout: 'Sign Out', }); const UserDropdown: React.FC = () => { + const intl = useIntl(); const dropdownRef = useRef(null); const { user, revalidate } = useUser(); const [isDropdownOpen, setDropdownOpen] = useState(false); @@ -55,13 +59,43 @@ const UserDropdown: React.FC = () => { aria-orientation="vertical" aria-labelledby="user-menu" > + + { + if (e.key === 'Enter') { + setDropdownOpen(false); + } + }} + onClick={() => setDropdownOpen(false)} + > + {intl.formatMessage(messages.myprofile)} + + + + { + if (e.key === 'Enter') { + setDropdownOpen(false); + } + }} + onClick={() => setDropdownOpen(false)} + > + {intl.formatMessage(messages.settings)} + + logout()} > - + {intl.formatMessage(messages.signout)} diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index 5205ec112..10d146fa8 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -36,6 +36,9 @@ const Layout: React.FC = ({ children }) => { return (

+
+
+
setSidebarOpen(false)} />
diff --git a/src/components/Login/LocalLogin.tsx b/src/components/Login/LocalLogin.tsx index 50c6d2066..c870acbff 100644 --- a/src/components/Login/LocalLogin.tsx +++ b/src/components/Login/LocalLogin.tsx @@ -4,6 +4,7 @@ import Button from '../Common/Button'; import { Field, Form, Formik } from 'formik'; import * as Yup from 'yup'; import axios from 'axios'; +import Link from 'next/link'; const messages = defineMessages({ email: 'Email Address', @@ -99,9 +100,11 @@ const LocalLogin: React.FC = ({ revalidate }) => {
- + + + + + + + ) : ( @@ -105,7 +108,7 @@ const ResetPassword: React.FC = () => { name="email" type="text" placeholder="name@example.com" - className="text-white flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5" + className="flex-1 block w-full min-w-0 text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5" />
{errors.email && touched.email && ( diff --git a/src/components/ResetPassword/index.tsx b/src/components/ResetPassword/index.tsx index ef6c1e4af..6eaeef43c 100644 --- a/src/components/ResetPassword/index.tsx +++ b/src/components/ResetPassword/index.tsx @@ -7,6 +7,7 @@ import { Field, Form, Formik } from 'formik'; import * as Yup from 'yup'; import axios from 'axios'; import { useRouter } from 'next/router'; +import Link from 'next/link'; const messages = defineMessages({ resetpassword: 'Reset Password', @@ -76,13 +77,15 @@ const ResetPassword: React.FC = () => {
{hasSubmitted ? ( <> -

+

{intl.formatMessage(messages.resetpasswordsuccessmessage)}

- - + + + + ) : ( @@ -124,7 +127,7 @@ const ResetPassword: React.FC = () => { placeholder={intl.formatMessage( messages.password )} - className="text-white flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5" + className="flex-1 block w-full min-w-0 text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5" />
{errors.password && touched.password && ( @@ -144,7 +147,7 @@ const ResetPassword: React.FC = () => { name="confirmPassword" placeholder="Confirm Password" type="password" - className="text-white flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5" + className="flex-1 block w-full min-w-0 text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5" />
{errors.confirmPassword && diff --git a/src/components/UserEdit/index.tsx b/src/components/UserEdit/index.tsx deleted file mode 100644 index 783f3b837..000000000 --- a/src/components/UserEdit/index.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useRouter } from 'next/router'; -import LoadingSpinner from '../Common/LoadingSpinner'; -import { useUser } from '../../hooks/useUser'; -import Button from '../Common/Button'; -import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; -import axios from 'axios'; -import { useToasts } from 'react-toast-notifications'; -import Header from '../Common/Header'; -import PermissionEdit from '../PermissionEdit'; -import { Field, Form, Formik } from 'formik'; -import { UserType } from '../../../server/constants/user'; -import PageTitle from '../Common/PageTitle'; -import * as Yup from 'yup'; - -export const messages = defineMessages({ - edituser: 'Edit User', - plexUsername: 'Plex Username', - username: 'Display Name', - avatar: 'Avatar', - email: 'Email', - permissions: 'Permissions', - save: 'Save Changes', - saving: 'Saving…', - usersaved: 'User saved!', - userfail: 'Something went wrong while saving the user.', - validationEmail: 'You must provide a valid email address', -}); - -const UserEdit: React.FC = () => { - const router = useRouter(); - const intl = useIntl(); - const { addToast } = useToasts(); - const { user: currentUser } = useUser(); - const { user, error, revalidate } = useUser({ - id: Number(router.query.userId), - }); - const [currentPermission, setCurrentPermission] = useState(0); - - useEffect(() => { - if (currentPermission !== user?.permissions ?? 0) { - setCurrentPermission(user?.permissions ?? 0); - } - // We know what we are doing here. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user]); - - const UserEditSchema = Yup.object().shape({ - email: Yup.string() - .required(intl.formatMessage(messages.validationEmail)) - .email(intl.formatMessage(messages.validationEmail)), - }); - - if (!user && !error) { - return ; - } - - return ( - { - try { - await axios.put(`/api/v1/user/${user?.id}`, { - permissions: currentPermission, - email: user?.email, - username: values.username, - }); - addToast(intl.formatMessage(messages.usersaved), { - appearance: 'success', - autoDismiss: true, - }); - } catch (e) { - addToast(intl.formatMessage(messages.userfail), { - appearance: 'error', - autoDismiss: true, - }); - throw new Error( - `Something went wrong while saving the user: ${e.message}` - ); - } finally { - revalidate(); - } - }} - > - {({ errors, touched, isSubmitting, handleSubmit }) => ( -
- -
-
-
- -
-
- {user?.userType === UserType.PLEX && ( -
- -
-
- -
-
-
- )} -
- -
-
- -
-
-
-
- -
-
- -
- {errors.email && touched.email && ( -
{errors.email}
- )} -
-
-
- - - -
-
- -
-
-
-
-
-
- - - -
-
- - setCurrentPermission(newPermission) - } - /> -
-
-
-
-
-
- - - -
-
- - )} -
- ); -}; - -export default UserEdit; diff --git a/src/components/UserList/BulkEditModal.tsx b/src/components/UserList/BulkEditModal.tsx index b036e8e2b..65c0261cb 100644 --- a/src/components/UserList/BulkEditModal.tsx +++ b/src/components/UserList/BulkEditModal.tsx @@ -5,7 +5,6 @@ import { User, useUser } from '../../hooks/useUser'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import axios from 'axios'; import { useToasts } from 'react-toast-notifications'; -import { messages as userEditMessages } from '../UserEdit'; interface BulkEditProps { selectedUserIds: number[]; @@ -17,6 +16,11 @@ interface BulkEditProps { const messages = defineMessages({ userssaved: 'Users saved', + save: 'Save Changes', + saving: 'Saving…', + userfail: 'Something went wrong while saving the user.', + permissions: 'Permissions', + edituser: 'Edit User', }); const BulkEditModal: React.FC = ({ @@ -53,7 +57,7 @@ const BulkEditModal: React.FC = ({ autoDismiss: true, }); } catch (e) { - addToast(intl.formatMessage(userEditMessages.userfail), { + addToast(intl.formatMessage(messages.userfail), { appearance: 'error', autoDismiss: true, }); @@ -81,12 +85,12 @@ const BulkEditModal: React.FC = ({ return ( { updateUsers(); }} okDisabled={isSaving} - okText={intl.formatMessage(userEditMessages.save)} + okText={intl.formatMessage(messages.save)} onCancel={onCancel} >
@@ -94,7 +98,7 @@ const BulkEditModal: React.FC = ({
- +
diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 8bd83ec9f..cb1bc21cf 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -1,12 +1,11 @@ import React, { useState } from 'react'; import useSWR from 'swr'; import LoadingSpinner from '../Common/LoadingSpinner'; -import type { User } from '../../../server/entity/User'; import Badge from '../Common/Badge'; import { FormattedDate, defineMessages, useIntl } from 'react-intl'; import Button from '../Common/Button'; import { hasPermission } from '../../../server/lib/permissions'; -import { Permission, UserType, useUser } from '../../hooks/useUser'; +import { Permission, User, UserType, useUser } from '../../hooks/useUser'; import { useRouter } from 'next/router'; import Header from '../Common/Header'; import Table from '../Common/Table'; @@ -21,6 +20,7 @@ import AddUserIcon from '../../assets/useradd.svg'; import Alert from '../Common/Alert'; import BulkEditModal from './BulkEditModal'; import PageTitle from '../Common/PageTitle'; +import Link from 'next/link'; const messages = defineMessages({ users: 'Users', @@ -485,17 +485,21 @@ const UserList: React.FC = () => {
-
- -
+ + + + +
-
- {user.displayName} -
+ + + {user.displayName} + +
{user.email}
@@ -533,8 +537,8 @@ const UserList: React.FC = () => { className="mr-2" onClick={() => router.push( - '/users/[userId]/edit', - `/users/${user.id}/edit` + '/users/[userId]/settings', + `/users/${user.id}/settings` ) } > diff --git a/src/components/UserProfile/ProfileHeader/index.tsx b/src/components/UserProfile/ProfileHeader/index.tsx new file mode 100644 index 000000000..480db379e --- /dev/null +++ b/src/components/UserProfile/ProfileHeader/index.tsx @@ -0,0 +1,120 @@ +import Link from 'next/link'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { Permission, User, useUser } from '../../../hooks/useUser'; +import Button from '../../Common/Button'; + +const messages = defineMessages({ + settings: 'Edit Settings', + profile: 'View Profile', +}); + +interface ProfileHeaderProps { + user: User; + isSettingsPage?: boolean; +} + +const ProfileHeader: React.FC = ({ + user, + isSettingsPage, +}) => { + const intl = useIntl(); + const { user: loggedInUser, hasPermission } = useUser(); + + return ( +
+
+
+
+ + +
+
+
+

+ + + {user.displayName} + + + {user.email && ( + + ({user.email}) + + )} +

+

+ Joined {intl.formatDate(user.createdAt)} |{' '} + {intl.formatNumber(user.requestCount)} Requests +

+
+
+
+ {(loggedInUser?.id === user.id || + hasPermission(Permission.MANAGE_USERS)) && + !isSettingsPage ? ( + + + + ) : ( + + + + )} +
+
+ ); +}; + +export default ProfileHeader; diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx new file mode 100644 index 000000000..6a63d6ec0 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -0,0 +1,135 @@ +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import { useRouter } from 'next/router'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import { UserType, useUser } from '../../../../hooks/useUser'; +import Error from '../../../../pages/_error'; +import Badge from '../../../Common/Badge'; +import Button from '../../../Common/Button'; +import LoadingSpinner from '../../../Common/LoadingSpinner'; + +const messages = defineMessages({ + generalsettings: 'General Settings', + displayName: 'Display Name', + save: 'Save Changes', + saving: 'Saving…', + plexuser: 'Plex User', + localuser: 'Local User', + toastSettingsSuccess: 'Settings successfully saved!', + toastSettingsFailure: 'Something went wrong while saving settings.', +}); + +const UserGeneralSettings: React.FC = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const router = useRouter(); + const { user, mutate } = useUser({ id: Number(router.query.userId) }); + const { data, error, revalidate } = useSWR<{ username?: string }>( + user ? `/api/v1/user/${user?.id}/settings/main` : null + ); + + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + return ( + <> +
+

+ {intl.formatMessage(messages.generalsettings)} +

+
+ { + try { + await axios.post(`/api/v1/user/${user?.id}/settings/main`, { + username: values.displayName, + }); + + addToast(intl.formatMessage(messages.toastSettingsSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + addToast(intl.formatMessage(messages.toastSettingsFailure), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + revalidate(); + mutate(); + } + }} + > + {({ errors, touched, isSubmitting }) => { + return ( +
+
+
Account Type
+
+
+ {user?.userType === UserType.PLEX ? ( + + {intl.formatMessage(messages.plexuser)} + + ) : ( + + {intl.formatMessage(messages.localuser)} + + )} +
+
+
+
+ +
+
+ +
+ {errors.displayName && touched.displayName && ( +
{errors.displayName}
+ )} +
+
+
+
+ + + +
+
+
+ ); + }} +
+ + ); +}; + +export default UserGeneralSettings; diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx new file mode 100644 index 000000000..4a1f3fa57 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx @@ -0,0 +1,134 @@ +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import { useRouter } from 'next/router'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import { useUser } from '../../../../hooks/useUser'; +import Error from '../../../../pages/_error'; +import Button from '../../../Common/Button'; +import LoadingSpinner from '../../../Common/LoadingSpinner'; +import { UserSettingsNotificationsResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces'; + +const messages = defineMessages({ + notificationsettings: 'Notification Settings', + enableNotifications: 'Enable Notifications', + discordId: 'Discord ID', + save: 'Save Changes', + saving: 'Saving…', + plexuser: 'Plex User', + localuser: 'Local User', + toastSettingsSuccess: 'Settings successfully saved!', + toastSettingsFailure: 'Something went wrong while saving settings.', +}); + +const UserNotificationSettings: React.FC = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const router = useRouter(); + const { user, mutate } = useUser({ id: Number(router.query.userId) }); + const { data, error, revalidate } = useSWR( + user ? `/api/v1/user/${user?.id}/settings/notifications` : null + ); + + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + return ( + <> +
+

+ {intl.formatMessage(messages.notificationsettings)} +

+
+ { + try { + await axios.post( + `/api/v1/user/${user?.id}/settings/notifications`, + { + enableNotifications: values.enableNotifications, + discordId: values.discordId, + } + ); + + addToast(intl.formatMessage(messages.toastSettingsSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + addToast(intl.formatMessage(messages.toastSettingsFailure), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + revalidate(); + mutate(); + } + }} + > + {({ errors, touched, isSubmitting }) => { + return ( +
+
+ +
+ +
+
+
+ +
+
+ +
+ {errors.discordId && touched.discordId && ( +
{errors.discordId}
+ )} +
+
+
+
+ + + +
+
+
+ ); + }} +
+ + ); +}; + +export default UserNotificationSettings; diff --git a/src/components/UserProfile/UserSettings/UserPasswordChange/index.tsx b/src/components/UserProfile/UserSettings/UserPasswordChange/index.tsx new file mode 100644 index 000000000..d2b4a3ff8 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserPasswordChange/index.tsx @@ -0,0 +1,192 @@ +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import { useRouter } from 'next/router'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import { useUser } from '../../../../hooks/useUser'; +import Error from '../../../../pages/_error'; +import Alert from '../../../Common/Alert'; +import Button from '../../../Common/Button'; +import LoadingSpinner from '../../../Common/LoadingSpinner'; +import * as Yup from 'yup'; + +const messages = defineMessages({ + password: 'Password', + currentpassword: 'Current Password', + newpassword: 'New Password', + confirmpassword: 'Confirm Password', + save: 'Save Changes', + saving: 'Saving…', + toastSettingsSuccess: 'Password changed!', + toastSettingsFailure: + 'Something went wrong while changing the password. Is your current password correct?', + validationCurrentPassword: 'You must provide your current password', + validationNewPassword: 'You must provide a new password', + validationNewPasswordLength: + 'Password is too short; should be a minimum of 8 characters', + validationConfirmPassword: 'You must confirm your new password', + validationConfirmPasswordSame: 'Password must match', + nopasswordset: 'No Password Set', + nopasswordsetDescription: + 'This user account currently does not have an Overseerr-specific password. Configure a password below to allow this account to sign-in as a "local user."', +}); + +const UserPasswordChange: React.FC = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const router = useRouter(); + const { user: currentUser } = useUser(); + const { user } = useUser({ id: Number(router.query.userId) }); + const { data, error, revalidate } = useSWR<{ hasPassword: boolean }>( + user ? `/api/v1/user/${user?.id}/settings/password` : null + ); + + const PasswordChangeSchema = Yup.object().shape({ + currentPassword: Yup.lazy(() => + data?.hasPassword + ? Yup.string().required( + intl.formatMessage(messages.validationCurrentPassword) + ) + : Yup.mixed().optional() + ), + newPassword: Yup.string() + .required(intl.formatMessage(messages.validationNewPassword)) + .min(8, intl.formatMessage(messages.validationNewPasswordLength)), + confirmPassword: Yup.string() + .required(intl.formatMessage(messages.validationConfirmPassword)) + .oneOf( + [Yup.ref('newPassword'), null], + intl.formatMessage(messages.validationConfirmPasswordSame) + ), + }); + + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + return ( + <> +
+

{intl.formatMessage(messages.password)}

+
+ { + try { + await axios.post(`/api/v1/user/${user?.id}/settings/password`, { + currentPassword: values.currentPassword, + newPassword: values.newPassword, + confirmPassword: values.confirmPassword, + }); + + addToast(intl.formatMessage(messages.toastSettingsSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + addToast(intl.formatMessage(messages.toastSettingsFailure), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + revalidate(); + resetForm(); + } + }} + > + {({ errors, touched, isSubmitting }) => { + return ( +
+ {!data.hasPassword && ( + + {intl.formatMessage(messages.nopasswordsetDescription)} + + )} + {data.hasPassword && user?.id === currentUser?.id && ( +
+ +
+
+ +
+ {errors.currentPassword && touched.currentPassword && ( +
{errors.currentPassword}
+ )} +
+
+ )} +
+ +
+
+ +
+ {errors.newPassword && touched.newPassword && ( +
{errors.newPassword}
+ )} +
+
+
+ +
+
+ +
+ {errors.confirmPassword && touched.confirmPassword && ( +
{errors.confirmPassword}
+ )} +
+
+
+
+ + + +
+
+
+ ); + }} +
+ + ); +}; + +export default UserPasswordChange; diff --git a/src/components/UserProfile/UserSettings/UserPermissions/index.tsx b/src/components/UserProfile/UserSettings/UserPermissions/index.tsx new file mode 100644 index 000000000..16df9ef07 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserPermissions/index.tsx @@ -0,0 +1,122 @@ +import axios from 'axios'; +import { Form, Formik } from 'formik'; +import { useRouter } from 'next/router'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import { useUser } from '../../../../hooks/useUser'; +import Error from '../../../../pages/_error'; +import Button from '../../../Common/Button'; +import LoadingSpinner from '../../../Common/LoadingSpinner'; +import PermissionEdit from '../../../PermissionEdit'; + +const messages = defineMessages({ + displayName: 'Display Name', + save: 'Save Changes', + saving: 'Saving…', + plexuser: 'Plex User', + localuser: 'Local User', + toastSettingsSuccess: 'Settings successfully saved!', + toastSettingsFailure: 'Something went wrong while saving settings.', + permissions: 'Permissions', +}); + +const UserPermissions: React.FC = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const router = useRouter(); + const { user: currentUser } = useUser(); + const { user, mutate } = useUser({ id: Number(router.query.userId) }); + const { data, error, revalidate } = useSWR<{ permissions?: number }>( + user ? `/api/v1/user/${user?.id}/settings/permissions` : null + ); + + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + + return ( + <> +
+

{intl.formatMessage(messages.permissions)}

+
+ { + try { + await axios.post(`/api/v1/user/${user?.id}/settings/permissions`, { + permissions: values.currentPermissions ?? 0, + }); + + addToast(intl.formatMessage(messages.toastSettingsSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + addToast(intl.formatMessage(messages.toastSettingsFailure), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + revalidate(); + mutate(); + } + }} + > + {({ isSubmitting, setFieldValue, values }) => { + return ( +
+
+
+ + {intl.formatMessage(messages.permissions)} + +
+
+ + setFieldValue('currentPermissions', newPermission) + } + /> +
+
+
+
+
+
+ + + +
+
+
+ ); + }} +
+ + ); +}; + +export default UserPermissions; diff --git a/src/components/UserProfile/UserSettings/index.tsx b/src/components/UserProfile/UserSettings/index.tsx new file mode 100644 index 000000000..61b03b686 --- /dev/null +++ b/src/components/UserProfile/UserSettings/index.tsx @@ -0,0 +1,172 @@ +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import React from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { Permission, useUser } from '../../../hooks/useUser'; +import Error from '../../../pages/_error'; +import LoadingSpinner from '../../Common/LoadingSpinner'; +import PageTitle from '../../Common/PageTitle'; +import ProfileHeader from '../ProfileHeader'; + +const messages = defineMessages({ + settings: 'User Settings', + menuGeneralSettings: 'General Settings', + menuChangePass: 'Password', + menuNotifications: 'Notifications', + menuPermissions: 'Permissions', +}); + +interface SettingsRoute { + text: string; + route: string; + regex: RegExp; + requiredPermission?: Permission | Permission[]; + permissionType?: { type: 'and' | 'or' }; +} + +const UserSettings: React.FC = ({ children }) => { + const router = useRouter(); + const { hasPermission } = useUser(); + const { user, error } = useUser({ id: Number(router.query.userId) }); + const intl = useIntl(); + + if (!user && !error) { + return ; + } + + if (!user) { + return ; + } + + const settingsRoutes: SettingsRoute[] = [ + { + text: intl.formatMessage(messages.menuGeneralSettings), + route: '/settings/main', + regex: /\/settings(\/main)?$/, + }, + { + text: intl.formatMessage(messages.menuChangePass), + route: '/settings/password', + regex: /\/settings\/password/, + }, + { + text: intl.formatMessage(messages.menuNotifications), + route: '/settings/notifications', + regex: /\/settings\/notifications/, + }, + { + text: intl.formatMessage(messages.menuPermissions), + route: '/settings/permissions', + regex: /\/settings\/permissions/, + requiredPermission: Permission.MANAGE_USERS, + }, + ]; + + const activeLinkColor = + 'border-indigo-600 text-indigo-500 focus:outline-none focus:text-indigo-500 focus:border-indigo-500'; + + const inactiveLinkColor = + 'border-transparent text-gray-500 hover:text-gray-400 hover:border-gray-300 focus:outline-none focus:text-gray-4700 focus:border-gray-300'; + + const SettingsLink: React.FC<{ + route: string; + regex: RegExp; + isMobile?: boolean; + }> = ({ children, route, regex, isMobile = false }) => { + const finalRoute = router.asPath.includes('/profile') + ? `/profile${route}` + : `/users/${user.id}${route}`; + if (isMobile) { + return ; + } + + return ( + + + {children} + + + ); + }; + + const currentRoute = settingsRoutes.find( + (route) => !!router.pathname.match(route.regex) + )?.route; + + const finalRoute = router.asPath.includes('/profile') + ? `/profile${currentRoute}` + : `/users/${user.id}${currentRoute}`; + + return ( + <> + + +
+
+ +
+
+
+ +
+
+
+
{children}
+ + ); +}; + +export default UserSettings; diff --git a/src/components/UserProfile/index.tsx b/src/components/UserProfile/index.tsx new file mode 100644 index 000000000..2596c974e --- /dev/null +++ b/src/components/UserProfile/index.tsx @@ -0,0 +1,105 @@ +import { useRouter } from 'next/router'; +import React, { useCallback, useEffect, useState } from 'react'; +import useSWR from 'swr'; +import { useUser } from '../../hooks/useUser'; +import Error from '../../pages/_error'; +import LoadingSpinner from '../Common/LoadingSpinner'; +import { UserRequestsResponse } from '../../../server/interfaces/api/userInterfaces'; +import Slider from '../Slider'; +import RequestCard from '../RequestCard'; +import { MovieDetails } from '../../../server/models/Movie'; +import { TvDetails } from '../../../server/models/Tv'; +import ImageFader from '../Common/ImageFader'; +import PageTitle from '../Common/PageTitle'; +import ProfileHeader from './ProfileHeader'; +import { defineMessages, useIntl } from 'react-intl'; + +const messages = defineMessages({ + recentrequests: 'Recent Requests', +}); + +type MediaTitle = MovieDetails | TvDetails; + +const UserProfile: React.FC = () => { + const intl = useIntl(); + const router = useRouter(); + const { user, error } = useUser({ + id: Number(router.query.userId), + }); + const [availableTitles, setAvailableTitles] = useState< + Record + >({}); + + const { data: requests, error: requestError } = useSWR( + user ? `/api/v1/user/${user?.id}/requests?take=10&skip=0` : null + ); + + const updateAvailableTitles = useCallback( + (requestId: number, mediaTitle: MediaTitle) => { + setAvailableTitles((titles) => ({ + ...titles, + [requestId]: mediaTitle, + })); + }, + [] + ); + + useEffect(() => { + setAvailableTitles({}); + }, [user?.id]); + + if (!user && !error) { + return ; + } + + if (!user) { + return ; + } + + return ( + <> + + {Object.keys(availableTitles).length > 0 && ( +
+ media.backdropPath) + .map( + (media) => + `//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${media.backdropPath}` + ) + .slice(0, 6)} + /> +
+ )} + +
+
+
+ {intl.formatMessage(messages.recentrequests)} +
+
+
+
+ ( + + ))} + placeholder={} + emptyMessage={'No Requests'} + /> +
+ + ); +}; + +export default UserProfile; diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index 43ecc5812..755bd3095 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -5,6 +5,7 @@ import { PermissionCheckOptions, } from '../../server/lib/permissions'; import { UserType } from '../../server/constants/user'; +import { mutateCallback } from 'swr/dist/types'; export { Permission, UserType }; @@ -17,6 +18,9 @@ export interface User { avatar: string; permissions: number; userType: number; + createdAt: Date; + updatedAt: Date; + requestCount: number; } interface UserHookResponse { @@ -24,6 +28,10 @@ interface UserHookResponse { loading: boolean; error: string; revalidate: () => Promise; + mutate: ( + data?: User | Promise | mutateCallback | undefined, + shouldRevalidate?: boolean | undefined + ) => Promise; hasPermission: ( permission: Permission | Permission[], options?: PermissionCheckOptions @@ -34,7 +42,7 @@ export const useUser = ({ id, initialData, }: { id?: number; initialData?: User } = {}): UserHookResponse => { - const { data, error, revalidate } = useSwr( + const { data, error, revalidate, mutate } = useSwr( id ? `/api/v1/user/${id}` : `/api/v1/auth/me`, { initialData, @@ -57,5 +65,6 @@ export const useUser = ({ error, revalidate, hasPermission: checkPermission, + mutate, }; }; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index bafd08576..2d8247af3 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -28,6 +28,8 @@ "components.Layout.Sidebar.requests": "Requests", "components.Layout.Sidebar.settings": "Settings", "components.Layout.Sidebar.users": "Users", + "components.Layout.UserDropdown.myprofile": "Profile", + "components.Layout.UserDropdown.settings": "Settings", "components.Layout.UserDropdown.signout": "Sign Out", "components.Layout.alphawarning": "This is ALPHA software. Features may be broken and/or unstable. Please report issues on GitHub!", "components.Login.email": "Email Address", @@ -614,17 +616,6 @@ "components.TvDetails.userrating": "User Rating", "components.TvDetails.viewfullcrew": "View Full Crew", "components.TvDetails.watchtrailer": "Watch Trailer", - "components.UserEdit.avatar": "Avatar", - "components.UserEdit.edituser": "Edit User", - "components.UserEdit.email": "Email Address", - "components.UserEdit.permissions": "Permissions", - "components.UserEdit.plexUsername": "Plex Username", - "components.UserEdit.save": "Save Changes", - "components.UserEdit.saving": "Saving…", - "components.UserEdit.userfail": "Something went wrong while saving the user.", - "components.UserEdit.username": "Display Name", - "components.UserEdit.usersaved": "User saved!", - "components.UserEdit.validationEmail": "You must provide a valid email address", "components.UserList.admin": "Admin", "components.UserList.autogeneratepassword": "Automatically generate password", "components.UserList.bulkedit": "Bulk Edit", @@ -637,6 +628,7 @@ "components.UserList.deleteconfirm": "Are you sure you want to delete this user? All existing request data from this user will be removed.", "components.UserList.deleteuser": "Delete User", "components.UserList.edit": "Edit", + "components.UserList.edituser": "Edit User", "components.UserList.email": "Email Address", "components.UserList.importedfromplex": "{userCount, plural, =0 {No new users} one {# new user} other {# new users}} imported from Plex", "components.UserList.importfromplex": "Import Users from Plex", @@ -646,8 +638,11 @@ "components.UserList.password": "Password", "components.UserList.passwordinfo": "Password Information", "components.UserList.passwordinfodescription": "Email notifications need to be configured and enabled in order to automatically generate passwords.", + "components.UserList.permissions": "Permissions", "components.UserList.plexuser": "Plex User", "components.UserList.role": "Role", + "components.UserList.save": "Save Changes", + "components.UserList.saving": "Saving…", "components.UserList.sortCreated": "Creation Date", "components.UserList.sortDisplayName": "Display Name", "components.UserList.sortRequests": "Request Count", @@ -658,12 +653,61 @@ "components.UserList.usercreatedsuccess": "User created successfully!", "components.UserList.userdeleted": "User deleted.", "components.UserList.userdeleteerror": "Something went wrong while deleting the user.", + "components.UserList.userfail": "Something went wrong while saving the user.", "components.UserList.userlist": "User List", "components.UserList.users": "Users", "components.UserList.userssaved": "Users saved!", "components.UserList.usertype": "User Type", "components.UserList.validationEmail": "You must provide a valid email address", "components.UserList.validationpasswordminchars": "Password is too short; should be a minimum of 8 characters", + "components.UserProfile.ProfileHeader.profile": "View Profile", + "components.UserProfile.ProfileHeader.settings": "Edit Settings", + "components.UserProfile.UserSettings.UserGeneralSettings.displayName": "Display Name", + "components.UserProfile.UserSettings.UserGeneralSettings.generalsettings": "General Settings", + "components.UserProfile.UserSettings.UserGeneralSettings.localuser": "Local User", + "components.UserProfile.UserSettings.UserGeneralSettings.plexuser": "Plex User", + "components.UserProfile.UserSettings.UserGeneralSettings.save": "Save Changes", + "components.UserProfile.UserSettings.UserGeneralSettings.saving": "Saving…", + "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Something went wrong while saving settings.", + "components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings successfully saved!", + "components.UserProfile.UserSettings.UserNotificationSettings.discordId": "Discord ID", + "components.UserProfile.UserSettings.UserNotificationSettings.enableNotifications": "Enable Notifications", + "components.UserProfile.UserSettings.UserNotificationSettings.localuser": "Local User", + "components.UserProfile.UserSettings.UserNotificationSettings.notificationsettings": "Notification Settings", + "components.UserProfile.UserSettings.UserNotificationSettings.plexuser": "Plex User", + "components.UserProfile.UserSettings.UserNotificationSettings.save": "Save Changes", + "components.UserProfile.UserSettings.UserNotificationSettings.saving": "Saving…", + "components.UserProfile.UserSettings.UserNotificationSettings.toastSettingsFailure": "Something went wrong while saving settings.", + "components.UserProfile.UserSettings.UserNotificationSettings.toastSettingsSuccess": "Settings successfully saved!", + "components.UserProfile.UserSettings.UserPasswordChange.confirmpassword": "Confirm Password", + "components.UserProfile.UserSettings.UserPasswordChange.currentpassword": "Current Password", + "components.UserProfile.UserSettings.UserPasswordChange.newpassword": "New Password", + "components.UserProfile.UserSettings.UserPasswordChange.nopasswordset": "No Password Set", + "components.UserProfile.UserSettings.UserPasswordChange.nopasswordsetDescription": "This user account currently does not have an Overseerr-specific password. Configure a password below to allow this account to sign-in as a \"local user.\"", + "components.UserProfile.UserSettings.UserPasswordChange.password": "Password", + "components.UserProfile.UserSettings.UserPasswordChange.save": "Save Changes", + "components.UserProfile.UserSettings.UserPasswordChange.saving": "Saving…", + "components.UserProfile.UserSettings.UserPasswordChange.toastSettingsFailure": "Something went wrong while changing the password. Is your current password correct?", + "components.UserProfile.UserSettings.UserPasswordChange.toastSettingsSuccess": "Password changed!", + "components.UserProfile.UserSettings.UserPasswordChange.validationConfirmPassword": "You must confirm your new password", + "components.UserProfile.UserSettings.UserPasswordChange.validationConfirmPasswordSame": "Password must match", + "components.UserProfile.UserSettings.UserPasswordChange.validationCurrentPassword": "You must provide your current password", + "components.UserProfile.UserSettings.UserPasswordChange.validationNewPassword": "You must provide a new password", + "components.UserProfile.UserSettings.UserPasswordChange.validationNewPasswordLength": "Password is too short; should be a minimum of 8 characters", + "components.UserProfile.UserSettings.UserPermissions.displayName": "Display Name", + "components.UserProfile.UserSettings.UserPermissions.localuser": "Local User", + "components.UserProfile.UserSettings.UserPermissions.permissions": "Permissions", + "components.UserProfile.UserSettings.UserPermissions.plexuser": "Plex User", + "components.UserProfile.UserSettings.UserPermissions.save": "Save Changes", + "components.UserProfile.UserSettings.UserPermissions.saving": "Saving…", + "components.UserProfile.UserSettings.UserPermissions.toastSettingsFailure": "Something went wrong while saving settings.", + "components.UserProfile.UserSettings.UserPermissions.toastSettingsSuccess": "Settings successfully saved!", + "components.UserProfile.UserSettings.menuChangePass": "Password", + "components.UserProfile.UserSettings.menuGeneralSettings": "General Settings", + "components.UserProfile.UserSettings.menuNotifications": "Notifications", + "components.UserProfile.UserSettings.menuPermissions": "Permissions", + "components.UserProfile.UserSettings.settings": "User Settings", + "components.UserProfile.recentrequests": "Recent Requests", "i18n.advanced": "Advanced", "i18n.approve": "Approve", "i18n.approved": "Approved", diff --git a/src/pages/profile/index.tsx b/src/pages/profile/index.tsx new file mode 100644 index 000000000..22fef09e5 --- /dev/null +++ b/src/pages/profile/index.tsx @@ -0,0 +1,9 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserProfile from '../../components/UserProfile'; + +const UserPage: NextPage = () => { + return ; +}; + +export default UserPage; diff --git a/src/pages/profile/settings/index.tsx b/src/pages/profile/settings/index.tsx new file mode 100644 index 000000000..40fb7d2db --- /dev/null +++ b/src/pages/profile/settings/index.tsx @@ -0,0 +1,14 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserSettings from '../../../components/UserProfile/UserSettings'; +import UserGeneralSettings from '../../../components/UserProfile/UserSettings/UserGeneralSettings'; + +const UserSettingsPage: NextPage = () => { + return ( + + + + ); +}; + +export default UserSettingsPage; diff --git a/src/pages/profile/settings/main.tsx b/src/pages/profile/settings/main.tsx new file mode 100644 index 000000000..083358e07 --- /dev/null +++ b/src/pages/profile/settings/main.tsx @@ -0,0 +1,14 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserSettings from '../../../components/UserProfile/UserSettings'; +import UserGeneralSettings from '../../../components/UserProfile/UserSettings/UserGeneralSettings'; + +const UserSettingsMainPage: NextPage = () => { + return ( + + + + ); +}; + +export default UserSettingsMainPage; diff --git a/src/pages/profile/settings/notifications.tsx b/src/pages/profile/settings/notifications.tsx new file mode 100644 index 000000000..dcb27361b --- /dev/null +++ b/src/pages/profile/settings/notifications.tsx @@ -0,0 +1,14 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserSettings from '../../../components/UserProfile/UserSettings'; +import UserNotificationSettings from '../../../components/UserProfile/UserSettings/UserNotificationSettings'; + +const UserSettingsMainPage: NextPage = () => { + return ( + + + + ); +}; + +export default UserSettingsMainPage; diff --git a/src/pages/profile/settings/password.tsx b/src/pages/profile/settings/password.tsx new file mode 100644 index 000000000..304e29aa3 --- /dev/null +++ b/src/pages/profile/settings/password.tsx @@ -0,0 +1,14 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserSettings from '../../../components/UserProfile/UserSettings'; +import UserPasswordChange from '../../../components/UserProfile/UserSettings/UserPasswordChange'; + +const UserPassswordPage: NextPage = () => { + return ( + + + + ); +}; + +export default UserPassswordPage; diff --git a/src/pages/profile/settings/permissions.tsx b/src/pages/profile/settings/permissions.tsx new file mode 100644 index 000000000..7926a2ebe --- /dev/null +++ b/src/pages/profile/settings/permissions.tsx @@ -0,0 +1,14 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserSettings from '../../../components/UserProfile/UserSettings'; +import UserPermissions from '../../../components/UserProfile/UserSettings/UserPermissions'; + +const UserPermissionsPage: NextPage = () => { + return ( + + + + ); +}; + +export default UserPermissionsPage; diff --git a/src/pages/users/[userId]/edit.tsx b/src/pages/users/[userId]/edit.tsx deleted file mode 100644 index 8f28cca5e..000000000 --- a/src/pages/users/[userId]/edit.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import { NextPage } from 'next'; -import UserEdit from '../../../components/UserEdit'; -import useRouteGuard from '../../../hooks/useRouteGuard'; -import { Permission } from '../../../hooks/useUser'; - -const UserProfilePage: NextPage = () => { - useRouteGuard(Permission.MANAGE_USERS); - return ; -}; - -export default UserProfilePage; diff --git a/src/pages/users/[userId]/index.tsx b/src/pages/users/[userId]/index.tsx new file mode 100644 index 000000000..4385981cc --- /dev/null +++ b/src/pages/users/[userId]/index.tsx @@ -0,0 +1,9 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserProfile from '../../../components/UserProfile'; + +const UserPage: NextPage = () => { + return ; +}; + +export default UserPage; diff --git a/src/pages/users/[userId]/settings/index.tsx b/src/pages/users/[userId]/settings/index.tsx new file mode 100644 index 000000000..d6708b9ff --- /dev/null +++ b/src/pages/users/[userId]/settings/index.tsx @@ -0,0 +1,17 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserSettings from '../../../../components/UserProfile/UserSettings'; +import UserGeneralSettings from '../../../../components/UserProfile/UserSettings/UserGeneralSettings'; +import useRouteGuard from '../../../../hooks/useRouteGuard'; +import { Permission } from '../../../../hooks/useUser'; + +const UserSettingsPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); + return ( + + + + ); +}; + +export default UserSettingsPage; diff --git a/src/pages/users/[userId]/settings/main.tsx b/src/pages/users/[userId]/settings/main.tsx new file mode 100644 index 000000000..a2d5e7fe3 --- /dev/null +++ b/src/pages/users/[userId]/settings/main.tsx @@ -0,0 +1,17 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserSettings from '../../../../components/UserProfile/UserSettings'; +import UserGeneralSettings from '../../../../components/UserProfile/UserSettings/UserGeneralSettings'; +import useRouteGuard from '../../../../hooks/useRouteGuard'; +import { Permission } from '../../../../hooks/useUser'; + +const UserSettingsMainPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); + return ( + + + + ); +}; + +export default UserSettingsMainPage; diff --git a/src/pages/users/[userId]/settings/notifications.tsx b/src/pages/users/[userId]/settings/notifications.tsx new file mode 100644 index 000000000..08d9d62fb --- /dev/null +++ b/src/pages/users/[userId]/settings/notifications.tsx @@ -0,0 +1,17 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserSettings from '../../../../components/UserProfile/UserSettings'; +import UserNotificationSettings from '../../../../components/UserProfile/UserSettings/UserNotificationSettings'; +import useRouteGuard from '../../../../hooks/useRouteGuard'; +import { Permission } from '../../../../hooks/useUser'; + +const UserSettingsMainPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); + return ( + + + + ); +}; + +export default UserSettingsMainPage; diff --git a/src/pages/users/[userId]/settings/password.tsx b/src/pages/users/[userId]/settings/password.tsx new file mode 100644 index 000000000..ee1da6b9c --- /dev/null +++ b/src/pages/users/[userId]/settings/password.tsx @@ -0,0 +1,17 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserSettings from '../../../../components/UserProfile/UserSettings'; +import UserPasswordChange from '../../../../components/UserProfile/UserSettings/UserPasswordChange'; +import useRouteGuard from '../../../../hooks/useRouteGuard'; +import { Permission } from '../../../../hooks/useUser'; + +const UserPassswordPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); + return ( + + + + ); +}; + +export default UserPassswordPage; diff --git a/src/pages/users/[userId]/settings/permissions.tsx b/src/pages/users/[userId]/settings/permissions.tsx new file mode 100644 index 000000000..826689368 --- /dev/null +++ b/src/pages/users/[userId]/settings/permissions.tsx @@ -0,0 +1,17 @@ +import { NextPage } from 'next'; +import React from 'react'; +import UserSettings from '../../../../components/UserProfile/UserSettings'; +import UserPermissions from '../../../../components/UserProfile/UserSettings/UserPermissions'; +import useRouteGuard from '../../../../hooks/useRouteGuard'; +import { Permission } from '../../../../hooks/useUser'; + +const UserPermissionsPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); + return ( + + + + ); +}; + +export default UserPermissionsPage;