mirror of
https://github.com/koel/koel
synced 2024-11-27 14:31:36 +00:00
feat: support Google SSO
This commit is contained in:
parent
58f62c24ed
commit
bd8ada1d10
46 changed files with 1099 additions and 88 deletions
|
@ -149,6 +149,14 @@ ALLOW_DOWNLOAD=true
|
|||
# Whether to create a backup of a song when deleting it from the filesystem.
|
||||
BACKUP_ON_DELETE=true
|
||||
|
||||
# If using SSO, set the providers details here. Koel will automatically enable SSO if these values are set.
|
||||
# Create an OAuth client and get these values from https://console.developers.google.com/apis/credentials
|
||||
SSO_GOOGLE_CLIENT_ID=
|
||||
SSO_GOOGLE_CLIENT_SECRET=
|
||||
# The domain that users must belong to in order to be able to log in.
|
||||
SSO_GOOGLE_HOSTED_DOMAIN=yourdomain.com
|
||||
|
||||
|
||||
# Sync logs can be found under storage/logs/. Valid options are:
|
||||
# all: Log everything (errored-, skipped-, and successfully processed file).
|
||||
# error: Log errors only. This is the default.
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Facades;
|
||||
|
||||
use App\Exceptions\KoelPlusRequiredException;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
/**
|
||||
|
@ -11,6 +12,11 @@ use Illuminate\Support\Facades\Facade;
|
|||
*/
|
||||
class License extends Facade
|
||||
{
|
||||
public static function requirePlus(): void
|
||||
{
|
||||
throw_unless(static::isPlus(), KoelPlusRequiredException::class);
|
||||
}
|
||||
|
||||
protected static function getFacadeAccessor(): string
|
||||
{
|
||||
return 'License';
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<?php
|
||||
|
||||
use App\Facades\License;
|
||||
use Illuminate\Support\Facades\File as FileFacade;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
@ -106,3 +107,23 @@ function mailer_configured(): bool
|
|||
{
|
||||
return config('mail.default') && !in_array(config('mail.default'), ['log', 'array'], true);
|
||||
}
|
||||
|
||||
/** @return array<string> */
|
||||
function collect_sso_providers(): array
|
||||
{
|
||||
if (License::isCommunity()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$providers = [];
|
||||
|
||||
if (
|
||||
config('services.google.client_id')
|
||||
&& config('services.google.client_secret')
|
||||
&& config('services.google.hd')
|
||||
) {
|
||||
$providers[] = 'Google';
|
||||
}
|
||||
|
||||
return $providers;
|
||||
}
|
||||
|
|
|
@ -34,8 +34,9 @@ class ProfileController extends Controller
|
|||
{
|
||||
static::disableInDemo(Response::HTTP_NO_CONTENT);
|
||||
|
||||
throw_unless(
|
||||
$this->hash->check($request->current_password, $this->user->password),
|
||||
// If the user is not using SSO, we need to verify their current password.
|
||||
throw_if(
|
||||
!$this->user->is_sso && !$this->hash->check($request->current_password, $this->user->password),
|
||||
ValidationException::withMessages(['current_password' => 'Invalid current password'])
|
||||
);
|
||||
|
||||
|
|
22
app/Http/Controllers/SSO/GoogleCallbackController.php
Normal file
22
app/Http/Controllers/SSO/GoogleCallbackController.php
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\SSO;
|
||||
|
||||
use App\Facades\License;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AuthenticationService;
|
||||
use App\Services\UserService;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
class GoogleCallbackController extends Controller
|
||||
{
|
||||
public function __invoke(AuthenticationService $auth, UserService $userService)
|
||||
{
|
||||
assert(License::isPlus());
|
||||
|
||||
$user = Socialite::driver('google')->user();
|
||||
$user = $userService->createOrUpdateUserFromSocialiteUser($user, 'Google');
|
||||
|
||||
return view('sso-callback')->with('token', $auth->logUserIn($user)->toArray());
|
||||
}
|
||||
}
|
|
@ -4,16 +4,21 @@ namespace App\Http;
|
|||
|
||||
use App\Http\Middleware\AudioAuthenticate;
|
||||
use App\Http\Middleware\Authenticate;
|
||||
use App\Http\Middleware\EncryptCookies;
|
||||
use App\Http\Middleware\ForceHttps;
|
||||
use App\Http\Middleware\ObjectStorageAuthenticate;
|
||||
use App\Http\Middleware\ThrottleRequests;
|
||||
use App\Http\Middleware\TrimStrings;
|
||||
use App\Http\Middleware\TrustHosts;
|
||||
use App\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Auth\Middleware\Authorize;
|
||||
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
|
||||
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
|
||||
class Kernel extends HttpKernel
|
||||
{
|
||||
|
@ -37,11 +42,16 @@ class Kernel extends HttpKernel
|
|||
*/
|
||||
protected $middlewareGroups = [
|
||||
'web' => [
|
||||
'bindings',
|
||||
EncryptCookies::class,
|
||||
AddQueuedCookiesToResponse::class,
|
||||
ShareErrorsFromSession::class,
|
||||
StartSession::class,
|
||||
VerifyCsrfToken::class,
|
||||
SubstituteBindings::class,
|
||||
],
|
||||
'api' => [
|
||||
'throttle:60,1',
|
||||
'bindings',
|
||||
SubstituteBindings::class,
|
||||
],
|
||||
];
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@ class ProfileUpdateRequest extends Request
|
|||
return [
|
||||
'name' => 'required',
|
||||
'email' => 'required|email|unique:users,email,' . auth()->user()->id,
|
||||
'current_password' => 'required',
|
||||
'current_password' => 'sometimes|required_with:new_password',
|
||||
'new_password' => ['sometimes', Password::defaults()],
|
||||
];
|
||||
}
|
||||
|
|
|
@ -16,6 +16,8 @@ class UserResource extends JsonResource
|
|||
'is_admin',
|
||||
'preferences',
|
||||
'is_prospect',
|
||||
'sso_provider',
|
||||
'sso_id',
|
||||
];
|
||||
|
||||
public function __construct(private User $user)
|
||||
|
@ -35,6 +37,8 @@ class UserResource extends JsonResource
|
|||
'is_admin' => $this->user->is_admin,
|
||||
'preferences' => $this->user->preferences,
|
||||
'is_prospect' => $this->user->is_prospect,
|
||||
'sso_provider' => $this->user->sso_provider,
|
||||
'sso_id' => $this->user->sso_id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Casts\UserPreferencesCast;
|
||||
use App\Facades\License;
|
||||
use App\Values\UserPreferences;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
|
@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
|
@ -24,6 +26,7 @@ use Laravel\Sanctum\PersonalAccessToken;
|
|||
* @property string $name
|
||||
* @property string $email
|
||||
* @property string $password
|
||||
* @property-read bool $has_custom_avatar
|
||||
* @property-read string $avatar
|
||||
* @property Collection|array<array-key, Playlist> $playlists
|
||||
* @property Collection|array<array-key, PlaylistFolder> $playlist_folders
|
||||
|
@ -34,6 +37,9 @@ use Laravel\Sanctum\PersonalAccessToken;
|
|||
* @property ?Carbon $invited_at
|
||||
* @property-read bool $is_prospect
|
||||
* @property Collection|array<array-key, Playlist> $collaboratedPlaylists
|
||||
* @property ?string $sso_provider
|
||||
* @property ?string $sso_id
|
||||
* @property bool $is_sso
|
||||
*/
|
||||
class User extends Authenticatable
|
||||
{
|
||||
|
@ -80,15 +86,29 @@ class User extends Authenticatable
|
|||
return Attribute::get(function (): string {
|
||||
$avatar = Arr::get($this->attributes, 'avatar');
|
||||
|
||||
if (Str::startsWith($avatar, ['http://', 'https://'])) {
|
||||
return $avatar;
|
||||
}
|
||||
|
||||
return $avatar ? user_avatar_url($avatar) : gravatar($this->email);
|
||||
});
|
||||
}
|
||||
|
||||
protected function hasCustomAvatar(): Attribute
|
||||
{
|
||||
return Attribute::get(fn (): bool => (bool) $this->attributes['avatar']);
|
||||
}
|
||||
|
||||
protected function isProspect(): Attribute
|
||||
{
|
||||
return Attribute::get(fn (): bool => (bool) $this->invitation_token);
|
||||
}
|
||||
|
||||
protected function isSso(): Attribute
|
||||
{
|
||||
return Attribute::get(fn (): bool => License::isPlus() && $this->sso_provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user is connected to Last.fm.
|
||||
*/
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
<?php
|
||||
|
||||
/** @noinspection PhpIncompatibleReturnTypeInspection */
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Socialite\Contracts\User as SocialiteUser;
|
||||
|
||||
class UserRepository extends Repository
|
||||
{
|
||||
|
@ -13,6 +16,15 @@ class UserRepository extends Repository
|
|||
|
||||
public function findOneByEmail(string $email): ?User
|
||||
{
|
||||
return User::query()->where('email', $email)->first();
|
||||
return User::query()->firstWhere('email', $email);
|
||||
}
|
||||
|
||||
public function findOneBySocialiteUser(SocialiteUser $socialiteUser, string $provider): ?User
|
||||
{
|
||||
// we prioritize the SSO ID over the email address, but still resort to the latter
|
||||
return User::query()->firstWhere([
|
||||
'sso_id' => $socialiteUser->getId(),
|
||||
'sso_provider' => $provider,
|
||||
]) ?? $this->findOneByEmail($socialiteUser->getEmail());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,6 +35,11 @@ class AuthenticationService
|
|||
$user->save();
|
||||
}
|
||||
|
||||
return $this->logUserIn($user);
|
||||
}
|
||||
|
||||
public function logUserIn(User $user): CompositeToken
|
||||
{
|
||||
return $this->tokenManager->createCompositeToken($user);
|
||||
}
|
||||
|
||||
|
@ -48,6 +53,11 @@ class AuthenticationService
|
|||
return $this->passwordBroker->sendResetLink(['email' => $email]) === Password::RESET_LINK_SENT;
|
||||
}
|
||||
|
||||
public function generatePasswordResetToken(User $user): string
|
||||
{
|
||||
return $this->passwordBroker->createToken($user);
|
||||
}
|
||||
|
||||
public function tryResetPasswordUsingBroker(string $email, string $password, string $token): bool
|
||||
{
|
||||
$credentials = [
|
||||
|
|
|
@ -4,7 +4,6 @@ namespace App\Services;
|
|||
|
||||
use App\Events\NewPlaylistCollaboratorJoined;
|
||||
use App\Exceptions\CannotRemoveOwnerFromPlaylistException;
|
||||
use App\Exceptions\KoelPlusRequiredException;
|
||||
use App\Exceptions\NotAPlaylistCollaboratorException;
|
||||
use App\Exceptions\OperationNotApplicableForSmartPlaylistException;
|
||||
use App\Exceptions\PlaylistCollaborationTokenExpiredException;
|
||||
|
@ -18,10 +17,13 @@ use Illuminate\Support\Facades\DB;
|
|||
|
||||
class PlaylistCollaborationService
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
License::requirePlus();
|
||||
}
|
||||
|
||||
public function createToken(Playlist $playlist): PlaylistCollaborationToken
|
||||
{
|
||||
self::assertKoelPlus();
|
||||
|
||||
throw_if($playlist->is_smart, OperationNotApplicableForSmartPlaylistException::class);
|
||||
|
||||
return $playlist->collaborationTokens()->create();
|
||||
|
@ -29,8 +31,6 @@ class PlaylistCollaborationService
|
|||
|
||||
public function acceptUsingToken(string $token, User $user): Playlist
|
||||
{
|
||||
self::assertKoelPlus();
|
||||
|
||||
/** @var PlaylistCollaborationToken $collaborationToken */
|
||||
$collaborationToken = PlaylistCollaborationToken::query()->where('token', $token)->firstOrFail();
|
||||
|
||||
|
@ -52,8 +52,6 @@ class PlaylistCollaborationService
|
|||
/** @return Collection|array<array-key, PlaylistCollaborator> */
|
||||
public function getCollaborators(Playlist $playlist): Collection
|
||||
{
|
||||
self::assertKoelPlus();
|
||||
|
||||
return $playlist->collaborators->unless(
|
||||
$playlist->collaborators->contains($playlist->user), // The owner is always a collaborator
|
||||
static fn (Collection $collaborators) => $collaborators->push($playlist->user)
|
||||
|
@ -63,8 +61,6 @@ class PlaylistCollaborationService
|
|||
|
||||
public function removeCollaborator(Playlist $playlist, User $user): void
|
||||
{
|
||||
self::assertKoelPlus();
|
||||
|
||||
throw_if($user->is($playlist->user), CannotRemoveOwnerFromPlaylistException::class);
|
||||
throw_if(!$playlist->hasCollaborator($user), NotAPlaylistCollaboratorException::class);
|
||||
|
||||
|
@ -73,9 +69,4 @@ class PlaylistCollaborationService
|
|||
$playlist->songs()->wherePivot('user_id', $user->id)->detach();
|
||||
});
|
||||
}
|
||||
|
||||
private static function assertKoelPlus(): void
|
||||
{
|
||||
throw_unless(License::isPlus(), KoelPlusRequiredException::class);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,69 +3,125 @@
|
|||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\UserProspectUpdateDeniedException;
|
||||
use App\Facades\License;
|
||||
use App\Models\User;
|
||||
use App\Repositories\UserRepository;
|
||||
use Illuminate\Contracts\Hashing\Hasher;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Contracts\User as SocialiteUser;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
class UserService
|
||||
{
|
||||
public function __construct(private Hasher $hash, private ImageWriter $imageWriter)
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $repository,
|
||||
private Hasher $hash,
|
||||
private ImageWriter $imageWriter
|
||||
) {
|
||||
}
|
||||
|
||||
public function createUser(string $name, string $email, string $plainTextPassword, bool $isAdmin): User
|
||||
{
|
||||
/** @noinspection PhpIncompatibleReturnTypeInspection */
|
||||
public function createUser(
|
||||
string $name,
|
||||
string $email,
|
||||
string $plainTextPassword,
|
||||
bool $isAdmin,
|
||||
?string $avatar = null,
|
||||
?string $ssoId = null,
|
||||
?string $ssoProvider = null,
|
||||
): User {
|
||||
if ($ssoProvider) {
|
||||
License::requirePlus();
|
||||
Assert::oneOf($ssoProvider, ['Google']);
|
||||
}
|
||||
|
||||
return User::query()->create([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => $this->hash->make($plainTextPassword),
|
||||
'password' => $plainTextPassword ? $this->hash->make($plainTextPassword) : '',
|
||||
'is_admin' => $isAdmin,
|
||||
'sso_id' => $ssoId,
|
||||
'sso_provider' => $ssoProvider,
|
||||
'avatar' => $avatar ? $this->createNewAvatar($avatar) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createOrUpdateUserFromSocialiteUser(SocialiteUser $socialiteUser, string $provider): User
|
||||
{
|
||||
License::requirePlus();
|
||||
Assert::oneOf($provider, ['Google']);
|
||||
|
||||
$existingUser = $this->repository->findOneBySocialiteUser($socialiteUser, $provider);
|
||||
|
||||
if ($existingUser) {
|
||||
$existingUser->update([
|
||||
'avatar' => $existingUser->has_custom_avatar ? $existingUser->avatar : $socialiteUser->getAvatar(),
|
||||
'sso_id' => $socialiteUser->getId(),
|
||||
'sso_provider' => $provider,
|
||||
]);
|
||||
|
||||
return $existingUser;
|
||||
}
|
||||
|
||||
return $this->createUser(
|
||||
name: $socialiteUser->getName(),
|
||||
email: $socialiteUser->getEmail(),
|
||||
plainTextPassword: '',
|
||||
isAdmin: false,
|
||||
avatar: $socialiteUser->getAvatar(),
|
||||
ssoId: $socialiteUser->getId(),
|
||||
ssoProvider: $provider
|
||||
);
|
||||
}
|
||||
|
||||
public function updateUser(
|
||||
User $user,
|
||||
string $name,
|
||||
string $email,
|
||||
?string $password,
|
||||
?string $password = null,
|
||||
?bool $isAdmin = null,
|
||||
?string $avatar = null
|
||||
): User {
|
||||
throw_if($user->is_prospect, new UserProspectUpdateDeniedException());
|
||||
|
||||
$data = [
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
];
|
||||
|
||||
if ($isAdmin !== null) {
|
||||
$data['is_admin'] = $isAdmin;
|
||||
}
|
||||
|
||||
if ($password) {
|
||||
$data['password'] = $this->hash->make($password);
|
||||
}
|
||||
|
||||
if ($avatar) {
|
||||
$oldAvatar = $user->getRawOriginal('avatar');
|
||||
|
||||
$path = self::generateUserAvatarPath();
|
||||
$this->imageWriter->write($path, $avatar, ['max_width' => 480]);
|
||||
$data['avatar'] = basename($path);
|
||||
|
||||
if ($oldAvatar) {
|
||||
File::delete($oldAvatar);
|
||||
}
|
||||
if ($user->sso_provider) {
|
||||
// An SSO profile is largely managed by the SSO provider
|
||||
$user->update([
|
||||
'is_admin' => $isAdmin ?? $user->is_admin,
|
||||
'name' => $name,
|
||||
'avatar' => $avatar ? $this->createNewAvatar($avatar, $user) : null,
|
||||
]);
|
||||
} else {
|
||||
$data['avatar'] = null;
|
||||
$user->update([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => $password ? $this->hash->make($password) : $user->password,
|
||||
'is_admin' => $isAdmin ?? $user->is_admin,
|
||||
'avatar' => $avatar ? $this->createNewAvatar($avatar, $user) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $avatar Either the URL of the avatar or image data
|
||||
*/
|
||||
private function createNewAvatar(string $avatar, ?User $user = null): string
|
||||
{
|
||||
if (Str::startsWith($avatar, ['http://', 'https://'])) {
|
||||
return $avatar;
|
||||
}
|
||||
|
||||
$path = self::generateUserAvatarPath();
|
||||
$this->imageWriter->write($path, $avatar, ['max_width' => 480]);
|
||||
|
||||
optional($user?->getRawOriginal('avatar'), static fn (string $oldAvatar) => File::delete($oldAvatar));
|
||||
|
||||
return basename($path);
|
||||
}
|
||||
|
||||
public function deleteUser(User $user): void
|
||||
{
|
||||
$user->delete();
|
||||
|
|
|
@ -39,7 +39,8 @@
|
|||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"spatie/flysystem-dropbox": "^3.0",
|
||||
"saloonphp/saloon": "^3.8",
|
||||
"saloonphp/laravel-plugin": "^3.0"
|
||||
"saloonphp/laravel-plugin": "^3.0",
|
||||
"laravel/socialite": "^5.12"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "~1.0",
|
||||
|
|
148
composer.lock
generated
148
composer.lock
generated
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "05ad56210e2f5ed90da83428f2777175",
|
||||
"content-hash": "b70ee2f17153e514d8f8a9bef5afa8a8",
|
||||
"packages": [
|
||||
{
|
||||
"name": "algolia/algoliasearch-client-php",
|
||||
|
@ -2661,6 +2661,76 @@
|
|||
},
|
||||
"time": "2023-01-30T18:31:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/socialite",
|
||||
"version": "v5.12.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/socialite.git",
|
||||
"reference": "7dae1b072573809f32ab6dcf4aebb57c8b3e8acf"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/socialite/zipball/7dae1b072573809f32ab6dcf4aebb57c8b3e8acf",
|
||||
"reference": "7dae1b072573809f32ab6dcf4aebb57c8b3e8acf",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"guzzlehttp/guzzle": "^6.0|^7.0",
|
||||
"illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
|
||||
"illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
|
||||
"illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
|
||||
"league/oauth1-client": "^1.10.1",
|
||||
"php": "^7.2|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.0",
|
||||
"orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"phpunit/phpunit": "^8.0|^9.3|^10.4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "5.x-dev"
|
||||
},
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Socialite\\SocialiteServiceProvider"
|
||||
],
|
||||
"aliases": {
|
||||
"Socialite": "Laravel\\Socialite\\Facades\\Socialite"
|
||||
}
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Socialite\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.",
|
||||
"homepage": "https://laravel.com",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"oauth"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/socialite/issues",
|
||||
"source": "https://github.com/laravel/socialite"
|
||||
},
|
||||
"time": "2024-02-16T08:58:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/ui",
|
||||
"version": "v3.4.6",
|
||||
|
@ -3182,6 +3252,82 @@
|
|||
],
|
||||
"time": "2024-01-28T23:22:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/oauth1-client",
|
||||
"version": "v1.10.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/oauth1-client.git",
|
||||
"reference": "d6365b901b5c287dd41f143033315e2f777e1167"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/d6365b901b5c287dd41f143033315e2f777e1167",
|
||||
"reference": "d6365b901b5c287dd41f143033315e2f777e1167",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"ext-openssl": "*",
|
||||
"guzzlehttp/guzzle": "^6.0|^7.0",
|
||||
"guzzlehttp/psr7": "^1.7|^2.0",
|
||||
"php": ">=7.1||>=8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-simplexml": "*",
|
||||
"friendsofphp/php-cs-fixer": "^2.17",
|
||||
"mockery/mockery": "^1.3.3",
|
||||
"phpstan/phpstan": "^0.12.42",
|
||||
"phpunit/phpunit": "^7.5||9.5"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-simplexml": "For decoding XML-based responses."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0-dev",
|
||||
"dev-develop": "2.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\OAuth1\\Client\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Corlett",
|
||||
"email": "bencorlett@me.com",
|
||||
"homepage": "http://www.webcomm.com.au",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "OAuth 1.0 Client Library",
|
||||
"keywords": [
|
||||
"Authentication",
|
||||
"SSO",
|
||||
"authorization",
|
||||
"bitbucket",
|
||||
"identity",
|
||||
"idp",
|
||||
"oauth",
|
||||
"oauth1",
|
||||
"single sign on",
|
||||
"trello",
|
||||
"tumblr",
|
||||
"twitter"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/thephpleague/oauth1-client/issues",
|
||||
"source": "https://github.com/thephpleague/oauth1-client/tree/v1.10.1"
|
||||
},
|
||||
"time": "2022-04-15T14:02:14+00:00"
|
||||
},
|
||||
{
|
||||
"name": "lstrojny/functional-php",
|
||||
"version": "1.17.0",
|
||||
|
|
|
@ -33,4 +33,11 @@ return [
|
|||
'key' => env('STRIPE_KEY'),
|
||||
'secret' => env('STRIPE_SECRET'),
|
||||
],
|
||||
|
||||
'google' => [
|
||||
'client_id' => env('SSO_GOOGLE_CLIENT_ID'),
|
||||
'client_secret' => env('SSO_GOOGLE_CLIENT_SECRET'),
|
||||
'redirect' => '/auth/google/callback',
|
||||
'hd' => env('SSO_GOOGLE_HOSTED_DOMAIN'),
|
||||
],
|
||||
];
|
||||
|
|
|
@ -30,4 +30,13 @@ class UserFactory extends Factory
|
|||
{
|
||||
return $this->state(fn () => ['is_admin' => true]); // @phpcs:ignore
|
||||
}
|
||||
|
||||
public function prospect(): self
|
||||
{
|
||||
return $this->state(fn () => [ // @phpcs:ignore
|
||||
'invitation_token' => Str::random(),
|
||||
'invited_at' => now(),
|
||||
'invited_by_id' => User::factory()->admin(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', static function (Blueprint $table): void {
|
||||
$table->string('sso_provider')->nullable();
|
||||
});
|
||||
}
|
||||
};
|
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', static function (Blueprint $table): void {
|
||||
$table->string('sso_id')->nullable()->index();
|
||||
$table->unique(['sso_provider', 'sso_id']);
|
||||
});
|
||||
}
|
||||
};
|
18
resources/assets/img/logos/google.svg
Normal file
18
resources/assets/img/logos/google.svg
Normal file
|
@ -0,0 +1,18 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48px" height="48px">
|
||||
<path
|
||||
fill="#FFC107"
|
||||
d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"
|
||||
/>
|
||||
<path
|
||||
fill="#FF3D00"
|
||||
d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"
|
||||
/>
|
||||
<path
|
||||
fill="#4CAF50"
|
||||
d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"
|
||||
/>
|
||||
<path
|
||||
fill="#1976D2"
|
||||
d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 1 KiB |
|
@ -9,7 +9,9 @@ export default (faker: Faker): User => ({
|
|||
is_prospect: false,
|
||||
is_admin: false,
|
||||
avatar: 'https://gravatar.com/foo',
|
||||
preferences: undefined
|
||||
preferences: undefined,
|
||||
sso_provider: null,
|
||||
sso_id: null
|
||||
})
|
||||
|
||||
export const states: Record<string, Omit<Partial<User>, 'type'>> = {
|
||||
|
|
|
@ -6,6 +6,7 @@ declare global {
|
|||
interface Window {
|
||||
BASE_URL: string
|
||||
MAILER_CONFIGURED: boolean
|
||||
SSO_PROVIDERS: string[]
|
||||
createLemonSqueezy: () => void
|
||||
}
|
||||
|
||||
|
@ -50,6 +51,7 @@ HTMLDialogElement.prototype.close = vi.fn(function mock () {
|
|||
|
||||
window.BASE_URL = 'http://test/'
|
||||
window.MAILER_CONFIGURED = true
|
||||
window.SSO_PROVIDERS = []
|
||||
|
||||
window.createLemonSqueezy = vi.fn()
|
||||
|
||||
|
|
|
@ -49,6 +49,10 @@ const requestResetPasswordLink = async () => {
|
|||
form {
|
||||
min-width: 480px;
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: .75rem;
|
||||
}
|
||||
|
@ -56,13 +60,26 @@ form {
|
|||
> div {
|
||||
display: flex;
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
border-radius: var(--border-radius-input) 0 0 var(--border-radius-input);
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
border-radius: var(--border-radius-input);
|
||||
}
|
||||
}
|
||||
|
||||
[type=submit] {
|
||||
border-radius: 0 var(--border-radius-input) var(--border-radius-input) 0;
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
border-radius: var(--border-radius-input);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,5 +47,21 @@ new class extends UnitTestCase {
|
|||
expect(screen.queryByText('Forgot password?')).toBeNull()
|
||||
window.MAILER_CONFIGURED = true
|
||||
})
|
||||
|
||||
it('shows Google login button', async () => {
|
||||
window.SSO_PROVIDERS = ['Google']
|
||||
|
||||
const { html } = this.render(LoginFrom, {
|
||||
global: {
|
||||
stubs: {
|
||||
GoogleLoginButton: this.stub('google-login-button')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(html()).toMatchSnapshot()
|
||||
|
||||
window.SSO_PROVIDERS = []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,17 +24,24 @@
|
|||
</a>
|
||||
</form>
|
||||
|
||||
<div v-if="ssoProviders.length" v-show="!showingForgotPasswordForm" class="sso">
|
||||
<GoogleLoginButton v-if="ssoProviders.includes('Google')" @error="onSSOError" @success="onSSOSuccess" />
|
||||
</div>
|
||||
|
||||
<ForgotPasswordForm v-if="showingForgotPasswordForm" @cancel="showingForgotPasswordForm = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { authService } from '@/services'
|
||||
import { authService, CompositeToken } from '@/services'
|
||||
import { logger } from '@/utils'
|
||||
import { useMessageToaster, useRouter } from '@/composables'
|
||||
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
import PasswordField from '@/components/ui/PasswordField.vue'
|
||||
import ForgotPasswordForm from '@/components/auth/ForgotPasswordForm.vue'
|
||||
import GoogleLoginButton from '@/components/auth/sso/GoogleLoginButton.vue'
|
||||
|
||||
const DEMO_ACCOUNT = {
|
||||
email: 'demo@koel.dev',
|
||||
|
@ -42,6 +49,7 @@ const DEMO_ACCOUNT = {
|
|||
}
|
||||
|
||||
const canResetPassword = window.MAILER_CONFIGURED && !window.IS_DEMO
|
||||
const ssoProviders = window.SSO_PROVIDERS || []
|
||||
|
||||
const email = ref(window.IS_DEMO ? DEMO_ACCOUNT.email : '')
|
||||
const password = ref(window.IS_DEMO ? DEMO_ACCOUNT.password : '')
|
||||
|
@ -66,6 +74,16 @@ const login = async () => {
|
|||
window.setTimeout(() => (failed.value = false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const onSSOError = (error: any) => {
|
||||
logger.error('SSO error: ', error)
|
||||
useMessageToaster().toastError('Login failed. Please try again.')
|
||||
}
|
||||
|
||||
const onSSOSuccess = (token: CompositeToken) => {
|
||||
authService.setTokensUsingCompositeToken(token)
|
||||
emit('loggedin')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -94,13 +112,23 @@ const login = async () => {
|
|||
}
|
||||
|
||||
.login-wrapper {
|
||||
@include vertical-center();
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
height: 100vh;
|
||||
.sso {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
form {
|
||||
width: 280px;
|
||||
width: 276px;
|
||||
padding: 1.8rem;
|
||||
background: rgba(255, 255, 255, .08);
|
||||
border-radius: .6rem;
|
||||
|
@ -125,7 +153,8 @@ form {
|
|||
font-size: .95rem;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 414px) {
|
||||
@media only screen and (max-width: 480px) {
|
||||
width: 100vw;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
|
|
@ -44,8 +44,9 @@ const submit = async () => {
|
|||
try {
|
||||
loading.value = true
|
||||
await authService.resetPassword(email.value, password.value, token.value)
|
||||
toastSuccess('Password updated. Please log in with your new password.')
|
||||
setTimeout(() => go('/', true), 3000)
|
||||
toastSuccess('Password set.')
|
||||
await authService.login(email.value, password.value)
|
||||
setTimeout(() => go('/', true))
|
||||
} catch (err: any) {
|
||||
toastError(err.response?.data?.message || 'Failed to set new password. Please try again.')
|
||||
} finally {
|
||||
|
@ -74,6 +75,11 @@ form {
|
|||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
width: 100vw;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.help {
|
||||
display: block;
|
||||
margin-top: .8rem;
|
||||
|
|
|
@ -7,5 +7,17 @@ exports[`renders 1`] = `
|
|||
<div data-v-a2893005="" data-v-0b0f87ea=""><input data-v-a2893005="" type="password" placeholder="Password" required=""><button data-v-a2893005="" type="button"><br data-v-a2893005="" data-testid="Icon" icon="[object Object]"></button></div><button data-v-e368fe26="" data-v-0b0f87ea="" type="submit">Log In</button><a data-v-0b0f87ea="" class="reset-password" role="button"> Forgot password? </a>
|
||||
</form>
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`shows Google login button 1`] = `
|
||||
<div data-v-0b0f87ea="" class="login-wrapper">
|
||||
<form data-v-0b0f87ea="" class="" data-testid="login-form">
|
||||
<div data-v-0b0f87ea="" class="logo"><img data-v-0b0f87ea="" alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="156"></div><input data-v-0b0f87ea="" autofocus="" placeholder="Email Address" required="" type="email">
|
||||
<div data-v-a2893005="" data-v-0b0f87ea=""><input data-v-a2893005="" type="password" placeholder="Password" required=""><button data-v-a2893005="" type="button"><br data-v-a2893005="" data-testid="Icon" icon="[object Object]"></button></div><button data-v-e368fe26="" data-v-0b0f87ea="" type="submit">Log In</button><a data-v-0b0f87ea="" class="reset-password" role="button"> Forgot password? </a>
|
||||
</form>
|
||||
<div data-v-0b0f87ea="" class="sso"><br data-v-0b0f87ea="" data-testid="google-login-button"></div>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
`;
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<button title="Log in with Google" type="button" @click.prevent="loginWithGoogle">
|
||||
<img :src="googleLogo" alt="Google Logo" width="32" height="32">
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import googleLogo from '@/../img/logos/google.svg'
|
||||
import { openPopup } from '@/utils'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'success', data: any): void
|
||||
(e: 'error', error: any): void
|
||||
}>()
|
||||
|
||||
const loginWithGoogle = async () => {
|
||||
try {
|
||||
window.onmessage = (msg: MessageEvent) => emit('success', msg.data)
|
||||
openPopup('/auth/google/redirect', 'Google Login', 768, 640, window)
|
||||
} catch (error: any) {
|
||||
emit('error', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
button {
|
||||
opacity: .5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -126,7 +126,7 @@ form {
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 414px) {
|
||||
@media only screen and (max-width: 480px) {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
<template>
|
||||
<form data-testid="update-profile-form" @submit.prevent="update">
|
||||
<AlertBox v-if="currentUser.sso_provider">
|
||||
You’re logging in via Single Sign On provided by <strong>{{ currentUser.sso_provider }}</strong>.
|
||||
You can still update your name and avatar here.
|
||||
</AlertBox>
|
||||
<div class="profile form-row">
|
||||
<div class="left">
|
||||
<div class="form-row">
|
||||
<div v-if="!currentUser.sso_provider" class="form-row">
|
||||
<label>
|
||||
Current Password
|
||||
<input
|
||||
|
@ -20,7 +24,7 @@
|
|||
<div class="form-row">
|
||||
<label>
|
||||
Name
|
||||
<input id="inputProfileName" v-model="profile.name" name="name" required type="text" data-testid="name">
|
||||
<input id="inputProfileName" v-model="profile.name" data-testid="name" name="name" required type="text">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
@ -28,13 +32,18 @@
|
|||
<label>
|
||||
Email Address
|
||||
<input
|
||||
id="inputProfileEmail" v-model="profile.email" name="email" required type="email"
|
||||
id="inputProfileEmail"
|
||||
v-model="profile.email"
|
||||
:readonly="currentUser.sso_provider"
|
||||
data-testid="email"
|
||||
name="email"
|
||||
required
|
||||
type="email"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div v-if="!currentUser.sso_provider" class="form-row">
|
||||
<label>
|
||||
New Password
|
||||
<PasswordField
|
||||
|
@ -65,14 +74,14 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { userStore } from '@/stores'
|
||||
import { authService, UpdateCurrentProfileData } from '@/services'
|
||||
import { logger, parseValidationError } from '@/utils'
|
||||
import { useDialogBox, useMessageToaster } from '@/composables'
|
||||
import { useDialogBox, useMessageToaster, useAuthorization } from '@/composables'
|
||||
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
import PasswordField from '@/components/ui/PasswordField.vue'
|
||||
import EditableProfileAvatar from '@/components/profile-preferences/EditableProfileAvatar.vue'
|
||||
import AlertBox from '@/components/ui/AlertBox.vue'
|
||||
|
||||
const { toastSuccess } = useMessageToaster()
|
||||
const { showErrorDialog } = useDialogBox()
|
||||
|
@ -81,11 +90,13 @@ const profile = ref<UpdateCurrentProfileData>({} as UpdateCurrentProfileData)
|
|||
|
||||
const isDemo = window.IS_DEMO
|
||||
|
||||
const { currentUser } = useAuthorization()
|
||||
|
||||
onMounted(() => {
|
||||
profile.value = {
|
||||
name: userStore.current.name,
|
||||
email: userStore.current.email,
|
||||
avatar: userStore.current.avatar,
|
||||
name: currentUser.value.name,
|
||||
email: currentUser.value.email,
|
||||
avatar: currentUser.value.avatar,
|
||||
current_password: null
|
||||
}
|
||||
})
|
||||
|
@ -117,6 +128,10 @@ const update = async () => {
|
|||
form {
|
||||
width: 66%;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
|
@ -126,8 +141,16 @@ form {
|
|||
align-items: flex-start;
|
||||
gap: 2.5rem;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.left {
|
||||
width: 50%;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
61
resources/assets/js/components/ui/AlertBox.vue
Normal file
61
resources/assets/js/components/ui/AlertBox.vue
Normal file
|
@ -0,0 +1,61 @@
|
|||
<template>
|
||||
<div class="alert-box" :class="`alert-box-${props.type}`">
|
||||
<Icon v-if="props.type === 'info' || props.type === 'default'" :icon="faInfoCircle" />
|
||||
<Icon v-if="props.type === 'danger'" :icon="faExclamationCircle" />
|
||||
<Icon v-if="props.type === 'success'" :icon="faCheckCircle" />
|
||||
<Icon v-if="props.type === 'warning'" :icon="faExclamationTriangle" />
|
||||
|
||||
<div class="text">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
faCheckCircle,
|
||||
faExclamationCircle,
|
||||
faExclamationTriangle,
|
||||
faInfoCircle
|
||||
} from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
const props = withDefaults(defineProps<{ type?: 'default' | 'info' | 'danger' | 'success' | 'warning' }>(), {
|
||||
type: 'default'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.alert-box {
|
||||
padding: 1rem;
|
||||
border-radius: 5px;
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background-color: rgba(255, 255, 255, .1);
|
||||
|
||||
// though setting margins in components is not recommended, it's safe to assume that an alert box will be used
|
||||
// in a context where this margin is desired
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&-info {
|
||||
background-color: rgb(59 130 246);
|
||||
}
|
||||
|
||||
&-success {
|
||||
background-color: rgb(16 185 129);
|
||||
}
|
||||
|
||||
&-warning {
|
||||
background-color: rgb(249 115 22);
|
||||
}
|
||||
|
||||
&-danger {
|
||||
background-color: rgb(185 28 28);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -5,6 +5,10 @@
|
|||
</header>
|
||||
|
||||
<main>
|
||||
<AlertBox v-if="user.sso_provider" type="info">
|
||||
This user logs in via SSO by {{ user.sso_provider }}.<br>
|
||||
</AlertBox>
|
||||
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Name
|
||||
|
@ -14,10 +18,17 @@
|
|||
<div class="form-row">
|
||||
<label>
|
||||
Email
|
||||
<input v-model="updateData.email" name="email" required title="Email" type="email">
|
||||
<input
|
||||
v-model="updateData.email"
|
||||
:readonly="user.sso_provider"
|
||||
name="email"
|
||||
required
|
||||
title="Email"
|
||||
type="email"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div v-if="!user.sso_provider" class="form-row">
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
|
@ -56,6 +67,7 @@ import { useDialogBox, useMessageToaster, useModal, useOverlay } from '@/composa
|
|||
import Btn from '@/components/ui/Btn.vue'
|
||||
import TooltipIcon from '@/components/ui/TooltipIcon.vue'
|
||||
import CheckBox from '@/components/ui/CheckBox.vue'
|
||||
import AlertBox from '@/components/ui/AlertBox.vue'
|
||||
|
||||
const { showOverlay, hideOverlay } = useOverlay()
|
||||
const { toastSuccess } = useMessageToaster()
|
||||
|
|
|
@ -7,12 +7,20 @@
|
|||
<span v-if="user.name" class="name">{{ user.name }}</span>
|
||||
<span v-else class="name anonymous">Anonymous</span>
|
||||
<Icon v-if="isCurrentUser" :icon="faCircleCheck" class="you text-highlight" title="This is you!" />
|
||||
<icon
|
||||
<Icon
|
||||
v-if="user.is_admin"
|
||||
:icon="faShield"
|
||||
class="is-admin text-blue"
|
||||
title="User has admin privileges"
|
||||
/>
|
||||
<img
|
||||
v-if="user.sso_provider === 'Google'"
|
||||
title="Google SSO"
|
||||
:src="googleLogo"
|
||||
alt="Google"
|
||||
width="14"
|
||||
height="14"
|
||||
>
|
||||
</h1>
|
||||
|
||||
<p class="email text-secondary">{{ user.email }}</p>
|
||||
|
@ -33,6 +41,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import googleLogo from '@/../img/logos/google.svg'
|
||||
import { faCircleCheck, faShield } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, toRefs } from 'vue'
|
||||
import { userStore } from '@/stores'
|
||||
|
@ -114,10 +123,9 @@ const revokeInvite = async () => {
|
|||
h1 {
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-normal);
|
||||
|
||||
> * + * {
|
||||
margin-left: .5rem
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
|
|
@ -27,13 +27,12 @@ const props = withDefaults(defineProps<{
|
|||
source?: string | null
|
||||
config?: {
|
||||
minWidth: number
|
||||
maxWidth: number
|
||||
maxWidth?: number
|
||||
}
|
||||
}>(), {
|
||||
source: null,
|
||||
config: () => ({
|
||||
minWidth: 192,
|
||||
maxWidth: 480
|
||||
minWidth: 192
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -23,10 +23,7 @@ const { get: lsGet, set: lsSet, remove: lsRemove } = useLocalStorage(false) // a
|
|||
|
||||
export const authService = {
|
||||
async login (email: string, password: string) {
|
||||
const token = await http.post<CompositeToken>('me', { email, password })
|
||||
|
||||
this.setAudioToken(token['audio-token'])
|
||||
this.setApiToken(token.token)
|
||||
this.setTokensUsingCompositeToken(await http.post<CompositeToken>('me', { email, password }))
|
||||
},
|
||||
|
||||
async logout () {
|
||||
|
@ -48,6 +45,11 @@ export const authService = {
|
|||
|
||||
setApiToken: (token: string) => lsSet(API_TOKEN_STORAGE_KEY, token),
|
||||
|
||||
setTokensUsingCompositeToken (compositeToken: CompositeToken) {
|
||||
this.setApiToken(compositeToken.token)
|
||||
this.setAudioToken(compositeToken['audio-token'])
|
||||
},
|
||||
|
||||
destroy: () => {
|
||||
lsRemove(API_TOKEN_STORAGE_KEY)
|
||||
lsRemove(AUDIO_TOKEN_STORAGE_KEY)
|
||||
|
|
5
resources/assets/js/types.d.ts
vendored
5
resources/assets/js/types.d.ts
vendored
|
@ -55,10 +55,13 @@ interface Constructable<T> {
|
|||
new (...args: any): T
|
||||
}
|
||||
|
||||
type SSOProvider = 'Google' | 'Facebook'
|
||||
|
||||
interface Window {
|
||||
BASE_URL: string
|
||||
MAILER_CONFIGURED: boolean
|
||||
IS_DEMO: boolean
|
||||
SSO_PROVIDERS: SSOProvider[] // not supporting Facebook yet, though
|
||||
|
||||
readonly PUSHER_APP_KEY: string
|
||||
readonly PUSHER_APP_CLUSTER: string
|
||||
|
@ -292,6 +295,8 @@ interface User {
|
|||
password?: string
|
||||
preferences?: UserPreferences
|
||||
avatar: string
|
||||
sso_provider: SSOProvider | null
|
||||
sso_id: string | null
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
|
|
|
@ -68,3 +68,9 @@ export const gravatar = (email: string, size = 192) => {
|
|||
const hash = md5(email.trim().toLowerCase())
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=${size}&d=robohash`
|
||||
}
|
||||
|
||||
export const openPopup = (url: string, name: string, width: number, height: number, parent: Window) => {
|
||||
const y = parent.top!.outerHeight / 2 + parent.top!.screenY - (height / 2)
|
||||
const x = parent.top!.outerWidth / 2 + parent.top!.screenX - (width / 2)
|
||||
return parent.open(url, name, `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${width}, height=${height}, top=${y}, left=${x}`)
|
||||
}
|
||||
|
|
|
@ -69,8 +69,8 @@ textarea,
|
|||
display: block;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: .5;
|
||||
&[disabled], &[readonly] {
|
||||
background: rgba(255, 255, 255, .7);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
|
|
|
@ -36,6 +36,8 @@
|
|||
window.MAILER_CONFIGURED = @json(mailer_configured());
|
||||
window.IS_DEMO = @json(config('koel.misc.demo'));
|
||||
|
||||
window.SSO_PROVIDERS = @json(collect_sso_providers());
|
||||
|
||||
window.PUSHER_APP_KEY = @json(config('broadcasting.connections.pusher.key'));
|
||||
window.PUSHER_APP_CLUSTER = @json(config('broadcasting.connections.pusher.options.cluster'));
|
||||
</script>
|
||||
|
|
11
resources/views/sso-callback.blade.php
Normal file
11
resources/views/sso-callback.blade.php
Normal file
|
@ -0,0 +1,11 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>SSO Callback | Koel</title>
|
||||
<script>
|
||||
window.opener.postMessage(@json($token), '*')
|
||||
window.close()
|
||||
</script>
|
||||
</head>
|
||||
</html>
|
|
@ -9,8 +9,10 @@ use App\Http\Controllers\Download\DownloadPlaylistController;
|
|||
use App\Http\Controllers\Download\DownloadSongsController;
|
||||
use App\Http\Controllers\LastfmController;
|
||||
use App\Http\Controllers\PlayController;
|
||||
use App\Http\Controllers\SSO\GoogleCallbackController;
|
||||
use App\Http\Controllers\ViewSongOnITunesController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
Route::middleware('web')->group(static function (): void {
|
||||
Route::get('/', static fn () => view('index'));
|
||||
|
@ -28,6 +30,9 @@ Route::middleware('web')->group(static function (): void {
|
|||
}
|
||||
});
|
||||
|
||||
Route::get('auth/google/redirect', static fn () => Socialite::driver('google')->redirect());
|
||||
Route::get('auth/google/callback', GoogleCallbackController::class);
|
||||
|
||||
Route::get('dropbox/authorize', AuthorizeDropboxController::class)->name('dropbox.authorize');
|
||||
|
||||
Route::middleware('audio.auth')->group(static function (): void {
|
||||
|
|
39
tests/Feature/KoelPlus/ProfileTest.php
Normal file
39
tests/Feature/KoelPlus/ProfileTest.php
Normal file
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\KoelPlus;
|
||||
|
||||
use Tests\PlusTestCase;
|
||||
|
||||
use function Tests\create_user;
|
||||
use function Tests\read_as_data_url;
|
||||
use function Tests\test_path;
|
||||
|
||||
class ProfileTest extends PlusTestCase
|
||||
{
|
||||
public function testUpdateSSOProfile(): void
|
||||
{
|
||||
$user = create_user([
|
||||
'sso_provider' => 'Google',
|
||||
'sso_id' => '123',
|
||||
'email' => 'user@koel.dev',
|
||||
'name' => 'SSO User',
|
||||
'avatar' => null,
|
||||
// no current password required for SSO users
|
||||
]);
|
||||
|
||||
self::assertTrue($user->is_sso);
|
||||
self::assertFalse($user->has_custom_avatar);
|
||||
|
||||
$this->putAs('api/me', [
|
||||
'name' => 'Bruce Dickinson',
|
||||
'email' => 'bruce@iron.com',
|
||||
'avatar' => read_as_data_url(test_path('blobs/cover.png')),
|
||||
], $user)->assertOk();
|
||||
|
||||
$user->refresh();
|
||||
|
||||
self::assertSame('Bruce Dickinson', $user->name);
|
||||
self::assertSame('user@koel.dev', $user->email); // email should not be updated
|
||||
self::assertTrue($user->has_custom_avatar);
|
||||
}
|
||||
}
|
73
tests/Feature/KoelPlus/SSO/GoogleTest.php
Normal file
73
tests/Feature/KoelPlus/SSO/GoogleTest.php
Normal file
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\KoelPlus\SSO;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\User as GoogleUser;
|
||||
use Mockery;
|
||||
use Tests\PlusTestCase;
|
||||
|
||||
use function Tests\create_user;
|
||||
|
||||
class GoogleTest extends PlusTestCase
|
||||
{
|
||||
public function testCallbackWithNewUser(): void
|
||||
{
|
||||
$googleUser = Mockery::mock(GoogleUser::class, [
|
||||
'getEmail' => 'bruce@iron.com',
|
||||
'getName' => 'Bruce Dickinson',
|
||||
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
|
||||
'getId' => Str::random(),
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn($googleUser);
|
||||
|
||||
$response = $this->get('auth/google/callback');
|
||||
$response->assertOk();
|
||||
$response->assertViewIs('sso-callback');
|
||||
$response->assertViewHas('token');
|
||||
}
|
||||
|
||||
public function testCallbackWithExistingEmail(): void
|
||||
{
|
||||
create_user(['email' => 'bruce@iron.com']);
|
||||
|
||||
$googleUser = Mockery::mock(GoogleUser::class, [
|
||||
'getEmail' => 'bruce@iron.com',
|
||||
'getName' => 'Bruce Dickinson',
|
||||
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
|
||||
'getId' => Str::random(),
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn($googleUser);
|
||||
|
||||
$response = $this->get('auth/google/callback');
|
||||
$response->assertOk();
|
||||
$response->assertViewIs('sso-callback');
|
||||
$response->assertViewHas('token');
|
||||
}
|
||||
|
||||
public function testCallbackWithExistingSSOUser(): void
|
||||
{
|
||||
create_user([
|
||||
'sso_provider' => 'Google',
|
||||
'sso_id' => '123',
|
||||
'email' => 'bruce@iron.com',
|
||||
]);
|
||||
|
||||
$googleUser = Mockery::mock(GoogleUser::class, [
|
||||
'getEmail' => 'bruce@iron.com',
|
||||
'getName' => 'Bruce Dickinson',
|
||||
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
|
||||
'getId' => '123',
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver->user')->andReturn($googleUser);
|
||||
|
||||
$response = $this->get('auth/google/callback');
|
||||
$response->assertOk();
|
||||
$response->assertViewIs('sso-callback');
|
||||
$response->assertViewHas('token');
|
||||
}
|
||||
}
|
|
@ -14,6 +14,11 @@ function create_admin(array $attributes = []): User
|
|||
return User::factory()->admin()->create($attributes);
|
||||
}
|
||||
|
||||
function create_user_prospect(array $attributes = []): User
|
||||
{
|
||||
return User::factory()->prospect()->create($attributes);
|
||||
}
|
||||
|
||||
function test_path(string $path = ''): string
|
||||
{
|
||||
return base_path('tests' . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR));
|
||||
|
|
134
tests/Integration/KoelPlus/Services/UserServiceTest.php
Normal file
134
tests/Integration/KoelPlus/Services/UserServiceTest.php
Normal file
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Integration\KoelPlus\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\UserService;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
use Mockery;
|
||||
use Tests\PlusTestCase;
|
||||
|
||||
use function Tests\create_user;
|
||||
|
||||
class UserServiceTest extends PlusTestCase
|
||||
{
|
||||
private UserService $service;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->service = app(UserService::class);
|
||||
}
|
||||
|
||||
public function testCreateUserViaSSOProvider(): void
|
||||
{
|
||||
$user = $this->service->createUser(
|
||||
name: 'Bruce Dickinson',
|
||||
email: 'bruce@dickison.com',
|
||||
plainTextPassword: '',
|
||||
isAdmin: false,
|
||||
avatar: 'https://lh3.googleusercontent.com/a/vatar',
|
||||
ssoId: '123',
|
||||
ssoProvider: 'Google'
|
||||
);
|
||||
|
||||
self::assertModelExists($user);
|
||||
self::assertSame('Google', $user->sso_provider);
|
||||
self::assertSame('123', $user->sso_id);
|
||||
self::assertSame('https://lh3.googleusercontent.com/a/vatar', $user->avatar);
|
||||
}
|
||||
|
||||
public function testCreateUserFromSocialiteUser(): void
|
||||
{
|
||||
self::assertDatabaseMissing(User::class, ['email' => 'bruce@iron.com']);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class, [
|
||||
'getId' => '123',
|
||||
'getEmail' => 'bruce@iron.com',
|
||||
'getName' => 'Bruce Dickinson',
|
||||
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
|
||||
]);
|
||||
|
||||
$user = $this->service->createOrUpdateUserFromSocialiteUser($socialiteUser, 'Google');
|
||||
|
||||
self::assertModelExists($user);
|
||||
|
||||
self::assertSame('Google', $user->sso_provider);
|
||||
self::assertSame('Bruce Dickinson', $user->name);
|
||||
self::assertSame('bruce@iron.com', $user->email);
|
||||
self::assertSame('123', $user->sso_id);
|
||||
self::assertSame('https://lh3.googleusercontent.com/a/vatar', $user->avatar);
|
||||
}
|
||||
|
||||
public function testUpdateUserFromSSOId(): void
|
||||
{
|
||||
$user = create_user([
|
||||
'email' => 'bruce@iron.com',
|
||||
'sso_id' => '123',
|
||||
'sso_provider' => 'Google',
|
||||
]);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class, [
|
||||
'getId' => '123',
|
||||
'getEmail' => 'steve@iron.com',
|
||||
'getName' => 'Steve Harris',
|
||||
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
|
||||
]);
|
||||
|
||||
$this->service->createOrUpdateUserFromSocialiteUser($socialiteUser, 'Google');
|
||||
$user->refresh();
|
||||
|
||||
self::assertSame('Steve Harris', $user->name);
|
||||
self::assertSame('https://lh3.googleusercontent.com/a/vatar', $user->avatar);
|
||||
self::assertSame('steve@iron.com', $user->email);
|
||||
self::assertSame('Google', $user->sso_provider);
|
||||
}
|
||||
|
||||
public function testUpdateUserFromSSOEmail(): void
|
||||
{
|
||||
$user = create_user(['email' => 'bruce@iron.com']);
|
||||
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class, [
|
||||
'getId' => '123',
|
||||
'getEmail' => 'bruce@iron.com',
|
||||
'getName' => 'Bruce Dickinson',
|
||||
'getAvatar' => 'https://lh3.googleusercontent.com/a/vatar',
|
||||
]);
|
||||
|
||||
$this->service->createOrUpdateUserFromSocialiteUser($socialiteUser, 'Google');
|
||||
$user->refresh();
|
||||
|
||||
self::assertSame('Bruce Dickinson', $user->name);
|
||||
self::assertSame('https://lh3.googleusercontent.com/a/vatar', $user->avatar);
|
||||
self::assertSame('Google', $user->sso_provider);
|
||||
}
|
||||
|
||||
public function testUpdateSSOUserCannotChangeProfileDetails(): void
|
||||
{
|
||||
$user = create_user([
|
||||
'email' => 'bruce@iron.com',
|
||||
'name' => 'Bruce Dickinson',
|
||||
'avatar' => 'https://lh3.googleusercontent.com/a/vatar',
|
||||
'sso_provider' => 'Google',
|
||||
]);
|
||||
|
||||
$this->service->updateUser(
|
||||
user: $user,
|
||||
name: 'Steve Harris',
|
||||
email: 'steve@iron.com',
|
||||
password: 'TheTrooper',
|
||||
isAdmin: true,
|
||||
avatar: 'https://lh3.googleusercontent.com/a/vatar/2'
|
||||
);
|
||||
|
||||
$user->refresh();
|
||||
|
||||
self::assertSame('Bruce Dickinson', $user->name);
|
||||
self::assertSame('bruce@iron.com', $user->email);
|
||||
self::assertFalse(Hash::check('TheTrooper', $user->password));
|
||||
self::assertTrue($user->is_admin);
|
||||
self::assertSame('https://lh3.googleusercontent.com/a/vatar', $user->avatar);
|
||||
}
|
||||
}
|
135
tests/Integration/Services/UserServiceTest.php
Normal file
135
tests/Integration/Services/UserServiceTest.php
Normal file
|
@ -0,0 +1,135 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Integration\Services;
|
||||
|
||||
use App\Exceptions\KoelPlusRequiredException;
|
||||
use App\Exceptions\UserProspectUpdateDeniedException;
|
||||
use App\Services\UserService;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
use function Tests\create_admin;
|
||||
use function Tests\create_user;
|
||||
use function Tests\create_user_prospect;
|
||||
use function Tests\read_as_data_url;
|
||||
use function Tests\test_path;
|
||||
|
||||
class UserServiceTest extends TestCase
|
||||
{
|
||||
private UserService $service;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->service = app(UserService::class);
|
||||
}
|
||||
|
||||
public function testCreateUser(): void
|
||||
{
|
||||
$user = $this->service->createUser(
|
||||
name: 'Bruce Dickinson',
|
||||
email: 'bruce@dickison.com',
|
||||
plainTextPassword: 'FearOfTheDark',
|
||||
isAdmin: true,
|
||||
avatar: read_as_data_url(test_path('blobs/cover.png')),
|
||||
);
|
||||
|
||||
self::assertModelExists($user);
|
||||
self::assertTrue(Hash::check('FearOfTheDark', $user->password));
|
||||
self::assertTrue($user->is_admin);
|
||||
self::assertFileExists(user_avatar_path($user->getRawOriginal('avatar')));
|
||||
}
|
||||
|
||||
public function testCreateUserWithEmptyAvatarHasGravatar(): void
|
||||
{
|
||||
$user = $this->service->createUser(
|
||||
name: 'Bruce Dickinson',
|
||||
email: 'bruce@dickison.com',
|
||||
plainTextPassword: 'FearOfTheDark',
|
||||
isAdmin: false
|
||||
);
|
||||
|
||||
self::assertModelExists($user);
|
||||
self::assertTrue(Hash::check('FearOfTheDark', $user->password));
|
||||
self::assertFalse($user->is_admin);
|
||||
self::assertStringStartsWith('https://www.gravatar.com/avatar/', $user->avatar);
|
||||
}
|
||||
|
||||
public function testCreateUserWithNoPassword(): void
|
||||
{
|
||||
$user = $this->service->createUser(
|
||||
name: 'Bruce Dickinson',
|
||||
email: 'bruce@dickison.com',
|
||||
plainTextPassword: '',
|
||||
isAdmin: false
|
||||
);
|
||||
|
||||
self::assertModelExists($user);
|
||||
self::assertEmpty($user->password);
|
||||
}
|
||||
|
||||
public function testCreateSSOUserRequiresKoelPlus(): void
|
||||
{
|
||||
$this->expectException(KoelPlusRequiredException::class);
|
||||
|
||||
$this->service->createUser(
|
||||
name: 'Bruce Dickinson',
|
||||
email: 'bruce@dickison.com',
|
||||
plainTextPassword: 'FearOfTheDark',
|
||||
isAdmin: false,
|
||||
ssoProvider: 'Google'
|
||||
);
|
||||
}
|
||||
|
||||
public function testUpdateUser(): void
|
||||
{
|
||||
$user = create_user();
|
||||
|
||||
$this->service->updateUser(
|
||||
user: $user,
|
||||
name: 'Steve Harris',
|
||||
email: 'steve@iron.com',
|
||||
password: 'TheTrooper',
|
||||
isAdmin: true,
|
||||
avatar: read_as_data_url(test_path('blobs/cover.png'))
|
||||
);
|
||||
|
||||
$user->refresh();
|
||||
|
||||
self::assertSame('Steve Harris', $user->name);
|
||||
self::assertSame('steve@iron.com', $user->email);
|
||||
self::assertTrue(Hash::check('TheTrooper', $user->password));
|
||||
self::assertTrue($user->is_admin);
|
||||
self::assertFileExists(user_avatar_path($user->getRawOriginal('avatar')));
|
||||
}
|
||||
|
||||
public function testUpdateUserWithoutSettingPasswordOrAdminStatus(): void
|
||||
{
|
||||
$user = create_admin(['password' => Hash::make('TheTrooper')]);
|
||||
|
||||
$this->service->updateUser(
|
||||
user: $user,
|
||||
name: 'Steve Harris',
|
||||
email: 'steve@iron.com'
|
||||
);
|
||||
|
||||
$user->refresh();
|
||||
|
||||
self::assertSame('Steve Harris', $user->name);
|
||||
self::assertSame('steve@iron.com', $user->email);
|
||||
self::assertTrue(Hash::check('TheTrooper', $user->password));
|
||||
self::assertTrue($user->is_admin);
|
||||
}
|
||||
|
||||
public function testUpdateProspectUserIsNotAllowed(): void
|
||||
{
|
||||
$this->expectException(UserProspectUpdateDeniedException::class);
|
||||
|
||||
$this->service->updateUser(
|
||||
user: create_user_prospect(),
|
||||
name: 'Steve Harris',
|
||||
email: 'steve@iron.com'
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue