From f87d970b502acfe7ceeac97b494a249435ea6eaf Mon Sep 17 00:00:00 2001 From: Phan An Date: Mon, 21 Aug 2023 00:35:58 +0200 Subject: [PATCH] feat: invite users --- .env.example | 6 +- .../InvalidCredentialsException.php | 9 + .../InvitationNotFoundException.php | 9 + .../UserProspectUpdateDeniedException.php | 9 + app/Http/Controllers/API/AuthController.php | 48 +- app/Http/Controllers/API/SongController.php | 2 + app/Http/Controllers/API/UserController.php | 20 +- .../API/UserInvitationController.php | 75 ++ .../API/AcceptUserInvitationRequest.php | 23 + .../Requests/API/GetUserInvitationRequest.php | 19 + app/Http/Requests/API/InviteUserRequest.php | 30 + .../API/RevokeUserInvitationRequest.php | 15 + app/Http/Requests/API/SongUpdateRequest.php | 5 - app/Http/Resources/UserResource.php | 1 + app/Mail/UserInvite.php | 36 + app/Models/User.php | 19 +- app/Services/AuthenticationService.php | 36 + app/Services/UserInvitationService.php | 77 ++ app/Services/UserService.php | 3 + app/Values/CompositionToken.php | 11 +- config/mail.php | 4 +- ...3_08_20_122210_support_user_invitation.php | 18 + package.json | 2 +- resources/assets/js/App.vue | 34 +- .../js/__tests__/factory/userFactory.ts | 4 + .../__snapshots__/AlbumCard.spec.ts.snap | 6 +- .../AlbumContextMenu.spec.ts.snap | 2 +- .../AlbumTrackListItem.spec.ts.snap | 4 +- .../__snapshots__/ArtistCard.spec.ts.snap | 6 +- .../ArtistContextMenu.spec.ts.snap | 2 +- .../assets/js/components/auth/LoginForm.vue | 28 +- .../auth/__snapshots__/LoginForm.spec.ts.snap | 9 +- .../invitation/AcceptInvitation.vue | 126 +++ .../js/components/layout/ModalWrapper.spec.ts | 2 + .../js/components/layout/ModalWrapper.vue | 2 + .../FooterExtraControls.spec.ts.snap | 6 +- .../FooterPlaybackControls.spec.ts.snap | 12 +- .../__snapshots__/FooterSongInfo.spec.ts.snap | 8 +- .../layout/main-wrapper/MainContent.vue | 8 +- .../__snapshots__/ExtraDrawer.spec.ts.snap | 10 +- .../main-wrapper/sidebar/SidebarItem.spec.ts | 4 +- .../__snapshots__/AboutKoelModal.spec.ts.snap | 14 +- .../__snapshots__/SponsorList.spec.ts.snap | 2 +- .../__snapshots__/SupportKoel.spec.ts.snap | 4 +- .../profile-preferences/ProfileForm.vue | 20 +- .../__snapshots__/ThemeCard.spec.ts.snap | 4 +- .../js/components/screens/AlbumScreen.vue | 10 +- .../js/components/screens/ArtistScreen.vue | 10 +- .../js/components/screens/PlaylistScreen.vue | 6 +- ...serList.spec.ts => UserListScreen.spec.ts} | 27 +- .../js/components/screens/UserListScreen.vue | 67 +- .../__snapshots__/AllSongsScreen.spec.ts.snap | 68 ++ .../__snapshots__/SettingsScreen.spec.ts.snap | 10 +- .../js/components/song/SongContextMenu.vue | 2 +- .../components/song/SongListControls.spec.ts | 2 +- .../song/__snapshots__/AddToMenu.spec.ts.snap | 16 +- .../__snapshots__/EditSongForm.spec.ts.snap | 830 +++++++++--------- .../__snapshots__/SongListItem.spec.ts.snap | 2 +- resources/assets/js/components/ui/Btn.vue | 4 +- .../assets/js/components/ui/CheckBox.vue | 2 +- .../js/components/ui/FooterPlayButton.spec.ts | 2 +- .../assets/js/components/ui/PasswordField.vue | 48 + .../AlbumArtOverlay.spec.ts.snap | 4 + .../AlbumArtistThumbnail.spec.ts.snap | 4 +- .../AppleMusicButton.spec.ts.snap | 4 +- .../ui/__snapshots__/Btn.spec.ts.snap | 2 +- .../__snapshots__/BtnCloseModal.spec.ts.snap | 2 +- .../ui/__snapshots__/BtnGroup.spec.ts.snap | 2 +- .../__snapshots__/BtnScrollToTop.spec.ts.snap | 2 +- .../ui/__snapshots__/CheckBox.spec.ts.snap | 4 +- .../ui/__snapshots__/LyricsPane.spec.ts.snap | 6 +- .../ui/__snapshots__/Magnifier.spec.ts.snap | 2 +- .../__snapshots__/MessageToast.spec.ts.snap | 4 +- .../__snapshots__/ProfileAvatar.spec.ts.snap | 2 +- .../__snapshots__/ScreenHeader.spec.ts.snap | 8 +- .../__snapshots__/ViewModeSwitch.spec.ts.snap | 4 +- .../YouTubeVideoItem.spec.ts.snap | 8 +- .../__snapshots__/UploadItem.spec.ts.snap | 2 +- .../js/components/user/InviteUserForm.spec.ts | 54 ++ .../js/components/user/InviteUserForm.vue | 114 +++ .../js/components/user/UserCard.spec.ts | 23 + .../assets/js/components/user/UserCard.vue | 50 +- resources/assets/js/composables/useRouter.ts | 1 + .../assets/js/composables/useSongList.ts | 2 +- .../js/composables/useSongListControls.ts | 2 +- resources/assets/js/config/events.ts | 1 + resources/assets/js/config/routes.ts | 12 +- resources/assets/js/router.ts | 14 +- resources/assets/js/services/http.ts | 2 +- resources/assets/js/services/index.ts | 1 + .../assets/js/services/invitationService.ts | 17 + resources/assets/js/stores/userStore.ts | 6 +- resources/assets/js/types.d.ts | 2 + resources/assets/sass/partials/_mixins.scss | 2 +- resources/assets/sass/partials/_shared.scss | 5 + resources/views/emails/users/invite.blade.php | 12 + routes/api.base.php | 7 + tests/Feature/UserInvitationTest.php | 105 +++ .../Services/UserInvitationServiceTest.php | 106 +++ yarn.lock | 187 ++-- 100 files changed, 1957 insertions(+), 728 deletions(-) create mode 100644 app/Exceptions/InvalidCredentialsException.php create mode 100644 app/Exceptions/InvitationNotFoundException.php create mode 100644 app/Exceptions/UserProspectUpdateDeniedException.php create mode 100644 app/Http/Controllers/API/UserInvitationController.php create mode 100644 app/Http/Requests/API/AcceptUserInvitationRequest.php create mode 100644 app/Http/Requests/API/GetUserInvitationRequest.php create mode 100644 app/Http/Requests/API/InviteUserRequest.php create mode 100644 app/Http/Requests/API/RevokeUserInvitationRequest.php create mode 100644 app/Mail/UserInvite.php create mode 100644 app/Services/AuthenticationService.php create mode 100644 app/Services/UserInvitationService.php create mode 100644 database/migrations/2023_08_20_122210_support_user_invitation.php create mode 100644 resources/assets/js/components/invitation/AcceptInvitation.vue rename resources/assets/js/components/screens/{UserList.spec.ts => UserListScreen.spec.ts} (56%) create mode 100644 resources/assets/js/components/ui/PasswordField.vue create mode 100644 resources/assets/js/components/user/InviteUserForm.spec.ts create mode 100644 resources/assets/js/components/user/InviteUserForm.vue create mode 100644 resources/assets/js/services/invitationService.ts create mode 100644 resources/views/emails/users/invite.blade.php create mode 100644 tests/Feature/UserInvitationTest.php create mode 100644 tests/Integration/Services/UserInvitationServiceTest.php diff --git a/.env.example b/.env.example index dfce5dd4..49c81ab1 100644 --- a/.env.example +++ b/.env.example @@ -140,14 +140,16 @@ PUSHER_APP_KEY= PUSHER_APP_SECRET= PUSHER_APP_CLUSTER= + +# The following settings are for Koel to send emails, for example to send user invitations and reset passwords. +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" MAIL_MAILER=smtp MAIL_HOST=mailhog MAIL_PORT=1025 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" SQS_PUBLIC_KEY= SQS_SECRET_KEY= diff --git a/app/Exceptions/InvalidCredentialsException.php b/app/Exceptions/InvalidCredentialsException.php new file mode 100644 index 00000000..c5997faf --- /dev/null +++ b/app/Exceptions/InvalidCredentialsException.php @@ -0,0 +1,9 @@ +userRepository->getFirstWhere('email', $request->email); - - if (!$user || !$this->hash->check($request->password, $user->password)) { - abort(Response::HTTP_UNAUTHORIZED, 'Invalid credentials'); + if ($this->hasTooManyLoginAttempts($request)) { + $this->fireLockoutEvent($request); + $this->sendLockoutResponse($request); } - $token = $this->tokenManager->createCompositionToken($user); - - return response()->json([ - 'token' => $token->apiToken, - 'audio-token' => $token->audioToken, - ]); + try { + return response()->json($this->auth->login($request->email, $request->password)->toArray()); + } catch (InvalidCredentialsException) { + $this->incrementLoginAttempts($request); + abort(Response::HTTP_UNAUTHORIZED, 'Invalid credentials'); + } } public function logout(Request $request) { - if ($this->user) { - attempt(fn () => $this->tokenManager->deleteCompositionToken($request->bearerToken())); - } + attempt(fn () => $this->auth->logoutViaBearerToken($request->bearerToken())); return response()->noContent(); } + + /** + * For the throttle middleware. + */ + protected function username(): string + { + return 'email'; + } } diff --git a/app/Http/Controllers/API/SongController.php b/app/Http/Controllers/API/SongController.php index 9fe23e10..cc5c031f 100644 --- a/app/Http/Controllers/API/SongController.php +++ b/app/Http/Controllers/API/SongController.php @@ -50,6 +50,8 @@ class SongController extends Controller public function update(SongUpdateRequest $request) { + $this->authorize('admin', $this->user); + $updatedSongs = $this->songService->updateSongs($request->songs, SongUpdateData::fromRequest($request)); $albums = $this->albumRepository->getByIds($updatedSongs->pluck('album_id')->toArray()); diff --git a/app/Http/Controllers/API/UserController.php b/app/Http/Controllers/API/UserController.php index 9aa99268..c42b7fb0 100644 --- a/app/Http/Controllers/API/UserController.php +++ b/app/Http/Controllers/API/UserController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\API; +use App\Exceptions\UserProspectUpdateDeniedException; use App\Http\Controllers\Controller; use App\Http\Requests\API\UserStoreRequest; use App\Http\Requests\API\UserUpdateRequest; @@ -9,6 +10,7 @@ use App\Http\Resources\UserResource; use App\Models\User; use App\Repositories\UserRepository; use App\Services\UserService; +use Illuminate\Http\Response; class UserController extends Controller { @@ -39,13 +41,17 @@ class UserController extends Controller { $this->authorize('admin', User::class); - return UserResource::make($this->userService->updateUser( - $user, - $request->name, - $request->email, - $request->password, - $request->get('is_admin') ?: false - )); + try { + return UserResource::make($this->userService->updateUser( + $user, + $request->name, + $request->email, + $request->password, + $request->get('is_admin') ?: false + )); + } catch (UserProspectUpdateDeniedException) { + abort(Response::HTTP_FORBIDDEN, 'Cannot update a user prospect.'); + } } public function destroy(User $user) diff --git a/app/Http/Controllers/API/UserInvitationController.php b/app/Http/Controllers/API/UserInvitationController.php new file mode 100644 index 00000000..196961a5 --- /dev/null +++ b/app/Http/Controllers/API/UserInvitationController.php @@ -0,0 +1,75 @@ +authorize('admin', $this->invitor); + + $invitees = $this->invitationService->invite( + $request->emails, + $request->get('is_admin') ?: false, + $this->invitor + ); + + return UserResource::collection($invitees); + } + + public function get(GetUserInvitationRequest $request) + { + try { + return UserResource::make($this->invitationService->getUserProspectByToken($request->token)); + } catch (InvitationNotFoundException) { + abort(Response::HTTP_NOT_FOUND, 'The invitation token is invalid.'); + } + } + + public function accept(AcceptUserInvitationRequest $request) + { + try { + $user = $this->invitationService->accept($request->token, $request->name, $request->password); + + return response()->json($this->auth->login($user->email, $request->password)->toArray()); + } catch (InvitationNotFoundException) { + abort(Response::HTTP_NOT_FOUND, 'The invitation token is invalid.'); + } + } + + public function revoke(RevokeUserInvitationRequest $request) + { + $this->authorize('admin', $this->invitor); + + try { + $this->invitationService->revokeByEmail($request->email); + + return response()->noContent(); + } catch (InvitationNotFoundException) { + abort(Response::HTTP_NOT_FOUND, 'The invitation token is invalid.'); + } + } +} diff --git a/app/Http/Requests/API/AcceptUserInvitationRequest.php b/app/Http/Requests/API/AcceptUserInvitationRequest.php new file mode 100644 index 00000000..5165da85 --- /dev/null +++ b/app/Http/Requests/API/AcceptUserInvitationRequest.php @@ -0,0 +1,23 @@ + */ + public function rules(): array + { + return [ + 'name' => 'required', + 'token' => 'required', + 'password' => ['required', Password::defaults()], + ]; + } +} diff --git a/app/Http/Requests/API/GetUserInvitationRequest.php b/app/Http/Requests/API/GetUserInvitationRequest.php new file mode 100644 index 00000000..2733f31f --- /dev/null +++ b/app/Http/Requests/API/GetUserInvitationRequest.php @@ -0,0 +1,19 @@ + + */ + public function rules(): array + { + return [ + 'token' => 'required|string', + ]; + } +} diff --git a/app/Http/Requests/API/InviteUserRequest.php b/app/Http/Requests/API/InviteUserRequest.php new file mode 100644 index 00000000..9e7707e5 --- /dev/null +++ b/app/Http/Requests/API/InviteUserRequest.php @@ -0,0 +1,30 @@ + $emails + */ +class InviteUserRequest extends Request +{ + /** + * @return array + */ + public function rules(): array + { + return [ + 'emails.*' => 'required|email|unique:users,email', + 'is_admin' => 'sometimes', + ]; + } + + /** + * @return array + */ + public function messages(): array + { + return [ + 'emails.*.unique' => 'The email :input is already registered.', + ]; + } +} diff --git a/app/Http/Requests/API/RevokeUserInvitationRequest.php b/app/Http/Requests/API/RevokeUserInvitationRequest.php new file mode 100644 index 00000000..d9f0c2ce --- /dev/null +++ b/app/Http/Requests/API/RevokeUserInvitationRequest.php @@ -0,0 +1,15 @@ + */ + public function rules(): array + { + return ['email' => 'required|email']; + } +} diff --git a/app/Http/Requests/API/SongUpdateRequest.php b/app/Http/Requests/API/SongUpdateRequest.php index a70d00f1..051c4347 100644 --- a/app/Http/Requests/API/SongUpdateRequest.php +++ b/app/Http/Requests/API/SongUpdateRequest.php @@ -8,11 +8,6 @@ namespace App\Http\Requests\API; */ class SongUpdateRequest extends Request { - public function authorize(): bool - { - return $this->user()->is_admin; - } - /** @return array */ public function rules(): array { diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index c7987de4..a1efeb66 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -23,6 +23,7 @@ class UserResource extends JsonResource 'avatar' => $this->user->avatar, 'is_admin' => $this->user->is_admin, 'preferences' => $this->when($this->includePreferences, $this->user->preferences), + 'is_prospect' => $this->user->is_prospect, ]; } } diff --git a/app/Mail/UserInvite.php b/app/Mail/UserInvite.php new file mode 100644 index 00000000..32b9706f --- /dev/null +++ b/app/Mail/UserInvite.php @@ -0,0 +1,36 @@ + $this->invitee, + 'url' => url("/#/invitation/accept/{$this->invitee->invitation_token}"), + ], + ); + } + + public function envelope(): Envelope + { + return new Envelope(subject: 'Invitation to join Koel'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 47283718..5781335e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,9 +4,11 @@ namespace App\Models; use App\Casts\UserPreferencesCast; use App\Values\UserPreferences; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -25,6 +27,11 @@ use Laravel\Sanctum\PersonalAccessToken; * @property Collection|array $playlists * @property Collection|array $playlist_folders * @property PersonalAccessToken $currentAccessToken + * @property ?Carbon $invitation_accepted_at + * @property ?User $invitedBy + * @property ?string $invitation_token + * @property ?Carbon $invited_at + * @property-read bool $is_prospect */ class User extends Authenticatable { @@ -33,7 +40,7 @@ class User extends Authenticatable use Notifiable; protected $guarded = ['id']; - protected $hidden = ['password', 'remember_token', 'created_at', 'updated_at']; + protected $hidden = ['password', 'remember_token', 'created_at', 'updated_at', 'invitation_accepted_at']; protected $appends = ['avatar']; protected $casts = [ @@ -41,6 +48,11 @@ class User extends Authenticatable 'preferences' => UserPreferencesCast::class, ]; + public function invitedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by_id'); + } + public function playlists(): HasMany { return $this->hasMany(Playlist::class); @@ -71,6 +83,11 @@ class User extends Authenticatable return Attribute::get(fn (): ?string => $this->preferences->lastFmSessionKey); } + protected function isProspect(): Attribute + { + return Attribute::get(fn (): bool => (bool) $this->invitation_token); + } + /** * Determine if the user is connected to Last.fm. */ diff --git a/app/Services/AuthenticationService.php b/app/Services/AuthenticationService.php new file mode 100644 index 00000000..c9a1f32f --- /dev/null +++ b/app/Services/AuthenticationService.php @@ -0,0 +1,36 @@ +userRepository->getFirstWhere('email', $email); + + if (!$user || !$this->hash->check($password, $user->password)) { + throw new InvalidCredentialsException(); + } + + return $this->tokenManager->createCompositionToken($user); + } + + public function logoutViaBearerToken(string $token): void + { + $this->tokenManager->deleteCompositionToken($token); + } +} diff --git a/app/Services/UserInvitationService.php b/app/Services/UserInvitationService.php new file mode 100644 index 00000000..ce8240e9 --- /dev/null +++ b/app/Services/UserInvitationService.php @@ -0,0 +1,77 @@ + */ + public function invite(array $emails, bool $isAdmin, User $invitor): Collection + { + return DB::transaction(function () use ($emails, $isAdmin, $invitor) { + return collect($emails)->map(fn ($email) => $this->inviteOne($email, $isAdmin, $invitor)); + }); + } + + /** @throws InvitationNotFoundException */ + public function getUserProspectByToken(string $token): User + { + return User::query()->where('invitation_token', $token)->firstOr(static function (): void { + throw new InvitationNotFoundException(); + }); + } + + /** @throws InvitationNotFoundException */ + public function revokeByEmail(string $email): void + { + /** @var ?User $user */ + $user = User::query()->where('email', $email)->first(); + throw_unless($user?->is_prospect, new InvitationNotFoundException()); + $user->delete(); + } + + private function inviteOne(string $email, bool $isAdmin, User $invitor): User + { + /** @var User $invitee */ + $invitee = User::query()->create([ + 'name' => '', + 'email' => $email, + 'password' => '', + 'is_admin' => $isAdmin, + 'invited_by_id' => $invitor->id, + 'invitation_token' => Str::uuid()->toString(), + 'invited_at' => now(), + ]); + + Mail::to($email)->queue(new UserInvite($invitee)); + + return $invitee; + } + + /** @throws InvitationNotFoundException */ + public function accept(string $token, string $name, string $password): User + { + $user = $this->getUserProspectByToken($token); + + $user->update([ + 'name' => $name, + 'password' => $this->hash->make($password), + 'invitation_token' => null, + 'invitation_accepted_at' => now(), + ]); + + return $user; + } +} diff --git a/app/Services/UserService.php b/app/Services/UserService.php index ed75d193..d5b9068a 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Exceptions\UserProspectUpdateDeniedException; use App\Models\User; use Illuminate\Contracts\Hashing\Hasher; @@ -23,6 +24,8 @@ class UserService public function updateUser(User $user, string $name, string $email, string|null $password, bool $isAdmin): User { + throw_if($user->is_prospect, new UserProspectUpdateDeniedException()); + $data = [ 'name' => $name, 'email' => $email, diff --git a/app/Values/CompositionToken.php b/app/Values/CompositionToken.php index 0d251378..63fac212 100644 --- a/app/Values/CompositionToken.php +++ b/app/Values/CompositionToken.php @@ -2,6 +2,7 @@ namespace App\Values; +use Illuminate\Contracts\Support\Arrayable; use Laravel\Sanctum\NewAccessToken; /** @@ -13,7 +14,7 @@ use Laravel\Sanctum\NewAccessToken; * * This approach helps prevent the API token from being logged by servers and proxies. */ -final class CompositionToken +final class CompositionToken implements Arrayable { private function __construct(public string $apiToken, public string $audioToken) { @@ -23,4 +24,12 @@ final class CompositionToken { return new self($api->plainTextToken, $audio->plainTextToken); } + + public function toArray(): array + { + return [ + 'token' => $this->apiToken, + 'audio-token' => $this->audioToken, + ]; + } } diff --git a/config/mail.php b/config/mail.php index 23f32a70..0e457422 100644 --- a/config/mail.php +++ b/config/mail.php @@ -48,8 +48,8 @@ return [ | */ 'from' => [ - 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), - 'name' => env('MAIL_FROM_NAME', 'Example'), + 'address' => env('MAIL_FROM_ADDRESS', 'noreply@koel.local'), + 'name' => env('MAIL_FROM_NAME', 'Koel'), ], /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2023_08_20_122210_support_user_invitation.php b/database/migrations/2023_08_20_122210_support_user_invitation.php new file mode 100644 index 00000000..872aa47b --- /dev/null +++ b/database/migrations/2023_08_20_122210_support_user_invitation.php @@ -0,0 +1,18 @@ +string('invitation_token', 36)->nullable()->index(); + $table->timestamp('invited_at')->nullable(); + $table->timestamp('invitation_accepted_at')->nullable(); + $table->unsignedInteger('invited_by_id')->nullable(); + $table->foreign('invited_by_id')->references('id')->on('users')->nullOnDelete(); + }); + } +}; diff --git a/package.json b/package.json index 8dad202a..1c55ce48 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "slugify": "^1.0.2", "three": "^0.146.0", "tiny-typed-emitter": "^2.1.0", - "vue": "^3.2.32", + "vue": "^3.3.4", "vue-global-events": "^2.1.1", "youtube-player": "^3.0.4" }, diff --git a/resources/assets/js/App.vue b/resources/assets/js/App.vue index ff48c3e7..3ba58224 100644 --- a/resources/assets/js/App.vue +++ b/resources/assets/js/App.vue @@ -5,7 +5,7 @@ -
+
@@ -19,9 +19,9 @@
- + + + + + diff --git a/resources/assets/js/components/layout/ModalWrapper.spec.ts b/resources/assets/js/components/layout/ModalWrapper.spec.ts index a1b0504e..f0d7913b 100644 --- a/resources/assets/js/components/layout/ModalWrapper.spec.ts +++ b/resources/assets/js/components/layout/ModalWrapper.spec.ts @@ -10,6 +10,7 @@ new class extends UnitTestCase { protected test () { it.each<[string, keyof Events, User | Song[] | Playlist | PlaylistFolder | undefined]>([ ['add-user-form', 'MODAL_SHOW_ADD_USER_FORM', undefined], + ['invite-user-form', 'MODAL_SHOW_INVITE_USER_FORM', undefined], ['edit-user-form', 'MODAL_SHOW_EDIT_USER_FORM', factory('user')], ['edit-song-form', 'MODAL_SHOW_EDIT_SONG_FORM', [factory('song')]], ['create-playlist-form', 'MODAL_SHOW_CREATE_PLAYLIST_FORM', factory('playlist-folder')], @@ -25,6 +26,7 @@ new class extends UnitTestCase { stubs: { AddUserForm: this.stub('add-user-form'), EditUserForm: this.stub('edit-user-form'), + InviteUserForm: this.stub('invite-user-form'), EditSongForm: this.stub('edit-song-form'), CreatePlaylistForm: this.stub('create-playlist-form'), CreatePlaylistFolderForm: this.stub('create-playlist-folder-form'), diff --git a/resources/assets/js/components/layout/ModalWrapper.vue b/resources/assets/js/components/layout/ModalWrapper.vue index 647a9abe..203be447 100644 --- a/resources/assets/js/components/layout/ModalWrapper.vue +++ b/resources/assets/js/components/layout/ModalWrapper.vue @@ -16,6 +16,7 @@ const modalNameToComponentMap = { 'edit-smart-playlist-form': defineAsyncComponent(() => import('@/components/playlist/smart-playlist/EditSmartPlaylistForm.vue')), 'add-user-form': defineAsyncComponent(() => import('@/components/user/AddUserForm.vue')), 'edit-user-form': defineAsyncComponent(() => import('@/components/user/EditUserForm.vue')), + 'invite-user-form': defineAsyncComponent(() => import('@/components/user/InviteUserForm.vue')), 'edit-song-form': defineAsyncComponent(() => import('@/components/song/EditSongForm.vue')), 'create-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/CreatePlaylistFolderForm.vue')), 'edit-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/EditPlaylistFolderForm.vue')), @@ -40,6 +41,7 @@ const close = () => { eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel')) .on('MODAL_SHOW_ADD_USER_FORM', () => (activeModalName.value = 'add-user-form')) + .on('MODAL_SHOW_INVITE_USER_FORM', () => (activeModalName.value = 'invite-user-form')) .on('MODAL_SHOW_CREATE_PLAYLIST_FORM', (folder, songs) => { context.value = { folder, diff --git a/resources/assets/js/components/layout/app-footer/__snapshots__/FooterExtraControls.spec.ts.snap b/resources/assets/js/components/layout/app-footer/__snapshots__/FooterExtraControls.spec.ts.snap index 9fe7a2e1..85867a4c 100644 --- a/resources/assets/js/components/layout/app-footer/__snapshots__/FooterExtraControls.spec.ts.snap +++ b/resources/assets/js/components/layout/app-footer/__snapshots__/FooterExtraControls.spec.ts.snap @@ -1,9 +1,9 @@ // Vitest Snapshot v1 exports[`renders 1`] = ` -
-

-
+
+

+
diff --git a/resources/assets/js/components/layout/app-footer/__snapshots__/FooterPlaybackControls.spec.ts.snap b/resources/assets/js/components/layout/app-footer/__snapshots__/FooterPlaybackControls.spec.ts.snap index 0307f00d..5539d6a0 100644 --- a/resources/assets/js/components/layout/app-footer/__snapshots__/FooterPlaybackControls.spec.ts.snap +++ b/resources/assets/js/components/layout/app-footer/__snapshots__/FooterPlaybackControls.spec.ts.snap @@ -1,9 +1,9 @@ // Vitest Snapshot v1 exports[`renders with a current song 1`] = ` -
-


@@ -11,9 +11,9 @@ exports[`renders with a current song 1`] = ` `; exports[`renders without a current song 1`] = ` -
-


diff --git a/resources/assets/js/components/layout/app-footer/__snapshots__/FooterSongInfo.spec.ts.snap b/resources/assets/js/components/layout/app-footer/__snapshots__/FooterSongInfo.spec.ts.snap index ab22acef..8dbe3b18 100644 --- a/resources/assets/js/components/layout/app-footer/__snapshots__/FooterSongInfo.spec.ts.snap +++ b/resources/assets/js/components/layout/app-footer/__snapshots__/FooterSongInfo.spec.ts.snap @@ -1,15 +1,15 @@ // Vitest Snapshot v1 exports[`renders with current song 1`] = ` -
-
-

Fahrstuhl zum Mond

Led Zeppelin +
+
+

Fahrstuhl zum Mond

Led Zeppelin
`; exports[`renders with no current song 1`] = ` -
+
`; diff --git a/resources/assets/js/components/layout/main-wrapper/MainContent.vue b/resources/assets/js/components/layout/main-wrapper/MainContent.vue index 5e9250c2..f3499d6a 100644 --- a/resources/assets/js/components/layout/main-wrapper/MainContent.vue +++ b/resources/assets/js/components/layout/main-wrapper/MainContent.vue @@ -64,16 +64,18 @@ const NotFoundScreen = defineAsyncComponent(() => import('@/components/screens/N const VisualizerScreen = defineAsyncComponent(() => import('@/components/screens/VisualizerScreen.vue')) const { useYouTube } = useThirdPartyServices() -const { resolveRoute, onRouteChanged } = useRouter() +const { resolveRoute, onRouteChanged, getCurrentScreen } = useRouter() -const currentSong = requireInjection(CurrentSongKey, ref(null)) +const currentSong = requireInjection(CurrentSongKey, ref(undefined)) const showAlbumArtOverlay = toRef(preferenceStore.state, 'showAlbumArtOverlay') const screen = ref('Home') onRouteChanged(route => (screen.value = route.screen)) -onMounted(() => resolveRoute()) +onMounted(async () => { + screen.value = getCurrentScreen() +}) diff --git a/resources/assets/js/components/screens/__snapshots__/AllSongsScreen.spec.ts.snap b/resources/assets/js/components/screens/__snapshots__/AllSongsScreen.spec.ts.snap index 7d721ebb..d555be84 100644 --- a/resources/assets/js/components/screens/__snapshots__/AllSongsScreen.spec.ts.snap +++ b/resources/assets/js/components/screens/__snapshots__/AllSongsScreen.spec.ts.snap @@ -33,3 +33,71 @@ exports[`renders 1`] = `
`; + +exports[`renders 2`] = ` +
+
+ +
+
+

All Songs + +

420 songs34 hr 17 min +
+
+
+ + +
+ +
+
+

+
+`; + +exports[`renders 3`] = ` +
+
+ +
+
+

All Songs + +

420 songs34 hr 17 min +
+
+
+ + +
+ +
+
+

+
+`; diff --git a/resources/assets/js/components/screens/__snapshots__/SettingsScreen.spec.ts.snap b/resources/assets/js/components/screens/__snapshots__/SettingsScreen.spec.ts.snap index d0dca7f7..1884849a 100644 --- a/resources/assets/js/components/screens/__snapshots__/SettingsScreen.spec.ts.snap +++ b/resources/assets/js/components/screens/__snapshots__/SettingsScreen.spec.ts.snap @@ -2,11 +2,11 @@ exports[`renders 1`] = `
-
- +
+
-
-

Settings

+
+

Settings

@@ -14,7 +14,7 @@ exports[`renders 1`] = `

The absolute path to the server directory containing your media. Koel will scan this directory for songs and extract any available information.
Scanning may take a while, especially if you have a lot of songs, so be patient.

-
+
`; diff --git a/resources/assets/js/components/song/SongContextMenu.vue b/resources/assets/js/components/song/SongContextMenu.vue index e138f6fb..8ac2cf71 100644 --- a/resources/assets/js/components/song/SongContextMenu.vue +++ b/resources/assets/js/components/song/SongContextMenu.vue @@ -61,7 +61,7 @@ + + diff --git a/resources/assets/js/components/ui/__snapshots__/AlbumArtOverlay.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/AlbumArtOverlay.spec.ts.snap index da7f3716..270ad67c 100644 --- a/resources/assets/js/components/ui/__snapshots__/AlbumArtOverlay.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/AlbumArtOverlay.spec.ts.snap @@ -2,4 +2,8 @@ exports[`displays nothing if fetching fails 1`] = `
`; +exports[`displays nothing if fetching fails 2`] = `
`; + exports[`fetches and displays the album thumbnail 1`] = `
`; + +exports[`fetches and displays the album thumbnail 2`] = `
`; diff --git a/resources/assets/js/components/ui/__snapshots__/AlbumArtistThumbnail.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/AlbumArtistThumbnail.spec.ts.snap index 6eb6488e..63c3d133 100644 --- a/resources/assets/js/components/ui/__snapshots__/AlbumArtistThumbnail.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/AlbumArtistThumbnail.spec.ts.snap @@ -1,5 +1,5 @@ // Vitest Snapshot v1 -exports[`renders for album 1`] = `IV`; +exports[`renders for album 1`] = `IV`; -exports[`renders for artist 1`] = `Led Zeppelin`; +exports[`renders for artist 1`] = `Led Zeppelin`; diff --git a/resources/assets/js/components/ui/__snapshots__/AppleMusicButton.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/AppleMusicButton.spec.ts.snap index e818d4d6..9fb6a6bd 100644 --- a/resources/assets/js/components/ui/__snapshots__/AppleMusicButton.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/AppleMusicButton.spec.ts.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1 exports[`renders 1`] = ` - - + + `; diff --git a/resources/assets/js/components/ui/__snapshots__/Btn.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/Btn.spec.ts.snap index 824bc4ce..73564cee 100644 --- a/resources/assets/js/components/ui/__snapshots__/Btn.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/Btn.spec.ts.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1 -exports[`renders 1`] = ``; +exports[`renders 1`] = ``; diff --git a/resources/assets/js/components/ui/__snapshots__/BtnCloseModal.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/BtnCloseModal.spec.ts.snap index a6b7d0c1..bbee7dda 100644 --- a/resources/assets/js/components/ui/__snapshots__/BtnCloseModal.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/BtnCloseModal.spec.ts.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1 -exports[`renders 1`] = `
`; +exports[`renders 1`] = ``; diff --git a/resources/assets/js/components/ui/__snapshots__/BtnGroup.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/BtnGroup.spec.ts.snap index 78df955b..b98e6e63 100644 --- a/resources/assets/js/components/ui/__snapshots__/BtnGroup.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/BtnGroup.spec.ts.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1 -exports[`renders 1`] = ``; +exports[`renders 1`] = ``; diff --git a/resources/assets/js/components/ui/__snapshots__/BtnScrollToTop.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/BtnScrollToTop.spec.ts.snap index 7cfac2ed..e7b203e0 100644 --- a/resources/assets/js/components/ui/__snapshots__/BtnScrollToTop.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/BtnScrollToTop.spec.ts.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1 -exports[`renders 1`] = ``; +exports[`renders 1`] = ``; diff --git a/resources/assets/js/components/ui/__snapshots__/CheckBox.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/CheckBox.spec.ts.snap index e387fd55..82d20d34 100644 --- a/resources/assets/js/components/ui/__snapshots__/CheckBox.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/CheckBox.spec.ts.snap @@ -1,5 +1,5 @@ // Vitest Snapshot v1 -exports[`renders checked state 1`] = `
`; +exports[`renders checked state 1`] = `
`; -exports[`renders unchecked state 1`] = ``; +exports[`renders unchecked state 1`] = ``; diff --git a/resources/assets/js/components/ui/__snapshots__/LyricsPane.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/LyricsPane.spec.ts.snap index 069b149c..16a156a2 100644 --- a/resources/assets/js/components/ui/__snapshots__/LyricsPane.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/LyricsPane.spec.ts.snap @@ -1,9 +1,9 @@ // Vitest Snapshot v1 exports[`renders 1`] = ` -
-
-
Foo bar baz qux
+
+
+
Foo bar baz qux
diff --git a/resources/assets/js/components/ui/__snapshots__/Magnifier.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/Magnifier.spec.ts.snap index c42ab9c5..a84aa8db 100644 --- a/resources/assets/js/components/ui/__snapshots__/Magnifier.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/Magnifier.spec.ts.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1 -exports[`renders and functions 1`] = ``; +exports[`renders and functions 1`] = ``; diff --git a/resources/assets/js/components/ui/__snapshots__/MessageToast.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/MessageToast.spec.ts.snap index 9fec3dd5..7d7e7829 100644 --- a/resources/assets/js/components/ui/__snapshots__/MessageToast.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/MessageToast.spec.ts.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1 exports[`renders 1`] = ` -
- +
+
`; diff --git a/resources/assets/js/components/ui/__snapshots__/ProfileAvatar.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/ProfileAvatar.spec.ts.snap index 9f5ba508..2aab7fbe 100644 --- a/resources/assets/js/components/ui/__snapshots__/ProfileAvatar.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/ProfileAvatar.spec.ts.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1 -exports[`renders 1`] = `
Avatar of John Doe`; +exports[`renders 1`] = `Avatar of John Doe`; diff --git a/resources/assets/js/components/ui/__snapshots__/ScreenHeader.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/ScreenHeader.spec.ts.snap index 986f4598..3c228466 100644 --- a/resources/assets/js/components/ui/__snapshots__/ScreenHeader.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/ScreenHeader.spec.ts.snap @@ -1,11 +1,11 @@ // Vitest Snapshot v1 exports[`renders 1`] = ` -
- +
+
-
-

This Header

Some meta

+
+

This Header

Some meta

diff --git a/resources/assets/js/components/ui/__snapshots__/ViewModeSwitch.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/ViewModeSwitch.spec.ts.snap index 24d2291f..cdeb4c7b 100644 --- a/resources/assets/js/components/ui/__snapshots__/ViewModeSwitch.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/ViewModeSwitch.spec.ts.snap @@ -1,5 +1,5 @@ // Vitest Snapshot v1 -exports[`renders list mode 1`] = ``; +exports[`renders list mode 1`] = ``; -exports[`renders thumbnails mode 1`] = ``; +exports[`renders thumbnails mode 1`] = ``; diff --git a/resources/assets/js/components/ui/__snapshots__/YouTubeVideoItem.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/YouTubeVideoItem.spec.ts.snap index 3dab1cb8..9a822210 100644 --- a/resources/assets/js/components/ui/__snapshots__/YouTubeVideoItem.spec.ts.snap +++ b/resources/assets/js/components/ui/__snapshots__/YouTubeVideoItem.spec.ts.snap @@ -1,10 +1,10 @@ // Vitest Snapshot v1 exports[`renders 1`] = ` -Guess what it is -
-

Guess what it is

-

From the LA Opening Gala 2014: John Williams Celebration

+
Guess what it is +
+

Guess what it is

+

From the LA Opening Gala 2014: John Williams Celebration

`; diff --git a/resources/assets/js/components/ui/upload/__snapshots__/UploadItem.spec.ts.snap b/resources/assets/js/components/ui/upload/__snapshots__/UploadItem.spec.ts.snap index e926da80..e3e918fc 100644 --- a/resources/assets/js/components/ui/upload/__snapshots__/UploadItem.spec.ts.snap +++ b/resources/assets/js/components/ui/upload/__snapshots__/UploadItem.spec.ts.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1 -exports[`renders 1`] = `
Sample Track
`; +exports[`renders 1`] = `
Sample Track
`; diff --git a/resources/assets/js/components/user/InviteUserForm.spec.ts b/resources/assets/js/components/user/InviteUserForm.spec.ts new file mode 100644 index 00000000..fe175e0c --- /dev/null +++ b/resources/assets/js/components/user/InviteUserForm.spec.ts @@ -0,0 +1,54 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { screen, waitFor } from '@testing-library/vue' +import { MessageToasterStub } from '@/__tests__/stubs' +import { invitationService } from '@/services' +import InviteUserForm from './InviteUserForm.vue' + +new class extends UnitTestCase { + protected test () { + it('invites single email', async () => { + const inviteMock = this.mock(invitationService, 'invite') + const alertMock = this.mock(MessageToasterStub.value, 'success') + + this.render(InviteUserForm) + + await this.type(screen.getByRole('textbox'), 'foo@bar.ai\n') + await this.user.click(screen.getByRole('checkbox')) + await this.user.click(screen.getByRole('button', { name: 'Invite' })) + + await waitFor(() => { + expect(inviteMock).toHaveBeenCalledWith(['foo@bar.ai'], true) + expect(alertMock).toHaveBeenCalledWith('Invitation sent.') + }) + }) + + it('invites multiple emails', async () => { + const inviteMock = this.mock(invitationService, 'invite') + const alertMock = this.mock(MessageToasterStub.value, 'success') + + this.render(InviteUserForm) + + await this.type(screen.getByRole('textbox'), 'foo@bar.ai\n\na@b.c\n\n') + await this.user.click(screen.getByRole('checkbox')) + await this.user.click(screen.getByRole('button', { name: 'Invite' })) + + await waitFor(() => { + expect(inviteMock).toHaveBeenCalledWith(['foo@bar.ai', 'a@b.c'], true) + expect(alertMock).toHaveBeenCalledWith('Invitations sent.') + }) + }) + + it('does not invites if at least one email is invalid', async () => { + const inviteMock = this.mock(invitationService, 'invite') + + this.render(InviteUserForm) + + await this.type(screen.getByRole('textbox'), 'invalid\n\na@b.c\n\n') + await this.user.click(screen.getByRole('checkbox')) + await this.user.click(screen.getByRole('button', { name: 'Invite' })) + + await waitFor(() => expect(inviteMock).not.toHaveBeenCalled()) + }) + } +} diff --git a/resources/assets/js/components/user/InviteUserForm.vue b/resources/assets/js/components/user/InviteUserForm.vue new file mode 100644 index 00000000..140220ef --- /dev/null +++ b/resources/assets/js/components/user/InviteUserForm.vue @@ -0,0 +1,114 @@ +