commit
6ab3cd77a7
@ -0,0 +1,12 @@
|
||||
# Global code ownership
|
||||
* @sct
|
||||
|
||||
# Documentation
|
||||
docs/ @TheCatLady @samwiseg0
|
||||
|
||||
# Snap-related files
|
||||
.github/workflows/snap.yaml @samwiseg0
|
||||
snap/ @samwiseg0
|
||||
|
||||
# i18n locale files
|
||||
src/i18n/locale/ @sct @TheCatLady
|
@ -1,45 +1,45 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
about: Submit a report to help us improve
|
||||
title: ''
|
||||
labels: 'awaiting-triage, type:bug'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
#### Description
|
||||
|
||||
**What version of Overseerr are you running?**
|
||||
Please fill in the version you are currently running.
|
||||
Please provide a clear and concise description of the bug or issue.
|
||||
|
||||
You can find it under: Settings -> About -> Version
|
||||
#### Version
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
What version of Overseerr are you running? (You can find this in Settings → About → Version.)
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
#### Steps to Reproduce
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
Please tell us how we can reproduce the undesired behavior.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
1. Go to [...]
|
||||
2. Click on [...]
|
||||
3. Scroll down to [...]
|
||||
4. See error in [...]
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
#### Expected Behavior
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
Please provide a clear and concise description of what you expected to happen.
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
#### Screenshots
|
||||
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
If applicable, please provide screenshots depicting the problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
#### Device
|
||||
|
||||
What device were you using when you encountered this issue? Please provide this information to help us reproduce and investigate the bug.
|
||||
|
||||
- **Platform:** [e.g., desktop, smartphone, tablet]
|
||||
- **Device:** [e.g., iPhone X, Surface Pro, Samsung Galaxy Tab]
|
||||
- **OS:** [e.g., iOS 8.1, Windows 10, Android 11]
|
||||
- **Browser:** [e.g., Chrome, Safari, Edge, Firefox]
|
||||
|
||||
#### Additional Context
|
||||
|
||||
Please provide any additional information that may be relevant or helpful.
|
||||
|
@ -1,13 +1,13 @@
|
||||
#### Description
|
||||
|
||||
#### Screenshot (if UI related)
|
||||
#### Screenshot (if UI-related)
|
||||
|
||||
#### Todos
|
||||
#### To-Dos
|
||||
|
||||
- [ ] Sucessfully builds `yarn build`
|
||||
- [ ] Translation Keys `yarn i18n:extract`
|
||||
- [ ] Database migration created (if required)
|
||||
- [ ] Successful build `yarn build`
|
||||
- [ ] Translation keys `yarn i18n:extract`
|
||||
- [ ] Database migration (if required)
|
||||
|
||||
#### Issues Fixed or Closed by this PR
|
||||
#### Issues Fixed or Closed
|
||||
|
||||
- Fixes #XXXX
|
||||
|
@ -1,34 +1,33 @@
|
||||
# Asking for Support
|
||||
|
||||
## Before asking for support, make sure you try these things first
|
||||
## Before Asking for Support
|
||||
|
||||
* Make sure you have **updated** to the latest version.
|
||||
* ["Have you tried turning it off and on again?"](https://www.youtube.com/watch?v=nn2FB1P_Mn8)
|
||||
* **Analyzing** your logs, you just might find the solution yourself!
|
||||
* **Search** the [Wiki](../), [Installation Guides](../getting-started/installation.md), and [FAQs](faq.md).
|
||||
* If you have questions, feel free to ask them on [Discord](https://discord.gg/PkCWJSeCk7) \(Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md)\). Please include a link to your logs. See [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) for more details.
|
||||
Before seeking help, please make sure you have tried these following first:
|
||||
|
||||
- **Update** to the latest version.
|
||||
- ["Have you tried turning it off and on again?"](https://www.youtube.com/watch?v=nn2FB1P_Mn8)
|
||||
- **Analyze** your logs, you just might find the solution yourself!
|
||||
- **Search** the [Wiki](../), [Installation Guides](../getting-started/installation.md), and [FAQs](faq.md).
|
||||
- If you have questions, feel free to ask on [Discord](https://discord.gg/PkCWJSeCk7) \(Please review our [Code of Conduct](https://github.com/sct/overseerr/blob/develop/CODE_OF_CONDUCT.md).\) Be sure to include a link to your logs. See [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below.
|
||||
|
||||
## What should I include when asking for support?
|
||||
|
||||
When you contact support saying something like "it doesn't work" leaves little to go on to figure out what is wrong for you. When contacting support try to include information such as the following:
|
||||
When you contact support, a vague statement like "it doesn't work" leaves little to go on to figure out what is wrong for you. When contacting support, try to include as much information as possible. Try to answer the following questions:
|
||||
|
||||
* What did you try to do? When you describe what you did to reach the state you are in we may notice something you did different from the instructions, or something that your unique setup requires in addition. Some examples of what to provide here:
|
||||
* What command did you enter?
|
||||
* What did you click on?
|
||||
* What settings did you change?
|
||||
* Provide a step-by-step list of what you tried.
|
||||
* What do you see? We cannot see your screen so some of the following is necessary for us to know what is going on:
|
||||
* Did something happen?
|
||||
* Did something not happen?
|
||||
* Are there any error messages showing?
|
||||
* Screenshots can help us see what you are seeing
|
||||
* The Overseerr logs show exactly what happened and are often critical for identifying issues \(see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below\).
|
||||
- What did you try to do? When you describe what you did to reach the state you are in, we may notice something you did differently from the official instructions, or something required by your unique setup. The following are questions that should be answered in your request:
|
||||
- What command did you enter?
|
||||
- What did you click on?
|
||||
- What settings did you change?
|
||||
- Provide a step-by-step list of what you tried.
|
||||
- What do you see? We cannot see your screen so some of the following is necessary for us to know what is going on:
|
||||
- Did something happen?
|
||||
- Did something not happen?
|
||||
- Are there any error messages showing?
|
||||
- Provide screenshots to help us see what you are seeing.
|
||||
- Share your Overseerr logs, which show exactly what happened and are often critical for identifying issues \(see [How can I share my logs?](asking-for-support.md#how-can-i-share-my-logs) below\).
|
||||
|
||||
## How can I share my logs?
|
||||
|
||||
First you will need to gather your logs from the install directory.
|
||||
|
||||
1. Collect the log file from `<Overseeerr-install-directory>/logs/overseerr.log`
|
||||
2. Open the log file and **upload the text** by going to [gist.github.com](https://gist.github.com/) and creating a new secret Gist of the contents.
|
||||
3. **Share the link** with support in [Discord](https://discord.gg/PkCWJSeCk7) by copying the URL of the page.
|
||||
|
||||
1. Locate the log file at `<Overseeerr-install-directory>/logs/overseerr.log`
|
||||
2. Open the log file and **copy its contents** into a [**secret gist** on GitHub](https://gist.github.com/). If you upload your logs elsewhere, we may ask you to share them again via GitHub Gist.
|
||||
3. **Share the link/URL to your secret gist** in the [`#support` channel in our Discord server](https://discord.gg/PkCWJSeCk7).
|
||||
|
@ -0,0 +1,34 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity()
|
||||
export class UserSettings {
|
||||
constructor(init?: Partial<UserSettings>) {
|
||||
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;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public region?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
public originalLanguage?: string;
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
import type { User } from '../../entity/User';
|
||||
import { MediaRequest } from '../../entity/MediaRequest';
|
||||
import { PaginatedResponse } from './common';
|
||||
|
||||
export interface UserResultsResponse extends PaginatedResponse {
|
||||
results: User[];
|
||||
}
|
||||
|
||||
export interface UserRequestsResponse extends PaginatedResponse {
|
||||
results: MediaRequest[];
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
export interface UserSettingsGeneralResponse {
|
||||
username?: string;
|
||||
region?: string;
|
||||
originalLanguage?: string;
|
||||
}
|
||||
|
||||
export interface UserSettingsNotificationsResponse {
|
||||
enableNotifications: boolean;
|
||||
discordId?: string;
|
||||
}
|
@ -0,0 +1,146 @@
|
||||
import axios from 'axios';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentPushbullet } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
interface PushbulletPayload {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
class PushbulletAgent
|
||||
extends BaseAgent<NotificationAgentPushbullet>
|
||||
implements NotificationAgent {
|
||||
protected getSettings(): NotificationAgentPushbullet {
|
||||
if (this.settings) {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
return settings.notifications.agents.pushbullet;
|
||||
}
|
||||
|
||||
public shouldSend(type: Notification): boolean {
|
||||
if (
|
||||
this.getSettings().enabled &&
|
||||
this.getSettings().options.accessToken &&
|
||||
hasNotificationType(type, this.getSettings().types)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private constructMessageDetails(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): {
|
||||
title: string;
|
||||
body: string;
|
||||
} {
|
||||
let messageTitle = '';
|
||||
let message = '';
|
||||
|
||||
const title = payload.subject;
|
||||
const plot = payload.message;
|
||||
const username = payload.notifyUser.displayName;
|
||||
|
||||
switch (type) {
|
||||
case Notification.MEDIA_PENDING:
|
||||
messageTitle = 'New Request';
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Pending Approval`;
|
||||
break;
|
||||
case Notification.MEDIA_APPROVED:
|
||||
messageTitle = 'Request Approved';
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Processing`;
|
||||
break;
|
||||
case Notification.MEDIA_AVAILABLE:
|
||||
messageTitle = 'Now Available';
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Available`;
|
||||
break;
|
||||
case Notification.MEDIA_DECLINED:
|
||||
messageTitle = 'Request Declined';
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Declined`;
|
||||
break;
|
||||
case Notification.MEDIA_FAILED:
|
||||
messageTitle = 'Failed Request';
|
||||
message += `${title}`;
|
||||
if (plot) {
|
||||
message += `\n\n${plot}`;
|
||||
}
|
||||
message += `\n\nRequested By: ${username}`;
|
||||
message += `\nStatus: Failed`;
|
||||
break;
|
||||
case Notification.TEST_NOTIFICATION:
|
||||
messageTitle = 'Test Notification';
|
||||
message += `${plot}`;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
title: messageTitle,
|
||||
body: message,
|
||||
};
|
||||
}
|
||||
|
||||
public async send(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending Pushbullet notification', { label: 'Notifications' });
|
||||
try {
|
||||
const endpoint = 'https://api.pushbullet.com/v2/pushes';
|
||||
|
||||
const { accessToken } = this.getSettings().options;
|
||||
|
||||
const { title, body } = this.constructMessageDetails(type, payload);
|
||||
|
||||
await axios.post(
|
||||
endpoint,
|
||||
{
|
||||
type: 'note',
|
||||
title: title,
|
||||
body: body,
|
||||
} as PushbulletPayload,
|
||||
{
|
||||
headers: {
|
||||
'Access-Token': accessToken,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Pushbullet notification', {
|
||||
label: 'Notifications',
|
||||
message: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default PushbulletAgent;
|
@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddResetPasswordGuidAndExpiryDate1612482778137
|
||||
implements MigrationInterface {
|
||||
name = 'AddResetPasswordGuidAndExpiryDate1612482778137';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "user"`);
|
||||
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddLanguageProfileId1612571545781 implements MigrationInterface {
|
||||
name = 'AddLanguageProfileId1612571545781';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder" FROM "media_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media_request"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder" FROM "temporary_media_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media_request"`);
|
||||
}
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateUserSettings1613615266968 implements MigrationInterface {
|
||||
name = 'CreateUserSettings1613615266968';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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"`);
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class UpdateUserSettingsRegions1613955393450
|
||||
implements MigrationInterface {
|
||||
name = 'UpdateUserSettingsRegions1613955393450';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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, "region" varchar, "originalLanguage" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" 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<void> {
|
||||
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 "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
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"`);
|
||||
}
|
||||
}
|
@ -1,264 +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) => {
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
const users = await userRepository.find();
|
||||
|
||||
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?.resetPassword();
|
||||
}
|
||||
|
||||
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<string, never>,
|
||||
Partial<User>[],
|
||||
{ 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>{
|
||||
...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;
|
@ -0,0 +1,380 @@
|
||||
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 { UserResultsResponse } from '../../interfaces/api/userInterfaces';
|
||||
import { UserRequestsResponse } from '../../interfaces/api/userInterfaces';
|
||||
import userSettingsRoutes from './usersettings';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||
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, userCount] = await query
|
||||
.take(pageSize)
|
||||
.skip(skip)
|
||||
.getManyAndCount();
|
||||
|
||||
return res.status(200).json({
|
||||
pageInfo: {
|
||||
pages: Math.ceil(userCount / pageSize),
|
||||
pageSize,
|
||||
results: userCount,
|
||||
page: Math.ceil(skip / pageSize) + 1,
|
||||
},
|
||||
results: User.filterMany(
|
||||
users,
|
||||
req.user?.hasPermission(Permission.MANAGE_USERS)
|
||||
),
|
||||
} as UserResultsResponse);
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
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 },
|
||||
order: { id: 'DESC' },
|
||||
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<string, never>,
|
||||
Partial<User>[],
|
||||
{ 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>{
|
||||
...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 &&
|
||||
(await mainPlexTv.checkUserAccess(Number(account.id)))
|
||||
) {
|
||||
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;
|
@ -0,0 +1,304 @@
|
||||
import { Router } from 'express';
|
||||
import { getRepository } from 'typeorm';
|
||||
import { User } from '../../entity/User';
|
||||
import { UserSettings } from '../../entity/UserSettings';
|
||||
import {
|
||||
UserSettingsGeneralResponse,
|
||||
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 }, UserSettingsGeneralResponse>(
|
||||
'/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,
|
||||
region: user.settings?.region,
|
||||
originalLanguage: user.settings?.originalLanguage,
|
||||
});
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.post<
|
||||
{ id: string },
|
||||
UserSettingsGeneralResponse,
|
||||
UserSettingsGeneralResponse
|
||||
>('/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;
|
||||
if (!user.settings) {
|
||||
user.settings = new UserSettings({
|
||||
user: req.user,
|
||||
region: req.body.region,
|
||||
originalLanguage: req.body.originalLanguage,
|
||||
});
|
||||
} else {
|
||||
user.settings.region = req.body.region;
|
||||
user.settings.originalLanguage = req.body.originalLanguage;
|
||||
}
|
||||
|
||||
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.' });
|
||||
}
|
||||
|
||||
if (user.id === 1) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Permissions for user with ID 1 cannot be modified',
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
@ -0,0 +1 @@
|
||||
!= `Account Information [${applicationTitle}]`
|
@ -1 +1 @@
|
||||
= `${requestType}: ${mediaName} - ${applicationTitle}`
|
||||
!= `${requestType} - ${mediaName} [${applicationTitle}]`
|
||||
|
@ -1 +0,0 @@
|
||||
= `Password Reset - ${applicationTitle}`
|
@ -0,0 +1,100 @@
|
||||
doctype html
|
||||
head
|
||||
meta(charset='utf-8')
|
||||
meta(name='x-apple-disable-message-reformatting')
|
||||
meta(http-equiv='x-ua-compatible' content='ie=edge')
|
||||
meta(name='viewport' content='width=device-width, initial-scale=1')
|
||||
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
|
||||
link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap' rel='stylesheet' media='screen')
|
||||
//if mso
|
||||
xml
|
||||
o:officedocumentsettings
|
||||
o:pixelsperinch 96
|
||||
style.
|
||||
td,
|
||||
th,
|
||||
div,
|
||||
p,
|
||||
a,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
||||
style.
|
||||
@media (max-width: 600px) {
|
||||
.sm-w-full {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
div(role='article' aria-roledescription='email' aria-label='' lang='en')
|
||||
table(style="\
|
||||
background-color: #f2f4f6;\
|
||||
font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\
|
||||
width: 100%;\
|
||||
" width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center')
|
||||
table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center' style='\
|
||||
font-size: 16px;\
|
||||
padding-top: 25px;\
|
||||
padding-bottom: 25px;\
|
||||
text-align: center;\
|
||||
')
|
||||
a(href=applicationUrl style='\
|
||||
text-shadow: 0 1px 0 #ffffff;\
|
||||
font-weight: 700;\
|
||||
font-size: 16px;\
|
||||
color: #a8aaaf;\
|
||||
text-decoration: none;\
|
||||
')
|
||||
| #{applicationTitle}
|
||||
tr
|
||||
td(style='width: 100%' width='100%')
|
||||
table.sm-w-full(align='center' style='\
|
||||
background-color: #ffffff;\
|
||||
margin-left: auto;\
|
||||
margin-right: auto;\
|
||||
width: 570px;\
|
||||
' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(style='padding: 45px')
|
||||
div(style='font-size: 16px; text-align: center; padding-bottom: 14px;')
|
||||
| A request to reset the password was made. Click
|
||||
a(href=applicationUrl style='color: #3869d4; padding: 0px 5px;') here
|
||||
| to set a new password.
|
||||
div(style='font-size: 16px; text-align: center; padding-bottom: 14px;')
|
||||
| If you did not request this recovery link you can safely ignore this email.
|
||||
p(style='\
|
||||
font-size: 13px;\
|
||||
line-height: 24px;\
|
||||
margin-top: 6px;\
|
||||
margin-bottom: 20px;\
|
||||
color: #51545e;\
|
||||
')
|
||||
a(href=applicationUrl style='color: #3869d4') Open #{applicationTitle}
|
||||
tr
|
||||
td
|
||||
table.sm-w-full(align='center' style='\
|
||||
margin-left: auto;\
|
||||
margin-right: auto;\
|
||||
text-align: center;\
|
||||
width: 570px;\
|
||||
' width='570' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center' style='font-size: 16px; padding: 45px')
|
||||
p(style='\
|
||||
font-size: 13px;\
|
||||
line-height: 24px;\
|
||||
margin-top: 6px;\
|
||||
margin-bottom: 20px;\
|
||||
text-align: center;\
|
||||
color: #a8aaaf;\
|
||||
')
|
||||
| #{applicationTitle}.
|
@ -0,0 +1 @@
|
||||
!= `Password Reset [${applicationTitle}]`
|
@ -1 +1 @@
|
||||
= `Test Notification - ${applicationTitle}`
|
||||
!= `Test Notification [${applicationTitle}]`
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
After Width: | Height: | Size: 992 B |
@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import ButtonWithDropdown from '../ButtonWithDropdown';
|
||||
|
||||
interface PlayButtonProps {
|
||||
links: PlayButtonLink[];
|
||||
}
|
||||
|
||||
export interface PlayButtonLink {
|
||||
text: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const PlayButton: React.FC<PlayButtonProps> = ({ links }) => {
|
||||
if (!links || !links.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonWithDropdown
|
||||
buttonType="ghost"
|
||||
text={
|
||||
<>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{links[0].text}</span>
|
||||
</>
|
||||
}
|
||||
onClick={() => {
|
||||
window.open(links[0].url, '_blank');
|
||||
}}
|
||||
>
|
||||
{links.length > 1 &&
|
||||
links.slice(1).map((link, i) => {
|
||||
return (
|
||||
<ButtonWithDropdown.Item
|
||||
key={`play-button-dropdown-item-${i}`}
|
||||
onClick={() => {
|
||||
window.open(link.url, '_blank');
|
||||
}}
|
||||
buttonType="ghost"
|
||||
>
|
||||
{link.text}
|
||||
</ButtonWithDropdown.Item>
|
||||
);
|
||||
})}
|
||||
</ButtonWithDropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlayButton;
|
@ -0,0 +1,93 @@
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import type { TvResult } from '../../../server/models/Search';
|
||||
import ListView from '../Common/ListView';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import Header from '../Common/Header';
|
||||
import useSettings from '../../hooks/useSettings';
|
||||
import { MediaStatus } from '../../../server/constants/media';
|
||||
import PageTitle from '../Common/PageTitle';
|
||||
|
||||
const messages = defineMessages({
|
||||
upcomingtv: 'Upcoming Series',
|
||||
});
|
||||
|
||||
interface SearchResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: TvResult[];
|
||||
}
|
||||
|
||||
const DiscoverTvUpcoming: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
|
||||
(pageIndex: number, previousPageData: SearchResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/v1/discover/tv/upcoming?page=${
|
||||
pageIndex + 1
|
||||
}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 3,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoadingInitialData = !data && !error;
|
||||
const isLoadingMore =
|
||||
isLoadingInitialData ||
|
||||
(size > 0 && data && typeof data[size - 1] === 'undefined');
|
||||
|
||||
const fetchMore = () => {
|
||||
setSize(size + 1);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div>{error}</div>;
|
||||
}
|
||||
|
||||
let titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as TvResult[]
|
||||
);
|
||||
|
||||
if (settings.currentSettings.hideAvailable) {
|
||||
titles = titles.filter(
|
||||
(i) =>
|
||||
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !isLoadingInitialData && titles?.length === 0;
|
||||
const isReachingEnd =
|
||||
isEmpty || (data && data[data.length - 1]?.results.length < 20);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.upcomingtv)} />
|
||||
<div className="mt-1 mb-5">
|
||||
<Header>
|
||||
<FormattedMessage {...messages.upcomingtv} />
|
||||
</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isReachingEnd={isReachingEnd}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DiscoverTvUpcoming;
|
@ -0,0 +1,186 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
import { countryCodeEmoji } from 'country-code-emoji';
|
||||
import useSWR from 'swr';
|
||||
import type { Region } from '../../../server/lib/settings';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
regionDefault: 'All Regions',
|
||||
});
|
||||
|
||||
interface RegionSelectorProps {
|
||||
value: string;
|
||||
name: string;
|
||||
onChange?: (fieldName: string, region: string) => void;
|
||||
}
|
||||
|
||||
const RegionSelector: React.FC<RegionSelectorProps> = ({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { data: regions } = useSWR<Region[]>('/api/v1/regions');
|
||||
const [selectedRegion, setSelectedRegion] = useState<Region | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (regions && value) {
|
||||
const matchedRegion = regions.find(
|
||||
(region) => region.iso_3166_1 === value
|
||||
);
|
||||
setSelectedRegion(matchedRegion ?? null);
|
||||
}
|
||||
}, [value, regions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange && regions) {
|
||||
onChange(name, selectedRegion?.iso_3166_1 ?? '');
|
||||
}
|
||||
}, [onChange, selectedRegion, name, regions]);
|
||||
|
||||
return (
|
||||
<div className="relative z-40 flex max-w-lg">
|
||||
<div className="w-full">
|
||||
<Listbox as="div" value={selectedRegion} onChange={setSelectedRegion}>
|
||||
{({ open }) => (
|
||||
<div className="relative">
|
||||
<span className="inline-block w-full rounded-md shadow-sm">
|
||||
<Listbox.Button className="relative flex items-center w-full py-2 pl-3 pr-10 text-left text-white transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md cursor-default focus:outline-none focus:shadow-outline-blue focus:border-blue-300 sm:text-sm sm:leading-5">
|
||||
{selectedRegion && (
|
||||
<span className="h-4 mr-2 overflow-hidden text-lg leading-4">
|
||||
{countryCodeEmoji(selectedRegion.iso_3166_1)}
|
||||
</span>
|
||||
)}
|
||||
<span className="block truncate">
|
||||
{selectedRegion
|
||||
? intl.formatDisplayName(selectedRegion.iso_3166_1, {
|
||||
type: 'region',
|
||||
})
|
||||
: intl.formatMessage(messages.regionDefault)}
|
||||
</span>
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 20 20"
|
||||
className="w-5 h-5 text-gray-500"
|
||||
>
|
||||
<path
|
||||
stroke="#6b7280"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
d="M6 8l4 4 4-4"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
</span>
|
||||
|
||||
<Transition
|
||||
show={open}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
className="absolute w-full mt-1 bg-gray-800 rounded-md shadow-lg"
|
||||
>
|
||||
<Listbox.Options
|
||||
static
|
||||
className="py-1 overflow-auto text-base leading-6 rounded-md shadow-xs max-h-60 focus:outline-none sm:text-sm sm:leading-5"
|
||||
>
|
||||
<Listbox.Option value={null}>
|
||||
{({ selected, active }) => (
|
||||
<div
|
||||
className={`${
|
||||
active ? 'text-white bg-indigo-600' : 'text-gray-300'
|
||||
} cursor-default select-none relative py-2 pl-8 pr-4`}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} block truncate`}
|
||||
>
|
||||
{intl.formatMessage(messages.regionDefault)}
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={`${
|
||||
active ? 'text-white' : 'text-indigo-600'
|
||||
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
{regions?.map((region) => (
|
||||
<Listbox.Option key={region.iso_3166_1} value={region}>
|
||||
{({ selected, active }) => (
|
||||
<div
|
||||
className={`${
|
||||
active
|
||||
? 'text-white bg-indigo-600'
|
||||
: 'text-gray-300'
|
||||
} cursor-default select-none relative py-2 pl-8 pr-4 flex items-center`}
|
||||
>
|
||||
<span className="mr-2 text-lg">
|
||||
{countryCodeEmoji(region.iso_3166_1)}
|
||||
</span>
|
||||
<span
|
||||
className={`${
|
||||
selected ? 'font-semibold' : 'font-normal'
|
||||
} block truncate`}
|
||||
>
|
||||
{intl.formatDisplayName(region.iso_3166_1, {
|
||||
type: 'region',
|
||||
fallback: 'none',
|
||||
}) ?? region.english_name}
|
||||
</span>
|
||||
{selected && (
|
||||
<span
|
||||
className={`${
|
||||
active ? 'text-white' : 'text-indigo-600'
|
||||
} absolute inset-y-0 left-0 flex items-center pl-1.5`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Listbox>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegionSelector;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue