aboutsummaryrefslogtreecommitdiffhomepage
path: root/client
diff options
context:
space:
mode:
Diffstat (limited to 'client')
-rw-r--r--client/.gitignore1
-rw-r--r--client/e2e/src/po/admin-config.po.ts27
-rw-r--r--client/e2e/src/po/admin-registration.po.ts35
-rw-r--r--client/e2e/src/po/login.po.ts36
-rw-r--r--client/e2e/src/po/signup.po.ts51
-rw-r--r--client/e2e/src/suites-local/signup.e2e-spec.ts403
-rw-r--r--client/e2e/src/utils/elements.ts17
-rw-r--r--client/e2e/src/utils/email.ts31
-rw-r--r--client/e2e/src/utils/hooks.ts24
-rw-r--r--client/e2e/src/utils/index.ts2
-rw-r--r--client/e2e/src/utils/mock-smtp.ts58
-rw-r--r--client/e2e/src/utils/server.ts4
-rw-r--r--client/e2e/wdio.local-test.conf.ts2
-rw-r--r--client/e2e/wdio.local.conf.ts2
-rw-r--r--client/package.json6
-rw-r--r--client/src/app/+about/about-instance/about-instance.component.html40
-rw-r--r--client/src/app/+about/about-instance/about-instance.component.ts41
-rw-r--r--client/src/app/+about/about-instance/about-instance.resolver.ts25
-rw-r--r--client/src/app/+admin/admin.component.ts12
-rw-r--r--client/src/app/+admin/admin.module.ts16
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html25
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts1
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html2
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html2
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.html4
-rw-r--r--client/src/app/+admin/follows/followers-list/followers-list.component.ts19
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.html4
-rw-r--r--client/src/app/+admin/follows/following-list/following-list.component.ts19
-rw-r--r--client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts2
-rw-r--r--client/src/app/+admin/moderation/index.ts1
-rw-r--r--client/src/app/+admin/moderation/moderation.routes.ts15
-rw-r--r--client/src/app/+admin/moderation/registration-list/admin-registration.service.ts81
-rw-r--r--client/src/app/+admin/moderation/registration-list/index.ts4
-rw-r--r--client/src/app/+admin/moderation/registration-list/process-registration-modal.component.html74
-rw-r--r--client/src/app/+admin/moderation/registration-list/process-registration-modal.component.scss3
-rw-r--r--client/src/app/+admin/moderation/registration-list/process-registration-modal.component.ts122
-rw-r--r--client/src/app/+admin/moderation/registration-list/process-registration-validators.ts11
-rw-r--r--client/src/app/+admin/moderation/registration-list/registration-list.component.html135
-rw-r--r--client/src/app/+admin/moderation/registration-list/registration-list.component.scss7
-rw-r--r--client/src/app/+admin/moderation/registration-list/registration-list.component.ts151
-rw-r--r--client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts27
-rw-r--r--client/src/app/+admin/overview/comments/video-comment-list.component.html4
-rw-r--r--client/src/app/+admin/overview/comments/video-comment-list.component.ts15
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.html23
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.scss10
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.ts17
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.html4
-rw-r--r--client/src/app/+admin/overview/videos/video-list.component.ts46
-rw-r--r--client/src/app/+admin/shared/shared-admin.module.ts7
-rw-r--r--client/src/app/+admin/shared/user-email-info.component.html13
-rw-r--r--client/src/app/+admin/shared/user-email-info.component.scss10
-rw-r--r--client/src/app/+admin/shared/user-email-info.component.ts20
-rw-r--r--client/src/app/+admin/system/jobs/job.service.ts2
-rw-r--r--client/src/app/+admin/system/jobs/jobs.component.ts4
-rw-r--r--client/src/app/+login/login.component.ts33
-rw-r--r--client/src/app/+my-library/my-ownership/my-ownership.component.scss4
-rw-r--r--client/src/app/+my-library/my-ownership/my-ownership.component.ts2
-rw-r--r--client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.ts2
-rw-r--r--client/src/app/+my-library/my-video-imports/my-video-imports.component.ts2
-rw-r--r--client/src/app/+signup/+register/register.component.html35
-rw-r--r--client/src/app/+signup/+register/register.component.ts62
-rw-r--r--client/src/app/+signup/+register/shared/index.ts1
-rw-r--r--client/src/app/+signup/+register/shared/register-validators.ts18
-rw-r--r--client/src/app/+signup/+register/steps/register-step-about.component.html4
-rw-r--r--client/src/app/+signup/+register/steps/register-step-about.component.ts1
-rw-r--r--client/src/app/+signup/+register/steps/register-step-channel.component.ts6
-rw-r--r--client/src/app/+signup/+register/steps/register-step-terms.component.html14
-rw-r--r--client/src/app/+signup/+register/steps/register-step-terms.component.ts10
-rw-r--r--client/src/app/+signup/+register/steps/register-step-user.component.ts6
-rw-r--r--client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts6
-rw-r--r--client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html17
-rw-r--r--client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts86
-rw-r--r--client/src/app/+signup/shared/shared-signup.module.ts11
-rw-r--r--client/src/app/+signup/shared/signup-success-after-email.component.html21
-rw-r--r--client/src/app/+signup/shared/signup-success-after-email.component.ts10
-rw-r--r--client/src/app/+signup/shared/signup-success-before-email.component.html35
-rw-r--r--client/src/app/+signup/shared/signup-success-before-email.component.ts12
-rw-r--r--client/src/app/+signup/shared/signup-success.component.html22
-rw-r--r--client/src/app/+signup/shared/signup-success.component.ts19
-rw-r--r--client/src/app/+signup/shared/signup.service.ts (renamed from client/src/app/shared/shared-users/user-signup.service.ts)41
-rw-r--r--client/src/app/+videos/+video-watch/video-watch.component.ts40
-rw-r--r--client/src/app/+videos/video-list/videos-list-common-page.component.ts3
-rw-r--r--client/src/app/core/auth/auth.service.ts45
-rw-r--r--client/src/app/core/renderer/linkifier.service.ts2
-rw-r--r--client/src/app/core/renderer/markdown.service.ts10
-rw-r--r--client/src/app/core/rest/rest-extractor.service.ts6
-rw-r--r--client/src/app/core/rest/rest-table.ts16
-rw-r--r--client/src/app/menu/menu.component.html4
-rw-r--r--client/src/app/menu/menu.component.ts12
-rw-r--r--client/src/app/shared/form-validators/form-validator.model.ts2
-rw-r--r--client/src/app/shared/form-validators/user-validators.ts7
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-details.component.html6
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts2
-rw-r--r--client/src/app/shared/shared-custom-markup/custom-markup-container.component.ts20
-rw-r--r--client/src/app/shared/shared-custom-markup/peertube-custom-tags/button-markup.component.ts5
-rw-r--r--client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts5
-rw-r--r--client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts5
-rw-r--r--client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts5
-rw-r--r--client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts5
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.ts4
-rw-r--r--client/src/app/shared/shared-instance/instance-features-table.component.html7
-rw-r--r--client/src/app/shared/shared-instance/instance-features-table.component.ts9
-rw-r--r--client/src/app/shared/shared-instance/instance.service.ts7
-rw-r--r--client/src/app/shared/shared-main/account/index.ts1
-rw-r--r--client/src/app/shared/shared-main/account/signup-label.component.html2
-rw-r--r--client/src/app/shared/shared-main/account/signup-label.component.ts9
-rw-r--r--client/src/app/shared/shared-main/shared-main.module.ts6
-rw-r--r--client/src/app/shared/shared-main/users/user-notification.model.ts12
-rw-r--r--client/src/app/shared/shared-main/users/user-notifications.component.html8
-rw-r--r--client/src/app/shared/shared-moderation/account-blocklist.component.scss4
-rw-r--r--client/src/app/shared/shared-moderation/account-blocklist.component.ts2
-rw-r--r--client/src/app/shared/shared-moderation/moderation.scss4
-rw-r--r--client/src/app/shared/shared-moderation/server-blocklist.component.scss4
-rw-r--r--client/src/app/shared/shared-moderation/server-blocklist.component.ts2
-rw-r--r--client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts2
-rw-r--r--client/src/app/shared/shared-users/index.ts1
-rw-r--r--client/src/app/shared/shared-users/shared-users.module.ts3
-rw-r--r--client/src/app/shared/shared-users/user-admin.service.ts2
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.html4
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.scss4
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.ts2
-rw-r--r--client/src/app/shared/shared-video-miniature/videos-list.component.ts60
-rw-r--r--client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts4
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist.service.ts16
-rw-r--r--client/src/assets/player/peertube-player-manager.ts7
-rw-r--r--client/src/assets/player/shared/control-bar/index.ts1
-rw-r--r--client/src/assets/player/shared/control-bar/peertube-live-display.ts93
-rw-r--r--client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts76
-rw-r--r--client/src/assets/player/shared/manager-options/control-bar-options-builder.ts25
-rw-r--r--client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts4
-rw-r--r--client/src/assets/player/shared/stats/stats-card.ts4
-rw-r--r--client/src/assets/player/types/manager-options.ts2
-rw-r--r--client/src/assets/player/types/peertube-videojs-typings.ts3
-rw-r--r--client/src/root-helpers/logger.ts8
-rw-r--r--client/src/root-helpers/plugins-manager.ts11
-rw-r--r--client/src/sass/class-helpers.scss6
-rw-r--r--client/src/sass/include/_badges.scss4
-rw-r--r--client/src/sass/include/_fonts.scss4
-rw-r--r--client/src/sass/include/_mixins.scss36
-rw-r--r--client/src/sass/player/control-bar.scss21
-rw-r--r--client/src/sass/primeng-custom.scss1
-rw-r--r--client/src/standalone/videos/shared/player-manager-options.ts4
-rw-r--r--client/yarn.lock463
143 files changed, 2621 insertions, 728 deletions
diff --git a/client/.gitignore b/client/.gitignore
index 3241b09ed..ca68413c8 100644
--- a/client/.gitignore
+++ b/client/.gitignore
@@ -11,5 +11,6 @@
11/src/locale/target/server_*.xml 11/src/locale/target/server_*.xml
12/e2e/local.log 12/e2e/local.log
13/e2e/browserstack.err 13/e2e/browserstack.err
14/e2e/screenshots
14/src/standalone/player/build 15/src/standalone/player/build
15/src/standalone/player/dist 16/src/standalone/player/dist
diff --git a/client/e2e/src/po/admin-config.po.ts b/client/e2e/src/po/admin-config.po.ts
index 27957a71f..510037ddd 100644
--- a/client/e2e/src/po/admin-config.po.ts
+++ b/client/e2e/src/po/admin-config.po.ts
@@ -1,4 +1,4 @@
1import { getCheckbox, go } from '../utils' 1import { browserSleep, getCheckbox, go, isCheckboxSelected } from '../utils'
2 2
3export class AdminConfigPage { 3export class AdminConfigPage {
4 4
@@ -8,7 +8,6 @@ export class AdminConfigPage {
8 'basic-configuration': 'APPEARANCE', 8 'basic-configuration': 'APPEARANCE',
9 'instance-information': 'INSTANCE' 9 'instance-information': 'INSTANCE'
10 } 10 }
11
12 await go('/admin/config/edit-custom#' + tab) 11 await go('/admin/config/edit-custom#' + tab)
13 12
14 await $('.inner-form-title=' + waitTitles[tab]).waitForDisplayed() 13 await $('.inner-form-title=' + waitTitles[tab]).waitForDisplayed()
@@ -28,17 +27,39 @@ export class AdminConfigPage {
28 return $('#instanceCustomHomepageContent').setValue(newValue) 27 return $('#instanceCustomHomepageContent').setValue(newValue)
29 } 28 }
30 29
31 async toggleSignup () { 30 async toggleSignup (enabled: boolean) {
31 if (await isCheckboxSelected('signupEnabled') === enabled) return
32
32 const checkbox = await getCheckbox('signupEnabled') 33 const checkbox = await getCheckbox('signupEnabled')
33 34
34 await checkbox.waitForClickable() 35 await checkbox.waitForClickable()
35 await checkbox.click() 36 await checkbox.click()
36 } 37 }
37 38
39 async toggleSignupApproval (required: boolean) {
40 if (await isCheckboxSelected('signupRequiresApproval') === required) return
41
42 const checkbox = await getCheckbox('signupRequiresApproval')
43
44 await checkbox.waitForClickable()
45 await checkbox.click()
46 }
47
48 async toggleSignupEmailVerification (required: boolean) {
49 if (await isCheckboxSelected('signupRequiresEmailVerification') === required) return
50
51 const checkbox = await getCheckbox('signupRequiresEmailVerification')
52
53 await checkbox.waitForClickable()
54 await checkbox.click()
55 }
56
38 async save () { 57 async save () {
39 const button = $('input[type=submit]') 58 const button = $('input[type=submit]')
40 59
41 await button.waitForClickable() 60 await button.waitForClickable()
42 await button.click() 61 await button.click()
62
63 await browserSleep(1000)
43 } 64 }
44} 65}
diff --git a/client/e2e/src/po/admin-registration.po.ts b/client/e2e/src/po/admin-registration.po.ts
new file mode 100644
index 000000000..85234654d
--- /dev/null
+++ b/client/e2e/src/po/admin-registration.po.ts
@@ -0,0 +1,35 @@
1import { browserSleep, findParentElement, go } from '../utils'
2
3export class AdminRegistrationPage {
4
5 async navigateToRegistratonsList () {
6 await go('/admin/moderation/registrations/list')
7
8 await $('my-registration-list').waitForDisplayed()
9 }
10
11 async accept (username: string, moderationResponse: string) {
12 const usernameEl = await $('*=' + username)
13 await usernameEl.waitForDisplayed()
14
15 const tr = await findParentElement(usernameEl, async el => await el.getTagName() === 'tr')
16
17 await tr.$('.action-cell .dropdown-root').click()
18
19 const accept = await $('span*=Accept this registration')
20 await accept.waitForClickable()
21 await accept.click()
22
23 const moderationResponseTextarea = await $('#moderationResponse')
24 await moderationResponseTextarea.waitForDisplayed()
25
26 await moderationResponseTextarea.setValue(moderationResponse)
27
28 const submitButton = $('.modal-footer input[type=submit]')
29 await submitButton.waitForClickable()
30 await submitButton.click()
31
32 await browserSleep(1000)
33 }
34
35}
diff --git a/client/e2e/src/po/login.po.ts b/client/e2e/src/po/login.po.ts
index bc1854dbc..f1d13a2b0 100644
--- a/client/e2e/src/po/login.po.ts
+++ b/client/e2e/src/po/login.po.ts
@@ -6,7 +6,14 @@ export class LoginPage {
6 6
7 } 7 }
8 8
9 async login (username: string, password: string, url = '/login') { 9 async login (options: {
10 username: string
11 password: string
12 displayName?: string
13 url?: string
14 }) {
15 const { username, password, url = '/login', displayName = username } = options
16
10 await go(url) 17 await go(url)
11 18
12 await browser.execute(`window.localStorage.setItem('no_account_setup_warning_modal', 'true')`) 19 await browser.execute(`window.localStorage.setItem('no_account_setup_warning_modal', 'true')`)
@@ -27,27 +34,40 @@ export class LoginPage {
27 34
28 await menuToggle.click() 35 await menuToggle.click()
29 36
30 await this.ensureIsLoggedInAs(username) 37 await this.ensureIsLoggedInAs(displayName)
31 38
32 await menuToggle.click() 39 await menuToggle.click()
33 } else { 40 } else {
34 await this.ensureIsLoggedInAs(username) 41 await this.ensureIsLoggedInAs(displayName)
35 } 42 }
36 } 43 }
37 44
45 async getLoginError (username: string, password: string) {
46 await go('/login')
47
48 await $('input#username').setValue(username)
49 await $('input#password').setValue(password)
50
51 await browser.pause(1000)
52
53 await $('form input[type=submit]').click()
54
55 return $('.alert-danger').getText()
56 }
57
38 async loginAsRootUser () { 58 async loginAsRootUser () {
39 return this.login('root', 'test' + this.getSuffix()) 59 return this.login({ username: 'root', password: 'test' + this.getSuffix() })
40 } 60 }
41 61
42 loginOnPeerTube2 () { 62 loginOnPeerTube2 () {
43 return this.login('e2e', process.env.PEERTUBE2_E2E_PASSWORD, 'https://peertube2.cpy.re/login') 63 return this.login({ username: 'e2e', password: process.env.PEERTUBE2_E2E_PASSWORD, url: 'https://peertube2.cpy.re/login' })
44 } 64 }
45 65
46 async logout () { 66 async logout () {
47 const loggedInMore = $('.logged-in-more') 67 const loggedInDropdown = $('.logged-in-more .logged-in-info')
48 68
49 await loggedInMore.waitForClickable() 69 await loggedInDropdown.waitForClickable()
50 await loggedInMore.click() 70 await loggedInDropdown.click()
51 71
52 const logout = $('.dropdown-item*=Log out') 72 const logout = $('.dropdown-item*=Log out')
53 73
diff --git a/client/e2e/src/po/signup.po.ts b/client/e2e/src/po/signup.po.ts
index cc2ed7c5f..7917cdda7 100644
--- a/client/e2e/src/po/signup.po.ts
+++ b/client/e2e/src/po/signup.po.ts
@@ -27,42 +27,39 @@ export class SignupPage {
27 return terms.click() 27 return terms.click()
28 } 28 }
29 29
30 async getEndMessage () {
31 const alert = $('.pt-alert-primary')
32 await alert.waitForDisplayed()
33
34 return alert.getText()
35 }
36
37 async fillRegistrationReason (reason: string) {
38 await $('#registrationReason').setValue(reason)
39 }
40
30 async fillAccountStep (options: { 41 async fillAccountStep (options: {
31 displayName: string
32 username: string 42 username: string
33 email: string 43 password?: string
34 password: string 44 displayName?: string
45 email?: string
35 }) { 46 }) {
36 if (options.displayName) { 47 await $('#displayName').setValue(options.displayName || `${options.username} display name`)
37 await $('#displayName').setValue(options.displayName)
38 }
39
40 if (options.username) {
41 await $('#username').setValue(options.username)
42 }
43 48
44 if (options.email) { 49 await $('#username').setValue(options.username)
45 // Fix weird bug on firefox that "cannot scroll into view" when using just `setValue` 50 await $('#password').setValue(options.password || 'password')
46 await $('#email').scrollIntoView(false)
47 await $('#email').waitForClickable()
48 await $('#email').setValue(options.email)
49 }
50 51
51 if (options.password) { 52 // Fix weird bug on firefox that "cannot scroll into view" when using just `setValue`
52 await $('#password').setValue(options.password) 53 await $('#email').scrollIntoView(false)
53 } 54 await $('#email').waitForClickable()
55 await $('#email').setValue(options.email || `${options.username}@example.com`)
54 } 56 }
55 57
56 async fillChannelStep (options: { 58 async fillChannelStep (options: {
57 displayName: string
58 name: string 59 name: string
60 displayName?: string
59 }) { 61 }) {
60 if (options.displayName) { 62 await $('#displayName').setValue(options.displayName || `${options.name} channel display name`)
61 await $('#displayName').setValue(options.displayName) 63 await $('#name').setValue(options.name)
62 }
63
64 if (options.name) {
65 await $('#name').setValue(options.name)
66 }
67 } 64 }
68} 65}
diff --git a/client/e2e/src/suites-local/signup.e2e-spec.ts b/client/e2e/src/suites-local/signup.e2e-spec.ts
index 4eed3eefe..b6f7ad1a7 100644
--- a/client/e2e/src/suites-local/signup.e2e-spec.ts
+++ b/client/e2e/src/suites-local/signup.e2e-spec.ts
@@ -1,12 +1,89 @@
1import { AdminConfigPage } from '../po/admin-config.po' 1import { AdminConfigPage } from '../po/admin-config.po'
2import { AdminRegistrationPage } from '../po/admin-registration.po'
2import { LoginPage } from '../po/login.po' 3import { LoginPage } from '../po/login.po'
3import { SignupPage } from '../po/signup.po' 4import { SignupPage } from '../po/signup.po'
4import { isMobileDevice, waitServerUp } from '../utils' 5import { browserSleep, getVerificationLink, go, findEmailTo, isMobileDevice, MockSMTPServer, waitServerUp } from '../utils'
6
7function checkEndMessage (options: {
8 message: string
9 requiresEmailVerification: boolean
10 requiresApproval: boolean
11 afterEmailVerification: boolean
12}) {
13 const { message, requiresApproval, requiresEmailVerification, afterEmailVerification } = options
14
15 {
16 const created = 'account has been created'
17 const request = 'account request has been sent'
18
19 if (requiresApproval) {
20 expect(message).toContain(request)
21 expect(message).not.toContain(created)
22 } else {
23 expect(message).not.toContain(request)
24 expect(message).toContain(created)
25 }
26 }
27
28 {
29 const checkEmail = 'Check your emails'
30
31 if (requiresEmailVerification) {
32 expect(message).toContain(checkEmail)
33 } else {
34 expect(message).not.toContain(checkEmail)
35
36 const moderatorsApproval = 'moderator will check your registration request'
37 if (requiresApproval) {
38 expect(message).toContain(moderatorsApproval)
39 } else {
40 expect(message).not.toContain(moderatorsApproval)
41 }
42 }
43 }
44
45 {
46 const emailVerified = 'email has been verified'
47
48 if (afterEmailVerification) {
49 expect(message).toContain(emailVerified)
50 } else {
51 expect(message).not.toContain(emailVerified)
52 }
53 }
54}
5 55
6describe('Signup', () => { 56describe('Signup', () => {
7 let loginPage: LoginPage 57 let loginPage: LoginPage
8 let adminConfigPage: AdminConfigPage 58 let adminConfigPage: AdminConfigPage
9 let signupPage: SignupPage 59 let signupPage: SignupPage
60 let adminRegistrationPage: AdminRegistrationPage
61
62 async function prepareSignup (options: {
63 enabled: boolean
64 requiresApproval?: boolean
65 requiresEmailVerification?: boolean
66 }) {
67 await loginPage.loginAsRootUser()
68
69 await adminConfigPage.navigateTo('basic-configuration')
70 await adminConfigPage.toggleSignup(options.enabled)
71
72 if (options.enabled) {
73 if (options.requiresApproval !== undefined) {
74 await adminConfigPage.toggleSignupApproval(options.requiresApproval)
75 }
76
77 if (options.requiresEmailVerification !== undefined) {
78 await adminConfigPage.toggleSignupEmailVerification(options.requiresEmailVerification)
79 }
80 }
81
82 await adminConfigPage.save()
83
84 await loginPage.logout()
85 await browser.refresh()
86 }
10 87
11 before(async () => { 88 before(async () => {
12 await waitServerUp() 89 await waitServerUp()
@@ -16,72 +93,310 @@ describe('Signup', () => {
16 loginPage = new LoginPage(isMobileDevice()) 93 loginPage = new LoginPage(isMobileDevice())
17 adminConfigPage = new AdminConfigPage() 94 adminConfigPage = new AdminConfigPage()
18 signupPage = new SignupPage() 95 signupPage = new SignupPage()
96 adminRegistrationPage = new AdminRegistrationPage()
19 97
20 await browser.maximizeWindow() 98 await browser.maximizeWindow()
21 }) 99 })
22 100
23 it('Should disable signup', async () => { 101 describe('Signup disabled', function () {
24 await loginPage.loginAsRootUser() 102 it('Should disable signup', async () => {
103 await prepareSignup({ enabled: false })
25 104
26 await adminConfigPage.navigateTo('basic-configuration') 105 await expect(signupPage.getRegisterMenuButton()).not.toBeDisplayed()
27 await adminConfigPage.toggleSignup() 106 })
107 })
28 108
29 await adminConfigPage.save() 109 describe('Email verification disabled', function () {
30 110
31 await loginPage.logout() 111 describe('Direct registration', function () {
32 await browser.refresh()
33 112
34 expect(signupPage.getRegisterMenuButton()).not.toBeDisplayed() 113 it('Should enable signup without approval', async () => {
35 }) 114 await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: false })
36 115
37 it('Should enable signup', async () => { 116 await signupPage.getRegisterMenuButton().waitForDisplayed()
38 await loginPage.loginAsRootUser() 117 })
39 118
40 await adminConfigPage.navigateTo('basic-configuration') 119 it('Should go on signup page', async function () {
41 await adminConfigPage.toggleSignup() 120 await signupPage.clickOnRegisterInMenu()
121 })
42 122
43 await adminConfigPage.save() 123 it('Should validate the first step (about page)', async function () {
124 await signupPage.validateStep()
125 })
44 126
45 await loginPage.logout() 127 it('Should validate the second step (terms)', async function () {
46 await browser.refresh() 128 await signupPage.checkTerms()
129 await signupPage.validateStep()
130 })
47 131
48 expect(signupPage.getRegisterMenuButton()).toBeDisplayed() 132 it('Should validate the third step (account)', async function () {
49 }) 133 await signupPage.fillAccountStep({ username: 'user_1', displayName: 'user_1_dn' })
50 134
51 it('Should go on signup page', async function () { 135 await signupPage.validateStep()
52 await signupPage.clickOnRegisterInMenu() 136 })
53 })
54 137
55 it('Should validate the first step (about page)', async function () { 138 it('Should validate the third step (channel)', async function () {
56 await signupPage.validateStep() 139 await signupPage.fillChannelStep({ name: 'user_1_channel' })
57 })
58 140
59 it('Should validate the second step (terms)', async function () { 141 await signupPage.validateStep()
60 await signupPage.checkTerms() 142 })
61 await signupPage.validateStep() 143
62 }) 144 it('Should be logged in', async function () {
145 await loginPage.ensureIsLoggedInAs('user_1_dn')
146 })
147
148 it('Should have a valid end message', async function () {
149 const message = await signupPage.getEndMessage()
150
151 checkEndMessage({
152 message,
153 requiresEmailVerification: false,
154 requiresApproval: false,
155 afterEmailVerification: false
156 })
63 157
64 it('Should validate the third step (account)', async function () { 158 await browser.saveScreenshot('./screenshots/direct-without-email.png')
65 await signupPage.fillAccountStep({ 159
66 displayName: 'user 1', 160 await loginPage.logout()
67 username: 'user_1', 161 })
68 email: 'user_1@example.com',
69 password: 'my_super_password'
70 }) 162 })
71 163
72 await signupPage.validateStep() 164 describe('Registration with approval', function () {
165
166 it('Should enable signup with approval', async () => {
167 await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: false })
168
169 await signupPage.getRegisterMenuButton().waitForDisplayed()
170 })
171
172 it('Should go on signup page', async function () {
173 await signupPage.clickOnRegisterInMenu()
174 })
175
176 it('Should validate the first step (about page)', async function () {
177 await signupPage.validateStep()
178 })
179
180 it('Should validate the second step (terms)', async function () {
181 await signupPage.checkTerms()
182 await signupPage.fillRegistrationReason('my super reason')
183 await signupPage.validateStep()
184 })
185
186 it('Should validate the third step (account)', async function () {
187 await signupPage.fillAccountStep({ username: 'user_2', displayName: 'user_2 display name', password: 'password' })
188 await signupPage.validateStep()
189 })
190
191 it('Should validate the third step (channel)', async function () {
192 await signupPage.fillChannelStep({ name: 'user_2_channel' })
193 await signupPage.validateStep()
194 })
195
196 it('Should have a valid end message', async function () {
197 const message = await signupPage.getEndMessage()
198
199 checkEndMessage({
200 message,
201 requiresEmailVerification: false,
202 requiresApproval: true,
203 afterEmailVerification: false
204 })
205
206 await browser.saveScreenshot('./screenshots/request-without-email.png')
207 })
208
209 it('Should display a message when trying to login with this account', async function () {
210 const error = await loginPage.getLoginError('user_2', 'password')
211
212 expect(error).toContain('awaiting approval')
213 })
214
215 it('Should accept the registration', async function () {
216 await loginPage.loginAsRootUser()
217
218 await adminRegistrationPage.navigateToRegistratonsList()
219 await adminRegistrationPage.accept('user_2', 'moderation response')
220
221 await loginPage.logout()
222 })
223
224 it('Should be able to login with this new account', async function () {
225 await loginPage.login({ username: 'user_2', password: 'password', displayName: 'user_2 display name' })
226
227 await loginPage.logout()
228 })
229 })
73 }) 230 })
74 231
75 it('Should validate the third step (channel)', async function () { 232 describe('Email verification enabled', function () {
76 await signupPage.fillChannelStep({ 233 const emails: any[] = []
77 displayName: 'user 1 channel', 234 let emailPort: number
78 name: 'user_1_channel' 235
236 before(async () => {
237 // FIXME: typings are wrong, get returns a promise
238 emailPort = await browser.sharedStore.get('emailPort') as unknown as number
239
240 MockSMTPServer.Instance.collectEmails(emailPort, emails)
79 }) 241 })
80 242
81 await signupPage.validateStep() 243 describe('Direct registration', function () {
82 }) 244
245 it('Should enable signup without approval', async () => {
246 await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: true })
247
248 await signupPage.getRegisterMenuButton().waitForDisplayed()
249 })
250
251 it('Should go on signup page', async function () {
252 await signupPage.clickOnRegisterInMenu()
253 })
254
255 it('Should validate the first step (about page)', async function () {
256 await signupPage.validateStep()
257 })
258
259 it('Should validate the second step (terms)', async function () {
260 await signupPage.checkTerms()
261 await signupPage.validateStep()
262 })
263
264 it('Should validate the third step (account)', async function () {
265 await signupPage.fillAccountStep({ username: 'user_3', displayName: 'user_3 display name', email: 'user_3@example.com' })
266
267 await signupPage.validateStep()
268 })
269
270 it('Should validate the third step (channel)', async function () {
271 await signupPage.fillChannelStep({ name: 'user_3_channel' })
272
273 await signupPage.validateStep()
274 })
275
276 it('Should have a valid end message', async function () {
277 const message = await signupPage.getEndMessage()
278
279 checkEndMessage({
280 message,
281 requiresEmailVerification: true,
282 requiresApproval: false,
283 afterEmailVerification: false
284 })
285
286 await browser.saveScreenshot('./screenshots/direct-with-email.png')
287 })
288
289 it('Should validate the email', async function () {
290 let email: { text: string }
291
292 while (!(email = findEmailTo(emails, 'user_3@example.com'))) {
293 await browserSleep(100)
294 }
295
296 await go(getVerificationLink(email))
297
298 const message = await signupPage.getEndMessage()
299
300 checkEndMessage({
301 message,
302 requiresEmailVerification: false,
303 requiresApproval: false,
304 afterEmailVerification: true
305 })
83 306
84 it('Should be logged in', async function () { 307 await browser.saveScreenshot('./screenshots/direct-after-email.png')
85 await loginPage.ensureIsLoggedInAs('user 1') 308 })
309 })
310
311 describe('Registration with approval', function () {
312
313 it('Should enable signup without approval', async () => {
314 await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: true })
315
316 await signupPage.getRegisterMenuButton().waitForDisplayed()
317 })
318
319 it('Should go on signup page', async function () {
320 await signupPage.clickOnRegisterInMenu()
321 })
322
323 it('Should validate the first step (about page)', async function () {
324 await signupPage.validateStep()
325 })
326
327 it('Should validate the second step (terms)', async function () {
328 await signupPage.checkTerms()
329 await signupPage.fillRegistrationReason('my super reason 2')
330 await signupPage.validateStep()
331 })
332
333 it('Should validate the third step (account)', async function () {
334 await signupPage.fillAccountStep({
335 username: 'user_4',
336 displayName: 'user_4 display name',
337 email: 'user_4@example.com',
338 password: 'password'
339 })
340 await signupPage.validateStep()
341 })
342
343 it('Should validate the third step (channel)', async function () {
344 await signupPage.fillChannelStep({ name: 'user_4_channel' })
345 await signupPage.validateStep()
346 })
347
348 it('Should have a valid end message', async function () {
349 const message = await signupPage.getEndMessage()
350
351 checkEndMessage({
352 message,
353 requiresEmailVerification: true,
354 requiresApproval: true,
355 afterEmailVerification: false
356 })
357
358 await browser.saveScreenshot('./screenshots/request-with-email.png')
359 })
360
361 it('Should display a message when trying to login with this account', async function () {
362 const error = await loginPage.getLoginError('user_4', 'password')
363
364 expect(error).toContain('awaiting approval')
365 })
366
367 it('Should accept the registration', async function () {
368 await loginPage.loginAsRootUser()
369
370 await adminRegistrationPage.navigateToRegistratonsList()
371 await adminRegistrationPage.accept('user_4', 'moderation response 2')
372
373 await loginPage.logout()
374 })
375
376 it('Should validate the email', async function () {
377 let email: { text: string }
378
379 while (!(email = findEmailTo(emails, 'user_4@example.com'))) {
380 await browserSleep(100)
381 }
382
383 await go(getVerificationLink(email))
384
385 const message = await signupPage.getEndMessage()
386
387 checkEndMessage({
388 message,
389 requiresEmailVerification: false,
390 requiresApproval: true,
391 afterEmailVerification: true
392 })
393
394 await browser.saveScreenshot('./screenshots/request-after-email.png')
395 })
396 })
397
398 before(() => {
399 MockSMTPServer.Instance.kill()
400 })
86 }) 401 })
87}) 402})
diff --git a/client/e2e/src/utils/elements.ts b/client/e2e/src/utils/elements.ts
index b0ddd5a65..d9435e520 100644
--- a/client/e2e/src/utils/elements.ts
+++ b/client/e2e/src/utils/elements.ts
@@ -5,6 +5,10 @@ async function getCheckbox (name: string) {
5 return input.parentElement() 5 return input.parentElement()
6} 6}
7 7
8function isCheckboxSelected (name: string) {
9 return $(`input[id=${name}]`).isSelected()
10}
11
8async function selectCustomSelect (id: string, valueLabel: string) { 12async function selectCustomSelect (id: string, valueLabel: string) {
9 const wrapper = $(`[formcontrolname=${id}] .ng-arrow-wrapper`) 13 const wrapper = $(`[formcontrolname=${id}] .ng-arrow-wrapper`)
10 14
@@ -22,7 +26,18 @@ async function selectCustomSelect (id: string, valueLabel: string) {
22 return option.click() 26 return option.click()
23} 27}
24 28
29async function findParentElement (
30 el: WebdriverIO.Element,
31 finder: (el: WebdriverIO.Element) => Promise<boolean>
32) {
33 if (await finder(el) === true) return el
34
35 return findParentElement(await el.parentElement(), finder)
36}
37
25export { 38export {
26 getCheckbox, 39 getCheckbox,
27 selectCustomSelect 40 isCheckboxSelected,
41 selectCustomSelect,
42 findParentElement
28} 43}
diff --git a/client/e2e/src/utils/email.ts b/client/e2e/src/utils/email.ts
new file mode 100644
index 000000000..2ad120333
--- /dev/null
+++ b/client/e2e/src/utils/email.ts
@@ -0,0 +1,31 @@
1function getVerificationLink (email: { text: string }) {
2 const { text } = email
3
4 const regexp = /\[(?<link>http:\/\/[^\]]+)\]/g
5 const matched = text.matchAll(regexp)
6
7 if (!matched) throw new Error('Could not find verification link in email')
8
9 for (const match of matched) {
10 const link = match.groups.link
11
12 if (link.includes('/verify-account/')) return link
13 }
14
15 throw new Error('Could not find /verify-account/ link')
16}
17
18function findEmailTo (emails: { text: string, to: { address: string }[] }[], to: string) {
19 for (const email of emails) {
20 for (const { address } of email.to) {
21 if (address === to) return email
22 }
23 }
24
25 return undefined
26}
27
28export {
29 getVerificationLink,
30 findEmailTo
31}
diff --git a/client/e2e/src/utils/hooks.ts b/client/e2e/src/utils/hooks.ts
index 889cf1d86..7fe247681 100644
--- a/client/e2e/src/utils/hooks.ts
+++ b/client/e2e/src/utils/hooks.ts
@@ -1,10 +1,13 @@
1import { ChildProcessWithoutNullStreams } from 'child_process' 1import { ChildProcessWithoutNullStreams } from 'child_process'
2import { basename } from 'path' 2import { basename } from 'path'
3import { runCommand, runServer } from './server' 3import { runCommand, runServer } from './server'
4import { setValue } from '@wdio/shared-store-service'
4 5
5let appInstance: string 6let appInstance: number
6let app: ChildProcessWithoutNullStreams 7let app: ChildProcessWithoutNullStreams
7 8
9let emailPort: number
10
8async function beforeLocalSuite (suite: any) { 11async function beforeLocalSuite (suite: any) {
9 const config = buildConfig(suite.file) 12 const config = buildConfig(suite.file)
10 13
@@ -17,13 +20,20 @@ function afterLocalSuite () {
17 app = undefined 20 app = undefined
18} 21}
19 22
20function beforeLocalSession (config: { baseUrl: string }, capabilities: { browserName: string }) { 23async function beforeLocalSession (config: { baseUrl: string }, capabilities: { browserName: string }) {
21 appInstance = capabilities['browserName'] === 'chrome' ? '1' : '2' 24 appInstance = capabilities['browserName'] === 'chrome'
25 ? 1
26 : 2
27
28 emailPort = 1025 + appInstance
29
22 config.baseUrl = 'http://localhost:900' + appInstance 30 config.baseUrl = 'http://localhost:900' + appInstance
31
32 await setValue('emailPort', emailPort)
23} 33}
24 34
25async function onBrowserStackPrepare () { 35async function onBrowserStackPrepare () {
26 const appInstance = '1' 36 const appInstance = 1
27 37
28 await runCommand('npm run clean:server:test -- ' + appInstance) 38 await runCommand('npm run clean:server:test -- ' + appInstance)
29 app = runServer(appInstance) 39 app = runServer(appInstance)
@@ -71,7 +81,11 @@ function buildConfig (suiteFile: string = undefined) {
71 if (filename === 'signup.e2e-spec.ts') { 81 if (filename === 'signup.e2e-spec.ts') {
72 return { 82 return {
73 signup: { 83 signup: {
74 enabled: true 84 limit: -1
85 },
86 smtp: {
87 hostname: '127.0.0.1',
88 port: emailPort
75 } 89 }
76 } 90 }
77 } 91 }
diff --git a/client/e2e/src/utils/index.ts b/client/e2e/src/utils/index.ts
index 354352ee2..420fd239e 100644
--- a/client/e2e/src/utils/index.ts
+++ b/client/e2e/src/utils/index.ts
@@ -1,5 +1,7 @@
1export * from './common' 1export * from './common'
2export * from './elements' 2export * from './elements'
3export * from './email'
3export * from './hooks' 4export * from './hooks'
5export * from './mock-smtp'
4export * from './server' 6export * from './server'
5export * from './urls' 7export * from './urls'
diff --git a/client/e2e/src/utils/mock-smtp.ts b/client/e2e/src/utils/mock-smtp.ts
new file mode 100644
index 000000000..614477d7d
--- /dev/null
+++ b/client/e2e/src/utils/mock-smtp.ts
@@ -0,0 +1,58 @@
1import { ChildProcess } from 'child_process'
2import MailDev from '@peertube/maildev'
3
4class MockSMTPServer {
5
6 private static instance: MockSMTPServer
7 private started = false
8 private emailChildProcess: ChildProcess
9 private emails: object[]
10
11 collectEmails (port: number, emailsCollection: object[]) {
12 return new Promise<number>((res, rej) => {
13 this.emails = emailsCollection
14
15 if (this.started) {
16 return res(undefined)
17 }
18
19 const maildev = new MailDev({
20 ip: '127.0.0.1',
21 smtp: port,
22 disableWeb: true,
23 silent: true
24 })
25
26 maildev.on('new', email => {
27 this.emails.push(email)
28 })
29
30 maildev.listen(err => {
31 if (err) return rej(err)
32
33 this.started = true
34
35 return res(port)
36 })
37 })
38 }
39
40 kill () {
41 if (!this.emailChildProcess) return
42
43 process.kill(this.emailChildProcess.pid)
44
45 this.emailChildProcess = null
46 MockSMTPServer.instance = null
47 }
48
49 static get Instance () {
50 return this.instance || (this.instance = new this())
51 }
52}
53
54// ---------------------------------------------------------------------------
55
56export {
57 MockSMTPServer
58}
diff --git a/client/e2e/src/utils/server.ts b/client/e2e/src/utils/server.ts
index 140054794..227f4aea6 100644
--- a/client/e2e/src/utils/server.ts
+++ b/client/e2e/src/utils/server.ts
@@ -1,10 +1,10 @@
1import { exec, spawn } from 'child_process' 1import { exec, spawn } from 'child_process'
2import { join, resolve } from 'path' 2import { join, resolve } from 'path'
3 3
4function runServer (appInstance: string, config: any = {}) { 4function runServer (appInstance: number, config: any = {}) {
5 const env = Object.create(process.env) 5 const env = Object.create(process.env)
6 env['NODE_ENV'] = 'test' 6 env['NODE_ENV'] = 'test'
7 env['NODE_APP_INSTANCE'] = appInstance 7 env['NODE_APP_INSTANCE'] = appInstance + ''
8 8
9 env['NODE_CONFIG'] = JSON.stringify({ 9 env['NODE_CONFIG'] = JSON.stringify({
10 rates_limit: { 10 rates_limit: {
diff --git a/client/e2e/wdio.local-test.conf.ts b/client/e2e/wdio.local-test.conf.ts
index ca0bb5bfe..bc15123a0 100644
--- a/client/e2e/wdio.local-test.conf.ts
+++ b/client/e2e/wdio.local-test.conf.ts
@@ -37,7 +37,7 @@ module.exports = {
37 // } 37 // }
38 ], 38 ],
39 39
40 services: [ 'chromedriver', 'geckodriver' ], 40 services: [ 'chromedriver', 'geckodriver', 'shared-store' ],
41 41
42 beforeSession: beforeLocalSession, 42 beforeSession: beforeLocalSession,
43 beforeSuite: beforeLocalSuite, 43 beforeSuite: beforeLocalSuite,
diff --git a/client/e2e/wdio.local.conf.ts b/client/e2e/wdio.local.conf.ts
index d02679e06..27c6e867b 100644
--- a/client/e2e/wdio.local.conf.ts
+++ b/client/e2e/wdio.local.conf.ts
@@ -33,7 +33,7 @@ module.exports = {
33 } 33 }
34 ], 34 ],
35 35
36 services: [ 'chromedriver', 'geckodriver' ], 36 services: [ 'chromedriver', 'geckodriver', 'shared-store' ],
37 37
38 beforeSession: beforeLocalSession, 38 beforeSession: beforeLocalSession,
39 beforeSuite: beforeLocalSuite, 39 beforeSuite: beforeLocalSuite,
diff --git a/client/package.json b/client/package.json
index 115a4a199..31d9b1e7c 100644
--- a/client/package.json
+++ b/client/package.json
@@ -52,8 +52,9 @@
52 "@ngx-loading-bar/core": "^6.0.0", 52 "@ngx-loading-bar/core": "^6.0.0",
53 "@ngx-loading-bar/http-client": "^6.0.0", 53 "@ngx-loading-bar/http-client": "^6.0.0",
54 "@ngx-loading-bar/router": "^6.0.0", 54 "@ngx-loading-bar/router": "^6.0.0",
55 "@peertube/p2p-media-loader-core": "^1.0.13", 55 "@peertube/maildev": "^1.2.0",
56 "@peertube/p2p-media-loader-hlsjs": "^1.0.13", 56 "@peertube/p2p-media-loader-core": "^1.0.14",
57 "@peertube/p2p-media-loader-hlsjs": "^1.0.14",
57 "@peertube/videojs-contextmenu": "^5.5.0", 58 "@peertube/videojs-contextmenu": "^5.5.0",
58 "@peertube/xliffmerge": "^2.0.3", 59 "@peertube/xliffmerge": "^2.0.3",
59 "@popperjs/core": "^2.11.5", 60 "@popperjs/core": "^2.11.5",
@@ -75,6 +76,7 @@
75 "@wdio/cli": "^7.25.2", 76 "@wdio/cli": "^7.25.2",
76 "@wdio/local-runner": "^7.25.2", 77 "@wdio/local-runner": "^7.25.2",
77 "@wdio/mocha-framework": "^7.25.2", 78 "@wdio/mocha-framework": "^7.25.2",
79 "@wdio/shared-store-service": "^7.25.2",
78 "@wdio/spec-reporter": "^7.25.1", 80 "@wdio/spec-reporter": "^7.25.1",
79 "angular2-hotkeys": "^13.1.0", 81 "angular2-hotkeys": "^13.1.0",
80 "angularx-qrcode": "14.0.0", 82 "angularx-qrcode": "14.0.0",
diff --git a/client/src/app/+about/about-instance/about-instance.component.html b/client/src/app/+about/about-instance/about-instance.component.html
index b113df82f..fdd6157e5 100644
--- a/client/src/app/+about/about-instance/about-instance.component.html
+++ b/client/src/app/+about/about-instance/about-instance.component.html
@@ -21,7 +21,7 @@
21 21
22 <div class="anchor" id="administrators-and-sustainability"></div> 22 <div class="anchor" id="administrators-and-sustainability"></div>
23 <a 23 <a
24 *ngIf="html.administrator || html.maintenanceLifetime || html.businessModel" 24 *ngIf="aboutHTML.administrator || aboutHTML.maintenanceLifetime || aboutHTML.businessModel"
25 class="anchor-link" 25 class="anchor-link"
26 routerLink="/about/instance" 26 routerLink="/about/instance"
27 fragment="administrators-and-sustainability" 27 fragment="administrators-and-sustainability"
@@ -33,7 +33,7 @@
33 </h2> 33 </h2>
34 </a> 34 </a>
35 35
36 <div class="block administrator" *ngIf="html.administrator"> 36 <div class="block administrator" *ngIf="aboutHTML.administrator">
37 <div class="anchor" id="administrators"></div> 37 <div class="anchor" id="administrators"></div>
38 <a 38 <a
39 class="anchor-link" 39 class="anchor-link"
@@ -44,10 +44,10 @@
44 <h3 i18n class="section-title">Who we are</h3> 44 <h3 i18n class="section-title">Who we are</h3>
45 </a> 45 </a>
46 46
47 <div [innerHTML]="html.administrator"></div> 47 <div [innerHTML]="aboutHTML.administrator"></div>
48 </div> 48 </div>
49 49
50 <div class="block creation-reason" *ngIf="html.creationReason"> 50 <div class="block creation-reason" *ngIf="aboutHTML.creationReason">
51 <div class="anchor" id="creation-reason"></div> 51 <div class="anchor" id="creation-reason"></div>
52 <a 52 <a
53 class="anchor-link" 53 class="anchor-link"
@@ -58,10 +58,10 @@
58 <h3 i18n class="section-title">Why we created this instance</h3> 58 <h3 i18n class="section-title">Why we created this instance</h3>
59 </a> 59 </a>
60 60
61 <div [innerHTML]="html.creationReason"></div> 61 <div [innerHTML]="aboutHTML.creationReason"></div>
62 </div> 62 </div>
63 63
64 <div class="block maintenance-lifetime" *ngIf="html.maintenanceLifetime"> 64 <div class="block maintenance-lifetime" *ngIf="aboutHTML.maintenanceLifetime">
65 <div class="anchor" id="maintenance-lifetime"></div> 65 <div class="anchor" id="maintenance-lifetime"></div>
66 <a 66 <a
67 class="anchor-link" 67 class="anchor-link"
@@ -72,10 +72,10 @@
72 <h3 i18n class="section-title">How long we plan to maintain this instance</h3> 72 <h3 i18n class="section-title">How long we plan to maintain this instance</h3>
73 </a> 73 </a>
74 74
75 <div [innerHTML]="html.maintenanceLifetime"></div> 75 <div [innerHTML]="aboutHTML.maintenanceLifetime"></div>
76 </div> 76 </div>
77 77
78 <div class="block business-model" *ngIf="html.businessModel"> 78 <div class="block business-model" *ngIf="aboutHTML.businessModel">
79 <div class="anchor" id="business-model"></div> 79 <div class="anchor" id="business-model"></div>
80 <a 80 <a
81 class="anchor-link" 81 class="anchor-link"
@@ -86,12 +86,12 @@
86 <h3 i18n class="section-title">How we will pay for keeping our instance running</h3> 86 <h3 i18n class="section-title">How we will pay for keeping our instance running</h3>
87 </a> 87 </a>
88 88
89 <div [innerHTML]="html.businessModel"></div> 89 <div [innerHTML]="aboutHTML.businessModel"></div>
90 </div> 90 </div>
91 91
92 <div class="anchor" id="information"></div> 92 <div class="anchor" id="information"></div>
93 <a 93 <a
94 *ngIf="descriptionContent" 94 *ngIf="descriptionElement"
95 class="anchor-link" 95 class="anchor-link"
96 routerLink="/about/instance" 96 routerLink="/about/instance"
97 fragment="information" 97 fragment="information"
@@ -113,13 +113,13 @@
113 <h3 i18n class="section-title">Description</h3> 113 <h3 i18n class="section-title">Description</h3>
114 </a> 114 </a>
115 115
116 <my-custom-markup-container [content]="descriptionContent"></my-custom-markup-container> 116 <my-custom-markup-container [content]="descriptionElement"></my-custom-markup-container>
117 </div> 117 </div>
118 118
119 <div myPluginSelector pluginSelectorId="about-instance-moderation"> 119 <div myPluginSelector pluginSelectorId="about-instance-moderation">
120 <div class="anchor" id="moderation"></div> 120 <div class="anchor" id="moderation"></div>
121 <a 121 <a
122 *ngIf="html.moderationInformation || html.codeOfConduct || html.terms" 122 *ngIf="aboutHTML.moderationInformation || aboutHTML.codeOfConduct || aboutHTML.terms"
123 class="anchor-link" 123 class="anchor-link"
124 routerLink="/about/instance" 124 routerLink="/about/instance"
125 fragment="moderation" 125 fragment="moderation"
@@ -130,7 +130,7 @@
130 </h2> 130 </h2>
131 </a> 131 </a>
132 132
133 <div class="block moderation-information" *ngIf="html.moderationInformation"> 133 <div class="block moderation-information" *ngIf="aboutHTML.moderationInformation">
134 <div class="anchor" id="moderation-information"></div> 134 <div class="anchor" id="moderation-information"></div>
135 <a 135 <a
136 class="anchor-link" 136 class="anchor-link"
@@ -141,10 +141,10 @@
141 <h3 i18n class="section-title">Moderation information</h3> 141 <h3 i18n class="section-title">Moderation information</h3>
142 </a> 142 </a>
143 143
144 <div [innerHTML]="html.moderationInformation"></div> 144 <div [innerHTML]="aboutHTML.moderationInformation"></div>
145 </div> 145 </div>
146 146
147 <div class="block code-of-conduct" *ngIf="html.codeOfConduct"> 147 <div class="block code-of-conduct" *ngIf="aboutHTML.codeOfConduct">
148 <div class="anchor" id="code-of-conduct"></div> 148 <div class="anchor" id="code-of-conduct"></div>
149 <a 149 <a
150 class="anchor-link" 150 class="anchor-link"
@@ -155,7 +155,7 @@
155 <h3 i18n class="section-title">Code of conduct</h3> 155 <h3 i18n class="section-title">Code of conduct</h3>
156 </a> 156 </a>
157 157
158 <div [innerHTML]="html.codeOfConduct"></div> 158 <div [innerHTML]="aboutHTML.codeOfConduct"></div>
159 </div> 159 </div>
160 160
161 <div class="block terms"> 161 <div class="block terms">
@@ -169,14 +169,14 @@
169 <h3 i18n class="section-title">Terms</h3> 169 <h3 i18n class="section-title">Terms</h3>
170 </a> 170 </a>
171 171
172 <div [innerHTML]="html.terms"></div> 172 <div [innerHTML]="aboutHTML.terms"></div>
173 </div> 173 </div>
174 </div> 174 </div>
175 175
176 <div myPluginSelector pluginSelectorId="about-instance-other-information"> 176 <div myPluginSelector pluginSelectorId="about-instance-other-information">
177 <div class="anchor" id="other-information"></div> 177 <div class="anchor" id="other-information"></div>
178 <a 178 <a
179 *ngIf="html.hardwareInformation" 179 *ngIf="aboutHTML.hardwareInformation"
180 class="anchor-link" 180 class="anchor-link"
181 routerLink="/about/instance" 181 routerLink="/about/instance"
182 fragment="other-information" 182 fragment="other-information"
@@ -187,7 +187,7 @@
187 </h2> 187 </h2>
188 </a> 188 </a>
189 189
190 <div class="block hardware-information" *ngIf="html.hardwareInformation"> 190 <div class="block hardware-information" *ngIf="aboutHTML.hardwareInformation">
191 <div class="anchor" id="hardware-information"></div> 191 <div class="anchor" id="hardware-information"></div>
192 <a 192 <a
193 class="anchor-link" 193 class="anchor-link"
@@ -198,7 +198,7 @@
198 <h3 i18n class="section-title">Hardware information</h3> 198 <h3 i18n class="section-title">Hardware information</h3>
199 </a> 199 </a>
200 200
201 <div [innerHTML]="html.hardwareInformation"></div> 201 <div [innerHTML]="aboutHTML.hardwareInformation"></div>
202 </div> 202 </div>
203 </div> 203 </div>
204 </div> 204 </div>
diff --git a/client/src/app/+about/about-instance/about-instance.component.ts b/client/src/app/+about/about-instance/about-instance.component.ts
index 0826bbc5a..e1501d7de 100644
--- a/client/src/app/+about/about-instance/about-instance.component.ts
+++ b/client/src/app/+about/about-instance/about-instance.component.ts
@@ -2,7 +2,7 @@ import { ViewportScroller } from '@angular/common'
2import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild } from '@angular/core' 2import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute } from '@angular/router' 3import { ActivatedRoute } from '@angular/router'
4import { Notifier, ServerService } from '@app/core' 4import { Notifier, ServerService } from '@app/core'
5import { InstanceService } from '@app/shared/shared-instance' 5import { AboutHTML } from '@app/shared/shared-instance'
6import { copyToClipboard } from '@root-helpers/utils' 6import { copyToClipboard } from '@root-helpers/utils'
7import { HTMLServerConfig } from '@shared/models/server' 7import { HTMLServerConfig } from '@shared/models/server'
8import { ResolverData } from './about-instance.resolver' 8import { ResolverData } from './about-instance.resolver'
@@ -17,22 +17,12 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
17 @ViewChild('descriptionWrapper') descriptionWrapper: ElementRef<HTMLInputElement> 17 @ViewChild('descriptionWrapper') descriptionWrapper: ElementRef<HTMLInputElement>
18 @ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent 18 @ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent
19 19
20 shortDescription = '' 20 aboutHTML: AboutHTML
21 descriptionContent: string 21 descriptionElement: HTMLDivElement
22
23 html = {
24 terms: '',
25 codeOfConduct: '',
26 moderationInformation: '',
27 administrator: '',
28 creationReason: '',
29 maintenanceLifetime: '',
30 businessModel: '',
31 hardwareInformation: ''
32 }
33 22
34 languages: string[] = [] 23 languages: string[] = []
35 categories: string[] = [] 24 categories: string[] = []
25 shortDescription = ''
36 26
37 initialized = false 27 initialized = false
38 28
@@ -44,8 +34,7 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
44 private viewportScroller: ViewportScroller, 34 private viewportScroller: ViewportScroller,
45 private route: ActivatedRoute, 35 private route: ActivatedRoute,
46 private notifier: Notifier, 36 private notifier: Notifier,
47 private serverService: ServerService, 37 private serverService: ServerService
48 private instanceService: InstanceService
49 ) {} 38 ) {}
50 39
51 get instanceName () { 40 get instanceName () {
@@ -60,8 +49,16 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
60 return this.serverConfig.instance.isNSFW 49 return this.serverConfig.instance.isNSFW
61 } 50 }
62 51
63 async ngOnInit () { 52 ngOnInit () {
64 const { about, languages, categories }: ResolverData = this.route.snapshot.data.instanceData 53 const { about, languages, categories, aboutHTML, descriptionElement }: ResolverData = this.route.snapshot.data.instanceData
54
55 this.aboutHTML = aboutHTML
56 this.descriptionElement = descriptionElement
57
58 this.languages = languages
59 this.categories = categories
60
61 this.shortDescription = about.instance.shortDescription
65 62
66 this.serverConfig = this.serverService.getHTMLConfig() 63 this.serverConfig = this.serverService.getHTMLConfig()
67 64
@@ -73,14 +70,6 @@ export class AboutInstanceComponent implements OnInit, AfterViewChecked {
73 this.contactAdminModal.show(prefill) 70 this.contactAdminModal.show(prefill)
74 }) 71 })
75 72
76 this.languages = languages
77 this.categories = categories
78
79 this.shortDescription = about.instance.shortDescription
80 this.descriptionContent = about.instance.description
81
82 this.html = await this.instanceService.buildHtml(about)
83
84 this.initialized = true 73 this.initialized = true
85 } 74 }
86 75
diff --git a/client/src/app/+about/about-instance/about-instance.resolver.ts b/client/src/app/+about/about-instance/about-instance.resolver.ts
index ee0219df0..8818fc582 100644
--- a/client/src/app/+about/about-instance/about-instance.resolver.ts
+++ b/client/src/app/+about/about-instance/about-instance.resolver.ts
@@ -2,16 +2,25 @@ import { forkJoin } from 'rxjs'
2import { map, switchMap } from 'rxjs/operators' 2import { map, switchMap } from 'rxjs/operators'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { Resolve } from '@angular/router' 4import { Resolve } from '@angular/router'
5import { InstanceService } from '@app/shared/shared-instance' 5import { CustomMarkupService } from '@app/shared/shared-custom-markup'
6import { AboutHTML, InstanceService } from '@app/shared/shared-instance'
6import { About } from '@shared/models/server' 7import { About } from '@shared/models/server'
7 8
8export type ResolverData = { about: About, languages: string[], categories: string[] } 9export type ResolverData = {
10 about: About
11 languages: string[]
12 categories: string[]
13 aboutHTML: AboutHTML
14 descriptionElement: HTMLDivElement
15}
9 16
10@Injectable() 17@Injectable()
11export class AboutInstanceResolver implements Resolve<any> { 18export class AboutInstanceResolver implements Resolve<any> {
12 19
13 constructor ( 20 constructor (
14 private instanceService: InstanceService 21 private instanceService: InstanceService,
22 private customMarkupService: CustomMarkupService
23
15 ) {} 24 ) {}
16 25
17 resolve () { 26 resolve () {
@@ -19,9 +28,15 @@ export class AboutInstanceResolver implements Resolve<any> {
19 .pipe( 28 .pipe(
20 switchMap(about => { 29 switchMap(about => {
21 return forkJoin([ 30 return forkJoin([
31 Promise.resolve(about),
22 this.instanceService.buildTranslatedLanguages(about), 32 this.instanceService.buildTranslatedLanguages(about),
23 this.instanceService.buildTranslatedCategories(about) 33 this.instanceService.buildTranslatedCategories(about),
24 ]).pipe(map(([ languages, categories ]) => ({ about, languages, categories }) as ResolverData)) 34 this.instanceService.buildHtml(about),
35 this.customMarkupService.buildElement(about.instance.description)
36 ])
37 }),
38 map(([ about, languages, categories, aboutHTML, { rootElement } ]) => {
39 return { about, languages, categories, aboutHTML, descriptionElement: rootElement } as ResolverData
25 }) 40 })
26 ) 41 )
27 } 42 }
diff --git a/client/src/app/+admin/admin.component.ts b/client/src/app/+admin/admin.component.ts
index 746549555..630bfe253 100644
--- a/client/src/app/+admin/admin.component.ts
+++ b/client/src/app/+admin/admin.component.ts
@@ -96,6 +96,14 @@ export class AdminComponent implements OnInit {
96 children: [] 96 children: []
97 } 97 }
98 98
99 if (this.hasRegistrationsRight()) {
100 moderationItems.children.push({
101 label: $localize`Registrations`,
102 routerLink: '/admin/moderation/registrations/list',
103 iconName: 'user'
104 })
105 }
106
99 if (this.hasAbusesRight()) { 107 if (this.hasAbusesRight()) {
100 moderationItems.children.push({ 108 moderationItems.children.push({
101 label: $localize`Reports`, 109 label: $localize`Reports`,
@@ -229,4 +237,8 @@ export class AdminComponent implements OnInit {
229 private hasVideosRight () { 237 private hasVideosRight () {
230 return this.auth.getUser().hasRight(UserRight.SEE_ALL_VIDEOS) 238 return this.auth.getUser().hasRight(UserRight.SEE_ALL_VIDEOS)
231 } 239 }
240
241 private hasRegistrationsRight () {
242 return this.auth.getUser().hasRight(UserRight.MANAGE_REGISTRATIONS)
243 }
232} 244}
diff --git a/client/src/app/+admin/admin.module.ts b/client/src/app/+admin/admin.module.ts
index f01967ea6..891ff4ed1 100644
--- a/client/src/app/+admin/admin.module.ts
+++ b/client/src/app/+admin/admin.module.ts
@@ -30,7 +30,13 @@ import { FollowersListComponent, FollowModalComponent, VideoRedundanciesListComp
30import { FollowingListComponent } from './follows/following-list/following-list.component' 30import { FollowingListComponent } from './follows/following-list/following-list.component'
31import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component' 31import { RedundancyCheckboxComponent } from './follows/shared/redundancy-checkbox.component'
32import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component' 32import { VideoRedundancyInformationComponent } from './follows/video-redundancies-list/video-redundancy-information.component'
33import { AbuseListComponent, VideoBlockListComponent } from './moderation' 33import {
34 AbuseListComponent,
35 AdminRegistrationService,
36 ProcessRegistrationModalComponent,
37 RegistrationListComponent,
38 VideoBlockListComponent
39} from './moderation'
34import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist' 40import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } from './moderation/instance-blocklist'
35import { 41import {
36 UserCreateComponent, 42 UserCreateComponent,
@@ -116,7 +122,10 @@ import { JobsComponent } from './system/jobs/jobs.component'
116 EditLiveConfigurationComponent, 122 EditLiveConfigurationComponent,
117 EditAdvancedConfigurationComponent, 123 EditAdvancedConfigurationComponent,
118 EditInstanceInformationComponent, 124 EditInstanceInformationComponent,
119 EditHomepageComponent 125 EditHomepageComponent,
126
127 RegistrationListComponent,
128 ProcessRegistrationModalComponent
120 ], 129 ],
121 130
122 exports: [ 131 exports: [
@@ -130,7 +139,8 @@ import { JobsComponent } from './system/jobs/jobs.component'
130 ConfigService, 139 ConfigService,
131 PluginApiService, 140 PluginApiService,
132 EditConfigurationService, 141 EditConfigurationService,
133 VideoAdminService 142 VideoAdminService,
143 AdminRegistrationService
134 ] 144 ]
135}) 145})
136export class AdminModule { } 146export class AdminModule { }
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
index 43f1438e0..0f3803f97 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-basic-configuration.component.html
@@ -44,9 +44,13 @@
44 44
45 <div class="peertube-select-container"> 45 <div class="peertube-select-container">
46 <select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control"> 46 <select id="trendingVideosAlgorithmsDefault" formControlName="default" class="form-control">
47 <option i18n value="publishedAt">Recently added videos</option>
48 <option i18n value="originallyPublishedAt">Original publication date</option>
49 <option i18n value="name">Name</option>
47 <option i18n value="hot">Hot videos</option> 50 <option i18n value="hot">Hot videos</option>
48 <option i18n value="most-viewed">Most viewed videos</option> 51 <option i18n value="most-viewed">Recent views</option>
49 <option i18n value="most-liked">Most liked videos</option> 52 <option i18n value="most-liked">Most liked videos</option>
53 <option i18n value="views">Global views</option>
50 </select> 54 </select>
51 </div> 55 </div>
52 56
@@ -167,12 +171,21 @@
167 </ng-container> 171 </ng-container>
168 172
169 <ng-container ngProjectAs="extra"> 173 <ng-container ngProjectAs="extra">
170 <my-peertube-checkbox [ngClass]="getDisabledSignupClass()" 174 <div class="form-group">
171 inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification" 175 <my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
172 i18n-labelText labelText="Signup requires email verification" 176 inputName="signupRequiresApproval" formControlName="requiresApproval"
173 ></my-peertube-checkbox> 177 i18n-labelText labelText="Signup requires approval by moderators"
178 ></my-peertube-checkbox>
179 </div>
174 180
175 <div [ngClass]="getDisabledSignupClass()" class="mt-3"> 181 <div class="form-group">
182 <my-peertube-checkbox [ngClass]="getDisabledSignupClass()"
183 inputName="signupRequiresEmailVerification" formControlName="requiresEmailVerification"
184 i18n-labelText labelText="Signup requires email verification"
185 ></my-peertube-checkbox>
186 </div>
187
188 <div [ngClass]="getDisabledSignupClass()">
176 <label i18n for="signupLimit">Signup limit</label> 189 <label i18n for="signupLimit">Signup limit</label>
177 190
178 <div class="number-with-unit"> 191 <div class="number-with-unit">
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index 168f4702c..2afe80a03 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -132,6 +132,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
132 signup: { 132 signup: {
133 enabled: null, 133 enabled: null,
134 limit: SIGNUP_LIMIT_VALIDATOR, 134 limit: SIGNUP_LIMIT_VALIDATOR,
135 requiresApproval: null,
135 requiresEmailVerification: null, 136 requiresEmailVerification: null,
136 minimumAge: SIGNUP_MINIMUM_AGE_VALIDATOR 137 minimumAge: SIGNUP_MINIMUM_AGE_VALIDATOR
137 }, 138 },
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html
index 5339240bb..3d8414f5c 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-homepage.component.html
@@ -17,7 +17,7 @@
17 17
18 <my-markdown-textarea 18 <my-markdown-textarea
19 name="instanceCustomHomepageContent" formControlName="content" 19 name="instanceCustomHomepageContent" formControlName="content"
20 [customMarkdownRenderer]="getCustomMarkdownRenderer()" 20 [customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
21 [formError]="formErrors['instanceCustomHomepage.content']" 21 [formError]="formErrors['instanceCustomHomepage.content']"
22 ></my-markdown-textarea> 22 ></my-markdown-textarea>
23 23
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html
index b54733327..504afa189 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html
+++ b/client/src/app/+admin/config/edit-custom-config/edit-instance-information.component.html
@@ -38,7 +38,7 @@
38 38
39 <my-markdown-textarea 39 <my-markdown-textarea
40 name="instanceDescription" formControlName="description" 40 name="instanceDescription" formControlName="description"
41 [customMarkdownRenderer]="getCustomMarkdownRenderer()" 41 [customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
42 [formError]="formErrors['instance.description']" 42 [formError]="formErrors['instance.description']"
43 ></my-markdown-textarea> 43 ></my-markdown-textarea>
44 </div> 44 </div>
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.html b/client/src/app/+admin/follows/followers-list/followers-list.component.html
index 8fe0d2348..14c62f1af 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.html
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.html
@@ -9,14 +9,14 @@
9 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" 9 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate 10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} followers" 11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} followers"
12 [(selection)]="selectedFollows" 12 [(selection)]="selectedRows"
13> 13>
14 <ng-template pTemplate="caption"> 14 <ng-template pTemplate="caption">
15 <div class="caption"> 15 <div class="caption">
16 <div class="left-buttons"> 16 <div class="left-buttons">
17 <my-action-dropdown 17 <my-action-dropdown
18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange" 18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
19 [actions]="bulkFollowsActions" [entry]="selectedFollows" 19 [actions]="bulkActions" [entry]="selectedRows"
20 > 20 >
21 </my-action-dropdown> 21 </my-action-dropdown>
22 </div> 22 </div>
diff --git a/client/src/app/+admin/follows/followers-list/followers-list.component.ts b/client/src/app/+admin/follows/followers-list/followers-list.component.ts
index b2d333e83..cebb2e1a2 100644
--- a/client/src/app/+admin/follows/followers-list/followers-list.component.ts
+++ b/client/src/app/+admin/follows/followers-list/followers-list.component.ts
@@ -12,7 +12,7 @@ import { ActorFollow } from '@shared/models'
12 templateUrl: './followers-list.component.html', 12 templateUrl: './followers-list.component.html',
13 styleUrls: [ './followers-list.component.scss' ] 13 styleUrls: [ './followers-list.component.scss' ]
14}) 14})
15export class FollowersListComponent extends RestTable implements OnInit { 15export class FollowersListComponent extends RestTable <ActorFollow> implements OnInit {
16 followers: ActorFollow[] = [] 16 followers: ActorFollow[] = []
17 totalRecords = 0 17 totalRecords = 0
18 sort: SortMeta = { field: 'createdAt', order: -1 } 18 sort: SortMeta = { field: 'createdAt', order: -1 }
@@ -20,8 +20,7 @@ export class FollowersListComponent extends RestTable implements OnInit {
20 20
21 searchFilters: AdvancedInputFilter[] = [] 21 searchFilters: AdvancedInputFilter[] = []
22 22
23 selectedFollows: ActorFollow[] = [] 23 bulkActions: DropdownAction<ActorFollow[]>[] = []
24 bulkFollowsActions: DropdownAction<ActorFollow[]>[] = []
25 24
26 constructor ( 25 constructor (
27 private confirmService: ConfirmService, 26 private confirmService: ConfirmService,
@@ -36,7 +35,7 @@ export class FollowersListComponent extends RestTable implements OnInit {
36 35
37 this.searchFilters = this.followService.buildFollowsListFilters() 36 this.searchFilters = this.followService.buildFollowsListFilters()
38 37
39 this.bulkFollowsActions = [ 38 this.bulkActions = [
40 { 39 {
41 label: $localize`Reject`, 40 label: $localize`Reject`,
42 handler: follows => this.rejectFollower(follows), 41 handler: follows => this.rejectFollower(follows),
@@ -105,12 +104,14 @@ export class FollowersListComponent extends RestTable implements OnInit {
105 } 104 }
106 105
107 async deleteFollowers (follows: ActorFollow[]) { 106 async deleteFollowers (follows: ActorFollow[]) {
107 const icuParams = { count: follows.length, followerName: this.buildFollowerName(follows[0]) }
108
108 let message = $localize`Deleted followers will be able to send again a follow request.` 109 let message = $localize`Deleted followers will be able to send again a follow request.`
109 message += '<br /><br />' 110 message += '<br /><br />'
110 111
111 // eslint-disable-next-line max-len 112 // eslint-disable-next-line max-len
112 message += prepareIcu($localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)( 113 message += prepareIcu($localize`Do you really want to delete {count, plural, =1 {{followerName} follow request?} other {{count} follow requests?}}`)(
113 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, 114 icuParams,
114 $localize`Do you really want to delete these follow requests?` 115 $localize`Do you really want to delete these follow requests?`
115 ) 116 )
116 117
@@ -122,7 +123,7 @@ export class FollowersListComponent extends RestTable implements OnInit {
122 next: () => { 123 next: () => {
123 // eslint-disable-next-line max-len 124 // eslint-disable-next-line max-len
124 const message = prepareIcu($localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)( 125 const message = prepareIcu($localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`)(
125 { count: follows.length, followerName: this.buildFollowerName(follows[0]) }, 126 icuParams,
126 $localize`Follow requests removed` 127 $localize`Follow requests removed`
127 ) 128 )
128 129
@@ -139,11 +140,7 @@ export class FollowersListComponent extends RestTable implements OnInit {
139 return follow.follower.name + '@' + follow.follower.host 140 return follow.follower.name + '@' + follow.follower.host
140 } 141 }
141 142
142 isInSelectionMode () { 143 protected reloadDataInternal () {
143 return this.selectedFollows.length !== 0
144 }
145
146 protected reloadData () {
147 this.followService.getFollowers({ pagination: this.pagination, sort: this.sort, search: this.search }) 144 this.followService.getFollowers({ pagination: this.pagination, sort: this.sort, search: this.search })
148 .subscribe({ 145 .subscribe({
149 next: resultList => { 146 next: resultList => {
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.html b/client/src/app/+admin/follows/following-list/following-list.component.html
index f7abb7ede..eca79be71 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.html
+++ b/client/src/app/+admin/follows/following-list/following-list.component.html
@@ -9,14 +9,14 @@
9 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" 9 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate 10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} hosts" 11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} hosts"
12 [(selection)]="selectedFollows" 12 [(selection)]="selectedRows"
13> 13>
14 <ng-template pTemplate="caption"> 14 <ng-template pTemplate="caption">
15 <div class="caption"> 15 <div class="caption">
16 <div class="left-buttons"> 16 <div class="left-buttons">
17 <my-action-dropdown 17 <my-action-dropdown
18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange" 18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
19 [actions]="bulkFollowsActions" [entry]="selectedFollows" 19 [actions]="bulkActions" [entry]="selectedRows"
20 > 20 >
21 </my-action-dropdown> 21 </my-action-dropdown>
22 22
diff --git a/client/src/app/+admin/follows/following-list/following-list.component.ts b/client/src/app/+admin/follows/following-list/following-list.component.ts
index e3a56651a..71f2fbe66 100644
--- a/client/src/app/+admin/follows/following-list/following-list.component.ts
+++ b/client/src/app/+admin/follows/following-list/following-list.component.ts
@@ -12,7 +12,7 @@ import { prepareIcu } from '@app/helpers'
12 templateUrl: './following-list.component.html', 12 templateUrl: './following-list.component.html',
13 styleUrls: [ './following-list.component.scss' ] 13 styleUrls: [ './following-list.component.scss' ]
14}) 14})
15export class FollowingListComponent extends RestTable implements OnInit { 15export class FollowingListComponent extends RestTable <ActorFollow> implements OnInit {
16 @ViewChild('followModal') followModal: FollowModalComponent 16 @ViewChild('followModal') followModal: FollowModalComponent
17 17
18 following: ActorFollow[] = [] 18 following: ActorFollow[] = []
@@ -22,8 +22,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
22 22
23 searchFilters: AdvancedInputFilter[] = [] 23 searchFilters: AdvancedInputFilter[] = []
24 24
25 selectedFollows: ActorFollow[] = [] 25 bulkActions: DropdownAction<ActorFollow[]>[] = []
26 bulkFollowsActions: DropdownAction<ActorFollow[]>[] = []
27 26
28 constructor ( 27 constructor (
29 private notifier: Notifier, 28 private notifier: Notifier,
@@ -38,7 +37,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
38 37
39 this.searchFilters = this.followService.buildFollowsListFilters() 38 this.searchFilters = this.followService.buildFollowsListFilters()
40 39
41 this.bulkFollowsActions = [ 40 this.bulkActions = [
42 { 41 {
43 label: $localize`Delete`, 42 label: $localize`Delete`,
44 handler: follows => this.removeFollowing(follows) 43 handler: follows => this.removeFollowing(follows)
@@ -58,17 +57,15 @@ export class FollowingListComponent extends RestTable implements OnInit {
58 return follow.following.name === 'peertube' 57 return follow.following.name === 'peertube'
59 } 58 }
60 59
61 isInSelectionMode () {
62 return this.selectedFollows.length !== 0
63 }
64
65 buildFollowingName (follow: ActorFollow) { 60 buildFollowingName (follow: ActorFollow) {
66 return follow.following.name + '@' + follow.following.host 61 return follow.following.name + '@' + follow.following.host
67 } 62 }
68 63
69 async removeFollowing (follows: ActorFollow[]) { 64 async removeFollowing (follows: ActorFollow[]) {
65 const icuParams = { count: follows.length, entryName: this.buildFollowingName(follows[0]) }
66
70 const message = prepareIcu($localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`)( 67 const message = prepareIcu($localize`Do you really want to unfollow {count, plural, =1 {{entryName}?} other {{count} entries?}}`)(
71 { count: follows.length, entryName: this.buildFollowingName(follows[0]) }, 68 icuParams,
72 $localize`Do you really want to unfollow these entries?` 69 $localize`Do you really want to unfollow these entries?`
73 ) 70 )
74 71
@@ -80,7 +77,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
80 next: () => { 77 next: () => {
81 // eslint-disable-next-line max-len 78 // eslint-disable-next-line max-len
82 const message = prepareIcu($localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`)( 79 const message = prepareIcu($localize`You are not following {count, plural, =1 {{entryName} anymore.} other {these {count} entries anymore.}}`)(
83 { count: follows.length, entryName: this.buildFollowingName(follows[0]) }, 80 icuParams,
84 $localize`You are not following them anymore.` 81 $localize`You are not following them anymore.`
85 ) 82 )
86 83
@@ -92,7 +89,7 @@ export class FollowingListComponent extends RestTable implements OnInit {
92 }) 89 })
93 } 90 }
94 91
95 protected reloadData () { 92 protected reloadDataInternal () {
96 this.followService.getFollowing({ pagination: this.pagination, sort: this.sort, search: this.search }) 93 this.followService.getFollowing({ pagination: this.pagination, sort: this.sort, search: this.search })
97 .subscribe({ 94 .subscribe({
98 next: resultList => { 95 next: resultList => {
diff --git a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
index a89603048..b31c5b35e 100644
--- a/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
+++ b/client/src/app/+admin/follows/video-redundancies-list/video-redundancies-list.component.ts
@@ -162,7 +162,7 @@ export class VideoRedundanciesListComponent extends RestTable implements OnInit
162 162
163 } 163 }
164 164
165 protected reloadData () { 165 protected reloadDataInternal () {
166 const options = { 166 const options = {
167 pagination: this.pagination, 167 pagination: this.pagination,
168 sort: this.sort, 168 sort: this.sort,
diff --git a/client/src/app/+admin/moderation/index.ts b/client/src/app/+admin/moderation/index.ts
index 9dab270cc..135b4b408 100644
--- a/client/src/app/+admin/moderation/index.ts
+++ b/client/src/app/+admin/moderation/index.ts
@@ -1,4 +1,5 @@
1export * from './abuse-list' 1export * from './abuse-list'
2export * from './instance-blocklist' 2export * from './instance-blocklist'
3export * from './video-block-list' 3export * from './video-block-list'
4export * from './registration-list'
4export * from './moderation.routes' 5export * from './moderation.routes'
diff --git a/client/src/app/+admin/moderation/moderation.routes.ts b/client/src/app/+admin/moderation/moderation.routes.ts
index 1ad301039..378d2bed7 100644
--- a/client/src/app/+admin/moderation/moderation.routes.ts
+++ b/client/src/app/+admin/moderation/moderation.routes.ts
@@ -4,6 +4,7 @@ import { InstanceAccountBlocklistComponent, InstanceServerBlocklistComponent } f
4import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list' 4import { VideoBlockListComponent } from '@app/+admin/moderation/video-block-list'
5import { UserRightGuard } from '@app/core' 5import { UserRightGuard } from '@app/core'
6import { UserRight } from '@shared/models' 6import { UserRight } from '@shared/models'
7import { RegistrationListComponent } from './registration-list'
7 8
8export const ModerationRoutes: Routes = [ 9export const ModerationRoutes: Routes = [
9 { 10 {
@@ -68,7 +69,19 @@ export const ModerationRoutes: Routes = [
68 } 69 }
69 }, 70 },
70 71
71 // We move this component in admin overview pages 72 {
73 path: 'registrations/list',
74 component: RegistrationListComponent,
75 canActivate: [ UserRightGuard ],
76 data: {
77 userRight: UserRight.MANAGE_REGISTRATIONS,
78 meta: {
79 title: $localize`User registrations`
80 }
81 }
82 },
83
84 // We moved this component in admin overview pages
72 { 85 {
73 path: 'video-comments', 86 path: 'video-comments',
74 redirectTo: 'video-comments/list', 87 redirectTo: 'video-comments/list',
diff --git a/client/src/app/+admin/moderation/registration-list/admin-registration.service.ts b/client/src/app/+admin/moderation/registration-list/admin-registration.service.ts
new file mode 100644
index 000000000..a9f13cf2f
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/admin-registration.service.ts
@@ -0,0 +1,81 @@
1import { SortMeta } from 'primeng/api'
2import { from } from 'rxjs'
3import { catchError, concatMap, toArray } from 'rxjs/operators'
4import { HttpClient, HttpParams } from '@angular/common/http'
5import { Injectable } from '@angular/core'
6import { RestExtractor, RestPagination, RestService } from '@app/core'
7import { arrayify } from '@shared/core-utils'
8import { ResultList, UserRegistration, UserRegistrationUpdateState } from '@shared/models'
9import { environment } from '../../../../environments/environment'
10
11@Injectable()
12export class AdminRegistrationService {
13 private static BASE_REGISTRATION_URL = environment.apiUrl + '/api/v1/users/registrations'
14
15 constructor (
16 private authHttp: HttpClient,
17 private restExtractor: RestExtractor,
18 private restService: RestService
19 ) { }
20
21 listRegistrations (options: {
22 pagination: RestPagination
23 sort: SortMeta
24 search?: string
25 }) {
26 const { pagination, sort, search } = options
27
28 const url = AdminRegistrationService.BASE_REGISTRATION_URL
29
30 let params = new HttpParams()
31 params = this.restService.addRestGetParams(params, pagination, sort)
32
33 if (search) {
34 params = params.append('search', search)
35 }
36
37 return this.authHttp.get<ResultList<UserRegistration>>(url, { params })
38 .pipe(
39 catchError(res => this.restExtractor.handleError(res))
40 )
41 }
42
43 acceptRegistration (options: {
44 registration: UserRegistration
45 moderationResponse: string
46 preventEmailDelivery: boolean
47 }) {
48 const { registration, moderationResponse, preventEmailDelivery } = options
49
50 const url = AdminRegistrationService.BASE_REGISTRATION_URL + '/' + registration.id + '/accept'
51 const body: UserRegistrationUpdateState = { moderationResponse, preventEmailDelivery }
52
53 return this.authHttp.post(url, body)
54 .pipe(catchError(res => this.restExtractor.handleError(res)))
55 }
56
57 rejectRegistration (options: {
58 registration: UserRegistration
59 moderationResponse: string
60 preventEmailDelivery: boolean
61 }) {
62 const { registration, moderationResponse, preventEmailDelivery } = options
63
64 const url = AdminRegistrationService.BASE_REGISTRATION_URL + '/' + registration.id + '/reject'
65 const body: UserRegistrationUpdateState = { moderationResponse, preventEmailDelivery }
66
67 return this.authHttp.post(url, body)
68 .pipe(catchError(res => this.restExtractor.handleError(res)))
69 }
70
71 removeRegistrations (registrationsArg: UserRegistration | UserRegistration[]) {
72 const registrations = arrayify(registrationsArg)
73
74 return from(registrations)
75 .pipe(
76 concatMap(r => this.authHttp.delete(AdminRegistrationService.BASE_REGISTRATION_URL + '/' + r.id)),
77 toArray(),
78 catchError(err => this.restExtractor.handleError(err))
79 )
80 }
81}
diff --git a/client/src/app/+admin/moderation/registration-list/index.ts b/client/src/app/+admin/moderation/registration-list/index.ts
new file mode 100644
index 000000000..060b676a4
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/index.ts
@@ -0,0 +1,4 @@
1export * from './admin-registration.service'
2export * from './process-registration-modal.component'
3export * from './process-registration-validators'
4export * from './registration-list.component'
diff --git a/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.html b/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.html
new file mode 100644
index 000000000..8e46b0cf9
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.html
@@ -0,0 +1,74 @@
1<ng-template #modal>
2 <div class="modal-header">
3 <h4 i18n class="modal-title">
4 <ng-container *ngIf="isAccept()">Accept {{ registration.username }} registration</ng-container>
5 <ng-container *ngIf="isReject()">Reject {{ registration.username }} registration</ng-container>
6 </h4>
7
8 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon>
9 </div>
10
11 <form novalidate [formGroup]="form" (ngSubmit)="processRegistration()">
12 <div class="modal-body mb-3">
13
14 <div i18n *ngIf="!registration.emailVerified" class="alert alert-warning">
15 Registration email has not been verified. Email delivery has been disabled by default.
16 </div>
17
18 <div class="description">
19 <ng-container *ngIf="isAccept()">
20 <p i18n>
21 <strong>Accepting</strong>&nbsp;<em>{{ registration.username }}</em> registration will create the account and channel.
22 </p>
23
24 <p *ngIf="isEmailEnabled()" i18n [ngClass]="{ 'text-decoration-line-through': isPreventEmailDeliveryChecked() }">
25 An email will be sent to <em>{{ registration.email }}</em> explaining its account has been created with the moderation response you'll write below.
26 </p>
27
28 <div *ngIf="!isEmailEnabled()" class="alert alert-warning" i18n>
29 Emails are not enabled on this instance so PeerTube won't be able to send an email to <em>{{ registration.email }}</em> explaining its account has been created.
30 </div>
31 </ng-container>
32
33 <ng-container *ngIf="isReject()">
34 <p i18n [ngClass]="{ 'text-decoration-line-through': isPreventEmailDeliveryChecked() }">
35 An email will be sent to <em>{{ registration.email }}</em> explaining its registration request has been <strong>rejected</strong> with the moderation response you'll write below.
36 </p>
37
38 <div *ngIf="!isEmailEnabled()" class="alert alert-warning" i18n>
39 Emails are not enabled on this instance so PeerTube won't be able to send an email to <em>{{ registration.email }}</em> explaining its registration request has been rejected.
40 </div>
41 </ng-container>
42 </div>
43
44 <div class="form-group">
45 <label for="moderationResponse" i18n>Send a message to the user</label>
46
47 <textarea
48 formControlName="moderationResponse" ngbAutofocus name="moderationResponse" id="moderationResponse"
49 [ngClass]="{ 'input-error': formErrors['moderationResponse'] }" class="form-control"
50 ></textarea>
51
52 <div *ngIf="formErrors.moderationResponse" class="form-error">
53 {{ formErrors.moderationResponse }}
54 </div>
55 </div>
56
57 <div class="form-group">
58 <my-peertube-checkbox
59 inputName="preventEmailDelivery" formControlName="preventEmailDelivery" [disabled]="!isEmailEnabled()"
60 i18n-labelText labelText="Prevent email from being sent to the user"
61 ></my-peertube-checkbox>
62 </div>
63 </div>
64
65 <div class="modal-footer inputs">
66 <input
67 type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
68 (click)="hide()" (key.enter)="hide()"
69 >
70
71 <input type="submit" [value]="getSubmitValue()" class="peertube-button orange-button" [disabled]="!form.valid">
72 </div>
73 </form>
74</ng-template>
diff --git a/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.scss b/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.scss
new file mode 100644
index 000000000..3e03bed89
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.scss
@@ -0,0 +1,3 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
diff --git a/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.ts b/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.ts
new file mode 100644
index 000000000..3a7e5dea1
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/process-registration-modal.component.ts
@@ -0,0 +1,122 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier, ServerService } from '@app/core'
3import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
4import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
6import { UserRegistration } from '@shared/models'
7import { AdminRegistrationService } from './admin-registration.service'
8import { REGISTRATION_MODERATION_RESPONSE_VALIDATOR } from './process-registration-validators'
9
10@Component({
11 selector: 'my-process-registration-modal',
12 templateUrl: './process-registration-modal.component.html',
13 styleUrls: [ './process-registration-modal.component.scss' ]
14})
15export class ProcessRegistrationModalComponent extends FormReactive implements OnInit {
16 @ViewChild('modal', { static: true }) modal: NgbModal
17
18 @Output() registrationProcessed = new EventEmitter()
19
20 registration: UserRegistration
21
22 private openedModal: NgbModalRef
23 private processMode: 'accept' | 'reject'
24
25 constructor (
26 protected formReactiveService: FormReactiveService,
27 private server: ServerService,
28 private modalService: NgbModal,
29 private notifier: Notifier,
30 private registrationService: AdminRegistrationService
31 ) {
32 super()
33 }
34
35 ngOnInit () {
36 this.buildForm({
37 moderationResponse: REGISTRATION_MODERATION_RESPONSE_VALIDATOR,
38 preventEmailDelivery: null
39 })
40 }
41
42 isAccept () {
43 return this.processMode === 'accept'
44 }
45
46 isReject () {
47 return this.processMode === 'reject'
48 }
49
50 openModal (registration: UserRegistration, mode: 'accept' | 'reject') {
51 this.processMode = mode
52 this.registration = registration
53
54 this.form.patchValue({
55 preventEmailDelivery: !this.isEmailEnabled() || registration.emailVerified !== true
56 })
57
58 this.openedModal = this.modalService.open(this.modal, { centered: true })
59 }
60
61 hide () {
62 this.form.reset()
63
64 this.openedModal.close()
65 }
66
67 getSubmitValue () {
68 if (this.isAccept()) {
69 return $localize`Accept registration`
70 }
71
72 return $localize`Reject registration`
73 }
74
75 processRegistration () {
76 if (this.isAccept()) return this.acceptRegistration()
77
78 return this.rejectRegistration()
79 }
80
81 isEmailEnabled () {
82 return this.server.getHTMLConfig().email.enabled
83 }
84
85 isPreventEmailDeliveryChecked () {
86 return this.form.value.preventEmailDelivery
87 }
88
89 private acceptRegistration () {
90 this.registrationService.acceptRegistration({
91 registration: this.registration,
92 moderationResponse: this.form.value.moderationResponse,
93 preventEmailDelivery: this.form.value.preventEmailDelivery
94 }).subscribe({
95 next: () => {
96 this.notifier.success($localize`${this.registration.username} account created`)
97
98 this.registrationProcessed.emit()
99 this.hide()
100 },
101
102 error: err => this.notifier.error(err.message)
103 })
104 }
105
106 private rejectRegistration () {
107 this.registrationService.rejectRegistration({
108 registration: this.registration,
109 moderationResponse: this.form.value.moderationResponse,
110 preventEmailDelivery: this.form.value.preventEmailDelivery
111 }).subscribe({
112 next: () => {
113 this.notifier.success($localize`${this.registration.username} registration rejected`)
114
115 this.registrationProcessed.emit()
116 this.hide()
117 },
118
119 error: err => this.notifier.error(err.message)
120 })
121 }
122}
diff --git a/client/src/app/+admin/moderation/registration-list/process-registration-validators.ts b/client/src/app/+admin/moderation/registration-list/process-registration-validators.ts
new file mode 100644
index 000000000..e01a07d9d
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/process-registration-validators.ts
@@ -0,0 +1,11 @@
1import { Validators } from '@angular/forms'
2import { BuildFormValidator } from '@app/shared/form-validators'
3
4export const REGISTRATION_MODERATION_RESPONSE_VALIDATOR: BuildFormValidator = {
5 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
6 MESSAGES: {
7 required: $localize`Moderation response is required.`,
8 minlength: $localize`Moderation response must be at least 2 characters long.`,
9 maxlength: $localize`Moderation response cannot be more than 3000 characters long.`
10 }
11}
diff --git a/client/src/app/+admin/moderation/registration-list/registration-list.component.html b/client/src/app/+admin/moderation/registration-list/registration-list.component.html
new file mode 100644
index 000000000..a2b888101
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/registration-list.component.html
@@ -0,0 +1,135 @@
1<h1>
2 <my-global-icon iconName="user" aria-hidden="true"></my-global-icon>
3 <ng-container i18n>Registration requests</ng-container>
4</h1>
5
6<p-table
7 [value]="registrations" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
8 [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id"
9 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false"
10 [(selection)]="selectedRows" [showCurrentPageReport]="true" i18n-currentPageReportTemplate
11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} registrations"
12 [expandedRowKeys]="expandedRows"
13>
14 <ng-template pTemplate="caption">
15 <div class="caption">
16 <div class="left-buttons">
17 <my-action-dropdown
18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
19 [actions]="bulkActions" [entry]="selectedRows"
20 >
21 </my-action-dropdown>
22 </div>
23
24 <div class="ms-auto">
25 <my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter>
26 </div>
27 </div>
28 </ng-template>
29
30 <ng-template pTemplate="header">
31 <tr> <!-- header -->
32 <th style="width: 40px">
33 <p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
34 </th>
35 <th style="width: 40px;"></th>
36 <th style="width: 150px;"></th>
37 <th i18n>Account</th>
38 <th i18n>Email</th>
39 <th i18n>Channel</th>
40 <th i18n>Registration reason</th>
41 <th i18n pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
42 <th i18n>Moderation response</th>
43 <th style="width: 150px;" i18n pSortableColumn="createdAt">Requested on <p-sortIcon field="createdAt"></p-sortIcon></th>
44 </tr>
45 </ng-template>
46
47 <ng-template pTemplate="body" let-expanded="expanded" let-registration>
48 <tr [pSelectableRow]="registration">
49 <td class="checkbox-cell">
50 <p-tableCheckbox [value]="registration" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
51 </td>
52
53 <td class="expand-cell" [pRowToggler]="registration">
54 <my-table-expander-icon [expanded]="expanded"></my-table-expander-icon>
55 </td>
56
57 <td class="action-cell">
58 <my-action-dropdown
59 [ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
60 i18n-label label="Actions" [actions]="registrationActions" [entry]="registration"
61 ></my-action-dropdown>
62 </td>
63
64 <td>
65 <div class="chip two-lines">
66 <div>
67 <span>{{ registration.username }}</span>
68 <span class="muted">{{ registration.accountDisplayName }}</span>
69 </div>
70 </div>
71 </td>
72
73 <td>
74 <my-user-email-info [entry]="registration" [requiresEmailVerification]="requiresEmailVerification"></my-user-email-info>
75 </td>
76
77 <td>
78 <div class="chip two-lines">
79 <div>
80 <span>{{ registration.channelHandle }}</span>
81 <span class="muted">{{ registration.channelDisplayName }}</span>
82 </div>
83 </div>
84 </td>
85
86 <td container="body" placement="left auto" [ngbTooltip]="registration.registrationReason">
87 {{ registration.registrationReason }}
88 </td>
89
90 <td class="c-hand abuse-states" [pRowToggler]="registration">
91 <my-global-icon *ngIf="isRegistrationAccepted(registration)" [title]="registration.state.label" iconName="tick"></my-global-icon>
92 <my-global-icon *ngIf="isRegistrationRejected(registration)" [title]="registration.state.label" iconName="cross"></my-global-icon>
93 </td>
94
95 <td container="body" placement="left auto" [ngbTooltip]="registration.moderationResponse">
96 {{ registration.moderationResponse }}
97 </td>
98
99 <td class="c-hand" [pRowToggler]="registration">{{ registration.createdAt | date: 'short' }}</td>
100 </tr>
101 </ng-template>
102
103 <ng-template pTemplate="rowexpansion" let-registration>
104 <tr>
105 <td colspan="9">
106 <div class="moderation-expanded">
107 <div class="left">
108 <div class="d-flex">
109 <span class="moderation-expanded-label" i18n>Registration reason:</span>
110 <span class="moderation-expanded-text" [innerHTML]="registration.registrationReasonHTML"></span>
111 </div>
112
113 <div *ngIf="registration.moderationResponse">
114 <span class="moderation-expanded-label" i18n>Moderation response:</span>
115 <span class="moderation-expanded-text" [innerHTML]="registration.moderationResponseHTML"></span>
116 </div>
117 </div>
118 </div>
119 </td>
120 </tr>
121 </ng-template>
122
123 <ng-template pTemplate="emptymessage">
124 <tr>
125 <td colspan="9">
126 <div class="no-results">
127 <ng-container *ngIf="search" i18n>No registrations found matching current filters.</ng-container>
128 <ng-container *ngIf="!search" i18n>No registrations found.</ng-container>
129 </div>
130 </td>
131 </tr>
132 </ng-template>
133</p-table>
134
135<my-process-registration-modal #processRegistrationModal (registrationProcessed)="onRegistrationProcessed()"></my-process-registration-modal>
diff --git a/client/src/app/+admin/moderation/registration-list/registration-list.component.scss b/client/src/app/+admin/moderation/registration-list/registration-list.component.scss
new file mode 100644
index 000000000..9cae08e85
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/registration-list.component.scss
@@ -0,0 +1,7 @@
1@use '_mixins' as *;
2@use '_variables' as *;
3
4my-global-icon {
5 width: 24px;
6 height: 24px;
7}
diff --git a/client/src/app/+admin/moderation/registration-list/registration-list.component.ts b/client/src/app/+admin/moderation/registration-list/registration-list.component.ts
new file mode 100644
index 000000000..ed8fbec51
--- /dev/null
+++ b/client/src/app/+admin/moderation/registration-list/registration-list.component.ts
@@ -0,0 +1,151 @@
1import { SortMeta } from 'primeng/api'
2import { Component, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router'
4import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
5import { prepareIcu } from '@app/helpers'
6import { AdvancedInputFilter } from '@app/shared/shared-forms'
7import { DropdownAction } from '@app/shared/shared-main'
8import { UserRegistration, UserRegistrationState } from '@shared/models'
9import { AdminRegistrationService } from './admin-registration.service'
10import { ProcessRegistrationModalComponent } from './process-registration-modal.component'
11
12@Component({
13 selector: 'my-registration-list',
14 templateUrl: './registration-list.component.html',
15 styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './registration-list.component.scss' ]
16})
17export class RegistrationListComponent extends RestTable <UserRegistration> implements OnInit {
18 @ViewChild('processRegistrationModal', { static: true }) processRegistrationModal: ProcessRegistrationModalComponent
19
20 registrations: (UserRegistration & { registrationReasonHTML?: string, moderationResponseHTML?: string })[] = []
21 totalRecords = 0
22 sort: SortMeta = { field: 'createdAt', order: -1 }
23 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
24
25 registrationActions: DropdownAction<UserRegistration>[][] = []
26 bulkActions: DropdownAction<UserRegistration[]>[] = []
27
28 inputFilters: AdvancedInputFilter[] = []
29
30 requiresEmailVerification: boolean
31
32 constructor (
33 protected route: ActivatedRoute,
34 protected router: Router,
35 private server: ServerService,
36 private notifier: Notifier,
37 private markdownRenderer: MarkdownService,
38 private confirmService: ConfirmService,
39 private adminRegistrationService: AdminRegistrationService
40 ) {
41 super()
42
43 this.registrationActions = [
44 [
45 {
46 label: $localize`Accept this request`,
47 handler: registration => this.openRegistrationRequestProcessModal(registration, 'accept'),
48 isDisplayed: registration => registration.state.id === UserRegistrationState.PENDING
49 },
50 {
51 label: $localize`Reject this request`,
52 handler: registration => this.openRegistrationRequestProcessModal(registration, 'reject'),
53 isDisplayed: registration => registration.state.id === UserRegistrationState.PENDING
54 },
55 {
56 label: $localize`Remove this request`,
57 handler: registration => this.removeRegistrations([ registration ])
58 }
59 ]
60 ]
61
62 this.bulkActions = [
63 {
64 label: $localize`Delete`,
65 handler: registrations => this.removeRegistrations(registrations)
66 }
67 ]
68 }
69
70 ngOnInit () {
71 this.initialize()
72
73 this.server.getConfig()
74 .subscribe(config => {
75 this.requiresEmailVerification = config.signup.requiresEmailVerification
76 })
77 }
78
79 getIdentifier () {
80 return 'RegistrationListComponent'
81 }
82
83 isRegistrationAccepted (registration: UserRegistration) {
84 return registration.state.id === UserRegistrationState.ACCEPTED
85 }
86
87 isRegistrationRejected (registration: UserRegistration) {
88 return registration.state.id === UserRegistrationState.REJECTED
89 }
90
91 onRegistrationProcessed () {
92 this.reloadData()
93 }
94
95 protected reloadDataInternal () {
96 this.adminRegistrationService.listRegistrations({
97 pagination: this.pagination,
98 sort: this.sort,
99 search: this.search
100 }).subscribe({
101 next: async resultList => {
102 this.totalRecords = resultList.total
103 this.registrations = resultList.data
104
105 for (const registration of this.registrations) {
106 registration.registrationReasonHTML = await this.toHtml(registration.registrationReason)
107 registration.moderationResponseHTML = await this.toHtml(registration.moderationResponse)
108 }
109 },
110
111 error: err => this.notifier.error(err.message)
112 })
113 }
114
115 private openRegistrationRequestProcessModal (registration: UserRegistration, mode: 'accept' | 'reject') {
116 this.processRegistrationModal.openModal(registration, mode)
117 }
118
119 private async removeRegistrations (registrations: UserRegistration[]) {
120 const icuParams = { count: registrations.length, username: registrations[0].username }
121
122 // eslint-disable-next-line max-len
123 const message = prepareIcu($localize`Do you really want to delete {count, plural, =1 {{username} registration request?} other {{count} registration requests?}}`)(
124 icuParams,
125 $localize`Do you really want to delete these registration requests?`
126 )
127
128 const res = await this.confirmService.confirm(message, $localize`Delete`)
129 if (res === false) return
130
131 this.adminRegistrationService.removeRegistrations(registrations)
132 .subscribe({
133 next: () => {
134 // eslint-disable-next-line max-len
135 const message = prepareIcu($localize`Removed {count, plural, =1 {{username} registration request} other {{count} registration requests}}`)(
136 icuParams,
137 $localize`Registration requests removed`
138 )
139
140 this.notifier.success(message)
141 this.reloadData()
142 },
143
144 error: err => this.notifier.error(err.message)
145 })
146 }
147
148 private toHtml (text: string) {
149 return this.markdownRenderer.textMarkdownToHTML({ markdown: text })
150 }
151}
diff --git a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts
index efd99e52b..f365a2500 100644
--- a/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts
+++ b/client/src/app/+admin/moderation/video-block-list/video-block-list.component.ts
@@ -159,26 +159,25 @@ export class VideoBlockListComponent extends RestTable implements OnInit {
159 }) 159 })
160 } 160 }
161 161
162 protected reloadData () { 162 protected reloadDataInternal () {
163 this.videoBlocklistService.listBlocks({ 163 this.videoBlocklistService.listBlocks({
164 pagination: this.pagination, 164 pagination: this.pagination,
165 sort: this.sort, 165 sort: this.sort,
166 search: this.search 166 search: this.search
167 }) 167 }).subscribe({
168 .subscribe({ 168 next: async resultList => {
169 next: async resultList => { 169 this.totalRecords = resultList.total
170 this.totalRecords = resultList.total
171 170
172 this.blocklist = resultList.data 171 this.blocklist = resultList.data
173 172
174 for (const element of this.blocklist) { 173 for (const element of this.blocklist) {
175 Object.assign(element, { 174 Object.assign(element, {
176 reasonHtml: await this.toHtml(element.reason) 175 reasonHtml: await this.toHtml(element.reason)
177 }) 176 })
178 } 177 }
179 }, 178 },
180 179
181 error: err => this.notifier.error(err.message) 180 error: err => this.notifier.error(err.message)
182 }) 181 })
183 } 182 }
184} 183}
diff --git a/client/src/app/+admin/overview/comments/video-comment-list.component.html b/client/src/app/+admin/overview/comments/video-comment-list.component.html
index d2ca5f700..b0d8131bf 100644
--- a/client/src/app/+admin/overview/comments/video-comment-list.component.html
+++ b/client/src/app/+admin/overview/comments/video-comment-list.component.html
@@ -13,14 +13,14 @@
13 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true" 13 [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
14 [showCurrentPageReport]="true" i18n-currentPageReportTemplate 14 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
15 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} comments" 15 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} comments"
16 [expandedRowKeys]="expandedRows" [(selection)]="selectedComments" 16 [expandedRowKeys]="expandedRows" [(selection)]="selectedRows"
17> 17>
18 <ng-template pTemplate="caption"> 18 <ng-template pTemplate="caption">
19 <div class="caption"> 19 <div class="caption">
20 <div> 20 <div>
21 <my-action-dropdown 21 <my-action-dropdown
22 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange" 22 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
23 [actions]="bulkCommentActions" [entry]="selectedComments" 23 [actions]="bulkActions" [entry]="selectedRows"
24 > 24 >
25 </my-action-dropdown> 25 </my-action-dropdown>
26 </div> 26 </div>
diff --git a/client/src/app/+admin/overview/comments/video-comment-list.component.ts b/client/src/app/+admin/overview/comments/video-comment-list.component.ts
index c95d2ffeb..28efdc076 100644
--- a/client/src/app/+admin/overview/comments/video-comment-list.component.ts
+++ b/client/src/app/+admin/overview/comments/video-comment-list.component.ts
@@ -14,7 +14,7 @@ import { prepareIcu } from '@app/helpers'
14 templateUrl: './video-comment-list.component.html', 14 templateUrl: './video-comment-list.component.html',
15 styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './video-comment-list.component.scss' ] 15 styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './video-comment-list.component.scss' ]
16}) 16})
17export class VideoCommentListComponent extends RestTable implements OnInit { 17export class VideoCommentListComponent extends RestTable <VideoCommentAdmin> implements OnInit {
18 comments: VideoCommentAdmin[] 18 comments: VideoCommentAdmin[]
19 totalRecords = 0 19 totalRecords = 0
20 sort: SortMeta = { field: 'createdAt', order: -1 } 20 sort: SortMeta = { field: 'createdAt', order: -1 }
@@ -40,8 +40,7 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
40 } 40 }
41 ] 41 ]
42 42
43 selectedComments: VideoCommentAdmin[] = [] 43 bulkActions: DropdownAction<VideoCommentAdmin[]>[] = []
44 bulkCommentActions: DropdownAction<VideoCommentAdmin[]>[] = []
45 44
46 inputFilters: AdvancedInputFilter[] = [ 45 inputFilters: AdvancedInputFilter[] = [
47 { 46 {
@@ -100,7 +99,7 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
100 ngOnInit () { 99 ngOnInit () {
101 this.initialize() 100 this.initialize()
102 101
103 this.bulkCommentActions = [ 102 this.bulkActions = [
104 { 103 {
105 label: $localize`Delete`, 104 label: $localize`Delete`,
106 handler: comments => this.removeComments(comments), 105 handler: comments => this.removeComments(comments),
@@ -118,11 +117,7 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
118 return this.markdownRenderer.textMarkdownToHTML({ markdown: text, withHtml: true, withEmoji: true }) 117 return this.markdownRenderer.textMarkdownToHTML({ markdown: text, withHtml: true, withEmoji: true })
119 } 118 }
120 119
121 isInSelectionMode () { 120 protected reloadDataInternal () {
122 return this.selectedComments.length !== 0
123 }
124
125 reloadData () {
126 this.videoCommentService.getAdminVideoComments({ 121 this.videoCommentService.getAdminVideoComments({
127 pagination: this.pagination, 122 pagination: this.pagination,
128 sort: this.sort, 123 sort: this.sort,
@@ -162,7 +157,7 @@ export class VideoCommentListComponent extends RestTable implements OnInit {
162 157
163 error: err => this.notifier.error(err.message), 158 error: err => this.notifier.error(err.message),
164 159
165 complete: () => this.selectedComments = [] 160 complete: () => this.selectedRows = []
166 }) 161 })
167 } 162 }
168 163
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.html b/client/src/app/+admin/overview/users/user-list/user-list.component.html
index a96ce561c..7eb5e0fc7 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.html
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.html
@@ -6,7 +6,7 @@
6<p-table 6<p-table
7 [value]="users" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start" 7 [value]="users" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
8 [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true" 8 [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true"
9 [(selection)]="selectedUsers" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true" 9 [(selection)]="selectedRows" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate 10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} users" 11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} users"
12 [expandedRowKeys]="expandedRows" 12 [expandedRowKeys]="expandedRows"
@@ -16,7 +16,7 @@
16 <div class="left-buttons"> 16 <div class="left-buttons">
17 <my-action-dropdown 17 <my-action-dropdown
18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange" 18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
19 [actions]="bulkUserActions" [entry]="selectedUsers" 19 [actions]="bulkActions" [entry]="selectedRows"
20 > 20 >
21 </my-action-dropdown> 21 </my-action-dropdown>
22 22
@@ -95,7 +95,7 @@
95 <div class="chip two-lines"> 95 <div class="chip two-lines">
96 <my-actor-avatar [actor]="user?.account" actorType="account" size="32"></my-actor-avatar> 96 <my-actor-avatar [actor]="user?.account" actorType="account" size="32"></my-actor-avatar>
97 <div> 97 <div>
98 <span class="user-table-primary-text">{{ user.account.displayName }}</span> 98 <span>{{ user.account.displayName }}</span>
99 <span class="muted">{{ user.username }}</span> 99 <span class="muted">{{ user.username }}</span>
100 </div> 100 </div>
101 </div> 101 </div>
@@ -110,23 +110,10 @@
110 <span *ngIf="!user.blocked" class="pt-badge" [ngClass]="getRoleClass(user.role.id)">{{ user.role.label }}</span> 110 <span *ngIf="!user.blocked" class="pt-badge" [ngClass]="getRoleClass(user.role.id)">{{ user.role.label }}</span>
111 </td> 111 </td>
112 112
113 <td *ngIf="isSelected('email')" [title]="user.email"> 113 <td *ngIf="isSelected('email')">
114 <ng-container *ngIf="!requiresEmailVerification || user.blocked; else emailWithVerificationStatus"> 114 <my-user-email-info [entry]="user" [requiresEmailVerification]="requiresEmailVerification"></my-user-email-info>
115 <a class="table-email" [href]="'mailto:' + user.email">{{ user.email }}</a>
116 </ng-container>
117 </td> 115 </td>
118 116
119 <ng-template #emailWithVerificationStatus>
120 <td *ngIf="user.emailVerified === false; else emailVerifiedNotFalse" i18n-title title="User's email must be verified to login">
121 <em>? {{ user.email }}</em>
122 </td>
123 <ng-template #emailVerifiedNotFalse>
124 <td i18n-title title="User's email is verified / User can login without email verification">
125 &#x2713; {{ user.email }}
126 </td>
127 </ng-template>
128 </ng-template>
129
130 <td *ngIf="isSelected('quota')"> 117 <td *ngIf="isSelected('quota')">
131 <div class="progress" i18n-title title="Total video quota"> 118 <div class="progress" i18n-title title="Total video quota">
132 <div class="progress-bar" role="progressbar" [style]="{ width: getUserVideoQuotaPercentage(user) + '%' }" 119 <div class="progress-bar" role="progressbar" [style]="{ width: getUserVideoQuotaPercentage(user) + '%' }"
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.scss b/client/src/app/+admin/overview/users/user-list/user-list.component.scss
index 23e0d29ee..2a3b955d2 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.scss
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.scss
@@ -10,12 +10,6 @@ tr.banned > td {
10 background-color: lighten($color: $red, $amount: 40) !important; 10 background-color: lighten($color: $red, $amount: 40) !important;
11} 11}
12 12
13.table-email {
14 @include disable-default-a-behaviour;
15
16 color: pvar(--mainForegroundColor);
17}
18
19.banned-info { 13.banned-info {
20 font-style: italic; 14 font-style: italic;
21} 15}
@@ -37,10 +31,6 @@ my-global-icon {
37 width: 18px; 31 width: 18px;
38} 32}
39 33
40.chip {
41 @include chip;
42}
43
44.progress { 34.progress {
45 @include progressbar($small: true); 35 @include progressbar($small: true);
46 36
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.ts b/client/src/app/+admin/overview/users/user-list/user-list.component.ts
index 99987fdff..19420b748 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.ts
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.ts
@@ -22,7 +22,7 @@ type UserForList = User & {
22 templateUrl: './user-list.component.html', 22 templateUrl: './user-list.component.html',
23 styleUrls: [ './user-list.component.scss' ] 23 styleUrls: [ './user-list.component.scss' ]
24}) 24})
25export class UserListComponent extends RestTable implements OnInit { 25export class UserListComponent extends RestTable <User> implements OnInit {
26 private static readonly LOCAL_STORAGE_SELECTED_COLUMNS_KEY = 'admin-user-list-selected-columns' 26 private static readonly LOCAL_STORAGE_SELECTED_COLUMNS_KEY = 'admin-user-list-selected-columns'
27 27
28 @ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent 28 @ViewChild('userBanModal', { static: true }) userBanModal: UserBanModalComponent
@@ -35,8 +35,7 @@ export class UserListComponent extends RestTable implements OnInit {
35 35
36 highlightBannedUsers = false 36 highlightBannedUsers = false
37 37
38 selectedUsers: User[] = [] 38 bulkActions: DropdownAction<User[]>[][] = []
39 bulkUserActions: DropdownAction<User[]>[][] = []
40 columns: { id: string, label: string }[] 39 columns: { id: string, label: string }[]
41 40
42 inputFilters: AdvancedInputFilter[] = [ 41 inputFilters: AdvancedInputFilter[] = [
@@ -95,7 +94,7 @@ export class UserListComponent extends RestTable implements OnInit {
95 94
96 this.initialize() 95 this.initialize()
97 96
98 this.bulkUserActions = [ 97 this.bulkActions = [
99 [ 98 [
100 { 99 {
101 label: $localize`Delete`, 100 label: $localize`Delete`,
@@ -249,7 +248,7 @@ export class UserListComponent extends RestTable implements OnInit {
249 const res = await this.confirmService.confirm(message, $localize`Delete`) 248 const res = await this.confirmService.confirm(message, $localize`Delete`)
250 if (res === false) return 249 if (res === false) return
251 250
252 this.userAdminService.removeUser(users) 251 this.userAdminService.removeUsers(users)
253 .subscribe({ 252 .subscribe({
254 next: () => { 253 next: () => {
255 this.notifier.success( 254 this.notifier.success(
@@ -284,13 +283,7 @@ export class UserListComponent extends RestTable implements OnInit {
284 }) 283 })
285 } 284 }
286 285
287 isInSelectionMode () { 286 protected reloadDataInternal () {
288 return this.selectedUsers.length !== 0
289 }
290
291 protected reloadData () {
292 this.selectedUsers = []
293
294 this.userAdminService.getUsers({ 287 this.userAdminService.getUsers({
295 pagination: this.pagination, 288 pagination: this.pagination,
296 sort: this.sort, 289 sort: this.sort,
diff --git a/client/src/app/+admin/overview/videos/video-list.component.html b/client/src/app/+admin/overview/videos/video-list.component.html
index a6cd2e257..5b8405ad9 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.html
+++ b/client/src/app/+admin/overview/videos/video-list.component.html
@@ -6,7 +6,7 @@
6<p-table 6<p-table
7 [value]="videos" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start" 7 [value]="videos" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start"
8 [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true" 8 [rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" [resizableColumns]="true"
9 [(selection)]="selectedVideos" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true" 9 [(selection)]="selectedRows" [lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [selectionPageOnly]="true"
10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate 10 [showCurrentPageReport]="true" i18n-currentPageReportTemplate
11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} videos" 11 currentPageReportTemplate="Showing {{'{first}'}} to {{'{last}'}} of {{'{totalRecords}'}} videos"
12 [expandedRowKeys]="expandedRows" [ngClass]="{ loading: loading }" 12 [expandedRowKeys]="expandedRows" [ngClass]="{ loading: loading }"
@@ -16,7 +16,7 @@
16 <div class="left-buttons"> 16 <div class="left-buttons">
17 <my-action-dropdown 17 <my-action-dropdown
18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange" 18 *ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="orange"
19 [actions]="bulkVideoActions" [entry]="selectedVideos" 19 [actions]="bulkActions" [entry]="selectedRows"
20 > 20 >
21 </my-action-dropdown> 21 </my-action-dropdown>
22 </div> 22 </div>
diff --git a/client/src/app/+admin/overview/videos/video-list.component.ts b/client/src/app/+admin/overview/videos/video-list.component.ts
index 4d3e9873c..1ea295499 100644
--- a/client/src/app/+admin/overview/videos/video-list.component.ts
+++ b/client/src/app/+admin/overview/videos/video-list.component.ts
@@ -17,7 +17,7 @@ import { VideoAdminService } from './video-admin.service'
17 templateUrl: './video-list.component.html', 17 templateUrl: './video-list.component.html',
18 styleUrls: [ './video-list.component.scss' ] 18 styleUrls: [ './video-list.component.scss' ]
19}) 19})
20export class VideoListComponent extends RestTable implements OnInit { 20export class VideoListComponent extends RestTable <Video> implements OnInit {
21 @ViewChild('videoBlockModal') videoBlockModal: VideoBlockComponent 21 @ViewChild('videoBlockModal') videoBlockModal: VideoBlockComponent
22 22
23 videos: Video[] = [] 23 videos: Video[] = []
@@ -26,9 +26,7 @@ export class VideoListComponent extends RestTable implements OnInit {
26 sort: SortMeta = { field: 'publishedAt', order: -1 } 26 sort: SortMeta = { field: 'publishedAt', order: -1 }
27 pagination: RestPagination = { count: this.rowsPerPage, start: 0 } 27 pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
28 28
29 bulkVideoActions: DropdownAction<Video[]>[][] = [] 29 bulkActions: DropdownAction<Video[]>[][] = []
30
31 selectedVideos: Video[] = []
32 30
33 inputFilters: AdvancedInputFilter[] 31 inputFilters: AdvancedInputFilter[]
34 32
@@ -72,7 +70,7 @@ export class VideoListComponent extends RestTable implements OnInit {
72 70
73 this.inputFilters = this.videoAdminService.buildAdminInputFilter() 71 this.inputFilters = this.videoAdminService.buildAdminInputFilter()
74 72
75 this.bulkVideoActions = [ 73 this.bulkActions = [
76 [ 74 [
77 { 75 {
78 label: $localize`Delete`, 76 label: $localize`Delete`,
@@ -126,10 +124,6 @@ export class VideoListComponent extends RestTable implements OnInit {
126 return 'VideoListComponent' 124 return 'VideoListComponent'
127 } 125 }
128 126
129 isInSelectionMode () {
130 return this.selectedVideos.length !== 0
131 }
132
133 getPrivacyBadgeClass (video: Video) { 127 getPrivacyBadgeClass (video: Video) {
134 if (video.privacy.id === VideoPrivacy.PUBLIC) return 'badge-green' 128 if (video.privacy.id === VideoPrivacy.PUBLIC) return 'badge-green'
135 129
@@ -189,9 +183,23 @@ export class VideoListComponent extends RestTable implements OnInit {
189 return files.reduce((p, f) => p += f.size, 0) 183 return files.reduce((p, f) => p += f.size, 0)
190 } 184 }
191 185
192 reloadData () { 186 async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'webtorrent') {
193 this.selectedVideos = [] 187 const message = $localize`Are you sure you want to delete this ${file.resolution.label} file?`
188 const res = await this.confirmService.confirm(message, $localize`Delete file`)
189 if (res === false) return
190
191 this.videoService.removeFile(video.uuid, file.id, type)
192 .subscribe({
193 next: () => {
194 this.notifier.success($localize`File removed.`)
195 this.reloadData()
196 },
197
198 error: err => this.notifier.error(err.message)
199 })
200 }
194 201
202 protected reloadDataInternal () {
195 this.loading = true 203 this.loading = true
196 204
197 this.videoAdminService.getAdminVideos({ 205 this.videoAdminService.getAdminVideos({
@@ -209,22 +217,6 @@ export class VideoListComponent extends RestTable implements OnInit {
209 }) 217 })
210 } 218 }
211 219
212 async removeVideoFile (video: Video, file: VideoFile, type: 'hls' | 'webtorrent') {
213 const message = $localize`Are you sure you want to delete this ${file.resolution.label} file?`
214 const res = await this.confirmService.confirm(message, $localize`Delete file`)
215 if (res === false) return
216
217 this.videoService.removeFile(video.uuid, file.id, type)
218 .subscribe({
219 next: () => {
220 this.notifier.success($localize`File removed.`)
221 this.reloadData()
222 },
223
224 error: err => this.notifier.error(err.message)
225 })
226 }
227
228 private async removeVideos (videos: Video[]) { 220 private async removeVideos (videos: Video[]) {
229 const message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`)( 221 const message = prepareIcu($localize`Are you sure you want to delete {count, plural, =1 {this video} other {these {count} videos}}?`)(
230 { count: videos.length }, 222 { count: videos.length },
diff --git a/client/src/app/+admin/shared/shared-admin.module.ts b/client/src/app/+admin/shared/shared-admin.module.ts
index bef7d54ef..a5c300d12 100644
--- a/client/src/app/+admin/shared/shared-admin.module.ts
+++ b/client/src/app/+admin/shared/shared-admin.module.ts
@@ -1,5 +1,6 @@
1import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
2import { SharedMainModule } from '../../shared/shared-main/shared-main.module' 2import { SharedMainModule } from '../../shared/shared-main/shared-main.module'
3import { UserEmailInfoComponent } from './user-email-info.component'
3import { UserRealQuotaInfoComponent } from './user-real-quota-info.component' 4import { UserRealQuotaInfoComponent } from './user-real-quota-info.component'
4 5
5@NgModule({ 6@NgModule({
@@ -8,11 +9,13 @@ import { UserRealQuotaInfoComponent } from './user-real-quota-info.component'
8 ], 9 ],
9 10
10 declarations: [ 11 declarations: [
11 UserRealQuotaInfoComponent 12 UserRealQuotaInfoComponent,
13 UserEmailInfoComponent
12 ], 14 ],
13 15
14 exports: [ 16 exports: [
15 UserRealQuotaInfoComponent 17 UserRealQuotaInfoComponent,
18 UserEmailInfoComponent
16 ], 19 ],
17 20
18 providers: [] 21 providers: []
diff --git a/client/src/app/+admin/shared/user-email-info.component.html b/client/src/app/+admin/shared/user-email-info.component.html
new file mode 100644
index 000000000..244240619
--- /dev/null
+++ b/client/src/app/+admin/shared/user-email-info.component.html
@@ -0,0 +1,13 @@
1<ng-container>
2 <a [href]="'mailto:' + entry.email" [title]="getTitle()">
3 <ng-container *ngIf="!requiresEmailVerification">
4 {{ entry.email }}
5 </ng-container>
6
7 <ng-container *ngIf="requiresEmailVerification">
8 <em *ngIf="!entry.emailVerified">? {{ entry.email }}</em>
9
10 <ng-container *ngIf="entry.emailVerified === true">&#x2713; {{ entry.email }}</ng-container>
11 </ng-container>
12 </a>
13</ng-container>
diff --git a/client/src/app/+admin/shared/user-email-info.component.scss b/client/src/app/+admin/shared/user-email-info.component.scss
new file mode 100644
index 000000000..d34947edd
--- /dev/null
+++ b/client/src/app/+admin/shared/user-email-info.component.scss
@@ -0,0 +1,10 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4a {
5 color: pvar(--mainForegroundColor);
6
7 &:hover {
8 text-decoration: underline;
9 }
10}
diff --git a/client/src/app/+admin/shared/user-email-info.component.ts b/client/src/app/+admin/shared/user-email-info.component.ts
new file mode 100644
index 000000000..e33948b60
--- /dev/null
+++ b/client/src/app/+admin/shared/user-email-info.component.ts
@@ -0,0 +1,20 @@
1import { Component, Input } from '@angular/core'
2import { User, UserRegistration } from '@shared/models/users'
3
4@Component({
5 selector: 'my-user-email-info',
6 templateUrl: './user-email-info.component.html',
7 styleUrls: [ './user-email-info.component.scss' ]
8})
9export class UserEmailInfoComponent {
10 @Input() entry: User | UserRegistration
11 @Input() requiresEmailVerification: boolean
12
13 getTitle () {
14 if (this.entry.emailVerified) {
15 return $localize`User email has been verified`
16 }
17
18 return $localize`User email hasn't been verified`
19 }
20}
diff --git a/client/src/app/+admin/system/jobs/job.service.ts b/client/src/app/+admin/system/jobs/job.service.ts
index ef8ddd3b4..031e2bad8 100644
--- a/client/src/app/+admin/system/jobs/job.service.ts
+++ b/client/src/app/+admin/system/jobs/job.service.ts
@@ -19,7 +19,7 @@ export class JobService {
19 private restExtractor: RestExtractor 19 private restExtractor: RestExtractor
20 ) {} 20 ) {}
21 21
22 getJobs (options: { 22 listJobs (options: {
23 jobState?: JobStateClient 23 jobState?: JobStateClient
24 jobType: JobTypeClient 24 jobType: JobTypeClient
25 pagination: RestPagination 25 pagination: RestPagination
diff --git a/client/src/app/+admin/system/jobs/jobs.component.ts b/client/src/app/+admin/system/jobs/jobs.component.ts
index b8f3c3a68..6e10c81ff 100644
--- a/client/src/app/+admin/system/jobs/jobs.component.ts
+++ b/client/src/app/+admin/system/jobs/jobs.component.ts
@@ -120,12 +120,12 @@ export class JobsComponent extends RestTable implements OnInit {
120 this.reloadData() 120 this.reloadData()
121 } 121 }
122 122
123 protected reloadData () { 123 protected reloadDataInternal () {
124 let jobState = this.jobState as JobState 124 let jobState = this.jobState as JobState
125 if (this.jobState === 'all') jobState = null 125 if (this.jobState === 'all') jobState = null
126 126
127 this.jobsService 127 this.jobsService
128 .getJobs({ 128 .listJobs({
129 jobState, 129 jobState,
130 jobType: this.jobType, 130 jobType: this.jobType,
131 pagination: this.pagination, 131 pagination: this.pagination,
diff --git a/client/src/app/+login/login.component.ts b/client/src/app/+login/login.component.ts
index c1705807f..c03af38f2 100644
--- a/client/src/app/+login/login.component.ts
+++ b/client/src/app/+login/login.component.ts
@@ -1,3 +1,4 @@
1import { environment } from 'src/environments/environment'
1import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core' 2import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
3import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core' 4import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core'
@@ -7,8 +8,8 @@ import { USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-valid
7import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms' 8import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms'
8import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance' 9import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
9import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' 10import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
10import { PluginsManager } from '@root-helpers/plugins-manager' 11import { getExternalAuthHref } from '@shared/core-utils'
11import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models' 12import { RegisteredExternalAuthConfig, ServerConfig, ServerErrorCode } from '@shared/models'
12 13
13@Component({ 14@Component({
14 selector: 'my-login', 15 selector: 'my-login',
@@ -119,7 +120,7 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
119 } 120 }
120 121
121 getAuthHref (auth: RegisteredExternalAuthConfig) { 122 getAuthHref (auth: RegisteredExternalAuthConfig) {
122 return PluginsManager.getExternalAuthHref(auth) 123 return getExternalAuthHref(environment.apiUrl, auth)
123 } 124 }
124 125
125 login () { 126 login () {
@@ -196,6 +197,8 @@ The link will expire within 1 hour.`
196 } 197 }
197 198
198 private handleError (err: any) { 199 private handleError (err: any) {
200 console.log(err)
201
199 if (this.authService.isOTPMissingError(err)) { 202 if (this.authService.isOTPMissingError(err)) {
200 this.otpStep = true 203 this.otpStep = true
201 204
@@ -207,8 +210,26 @@ The link will expire within 1 hour.`
207 return 210 return
208 } 211 }
209 212
210 if (err.message.indexOf('credentials are invalid') !== -1) this.error = $localize`Incorrect username or password.` 213 if (err.message.includes('credentials are invalid')) {
211 else if (err.message.indexOf('blocked') !== -1) this.error = $localize`Your account is blocked.` 214 this.error = $localize`Incorrect username or password.`
212 else this.error = err.message 215 return
216 }
217
218 if (err.message.includes('blocked')) {
219 this.error = $localize`Your account is blocked.`
220 return
221 }
222
223 if (err.body?.code === ServerErrorCode.ACCOUNT_WAITING_FOR_APPROVAL) {
224 this.error = $localize`This account is awaiting approval by moderators.`
225 return
226 }
227
228 if (err.body?.code === ServerErrorCode.ACCOUNT_APPROVAL_REJECTED) {
229 this.error = $localize`Registration approval has been rejected for this account.`
230 return
231 }
232
233 this.error = err.message
213 } 234 }
214} 235}
diff --git a/client/src/app/+my-library/my-ownership/my-ownership.component.scss b/client/src/app/+my-library/my-ownership/my-ownership.component.scss
index a8450ff1b..98bed226d 100644
--- a/client/src/app/+my-library/my-ownership/my-ownership.component.scss
+++ b/client/src/app/+my-library/my-ownership/my-ownership.component.scss
@@ -2,10 +2,6 @@
2@use '_miniature' as *; 2@use '_miniature' as *;
3@use '_mixins' as *; 3@use '_mixins' as *;
4 4
5.chip {
6 @include chip;
7}
8
9.video-table-video { 5.video-table-video {
10 display: inline-flex; 6 display: inline-flex;
11 7
diff --git a/client/src/app/+my-library/my-ownership/my-ownership.component.ts b/client/src/app/+my-library/my-ownership/my-ownership.component.ts
index 7ea940ceb..8d6a42dfb 100644
--- a/client/src/app/+my-library/my-ownership/my-ownership.component.ts
+++ b/client/src/app/+my-library/my-ownership/my-ownership.component.ts
@@ -59,7 +59,7 @@ export class MyOwnershipComponent extends RestTable implements OnInit {
59 }) 59 })
60 } 60 }
61 61
62 protected reloadData () { 62 protected reloadDataInternal () {
63 return this.videoOwnershipService.getOwnershipChanges(this.pagination, this.sort) 63 return this.videoOwnershipService.getOwnershipChanges(this.pagination, this.sort)
64 .subscribe({ 64 .subscribe({
65 next: resultList => { 65 next: resultList => {
diff --git a/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.ts b/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.ts
index d18e78201..74dbe222d 100644
--- a/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.ts
+++ b/client/src/app/+my-library/my-video-channel-syncs/my-video-channel-syncs.component.ts
@@ -68,7 +68,7 @@ export class MyVideoChannelSyncsComponent extends RestTable implements OnInit {
68 ] 68 ]
69 } 69 }
70 70
71 protected reloadData () { 71 protected reloadDataInternal () {
72 this.error = undefined 72 this.error = undefined
73 73
74 this.authService.userInformationLoaded 74 this.authService.userInformationLoaded
diff --git a/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts b/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts
index 46d689bd1..7d82f62b9 100644
--- a/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts
+++ b/client/src/app/+my-library/my-video-imports/my-video-imports.component.ts
@@ -90,7 +90,7 @@ export class MyVideoImportsComponent extends RestTable implements OnInit {
90 }) 90 })
91 } 91 }
92 92
93 protected reloadData () { 93 protected reloadDataInternal () {
94 this.videoImportService.getMyVideoImports(this.pagination, this.sort, this.search) 94 this.videoImportService.getMyVideoImports(this.pagination, this.sort, this.search)
95 .subscribe({ 95 .subscribe({
96 next: resultList => { 96 next: resultList => {
diff --git a/client/src/app/+signup/+register/register.component.html b/client/src/app/+signup/+register/register.component.html
index bafb96a49..86763e801 100644
--- a/client/src/app/+signup/+register/register.component.html
+++ b/client/src/app/+signup/+register/register.component.html
@@ -5,29 +5,34 @@
5 </div> 5 </div>
6 6
7 <ng-container *ngIf="!signupDisabled"> 7 <ng-container *ngIf="!signupDisabled">
8 <h1 i18n class="title-page-v2"> 8 <h1 class="title-page-v2">
9 <strong class="underline-orange">{{ instanceName }}</strong> 9 <strong class="underline-orange">{{ instanceName }}</strong>
10 > 10 >
11 Create an account 11 <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
12 </h1> 12 </h1>
13 13
14 <div class="register-content"> 14 <div class="register-content">
15 <my-custom-stepper linear> 15 <my-custom-stepper linear>
16 16
17 <cdk-step i18n-label label="About" [editable]="!signupSuccess"> 17 <cdk-step i18n-label label="About" [editable]="!signupSuccess">
18 <my-signup-step-title mascotImageName="about" i18n> 18 <my-signup-step-title mascotImageName="about">
19 <strong>Create an account</strong> 19 <strong>
20 <div>on {{ instanceName }}</div> 20 <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
21 </strong>
22
23 <div i18n>on {{ instanceName }}</div>
21 </my-signup-step-title> 24 </my-signup-step-title>
22 25
23 <my-register-step-about [videoUploadDisabled]="videoUploadDisabled"></my-register-step-about> 26 <my-register-step-about [requiresApproval]="requiresApproval" [videoUploadDisabled]="videoUploadDisabled"></my-register-step-about>
24 27
25 <div class="step-buttons"> 28 <div class="step-buttons">
26 <a i18n class="skip-step underline-orange" routerLink="/login"> 29 <a i18n class="skip-step underline-orange" routerLink="/login">
27 <strong>I already have an account</strong>, I log in 30 <strong>I already have an account</strong>, I log in
28 </a> 31 </a>
29 32
30 <button i18n cdkStepperNext>Create an account</button> 33 <button cdkStepperNext>
34 <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
35 </button>
31 </div> 36 </div>
32 </cdk-step> 37 </cdk-step>
33 38
@@ -44,8 +49,8 @@
44 ></my-instance-about-accordion> 49 ></my-instance-about-accordion>
45 50
46 <my-register-step-terms 51 <my-register-step-terms
47 [hasCodeOfConduct]="!!aboutHtml.codeOfConduct" 52 [hasCodeOfConduct]="!!aboutHtml.codeOfConduct" [minimumAge]="minimumAge" [instanceName]="instanceName"
48 [minimumAge]="minimumAge" 53 [requiresApproval]="requiresApproval"
49 (formBuilt)="onTermsFormBuilt($event)" (termsClick)="onTermsClick()" (codeOfConductClick)="onCodeOfConductClick()" 54 (formBuilt)="onTermsFormBuilt($event)" (termsClick)="onTermsClick()" (codeOfConductClick)="onCodeOfConductClick()"
50 ></my-register-step-terms> 55 ></my-register-step-terms>
51 56
@@ -94,14 +99,15 @@
94 <div class="skip-step-description" i18n>You will be able to create a channel later</div> 99 <div class="skip-step-description" i18n>You will be able to create a channel later</div>
95 </div> 100 </div>
96 101
97 <button cdkStepperNext [disabled]="!formStepChannel || !formStepChannel.valid || hasSameChannelAndAccountNames()" (click)="signup()" i18n> 102 <button cdkStepperNext [disabled]="!formStepChannel || !formStepChannel.valid || hasSameChannelAndAccountNames()" (click)="signup()">
98 Create my account 103 <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
99 </button> 104 </button>
100 </div> 105 </div>
101 </cdk-step> 106 </cdk-step>
102 107
103 <cdk-step #lastStep i18n-label label="Done!" [editable]="false"> 108 <cdk-step #lastStep i18n-label label="Done!" [editable]="false">
104 <div *ngIf="!signupSuccess && !signupError" class="done-loader"> 109 <!-- Account creation can be a little bit long so display a loader -->
110 <div *ngIf="!requiresApproval && !signupSuccess && !signupError" class="done-loader">
105 <my-loader [loading]="true"></my-loader> 111 <my-loader [loading]="true"></my-loader>
106 112
107 <div i18n>PeerTube is creating your account...</div> 113 <div i18n>PeerTube is creating your account...</div>
@@ -109,7 +115,10 @@
109 115
110 <div *ngIf="signupError" class="alert alert-danger">{{ signupError }}</div> 116 <div *ngIf="signupError" class="alert alert-danger">{{ signupError }}</div>
111 117
112 <my-signup-success *ngIf="signupSuccess" [requiresEmailVerification]="requiresEmailVerification"></my-signup-success> 118 <my-signup-success-before-email
119 *ngIf="signupSuccess"
120 [requiresEmailVerification]="requiresEmailVerification" [requiresApproval]="requiresApproval" [instanceName]="instanceName"
121 ></my-signup-success-before-email>
113 122
114 <div *ngIf="signupError" class="steps-button"> 123 <div *ngIf="signupError" class="steps-button">
115 <button cdkStepperPrevious>{{ defaultPreviousStepButtonLabel }}</button> 124 <button cdkStepperPrevious>{{ defaultPreviousStepButtonLabel }}</button>
diff --git a/client/src/app/+signup/+register/register.component.ts b/client/src/app/+signup/+register/register.component.ts
index 958770ebf..9259d902c 100644
--- a/client/src/app/+signup/+register/register.component.ts
+++ b/client/src/app/+signup/+register/register.component.ts
@@ -5,10 +5,10 @@ import { ActivatedRoute } from '@angular/router'
5import { AuthService } from '@app/core' 5import { AuthService } from '@app/core'
6import { HooksService } from '@app/core/plugins/hooks.service' 6import { HooksService } from '@app/core/plugins/hooks.service'
7import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance' 7import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
8import { UserSignupService } from '@app/shared/shared-users'
9import { NgbAccordion } from '@ng-bootstrap/ng-bootstrap' 8import { NgbAccordion } from '@ng-bootstrap/ng-bootstrap'
10import { UserRegister } from '@shared/models' 9import { UserRegister } from '@shared/models'
11import { ServerConfig } from '@shared/models/server' 10import { ServerConfig } from '@shared/models/server'
11import { SignupService } from '../shared/signup.service'
12 12
13@Component({ 13@Component({
14 selector: 'my-register', 14 selector: 'my-register',
@@ -53,7 +53,7 @@ export class RegisterComponent implements OnInit {
53 constructor ( 53 constructor (
54 private route: ActivatedRoute, 54 private route: ActivatedRoute,
55 private authService: AuthService, 55 private authService: AuthService,
56 private userSignupService: UserSignupService, 56 private signupService: SignupService,
57 private hooks: HooksService 57 private hooks: HooksService
58 ) { } 58 ) { }
59 59
@@ -61,6 +61,10 @@ export class RegisterComponent implements OnInit {
61 return this.serverConfig.signup.requiresEmailVerification 61 return this.serverConfig.signup.requiresEmailVerification
62 } 62 }
63 63
64 get requiresApproval () {
65 return this.serverConfig.signup.requiresApproval
66 }
67
64 get minimumAge () { 68 get minimumAge () {
65 return this.serverConfig.signup.minimumAge 69 return this.serverConfig.signup.minimumAge
66 } 70 }
@@ -132,42 +136,49 @@ export class RegisterComponent implements OnInit {
132 skipChannelCreation () { 136 skipChannelCreation () {
133 this.formStepChannel.reset() 137 this.formStepChannel.reset()
134 this.lastStep.select() 138 this.lastStep.select()
139
135 this.signup() 140 this.signup()
136 } 141 }
137 142
138 async signup () { 143 async signup () {
139 this.signupError = undefined 144 this.signupError = undefined
140 145
141 const body: UserRegister = await this.hooks.wrapObject( 146 const termsForm = this.formStepTerms.value
147 const userForm = this.formStepUser.value
148 const channelForm = this.formStepChannel?.value
149
150 const channel = this.formStepChannel?.value?.name
151 ? { name: channelForm?.name, displayName: channelForm?.displayName }
152 : undefined
153
154 const body = await this.hooks.wrapObject(
142 { 155 {
143 ...this.formStepUser.value, 156 username: userForm.username,
157 password: userForm.password,
158 email: userForm.email,
159 displayName: userForm.displayName,
160
161 registrationReason: termsForm.registrationReason,
144 162
145 channel: this.formStepChannel?.value?.name 163 channel
146 ? this.formStepChannel.value
147 : undefined
148 }, 164 },
149 'signup', 165 'signup',
150 'filter:api.signup.registration.create.params' 166 'filter:api.signup.registration.create.params'
151 ) 167 )
152 168
153 this.userSignupService.signup(body).subscribe({ 169 const obs = this.requiresApproval
170 ? this.signupService.requestSignup(body)
171 : this.signupService.directSignup(body)
172
173 obs.subscribe({
154 next: () => { 174 next: () => {
155 if (this.requiresEmailVerification) { 175 if (this.requiresEmailVerification || this.requiresApproval) {
156 this.signupSuccess = true 176 this.signupSuccess = true
157 return 177 return
158 } 178 }
159 179
160 // Auto login 180 // Auto login
161 this.authService.login({ username: body.username, password: body.password }) 181 this.autoLogin(body)
162 .subscribe({
163 next: () => {
164 this.signupSuccess = true
165 },
166
167 error: err => {
168 this.signupError = err.message
169 }
170 })
171 }, 182 },
172 183
173 error: err => { 184 error: err => {
@@ -175,4 +186,17 @@ export class RegisterComponent implements OnInit {
175 } 186 }
176 }) 187 })
177 } 188 }
189
190 private autoLogin (body: UserRegister) {
191 this.authService.login({ username: body.username, password: body.password })
192 .subscribe({
193 next: () => {
194 this.signupSuccess = true
195 },
196
197 error: err => {
198 this.signupError = err.message
199 }
200 })
201 }
178} 202}
diff --git a/client/src/app/+signup/+register/shared/index.ts b/client/src/app/+signup/+register/shared/index.ts
new file mode 100644
index 000000000..affb54bf4
--- /dev/null
+++ b/client/src/app/+signup/+register/shared/index.ts
@@ -0,0 +1 @@
export * from './register-validators'
diff --git a/client/src/app/+signup/+register/shared/register-validators.ts b/client/src/app/+signup/+register/shared/register-validators.ts
new file mode 100644
index 000000000..f14803b68
--- /dev/null
+++ b/client/src/app/+signup/+register/shared/register-validators.ts
@@ -0,0 +1,18 @@
1import { Validators } from '@angular/forms'
2import { BuildFormValidator } from '@app/shared/form-validators'
3
4export const REGISTER_TERMS_VALIDATOR: BuildFormValidator = {
5 VALIDATORS: [ Validators.requiredTrue ],
6 MESSAGES: {
7 required: $localize`You must agree with the instance terms in order to register on it.`
8 }
9}
10
11export const REGISTER_REASON_VALIDATOR: BuildFormValidator = {
12 VALIDATORS: [ Validators.required, Validators.minLength(2), Validators.maxLength(3000) ],
13 MESSAGES: {
14 required: $localize`Registration reason is required.`,
15 minlength: $localize`Registration reason must be at least 2 characters long.`,
16 maxlength: $localize`Registration reason cannot be more than 3000 characters long.`
17 }
18}
diff --git a/client/src/app/+signup/+register/steps/register-step-about.component.html b/client/src/app/+signup/+register/steps/register-step-about.component.html
index 769fe3127..580e8a92c 100644
--- a/client/src/app/+signup/+register/steps/register-step-about.component.html
+++ b/client/src/app/+signup/+register/steps/register-step-about.component.html
@@ -13,6 +13,10 @@
13 <li i18n>Have access to your <strong>watch history</strong></li> 13 <li i18n>Have access to your <strong>watch history</strong></li>
14 <li *ngIf="!videoUploadDisabled" i18n>Create your channel to <strong>publish videos</strong></li> 14 <li *ngIf="!videoUploadDisabled" i18n>Create your channel to <strong>publish videos</strong></li>
15 </ul> 15 </ul>
16
17 <p *ngIf="requiresApproval" i18n>
18 Moderators of {{ instanceName }} will have to approve your registration request once you have finished to fill the form.
19 </p>
16</div> 20</div>
17 21
18<div> 22<div>
diff --git a/client/src/app/+signup/+register/steps/register-step-about.component.ts b/client/src/app/+signup/+register/steps/register-step-about.component.ts
index 9a0941016..b176ffa59 100644
--- a/client/src/app/+signup/+register/steps/register-step-about.component.ts
+++ b/client/src/app/+signup/+register/steps/register-step-about.component.ts
@@ -7,6 +7,7 @@ import { ServerService } from '@app/core'
7 styleUrls: [ './register-step-about.component.scss' ] 7 styleUrls: [ './register-step-about.component.scss' ]
8}) 8})
9export class RegisterStepAboutComponent { 9export class RegisterStepAboutComponent {
10 @Input() requiresApproval: boolean
10 @Input() videoUploadDisabled: boolean 11 @Input() videoUploadDisabled: boolean
11 12
12 constructor (private serverService: ServerService) { 13 constructor (private serverService: ServerService) {
diff --git a/client/src/app/+signup/+register/steps/register-step-channel.component.ts b/client/src/app/+signup/+register/steps/register-step-channel.component.ts
index df92c5145..478ca0177 100644
--- a/client/src/app/+signup/+register/steps/register-step-channel.component.ts
+++ b/client/src/app/+signup/+register/steps/register-step-channel.component.ts
@@ -2,9 +2,9 @@ import { concat, of } from 'rxjs'
2import { pairwise } from 'rxjs/operators' 2import { pairwise } from 'rxjs/operators'
3import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 3import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
4import { FormGroup } from '@angular/forms' 4import { FormGroup } from '@angular/forms'
5import { SignupService } from '@app/+signup/shared/signup.service'
5import { VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, VIDEO_CHANNEL_NAME_VALIDATOR } from '@app/shared/form-validators/video-channel-validators' 6import { VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, VIDEO_CHANNEL_NAME_VALIDATOR } from '@app/shared/form-validators/video-channel-validators'
6import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 7import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
7import { UserSignupService } from '@app/shared/shared-users'
8 8
9@Component({ 9@Component({
10 selector: 'my-register-step-channel', 10 selector: 'my-register-step-channel',
@@ -20,7 +20,7 @@ export class RegisterStepChannelComponent extends FormReactive implements OnInit
20 20
21 constructor ( 21 constructor (
22 protected formReactiveService: FormReactiveService, 22 protected formReactiveService: FormReactiveService,
23 private userSignupService: UserSignupService 23 private signupService: SignupService
24 ) { 24 ) {
25 super() 25 super()
26 } 26 }
@@ -51,7 +51,7 @@ export class RegisterStepChannelComponent extends FormReactive implements OnInit
51 private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) { 51 private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) {
52 const name = this.form.value['name'] || '' 52 const name = this.form.value['name'] || ''
53 53
54 const newName = this.userSignupService.getNewUsername(oldDisplayName, newDisplayName, name) 54 const newName = this.signupService.getNewUsername(oldDisplayName, newDisplayName, name)
55 this.form.patchValue({ name: newName }) 55 this.form.patchValue({ name: newName })
56 } 56 }
57} 57}
diff --git a/client/src/app/+signup/+register/steps/register-step-terms.component.html b/client/src/app/+signup/+register/steps/register-step-terms.component.html
index cbfb32518..1d753a3f2 100644
--- a/client/src/app/+signup/+register/steps/register-step-terms.component.html
+++ b/client/src/app/+signup/+register/steps/register-step-terms.component.html
@@ -1,4 +1,16 @@
1<form role="form" [formGroup]="form"> 1<form role="form" [formGroup]="form">
2
3 <div *ngIf="requiresApproval" class="form-group">
4 <label i18n for="registrationReason">Why do you want to join {{ instanceName }}?</label>
5
6 <textarea
7 id="registrationReason" formControlName="registrationReason" class="form-control" rows="4"
8 [ngClass]="{ 'input-error': formErrors['registrationReason'] }"
9 ></textarea>
10
11 <div *ngIf="formErrors.registrationReason" class="form-error">{{ formErrors.registrationReason }}</div>
12 </div>
13
2 <div class="form-group"> 14 <div class="form-group">
3 <my-peertube-checkbox inputName="terms" formControlName="terms"> 15 <my-peertube-checkbox inputName="terms" formControlName="terms">
4 <ng-template ptTemplate="label"> 16 <ng-template ptTemplate="label">
@@ -6,7 +18,7 @@
6 I am at least {{ minimumAge }} years old and agree 18 I am at least {{ minimumAge }} years old and agree
7 to the <a class="link-orange" (click)="onTermsClick($event)" href='#'>Terms</a> 19 to the <a class="link-orange" (click)="onTermsClick($event)" href='#'>Terms</a>
8 <ng-container *ngIf="hasCodeOfConduct"> and to the <a class="link-orange" (click)="onCodeOfConductClick($event)" href='#'>Code of Conduct</a></ng-container> 20 <ng-container *ngIf="hasCodeOfConduct"> and to the <a class="link-orange" (click)="onCodeOfConductClick($event)" href='#'>Code of Conduct</a></ng-container>
9 of this instance 21 of {{ instanceName }}
10 </ng-container> 22 </ng-container>
11 </ng-template> 23 </ng-template>
12 </my-peertube-checkbox> 24 </my-peertube-checkbox>
diff --git a/client/src/app/+signup/+register/steps/register-step-terms.component.ts b/client/src/app/+signup/+register/steps/register-step-terms.component.ts
index 2df963b30..1b1fb49ee 100644
--- a/client/src/app/+signup/+register/steps/register-step-terms.component.ts
+++ b/client/src/app/+signup/+register/steps/register-step-terms.component.ts
@@ -1,7 +1,7 @@
1import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 1import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
2import { FormGroup } from '@angular/forms' 2import { FormGroup } from '@angular/forms'
3import { USER_TERMS_VALIDATOR } from '@app/shared/form-validators/user-validators'
4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 3import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
4import { REGISTER_REASON_VALIDATOR, REGISTER_TERMS_VALIDATOR } from '../shared'
5 5
6@Component({ 6@Component({
7 selector: 'my-register-step-terms', 7 selector: 'my-register-step-terms',
@@ -10,7 +10,9 @@ import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
10}) 10})
11export class RegisterStepTermsComponent extends FormReactive implements OnInit { 11export class RegisterStepTermsComponent extends FormReactive implements OnInit {
12 @Input() hasCodeOfConduct = false 12 @Input() hasCodeOfConduct = false
13 @Input() requiresApproval: boolean
13 @Input() minimumAge = 16 14 @Input() minimumAge = 16
15 @Input() instanceName: string
14 16
15 @Output() formBuilt = new EventEmitter<FormGroup>() 17 @Output() formBuilt = new EventEmitter<FormGroup>()
16 @Output() termsClick = new EventEmitter<void>() 18 @Output() termsClick = new EventEmitter<void>()
@@ -28,7 +30,11 @@ export class RegisterStepTermsComponent extends FormReactive implements OnInit {
28 30
29 ngOnInit () { 31 ngOnInit () {
30 this.buildForm({ 32 this.buildForm({
31 terms: USER_TERMS_VALIDATOR 33 terms: REGISTER_TERMS_VALIDATOR,
34
35 registrationReason: this.requiresApproval
36 ? REGISTER_REASON_VALIDATOR
37 : null
32 }) 38 })
33 39
34 setTimeout(() => this.formBuilt.emit(this.form)) 40 setTimeout(() => this.formBuilt.emit(this.form))
diff --git a/client/src/app/+signup/+register/steps/register-step-user.component.ts b/client/src/app/+signup/+register/steps/register-step-user.component.ts
index 822f8f5c5..0a5d2e437 100644
--- a/client/src/app/+signup/+register/steps/register-step-user.component.ts
+++ b/client/src/app/+signup/+register/steps/register-step-user.component.ts
@@ -2,6 +2,7 @@ import { concat, of } from 'rxjs'
2import { pairwise } from 'rxjs/operators' 2import { pairwise } from 'rxjs/operators'
3import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 3import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
4import { FormGroup } from '@angular/forms' 4import { FormGroup } from '@angular/forms'
5import { SignupService } from '@app/+signup/shared/signup.service'
5import { 6import {
6 USER_DISPLAY_NAME_REQUIRED_VALIDATOR, 7 USER_DISPLAY_NAME_REQUIRED_VALIDATOR,
7 USER_EMAIL_VALIDATOR, 8 USER_EMAIL_VALIDATOR,
@@ -9,7 +10,6 @@ import {
9 USER_USERNAME_VALIDATOR 10 USER_USERNAME_VALIDATOR
10} from '@app/shared/form-validators/user-validators' 11} from '@app/shared/form-validators/user-validators'
11import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 12import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
12import { UserSignupService } from '@app/shared/shared-users'
13 13
14@Component({ 14@Component({
15 selector: 'my-register-step-user', 15 selector: 'my-register-step-user',
@@ -24,7 +24,7 @@ export class RegisterStepUserComponent extends FormReactive implements OnInit {
24 24
25 constructor ( 25 constructor (
26 protected formReactiveService: FormReactiveService, 26 protected formReactiveService: FormReactiveService,
27 private userSignupService: UserSignupService 27 private signupService: SignupService
28 ) { 28 ) {
29 super() 29 super()
30 } 30 }
@@ -57,7 +57,7 @@ export class RegisterStepUserComponent extends FormReactive implements OnInit {
57 private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) { 57 private onDisplayNameChange (oldDisplayName: string, newDisplayName: string) {
58 const username = this.form.value['username'] || '' 58 const username = this.form.value['username'] || ''
59 59
60 const newUsername = this.userSignupService.getNewUsername(oldDisplayName, newDisplayName, username) 60 const newUsername = this.signupService.getNewUsername(oldDisplayName, newDisplayName, username)
61 this.form.patchValue({ username: newUsername }) 61 this.form.patchValue({ username: newUsername })
62 } 62 }
63} 63}
diff --git a/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts b/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
index 06905f678..75b599e0e 100644
--- a/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
+++ b/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
@@ -1,8 +1,8 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { SignupService } from '@app/+signup/shared/signup.service'
2import { Notifier, RedirectService, ServerService } from '@app/core' 3import { Notifier, RedirectService, ServerService } from '@app/core'
3import { USER_EMAIL_VALIDATOR } from '@app/shared/form-validators/user-validators' 4import { USER_EMAIL_VALIDATOR } from '@app/shared/form-validators/user-validators'
4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms' 5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
5import { UserSignupService } from '@app/shared/shared-users'
6 6
7@Component({ 7@Component({
8 selector: 'my-verify-account-ask-send-email', 8 selector: 'my-verify-account-ask-send-email',
@@ -15,7 +15,7 @@ export class VerifyAccountAskSendEmailComponent extends FormReactive implements
15 15
16 constructor ( 16 constructor (
17 protected formReactiveService: FormReactiveService, 17 protected formReactiveService: FormReactiveService,
18 private userSignupService: UserSignupService, 18 private signupService: SignupService,
19 private serverService: ServerService, 19 private serverService: ServerService,
20 private notifier: Notifier, 20 private notifier: Notifier,
21 private redirectService: RedirectService 21 private redirectService: RedirectService
@@ -34,7 +34,7 @@ export class VerifyAccountAskSendEmailComponent extends FormReactive implements
34 34
35 askSendVerifyEmail () { 35 askSendVerifyEmail () {
36 const email = this.form.value['verify-email-email'] 36 const email = this.form.value['verify-email-email']
37 this.userSignupService.askSendVerifyEmail(email) 37 this.signupService.askSendVerifyEmail(email)
38 .subscribe({ 38 .subscribe({
39 next: () => { 39 next: () => {
40 this.notifier.success($localize`An email with verification link will be sent to ${email}.`) 40 this.notifier.success($localize`An email with verification link will be sent to ${email}.`)
diff --git a/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html b/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html
index 122f3c28c..8c8b1098e 100644
--- a/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html
+++ b/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.html
@@ -1,14 +1,19 @@
1<div class="margin-content"> 1<div *ngIf="loaded" class="margin-content">
2 <h1 i18n class="title-page">Verify account email confirmation</h1> 2 <h1 i18n class="title-page">Verify email</h1>
3 3
4 <my-signup-success i18n *ngIf="!isPendingEmail && success" [requiresEmailVerification]="false"> 4 <my-signup-success-after-email
5 </my-signup-success> 5 *ngIf="displaySignupSuccess()"
6 [requiresApproval]="isRegistrationRequest() && requiresApproval"
7 >
8 </my-signup-success-after-email>
6 9
7 <div i18n class="alert alert-success" *ngIf="isPendingEmail && success">Email updated.</div> 10 <div i18n class="alert alert-success" *ngIf="!isRegistrationRequest() && isPendingEmail && success">Email updated.</div>
8 11
9 <div class="alert alert-danger" *ngIf="failed"> 12 <div class="alert alert-danger" *ngIf="failed">
10 <span i18n>An error occurred.</span> 13 <span i18n>An error occurred.</span>
11 14
12 <a i18n class="ms-1 link-orange" routerLink="/verify-account/ask-send-email" [queryParams]="{ isPendingEmail: isPendingEmail }">Request new verification email</a> 15 <a i18n class="ms-1 link-orange" routerLink="/verify-account/ask-send-email">
16 Request a new verification email
17 </a>
13 </div> 18 </div>
14</div> 19</div>
diff --git a/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts b/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts
index 88efce4a1..faf663391 100644
--- a/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts
+++ b/client/src/app/+signup/+verify-account/verify-account-email/verify-account-email.component.ts
@@ -1,7 +1,7 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { ActivatedRoute } from '@angular/router' 2import { ActivatedRoute } from '@angular/router'
3import { AuthService, Notifier } from '@app/core' 3import { SignupService } from '@app/+signup/shared/signup.service'
4import { UserSignupService } from '@app/shared/shared-users' 4import { AuthService, Notifier, ServerService } from '@app/core'
5 5
6@Component({ 6@Component({
7 selector: 'my-verify-account-email', 7 selector: 'my-verify-account-email',
@@ -13,32 +13,82 @@ export class VerifyAccountEmailComponent implements OnInit {
13 failed = false 13 failed = false
14 isPendingEmail = false 14 isPendingEmail = false
15 15
16 requiresApproval: boolean
17 loaded = false
18
16 private userId: number 19 private userId: number
20 private registrationId: number
17 private verificationString: string 21 private verificationString: string
18 22
19 constructor ( 23 constructor (
20 private userSignupService: UserSignupService, 24 private signupService: SignupService,
25 private server: ServerService,
21 private authService: AuthService, 26 private authService: AuthService,
22 private notifier: Notifier, 27 private notifier: Notifier,
23 private route: ActivatedRoute 28 private route: ActivatedRoute
24 ) { 29 ) {
25 } 30 }
26 31
32 get instanceName () {
33 return this.server.getHTMLConfig().instance.name
34 }
35
27 ngOnInit () { 36 ngOnInit () {
28 const queryParams = this.route.snapshot.queryParams 37 const queryParams = this.route.snapshot.queryParams
38
39 this.server.getConfig().subscribe(config => {
40 this.requiresApproval = config.signup.requiresApproval
41
42 this.loaded = true
43 })
44
29 this.userId = queryParams['userId'] 45 this.userId = queryParams['userId']
46 this.registrationId = queryParams['registrationId']
47
30 this.verificationString = queryParams['verificationString'] 48 this.verificationString = queryParams['verificationString']
49
31 this.isPendingEmail = queryParams['isPendingEmail'] === 'true' 50 this.isPendingEmail = queryParams['isPendingEmail'] === 'true'
32 51
33 if (!this.userId || !this.verificationString) { 52 if (!this.verificationString) {
34 this.notifier.error($localize`Unable to find user id or verification string.`) 53 this.notifier.error($localize`Unable to find verification string in URL query.`)
35 } else { 54 return
36 this.verifyEmail() 55 }
56
57 if (!this.userId && !this.registrationId) {
58 this.notifier.error($localize`Unable to find user id or registration id in URL query.`)
59 return
37 } 60 }
61
62 this.verifyEmail()
63 }
64
65 isRegistrationRequest () {
66 return !!this.registrationId
67 }
68
69 displaySignupSuccess () {
70 if (!this.success) return false
71 if (!this.isRegistrationRequest() && this.isPendingEmail) return false
72
73 return true
38 } 74 }
39 75
40 verifyEmail () { 76 verifyEmail () {
41 this.userSignupService.verifyEmail(this.userId, this.verificationString, this.isPendingEmail) 77 if (this.isRegistrationRequest()) {
78 return this.verifyRegistrationEmail()
79 }
80
81 return this.verifyUserEmail()
82 }
83
84 private verifyUserEmail () {
85 const options = {
86 userId: this.userId,
87 verificationString: this.verificationString,
88 isPendingEmail: this.isPendingEmail
89 }
90
91 this.signupService.verifyUserEmail(options)
42 .subscribe({ 92 .subscribe({
43 next: () => { 93 next: () => {
44 if (this.authService.isLoggedIn()) { 94 if (this.authService.isLoggedIn()) {
@@ -55,4 +105,24 @@ export class VerifyAccountEmailComponent implements OnInit {
55 } 105 }
56 }) 106 })
57 } 107 }
108
109 private verifyRegistrationEmail () {
110 const options = {
111 registrationId: this.registrationId,
112 verificationString: this.verificationString
113 }
114
115 this.signupService.verifyRegistrationEmail(options)
116 .subscribe({
117 next: () => {
118 this.success = true
119 },
120
121 error: err => {
122 this.failed = true
123
124 this.notifier.error(err.message)
125 }
126 })
127 }
58} 128}
diff --git a/client/src/app/+signup/shared/shared-signup.module.ts b/client/src/app/+signup/shared/shared-signup.module.ts
index 0aa08f3e2..0600f0af8 100644
--- a/client/src/app/+signup/shared/shared-signup.module.ts
+++ b/client/src/app/+signup/shared/shared-signup.module.ts
@@ -5,7 +5,9 @@ import { SharedMainModule } from '@app/shared/shared-main'
5import { SharedUsersModule } from '@app/shared/shared-users' 5import { SharedUsersModule } from '@app/shared/shared-users'
6import { SignupMascotComponent } from './signup-mascot.component' 6import { SignupMascotComponent } from './signup-mascot.component'
7import { SignupStepTitleComponent } from './signup-step-title.component' 7import { SignupStepTitleComponent } from './signup-step-title.component'
8import { SignupSuccessComponent } from './signup-success.component' 8import { SignupSuccessBeforeEmailComponent } from './signup-success-before-email.component'
9import { SignupSuccessAfterEmailComponent } from './signup-success-after-email.component'
10import { SignupService } from './signup.service'
9 11
10@NgModule({ 12@NgModule({
11 imports: [ 13 imports: [
@@ -16,7 +18,8 @@ import { SignupSuccessComponent } from './signup-success.component'
16 ], 18 ],
17 19
18 declarations: [ 20 declarations: [
19 SignupSuccessComponent, 21 SignupSuccessBeforeEmailComponent,
22 SignupSuccessAfterEmailComponent,
20 SignupStepTitleComponent, 23 SignupStepTitleComponent,
21 SignupMascotComponent 24 SignupMascotComponent
22 ], 25 ],
@@ -26,12 +29,14 @@ import { SignupSuccessComponent } from './signup-success.component'
26 SharedFormModule, 29 SharedFormModule,
27 SharedGlobalIconModule, 30 SharedGlobalIconModule,
28 31
29 SignupSuccessComponent, 32 SignupSuccessBeforeEmailComponent,
33 SignupSuccessAfterEmailComponent,
30 SignupStepTitleComponent, 34 SignupStepTitleComponent,
31 SignupMascotComponent 35 SignupMascotComponent
32 ], 36 ],
33 37
34 providers: [ 38 providers: [
39 SignupService
35 ] 40 ]
36}) 41})
37export class SharedSignupModule { } 42export class SharedSignupModule { }
diff --git a/client/src/app/+signup/shared/signup-success-after-email.component.html b/client/src/app/+signup/shared/signup-success-after-email.component.html
new file mode 100644
index 000000000..1c3536ada
--- /dev/null
+++ b/client/src/app/+signup/shared/signup-success-after-email.component.html
@@ -0,0 +1,21 @@
1<my-signup-step-title mascotImageName="success">
2 <strong i18n>Email verified!</strong>
3</my-signup-step-title>
4
5<div class="alert pt-alert-primary">
6 <ng-container *ngIf="requiresApproval">
7 <p i18n>Your email has been verified and your account request has been sent!</p>
8
9 <p i18n>
10 A moderator will check your registration request soon and you'll receive an email when it will be accepted or rejected.
11 </p>
12 </ng-container>
13
14 <ng-container *ngIf="!requiresApproval">
15 <p i18n>Your email has been verified and your account has been created!</p>
16
17 <p i18n>
18 If you need help to use PeerTube, you can have a look at the <a class="link-orange" href="https://docs.joinpeertube.org/use-setup-account" target="_blank" rel="noopener noreferrer">documentation</a>.
19 </p>
20 </ng-container>
21</div>
diff --git a/client/src/app/+signup/shared/signup-success-after-email.component.ts b/client/src/app/+signup/shared/signup-success-after-email.component.ts
new file mode 100644
index 000000000..3d72fdae9
--- /dev/null
+++ b/client/src/app/+signup/shared/signup-success-after-email.component.ts
@@ -0,0 +1,10 @@
1import { Component, Input } from '@angular/core'
2
3@Component({
4 selector: 'my-signup-success-after-email',
5 templateUrl: './signup-success-after-email.component.html',
6 styleUrls: [ './signup-success.component.scss' ]
7})
8export class SignupSuccessAfterEmailComponent {
9 @Input() requiresApproval: boolean
10}
diff --git a/client/src/app/+signup/shared/signup-success-before-email.component.html b/client/src/app/+signup/shared/signup-success-before-email.component.html
new file mode 100644
index 000000000..b9668ee82
--- /dev/null
+++ b/client/src/app/+signup/shared/signup-success-before-email.component.html
@@ -0,0 +1,35 @@
1<my-signup-step-title mascotImageName="success">
2 <ng-container *ngIf="requiresApproval">
3 <strong i18n>Account request sent</strong>
4 </ng-container>
5
6 <ng-container *ngIf="!requiresApproval" i18n>
7 <strong>Welcome</strong>
8 <div>on {{ instanceName }}</div>
9 </ng-container>
10</my-signup-step-title>
11
12<div class="alert pt-alert-primary">
13 <p *ngIf="requiresApproval" i18n>Your account request has been sent!</p>
14 <p *ngIf="!requiresApproval" i18n>Your account has been created!</p>
15
16 <ng-container *ngIf="requiresEmailVerification">
17 <p i18n *ngIf="requiresApproval">
18 <strong>Check your emails</strong> to validate your account and complete your registration request.
19 </p>
20
21 <p i18n *ngIf="!requiresApproval">
22 <strong>Check your emails</strong> to validate your account and complete your registration.
23 </p>
24 </ng-container>
25
26 <ng-container *ngIf="!requiresEmailVerification">
27 <p i18n *ngIf="requiresApproval">
28 A moderator will check your registration request soon and you'll receive an email when it will be accepted or rejected.
29 </p>
30
31 <p *ngIf="!requiresApproval" i18n>
32 If you need help to use PeerTube, you can have a look at the <a class="link-orange" href="https://docs.joinpeertube.org/use-setup-account" target="_blank" rel="noopener noreferrer">documentation</a>.
33 </p>
34 </ng-container>
35</div>
diff --git a/client/src/app/+signup/shared/signup-success-before-email.component.ts b/client/src/app/+signup/shared/signup-success-before-email.component.ts
new file mode 100644
index 000000000..d72462340
--- /dev/null
+++ b/client/src/app/+signup/shared/signup-success-before-email.component.ts
@@ -0,0 +1,12 @@
1import { Component, Input } from '@angular/core'
2
3@Component({
4 selector: 'my-signup-success-before-email',
5 templateUrl: './signup-success-before-email.component.html',
6 styleUrls: [ './signup-success.component.scss' ]
7})
8export class SignupSuccessBeforeEmailComponent {
9 @Input() requiresApproval: boolean
10 @Input() requiresEmailVerification: boolean
11 @Input() instanceName: string
12}
diff --git a/client/src/app/+signup/shared/signup-success.component.html b/client/src/app/+signup/shared/signup-success.component.html
deleted file mode 100644
index c14889c72..000000000
--- a/client/src/app/+signup/shared/signup-success.component.html
+++ /dev/null
@@ -1,22 +0,0 @@
1<my-signup-step-title mascotImageName="success" i18n>
2 <strong>Welcome</strong>
3 <div>on {{ instanceName }}</div>
4</my-signup-step-title>
5
6<div class="alert pt-alert-primary">
7 <p i18n>Your account has been created!</p>
8
9 <p i18n *ngIf="requiresEmailVerification">
10 <strong>Check your emails</strong> to validate your account and complete your inscription.
11 </p>
12
13 <ng-container *ngIf="!requiresEmailVerification">
14 <p i18n>
15 If you need help to use PeerTube, you can have a look at the <a class="link-orange" href="https://docs.joinpeertube.org/use-setup-account" target="_blank" rel="noopener noreferrer">documentation</a>.
16 </p>
17
18 <p i18n>
19 To help moderators and other users to know <strong>who you are</strong>, don't forget to <a class="link-orange" routerLink="/my-account/settings">set up your account profile</a> by adding an <strong>avatar</strong> and a <strong>description</strong>.
20 </p>
21 </ng-container>
22</div>
diff --git a/client/src/app/+signup/shared/signup-success.component.ts b/client/src/app/+signup/shared/signup-success.component.ts
deleted file mode 100644
index a03f3819d..000000000
--- a/client/src/app/+signup/shared/signup-success.component.ts
+++ /dev/null
@@ -1,19 +0,0 @@
1import { Component, Input } from '@angular/core'
2import { ServerService } from '@app/core'
3
4@Component({
5 selector: 'my-signup-success',
6 templateUrl: './signup-success.component.html',
7 styleUrls: [ './signup-success.component.scss' ]
8})
9export class SignupSuccessComponent {
10 @Input() requiresEmailVerification: boolean
11
12 constructor (private serverService: ServerService) {
13
14 }
15
16 get instanceName () {
17 return this.serverService.getHTMLConfig().instance.name
18 }
19}
diff --git a/client/src/app/shared/shared-users/user-signup.service.ts b/client/src/app/+signup/shared/signup.service.ts
index 46fe34af1..f647298be 100644
--- a/client/src/app/shared/shared-users/user-signup.service.ts
+++ b/client/src/app/+signup/shared/signup.service.ts
@@ -2,17 +2,18 @@ import { catchError, tap } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http' 2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core' 3import { Injectable } from '@angular/core'
4import { RestExtractor, UserService } from '@app/core' 4import { RestExtractor, UserService } from '@app/core'
5import { UserRegister } from '@shared/models' 5import { UserRegister, UserRegistrationRequest } from '@shared/models'
6 6
7@Injectable() 7@Injectable()
8export class UserSignupService { 8export class SignupService {
9
9 constructor ( 10 constructor (
10 private authHttp: HttpClient, 11 private authHttp: HttpClient,
11 private restExtractor: RestExtractor, 12 private restExtractor: RestExtractor,
12 private userService: UserService 13 private userService: UserService
13 ) { } 14 ) { }
14 15
15 signup (userCreate: UserRegister) { 16 directSignup (userCreate: UserRegister) {
16 return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate) 17 return this.authHttp.post(UserService.BASE_USERS_URL + 'register', userCreate)
17 .pipe( 18 .pipe(
18 tap(() => this.userService.setSignupInThisSession(true)), 19 tap(() => this.userService.setSignupInThisSession(true)),
@@ -20,8 +21,21 @@ export class UserSignupService {
20 ) 21 )
21 } 22 }
22 23
23 verifyEmail (userId: number, verificationString: string, isPendingEmail: boolean) { 24 requestSignup (userCreate: UserRegistrationRequest) {
24 const url = `${UserService.BASE_USERS_URL}/${userId}/verify-email` 25 return this.authHttp.post(UserService.BASE_USERS_URL + 'registrations/request', userCreate)
26 .pipe(catchError(err => this.restExtractor.handleError(err)))
27 }
28
29 // ---------------------------------------------------------------------------
30
31 verifyUserEmail (options: {
32 userId: number
33 verificationString: string
34 isPendingEmail: boolean
35 }) {
36 const { userId, verificationString, isPendingEmail } = options
37
38 const url = `${UserService.BASE_USERS_URL}${userId}/verify-email`
25 const body = { 39 const body = {
26 verificationString, 40 verificationString,
27 isPendingEmail 41 isPendingEmail
@@ -31,13 +45,28 @@ export class UserSignupService {
31 .pipe(catchError(res => this.restExtractor.handleError(res))) 45 .pipe(catchError(res => this.restExtractor.handleError(res)))
32 } 46 }
33 47
48 verifyRegistrationEmail (options: {
49 registrationId: number
50 verificationString: string
51 }) {
52 const { registrationId, verificationString } = options
53
54 const url = `${UserService.BASE_USERS_URL}registrations/${registrationId}/verify-email`
55 const body = { verificationString }
56
57 return this.authHttp.post(url, body)
58 .pipe(catchError(res => this.restExtractor.handleError(res)))
59 }
60
34 askSendVerifyEmail (email: string) { 61 askSendVerifyEmail (email: string) {
35 const url = UserService.BASE_USERS_URL + '/ask-send-verify-email' 62 const url = UserService.BASE_USERS_URL + 'ask-send-verify-email'
36 63
37 return this.authHttp.post(url, { email }) 64 return this.authHttp.post(url, { email })
38 .pipe(catchError(err => this.restExtractor.handleError(err))) 65 .pipe(catchError(err => this.restExtractor.handleError(err)))
39 } 66 }
40 67
68 // ---------------------------------------------------------------------------
69
41 getNewUsername (oldDisplayName: string, newDisplayName: string, currentUsername: string) { 70 getNewUsername (oldDisplayName: string, newDisplayName: string, currentUsername: string) {
42 // Don't update display name, the user seems to have changed it 71 // Don't update display name, the user seems to have changed it
43 if (this.displayNameToUsername(oldDisplayName) !== currentUsername) return currentUsername 72 if (this.displayNameToUsername(oldDisplayName) !== currentUsername) return currentUsername
diff --git a/client/src/app/+videos/+video-watch/video-watch.component.ts b/client/src/app/+videos/+video-watch/video-watch.component.ts
index 94853423b..84548de97 100644
--- a/client/src/app/+videos/+video-watch/video-watch.component.ts
+++ b/client/src/app/+videos/+video-watch/video-watch.component.ts
@@ -133,8 +133,6 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
133 this.loadRouteParams() 133 this.loadRouteParams()
134 this.loadRouteQuery() 134 this.loadRouteQuery()
135 135
136 this.initHotkeys()
137
138 this.theaterEnabled = getStoredTheater() 136 this.theaterEnabled = getStoredTheater()
139 137
140 this.hooks.runAction('action:video-watch.init', 'video-watch') 138 this.hooks.runAction('action:video-watch.init', 'video-watch')
@@ -295,6 +293,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
295 subtitle: queryParams.subtitle, 293 subtitle: queryParams.subtitle,
296 294
297 playerMode: queryParams.mode, 295 playerMode: queryParams.mode,
296 playbackRate: queryParams.playbackRate,
298 peertubeLink: false 297 peertubeLink: false
299 } 298 }
300 299
@@ -406,6 +405,8 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
406 if (res === false) return this.location.back() 405 if (res === false) return this.location.back()
407 } 406 }
408 407
408 this.buildHotkeysHelp(video)
409
409 this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay }) 410 this.buildPlayer({ urlOptions, loggedInOrAnonymousUser, forceAutoplay })
410 .catch(err => logger.error('Cannot build the player', err)) 411 .catch(err => logger.error('Cannot build the player', err))
411 412
@@ -657,6 +658,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
657 muted: urlOptions.muted, 658 muted: urlOptions.muted,
658 loop: urlOptions.loop, 659 loop: urlOptions.loop,
659 subtitle: urlOptions.subtitle, 660 subtitle: urlOptions.subtitle,
661 playbackRate: urlOptions.playbackRate,
660 662
661 peertubeLink: urlOptions.peertubeLink, 663 peertubeLink: urlOptions.peertubeLink,
662 664
@@ -785,33 +787,43 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
785 this.video.viewers = newViewers 787 this.video.viewers = newViewers
786 } 788 }
787 789
788 private initHotkeys () { 790 private buildHotkeysHelp (video: Video) {
791 if (this.hotkeys.length !== 0) {
792 this.hotkeysService.remove(this.hotkeys)
793 }
794
789 this.hotkeys = [ 795 this.hotkeys = [
790 // These hotkeys are managed by the player 796 // These hotkeys are managed by the player
791 new Hotkey('f', e => e, undefined, $localize`Enter/exit fullscreen`), 797 new Hotkey('f', e => e, undefined, $localize`Enter/exit fullscreen`),
792 new Hotkey('space', e => e, undefined, $localize`Play/Pause the video`), 798 new Hotkey('space', e => e, undefined, $localize`Play/Pause the video`),
793 new Hotkey('m', e => e, undefined, $localize`Mute/unmute the video`), 799 new Hotkey('m', e => e, undefined, $localize`Mute/unmute the video`),
794 800
795 new Hotkey('0-9', e => e, undefined, $localize`Skip to a percentage of the video: 0 is 0% and 9 is 90%`),
796
797 new Hotkey('up', e => e, undefined, $localize`Increase the volume`), 801 new Hotkey('up', e => e, undefined, $localize`Increase the volume`),
798 new Hotkey('down', e => e, undefined, $localize`Decrease the volume`), 802 new Hotkey('down', e => e, undefined, $localize`Decrease the volume`),
799 803
800 new Hotkey('right', e => e, undefined, $localize`Seek the video forward`),
801 new Hotkey('left', e => e, undefined, $localize`Seek the video backward`),
802
803 new Hotkey('>', e => e, undefined, $localize`Increase playback rate`),
804 new Hotkey('<', e => e, undefined, $localize`Decrease playback rate`),
805
806 new Hotkey(',', e => e, undefined, $localize`Navigate in the video to the previous frame`),
807 new Hotkey('.', e => e, undefined, $localize`Navigate in the video to the next frame`),
808
809 new Hotkey('t', e => { 804 new Hotkey('t', e => {
810 this.theaterEnabled = !this.theaterEnabled 805 this.theaterEnabled = !this.theaterEnabled
811 return false 806 return false
812 }, undefined, $localize`Toggle theater mode`) 807 }, undefined, $localize`Toggle theater mode`)
813 ] 808 ]
814 809
810 if (!video.isLive) {
811 this.hotkeys = this.hotkeys.concat([
812 // These hotkeys are also managed by the player but only for VOD
813
814 new Hotkey('0-9', e => e, undefined, $localize`Skip to a percentage of the video: 0 is 0% and 9 is 90%`),
815
816 new Hotkey('right', e => e, undefined, $localize`Seek the video forward`),
817 new Hotkey('left', e => e, undefined, $localize`Seek the video backward`),
818
819 new Hotkey('>', e => e, undefined, $localize`Increase playback rate`),
820 new Hotkey('<', e => e, undefined, $localize`Decrease playback rate`),
821
822 new Hotkey(',', e => e, undefined, $localize`Navigate in the video to the previous frame`),
823 new Hotkey('.', e => e, undefined, $localize`Navigate in the video to the next frame`)
824 ])
825 }
826
815 if (this.isUserLoggedIn()) { 827 if (this.isUserLoggedIn()) {
816 this.hotkeys = this.hotkeys.concat([ 828 this.hotkeys = this.hotkeys.concat([
817 new Hotkey('shift+s', () => { 829 new Hotkey('shift+s', () => {
diff --git a/client/src/app/+videos/video-list/videos-list-common-page.component.ts b/client/src/app/+videos/video-list/videos-list-common-page.component.ts
index c8fa8ef30..bafe30fd7 100644
--- a/client/src/app/+videos/video-list/videos-list-common-page.component.ts
+++ b/client/src/app/+videos/video-list/videos-list-common-page.component.ts
@@ -177,6 +177,9 @@ export class VideosListCommonPageComponent implements OnInit, OnDestroy, Disable
177 case 'best': 177 case 'best':
178 return '-hot' 178 return '-hot'
179 179
180 case 'name':
181 return 'name'
182
180 default: 183 default:
181 return '-' + algorithm as VideoSortField 184 return '-' + algorithm as VideoSortField
182 } 185 }
diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts
index 4de28e51e..ed7eabb76 100644
--- a/client/src/app/core/auth/auth.service.ts
+++ b/client/src/app/core/auth/auth.service.ts
@@ -5,10 +5,11 @@ import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular
5import { Injectable } from '@angular/core' 5import { Injectable } from '@angular/core'
6import { Router } from '@angular/router' 6import { Router } from '@angular/router'
7import { Notifier } from '@app/core/notification/notifier.service' 7import { Notifier } from '@app/core/notification/notifier.service'
8import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage } from '@root-helpers/index' 8import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage, PluginsManager } from '@root-helpers/index'
9import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models' 9import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models'
10import { environment } from '../../../environments/environment' 10import { environment } from '../../../environments/environment'
11import { RestExtractor } from '../rest/rest-extractor.service' 11import { RestExtractor } from '../rest/rest-extractor.service'
12import { ServerService } from '../server'
12import { AuthStatus } from './auth-status.model' 13import { AuthStatus } from './auth-status.model'
13import { AuthUser } from './auth-user.model' 14import { AuthUser } from './auth-user.model'
14 15
@@ -44,6 +45,7 @@ export class AuthService {
44 private refreshingTokenObservable: Observable<any> 45 private refreshingTokenObservable: Observable<any>
45 46
46 constructor ( 47 constructor (
48 private serverService: ServerService,
47 private http: HttpClient, 49 private http: HttpClient,
48 private notifier: Notifier, 50 private notifier: Notifier,
49 private hotkeysService: HotkeysService, 51 private hotkeysService: HotkeysService,
@@ -213,25 +215,28 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
213 const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded') 215 const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
214 216
215 this.refreshingTokenObservable = this.http.post<UserRefreshToken>(AuthService.BASE_TOKEN_URL, body, { headers }) 217 this.refreshingTokenObservable = this.http.post<UserRefreshToken>(AuthService.BASE_TOKEN_URL, body, { headers })
216 .pipe( 218 .pipe(
217 map(res => this.handleRefreshToken(res)), 219 map(res => this.handleRefreshToken(res)),
218 tap(() => { 220 tap(() => {
219 this.refreshingTokenObservable = null 221 this.refreshingTokenObservable = null
220 }), 222 }),
221 catchError(err => { 223 catchError(err => {
222 this.refreshingTokenObservable = null 224 this.refreshingTokenObservable = null
223 225
224 logger.error(err) 226 logger.error(err)
225 logger.info('Cannot refresh token -> logout...') 227 logger.info('Cannot refresh token -> logout...')
226 this.logout() 228 this.logout()
227 this.router.navigate([ '/login' ]) 229
228 230 const externalLoginUrl = PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverService.getHTMLConfig())
229 return observableThrowError(() => ({ 231 if (externalLoginUrl) window.location.href = externalLoginUrl
230 error: $localize`You need to reconnect.` 232 else this.router.navigate([ '/login' ])
231 })) 233
232 }), 234 return observableThrowError(() => ({
233 share() 235 error: $localize`You need to reconnect.`
234 ) 236 }))
237 }),
238 share()
239 )
235 240
236 return this.refreshingTokenObservable 241 return this.refreshingTokenObservable
237 } 242 }
diff --git a/client/src/app/core/renderer/linkifier.service.ts b/client/src/app/core/renderer/linkifier.service.ts
index 78df92cc9..d99591d6c 100644
--- a/client/src/app/core/renderer/linkifier.service.ts
+++ b/client/src/app/core/renderer/linkifier.service.ts
@@ -15,7 +15,7 @@ export class LinkifierService {
15 }, 15 },
16 formatHref: { 16 formatHref: {
17 mention: (href: string) => { 17 mention: (href: string) => {
18 return getAbsoluteAPIUrl() + '/services/redirect/accounts/' + href.substr(1) 18 return getAbsoluteAPIUrl() + '/services/redirect/accounts/' + href.substring(1)
19 } 19 }
20 } 20 }
21 } 21 }
diff --git a/client/src/app/core/renderer/markdown.service.ts b/client/src/app/core/renderer/markdown.service.ts
index a5fd72862..dd23a1b01 100644
--- a/client/src/app/core/renderer/markdown.service.ts
+++ b/client/src/app/core/renderer/markdown.service.ts
@@ -64,8 +64,8 @@ export class MarkdownService {
64 64
65 textMarkdownToHTML (options: { 65 textMarkdownToHTML (options: {
66 markdown: string 66 markdown: string
67 withHtml?: boolean 67 withHtml?: boolean // default false
68 withEmoji?: boolean 68 withEmoji?: boolean // default false
69 }) { 69 }) {
70 const { markdown, withHtml = false, withEmoji = false } = options 70 const { markdown, withHtml = false, withEmoji = false } = options
71 71
@@ -76,8 +76,8 @@ export class MarkdownService {
76 76
77 enhancedMarkdownToHTML (options: { 77 enhancedMarkdownToHTML (options: {
78 markdown: string 78 markdown: string
79 withHtml?: boolean 79 withHtml?: boolean // default false
80 withEmoji?: boolean 80 withEmoji?: boolean // default false
81 }) { 81 }) {
82 const { markdown, withHtml = false, withEmoji = false } = options 82 const { markdown, withHtml = false, withEmoji = false } = options
83 83
@@ -99,6 +99,8 @@ export class MarkdownService {
99 return this.render({ name: 'customPageMarkdownIt', markdown, withEmoji: true, additionalAllowedTags }) 99 return this.render({ name: 'customPageMarkdownIt', markdown, withEmoji: true, additionalAllowedTags })
100 } 100 }
101 101
102 // ---------------------------------------------------------------------------
103
102 processVideoTimestamps (videoShortUUID: string, html: string) { 104 processVideoTimestamps (videoShortUUID: string, html: string) {
103 return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) { 105 return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
104 const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0)) 106 const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
diff --git a/client/src/app/core/rest/rest-extractor.service.ts b/client/src/app/core/rest/rest-extractor.service.ts
index de3f2bfff..daed7f178 100644
--- a/client/src/app/core/rest/rest-extractor.service.ts
+++ b/client/src/app/core/rest/rest-extractor.service.ts
@@ -87,7 +87,11 @@ export class RestExtractor {
87 87
88 if (err.status !== undefined) { 88 if (err.status !== undefined) {
89 const errorMessage = this.buildServerErrorMessage(err) 89 const errorMessage = this.buildServerErrorMessage(err)
90 logger.error(`Backend returned code ${err.status}, errorMessage is: ${errorMessage}`) 90
91 const message = `Backend returned code ${err.status}, errorMessage is: ${errorMessage}`
92
93 if (err.status === HttpStatusCode.NOT_FOUND_404) logger.clientError(message)
94 else logger.error(message)
91 95
92 return errorMessage 96 return errorMessage
93 } 97 }
diff --git a/client/src/app/core/rest/rest-table.ts b/client/src/app/core/rest/rest-table.ts
index ec5646b5d..707110d7f 100644
--- a/client/src/app/core/rest/rest-table.ts
+++ b/client/src/app/core/rest/rest-table.ts
@@ -7,7 +7,7 @@ import { RestPagination } from './rest-pagination'
7 7
8const debugLogger = debug('peertube:tables:RestTable') 8const debugLogger = debug('peertube:tables:RestTable')
9 9
10export abstract class RestTable { 10export abstract class RestTable <T = unknown> {
11 11
12 abstract totalRecords: number 12 abstract totalRecords: number
13 abstract sort: SortMeta 13 abstract sort: SortMeta
@@ -17,6 +17,8 @@ export abstract class RestTable {
17 rowsPerPage = this.rowsPerPageOptions[0] 17 rowsPerPage = this.rowsPerPageOptions[0]
18 expandedRows = {} 18 expandedRows = {}
19 19
20 selectedRows: T[] = []
21
20 search: string 22 search: string
21 23
22 protected route: ActivatedRoute 24 protected route: ActivatedRoute
@@ -75,7 +77,17 @@ export abstract class RestTable {
75 this.reloadData() 77 this.reloadData()
76 } 78 }
77 79
78 protected abstract reloadData (): void 80 isInSelectionMode () {
81 return this.selectedRows.length !== 0
82 }
83
84 protected abstract reloadDataInternal (): void
85
86 protected reloadData () {
87 this.selectedRows = []
88
89 this.reloadDataInternal()
90 }
79 91
80 private getSortLocalStorageKey () { 92 private getSortLocalStorageKey () {
81 return 'rest-table-sort-' + this.getIdentifier() 93 return 'rest-table-sort-' + this.getIdentifier()
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html
index c5d08ab75..15b1a3c4a 100644
--- a/client/src/app/menu/menu.component.html
+++ b/client/src/app/menu/menu.component.html
@@ -103,7 +103,9 @@
103 <a i18n *ngIf="!getExternalLoginHref()" routerLink="/login" class="peertube-button-link orange-button">Login</a> 103 <a i18n *ngIf="!getExternalLoginHref()" routerLink="/login" class="peertube-button-link orange-button">Login</a>
104 <a i18n *ngIf="getExternalLoginHref()" [href]="getExternalLoginHref()" class="peertube-button-link orange-button">Login</a> 104 <a i18n *ngIf="getExternalLoginHref()" [href]="getExternalLoginHref()" class="peertube-button-link orange-button">Login</a>
105 105
106 <a i18n *ngIf="isRegistrationAllowed()" routerLink="/signup" class="peertube-button-link create-account-button">Create an account</a> 106 <a *ngIf="isRegistrationAllowed()" routerLink="/signup" class="peertube-button-link create-account-button">
107 <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
108 </a>
107 </div> 109 </div>
108 110
109 <ng-container *ngFor="let menuSection of menuSections" > 111 <ng-container *ngFor="let menuSection of menuSections" >
diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts
index 63f01df92..fc6d74cff 100644
--- a/client/src/app/menu/menu.component.ts
+++ b/client/src/app/menu/menu.component.ts
@@ -1,6 +1,7 @@
1import { HotkeysService } from 'angular2-hotkeys' 1import { HotkeysService } from 'angular2-hotkeys'
2import * as debug from 'debug' 2import * as debug from 'debug'
3import { switchMap } from 'rxjs/operators' 3import { switchMap } from 'rxjs/operators'
4import { environment } from 'src/environments/environment'
4import { ViewportScroller } from '@angular/common' 5import { ViewportScroller } from '@angular/common'
5import { Component, OnInit, ViewChild } from '@angular/core' 6import { Component, OnInit, ViewChild } from '@angular/core'
6import { Router } from '@angular/router' 7import { Router } from '@angular/router'
@@ -91,6 +92,10 @@ export class MenuComponent implements OnInit {
91 return this.languageChooserModal.getCurrentLanguage() 92 return this.languageChooserModal.getCurrentLanguage()
92 } 93 }
93 94
95 get requiresApproval () {
96 return this.serverConfig.signup.requiresApproval
97 }
98
94 ngOnInit () { 99 ngOnInit () {
95 this.htmlServerConfig = this.serverService.getHTMLConfig() 100 this.htmlServerConfig = this.serverService.getHTMLConfig()
96 this.currentInterfaceLanguage = this.languageChooserModal.getCurrentLanguage() 101 this.currentInterfaceLanguage = this.languageChooserModal.getCurrentLanguage()
@@ -131,12 +136,7 @@ export class MenuComponent implements OnInit {
131 } 136 }
132 137
133 getExternalLoginHref () { 138 getExternalLoginHref () {
134 if (!this.serverConfig || this.serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined 139 return PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverConfig)
135
136 const externalAuths = this.serverConfig.plugin.registeredExternalAuths
137 if (externalAuths.length !== 1) return undefined
138
139 return PluginsManager.getExternalAuthHref(externalAuths[0])
140 } 140 }
141 141
142 isRegistrationAllowed () { 142 isRegistrationAllowed () {
diff --git a/client/src/app/shared/form-validators/form-validator.model.ts b/client/src/app/shared/form-validators/form-validator.model.ts
index 31c253b9b..1e4bba86b 100644
--- a/client/src/app/shared/form-validators/form-validator.model.ts
+++ b/client/src/app/shared/form-validators/form-validator.model.ts
@@ -12,5 +12,5 @@ export type BuildFormArgument = {
12} 12}
13 13
14export type BuildFormDefaultValues = { 14export type BuildFormDefaultValues = {
15 [ name: string ]: number | string | string[] | BuildFormDefaultValues 15 [ name: string ]: boolean | number | string | string[] | BuildFormDefaultValues
16} 16}
diff --git a/client/src/app/shared/form-validators/user-validators.ts b/client/src/app/shared/form-validators/user-validators.ts
index b93de75ea..ed6e0582e 100644
--- a/client/src/app/shared/form-validators/user-validators.ts
+++ b/client/src/app/shared/form-validators/user-validators.ts
@@ -136,13 +136,6 @@ export const USER_DESCRIPTION_VALIDATOR: BuildFormValidator = {
136 } 136 }
137} 137}
138 138
139export const USER_TERMS_VALIDATOR: BuildFormValidator = {
140 VALIDATORS: [ Validators.requiredTrue ],
141 MESSAGES: {
142 required: $localize`You must agree with the instance terms in order to register on it.`
143 }
144}
145
146export const USER_BAN_REASON_VALIDATOR: BuildFormValidator = { 139export const USER_BAN_REASON_VALIDATOR: BuildFormValidator = {
147 VALIDATORS: [ 140 VALIDATORS: [
148 Validators.minLength(3), 141 Validators.minLength(3),
diff --git a/client/src/app/shared/shared-abuse-list/abuse-details.component.html b/client/src/app/shared/shared-abuse-list/abuse-details.component.html
index 089be501d..2d3e26a25 100644
--- a/client/src/app/shared/shared-abuse-list/abuse-details.component.html
+++ b/client/src/app/shared/shared-abuse-list/abuse-details.component.html
@@ -8,7 +8,7 @@
8 8
9 <span class="moderation-expanded-text"> 9 <span class="moderation-expanded-text">
10 <a [routerLink]="[ '.' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }" 10 <a [routerLink]="[ '.' ]" [queryParams]="{ 'search': 'reporter:&quot;' + abuse.reporterAccount.displayName + '&quot;' }"
11 class="chip" 11 class="chip me-1"
12 > 12 >
13 <my-actor-avatar size="18" [actor]="abuse.reporterAccount" actorType="account"></my-actor-avatar> 13 <my-actor-avatar size="18" [actor]="abuse.reporterAccount" actorType="account"></my-actor-avatar>
14 <div> 14 <div>
@@ -29,7 +29,7 @@
29 <span class="moderation-expanded-label" i18n>Reportee</span> 29 <span class="moderation-expanded-label" i18n>Reportee</span>
30 <span class="moderation-expanded-text"> 30 <span class="moderation-expanded-text">
31 <a [routerLink]="[ '.' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }" 31 <a [routerLink]="[ '.' ]" [queryParams]="{ 'search': 'reportee:&quot;' +abuse.flaggedAccount.displayName + '&quot;' }"
32 class="chip" 32 class="chip me-1"
33 > 33 >
34 <my-actor-avatar size="18" [actor]="abuse.flaggedAccount" actorType="account"></my-actor-avatar> 34 <my-actor-avatar size="18" [actor]="abuse.flaggedAccount" actorType="account"></my-actor-avatar>
35 <div> 35 <div>
@@ -63,7 +63,7 @@
63 <div *ngIf="predefinedReasons" class="mt-2 d-flex"> 63 <div *ngIf="predefinedReasons" class="mt-2 d-flex">
64 <span> 64 <span>
65 <a *ngFor="let reason of predefinedReasons" [routerLink]="[ '.' ]" 65 <a *ngFor="let reason of predefinedReasons" [routerLink]="[ '.' ]"
66 [queryParams]="{ 'search': 'tag:' + reason.id }" class="chip rectangular bg-secondary text-light" 66 [queryParams]="{ 'search': 'tag:' + reason.id }" class="pt-badge badge-secondary"
67 > 67 >
68 <div>{{ reason.label }}</div> 68 <div>{{ reason.label }}</div>
69 </a> 69 </a>
diff --git a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts
index 569a37b17..d8470e927 100644
--- a/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts
+++ b/client/src/app/shared/shared-abuse-list/abuse-list-table.component.ts
@@ -175,7 +175,7 @@ export class AbuseListTableComponent extends RestTable implements OnInit {
175 return Actor.IS_LOCAL(abuse.reporterAccount.host) 175 return Actor.IS_LOCAL(abuse.reporterAccount.host)
176 } 176 }
177 177
178 protected reloadData () { 178 protected reloadDataInternal () {
179 debugLogger('Loading data.') 179 debugLogger('Loading data.')
180 180
181 const options = { 181 const options = {
diff --git a/client/src/app/shared/shared-custom-markup/custom-markup-container.component.ts b/client/src/app/shared/shared-custom-markup/custom-markup-container.component.ts
index 4e802b14d..b2ee2d8f2 100644
--- a/client/src/app/shared/shared-custom-markup/custom-markup-container.component.ts
+++ b/client/src/app/shared/shared-custom-markup/custom-markup-container.component.ts
@@ -6,9 +6,9 @@ import { CustomMarkupService } from './custom-markup.service'
6 templateUrl: './custom-markup-container.component.html' 6 templateUrl: './custom-markup-container.component.html'
7}) 7})
8export class CustomMarkupContainerComponent implements OnChanges { 8export class CustomMarkupContainerComponent implements OnChanges {
9 @ViewChild('contentWrapper') contentWrapper: ElementRef<HTMLInputElement> 9 @ViewChild('contentWrapper', { static: true }) contentWrapper: ElementRef<HTMLInputElement>
10 10
11 @Input() content: string 11 @Input() content: string | HTMLDivElement
12 12
13 displayed = false 13 displayed = false
14 14
@@ -17,17 +17,23 @@ export class CustomMarkupContainerComponent implements OnChanges {
17 ) { } 17 ) { }
18 18
19 async ngOnChanges () { 19 async ngOnChanges () {
20 await this.buildElement() 20 await this.rebuild()
21 } 21 }
22 22
23 private async buildElement () { 23 private async rebuild () {
24 if (!this.content) return 24 if (this.content instanceof HTMLDivElement) {
25 return this.loadElement(this.content)
26 }
25 27
26 const { rootElement, componentsLoaded } = await this.customMarkupService.buildElement(this.content) 28 const { rootElement, componentsLoaded } = await this.customMarkupService.buildElement(this.content)
27 this.contentWrapper.nativeElement.appendChild(rootElement)
28
29 await componentsLoaded 29 await componentsLoaded
30 30
31 return this.loadElement(rootElement)
32 }
33
34 private loadElement (el: HTMLDivElement) {
35 this.contentWrapper.nativeElement.appendChild(el)
36
31 this.displayed = true 37 this.displayed = true
32 } 38 }
33} 39}
diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/button-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/button-markup.component.ts
index 1af060548..264dd9577 100644
--- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/button-markup.component.ts
+++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/button-markup.component.ts
@@ -1,4 +1,4 @@
1import { Component, Input } from '@angular/core' 1import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
2import { VideoChannel } from '../../shared-main' 2import { VideoChannel } from '../../shared-main'
3import { CustomMarkupComponent } from './shared' 3import { CustomMarkupComponent } from './shared'
4 4
@@ -9,7 +9,8 @@ import { CustomMarkupComponent } from './shared'
9@Component({ 9@Component({
10 selector: 'my-button-markup', 10 selector: 'my-button-markup',
11 templateUrl: 'button-markup.component.html', 11 templateUrl: 'button-markup.component.html',
12 styleUrls: [ 'button-markup.component.scss' ] 12 styleUrls: [ 'button-markup.component.scss' ],
13 changeDetection: ChangeDetectionStrategy.OnPush
13}) 14})
14export class ButtonMarkupComponent implements CustomMarkupComponent { 15export class ButtonMarkupComponent implements CustomMarkupComponent {
15 @Input() theme: 'primary' | 'secondary' 16 @Input() theme: 'primary' | 'secondary'
diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts
index ba12b7139..1e7860750 100644
--- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts
+++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/channel-miniature-markup.component.ts
@@ -1,6 +1,6 @@
1import { from } from 'rxjs' 1import { from } from 'rxjs'
2import { finalize, map, switchMap, tap } from 'rxjs/operators' 2import { finalize, map, switchMap, tap } from 'rxjs/operators'
3import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 3import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
4import { MarkdownService, Notifier, UserService } from '@app/core' 4import { MarkdownService, Notifier, UserService } from '@app/core'
5import { FindInBulkService } from '@app/shared/shared-search' 5import { FindInBulkService } from '@app/shared/shared-search'
6import { VideoSortField } from '@shared/models' 6import { VideoSortField } from '@shared/models'
@@ -14,7 +14,8 @@ import { CustomMarkupComponent } from './shared'
14@Component({ 14@Component({
15 selector: 'my-channel-miniature-markup', 15 selector: 'my-channel-miniature-markup',
16 templateUrl: 'channel-miniature-markup.component.html', 16 templateUrl: 'channel-miniature-markup.component.html',
17 styleUrls: [ 'channel-miniature-markup.component.scss' ] 17 styleUrls: [ 'channel-miniature-markup.component.scss' ],
18 changeDetection: ChangeDetectionStrategy.OnPush
18}) 19})
19export class ChannelMiniatureMarkupComponent implements CustomMarkupComponent, OnInit { 20export class ChannelMiniatureMarkupComponent implements CustomMarkupComponent, OnInit {
20 @Input() name: string 21 @Input() name: string
diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts
index 07fa6fd2d..ab52e7e37 100644
--- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts
+++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/playlist-miniature-markup.component.ts
@@ -1,5 +1,5 @@
1import { finalize } from 'rxjs/operators' 1import { finalize } from 'rxjs/operators'
2import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 2import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { FindInBulkService } from '@app/shared/shared-search' 4import { FindInBulkService } from '@app/shared/shared-search'
5import { MiniatureDisplayOptions } from '../../shared-video-miniature' 5import { MiniatureDisplayOptions } from '../../shared-video-miniature'
@@ -13,7 +13,8 @@ import { CustomMarkupComponent } from './shared'
13@Component({ 13@Component({
14 selector: 'my-playlist-miniature-markup', 14 selector: 'my-playlist-miniature-markup',
15 templateUrl: 'playlist-miniature-markup.component.html', 15 templateUrl: 'playlist-miniature-markup.component.html',
16 styleUrls: [ 'playlist-miniature-markup.component.scss' ] 16 styleUrls: [ 'playlist-miniature-markup.component.scss' ],
17 changeDetection: ChangeDetectionStrategy.OnPush
17}) 18})
18export class PlaylistMiniatureMarkupComponent implements CustomMarkupComponent, OnInit { 19export class PlaylistMiniatureMarkupComponent implements CustomMarkupComponent, OnInit {
19 @Input() uuid: string 20 @Input() uuid: string
diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts
index cbbacf77c..c37666359 100644
--- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts
+++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/video-miniature-markup.component.ts
@@ -1,5 +1,5 @@
1import { finalize } from 'rxjs/operators' 1import { finalize } from 'rxjs/operators'
2import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 2import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
3import { AuthService, Notifier } from '@app/core' 3import { AuthService, Notifier } from '@app/core'
4import { FindInBulkService } from '@app/shared/shared-search' 4import { FindInBulkService } from '@app/shared/shared-search'
5import { Video } from '../../shared-main' 5import { Video } from '../../shared-main'
@@ -13,7 +13,8 @@ import { CustomMarkupComponent } from './shared'
13@Component({ 13@Component({
14 selector: 'my-video-miniature-markup', 14 selector: 'my-video-miniature-markup',
15 templateUrl: 'video-miniature-markup.component.html', 15 templateUrl: 'video-miniature-markup.component.html',
16 styleUrls: [ 'video-miniature-markup.component.scss' ] 16 styleUrls: [ 'video-miniature-markup.component.scss' ],
17 changeDetection: ChangeDetectionStrategy.OnPush
17}) 18})
18export class VideoMiniatureMarkupComponent implements CustomMarkupComponent, OnInit { 19export class VideoMiniatureMarkupComponent implements CustomMarkupComponent, OnInit {
19 @Input() uuid: string 20 @Input() uuid: string
diff --git a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts
index 7d3498d4c..70e88ea51 100644
--- a/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts
+++ b/client/src/app/shared/shared-custom-markup/peertube-custom-tags/videos-list-markup.component.ts
@@ -1,5 +1,5 @@
1import { finalize } from 'rxjs/operators' 1import { finalize } from 'rxjs/operators'
2import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 2import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
3import { AuthService, Notifier } from '@app/core' 3import { AuthService, Notifier } from '@app/core'
4import { VideoSortField } from '@shared/models' 4import { VideoSortField } from '@shared/models'
5import { Video, VideoService } from '../../shared-main' 5import { Video, VideoService } from '../../shared-main'
@@ -13,7 +13,8 @@ import { CustomMarkupComponent } from './shared'
13@Component({ 13@Component({
14 selector: 'my-videos-list-markup', 14 selector: 'my-videos-list-markup',
15 templateUrl: 'videos-list-markup.component.html', 15 templateUrl: 'videos-list-markup.component.html',
16 styleUrls: [ 'videos-list-markup.component.scss' ] 16 styleUrls: [ 'videos-list-markup.component.scss' ],
17 changeDetection: ChangeDetectionStrategy.OnPush
17}) 18})
18export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit { 19export class VideosListMarkupComponent implements CustomMarkupComponent, OnInit {
19 @Input() sort: string 20 @Input() sort: string
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.ts b/client/src/app/shared/shared-forms/markdown-textarea.component.ts
index e3371f22c..c6527e169 100644
--- a/client/src/app/shared/shared-forms/markdown-textarea.component.ts
+++ b/client/src/app/shared/shared-forms/markdown-textarea.component.ts
@@ -31,6 +31,8 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
31 @Input() markdownType: 'text' | 'enhanced' = 'text' 31 @Input() markdownType: 'text' | 'enhanced' = 'text'
32 @Input() customMarkdownRenderer?: (text: string) => Promise<string | HTMLElement> 32 @Input() customMarkdownRenderer?: (text: string) => Promise<string | HTMLElement>
33 33
34 @Input() debounceTime = 150
35
34 @Input() markdownVideo: Video 36 @Input() markdownVideo: Video
35 37
36 @Input() name = 'description' 38 @Input() name = 'description'
@@ -59,7 +61,7 @@ export class MarkdownTextareaComponent implements ControlValueAccessor, OnInit {
59 ngOnInit () { 61 ngOnInit () {
60 this.contentChanged 62 this.contentChanged
61 .pipe( 63 .pipe(
62 debounceTime(150), 64 debounceTime(this.debounceTime),
63 distinctUntilChanged() 65 distinctUntilChanged()
64 ) 66 )
65 .subscribe(() => this.updatePreviews()) 67 .subscribe(() => this.updatePreviews())
diff --git a/client/src/app/shared/shared-instance/instance-features-table.component.html b/client/src/app/shared/shared-instance/instance-features-table.component.html
index 6c05764df..205f2bc97 100644
--- a/client/src/app/shared/shared-instance/instance-features-table.component.html
+++ b/client/src/app/shared/shared-instance/instance-features-table.component.html
@@ -18,10 +18,9 @@
18 </tr> 18 </tr>
19 19
20 <tr> 20 <tr>
21 <th i18n class="label" scope="row">User registration allowed</th> 21 <th i18n class="label" scope="row">User registration</th>
22 <td> 22
23 <my-feature-boolean [value]="serverConfig.signup.allowed"></my-feature-boolean> 23 <td class="value">{{ buildRegistrationLabel() }}</td>
24 </td>
25 </tr> 24 </tr>
26 25
27 <tr> 26 <tr>
diff --git a/client/src/app/shared/shared-instance/instance-features-table.component.ts b/client/src/app/shared/shared-instance/instance-features-table.component.ts
index e405c5790..c3df7c594 100644
--- a/client/src/app/shared/shared-instance/instance-features-table.component.ts
+++ b/client/src/app/shared/shared-instance/instance-features-table.component.ts
@@ -56,6 +56,15 @@ export class InstanceFeaturesTableComponent implements OnInit {
56 if (policy === 'display') return $localize`Displayed` 56 if (policy === 'display') return $localize`Displayed`
57 } 57 }
58 58
59 buildRegistrationLabel () {
60 const config = this.serverConfig.signup
61
62 if (config.allowed !== true) return $localize`Disabled`
63 if (config.requiresApproval === true) return $localize`Requires approval by moderators`
64
65 return $localize`Enabled`
66 }
67
59 getServerVersionAndCommit () { 68 getServerVersionAndCommit () {
60 return this.serverService.getServerVersionAndCommit() 69 return this.serverService.getServerVersionAndCommit()
61 } 70 }
diff --git a/client/src/app/shared/shared-instance/instance.service.ts b/client/src/app/shared/shared-instance/instance.service.ts
index 89f47db24..f5b2e05db 100644
--- a/client/src/app/shared/shared-instance/instance.service.ts
+++ b/client/src/app/shared/shared-instance/instance.service.ts
@@ -7,6 +7,11 @@ import { peertubeTranslate } from '@shared/core-utils/i18n'
7import { About } from '@shared/models' 7import { About } from '@shared/models'
8import { environment } from '../../../environments/environment' 8import { environment } from '../../../environments/environment'
9 9
10export type AboutHTML = Pick<About['instance'],
11'terms' | 'codeOfConduct' | 'moderationInformation' | 'administrator' | 'creationReason' |
12'maintenanceLifetime' | 'businessModel' | 'hardwareInformation'
13>
14
10@Injectable() 15@Injectable()
11export class InstanceService { 16export class InstanceService {
12 private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config' 17 private static BASE_CONFIG_URL = environment.apiUrl + '/api/v1/config'
@@ -39,7 +44,7 @@ export class InstanceService {
39 } 44 }
40 45
41 async buildHtml (about: About) { 46 async buildHtml (about: About) {
42 const html = { 47 const html: AboutHTML = {
43 terms: '', 48 terms: '',
44 codeOfConduct: '', 49 codeOfConduct: '',
45 moderationInformation: '', 50 moderationInformation: '',
diff --git a/client/src/app/shared/shared-main/account/index.ts b/client/src/app/shared/shared-main/account/index.ts
index b80ddb9f5..dd41a5f05 100644
--- a/client/src/app/shared/shared-main/account/index.ts
+++ b/client/src/app/shared/shared-main/account/index.ts
@@ -1,3 +1,4 @@
1export * from './account.model' 1export * from './account.model'
2export * from './account.service' 2export * from './account.service'
3export * from './actor.model' 3export * from './actor.model'
4export * from './signup-label.component'
diff --git a/client/src/app/shared/shared-main/account/signup-label.component.html b/client/src/app/shared/shared-main/account/signup-label.component.html
new file mode 100644
index 000000000..35d6c5360
--- /dev/null
+++ b/client/src/app/shared/shared-main/account/signup-label.component.html
@@ -0,0 +1,2 @@
1<ng-container i18n *ngIf="requiresApproval">Request an account</ng-container>
2<ng-container i18n *ngIf="!requiresApproval">Create an account</ng-container>
diff --git a/client/src/app/shared/shared-main/account/signup-label.component.ts b/client/src/app/shared/shared-main/account/signup-label.component.ts
new file mode 100644
index 000000000..caacb9c6f
--- /dev/null
+++ b/client/src/app/shared/shared-main/account/signup-label.component.ts
@@ -0,0 +1,9 @@
1import { Component, Input } from '@angular/core'
2
3@Component({
4 selector: 'my-signup-label',
5 templateUrl: './signup-label.component.html'
6})
7export class SignupLabelComponent {
8 @Input() requiresApproval: boolean
9}
diff --git a/client/src/app/shared/shared-main/shared-main.module.ts b/client/src/app/shared/shared-main/shared-main.module.ts
index c1523bc50..eb1642d97 100644
--- a/client/src/app/shared/shared-main/shared-main.module.ts
+++ b/client/src/app/shared/shared-main/shared-main.module.ts
@@ -16,7 +16,7 @@ import {
16import { LoadingBarModule } from '@ngx-loading-bar/core' 16import { LoadingBarModule } from '@ngx-loading-bar/core'
17import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client' 17import { LoadingBarHttpClientModule } from '@ngx-loading-bar/http-client'
18import { SharedGlobalIconModule } from '../shared-icons' 18import { SharedGlobalIconModule } from '../shared-icons'
19import { AccountService } from './account' 19import { AccountService, SignupLabelComponent } from './account'
20import { 20import {
21 AutofocusDirective, 21 AutofocusDirective,
22 BytesPipe, 22 BytesPipe,
@@ -113,6 +113,8 @@ import { VideoChannelService } from './video-channel'
113 UserQuotaComponent, 113 UserQuotaComponent,
114 UserNotificationsComponent, 114 UserNotificationsComponent,
115 115
116 SignupLabelComponent,
117
116 EmbedComponent, 118 EmbedComponent,
117 119
118 PluginPlaceholderComponent, 120 PluginPlaceholderComponent,
@@ -171,6 +173,8 @@ import { VideoChannelService } from './video-channel'
171 UserQuotaComponent, 173 UserQuotaComponent,
172 UserNotificationsComponent, 174 UserNotificationsComponent,
173 175
176 SignupLabelComponent,
177
174 EmbedComponent, 178 EmbedComponent,
175 179
176 PluginPlaceholderComponent, 180 PluginPlaceholderComponent,
diff --git a/client/src/app/shared/shared-main/users/user-notification.model.ts b/client/src/app/shared/shared-main/users/user-notification.model.ts
index bf8870a79..96e7b4dd0 100644
--- a/client/src/app/shared/shared-main/users/user-notification.model.ts
+++ b/client/src/app/shared/shared-main/users/user-notification.model.ts
@@ -83,6 +83,11 @@ export class UserNotification implements UserNotificationServer {
83 latestVersion: string 83 latestVersion: string
84 } 84 }
85 85
86 registration?: {
87 id: number
88 username: string
89 }
90
86 createdAt: string 91 createdAt: string
87 updatedAt: string 92 updatedAt: string
88 93
@@ -97,6 +102,8 @@ export class UserNotification implements UserNotificationServer {
97 102
98 accountUrl?: string 103 accountUrl?: string
99 104
105 registrationsUrl?: string
106
100 videoImportIdentifier?: string 107 videoImportIdentifier?: string
101 videoImportUrl?: string 108 videoImportUrl?: string
102 109
@@ -135,6 +142,7 @@ export class UserNotification implements UserNotificationServer {
135 142
136 this.plugin = hash.plugin 143 this.plugin = hash.plugin
137 this.peertube = hash.peertube 144 this.peertube = hash.peertube
145 this.registration = hash.registration
138 146
139 this.createdAt = hash.createdAt 147 this.createdAt = hash.createdAt
140 this.updatedAt = hash.updatedAt 148 this.updatedAt = hash.updatedAt
@@ -208,6 +216,10 @@ export class UserNotification implements UserNotificationServer {
208 this.accountUrl = this.buildAccountUrl(this.account) 216 this.accountUrl = this.buildAccountUrl(this.account)
209 break 217 break
210 218
219 case UserNotificationType.NEW_USER_REGISTRATION_REQUEST:
220 this.registrationsUrl = '/admin/moderation/registrations/list'
221 break
222
211 case UserNotificationType.NEW_FOLLOW: 223 case UserNotificationType.NEW_FOLLOW:
212 this.accountUrl = this.buildAccountUrl(this.actorFollow.follower) 224 this.accountUrl = this.buildAccountUrl(this.actorFollow.follower)
213 break 225 break
diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.html b/client/src/app/shared/shared-main/users/user-notifications.component.html
index e7cdb0183..a51e08292 100644
--- a/client/src/app/shared/shared-main/users/user-notifications.component.html
+++ b/client/src/app/shared/shared-main/users/user-notifications.component.html
@@ -215,6 +215,14 @@
215 </div> 215 </div>
216 </ng-container> 216 </ng-container>
217 217
218 <ng-container *ngSwitchCase="20"> <!-- UserNotificationType.NEW_USER_REGISTRATION_REQUEST -->
219 <my-global-icon iconName="user-add" aria-hidden="true"></my-global-icon>
220
221 <div class="message" i18n>
222 User <a (click)="markAsRead(notification)" [routerLink]="notification.registrationsUrl">{{ notification.registration.username }}</a> wants to register on your instance
223 </div>
224 </ng-container>
225
218 <ng-container *ngSwitchDefault> 226 <ng-container *ngSwitchDefault>
219 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon> 227 <my-global-icon iconName="alert" aria-hidden="true"></my-global-icon>
220 228
diff --git a/client/src/app/shared/shared-moderation/account-blocklist.component.scss b/client/src/app/shared/shared-moderation/account-blocklist.component.scss
index 8b1239d34..00aaf3b9c 100644
--- a/client/src/app/shared/shared-moderation/account-blocklist.component.scss
+++ b/client/src/app/shared/shared-moderation/account-blocklist.component.scss
@@ -1,10 +1,6 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *; 2@use '_mixins' as *;
3 3
4.chip {
5 @include chip;
6}
7
8.unblock-button { 4.unblock-button {
9 @include peertube-button; 5 @include peertube-button;
10 @include grey-button; 6 @include grey-button;
diff --git a/client/src/app/shared/shared-moderation/account-blocklist.component.ts b/client/src/app/shared/shared-moderation/account-blocklist.component.ts
index 9ed00bc12..38dbbff78 100644
--- a/client/src/app/shared/shared-moderation/account-blocklist.component.ts
+++ b/client/src/app/shared/shared-moderation/account-blocklist.component.ts
@@ -48,7 +48,7 @@ export class GenericAccountBlocklistComponent extends RestTable implements OnIni
48 ) 48 )
49 } 49 }
50 50
51 protected reloadData () { 51 protected reloadDataInternal () {
52 const operation = this.mode === BlocklistComponentType.Account 52 const operation = this.mode === BlocklistComponentType.Account
53 ? this.blocklistService.getUserAccountBlocklist({ 53 ? this.blocklistService.getUserAccountBlocklist({
54 pagination: this.pagination, 54 pagination: this.pagination,
diff --git a/client/src/app/shared/shared-moderation/moderation.scss b/client/src/app/shared/shared-moderation/moderation.scss
index eaf5a8250..7c1e308cf 100644
--- a/client/src/app/shared/shared-moderation/moderation.scss
+++ b/client/src/app/shared/shared-moderation/moderation.scss
@@ -40,10 +40,6 @@
40 } 40 }
41} 41}
42 42
43.chip {
44 @include chip;
45}
46
47my-action-dropdown.show { 43my-action-dropdown.show {
48 ::ng-deep .dropdown-root { 44 ::ng-deep .dropdown-root {
49 display: block !important; 45 display: block !important;
diff --git a/client/src/app/shared/shared-moderation/server-blocklist.component.scss b/client/src/app/shared/shared-moderation/server-blocklist.component.scss
index e29668a23..1a6b0435f 100644
--- a/client/src/app/shared/shared-moderation/server-blocklist.component.scss
+++ b/client/src/app/shared/shared-moderation/server-blocklist.component.scss
@@ -24,7 +24,3 @@ a {
24.block-button { 24.block-button {
25 @include create-button; 25 @include create-button;
26} 26}
27
28.chip {
29 @include chip;
30}
diff --git a/client/src/app/shared/shared-moderation/server-blocklist.component.ts b/client/src/app/shared/shared-moderation/server-blocklist.component.ts
index 1ba7a1b4d..f1bcbd561 100644
--- a/client/src/app/shared/shared-moderation/server-blocklist.component.ts
+++ b/client/src/app/shared/shared-moderation/server-blocklist.component.ts
@@ -75,7 +75,7 @@ export class GenericServerBlocklistComponent extends RestTable implements OnInit
75 }) 75 })
76 } 76 }
77 77
78 protected reloadData () { 78 protected reloadDataInternal () {
79 const operation = this.mode === BlocklistComponentType.Account 79 const operation = this.mode === BlocklistComponentType.Account
80 ? this.blocklistService.getUserServerBlocklist({ 80 ? this.blocklistService.getUserServerBlocklist({
81 pagination: this.pagination, 81 pagination: this.pagination,
diff --git a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
index c69a45c25..50dccf862 100644
--- a/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
+++ b/client/src/app/shared/shared-moderation/user-moderation-dropdown.component.ts
@@ -105,7 +105,7 @@ export class UserModerationDropdownComponent implements OnInit, OnChanges {
105 const res = await this.confirmService.confirm(message, $localize`Delete ${user.username}`) 105 const res = await this.confirmService.confirm(message, $localize`Delete ${user.username}`)
106 if (res === false) return 106 if (res === false) return
107 107
108 this.userAdminService.removeUser(user) 108 this.userAdminService.removeUsers(user)
109 .subscribe({ 109 .subscribe({
110 next: () => { 110 next: () => {
111 this.notifier.success($localize`User ${user.username} deleted.`) 111 this.notifier.success($localize`User ${user.username} deleted.`)
diff --git a/client/src/app/shared/shared-users/index.ts b/client/src/app/shared/shared-users/index.ts
index 20e60486d..95d90e49e 100644
--- a/client/src/app/shared/shared-users/index.ts
+++ b/client/src/app/shared/shared-users/index.ts
@@ -1,5 +1,4 @@
1export * from './user-admin.service' 1export * from './user-admin.service'
2export * from './user-signup.service'
3export * from './two-factor.service' 2export * from './two-factor.service'
4 3
5export * from './shared-users.module' 4export * from './shared-users.module'
diff --git a/client/src/app/shared/shared-users/shared-users.module.ts b/client/src/app/shared/shared-users/shared-users.module.ts
index 5a1675dc9..efffc6026 100644
--- a/client/src/app/shared/shared-users/shared-users.module.ts
+++ b/client/src/app/shared/shared-users/shared-users.module.ts
@@ -1,9 +1,7 @@
1
2import { NgModule } from '@angular/core' 1import { NgModule } from '@angular/core'
3import { SharedMainModule } from '../shared-main/shared-main.module' 2import { SharedMainModule } from '../shared-main/shared-main.module'
4import { TwoFactorService } from './two-factor.service' 3import { TwoFactorService } from './two-factor.service'
5import { UserAdminService } from './user-admin.service' 4import { UserAdminService } from './user-admin.service'
6import { UserSignupService } from './user-signup.service'
7 5
8@NgModule({ 6@NgModule({
9 imports: [ 7 imports: [
@@ -15,7 +13,6 @@ import { UserSignupService } from './user-signup.service'
15 exports: [], 13 exports: [],
16 14
17 providers: [ 15 providers: [
18 UserSignupService,
19 UserAdminService, 16 UserAdminService,
20 TwoFactorService 17 TwoFactorService
21 ] 18 ]
diff --git a/client/src/app/shared/shared-users/user-admin.service.ts b/client/src/app/shared/shared-users/user-admin.service.ts
index 0b04023a3..6224f0bd5 100644
--- a/client/src/app/shared/shared-users/user-admin.service.ts
+++ b/client/src/app/shared/shared-users/user-admin.service.ts
@@ -64,7 +64,7 @@ export class UserAdminService {
64 ) 64 )
65 } 65 }
66 66
67 removeUser (usersArg: UserServerModel | UserServerModel[]) { 67 removeUsers (usersArg: UserServerModel | UserServerModel[]) {
68 const users = arrayify(usersArg) 68 const users = arrayify(usersArg)
69 69
70 return from(users) 70 return from(users)
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
index 6fdf24b2d..227c12130 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
@@ -53,8 +53,8 @@
53 <ng-container *ngIf="displayOptions.state">{{ getStateLabel(video) }}</ng-container> 53 <ng-container *ngIf="displayOptions.state">{{ getStateLabel(video) }}</ng-container>
54 </div> 54 </div>
55 55
56 <div *ngIf="containedInPlaylists" class="video-contained-in-playlists"> 56 <div *ngIf="containedInPlaylists" class="fs-6">
57 <a *ngFor="let playlist of containedInPlaylists" class="chip rectangular bg-secondary text-light" [routerLink]="['/w/p/', playlist.playlistShortUUID]"> 57 <a *ngFor="let playlist of containedInPlaylists" class="pt-badge badge-secondary" [routerLink]="['/w/p/', playlist.playlistShortUUID]">
58 {{ playlist.playlistDisplayName }} 58 {{ playlist.playlistDisplayName }}
59 </a> 59 </a>
60 </div> 60 </div>
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.scss b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss
index ba2adfc5a..a397efdca 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.scss
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss
@@ -4,10 +4,6 @@
4 4
5$more-button-width: 40px; 5$more-button-width: 40px;
6 6
7.chip {
8 @include chip;
9}
10
11.video-miniature { 7.video-miniature {
12 font-size: 14px; 8 font-size: 14px;
13} 9}
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
index 85c63c173..706227e66 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.ts
@@ -314,6 +314,6 @@ export class VideoMiniatureComponent implements OnInit {
314 this.cd.markForCheck() 314 this.cd.markForCheck()
315 }) 315 })
316 316
317 this.videoPlaylistService.runPlaylistCheck(this.video.id) 317 this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
318 } 318 }
319} 319}
diff --git a/client/src/app/shared/shared-video-miniature/videos-list.component.ts b/client/src/app/shared/shared-video-miniature/videos-list.component.ts
index d5cdd958e..7b832263e 100644
--- a/client/src/app/shared/shared-video-miniature/videos-list.component.ts
+++ b/client/src/app/shared/shared-video-miniature/videos-list.component.ts
@@ -1,6 +1,6 @@
1import * as debug from 'debug' 1import * as debug from 'debug'
2import { fromEvent, Observable, Subject, Subscription } from 'rxjs' 2import { fromEvent, Observable, Subject, Subscription } from 'rxjs'
3import { debounceTime, switchMap } from 'rxjs/operators' 3import { concatMap, debounceTime, map, switchMap } from 'rxjs/operators'
4import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core' 4import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges } from '@angular/core'
5import { ActivatedRoute } from '@angular/router' 5import { ActivatedRoute } from '@angular/router'
6import { 6import {
@@ -111,6 +111,8 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
111 111
112 private lastQueryLength: number 112 private lastQueryLength: number
113 113
114 private videoRequests = new Subject<{ reset: boolean, obs: Observable<ResultList<Video>> }>()
115
114 constructor ( 116 constructor (
115 private notifier: Notifier, 117 private notifier: Notifier,
116 private authService: AuthService, 118 private authService: AuthService,
@@ -124,6 +126,8 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
124 } 126 }
125 127
126 ngOnInit () { 128 ngOnInit () {
129 this.subscribeToVideoRequests()
130
127 const hiddenFilters = this.hideScopeFilter 131 const hiddenFilters = this.hideScopeFilter
128 ? [ 'scope' ] 132 ? [ 'scope' ]
129 : [] 133 : []
@@ -228,30 +232,12 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
228 } 232 }
229 233
230 loadMoreVideos (reset = false) { 234 loadMoreVideos (reset = false) {
231 if (reset) this.hasDoneFirstQuery = false 235 if (reset) {
232 236 this.hasDoneFirstQuery = false
233 this.getVideosObservableFunction(this.pagination, this.filters) 237 this.videos = []
234 .subscribe({ 238 }
235 next: ({ data }) => {
236 this.hasDoneFirstQuery = true
237 this.lastQueryLength = data.length
238
239 if (reset) this.videos = []
240 this.videos = this.videos.concat(data)
241
242 if (this.groupByDate) this.buildGroupedDateLabels()
243
244 this.onDataSubject.next(data)
245 this.videosLoaded.emit(this.videos)
246 },
247
248 error: err => {
249 const message = $localize`Cannot load more videos. Try again later.`
250 239
251 logger.error(message, err) 240 this.videoRequests.next({ reset, obs: this.getVideosObservableFunction(this.pagination, this.filters) })
252 this.notifier.error(message)
253 }
254 })
255 } 241 }
256 242
257 reloadVideos () { 243 reloadVideos () {
@@ -423,4 +409,30 @@ export class VideosListComponent implements OnInit, OnChanges, OnDestroy {
423 this.onFiltersChanged(true) 409 this.onFiltersChanged(true)
424 }) 410 })
425 } 411 }
412
413 private subscribeToVideoRequests () {
414 this.videoRequests
415 .pipe(concatMap(({ reset, obs }) => obs.pipe(map(({ data }) => ({ data, reset })))))
416 .subscribe({
417 next: ({ data, reset }) => {
418 this.hasDoneFirstQuery = true
419 this.lastQueryLength = data.length
420
421 if (reset) this.videos = []
422 this.videos = this.videos.concat(data)
423
424 if (this.groupByDate) this.buildGroupedDateLabels()
425
426 this.onDataSubject.next(data)
427 this.videosLoaded.emit(this.videos)
428 },
429
430 error: err => {
431 const message = $localize`Cannot load more videos. Try again later.`
432
433 logger.error(message, err)
434 this.notifier.error(message)
435 }
436 })
437 }
426} 438}
diff --git a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
index 2fc39fc75..f802416a4 100644
--- a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
+++ b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
@@ -81,7 +81,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
81 .subscribe(result => { 81 .subscribe(result => {
82 this.playlistsData = result.data 82 this.playlistsData = result.data
83 83
84 this.videoPlaylistService.runPlaylistCheck(this.video.id) 84 this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
85 }) 85 })
86 86
87 this.videoPlaylistSearchChanged 87 this.videoPlaylistSearchChanged
@@ -129,7 +129,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
129 .subscribe(playlistsResult => { 129 .subscribe(playlistsResult => {
130 this.playlistsData = playlistsResult.data 130 this.playlistsData = playlistsResult.data
131 131
132 this.videoPlaylistService.runPlaylistCheck(this.video.id) 132 this.videoPlaylistService.runVideoExistsInPlaylistCheck(this.video.id)
133 }) 133 })
134 } 134 }
135 135
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
index 330a51f91..bc9fb0d74 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
+++ b/client/src/app/shared/shared-video-playlist/video-playlist.service.ts
@@ -206,7 +206,15 @@ export class VideoPlaylistService {
206 stopTimestamp: body.stopTimestamp 206 stopTimestamp: body.stopTimestamp
207 }) 207 })
208 208
209 this.runPlaylistCheck(body.videoId) 209 this.runVideoExistsInPlaylistCheck(body.videoId)
210
211 if (this.myAccountPlaylistCache) {
212 const playlist = this.myAccountPlaylistCache.data.find(p => p.id === playlistId)
213 if (!playlist) return
214
215 const otherPlaylists = this.myAccountPlaylistCache.data.filter(p => p !== playlist)
216 this.myAccountPlaylistCache.data = [ playlist, ...otherPlaylists ]
217 }
210 }), 218 }),
211 catchError(err => this.restExtractor.handleError(err)) 219 catchError(err => this.restExtractor.handleError(err))
212 ) 220 )
@@ -225,7 +233,7 @@ export class VideoPlaylistService {
225 elem.stopTimestamp = body.stopTimestamp 233 elem.stopTimestamp = body.stopTimestamp
226 } 234 }
227 235
228 this.runPlaylistCheck(videoId) 236 this.runVideoExistsInPlaylistCheck(videoId)
229 }), 237 }),
230 catchError(err => this.restExtractor.handleError(err)) 238 catchError(err => this.restExtractor.handleError(err))
231 ) 239 )
@@ -242,7 +250,7 @@ export class VideoPlaylistService {
242 .filter(e => e.playlistElementId !== playlistElementId) 250 .filter(e => e.playlistElementId !== playlistElementId)
243 } 251 }
244 252
245 this.runPlaylistCheck(videoId) 253 this.runVideoExistsInPlaylistCheck(videoId)
246 }), 254 }),
247 catchError(err => this.restExtractor.handleError(err)) 255 catchError(err => this.restExtractor.handleError(err))
248 ) 256 )
@@ -296,7 +304,7 @@ export class VideoPlaylistService {
296 return obs 304 return obs
297 } 305 }
298 306
299 runPlaylistCheck (videoId: number) { 307 runVideoExistsInPlaylistCheck (videoId: number) {
300 debugLogger('Running playlist check.') 308 debugLogger('Running playlist check.')
301 309
302 if (this.videoExistsCache[videoId]) { 310 if (this.videoExistsCache[videoId]) {
diff --git a/client/src/assets/player/peertube-player-manager.ts b/client/src/assets/player/peertube-player-manager.ts
index 56310c4e9..2781850b9 100644
--- a/client/src/assets/player/peertube-player-manager.ts
+++ b/client/src/assets/player/peertube-player-manager.ts
@@ -11,6 +11,7 @@ import './shared/control-bar/p2p-info-button'
11import './shared/control-bar/peertube-link-button' 11import './shared/control-bar/peertube-link-button'
12import './shared/control-bar/peertube-load-progress-bar' 12import './shared/control-bar/peertube-load-progress-bar'
13import './shared/control-bar/theater-button' 13import './shared/control-bar/theater-button'
14import './shared/control-bar/peertube-live-display'
14import './shared/settings/resolution-menu-button' 15import './shared/settings/resolution-menu-button'
15import './shared/settings/resolution-menu-item' 16import './shared/settings/resolution-menu-item'
16import './shared/settings/settings-dialog' 17import './shared/settings/settings-dialog'
@@ -96,6 +97,10 @@ export class PeertubePlayerManager {
96 videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) { 97 videojs(options.common.playerElement, videojsOptions, function (this: videojs.Player) {
97 const player = this 98 const player = this
98 99
100 if (!isNaN(+options.common.playbackRate)) {
101 player.playbackRate(+options.common.playbackRate)
102 }
103
99 let alreadyFallback = false 104 let alreadyFallback = false
100 105
101 const handleError = () => { 106 const handleError = () => {
@@ -118,7 +123,7 @@ export class PeertubePlayerManager {
118 self.addContextMenu(videojsOptionsBuilder, player, options.common) 123 self.addContextMenu(videojsOptionsBuilder, player, options.common)
119 124
120 if (isMobile()) player.peertubeMobile() 125 if (isMobile()) player.peertubeMobile()
121 if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin() 126 if (options.common.enableHotkeys === true) player.peerTubeHotkeysPlugin({ isLive: options.common.isLive })
122 if (options.common.controlBar === false) player.controlBar.addClass('control-bar-hidden') 127 if (options.common.controlBar === false) player.controlBar.addClass('control-bar-hidden')
123 128
124 player.bezels() 129 player.bezels()
diff --git a/client/src/assets/player/shared/control-bar/index.ts b/client/src/assets/player/shared/control-bar/index.ts
index db5b8938d..e71e90713 100644
--- a/client/src/assets/player/shared/control-bar/index.ts
+++ b/client/src/assets/player/shared/control-bar/index.ts
@@ -1,5 +1,6 @@
1export * from './next-previous-video-button' 1export * from './next-previous-video-button'
2export * from './p2p-info-button' 2export * from './p2p-info-button'
3export * from './peertube-link-button' 3export * from './peertube-link-button'
4export * from './peertube-live-display'
4export * from './peertube-load-progress-bar' 5export * from './peertube-load-progress-bar'
5export * from './theater-button' 6export * from './theater-button'
diff --git a/client/src/assets/player/shared/control-bar/peertube-live-display.ts b/client/src/assets/player/shared/control-bar/peertube-live-display.ts
new file mode 100644
index 000000000..649eb0b00
--- /dev/null
+++ b/client/src/assets/player/shared/control-bar/peertube-live-display.ts
@@ -0,0 +1,93 @@
1import videojs from 'video.js'
2import { PeerTubeLinkButtonOptions } from '../../types'
3
4const ClickableComponent = videojs.getComponent('ClickableComponent')
5
6class PeerTubeLiveDisplay extends ClickableComponent {
7 private interval: any
8
9 private contentEl_: any
10
11 constructor (player: videojs.Player, options?: PeerTubeLinkButtonOptions) {
12 super(player, options as any)
13
14 this.interval = this.setInterval(() => this.updateClass(), 1000)
15
16 this.show()
17 this.updateSync(true)
18 }
19
20 dispose () {
21 if (this.interval) {
22 this.clearInterval(this.interval)
23 this.interval = undefined
24 }
25
26 this.contentEl_ = null
27
28 super.dispose()
29 }
30
31 createEl () {
32 const el = super.createEl('div', {
33 className: 'vjs-live-control vjs-control'
34 })
35
36 this.contentEl_ = videojs.dom.createEl('div', {
37 className: 'vjs-live-display'
38 }, {
39 'aria-live': 'off'
40 })
41
42 this.contentEl_.appendChild(videojs.dom.createEl('span', {
43 className: 'vjs-control-text',
44 textContent: `${this.localize('Stream Type')}\u00a0`
45 }))
46
47 this.contentEl_.appendChild(document.createTextNode(this.localize('LIVE')))
48
49 el.appendChild(this.contentEl_)
50 return el
51 }
52
53 handleClick () {
54 const hlsjs = this.getHLSJS()
55 if (!hlsjs) return
56
57 this.player().currentTime(hlsjs.liveSyncPosition)
58 this.player().play()
59 this.updateSync(true)
60 }
61
62 private updateClass () {
63 const hlsjs = this.getHLSJS()
64 if (!hlsjs) return
65
66 // Not loaded yet
67 if (this.player().currentTime() === 0) return
68
69 const isSync = Math.abs(this.player().currentTime() - hlsjs.liveSyncPosition) < 10
70 this.updateSync(isSync)
71 }
72
73 private updateSync (isSync: boolean) {
74 if (isSync) {
75 this.addClass('synced-with-live-edge')
76 this.removeAttribute('title')
77 this.disable()
78 } else {
79 this.removeClass('synced-with-live-edge')
80 this.setAttribute('title', this.localize('Go back to the live'))
81 this.enable()
82 }
83 }
84
85 private getHLSJS () {
86 const p2pMediaLoader = this.player()?.p2pMediaLoader
87 if (!p2pMediaLoader) return undefined
88
89 return p2pMediaLoader().getHLSJS()
90 }
91}
92
93videojs.registerComponent('PeerTubeLiveDisplay', PeerTubeLiveDisplay)
diff --git a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
index ec1e1038b..f5b4b3919 100644
--- a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
+++ b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
@@ -4,6 +4,10 @@ type KeyHandler = { accept: (event: KeyboardEvent) => boolean, cb: (e: KeyboardE
4 4
5const Plugin = videojs.getPlugin('plugin') 5const Plugin = videojs.getPlugin('plugin')
6 6
7export type HotkeysOptions = {
8 isLive: boolean
9}
10
7class PeerTubeHotkeysPlugin extends Plugin { 11class PeerTubeHotkeysPlugin extends Plugin {
8 private static readonly VOLUME_STEP = 0.1 12 private static readonly VOLUME_STEP = 0.1
9 private static readonly SEEK_STEP = 5 13 private static readonly SEEK_STEP = 5
@@ -12,9 +16,13 @@ class PeerTubeHotkeysPlugin extends Plugin {
12 16
13 private readonly handlers: KeyHandler[] 17 private readonly handlers: KeyHandler[]
14 18
15 constructor (player: videojs.Player, options: videojs.PlayerOptions) { 19 private readonly isLive: boolean
20
21 constructor (player: videojs.Player, options: videojs.PlayerOptions & HotkeysOptions) {
16 super(player, options) 22 super(player, options)
17 23
24 this.isLive = options.isLive
25
18 this.handlers = this.buildHandlers() 26 this.handlers = this.buildHandlers()
19 27
20 this.handleKeyFunction = (event: KeyboardEvent) => this.onKeyDown(event) 28 this.handleKeyFunction = (event: KeyboardEvent) => this.onKeyDown(event)
@@ -68,28 +76,6 @@ class PeerTubeHotkeysPlugin extends Plugin {
68 } 76 }
69 }, 77 },
70 78
71 // Rewind
72 {
73 accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'),
74 cb: e => {
75 e.preventDefault()
76
77 const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP)
78 this.player.currentTime(target)
79 }
80 },
81
82 // Forward
83 {
84 accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'),
85 cb: e => {
86 e.preventDefault()
87
88 const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP)
89 this.player.currentTime(target)
90 }
91 },
92
93 // Fullscreen 79 // Fullscreen
94 { 80 {
95 // f key or Ctrl + Enter 81 // f key or Ctrl + Enter
@@ -116,6 +102,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
116 { 102 {
117 accept: e => e.key === '>', 103 accept: e => e.key === '>',
118 cb: () => { 104 cb: () => {
105 if (this.isLive) return
106
119 const target = Math.min(this.player.playbackRate() + 0.1, 5) 107 const target = Math.min(this.player.playbackRate() + 0.1, 5)
120 108
121 this.player.playbackRate(parseFloat(target.toFixed(2))) 109 this.player.playbackRate(parseFloat(target.toFixed(2)))
@@ -126,6 +114,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
126 { 114 {
127 accept: e => e.key === '<', 115 accept: e => e.key === '<',
128 cb: () => { 116 cb: () => {
117 if (this.isLive) return
118
129 const target = Math.max(this.player.playbackRate() - 0.1, 0.10) 119 const target = Math.max(this.player.playbackRate() - 0.1, 0.10)
130 120
131 this.player.playbackRate(parseFloat(target.toFixed(2))) 121 this.player.playbackRate(parseFloat(target.toFixed(2)))
@@ -136,6 +126,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
136 { 126 {
137 accept: e => e.key === ',', 127 accept: e => e.key === ',',
138 cb: () => { 128 cb: () => {
129 if (this.isLive) return
130
139 this.player.pause() 131 this.player.pause()
140 132
141 // Calculate movement distance (assuming 30 fps) 133 // Calculate movement distance (assuming 30 fps)
@@ -148,6 +140,8 @@ class PeerTubeHotkeysPlugin extends Plugin {
148 { 140 {
149 accept: e => e.key === '.', 141 accept: e => e.key === '.',
150 cb: () => { 142 cb: () => {
143 if (this.isLive) return
144
151 this.player.pause() 145 this.player.pause()
152 146
153 // Calculate movement distance (assuming 30 fps) 147 // Calculate movement distance (assuming 30 fps)
@@ -157,11 +151,47 @@ class PeerTubeHotkeysPlugin extends Plugin {
157 } 151 }
158 ] 152 ]
159 153
154 if (this.isLive) return handlers
155
156 return handlers.concat(this.buildVODHandlers())
157 }
158
159 private buildVODHandlers () {
160 const handlers: KeyHandler[] = [
161 // Rewind
162 {
163 accept: e => this.isNaked(e, 'ArrowLeft') || this.isNaked(e, 'MediaRewind'),
164 cb: e => {
165 if (this.isLive) return
166
167 e.preventDefault()
168
169 const target = Math.max(0, this.player.currentTime() - PeerTubeHotkeysPlugin.SEEK_STEP)
170 this.player.currentTime(target)
171 }
172 },
173
174 // Forward
175 {
176 accept: e => this.isNaked(e, 'ArrowRight') || this.isNaked(e, 'MediaForward'),
177 cb: e => {
178 if (this.isLive) return
179
180 e.preventDefault()
181
182 const target = Math.min(this.player.duration(), this.player.currentTime() + PeerTubeHotkeysPlugin.SEEK_STEP)
183 this.player.currentTime(target)
184 }
185 }
186 ]
187
160 // 0-9 key handlers 188 // 0-9 key handlers
161 for (let i = 0; i < 10; i++) { 189 for (let i = 0; i < 10; i++) {
162 handlers.push({ 190 handlers.push({
163 accept: e => this.isNakedOrShift(e, i + ''), 191 accept: e => this.isNakedOrShift(e, i + ''),
164 cb: e => { 192 cb: e => {
193 if (this.isLive) return
194
165 e.preventDefault() 195 e.preventDefault()
166 196
167 this.player.currentTime(this.player.duration() * i * 0.1) 197 this.player.currentTime(this.player.duration() * i * 0.1)
diff --git a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts b/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
index 27f366732..26f923e92 100644
--- a/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
+++ b/client/src/assets/player/shared/manager-options/control-bar-options-builder.ts
@@ -30,10 +30,7 @@ export class ControlBarOptionsBuilder {
30 } 30 }
31 31
32 Object.assign(children, { 32 Object.assign(children, {
33 currentTimeDisplay: {}, 33 ...this.getTimeControls(),
34 timeDivider: {},
35 durationDisplay: {},
36 liveDisplay: {},
37 34
38 flexibleWidthSpacer: {}, 35 flexibleWidthSpacer: {},
39 36
@@ -74,7 +71,9 @@ export class ControlBarOptionsBuilder {
74 private getSettingsButton () { 71 private getSettingsButton () {
75 const settingEntries: string[] = [] 72 const settingEntries: string[] = []
76 73
77 settingEntries.push('playbackRateMenuButton') 74 if (!this.options.isLive) {
75 settingEntries.push('playbackRateMenuButton')
76 }
78 77
79 if (this.options.captions === true) settingEntries.push('captionsButton') 78 if (this.options.captions === true) settingEntries.push('captionsButton')
80 79
@@ -90,7 +89,23 @@ export class ControlBarOptionsBuilder {
90 } 89 }
91 } 90 }
92 91
92 private getTimeControls () {
93 if (this.options.isLive) {
94 return {
95 peerTubeLiveDisplay: {}
96 }
97 }
98
99 return {
100 currentTimeDisplay: {},
101 timeDivider: {},
102 durationDisplay: {}
103 }
104 }
105
93 private getProgressControl () { 106 private getProgressControl () {
107 if (this.options.isLive) return {}
108
94 const loadProgressBar = this.mode === 'webtorrent' 109 const loadProgressBar = this.mode === 'webtorrent'
95 ? 'peerTubeLoadProgressBar' 110 ? 'peerTubeLoadProgressBar'
96 : 'loadProgressBar' 111 : 'loadProgressBar'
diff --git a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
index a14beb347..7f7d90ab9 100644
--- a/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
+++ b/client/src/assets/player/shared/p2p-media-loader/hls-plugin.ts
@@ -281,8 +281,8 @@ class Html5Hlsjs {
281 if (this.errorCounts[data.type]) this.errorCounts[data.type] += 1 281 if (this.errorCounts[data.type]) this.errorCounts[data.type] += 1
282 else this.errorCounts[data.type] = 1 282 else this.errorCounts[data.type] = 1
283 283
284 if (data.fatal) logger.warn(error.message) 284 if (data.fatal) logger.error(error.message, { currentTime: this.player.currentTime(), data })
285 else logger.error(error.message, { data }) 285 else logger.warn(error.message)
286 286
287 if (data.type === Hlsjs.ErrorTypes.NETWORK_ERROR) { 287 if (data.type === Hlsjs.ErrorTypes.NETWORK_ERROR) {
288 error.code = 2 288 error.code = 2
diff --git a/client/src/assets/player/shared/stats/stats-card.ts b/client/src/assets/player/shared/stats/stats-card.ts
index f23ae48be..471a5e46c 100644
--- a/client/src/assets/player/shared/stats/stats-card.ts
+++ b/client/src/assets/player/shared/stats/stats-card.ts
@@ -182,7 +182,7 @@ class StatsCard extends Component {
182 let colorSpace = 'unknown' 182 let colorSpace = 'unknown'
183 let codecs = 'unknown' 183 let codecs = 'unknown'
184 184
185 if (metadata?.streams[0]) { 185 if (metadata?.streams?.[0]) {
186 const stream = metadata.streams[0] 186 const stream = metadata.streams[0]
187 187
188 colorSpace = stream['color_space'] !== 'unknown' 188 colorSpace = stream['color_space'] !== 'unknown'
@@ -193,7 +193,7 @@ class StatsCard extends Component {
193 } 193 }
194 194
195 const resolution = videoFile?.resolution.label + videoFile?.fps 195 const resolution = videoFile?.resolution.label + videoFile?.fps
196 const buffer = this.timeRangesToString(this.player().buffered()) 196 const buffer = this.timeRangesToString(this.player_.buffered())
197 const progress = this.player_.webtorrent().getTorrent()?.progress 197 const progress = this.player_.webtorrent().getTorrent()?.progress
198 198
199 return { 199 return {
diff --git a/client/src/assets/player/types/manager-options.ts b/client/src/assets/player/types/manager-options.ts
index 3057a5adb..3fbcec29c 100644
--- a/client/src/assets/player/types/manager-options.ts
+++ b/client/src/assets/player/types/manager-options.ts
@@ -29,6 +29,8 @@ export interface CustomizationOptions {
29 resume?: string 29 resume?: string
30 30
31 peertubeLink: boolean 31 peertubeLink: boolean
32
33 playbackRate?: number | string
32} 34}
33 35
34export interface CommonOptions extends CustomizationOptions { 36export interface CommonOptions extends CustomizationOptions {
diff --git a/client/src/assets/player/types/peertube-videojs-typings.ts b/client/src/assets/player/types/peertube-videojs-typings.ts
index c60154f3b..5674f78cb 100644
--- a/client/src/assets/player/types/peertube-videojs-typings.ts
+++ b/client/src/assets/player/types/peertube-videojs-typings.ts
@@ -3,6 +3,7 @@ import videojs from 'video.js'
3import { Engine } from '@peertube/p2p-media-loader-hlsjs' 3import { Engine } from '@peertube/p2p-media-loader-hlsjs'
4import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models' 4import { VideoFile, VideoPlaylist, VideoPlaylistElement } from '@shared/models'
5import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin' 5import { PeerTubeDockPluginOptions } from '../shared/dock/peertube-dock-plugin'
6import { HotkeysOptions } from '../shared/hotkeys/peertube-hotkeys-plugin'
6import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin' 7import { Html5Hlsjs } from '../shared/p2p-media-loader/hls-plugin'
7import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin' 8import { P2pMediaLoaderPlugin } from '../shared/p2p-media-loader/p2p-media-loader-plugin'
8import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager' 9import { RedundancyUrlManager } from '../shared/p2p-media-loader/redundancy-url-manager'
@@ -44,7 +45,7 @@ declare module 'video.js' {
44 45
45 bezels (): void 46 bezels (): void
46 peertubeMobile (): void 47 peertubeMobile (): void
47 peerTubeHotkeysPlugin (): void 48 peerTubeHotkeysPlugin (options?: HotkeysOptions): void
48 49
49 stats (options?: StatsCardOptions): StatsForNerdsPlugin 50 stats (options?: StatsCardOptions): StatsForNerdsPlugin
50 51
diff --git a/client/src/root-helpers/logger.ts b/client/src/root-helpers/logger.ts
index d1fdf73aa..618be62cd 100644
--- a/client/src/root-helpers/logger.ts
+++ b/client/src/root-helpers/logger.ts
@@ -27,6 +27,10 @@ class Logger {
27 warn (message: LoggerMessage, meta?: LoggerMeta) { 27 warn (message: LoggerMessage, meta?: LoggerMeta) {
28 this.runHooks('warn', message, meta) 28 this.runHooks('warn', message, meta)
29 29
30 this.clientWarn(message, meta)
31 }
32
33 clientWarn (message: LoggerMessage, meta?: LoggerMeta) {
30 if (meta) console.warn(message, meta) 34 if (meta) console.warn(message, meta)
31 else console.warn(message) 35 else console.warn(message)
32 } 36 }
@@ -34,6 +38,10 @@ class Logger {
34 error (message: LoggerMessage, meta?: LoggerMeta) { 38 error (message: LoggerMessage, meta?: LoggerMeta) {
35 this.runHooks('error', message, meta) 39 this.runHooks('error', message, meta)
36 40
41 this.clientError(message, meta)
42 }
43
44 clientError (message: LoggerMessage, meta?: LoggerMeta) {
37 if (meta) console.error(message, meta) 45 if (meta) console.error(message, meta)
38 else console.error(message) 46 else console.error(message)
39 } 47 }
diff --git a/client/src/root-helpers/plugins-manager.ts b/client/src/root-helpers/plugins-manager.ts
index 6c64e2b01..e5b06a94c 100644
--- a/client/src/root-helpers/plugins-manager.ts
+++ b/client/src/root-helpers/plugins-manager.ts
@@ -3,7 +3,7 @@ import * as debug from 'debug'
3import { firstValueFrom, ReplaySubject } from 'rxjs' 3import { firstValueFrom, ReplaySubject } from 'rxjs'
4import { first, shareReplay } from 'rxjs/operators' 4import { first, shareReplay } from 'rxjs/operators'
5import { RegisterClientHelpers } from 'src/types/register-client-option.model' 5import { RegisterClientHelpers } from 'src/types/register-client-option.model'
6import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks' 6import { getExternalAuthHref, getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
7import { 7import {
8 ClientHookName, 8 ClientHookName,
9 clientHookObject, 9 clientHookObject,
@@ -16,7 +16,6 @@ import {
16 RegisterClientRouteOptions, 16 RegisterClientRouteOptions,
17 RegisterClientSettingsScriptOptions, 17 RegisterClientSettingsScriptOptions,
18 RegisterClientVideoFieldOptions, 18 RegisterClientVideoFieldOptions,
19 RegisteredExternalAuthConfig,
20 ServerConfigPlugin 19 ServerConfigPlugin
21} from '@shared/models' 20} from '@shared/models'
22import { environment } from '../environments/environment' 21import { environment } from '../environments/environment'
@@ -94,9 +93,13 @@ class PluginsManager {
94 return isTheme ? '/themes' : '/plugins' 93 return isTheme ? '/themes' : '/plugins'
95 } 94 }
96 95
97 static getExternalAuthHref (auth: RegisteredExternalAuthConfig) { 96 static getDefaultLoginHref (apiUrl: string, serverConfig: HTMLServerConfig) {
98 return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}` 97 if (!serverConfig || serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined
99 98
99 const externalAuths = serverConfig.plugin.registeredExternalAuths
100 if (externalAuths.length !== 1) return undefined
101
102 return getExternalAuthHref(apiUrl, externalAuths[0])
100 } 103 }
101 104
102 loadPluginsList (config: HTMLServerConfig) { 105 loadPluginsList (config: HTMLServerConfig) {
diff --git a/client/src/sass/class-helpers.scss b/client/src/sass/class-helpers.scss
index bc965331a..feb3a6de2 100644
--- a/client/src/sass/class-helpers.scss
+++ b/client/src/sass/class-helpers.scss
@@ -284,3 +284,9 @@ label + .form-group-description {
284 border: 2px solid pvar(--mainColorLightest); 284 border: 2px solid pvar(--mainColorLightest);
285 } 285 }
286} 286}
287
288// ---------------------------------------------------------------------------
289
290.chip {
291 @include chip;
292}
diff --git a/client/src/sass/include/_badges.scss b/client/src/sass/include/_badges.scss
index 4bc70d4a9..7efd2fb81 100644
--- a/client/src/sass/include/_badges.scss
+++ b/client/src/sass/include/_badges.scss
@@ -9,6 +9,10 @@
9 font-weight: $font-semibold; 9 font-weight: $font-semibold;
10 line-height: 1.1; 10 line-height: 1.1;
11 11
12 &.badge-fs-normal {
13 font-size: 100%;
14 }
15
12 &.badge-primary { 16 &.badge-primary {
13 color: pvar(--mainBackgroundColor); 17 color: pvar(--mainBackgroundColor);
14 background-color: pvar(--mainColor); 18 background-color: pvar(--mainColor);
diff --git a/client/src/sass/include/_fonts.scss b/client/src/sass/include/_fonts.scss
index e5a40af34..514261d01 100644
--- a/client/src/sass/include/_fonts.scss
+++ b/client/src/sass/include/_fonts.scss
@@ -15,7 +15,3 @@
15 font-display: swap; 15 font-display: swap;
16 src: url('../fonts/source-sans/WOFF2/VAR/SourceSans3VF-Italic.ttf.woff2') format('woff2'); 16 src: url('../fonts/source-sans/WOFF2/VAR/SourceSans3VF-Italic.ttf.woff2') format('woff2');
17} 17}
18
19@mixin muted {
20 color: pvar(--greyForegroundColor) !important;
21}
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index b5ccb6598..8816437d9 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -36,6 +36,10 @@
36 max-height: $font-size * $number-of-lines; 36 max-height: $font-size * $number-of-lines;
37} 37}
38 38
39@mixin muted {
40 color: pvar(--greyForegroundColor) !important;
41}
42
39@mixin fade-text ($fade-after, $background-color) { 43@mixin fade-text ($fade-after, $background-color) {
40 position: relative; 44 position: relative;
41 overflow: hidden; 45 overflow: hidden;
@@ -791,51 +795,39 @@
791} 795}
792 796
793@mixin chip { 797@mixin chip {
794 --chip-radius: 5rem; 798 --avatar-size: 1.2rem;
795 --chip-padding: .2rem .4rem;
796 $avatar-height: 1.2rem;
797 799
798 align-items: center;
799 border-radius: var(--chip-radius);
800 display: inline-flex; 800 display: inline-flex;
801 font-size: 90%;
802 color: pvar(--mainForegroundColor); 801 color: pvar(--mainForegroundColor);
803 height: $avatar-height; 802 height: var(--avatar-size);
804 line-height: 1rem;
805 margin: .1rem;
806 max-width: 320px; 803 max-width: 320px;
807 overflow: hidden; 804 overflow: hidden;
808 padding: var(--chip-padding);
809 text-decoration: none; 805 text-decoration: none;
810 text-overflow: ellipsis; 806 text-overflow: ellipsis;
811 vertical-align: middle; 807 vertical-align: middle;
812 white-space: nowrap; 808 white-space: nowrap;
813 809
814 &.rectangular {
815 --chip-radius: .2rem;
816 --chip-padding: .2rem .3rem;
817 }
818
819 my-actor-avatar { 810 my-actor-avatar {
820 @include margin-left(-.4rem);
821 @include margin-right(.2rem); 811 @include margin-right(.2rem);
812
813 border-radius: 5rem;
814 width: var(--avatar-size);
815 height: var(--avatar-size);
822 } 816 }
823 817
824 &.two-lines { 818 &.two-lines {
825 $avatar-height: 2rem; 819 --avatar-size: 2rem;
826 820
827 height: $avatar-height; 821 font-size: 14px;
822 line-height: 1rem;
828 823
829 my-actor-avatar { 824 my-actor-avatar {
830 display: inline-block; 825 display: inline-block;
831 } 826 }
832 827
833 div { 828 > div {
834 margin: 0 .1rem;
835
836 display: flex; 829 display: flex;
837 flex-direction: column; 830 flex-direction: column;
838 height: $avatar-height;
839 justify-content: center; 831 justify-content: center;
840 } 832 }
841 } 833 }
diff --git a/client/src/sass/player/control-bar.scss b/client/src/sass/player/control-bar.scss
index 0082378e4..96b3adf66 100644
--- a/client/src/sass/player/control-bar.scss
+++ b/client/src/sass/player/control-bar.scss
@@ -153,8 +153,25 @@
153 } 153 }
154 154
155 .vjs-live-control { 155 .vjs-live-control {
156 line-height: $control-bar-height; 156 padding: 5px 7px;
157 min-width: 4em; 157 border-radius: 3px;
158 height: fit-content;
159 margin: auto 10px;
160 font-weight: bold;
161 max-width: fit-content;
162 opacity: 1 !important;
163 line-height: normal;
164 position: relative;
165 top: -1px;
166
167 &.synced-with-live-edge {
168 background: #d7281c;
169 }
170
171 &:not(.synced-with-live-edge) {
172 cursor: pointer;
173 background: #80807f;
174 }
158 } 175 }
159 176
160 .vjs-peertube { 177 .vjs-peertube {
diff --git a/client/src/sass/primeng-custom.scss b/client/src/sass/primeng-custom.scss
index 88f6efb6a..ee66a9db3 100644
--- a/client/src/sass/primeng-custom.scss
+++ b/client/src/sass/primeng-custom.scss
@@ -294,6 +294,7 @@ body .p-datepicker .p-datepicker-header .p-datepicker-title select:focus {
294body .p-datepicker table { 294body .p-datepicker table {
295 font-size: 14px; 295 font-size: 14px;
296 margin: 0.857em 0 0 0; 296 margin: 0.857em 0 0 0;
297 table-layout: fixed;
297} 298}
298body .p-datepicker table th { 299body .p-datepicker table th {
299 padding: 0.5em; 300 padding: 0.5em;
diff --git a/client/src/standalone/videos/shared/player-manager-options.ts b/client/src/standalone/videos/shared/player-manager-options.ts
index b0bdb2dd9..f09c86d14 100644
--- a/client/src/standalone/videos/shared/player-manager-options.ts
+++ b/client/src/standalone/videos/shared/player-manager-options.ts
@@ -38,6 +38,7 @@ export class PlayerManagerOptions {
38 private enableApi = false 38 private enableApi = false
39 private startTime: number | string = 0 39 private startTime: number | string = 0
40 private stopTime: number | string 40 private stopTime: number | string
41 private playbackRate: number | string
41 42
42 private title: boolean 43 private title: boolean
43 private warningTitle: boolean 44 private warningTitle: boolean
@@ -130,6 +131,7 @@ export class PlayerManagerOptions {
130 this.subtitle = getParamString(params, 'subtitle') 131 this.subtitle = getParamString(params, 'subtitle')
131 this.startTime = getParamString(params, 'start') 132 this.startTime = getParamString(params, 'start')
132 this.stopTime = getParamString(params, 'stop') 133 this.stopTime = getParamString(params, 'stop')
134 this.playbackRate = getParamString(params, 'playbackRate')
133 135
134 this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor') 136 this.bigPlayBackgroundColor = getParamString(params, 'bigPlayBackgroundColor')
135 this.foregroundColor = getParamString(params, 'foregroundColor') 137 this.foregroundColor = getParamString(params, 'foregroundColor')
@@ -210,6 +212,8 @@ export class PlayerManagerOptions {
210 ? playlistTracker.getCurrentElement().stopTimestamp 212 ? playlistTracker.getCurrentElement().stopTimestamp
211 : this.stopTime, 213 : this.stopTime,
212 214
215 playbackRate: this.playbackRate,
216
213 videoCaptions, 217 videoCaptions,
214 inactivityTimeout: 2500, 218 inactivityTimeout: 2500,
215 videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid), 219 videoViewUrl: this.videoFetcher.getVideoViewsUrl(video.uuid),
diff --git a/client/yarn.lock b/client/yarn.lock
index b680bfdfb..1799df7b1 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -302,6 +302,11 @@
302 dependencies: 302 dependencies:
303 tslib "^2.3.0" 303 tslib "^2.3.0"
304 304
305"@arr/every@^1.0.0":
306 version "1.0.1"
307 resolved "https://registry.yarnpkg.com/@arr/every/-/every-1.0.1.tgz#22fe1f8e6355beca6c7c7bde965eb15cf994387b"
308 integrity sha512-UQFQ6SgyJ6LX42W8rHCs8KVc0JS0tzVL9ct4XYedJukskYVWTo49tNiMEK9C2HTyarbNiT/RVIRSY82vH+6sTg==
309
305"@assemblyscript/loader@^0.10.1": 310"@assemblyscript/loader@^0.10.1":
306 version "0.10.1" 311 version "0.10.1"
307 resolved "https://registry.yarnpkg.com/@assemblyscript/loader/-/loader-0.10.1.tgz#70e45678f06c72fa2e350e8553ec4a4d72b92e06" 312 resolved "https://registry.yarnpkg.com/@assemblyscript/loader/-/loader-0.10.1.tgz#70e45678f06c72fa2e350e8553ec4a4d72b92e06"
@@ -1789,10 +1794,22 @@
1789 read-package-json-fast "^2.0.3" 1794 read-package-json-fast "^2.0.3"
1790 which "^2.0.2" 1795 which "^2.0.2"
1791 1796
1792"@peertube/p2p-media-loader-core@^1.0.13", "@peertube/p2p-media-loader-core@^1.0.8": 1797"@peertube/maildev@^1.2.0":
1793 version "1.0.13" 1798 version "1.2.0"
1794 resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-core/-/p2p-media-loader-core-1.0.13.tgz#36744a291b69c001b2562c1a93017979f8534ff8" 1799 resolved "https://registry.yarnpkg.com/@peertube/maildev/-/maildev-1.2.0.tgz#f25ee9fa6a45c0a6bc99c5392f63139eaa8eb088"
1795 integrity sha512-ArSAaeuxwwBAG0Xd3Gj0TzKObLfJFYzHz9+fREvmUf+GZQEG6qGwWmrdVWL6xjPiEuo6LdFeCOnHSQzAbj/ptg== 1800 integrity sha512-VGog0A2gk0P8UnP0ZjCoYQumELiqqQY5i+gt18avTC7NJNJLUxMRMI045NAVSDFVbqt2EJJPsbZf3LFjUWRtmw==
1801 dependencies:
1802 async "^3.1.0"
1803 commander "^8.3.0"
1804 mailparser-mit "^1.0.0"
1805 rimraf "^3.0.2"
1806 smtp-server "^3.9.0"
1807 wildstring "1.0.9"
1808
1809"@peertube/p2p-media-loader-core@^1.0.14":
1810 version "1.0.14"
1811 resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-core/-/p2p-media-loader-core-1.0.14.tgz#b4442dd343d6b30a51502e1240275eb98ef2c788"
1812 integrity sha512-tjQv1CNziNY+zYzcL1h4q6AA2WuBUZnBIeVyjWR/EsO1EEC1VMdvPsL02cqYLz9yvIxgycjeTsWCm6XDqNgXRw==
1796 dependencies: 1813 dependencies:
1797 bittorrent-tracker "^9.19.0" 1814 bittorrent-tracker "^9.19.0"
1798 debug "^4.3.4" 1815 debug "^4.3.4"
@@ -1800,12 +1817,12 @@
1800 sha.js "^2.4.11" 1817 sha.js "^2.4.11"
1801 simple-peer "^9.11.1" 1818 simple-peer "^9.11.1"
1802 1819
1803"@peertube/p2p-media-loader-hlsjs@^1.0.13": 1820"@peertube/p2p-media-loader-hlsjs@^1.0.14":
1804 version "1.0.13" 1821 version "1.0.14"
1805 resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-1.0.13.tgz#5305e2008041d01850802544d1c49298f79dd67a" 1822 resolved "https://registry.yarnpkg.com/@peertube/p2p-media-loader-hlsjs/-/p2p-media-loader-hlsjs-1.0.14.tgz#829629a57608b0e30f4b50bc98578e6bee9f8b9b"
1806 integrity sha512-2BO2oaRsSHEhLkgi2iw1r4n1Yqq1EnyoOgOZccPDqjmHUsZSV/wNrno8WYr6LsleudrHA26Imu57hVD1jDx7lg== 1823 integrity sha512-ySUVgUvAFXCE5E94xxjfywQ8xzk3jy9UGVkgi5Oqq+QeY7uG+o7CZ+LsQ/RjXgWBD70tEnyyfADHtL+9FCnwyQ==
1807 dependencies: 1824 dependencies:
1808 "@peertube/p2p-media-loader-core" "^1.0.8" 1825 "@peertube/p2p-media-loader-core" "^1.0.14"
1809 debug "^4.3.4" 1826 debug "^4.3.4"
1810 events "^3.3.0" 1827 events "^3.3.0"
1811 m3u8-parser "^4.7.1" 1828 m3u8-parser "^4.7.1"
@@ -1830,6 +1847,16 @@
1830 tokenizr "^1.6.4" 1847 tokenizr "^1.6.4"
1831 xmldom "^0.6.0" 1848 xmldom "^0.6.0"
1832 1849
1850"@polka/parse@^1.0.0-next.0":
1851 version "1.0.0-next.0"
1852 resolved "https://registry.yarnpkg.com/@polka/parse/-/parse-1.0.0-next.0.tgz#3551d792acdf4ad0b053072e57498cbe32e45a94"
1853 integrity sha512-zcPNrc3PNrRLSCQ7ca8XR7h18VxdPIXhn+yvrYMdUFCHM7mhXGSPw5xBdbcf/dQ1cI4uE8pDfmm5uU+HX+WfFg==
1854
1855"@polka/url@^0.5.0":
1856 version "0.5.0"
1857 resolved "https://registry.yarnpkg.com/@polka/url/-/url-0.5.0.tgz#b21510597fd601e5d7c95008b76bf0d254ebfd31"
1858 integrity sha512-oZLYFEAzUKyi3SKnXvj32ZCEGH6RDnao7COuCVhDydMS9NrCSVXhM79VaKyP5+Zc33m0QXEd2DN3UkU7OsHcfw==
1859
1833"@polka/url@^1.0.0-next.20": 1860"@polka/url@^1.0.0-next.20":
1834 version "1.0.0-next.21" 1861 version "1.0.0-next.21"
1835 resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" 1862 resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1"
@@ -2021,6 +2048,11 @@
2021 dependencies: 2048 dependencies:
2022 "@types/node" "*" 2049 "@types/node" "*"
2023 2050
2051"@types/gitconfiglocal@^2.0.1":
2052 version "2.0.1"
2053 resolved "https://registry.yarnpkg.com/@types/gitconfiglocal/-/gitconfiglocal-2.0.1.tgz#c134f9fb03d71917afa35c14f3b82085520509a6"
2054 integrity sha512-AYC38la5dRwIfbrZhPNIvlGHlIbH+kdl2j8A37twoCQyhKPPoRPfVmoBZKajpLIfV7SMboU6MZ6w/RmZLH68IQ==
2055
2024"@types/html-minifier-terser@^6.0.0": 2056"@types/html-minifier-terser@^6.0.0":
2025 version "6.1.0" 2057 version "6.1.0"
2026 resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" 2058 resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35"
@@ -2127,9 +2159,9 @@
2127 "@types/lodash" "*" 2159 "@types/lodash" "*"
2128 2160
2129"@types/lodash@*": 2161"@types/lodash@*":
2130 version "4.14.189" 2162 version "4.14.191"
2131 resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.189.tgz#975ff8c38da5ae58b751127b19ad5e44b5b7f6d2" 2163 resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa"
2132 integrity sha512-kb9/98N6X8gyME9Cf7YaqIMvYGnBSWqEci6tiettE6iJWH1XdJz/PO8LB0GtLCG7x8dU3KWhZT+lA1a35127tA== 2164 integrity sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==
2133 2165
2134"@types/magnet-uri@*": 2166"@types/magnet-uri@*":
2135 version "5.1.3" 2167 version "5.1.3"
@@ -2162,9 +2194,9 @@
2162 integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== 2194 integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
2163 2195
2164"@types/mocha@^10.0.0": 2196"@types/mocha@^10.0.0":
2165 version "10.0.0" 2197 version "10.0.1"
2166 resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.0.tgz#3d9018c575f0e3f7386c1de80ee66cc21fbb7a52" 2198 resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.1.tgz#2f4f65bb08bc368ac39c96da7b2f09140b26851b"
2167 integrity sha512-rADY+HtTOA52l9VZWtgQfn4p+UDVM2eDVkMZT1I6syp0YKxW2F9v+0pbRZLsvskhQv/vMb6ZfCay81GHbz5SHg== 2199 integrity sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q==
2168 2200
2169"@types/mousetrap@^1.6.9": 2201"@types/mousetrap@^1.6.9":
2170 version "1.6.11" 2202 version "1.6.11"
@@ -2177,9 +2209,9 @@
2177 integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== 2209 integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==
2178 2210
2179"@types/node@*", "@types/node@^18.0.0": 2211"@types/node@*", "@types/node@^18.0.0":
2180 version "18.11.9" 2212 version "18.11.18"
2181 resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.9.tgz#02d013de7058cea16d36168ef2fc653464cfbad4" 2213 resolved "https://registry.yarnpkg.com/@types/node/-/node-18.11.18.tgz#8dfb97f0da23c2293e554c5a50d61ef134d7697f"
2182 integrity sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg== 2214 integrity sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==
2183 2215
2184"@types/node@^17.0.42": 2216"@types/node@^17.0.42":
2185 version "17.0.45" 2217 version "17.0.45"
@@ -2380,9 +2412,9 @@
2380 integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== 2412 integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==
2381 2413
2382"@types/yargs@^17.0.8": 2414"@types/yargs@^17.0.8":
2383 version "17.0.13" 2415 version "17.0.19"
2384 resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.13.tgz#34cced675ca1b1d51fcf4d34c3c6f0fa142a5c76" 2416 resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.19.tgz#8dbecdc9ab48bee0cb74f6e3327de3fa0d0c98ae"
2385 integrity sha512-9sWaruZk2JGxIQU+IhI1fhPYRcQ0UuTNuKuCW9bR5fp7qi2Llf7WDzNa17Cy7TKnh3cdxDOiyTu6gaLS0eDatg== 2417 integrity sha512-cAx3qamwaYX9R0fzOIZAlFpo4A+1uBVCxqpKz9D26uTF4srRXaGTTsikQmaotCtNdbhzyUH7ft6p9ktz9s6UNQ==
2386 dependencies: 2418 dependencies:
2387 "@types/yargs-parser" "*" 2419 "@types/yargs-parser" "*"
2388 2420
@@ -2509,22 +2541,26 @@
2509 is-function "^1.0.1" 2541 is-function "^1.0.1"
2510 2542
2511"@wdio/browserstack-service@^7.25.2": 2543"@wdio/browserstack-service@^7.25.2":
2512 version "7.26.0" 2544 version "7.29.1"
2513 resolved "https://registry.yarnpkg.com/@wdio/browserstack-service/-/browserstack-service-7.26.0.tgz#d303c5998e565734bd7f5c23fc9b291a588b7c21" 2545 resolved "https://registry.yarnpkg.com/@wdio/browserstack-service/-/browserstack-service-7.29.1.tgz#46282aa07b7c11a51ebac0bff1f12f1badd6e264"
2514 integrity sha512-hRKmg4u/DRNZm1EJGaYESAH6GsCPCtBm15fP9ngm/HFUG084thFfrD8Tt09hO+KSNoK4tXl4k1ZHZ4akrOq9KA== 2546 integrity sha512-1+MoqlIXIjbh1oEOZcvtemij+Yz/CB6orZjeT3WCoA9oY8Ul8EeIHhfF7GxmE6u0OVofjmC+wfO5NlHYCKgL1w==
2515 dependencies: 2547 dependencies:
2516 "@types/node" "^18.0.0" 2548 "@types/gitconfiglocal" "^2.0.1"
2517 "@wdio/logger" "7.26.0" 2549 "@wdio/logger" "7.26.0"
2550 "@wdio/reporter" "7.25.4"
2518 "@wdio/types" "7.26.0" 2551 "@wdio/types" "7.26.0"
2519 browserstack-local "^1.4.5" 2552 browserstack-local "^1.4.5"
2520 form-data "^4.0.0" 2553 form-data "^4.0.0"
2554 git-repo-info "^2.1.1"
2555 gitconfiglocal "^2.1.0"
2521 got "^11.0.2" 2556 got "^11.0.2"
2522 webdriverio "7.26.0" 2557 uuid "^8.3.2"
2558 webdriverio "7.29.1"
2523 2559
2524"@wdio/cli@^7.25.2": 2560"@wdio/cli@^7.25.2":
2525 version "7.26.0" 2561 version "7.29.1"
2526 resolved "https://registry.yarnpkg.com/@wdio/cli/-/cli-7.26.0.tgz#20c690a5ede4a35cb2f84da9041c250a6013bc54" 2562 resolved "https://registry.yarnpkg.com/@wdio/cli/-/cli-7.29.1.tgz#1b47f5a45f21754d42be814dbae94ff723a6a1a2"
2527 integrity sha512-xG+ZIzPqzz/Tvhfrogd8oNvTXzzdE+cbkmTHjMGo1hnmnoAQPeAEcV/QqaX5CHFE9DjaguEeadqjcZikB5U2GQ== 2563 integrity sha512-dldHNYlnuFUG10TlENbeL41tujqgYD7S/9nzV1J/szBryCO6AIVz/QWn/AUv3zrsO2sn8TNF8BMEXRvLgCxyeg==
2528 dependencies: 2564 dependencies:
2529 "@types/ejs" "^3.0.5" 2565 "@types/ejs" "^3.0.5"
2530 "@types/fs-extra" "^9.0.4" 2566 "@types/fs-extra" "^9.0.4"
@@ -2536,7 +2572,7 @@
2536 "@types/recursive-readdir" "^2.2.0" 2572 "@types/recursive-readdir" "^2.2.0"
2537 "@wdio/config" "7.26.0" 2573 "@wdio/config" "7.26.0"
2538 "@wdio/logger" "7.26.0" 2574 "@wdio/logger" "7.26.0"
2539 "@wdio/protocols" "7.22.0" 2575 "@wdio/protocols" "7.27.0"
2540 "@wdio/types" "7.26.0" 2576 "@wdio/types" "7.26.0"
2541 "@wdio/utils" "7.26.0" 2577 "@wdio/utils" "7.26.0"
2542 async-exit-hook "^2.0.1" 2578 async-exit-hook "^2.0.1"
@@ -2551,7 +2587,7 @@
2551 lodash.union "^4.6.0" 2587 lodash.union "^4.6.0"
2552 mkdirp "^1.0.4" 2588 mkdirp "^1.0.4"
2553 recursive-readdir "^2.2.2" 2589 recursive-readdir "^2.2.2"
2554 webdriverio "7.26.0" 2590 webdriverio "7.29.1"
2555 yargs "^17.0.0" 2591 yargs "^17.0.0"
2556 yarn-install "^1.0.0" 2592 yarn-install "^1.0.0"
2557 2593
@@ -2567,14 +2603,14 @@
2567 glob "^8.0.3" 2603 glob "^8.0.3"
2568 2604
2569"@wdio/local-runner@^7.25.2": 2605"@wdio/local-runner@^7.25.2":
2570 version "7.26.0" 2606 version "7.29.1"
2571 resolved "https://registry.yarnpkg.com/@wdio/local-runner/-/local-runner-7.26.0.tgz#a056c6e9d73c7f48e54fe3f07ce573a90dae26ab" 2607 resolved "https://registry.yarnpkg.com/@wdio/local-runner/-/local-runner-7.29.1.tgz#f93a2953847b4271b59ba1b9635920e8046f0e55"
2572 integrity sha512-GdCP7Y8s8qvoctC0WaSGBSmTSbVw74WEJm6Y3n3DpoCI8ABFNkQlhFlqJH+taQDs3sRVEM65bHGcU4C4FOVWXQ== 2608 integrity sha512-4w9Dsp9/4+MEU8yG7M8ynsCqpSP6UbKqZ2M/gWpvkvy57rb3eS9evFdIFfRzuQmbsztG9qeAlGILwlZ4/oaopg==
2573 dependencies: 2609 dependencies:
2574 "@types/stream-buffers" "^3.0.3" 2610 "@types/stream-buffers" "^3.0.3"
2575 "@wdio/logger" "7.26.0" 2611 "@wdio/logger" "7.26.0"
2576 "@wdio/repl" "7.26.0" 2612 "@wdio/repl" "7.26.0"
2577 "@wdio/runner" "7.26.0" 2613 "@wdio/runner" "7.29.1"
2578 "@wdio/types" "7.26.0" 2614 "@wdio/types" "7.26.0"
2579 async-exit-hook "^2.0.1" 2615 async-exit-hook "^2.0.1"
2580 split2 "^4.0.0" 2616 split2 "^4.0.0"
@@ -2602,10 +2638,10 @@
2602 expect-webdriverio "^3.0.0" 2638 expect-webdriverio "^3.0.0"
2603 mocha "^10.0.0" 2639 mocha "^10.0.0"
2604 2640
2605"@wdio/protocols@7.22.0": 2641"@wdio/protocols@7.27.0":
2606 version "7.22.0" 2642 version "7.27.0"
2607 resolved "https://registry.yarnpkg.com/@wdio/protocols/-/protocols-7.22.0.tgz#d89faef687cb08981d734bbc5e5dffc6fb5a064c" 2643 resolved "https://registry.yarnpkg.com/@wdio/protocols/-/protocols-7.27.0.tgz#8e2663ec877dce7a5f76b021209c18dd0132e853"
2608 integrity sha512-8EXRR+Ymdwousm/VGtW3H1hwxZ/1g1H99A1lF0U4GuJ5cFWHCd0IVE5H31Z52i8ZruouW8jueMkGZPSo2IIUSQ== 2644 integrity sha512-hT/U22R5i3HhwPjkaKAG0yd59eaOaZB0eibRj2+esCImkb5Y6rg8FirrlYRxIGFVBl0+xZV0jKHzR5+o097nvg==
2609 2645
2610"@wdio/repl@7.26.0": 2646"@wdio/repl@7.26.0":
2611 version "7.26.0" 2647 version "7.26.0"
@@ -2614,10 +2650,26 @@
2614 dependencies: 2650 dependencies:
2615 "@wdio/utils" "7.26.0" 2651 "@wdio/utils" "7.26.0"
2616 2652
2617"@wdio/reporter@7.26.0": 2653"@wdio/reporter@7.25.4":
2618 version "7.26.0" 2654 version "7.25.4"
2619 resolved "https://registry.yarnpkg.com/@wdio/reporter/-/reporter-7.26.0.tgz#26c0e7114a4c1e7b29a79e4d178e5312e04d7934" 2655 resolved "https://registry.yarnpkg.com/@wdio/reporter/-/reporter-7.25.4.tgz#b6a69652dd0c4ec131255000af128eac403a18b9"
2620 integrity sha512-kEb7i1A4V4E1wJgdyvLsDbap4cEp1fPZslErGtbAbK+9HI8Lt/SlTZCiOpZbvhgzvawEqOV6UqxZT1RsL8wZWw== 2656 integrity sha512-M37qzEmF5qNffyZmRQGjDlrXqWW21EFvgW8wsv1b/NtfpZc0c0MoRpeh6BnvX1KcE4nCXfjXgSJPOqV4ZCzUEQ==
2657 dependencies:
2658 "@types/diff" "^5.0.0"
2659 "@types/node" "^18.0.0"
2660 "@types/object-inspect" "^1.8.0"
2661 "@types/supports-color" "^8.1.0"
2662 "@types/tmp" "^0.2.0"
2663 "@wdio/types" "7.25.4"
2664 diff "^5.0.0"
2665 fs-extra "^10.0.0"
2666 object-inspect "^1.10.3"
2667 supports-color "8.1.1"
2668
2669"@wdio/reporter@7.29.1":
2670 version "7.29.1"
2671 resolved "https://registry.yarnpkg.com/@wdio/reporter/-/reporter-7.29.1.tgz#7fc2e3b7aa3843172dcd97221c44257384cbbd27"
2672 integrity sha512-mpusCpbw7RxnJSDu9qa1qv5IfEMCh7377y1Typ4J2TlMy+78CQzGZ8coEXjBxLcqijTUwcyyoLNI5yRSvbDExw==
2621 dependencies: 2673 dependencies:
2622 "@types/diff" "^5.0.0" 2674 "@types/diff" "^5.0.0"
2623 "@types/node" "^18.0.0" 2675 "@types/node" "^18.0.0"
@@ -2630,10 +2682,10 @@
2630 object-inspect "^1.10.3" 2682 object-inspect "^1.10.3"
2631 supports-color "8.1.1" 2683 supports-color "8.1.1"
2632 2684
2633"@wdio/runner@7.26.0": 2685"@wdio/runner@7.29.1":
2634 version "7.26.0" 2686 version "7.29.1"
2635 resolved "https://registry.yarnpkg.com/@wdio/runner/-/runner-7.26.0.tgz#c0b2848dc885b655e8690d3e0381dfb0ad221af5" 2687 resolved "https://registry.yarnpkg.com/@wdio/runner/-/runner-7.29.1.tgz#9fd2fa6dd28b8b130a10d23452eb155e1e887576"
2636 integrity sha512-DhQiOs10oPeLlv7/R+997arPg5OY7iEgespGkn6r+kdx2o+awxa6PFegQrjJmRKUmNv3TTuKXHouP34TbR/8sw== 2688 integrity sha512-lJEk/HJ5IiuvAJws8zTx9XL5LJuoexvjWIZmOmFJ6Gv8qRpUx6b0n+JM7vhhbTeIqs4QLXOwTQUHlDDRldQlzQ==
2637 dependencies: 2689 dependencies:
2638 "@wdio/config" "7.26.0" 2690 "@wdio/config" "7.26.0"
2639 "@wdio/logger" "7.26.0" 2691 "@wdio/logger" "7.26.0"
@@ -2641,21 +2693,41 @@
2641 "@wdio/utils" "7.26.0" 2693 "@wdio/utils" "7.26.0"
2642 deepmerge "^4.0.0" 2694 deepmerge "^4.0.0"
2643 gaze "^1.1.2" 2695 gaze "^1.1.2"
2644 webdriver "7.26.0" 2696 webdriver "7.27.0"
2645 webdriverio "7.26.0" 2697 webdriverio "7.29.1"
2698
2699"@wdio/shared-store-service@^7.25.2":
2700 version "7.29.1"
2701 resolved "https://registry.yarnpkg.com/@wdio/shared-store-service/-/shared-store-service-7.29.1.tgz#c43a3dbc7d47c8334970bc173e963688977e8a79"
2702 integrity sha512-13VOxyz956DSs2wloQ8gtyEx42zjAuOg+N8/4tGk1p2igPzHB2qUiY/P0yi6zamxYGb6PKLIumIeUjitWHtyWA==
2703 dependencies:
2704 "@polka/parse" "^1.0.0-next.0"
2705 "@wdio/logger" "7.26.0"
2706 "@wdio/types" "7.26.0"
2707 got "^11.0.2"
2708 polka "^0.5.2"
2709 webdriverio "7.29.1"
2646 2710
2647"@wdio/spec-reporter@^7.25.1": 2711"@wdio/spec-reporter@^7.25.1":
2648 version "7.26.0" 2712 version "7.29.1"
2649 resolved "https://registry.yarnpkg.com/@wdio/spec-reporter/-/spec-reporter-7.26.0.tgz#13eaa5a0fd089684d4c1bcd8ac11dc8646afb5b7" 2713 resolved "https://registry.yarnpkg.com/@wdio/spec-reporter/-/spec-reporter-7.29.1.tgz#08e13c02ea0876672226d5a2c326dda7e1a66c8e"
2650 integrity sha512-oisyVWn+MRoq0We0qORoDHNk+iKr7CFG4+IE5GCRecR8cgP7dUjVXZcEbn6blgRpry4jOxsAl24frfaPDOsZVA== 2714 integrity sha512-bwSGM72QrDedqacY7Wq9Gn86VgRwIGPYzZtcaD7aDnvppCuV8Z/31Wpdfen+CzUk2+whXjXKe66ohPyl9TG5+w==
2651 dependencies: 2715 dependencies:
2652 "@types/easy-table" "^1.2.0" 2716 "@types/easy-table" "^1.2.0"
2653 "@wdio/reporter" "7.26.0" 2717 "@wdio/reporter" "7.29.1"
2654 "@wdio/types" "7.26.0" 2718 "@wdio/types" "7.26.0"
2655 chalk "^4.0.0" 2719 chalk "^4.0.0"
2656 easy-table "^1.1.1" 2720 easy-table "^1.1.1"
2657 pretty-ms "^7.0.0" 2721 pretty-ms "^7.0.0"
2658 2722
2723"@wdio/types@7.25.4":
2724 version "7.25.4"
2725 resolved "https://registry.yarnpkg.com/@wdio/types/-/types-7.25.4.tgz#6f8f028e3108dc880de5068264695f1572e65352"
2726 integrity sha512-muvNmq48QZCvocctnbe0URq2FjJjUPIG4iLoeMmyF0AQgdbjaUkMkw3BHYNHVTbSOU9WMsr2z8alhj/I2H6NRQ==
2727 dependencies:
2728 "@types/node" "^18.0.0"
2729 got "^11.8.1"
2730
2659"@wdio/types@7.26.0": 2731"@wdio/types@7.26.0":
2660 version "7.26.0" 2732 version "7.26.0"
2661 resolved "https://registry.yarnpkg.com/@wdio/types/-/types-7.26.0.tgz#70bc879c5dbe316a0eebbac4a46f0f66430b1d84" 2733 resolved "https://registry.yarnpkg.com/@wdio/types/-/types-7.26.0.tgz#70bc879c5dbe316a0eebbac4a46f0f66430b1d84"
@@ -2882,6 +2954,11 @@ addr-to-ip-port@^1.0.1, addr-to-ip-port@^1.5.4:
2882 resolved "https://registry.yarnpkg.com/addr-to-ip-port/-/addr-to-ip-port-1.5.4.tgz#9542b1c6219fdb8c9ce6cc72c14ee880ab7ddd88" 2954 resolved "https://registry.yarnpkg.com/addr-to-ip-port/-/addr-to-ip-port-1.5.4.tgz#9542b1c6219fdb8c9ce6cc72c14ee880ab7ddd88"
2883 integrity sha512-ByxmJgv8vjmDcl3IDToxL2yrWFrRtFpZAToY0f46XFXl8zS081t7El5MXIodwm7RC6DhHBRoOSMLFSPKCtHukg== 2955 integrity sha512-ByxmJgv8vjmDcl3IDToxL2yrWFrRtFpZAToY0f46XFXl8zS081t7El5MXIodwm7RC6DhHBRoOSMLFSPKCtHukg==
2884 2956
2957addressparser@^1.0.1:
2958 version "1.0.1"
2959 resolved "https://registry.yarnpkg.com/addressparser/-/addressparser-1.0.1.tgz#47afbe1a2a9262191db6838e4fd1d39b40821746"
2960 integrity sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg==
2961
2885adjust-sourcemap-loader@^4.0.0: 2962adjust-sourcemap-loader@^4.0.0:
2886 version "4.0.0" 2963 version "4.0.0"
2887 resolved "https://registry.yarnpkg.com/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz#fc4a0fd080f7d10471f30a7320f25560ade28c99" 2964 resolved "https://registry.yarnpkg.com/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz#fc4a0fd080f7d10471f30a7320f25560ade28c99"
@@ -3057,9 +3134,9 @@ ansi-styles@^5.0.0:
3057 integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== 3134 integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
3058 3135
3059anymatch@~3.1.2: 3136anymatch@~3.1.2:
3060 version "3.1.2" 3137 version "3.1.3"
3061 resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" 3138 resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
3062 integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== 3139 integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
3063 dependencies: 3140 dependencies:
3064 normalize-path "^3.0.0" 3141 normalize-path "^3.0.0"
3065 picomatch "^2.0.4" 3142 picomatch "^2.0.4"
@@ -3188,7 +3265,7 @@ async-exit-hook@^2.0.1:
3188 resolved "https://registry.yarnpkg.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz#8bd8b024b0ec9b1c01cccb9af9db29bd717dfaf3" 3265 resolved "https://registry.yarnpkg.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz#8bd8b024b0ec9b1c01cccb9af9db29bd717dfaf3"
3189 integrity sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw== 3266 integrity sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==
3190 3267
3191async@^3.2.3: 3268async@^3.1.0, async@^3.2.3:
3192 version "3.2.4" 3269 version "3.2.4"
3193 resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" 3270 resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c"
3194 integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== 3271 integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==
@@ -3327,6 +3404,11 @@ balanced-match@^2.0.0:
3327 resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9" 3404 resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-2.0.0.tgz#dc70f920d78db8b858535795867bf48f820633d9"
3328 integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA== 3405 integrity sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==
3329 3406
3407base32.js@0.1.0:
3408 version "0.1.0"
3409 resolved "https://registry.yarnpkg.com/base32.js/-/base32.js-0.1.0.tgz#b582dec693c2f11e893cf064ee6ac5b6131a2202"
3410 integrity sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==
3411
3330base64-js@^1.2.0, base64-js@^1.3.1: 3412base64-js@^1.2.0, base64-js@^1.3.1:
3331 version "1.5.1" 3413 version "1.5.1"
3332 resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" 3414 resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@@ -3933,9 +4015,9 @@ chunk-store-stream@^4.3.0:
3933 readable-stream "^3.6.0" 4015 readable-stream "^3.6.0"
3934 4016
3935ci-info@^3.2.0: 4017ci-info@^3.2.0:
3936 version "3.6.1" 4018 version "3.7.1"
3937 resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.6.1.tgz#7594f1c95cb7fdfddee7af95a13af7dbc67afdcf" 4019 resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.7.1.tgz#708a6cdae38915d597afdf3b145f2f8e1ff55f3f"
3938 integrity sha512-up5ggbaDqOqJ4UqLKZ2naVkyqSJQgJi5lwD6b6mM748ysrghDBX0bx/qJTUHzw7zu6Mq4gycviSF5hJnwceD8w== 4020 integrity sha512-4jYS4MOAaCIStSRwiuxc4B8MYhIe676yO1sYGzARnjXkWpmzZMMYxY6zu8WYWDhSuth5zhrQ1rhNSibyyvv4/w==
3939 4021
3940clean-css@5.2.0: 4022clean-css@5.2.0:
3941 version "5.2.0" 4023 version "5.2.0"
@@ -4504,16 +4586,18 @@ decompress-response@^6.0.0:
4504 mimic-response "^3.1.0" 4586 mimic-response "^3.1.0"
4505 4587
4506deep-equal@^2.0.5: 4588deep-equal@^2.0.5:
4507 version "2.1.0" 4589 version "2.2.0"
4508 resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.1.0.tgz#5ba60402cf44ab92c2c07f3f3312c3d857a0e1dd" 4590 resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.2.0.tgz#5caeace9c781028b9ff459f33b779346637c43e6"
4509 integrity sha512-2pxgvWu3Alv1PoWEyVg7HS8YhGlUFUV7N5oOvfL6d+7xAmLSemMwv/c8Zv/i9KFzxV5Kt5CAvQc70fLwVuf4UA== 4591 integrity sha512-RdpzE0Hv4lhowpIUKKMJfeH6C1pXdtT1/it80ubgWqwI3qpuxUBpC1S4hnHg+zjnuOoDkzUtUCEEkG+XG5l3Mw==
4510 dependencies: 4592 dependencies:
4511 call-bind "^1.0.2" 4593 call-bind "^1.0.2"
4512 es-get-iterator "^1.1.2" 4594 es-get-iterator "^1.1.2"
4513 get-intrinsic "^1.1.3" 4595 get-intrinsic "^1.1.3"
4514 is-arguments "^1.1.1" 4596 is-arguments "^1.1.1"
4597 is-array-buffer "^3.0.1"
4515 is-date-object "^1.0.5" 4598 is-date-object "^1.0.5"
4516 is-regex "^1.1.4" 4599 is-regex "^1.1.4"
4600 is-shared-array-buffer "^1.0.2"
4517 isarray "^2.0.5" 4601 isarray "^2.0.5"
4518 object-is "^1.1.5" 4602 object-is "^1.1.5"
4519 object-keys "^1.1.1" 4603 object-keys "^1.1.1"
@@ -4522,7 +4606,7 @@ deep-equal@^2.0.5:
4522 side-channel "^1.0.4" 4606 side-channel "^1.0.4"
4523 which-boxed-primitive "^1.0.2" 4607 which-boxed-primitive "^1.0.2"
4524 which-collection "^1.0.1" 4608 which-collection "^1.0.1"
4525 which-typed-array "^1.1.8" 4609 which-typed-array "^1.1.9"
4526 4610
4527deep-is@^0.1.3: 4611deep-is@^0.1.3:
4528 version "0.1.4" 4612 version "0.1.4"
@@ -4606,21 +4690,21 @@ devtools-protocol@0.0.981744:
4606 resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.981744.tgz#9960da0370284577d46c28979a0b32651022bacf" 4690 resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.981744.tgz#9960da0370284577d46c28979a0b32651022bacf"
4607 integrity sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg== 4691 integrity sha512-0cuGS8+jhR67Fy7qG3i3Pc7Aw494sb9yG9QgpG97SFVWwolgYjlhJg7n+UaHxOQT30d1TYu/EYe9k01ivLErIg==
4608 4692
4609devtools-protocol@^0.0.1069585: 4693devtools-protocol@^0.0.1085790:
4610 version "0.0.1069585" 4694 version "0.0.1085790"
4611 resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1069585.tgz#c9a9f330462aabf054d581f254b13774297b84f2" 4695 resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1085790.tgz#315e4700eb960cf111cc908b9be2caca2257cb13"
4612 integrity sha512-sHmkZB6immWQWU4Wx3ogXwxjQUvQc92MmUDL52+q1z2hQmvpOcvDmbsjwX7QZOPTA32dMV7fgT6zUytcpPzy4A== 4696 integrity sha512-f5kfwdOTxPqX5v8ZfAAl9xBgoEVazBYtIONDWIRqYbb7yjOIcnk6vpzCgBCQvav5AuBRLzyUGG0V74OAx93LoA==
4613 4697
4614devtools@7.26.0: 4698devtools@7.28.1:
4615 version "7.26.0" 4699 version "7.28.1"
4616 resolved "https://registry.yarnpkg.com/devtools/-/devtools-7.26.0.tgz#3d568aea2238d190ad0cd71c00483c07c707124a" 4700 resolved "https://registry.yarnpkg.com/devtools/-/devtools-7.28.1.tgz#9699e0ca41c9a3adfa351d8afac2928f8e1d381c"
4617 integrity sha512-+8HNbNpzgo4Sn+WcrvXuwsHW9XPJfLo4bs9lgs6DPJHIIDXYJXQGsd7940wMX0Rp0D2vHXA4ibK0oTI5rogM3Q== 4701 integrity sha512-sDoszzrXDMLiBQqsg9A5gDqDBwhH4sjYzJIW15lQinB8qgNs0y4o1zdfNlqiKs4HstCA2uFixQeibbDCyMa7hQ==
4618 dependencies: 4702 dependencies:
4619 "@types/node" "^18.0.0" 4703 "@types/node" "^18.0.0"
4620 "@types/ua-parser-js" "^0.7.33" 4704 "@types/ua-parser-js" "^0.7.33"
4621 "@wdio/config" "7.26.0" 4705 "@wdio/config" "7.26.0"
4622 "@wdio/logger" "7.26.0" 4706 "@wdio/logger" "7.26.0"
4623 "@wdio/protocols" "7.22.0" 4707 "@wdio/protocols" "7.27.0"
4624 "@wdio/types" "7.26.0" 4708 "@wdio/types" "7.26.0"
4625 "@wdio/utils" "7.26.0" 4709 "@wdio/utils" "7.26.0"
4626 chrome-launcher "^0.15.0" 4710 chrome-launcher "^0.15.0"
@@ -4923,18 +5007,19 @@ es-abstract@^1.19.0, es-abstract@^1.20.4:
4923 unbox-primitive "^1.0.2" 5007 unbox-primitive "^1.0.2"
4924 5008
4925es-get-iterator@^1.1.2: 5009es-get-iterator@^1.1.2:
4926 version "1.1.2" 5010 version "1.1.3"
4927 resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.2.tgz#9234c54aba713486d7ebde0220864af5e2b283f7" 5011 resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.3.tgz#3ef87523c5d464d41084b2c3c9c214f1199763d6"
4928 integrity sha512-+DTO8GYwbMCwbywjimwZMHp8AuYXOS2JZFWoi2AlPOS3ebnII9w/NLpNZtA7A0YLaVDw+O7KFCeoIV7OPvM7hQ== 5012 integrity sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==
4929 dependencies: 5013 dependencies:
4930 call-bind "^1.0.2" 5014 call-bind "^1.0.2"
4931 get-intrinsic "^1.1.0" 5015 get-intrinsic "^1.1.3"
4932 has-symbols "^1.0.1" 5016 has-symbols "^1.0.3"
4933 is-arguments "^1.1.0" 5017 is-arguments "^1.1.1"
4934 is-map "^2.0.2" 5018 is-map "^2.0.2"
4935 is-set "^2.0.2" 5019 is-set "^2.0.2"
4936 is-string "^1.0.5" 5020 is-string "^1.0.7"
4937 isarray "^2.0.5" 5021 isarray "^2.0.5"
5022 stop-iteration-iterator "^1.0.0"
4938 5023
4939es-module-lexer@^0.9.0: 5024es-module-lexer@^0.9.0:
4940 version "0.9.3" 5025 version "0.9.3"
@@ -5104,7 +5189,7 @@ escape-string-regexp@4.0.0, escape-string-regexp@^4.0.0:
5104 resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" 5189 resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
5105 integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== 5190 integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
5106 5191
5107escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: 5192escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5, escape-string-regexp@~1.0.5:
5108 version "1.0.5" 5193 version "1.0.5"
5109 resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" 5194 resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
5110 integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== 5195 integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
@@ -5404,7 +5489,7 @@ express@^4.17.3:
5404 utils-merge "1.0.1" 5489 utils-merge "1.0.1"
5405 vary "~1.1.2" 5490 vary "~1.1.2"
5406 5491
5407extend@~3.0.2: 5492extend@~3.0.0, extend@~3.0.2:
5408 version "3.0.2" 5493 version "3.0.2"
5409 resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" 5494 resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
5410 integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== 5495 integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
@@ -5856,6 +5941,18 @@ getpass@^0.1.1:
5856 dependencies: 5941 dependencies:
5857 assert-plus "^1.0.0" 5942 assert-plus "^1.0.0"
5858 5943
5944git-repo-info@^2.1.1:
5945 version "2.1.1"
5946 resolved "https://registry.yarnpkg.com/git-repo-info/-/git-repo-info-2.1.1.tgz#220ffed8cbae74ef8a80e3052f2ccb5179aed058"
5947 integrity sha512-8aCohiDo4jwjOwma4FmYFd3i97urZulL8XL24nIPxuE+GZnfsAyy/g2Shqx6OjUiFKUXZM+Yy+KHnOmmA3FVcg==
5948
5949gitconfiglocal@^2.1.0:
5950 version "2.1.0"
5951 resolved "https://registry.yarnpkg.com/gitconfiglocal/-/gitconfiglocal-2.1.0.tgz#07c28685c55cc5338b27b5acbcfe34aeb92e43d1"
5952 integrity sha512-qoerOEliJn3z+Zyn1HW2F6eoYJqKwS6MgC9cztTLUB/xLWX8gD/6T60pKn4+t/d6tP7JlybI7Z3z+I572CR/Vg==
5953 dependencies:
5954 ini "^1.3.2"
5955
5859glob-parent@^5.1.2, glob-parent@~5.1.2: 5956glob-parent@^5.1.2, glob-parent@~5.1.2:
5860 version "5.1.2" 5957 version "5.1.2"
5861 resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" 5958 resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
@@ -5887,7 +5984,7 @@ glob@7.2.0:
5887 once "^1.3.0" 5984 once "^1.3.0"
5888 path-is-absolute "^1.0.0" 5985 path-is-absolute "^1.0.0"
5889 5986
5890glob@8.0.3, glob@^8.0.1, glob@^8.0.3: 5987glob@8.0.3, glob@^8.0.1:
5891 version "8.0.3" 5988 version "8.0.3"
5892 resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.3.tgz#415c6eb2deed9e502c68fa44a272e6da6eeca42e" 5989 resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.3.tgz#415c6eb2deed9e502c68fa44a272e6da6eeca42e"
5893 integrity sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ== 5990 integrity sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==
@@ -5910,6 +6007,17 @@ glob@^7.0.5, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
5910 once "^1.3.0" 6007 once "^1.3.0"
5911 path-is-absolute "^1.0.0" 6008 path-is-absolute "^1.0.0"
5912 6009
6010glob@^8.0.3:
6011 version "8.1.0"
6012 resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e"
6013 integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==
6014 dependencies:
6015 fs.realpath "^1.0.0"
6016 inflight "^1.0.4"
6017 inherits "2"
6018 minimatch "^5.0.1"
6019 once "^1.3.0"
6020
5913glob@~7.1.1: 6021glob@~7.1.1:
5914 version "7.1.7" 6022 version "7.1.7"
5915 resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" 6023 resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
@@ -6002,7 +6110,7 @@ gopd@^1.0.1:
6002 dependencies: 6110 dependencies:
6003 get-intrinsic "^1.1.3" 6111 get-intrinsic "^1.1.3"
6004 6112
6005got@11.8.5, got@^11.0.2, got@^11.8.1: 6113got@11.8.5:
6006 version "11.8.5" 6114 version "11.8.5"
6007 resolved "https://registry.yarnpkg.com/got/-/got-11.8.5.tgz#ce77d045136de56e8f024bebb82ea349bc730046" 6115 resolved "https://registry.yarnpkg.com/got/-/got-11.8.5.tgz#ce77d045136de56e8f024bebb82ea349bc730046"
6008 integrity sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ== 6116 integrity sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ==
@@ -6019,6 +6127,23 @@ got@11.8.5, got@^11.0.2, got@^11.8.1:
6019 p-cancelable "^2.0.0" 6127 p-cancelable "^2.0.0"
6020 responselike "^2.0.0" 6128 responselike "^2.0.0"
6021 6129
6130got@^11.0.2, got@^11.8.1:
6131 version "11.8.6"
6132 resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a"
6133 integrity sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==
6134 dependencies:
6135 "@sindresorhus/is" "^4.0.0"
6136 "@szmarczak/http-timer" "^4.0.5"
6137 "@types/cacheable-request" "^6.0.1"
6138 "@types/responselike" "^1.0.0"
6139 cacheable-lookup "^5.0.3"
6140 cacheable-request "^7.0.2"
6141 decompress-response "^6.0.0"
6142 http2-wrapper "^1.0.0-beta.5.2"
6143 lowercase-keys "^2.0.0"
6144 p-cancelable "^2.0.0"
6145 responselike "^2.0.0"
6146
6022graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: 6147graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9:
6023 version "4.2.10" 6148 version "4.2.10"
6024 resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" 6149 resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
@@ -6093,7 +6218,7 @@ has-property-descriptors@^1.0.0:
6093 dependencies: 6218 dependencies:
6094 get-intrinsic "^1.1.1" 6219 get-intrinsic "^1.1.1"
6095 6220
6096has-symbols@^1.0.1, has-symbols@^1.0.2, has-symbols@^1.0.3: 6221has-symbols@^1.0.2, has-symbols@^1.0.3:
6097 version "1.0.3" 6222 version "1.0.3"
6098 resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" 6223 resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
6099 integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== 6224 integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
@@ -6347,7 +6472,7 @@ humanize-ms@^1.2.1:
6347 dependencies: 6472 dependencies:
6348 ms "^2.0.0" 6473 ms "^2.0.0"
6349 6474
6350iconv-lite@0.4.24, iconv-lite@^0.4.24: 6475iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.24:
6351 version "0.4.24" 6476 version "0.4.24"
6352 resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" 6477 resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
6353 integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== 6478 integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@@ -6469,7 +6594,7 @@ ini@3.0.0:
6469 resolved "https://registry.yarnpkg.com/ini/-/ini-3.0.0.tgz#2f6de95006923aa75feed8894f5686165adc08f1" 6594 resolved "https://registry.yarnpkg.com/ini/-/ini-3.0.0.tgz#2f6de95006923aa75feed8894f5686165adc08f1"
6470 integrity sha512-TxYQaeNW/N8ymDvwAxPyRbhMBtnEwuvaTYpOQkFx1nSeusgezHniEc/l35Vo4iCq/mMiTJbpD7oYxN98hFlfmw== 6595 integrity sha512-TxYQaeNW/N8ymDvwAxPyRbhMBtnEwuvaTYpOQkFx1nSeusgezHniEc/l35Vo4iCq/mMiTJbpD7oYxN98hFlfmw==
6471 6596
6472ini@^1.3.5: 6597ini@^1.3.2, ini@^1.3.5:
6473 version "1.3.8" 6598 version "1.3.8"
6474 resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" 6599 resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
6475 integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== 6600 integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
@@ -6504,6 +6629,15 @@ internal-slot@^1.0.3:
6504 has "^1.0.3" 6629 has "^1.0.3"
6505 side-channel "^1.0.4" 6630 side-channel "^1.0.4"
6506 6631
6632internal-slot@^1.0.4:
6633 version "1.0.4"
6634 resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.4.tgz#8551e7baf74a7a6ba5f749cfb16aa60722f0d6f3"
6635 integrity sha512-tA8URYccNzMo94s5MQZgH8NB/XTa6HsOo0MLfXTKKEnHVVdegzaQoFZ7Jp44bdvLvY2waT5dc+j5ICEswhi7UQ==
6636 dependencies:
6637 get-intrinsic "^1.1.3"
6638 has "^1.0.3"
6639 side-channel "^1.0.4"
6640
6507interpret@^2.2.0: 6641interpret@^2.2.0:
6508 version "2.2.0" 6642 version "2.2.0"
6509 resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" 6643 resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
@@ -6556,7 +6690,12 @@ ipaddr.js@1.9.1:
6556 resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0" 6690 resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0"
6557 integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng== 6691 integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==
6558 6692
6559is-arguments@^1.1.0, is-arguments@^1.1.1: 6693ipv6-normalize@1.0.1:
6694 version "1.0.1"
6695 resolved "https://registry.yarnpkg.com/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz#1b3258290d365fa83239e89907dde4592e7620a8"
6696 integrity sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==
6697
6698is-arguments@^1.1.1:
6560 version "1.1.1" 6699 version "1.1.1"
6561 resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" 6700 resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b"
6562 integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== 6701 integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==
@@ -6564,6 +6703,15 @@ is-arguments@^1.1.0, is-arguments@^1.1.1:
6564 call-bind "^1.0.2" 6703 call-bind "^1.0.2"
6565 has-tostringtag "^1.0.0" 6704 has-tostringtag "^1.0.0"
6566 6705
6706is-array-buffer@^3.0.1:
6707 version "3.0.1"
6708 resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.1.tgz#deb1db4fcae48308d54ef2442706c0393997052a"
6709 integrity sha512-ASfLknmY8Xa2XtB4wmbz13Wu202baeA18cJBCeCy0wXUHZF0IPyVEXqKEcd+t2fNSLLL1vC6k7lxZEojNbISXQ==
6710 dependencies:
6711 call-bind "^1.0.2"
6712 get-intrinsic "^1.1.3"
6713 is-typed-array "^1.1.10"
6714
6567is-arrayish@^0.2.1: 6715is-arrayish@^0.2.1:
6568 version "0.2.1" 6716 version "0.2.1"
6569 resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" 6717 resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
@@ -7508,6 +7656,16 @@ magnet-uri@^6.2.0:
7508 bep53-range "^1.1.0" 7656 bep53-range "^1.1.0"
7509 thirty-two "^1.0.2" 7657 thirty-two "^1.0.2"
7510 7658
7659mailparser-mit@^1.0.0:
7660 version "1.0.0"
7661 resolved "https://registry.yarnpkg.com/mailparser-mit/-/mailparser-mit-1.0.0.tgz#19df8436c2a02e1d34a03ec518a2eb065e0a94a4"
7662 integrity sha512-sckRITNb3VCT1sQ275g47MAN786pQ5lU20bLY5f794dF/ARGzuVATQ64gO13FOw8jayjFT10e5ttsripKGGXcw==
7663 dependencies:
7664 addressparser "^1.0.1"
7665 iconv-lite "~0.4.24"
7666 mime "^1.6.0"
7667 uue "^3.1.0"
7668
7511make-dir@^2.1.0: 7669make-dir@^2.1.0:
7512 version "2.1.0" 7670 version "2.1.0"
7513 resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5" 7671 resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
@@ -7576,6 +7734,13 @@ marky@^1.2.2:
7576 resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0" 7734 resolved "https://registry.yarnpkg.com/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0"
7577 integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q== 7735 integrity sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==
7578 7736
7737matchit@^1.0.0:
7738 version "1.1.0"
7739 resolved "https://registry.yarnpkg.com/matchit/-/matchit-1.1.0.tgz#c4ccf17d9c824cc1301edbcffde9b75a61d10a7c"
7740 integrity sha512-+nGYoOlfHmxe5BW5tE0EMJppXEwdSf8uBA1GTZC7Q77kbT35+VKLYJMzVNWCHSsga1ps1tPYFtFyvxvKzWVmMA==
7741 dependencies:
7742 "@arr/every" "^1.0.0"
7743
7579mathml-tag-names@^2.1.3: 7744mathml-tag-names@^2.1.3:
7580 version "2.1.3" 7745 version "2.1.3"
7581 resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3" 7746 resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3"
@@ -7679,7 +7844,7 @@ mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17,
7679 dependencies: 7844 dependencies:
7680 mime-db "1.52.0" 7845 mime-db "1.52.0"
7681 7846
7682mime@1.6.0, mime@^1.4.1: 7847mime@1.6.0, mime@^1.4.1, mime@^1.6.0:
7683 version "1.6.0" 7848 version "1.6.0"
7684 resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" 7849 resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
7685 integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== 7850 integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
@@ -7740,7 +7905,7 @@ minimatch@5.0.1:
7740 dependencies: 7905 dependencies:
7741 brace-expansion "^2.0.1" 7906 brace-expansion "^2.0.1"
7742 7907
7743minimatch@5.1.0, minimatch@^5.0.0, minimatch@^5.0.1, minimatch@^5.1.0: 7908minimatch@5.1.0:
7744 version "5.1.0" 7909 version "5.1.0"
7745 resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.0.tgz#1717b464f4971b144f6aabe8f2d0b8e4511e09c7" 7910 resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.0.tgz#1717b464f4971b144f6aabe8f2d0b8e4511e09c7"
7746 integrity sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg== 7911 integrity sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==
@@ -7754,6 +7919,13 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2:
7754 dependencies: 7919 dependencies:
7755 brace-expansion "^1.1.7" 7920 brace-expansion "^1.1.7"
7756 7921
7922minimatch@^5.0.0, minimatch@^5.0.1, minimatch@^5.1.0:
7923 version "5.1.6"
7924 resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96"
7925 integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==
7926 dependencies:
7927 brace-expansion "^2.0.1"
7928
7757minimatch@~3.0.2: 7929minimatch@~3.0.2:
7758 version "3.0.8" 7930 version "3.0.8"
7759 resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.8.tgz#5e6a59bd11e2ab0de1cfb843eb2d82e546c321c1" 7931 resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.8.tgz#5e6a59bd11e2ab0de1cfb843eb2d82e546c321c1"
@@ -7848,9 +8020,9 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
7848 integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== 8020 integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
7849 8021
7850mocha@^10.0.0: 8022mocha@^10.0.0:
7851 version "10.1.0" 8023 version "10.2.0"
7852 resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.1.0.tgz#dbf1114b7c3f9d0ca5de3133906aea3dfc89ef7a" 8024 resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.2.0.tgz#1fd4a7c32ba5ac372e03a17eef435bd00e5c68b8"
7853 integrity sha512-vUF7IYxEoN7XhQpFLxQAEMtE4W91acW4B6En9l97MwE9stL1A9gusXfoHZCLVHDUJ/7V5+lbCM6yMqzo5vNymg== 8025 integrity sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==
7854 dependencies: 8026 dependencies:
7855 ansi-colors "4.1.1" 8027 ansi-colors "4.1.1"
7856 browser-stdout "1.3.1" 8028 browser-stdout "1.3.1"
@@ -8080,6 +8252,11 @@ node-releases@^2.0.6:
8080 resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" 8252 resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503"
8081 integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg== 8253 integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==
8082 8254
8255nodemailer@6.7.3:
8256 version "6.7.3"
8257 resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.7.3.tgz#b73f9a81b9c8fa8acb4ea14b608f5e725ea8e018"
8258 integrity sha512-KUdDsspqx89sD4UUyUKzdlUOper3hRkDVkrKh/89G+d9WKsU5ox51NWS4tB1XR5dPUdR4SP0E3molyEfOvSa3g==
8259
8083nopt@^6.0.0: 8260nopt@^6.0.0:
8084 version "6.0.0" 8261 version "6.0.0"
8085 resolved "https://registry.yarnpkg.com/nopt/-/nopt-6.0.0.tgz#245801d8ebf409c6df22ab9d95b65e1309cdb16d" 8262 resolved "https://registry.yarnpkg.com/nopt/-/nopt-6.0.0.tgz#245801d8ebf409c6df22ab9d95b65e1309cdb16d"
@@ -8267,7 +8444,12 @@ oauth-sign@~0.9.0:
8267 resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" 8444 resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
8268 integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== 8445 integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
8269 8446
8270object-inspect@^1.10.3, object-inspect@^1.12.2, object-inspect@^1.9.0: 8447object-inspect@^1.10.3, object-inspect@^1.9.0:
8448 version "1.12.3"
8449 resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9"
8450 integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==
8451
8452object-inspect@^1.12.2:
8271 version "1.12.2" 8453 version "1.12.2"
8272 resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" 8454 resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea"
8273 integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== 8455 integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==
@@ -8775,6 +8957,14 @@ pngjs@^5.0.0:
8775 resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" 8957 resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
8776 integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== 8958 integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
8777 8959
8960polka@^0.5.2:
8961 version "0.5.2"
8962 resolved "https://registry.yarnpkg.com/polka/-/polka-0.5.2.tgz#588bee0c5806dbc6c64958de3a1251860e9f2e26"
8963 integrity sha512-FVg3vDmCqP80tOrs+OeNlgXYmFppTXdjD5E7I4ET1NjvtNmQrb1/mJibybKkb/d4NA7YWAr1ojxuhpL3FHqdlw==
8964 dependencies:
8965 "@polka/url" "^0.5.0"
8966 trouter "^2.0.1"
8967
8778postcss-attribute-case-insensitive@^5.0.2: 8968postcss-attribute-case-insensitive@^5.0.2:
8779 version "5.0.2" 8969 version "5.0.2"
8780 resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.2.tgz#03d761b24afc04c09e757e92ff53716ae8ea2741" 8970 resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-5.0.2.tgz#03d761b24afc04c09e757e92ff53716ae8ea2741"
@@ -9285,9 +9475,9 @@ qs@~6.5.2:
9285 integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== 9475 integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==
9286 9476
9287query-selector-shadow-dom@^1.0.0: 9477query-selector-shadow-dom@^1.0.0:
9288 version "1.0.0" 9478 version "1.0.1"
9289 resolved "https://registry.yarnpkg.com/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.0.tgz#8fa7459a4620f094457640e74e953a9dbe61a38e" 9479 resolved "https://registry.yarnpkg.com/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz#1c7b0058eff4881ac44f45d8f84ede32e9a2f349"
9290 integrity sha512-bK0/0cCI+R8ZmOF1QjT7HupDUYCxbf/9TJgAmSXQxZpftXmTAeil9DRoCnTDkWbvOyZzhcMBwKpptWcdkGFIMg== 9480 integrity sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==
9291 9481
9292querystring@0.2.0: 9482querystring@0.2.0:
9293 version "0.2.0" 9483 version "0.2.0"
@@ -9736,9 +9926,9 @@ responselike@^2.0.0:
9736 lowercase-keys "^2.0.0" 9926 lowercase-keys "^2.0.0"
9737 9927
9738resq@^1.9.1: 9928resq@^1.9.1:
9739 version "1.10.2" 9929 version "1.11.0"
9740 resolved "https://registry.yarnpkg.com/resq/-/resq-1.10.2.tgz#cedf4f20d53f6e574b1e12afbda446ad9576c193" 9930 resolved "https://registry.yarnpkg.com/resq/-/resq-1.11.0.tgz#edec8c58be9af800fd628118c0ca8815283de196"
9741 integrity sha512-HmgVS3j+FLrEDBTDYysPdPVF9/hioDMJ/otOiQDKqk77YfZeeLOj0qi34yObumcud1gBpk+wpBTEg4kMicD++A== 9931 integrity sha512-G10EBz+zAAy3zUd/CDoBbXRL6ia9kOo3xRHrMDsHljI0GDkhYlyjwoCx5+3eCC4swi1uCoZQhskuJkj7Gp57Bw==
9742 dependencies: 9932 dependencies:
9743 fast-deep-equal "^2.0.1" 9933 fast-deep-equal "^2.0.1"
9744 9934
@@ -9835,13 +10025,20 @@ rxjs@6.6.7:
9835 dependencies: 10025 dependencies:
9836 tslib "^1.9.0" 10026 tslib "^1.9.0"
9837 10027
9838rxjs@^7.3.0, rxjs@^7.4.0, rxjs@^7.5.5: 10028rxjs@^7.3.0, rxjs@^7.4.0:
9839 version "7.5.7" 10029 version "7.5.7"
9840 resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.7.tgz#2ec0d57fdc89ece220d2e702730ae8f1e49def39" 10030 resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.7.tgz#2ec0d57fdc89ece220d2e702730ae8f1e49def39"
9841 integrity sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA== 10031 integrity sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==
9842 dependencies: 10032 dependencies:
9843 tslib "^2.1.0" 10033 tslib "^2.1.0"
9844 10034
10035rxjs@^7.5.5:
10036 version "7.8.0"
10037 resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.0.tgz#90a938862a82888ff4c7359811a595e14e1e09a4"
10038 integrity sha512-F2+gxDshqmIub1KdvZkaEfGDwLNpPvk9Fs6LD/MyQxNgMds/WH9OdDDXOmxUZpME+iSK3rQCctkL0DYyytUqMg==
10039 dependencies:
10040 tslib "^2.1.0"
10041
9845safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: 10042safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
9846 version "5.1.2" 10043 version "5.1.2"
9847 resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" 10044 resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
@@ -10191,6 +10388,15 @@ smart-buffer@^4.2.0:
10191 resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" 10388 resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
10192 integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== 10389 integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
10193 10390
10391smtp-server@^3.9.0:
10392 version "3.11.0"
10393 resolved "https://registry.yarnpkg.com/smtp-server/-/smtp-server-3.11.0.tgz#8820c191124fab37a8f16c8325a7f1fd38092c4f"
10394 integrity sha512-j/W6mEKeMNKuiM9oCAAjm87agPEN1O3IU4cFLT4ZOCyyq3UXN7HiIXF+q7izxJcYSar15B/JaSxcijoPCR8Tag==
10395 dependencies:
10396 base32.js "0.1.0"
10397 ipv6-normalize "1.0.1"
10398 nodemailer "6.7.3"
10399
10194socket.io-client@^4.5.4: 10400socket.io-client@^4.5.4:
10195 version "4.5.4" 10401 version "4.5.4"
10196 resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.5.4.tgz#d3cde8a06a6250041ba7390f08d2468ccebc5ac9" 10402 resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.5.4.tgz#d3cde8a06a6250041ba7390f08d2468ccebc5ac9"
@@ -10420,6 +10626,13 @@ statuses@2.0.1:
10420 resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" 10626 resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
10421 integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== 10627 integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
10422 10628
10629stop-iteration-iterator@^1.0.0:
10630 version "1.0.0"
10631 resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz#6a60be0b4ee757d1ed5254858ec66b10c49285e4"
10632 integrity sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==
10633 dependencies:
10634 internal-slot "^1.0.4"
10635
10423stream-browserify@^3.0.0: 10636stream-browserify@^3.0.0:
10424 version "3.0.0" 10637 version "3.0.0"
10425 resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" 10638 resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f"
@@ -10980,6 +11193,13 @@ trim-newlines@^3.0.0:
10980 resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144" 11193 resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144"
10981 integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw== 11194 integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==
10982 11195
11196trouter@^2.0.1:
11197 version "2.0.1"
11198 resolved "https://registry.yarnpkg.com/trouter/-/trouter-2.0.1.tgz#2726a5f8558e090d24c3a393f09eaab1df232df6"
11199 integrity sha512-kr8SKKw94OI+xTGOkfsvwZQ8mWoikZDd2n8XZHjJVZUARZT+4/VV6cacRS6CLsH9bNm+HFIPU1Zx4CnNnb4qlQ==
11200 dependencies:
11201 matchit "^1.0.0"
11202
10983ts-loader@^9.3.0: 11203ts-loader@^9.3.0:
10984 version "9.4.1" 11204 version "9.4.1"
10985 resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.4.1.tgz#b6f3d82db0eac5a8295994f8cb5e4940ff6b1060" 11205 resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.4.1.tgz#b6f3d82db0eac5a8295994f8cb5e4940ff6b1060"
@@ -11280,6 +11500,14 @@ utp-native@^2.5.3:
11280 timeout-refresh "^1.0.0" 11500 timeout-refresh "^1.0.0"
11281 unordered-set "^2.0.1" 11501 unordered-set "^2.0.1"
11282 11502
11503uue@^3.1.0:
11504 version "3.1.2"
11505 resolved "https://registry.yarnpkg.com/uue/-/uue-3.1.2.tgz#e99368414e87200012eb37de4dbaebaa1c742ad2"
11506 integrity sha512-axKLXVqwtdI/czrjG0X8hyV1KLgeWx8F4KvSbvVCnS+RUvsQMGRjx0kfuZDXXqj0LYvVJmx3B9kWlKtEdRrJLg==
11507 dependencies:
11508 escape-string-regexp "~1.0.5"
11509 extend "~3.0.0"
11510
11283uuid@8.3.2, uuid@^8.3.2: 11511uuid@8.3.2, uuid@^8.3.2:
11284 version "8.3.2" 11512 version "8.3.2"
11285 resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" 11513 resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
@@ -11415,31 +11643,31 @@ wdio-geckodriver-service@^3.0.2:
11415 split2 "^4.1.0" 11643 split2 "^4.1.0"
11416 tcp-port-used "^1.0.2" 11644 tcp-port-used "^1.0.2"
11417 11645
11418webdriver@7.26.0: 11646webdriver@7.27.0:
11419 version "7.26.0" 11647 version "7.27.0"
11420 resolved "https://registry.yarnpkg.com/webdriver/-/webdriver-7.26.0.tgz#cc20640ee9906c0126044449dfe9562b6277d14e" 11648 resolved "https://registry.yarnpkg.com/webdriver/-/webdriver-7.27.0.tgz#41d23a6c38bd79ea868f0b9fb9c9e3d4b6e4f8bd"
11421 integrity sha512-T21T31wq29D/rmpFHcAahhdrvfsfXsLs/LBe2su7wL725ptOEoSssuDXjXMkwjf9MSUIXnTcUIz8oJGbKRUMwQ== 11649 integrity sha512-870uIBnrGJ86g3DdYjM+PHhqdWf6NxysSme1KIs6irWxK+LqcaWKWhN75PldE+04xJB2mVWt1tKn0NBBFTWeMg==
11422 dependencies: 11650 dependencies:
11423 "@types/node" "^18.0.0" 11651 "@types/node" "^18.0.0"
11424 "@wdio/config" "7.26.0" 11652 "@wdio/config" "7.26.0"
11425 "@wdio/logger" "7.26.0" 11653 "@wdio/logger" "7.26.0"
11426 "@wdio/protocols" "7.22.0" 11654 "@wdio/protocols" "7.27.0"
11427 "@wdio/types" "7.26.0" 11655 "@wdio/types" "7.26.0"
11428 "@wdio/utils" "7.26.0" 11656 "@wdio/utils" "7.26.0"
11429 got "^11.0.2" 11657 got "^11.0.2"
11430 ky "0.30.0" 11658 ky "0.30.0"
11431 lodash.merge "^4.6.1" 11659 lodash.merge "^4.6.1"
11432 11660
11433webdriverio@7.26.0: 11661webdriverio@7.29.1:
11434 version "7.26.0" 11662 version "7.29.1"
11435 resolved "https://registry.yarnpkg.com/webdriverio/-/webdriverio-7.26.0.tgz#d6036d950ef96fb6cc29c6c5c9cfc452fcafa59a" 11663 resolved "https://registry.yarnpkg.com/webdriverio/-/webdriverio-7.29.1.tgz#f71c9de317326cff36d22f6277669477e5340c6f"
11436 integrity sha512-7m9TeP871aYxZYKBI4GDh5aQZLN9Fd/PASu5K/jEIT65J4OBB6g5ZaycGFOmfNHCfjWKjwPXZuKiN1f2mcrcRg== 11664 integrity sha512-2xhoaZvV0tzOgnj8H/B4Yol8LTcIrWdfTdfe01d+ERtdzKCoqimmPNP4vpr2lVRVKL/TW4rfoBTBNvDUaJHe2g==
11437 dependencies: 11665 dependencies:
11438 "@types/aria-query" "^5.0.0" 11666 "@types/aria-query" "^5.0.0"
11439 "@types/node" "^18.0.0" 11667 "@types/node" "^18.0.0"
11440 "@wdio/config" "7.26.0" 11668 "@wdio/config" "7.26.0"
11441 "@wdio/logger" "7.26.0" 11669 "@wdio/logger" "7.26.0"
11442 "@wdio/protocols" "7.22.0" 11670 "@wdio/protocols" "7.27.0"
11443 "@wdio/repl" "7.26.0" 11671 "@wdio/repl" "7.26.0"
11444 "@wdio/types" "7.26.0" 11672 "@wdio/types" "7.26.0"
11445 "@wdio/utils" "7.26.0" 11673 "@wdio/utils" "7.26.0"
@@ -11447,8 +11675,8 @@ webdriverio@7.26.0:
11447 aria-query "^5.0.0" 11675 aria-query "^5.0.0"
11448 css-shorthand-properties "^1.1.1" 11676 css-shorthand-properties "^1.1.1"
11449 css-value "^0.0.1" 11677 css-value "^0.0.1"
11450 devtools "7.26.0" 11678 devtools "7.28.1"
11451 devtools-protocol "^0.0.1069585" 11679 devtools-protocol "^0.0.1085790"
11452 fs-extra "^10.0.0" 11680 fs-extra "^10.0.0"
11453 grapheme-splitter "^1.0.2" 11681 grapheme-splitter "^1.0.2"
11454 lodash.clonedeep "^4.5.0" 11682 lodash.clonedeep "^4.5.0"
@@ -11461,7 +11689,7 @@ webdriverio@7.26.0:
11461 resq "^1.9.1" 11689 resq "^1.9.1"
11462 rgb2hex "0.2.5" 11690 rgb2hex "0.2.5"
11463 serialize-error "^8.0.0" 11691 serialize-error "^8.0.0"
11464 webdriver "7.26.0" 11692 webdriver "7.27.0"
11465 11693
11466webidl-conversions@^3.0.0: 11694webidl-conversions@^3.0.0:
11467 version "3.0.1" 11695 version "3.0.1"
@@ -11732,7 +11960,7 @@ which-module@^2.0.0:
11732 resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" 11960 resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
11733 integrity sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q== 11961 integrity sha512-B+enWhmw6cjfVC7kS8Pj9pCrKSc5txArRyaYGe088shv/FGWH+0Rjx/xPgtsWfsUtS27FkP697E4DDhgrgoc0Q==
11734 11962
11735which-typed-array@^1.1.8: 11963which-typed-array@^1.1.9:
11736 version "1.1.9" 11964 version "1.1.9"
11737 resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.9.tgz#307cf898025848cf995e795e8423c7f337efbde6" 11965 resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.9.tgz#307cf898025848cf995e795e8423c7f337efbde6"
11738 integrity sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA== 11966 integrity sha512-w9c4xkx6mPidwp7180ckYWfMmvxpjlZuIudNtDf4N/tTAUB8VJbX25qZoAsrtGuYNnGw3pa0AXgbGKRB8/EceA==
@@ -11770,6 +11998,11 @@ wildcard@^2.0.0:
11770 resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec" 11998 resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec"
11771 integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw== 11999 integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==
11772 12000
12001wildstring@1.0.9:
12002 version "1.0.9"
12003 resolved "https://registry.yarnpkg.com/wildstring/-/wildstring-1.0.9.tgz#82a696d5653c7d4ec9ba716859b6b53aba2761c5"
12004 integrity sha512-XBNxKIMLO6uVHf1Xvo++HGWAZZoiVCHmEMCmZJzJ82vQsuUJCLw13Gzq0mRCATk7a3+ZcgeOKSDioavuYqtlfA==
12005
11773word-wrap@^1.2.3: 12006word-wrap@^1.2.3:
11774 version "1.2.3" 12007 version "1.2.3"
11775 resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" 12008 resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"