diff --git a/composer.json b/composer.json index a3f08672a..e7b94b263 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ }, "require-dev": { "inertiajs/inertia-laravel": "^1.0", + "laravel/passport": "13.x-dev", "laravel/sanctum": "^4.0", "livewire/livewire": "^3.3", "mockery/mockery": "^1.0", diff --git a/routes/inertia.php b/routes/inertia.php index 00d13cfcf..2eb1bf237 100644 --- a/routes/inertia.php +++ b/routes/inertia.php @@ -2,9 +2,12 @@ use Illuminate\Support\Facades\Route; use Laravel\Jetstream\Http\Controllers\CurrentTeamController; -use Laravel\Jetstream\Http\Controllers\Inertia\ApiTokenController; +use Laravel\Jetstream\Http\Controllers\Inertia\ApiTokenController as SanctumApiTokenController; use Laravel\Jetstream\Http\Controllers\Inertia\CurrentUserController; +use Laravel\Jetstream\Http\Controllers\Inertia\OAuthAppController; +use Laravel\Jetstream\Http\Controllers\Inertia\OAuthConnectionController; use Laravel\Jetstream\Http\Controllers\Inertia\OtherBrowserSessionsController; +use Laravel\Jetstream\Http\Controllers\Inertia\PassportApiTokenController; use Laravel\Jetstream\Http\Controllers\Inertia\PrivacyPolicyController; use Laravel\Jetstream\Http\Controllers\Inertia\ProfilePhotoController; use Laravel\Jetstream\Http\Controllers\Inertia\TeamController; @@ -47,10 +50,26 @@ Route::group(['middleware' => 'verified'], function () { // API... if (Jetstream::hasApiFeatures()) { - Route::get('/user/api-tokens', [ApiTokenController::class, 'index'])->name('api-tokens.index'); - Route::post('/user/api-tokens', [ApiTokenController::class, 'store'])->name('api-tokens.store'); - Route::put('/user/api-tokens/{token}', [ApiTokenController::class, 'update'])->name('api-tokens.update'); - Route::delete('/user/api-tokens/{token}', [ApiTokenController::class, 'destroy'])->name('api-tokens.destroy'); + if (Jetstream::hasOAuthFeatures()) { + Route::get('/user/api-tokens', [PassportApiTokenController::class, 'index'])->name('api-tokens.index'); + Route::post('/user/api-tokens', [PassportApiTokenController::class, 'store'])->name('api-tokens.store'); + Route::delete('/user/api-tokens/{token}', [PassportApiTokenController::class, 'destroy'])->name('api-tokens.destroy'); + } else { + Route::get('/user/api-tokens', [SanctumApiTokenController::class, 'index'])->name('api-tokens.index'); + Route::post('/user/api-tokens', [SanctumApiTokenController::class, 'store'])->name('api-tokens.store'); + Route::put('/user/api-tokens/{token}', [SanctumApiTokenController::class, 'update'])->name('api-tokens.update'); + Route::delete('/user/api-tokens/{token}', [SanctumApiTokenController::class, 'destroy'])->name('api-tokens.destroy'); + } + } + + // OAuth... + if (Jetstream::hasOAuthFeatures()) { + Route::get('/user/oauth-apps', [OAuthAppController::class, 'index'])->name('oauth-apps.index'); + Route::post('/user/oauth-apps', [OAuthAppController::class, 'store'])->name('oauth-apps.store'); + Route::put('/user/oauth-apps/{app}', [OAuthAppController::class, 'update'])->name('oauth-apps.update'); + Route::delete('/user/oauth-apps/{app}', [OAuthAppController::class, 'destroy'])->name('oauth-apps.destroy'); + + Route::delete('/user/oauth-connections/{app}', [OAuthConnectionController::class, 'destroy'])->name('oauth-connections.destroy'); } // Teams... diff --git a/routes/livewire.php b/routes/livewire.php index 8de58fc95..7e88824f9 100644 --- a/routes/livewire.php +++ b/routes/livewire.php @@ -3,6 +3,7 @@ use Illuminate\Support\Facades\Route; use Laravel\Jetstream\Http\Controllers\CurrentTeamController; use Laravel\Jetstream\Http\Controllers\Livewire\ApiTokenController; +use Laravel\Jetstream\Http\Controllers\Livewire\OAuthAppController; use Laravel\Jetstream\Http\Controllers\Livewire\PrivacyPolicyController; use Laravel\Jetstream\Http\Controllers\Livewire\TeamController; use Laravel\Jetstream\Http\Controllers\Livewire\TermsOfServiceController; @@ -34,6 +35,11 @@ Route::get('/user/api-tokens', [ApiTokenController::class, 'index'])->name('api-tokens.index'); } + // OAuth... + if (Jetstream::hasOAuthFeatures()) { + Route::get('/user/oauth-apps', [OAuthAppController::class, 'index'])->name('oauth-apps.index'); + } + // Teams... if (Jetstream::hasTeamFeatures()) { Route::get('/teams/create', [TeamController::class, 'create'])->name('teams.create'); diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index ba47c7f5c..9d056bd29 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -33,6 +33,7 @@ class InstallCommand extends Command implements PromptsForMissingInput {--dark : Indicate that dark mode support should be installed} {--teams : Indicates if team support should be installed} {--api : Indicates if API support should be installed} + {--oauth : Indicates if OAuth support via Laravel Passport should be installed} {--verification : Indicates if email verification support should be installed} {--pest : Indicates if Pest should be installed} {--ssr : Indicates if Inertia SSR support should be installed} @@ -87,6 +88,12 @@ public function handle() $this->replaceInFile('// Features::api(),', 'Features::api(),', config_path('jetstream.php')); } + // Configure OAuth... + if ($this->option('oauth')) { + $this->replaceInFile('// Features::oauth(),', 'Features::oauth(),', config_path('jetstream.php')); + $this->replaceInFile('sanctum', 'web', config_path('jetstream.php')); + } + // Configure Email Verification... if ($this->option('verification')) { $this->replaceInFile('// Features::emailVerification(),', 'Features::emailVerification(),', config_path('fortify.php')); @@ -156,6 +163,7 @@ protected function installLivewireStack() $this->call('install:api', [ '--without-migration-prompt' => true, + '--passport' => $this->option('oauth'), ]); // Update Configuration... @@ -189,6 +197,10 @@ protected function installLivewireStack() (new Filesystem)->ensureDirectoryExists(resource_path('views/layouts')); (new Filesystem)->ensureDirectoryExists(resource_path('views/profile')); + if ($this->option('oauth')) { + (new Filesystem)->ensureDirectoryExists(app_path('Actions/Passport')); + } + (new Filesystem)->deleteDirectory(resource_path('sass')); // Terms Of Service / Privacy Policy... @@ -208,6 +220,10 @@ protected function installLivewireStack() // Models... copy(__DIR__.'/../../stubs/app/Models/User.php', app_path('Models/User.php')); + if ($this->option('oauth')) { + $this->replaceInFile('Laravel\Sanctum\HasApiTokens', 'Laravel\Passport\HasApiTokens', app_path('Models/User.php')); + } + // Factories... copy(__DIR__.'/../../database/factories/UserFactory.php', base_path('database/factories/UserFactory.php')); @@ -216,6 +232,11 @@ protected function installLivewireStack() copy(__DIR__.'/../../stubs/app/Actions/Fortify/UpdateUserProfileInformation.php', app_path('Actions/Fortify/UpdateUserProfileInformation.php')); copy(__DIR__.'/../../stubs/app/Actions/Jetstream/DeleteUser.php', app_path('Actions/Jetstream/DeleteUser.php')); + if ($this->option('oauth')) { + copy(__DIR__.'/../../stubs/app/Actions/Passport/CreateClient.php', app_path('Actions/Passport/CreateClient.php')); + copy(__DIR__.'/../../stubs/app/Actions/Passport/UpdateClient.php', app_path('Actions/Passport/UpdateClient.php')); + } + // Components... (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/livewire/resources/views/components', resource_path('views/components')); @@ -233,9 +254,15 @@ protected function installLivewireStack() copy(__DIR__.'/../../stubs/livewire/resources/views/policy.blade.php', resource_path('views/policy.blade.php')); // Other Views... - (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/livewire/resources/views/api', resource_path('views/api')); (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/livewire/resources/views/profile', resource_path('views/profile')); (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/livewire/resources/views/auth', resource_path('views/auth')); + (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/livewire/resources/views/oauth', resource_path('views/oauth')); + + if ($this->option('oauth')) { + (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/livewire/resources/views/passport-api', resource_path('views/api')); + } else { + (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/livewire/resources/views/api', resource_path('views/api')); + } if (! Str::contains(file_get_contents(base_path('routes/web.php')), "'/dashboard'")) { (new Filesystem)->append(base_path('routes/web.php'), $this->livewireRouteDefinition()); @@ -322,7 +349,7 @@ protected function livewireRouteDefinition() return <<<'EOF' Route::middleware([ - 'auth:sanctum', + config('jetstream.guard') ? 'auth:'.config('jetstream.guard') : 'auth', config('jetstream.auth_session'), 'verified', ])->group(function () { @@ -348,6 +375,7 @@ protected function installInertiaStack() $this->call('install:api', [ '--without-migration-prompt' => true, + '--passport' => $this->option('oauth'), ]); // Install NPM packages... @@ -385,6 +413,10 @@ protected function installInertiaStack() (new Filesystem)->ensureDirectoryExists(resource_path('views')); (new Filesystem)->ensureDirectoryExists(resource_path('markdown')); + if ($this->option('oauth')) { + (new Filesystem)->ensureDirectoryExists(app_path('Actions/Passport')); + } + (new Filesystem)->deleteDirectory(resource_path('sass')); // Terms Of Service / Privacy Policy... @@ -411,6 +443,10 @@ protected function installInertiaStack() // Models... copy(__DIR__.'/../../stubs/app/Models/User.php', app_path('Models/User.php')); + if ($this->option('oauth')) { + $this->replaceInFile('Laravel\Sanctum\HasApiTokens', 'Laravel\Passport\HasApiTokens', app_path('Models/User.php')); + } + // Factories... copy(__DIR__.'/../../database/factories/UserFactory.php', base_path('database/factories/UserFactory.php')); @@ -419,6 +455,11 @@ protected function installInertiaStack() copy(__DIR__.'/../../stubs/app/Actions/Fortify/UpdateUserProfileInformation.php', app_path('Actions/Fortify/UpdateUserProfileInformation.php')); copy(__DIR__.'/../../stubs/app/Actions/Jetstream/DeleteUser.php', app_path('Actions/Jetstream/DeleteUser.php')); + if ($this->option('oauth')) { + copy(__DIR__.'/../../stubs/app/Actions/Passport/CreateClient.php', app_path('Actions/Passport/CreateClient.php')); + copy(__DIR__.'/../../stubs/app/Actions/Passport/UpdateClient.php', app_path('Actions/Passport/UpdateClient.php')); + } + // Blade Views... copy(__DIR__.'/../../stubs/inertia/resources/views/app.blade.php', resource_path('views/app.blade.php')); @@ -434,9 +475,15 @@ protected function installInertiaStack() (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/inertia/resources/js/Components', resource_path('js/Components')); (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/inertia/resources/js/Layouts', resource_path('js/Layouts')); - (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/inertia/resources/js/Pages/API', resource_path('js/Pages/API')); (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/inertia/resources/js/Pages/Auth', resource_path('js/Pages/Auth')); (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/inertia/resources/js/Pages/Profile', resource_path('js/Pages/Profile')); + (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/inertia/resources/js/Pages/OAuth', resource_path('js/Pages/OAuth')); + + if ($this->option('oauth')) { + (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/inertia/resources/js/Pages/PassportAPI', resource_path('js/Pages/API')); + } else { + (new Filesystem)->copyDirectory(__DIR__.'/../../stubs/inertia/resources/js/Pages/API', resource_path('js/Pages/API')); + } copy(__DIR__.'/../../stubs/inertia/routes/web.php', base_path('routes/web.php')); @@ -543,6 +590,10 @@ protected function ensureApplicationIsTeamCompatible() copy(__DIR__.'/../../stubs/app/Models/TeamInvitation.php', app_path('Models/TeamInvitation.php')); copy(__DIR__.'/../../stubs/app/Models/UserWithTeams.php', app_path('Models/User.php')); + if ($this->option('oauth')) { + $this->replaceInFile('Laravel\Sanctum\HasApiTokens', 'Laravel\Passport\HasApiTokens', app_path('Models/User.php')); + } + // Actions... copy(__DIR__.'/../../stubs/app/Actions/Jetstream/AddTeamMember.php', app_path('Actions/Jetstream/AddTeamMember.php')); copy(__DIR__.'/../../stubs/app/Actions/Jetstream/CreateTeam.php', app_path('Actions/Jetstream/CreateTeam.php')); @@ -860,6 +911,7 @@ protected function afterPromptingForMissingArguments(InputInterface $input, Outp options: collect([ 'teams' => 'Team support', 'api' => 'API support', + 'oauth' => 'OAuth support via Laravel Passport', 'verification' => 'Email verification', 'dark' => 'Dark mode', ])->when( diff --git a/src/Contracts/CreatesOAuthClients.php b/src/Contracts/CreatesOAuthClients.php new file mode 100644 index 000000000..e6abb1351 --- /dev/null +++ b/src/Contracts/CreatesOAuthClients.php @@ -0,0 +1,11 @@ +tokenCan($permission) && $this->currentAccessToken() !== null) { return false; diff --git a/src/Http/Controllers/Inertia/OAuthAppController.php b/src/Http/Controllers/Inertia/OAuthAppController.php new file mode 100644 index 000000000..c20041ea9 --- /dev/null +++ b/src/Http/Controllers/Inertia/OAuthAppController.php @@ -0,0 +1,100 @@ +render($request, 'OAuth/Index', [ + 'connections' => $request->user()->tokens() + ->with('client') + ->where('revoked', false) + ->where('expires_at', '>', Date::now()) + ->get() + ->reject(fn (Token $token) => $token->client->revoked || $token->client->firstParty()) + ->groupBy('client_id') + ->map(fn ($tokens) => [ + 'client' => $tokens->first()->client, + 'scopes' => $tokens->pluck('scopes')->flatten()->unique()->values()->all(), + 'tokens_count' => $tokens->count(), + ]) + ->values(), + 'apps' => $request->user()->clients() + ->where('revoked', false) + ->get() + ->map(fn (Client $client) => $client->toArray() + [ + 'is_confidential' => $client->confidential(), + 'created_date' => $client->created_at->toFormattedDateString(), + ]), + ]); + } + + /** + * Create a new OAuth app. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse + */ + public function store(Request $request) + { + $client = app(CreatesOAuthClients::class)->create($request->user(), $request->all()); + + return back()->with('flash', [ + 'client_id' => $client->id, + 'client_secret' => $client->plainSecret, + ]); + } + + /** + * Update the given OAuth app. + * + * @param \Illuminate\Http\Request $request + * @param string $clientId + * @return \Illuminate\Http\RedirectResponse + */ + public function update(Request $request, string $clientId) + { + $client = $request->user()->clients()->findOrFail($clientId); + + app(UpdatesOAuthClients::class)->update($request->user(), $client, $request->all()); + + return back(303); + } + + /** + * Delete the given OAuth App. + * + * @param \Illuminate\Http\Request $request + * @param string $clientId + * @return \Illuminate\Http\RedirectResponse + */ + public function destroy(Request $request, string $clientId) + { + $client = $request->user()->clients()->findOrFail($clientId); + + $client->tokens()->each(function (Token $token) { + $token->refreshToken()->delete(); + $token->delete(); + }); + + $client->delete(); + + return back(303); + } +} diff --git a/src/Http/Controllers/Inertia/OAuthConnectionController.php b/src/Http/Controllers/Inertia/OAuthConnectionController.php new file mode 100644 index 000000000..10ec09346 --- /dev/null +++ b/src/Http/Controllers/Inertia/OAuthConnectionController.php @@ -0,0 +1,29 @@ +user()->tokens() + ->where('client_id', $clientId) + ->each(function (Token $token) { + $token->refreshToken()->delete(); + $token->delete(); + }); + + return back(303); + } +} diff --git a/src/Http/Controllers/Inertia/PassportApiTokenController.php b/src/Http/Controllers/Inertia/PassportApiTokenController.php new file mode 100644 index 000000000..b87cfb51e --- /dev/null +++ b/src/Http/Controllers/Inertia/PassportApiTokenController.php @@ -0,0 +1,73 @@ +render($request, 'API/Index', [ + 'tokens' => $request->user()->tokens() + ->with('client') + ->where('revoked', false) + ->where('expires_at', '>', Date::now()) + ->get() + ->filter(fn (Token $token) => $token->client->hasGrantType('personal_access')) + ->map(fn (Token $token) => $token->toArray() + [ + 'issued_ago' => $token->created_at->diffForHumans(), + 'expires_in' => $token->expires_at->longAbsoluteDiffForHumans(), + ]), + 'availableScopes' => Passport::scopes(), + 'defaultScopes' => Passport::defaultScopes(), + ]); + } + + /** + * Create a new API token. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\RedirectResponse + */ + public function store(Request $request) + { + $request->validate([ + 'name' => ['required', 'string', 'max:255'], + ]); + + $result = $request->user()->createToken( + $request->name, + Passport::validScopes($request->input('scopes', [])) + ); + + return back()->with('flash', [ + 'token' => $result->accessToken, + ]); + } + + /** + * Delete the given API token. + * + * @param \Illuminate\Http\Request $request + * @param string $tokenId + * @return \Illuminate\Http\RedirectResponse + */ + public function destroy(Request $request, string $tokenId) + { + $request->user()->tokens()->find($tokenId)->delete(); + + return back(303); + } +} diff --git a/src/Http/Controllers/Livewire/OAuthAppController.php b/src/Http/Controllers/Livewire/OAuthAppController.php new file mode 100644 index 000000000..d938c0b4d --- /dev/null +++ b/src/Http/Controllers/Livewire/OAuthAppController.php @@ -0,0 +1,18 @@ + + */ + public $apps; + + /** + * Create OAuth app form state. + * + * @var array + */ + public array $createOAuthAppForm = [ + 'name' => '', + 'redirect_uris' => [], + 'confidential' => false, + ]; + + /** + * Indicates if the client credentials is being displayed to the user. + */ + public bool $displayingClientCredentials = false; + + /** + * The client credentials. + */ + public array $clientCredentials = [ + 'id' => null, + 'secret' => null, + ]; + + /** + * Indicates if the user is currently managing an OAuth app. + * + * @var bool + */ + public bool $managingOAuthApp = false; + + /** + * The OAuth app that is currently being managed. + * + * @var \Laravel\Passport\Client|null + */ + public ?Client $oauthAppBeingManaged; + + /** + * The update OAuth app form state. + * + * @var array + */ + public array $updateOAuthAppForm = [ + 'name' => '', + 'redirect_uris' => [], + ]; + + /** + * Indicates if the application is confirming if a OAuth app should be deleted. + */ + public bool $confirmingOAuthAppDeletion = false; + + /** + * The ID of the OAuth app being deleted. + */ + public string $oauthAppIdBeingDeleted; + + /** + * Mount the component. + */ + public function mount(): void + { + $this->loadApps(); + } + + /** + * Render the component. + */ + public function render(): View + { + return view('oauth.oauth-app-manager'); + } + + /** + * Get the current user of the application. + * + * @return \Illuminate\Contracts\Auth\Authenticatable + */ + public function getUserProperty() + { + return Auth::user(); + } + + /** + * Load the user's OAuth apps. + */ + #[On(['app-created', 'app-updated', 'app-deleted'])] + public function loadApps(): void + { + $this->apps = $this->user->clients() + ->where('revoked', false) + ->get(); + } + + /** + * Create a new OAuth Client. + */ + public function createOAuthApp(CreatesOAuthClients $creator): void + { + $this->resetErrorBag(); + + $this->displayClientCredentials( + $creator->create($this->user, $this->createOAuthAppForm) + ); + + $this->createOAuthAppForm['name'] = ''; + $this->createOAuthAppForm['redirect_uris'] = []; + $this->createOAuthAppForm['confidential'] = false; + + $this->dispatch('app-created'); + } + + /** + * Display the token value to the user. + */ + protected function displayClientCredentials(Client $client): void + { + $this->displayingClientCredentials = true; + + $this->clientCredentials = [ + 'id' => $client->id, + 'secret' => $client->plainSecret, + ]; + + $this->dispatch('client-credentials-displayed'); + } + + /** + * Allow the given OAuth app to be managed. + */ + public function manageOAuthApp(string $clientId): void + { + $this->managingOAuthApp = true; + + $this->oauthAppBeingManaged = $this->apps->find($clientId); + + $this->updateOAuthAppForm['name'] = $this->oauthAppBeingManaged->name; + $this->updateOAuthAppForm['redirect_uris'] = $this->oauthAppBeingManaged->redirect_uris; + } + + /** + * Update the OAuth app. + */ + public function updateOAuthApp(UpdatesOAuthClients $updater): void + { + $updater->update($this->user, $this->oauthAppBeingManaged, $this->updateOAuthAppForm); + + $this->dispatch('app-updated'); + + $this->managingOAuthApp = false; + } + + /** + * Confirm that the given OAuth app should be deleted. + */ + public function confirmOAuthAppDeletion(string $clientId): void + { + $this->confirmingOAuthAppDeletion = true; + + $this->oauthAppIdBeingDeleted = $clientId; + } + + /** + * Delete the OAuth app. + */ + public function deleteOAuthApp(): void + { + $app = $this->apps->find($this->oauthAppIdBeingDeleted); + + $app->tokens()->each(function (Token $token) { + $token->refreshToken()->delete(); + $token->delete(); + }); + + $app->delete(); + + $this->dispatch('app-deleted'); + + $this->confirmingOAuthAppDeletion = false; + } +} diff --git a/src/Http/Livewire/OAuthConnectionManager.php b/src/Http/Livewire/OAuthConnectionManager.php new file mode 100644 index 000000000..2d6feed1f --- /dev/null +++ b/src/Http/Livewire/OAuthConnectionManager.php @@ -0,0 +1,105 @@ + + */ + public $connections; + + /** + * Indicates if the application is confirming if a connection should be deleted. + */ + public bool $confirmingConnectionDeletion = false; + + /** + * The ID of the client its connection being deleted. + */ + public ?string $connectionClientIdBeingDeleted; + + /** + * Mount the component. + */ + public function mount(): void + { + $this->loadConnections(); + } + + /** + * Render the component. + */ + public function render(): View + { + return view('oauth.oauth-connection-manager'); + } + + /** + * Get the current user of the application. + * + * @return \Illuminate\Contracts\Auth\Authenticatable + */ + public function getUserProperty() + { + return Auth::user(); + } + + /** + * Load the user's connections with OAuth apps. + */ + #[On('connection-deleted')] + public function loadConnections(): void + { + $this->connections = $this->user->tokens() + ->with('client') + ->where('revoked', false) + ->where('expires_at', '>', Date::now()) + ->get() + ->reject(fn (Token $token) => $token->client->revoked || $token->client->firstParty()) + ->groupBy('client_id') + ->map(fn ($tokens) => [ + 'client' => $tokens->first()->client, + 'scopes' => $tokens->pluck('scopes')->flatten()->unique()->values()->all(), + 'tokens_count' => $tokens->count(), + ]) + ->values(); + } + + /** + * Confirm that the given connection should be deleted. + */ + public function confirmConnectionDeletion(string $clientId): void + { + $this->confirmingConnectionDeletion = true; + + $this->connectionClientIdBeingDeleted = $clientId; + } + + /** + * Delete the connection with the OAuth app. + */ + public function deleteConnection(): void + { + $this->user->tokens() + ->where('client_id', $this->connectionClientIdBeingDeleted) + ->each(function (Token $token) { + $token->refreshToken()->delete(); + $token->delete(); + }); + + $this->dispatch('connection-deleted'); + + $this->confirmingConnectionDeletion = false; + $this->connectionClientIdBeingDeleted = null; + } +} diff --git a/src/Http/Livewire/PassportApiTokenManager.php b/src/Http/Livewire/PassportApiTokenManager.php new file mode 100644 index 000000000..3f94980d4 --- /dev/null +++ b/src/Http/Livewire/PassportApiTokenManager.php @@ -0,0 +1,152 @@ + + */ + public $tokens; + + /** + * Create API token form state. + * + * @var array + */ + public array $createApiTokenForm = [ + 'name' => '', + 'scopes' => [], + ]; + + /** + * Indicates if the token is being displayed to the user. + */ + public bool $displayingToken = false; + + /** + * The token value. + */ + public ?string $tokenValue; + + /** + * Indicates if the application is confirming if an API token should be deleted. + */ + public bool $confirmingApiTokenDeletion = false; + + /** + * The ID of the API token being deleted. + */ + public string $apiTokenIdBeingDeleted; + + /** + * Mount the component. + */ + public function mount(): void + { + $this->createApiTokenForm['scopes'] = Passport::defaultScopes(); + + $this->loadTokens(); + } + + /** + * Load the user's tokens. + */ + #[On(['token-created', 'token-deleted'])] + public function loadTokens(): void + { + $this->tokens = $this->user->tokens() + ->with('client') + ->where('revoked', false) + ->where('expires_at', '>', Date::now()) + ->get() + ->filter(fn ($token) => $token->client->hasGrantType('personal_access')); + } + + /** + * Create a new API token. + */ + public function createApiToken(): void + { + $this->resetErrorBag(); + + Validator::make([ + 'name' => $this->createApiTokenForm['name'], + ], [ + 'name' => ['required', 'string', 'max:255'], + ])->validateWithBag('createApiToken'); + + $this->displayTokenValue($this->user->createToken( + $this->createApiTokenForm['name'], + Passport::validScopes($this->createApiTokenForm['scopes']) + )); + + $this->createApiTokenForm['name'] = ''; + $this->createApiTokenForm['scopes'] = Passport::defaultScopes(); + + $this->dispatch('token-created'); + } + + /** + * Display the token value to the user. + */ + protected function displayTokenValue(PersonalAccessTokenResult $result): void + { + $this->displayingToken = true; + + $this->tokenValue = $result->accessToken; + + $this->dispatch('token-displayed'); + } + + /** + * Confirm that the given API token should be deleted. + */ + public function confirmApiTokenDeletion(string $tokenId): void + { + $this->confirmingApiTokenDeletion = true; + + $this->apiTokenIdBeingDeleted = $tokenId; + } + + /** + * Delete the API token. + */ + public function deleteApiToken(): void + { + $this->tokens->find($this->apiTokenIdBeingDeleted)->delete(); + + $this->dispatch('token-deleted'); + + $this->confirmingApiTokenDeletion = false; + } + + /** + * Get the current user of the application. + * + * @return \Illuminate\Contracts\Auth\Authenticatable + */ + public function getUserProperty() + { + return Auth::user(); + } + + /** + * Render the component. + */ + public function render(): View + { + return view('api.api-token-manager'); + } +} diff --git a/src/Http/Middleware/ShareInertiaData.php b/src/Http/Middleware/ShareInertiaData.php index 80c266358..1c5497d9f 100644 --- a/src/Http/Middleware/ShareInertiaData.php +++ b/src/Http/Middleware/ShareInertiaData.php @@ -34,6 +34,7 @@ public function handle($request, $next) 'flash' => $request->session()->get('flash', []), 'hasAccountDeletionFeatures' => Jetstream::hasAccountDeletionFeatures(), 'hasApiFeatures' => Jetstream::hasApiFeatures(), + 'hasOAuthFeatures' => Jetstream::hasOAuthFeatures(), 'hasTeamFeatures' => Jetstream::hasTeamFeatures(), 'hasTermsAndPrivacyPolicyFeature' => Jetstream::hasTermsAndPrivacyPolicyFeature(), 'managesProfilePhotos' => Jetstream::managesProfilePhotos(), diff --git a/src/Jetstream.php b/src/Jetstream.php index 07e7d16df..6906976f2 100644 --- a/src/Jetstream.php +++ b/src/Jetstream.php @@ -5,11 +5,13 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Laravel\Jetstream\Contracts\AddsTeamMembers; +use Laravel\Jetstream\Contracts\CreatesOAuthClients; use Laravel\Jetstream\Contracts\CreatesTeams; use Laravel\Jetstream\Contracts\DeletesTeams; use Laravel\Jetstream\Contracts\DeletesUsers; use Laravel\Jetstream\Contracts\InvitesTeamMembers; use Laravel\Jetstream\Contracts\RemovesTeamMembers; +use Laravel\Jetstream\Contracts\UpdatesOAuthClients; use Laravel\Jetstream\Contracts\UpdatesTeamNames; class Jetstream @@ -186,6 +188,16 @@ public static function hasApiFeatures() return Features::hasApiFeatures(); } + /** + * Determine if Jetstream is supporting OAuth features. + * + * @return bool + */ + public static function hasOAuthFeatures() + { + return Features::hasOAuthFeatures(); + } + /** * Determine if Jetstream is supporting team features. * @@ -444,6 +456,26 @@ public static function deleteUsersUsing(string $class) return app()->singleton(DeletesUsers::class, $class); } + /** + * Register a class / callback that should be used to create OAuth clients. + * + * @param class-string<\Laravel\Jetstream\Contracts\CreatesOAuthClients> $class + */ + public static function createOAuthClientsUsing(string $class): void + { + app()->singleton(CreatesOAuthClients::class, $class); + } + + /** + * Register a class / callback that should be used to update OAuth clients. + * + * @param class-string<\Laravel\Jetstream\Contracts\UpdatesOAuthClients> $class + */ + public static function updateOAuthClientsUsing(string $class): void + { + app()->singleton(UpdatesOAuthClients::class, $class); + } + /** * Manage Jetstream's Inertia settings. * diff --git a/src/JetstreamServiceProvider.php b/src/JetstreamServiceProvider.php index 720706409..16db34b63 100644 --- a/src/JetstreamServiceProvider.php +++ b/src/JetstreamServiceProvider.php @@ -14,18 +14,22 @@ use Inertia\Inertia; use Laravel\Fortify\Events\PasswordUpdatedViaController; use Laravel\Fortify\Fortify; -use Laravel\Jetstream\Http\Livewire\ApiTokenManager; +use Laravel\Jetstream\Http\Livewire\ApiTokenManager as SanctumApiTokenManager; use Laravel\Jetstream\Http\Livewire\CreateTeamForm; use Laravel\Jetstream\Http\Livewire\DeleteTeamForm; use Laravel\Jetstream\Http\Livewire\DeleteUserForm; use Laravel\Jetstream\Http\Livewire\LogoutOtherBrowserSessionsForm; use Laravel\Jetstream\Http\Livewire\NavigationMenu; +use Laravel\Jetstream\Http\Livewire\OAuthAppManager; +use Laravel\Jetstream\Http\Livewire\OAuthConnectionManager; +use Laravel\Jetstream\Http\Livewire\PassportApiTokenManager; use Laravel\Jetstream\Http\Livewire\TeamMemberManager; use Laravel\Jetstream\Http\Livewire\TwoFactorAuthenticationForm; use Laravel\Jetstream\Http\Livewire\UpdatePasswordForm; use Laravel\Jetstream\Http\Livewire\UpdateProfileInformationForm; use Laravel\Jetstream\Http\Livewire\UpdateTeamNameForm; use Laravel\Jetstream\Http\Middleware\ShareInertiaData; +use Laravel\Passport\Passport; use Livewire\Livewire; class JetstreamServiceProvider extends ServiceProvider @@ -49,6 +53,10 @@ public function boot() { Fortify::viewPrefix('auth.'); + if (class_exists(Passport::class)) { + Passport::viewPrefix('auth.oauth.'); + } + $this->configurePublishing(); $this->configureRoutes(); $this->configureCommands(); @@ -90,7 +98,14 @@ public function boot() Livewire::component('profile.delete-user-form', DeleteUserForm::class); if (Features::hasApiFeatures()) { - Livewire::component('api.api-token-manager', ApiTokenManager::class); + Livewire::component('api.api-token-manager', + Features::hasOAuthFeatures() ? PassportApiTokenManager::class : SanctumApiTokenManager::class + ); + } + + if (Features::hasOAuthFeatures()) { + Livewire::component('oauth.oauth-app-manager', OAuthAppManager::class); + Livewire::component('oauth.oauth-connection-manager', OAuthConnectionManager::class); } if (Features::hasTeamFeatures()) { @@ -232,5 +247,11 @@ protected function bootInertia() Fortify::confirmPasswordView(function () { return Inertia::render('Auth/ConfirmPassword'); }); + + if (class_exists(Passport::class)) { + Passport::authorizationView(fn ($params) => Inertia::render('Auth/OAuth/Authorize', $params)); + // Passport::deviceAuthorizationView(fn ($params) => Inertia::render('Auth/OAuth/Device/Authorize', $params)); + // Passport::deviceUserCodeView(fn ($params) => Inertia::render('Auth/OAuth/Device/UserCode', $params)); + } } } diff --git a/src/Rules/Uri.php b/src/Rules/Uri.php new file mode 100644 index 000000000..46aa2559e --- /dev/null +++ b/src/Rules/Uri.php @@ -0,0 +1,19 @@ + $input + */ + public function create(User $user, array $input): Client + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + 'redirect_uris' => ['required', 'list'], + 'redirect_uris.*' => ['required', 'string', new Uri], + 'confidential' => 'boolean', + ])->validateWithBag('createClient'); + + return $this->clients->createAuthorizationCodeGrantClient( + $input['name'], + $input['redirect_uris'], + (bool) ($input['confidential'] ?? false), + $user + ); + } +} diff --git a/stubs/app/Actions/Passport/UpdateClient.php b/stubs/app/Actions/Passport/UpdateClient.php new file mode 100644 index 000000000..5a81fd414 --- /dev/null +++ b/stubs/app/Actions/Passport/UpdateClient.php @@ -0,0 +1,31 @@ + $input + */ + public function update(User $user, Client $client, array $input): void + { + Validator::make($input, [ + 'name' => ['required', 'string', 'max:255'], + 'redirect_uris' => ['required', 'list'], + 'redirect_uris.*' => ['required', 'string', new Uri], + ])->validateWithBag('updateClient'); + + $client->forceFill([ + 'name' => $input['name'], + 'redirect_uris' => $input['redirect_uris'], + ])->save(); + } +} diff --git a/stubs/app/Providers/JetstreamServiceProvider.php b/stubs/app/Providers/JetstreamServiceProvider.php index 2c8b5f978..1c5d4502c 100644 --- a/stubs/app/Providers/JetstreamServiceProvider.php +++ b/stubs/app/Providers/JetstreamServiceProvider.php @@ -27,6 +27,11 @@ public function boot(): void Jetstream::deleteUsersUsing(DeleteUser::class); Vite::prefetch(concurrency: 3); + + if (Jetstream::hasOAuthFeatures()) { + Jetstream::createOAuthClientsUsing(\App\Actions\Passport\CreateClient::class); + Jetstream::updateOAuthClientsUsing(\App\Actions\Passport\UpdateClient::class); + } } /** diff --git a/stubs/app/Providers/JetstreamWithTeamsServiceProvider.php b/stubs/app/Providers/JetstreamWithTeamsServiceProvider.php index ca8ce8e61..53d515471 100644 --- a/stubs/app/Providers/JetstreamWithTeamsServiceProvider.php +++ b/stubs/app/Providers/JetstreamWithTeamsServiceProvider.php @@ -36,6 +36,11 @@ public function boot(): void Jetstream::removeTeamMembersUsing(RemoveTeamMember::class); Jetstream::deleteTeamsUsing(DeleteTeam::class); Jetstream::deleteUsersUsing(DeleteUser::class); + + if (Jetstream::hasOAuthFeatures()) { + Jetstream::createOAuthClientsUsing(\App\Actions\Passport\CreateClient::class); + Jetstream::updateOAuthClientsUsing(\App\Actions\Passport\UpdateClient::class); + } } /** diff --git a/stubs/config/jetstream.php b/stubs/config/jetstream.php index 1fd04dfa0..4582db93b 100644 --- a/stubs/config/jetstream.php +++ b/stubs/config/jetstream.php @@ -61,6 +61,7 @@ // Features::termsAndPrivacyPolicy(), // Features::profilePhotos(), // Features::api(), + // Features::oauth(), // Features::teams(['invitations' => true]), Features::accountDeletion(), ], diff --git a/stubs/inertia/resources/js/Layouts/AppLayout.vue b/stubs/inertia/resources/js/Layouts/AppLayout.vue index 654a6f793..b36f9a679 100644 --- a/stubs/inertia/resources/js/Layouts/AppLayout.vue +++ b/stubs/inertia/resources/js/Layouts/AppLayout.vue @@ -146,6 +146,10 @@ const logout = () => { API Tokens + + OAuth Apps + +
@@ -222,6 +226,10 @@ const logout = () => { API Tokens + + OAuth Apps + +
diff --git a/stubs/inertia/resources/js/Pages/Auth/OAuth/Authorize.vue b/stubs/inertia/resources/js/Pages/Auth/OAuth/Authorize.vue new file mode 100644 index 000000000..62fbfa01e --- /dev/null +++ b/stubs/inertia/resources/js/Pages/Auth/OAuth/Authorize.vue @@ -0,0 +1,76 @@ + + + diff --git a/stubs/inertia/resources/js/Pages/Auth/OAuth/Device/Authorize.vue b/stubs/inertia/resources/js/Pages/Auth/OAuth/Device/Authorize.vue new file mode 100644 index 000000000..d88cebba7 --- /dev/null +++ b/stubs/inertia/resources/js/Pages/Auth/OAuth/Device/Authorize.vue @@ -0,0 +1,71 @@ + + + diff --git a/stubs/inertia/resources/js/Pages/Auth/OAuth/Device/UserCode.vue b/stubs/inertia/resources/js/Pages/Auth/OAuth/Device/UserCode.vue new file mode 100644 index 000000000..6a9b30002 --- /dev/null +++ b/stubs/inertia/resources/js/Pages/Auth/OAuth/Device/UserCode.vue @@ -0,0 +1,71 @@ + + + diff --git a/stubs/inertia/resources/js/Pages/OAuth/Index.vue b/stubs/inertia/resources/js/Pages/OAuth/Index.vue new file mode 100644 index 000000000..b4723289a --- /dev/null +++ b/stubs/inertia/resources/js/Pages/OAuth/Index.vue @@ -0,0 +1,28 @@ + + + diff --git a/stubs/inertia/resources/js/Pages/OAuth/Partials/OAuthAppManager.vue b/stubs/inertia/resources/js/Pages/OAuth/Partials/OAuthAppManager.vue new file mode 100644 index 000000000..3754004b8 --- /dev/null +++ b/stubs/inertia/resources/js/Pages/OAuth/Partials/OAuthAppManager.vue @@ -0,0 +1,318 @@ + + + diff --git a/stubs/inertia/resources/js/Pages/OAuth/Partials/OAuthConnectionManager.vue b/stubs/inertia/resources/js/Pages/OAuth/Partials/OAuthConnectionManager.vue new file mode 100644 index 000000000..b28c072f5 --- /dev/null +++ b/stubs/inertia/resources/js/Pages/OAuth/Partials/OAuthConnectionManager.vue @@ -0,0 +1,102 @@ + + + diff --git a/stubs/inertia/resources/js/Pages/PassportAPI/Index.vue b/stubs/inertia/resources/js/Pages/PassportAPI/Index.vue new file mode 100644 index 000000000..bbb8f698e --- /dev/null +++ b/stubs/inertia/resources/js/Pages/PassportAPI/Index.vue @@ -0,0 +1,30 @@ + + + diff --git a/stubs/inertia/resources/js/Pages/PassportAPI/Partials/ApiTokenManager.vue b/stubs/inertia/resources/js/Pages/PassportAPI/Partials/ApiTokenManager.vue new file mode 100644 index 000000000..546e268fd --- /dev/null +++ b/stubs/inertia/resources/js/Pages/PassportAPI/Partials/ApiTokenManager.vue @@ -0,0 +1,210 @@ + + + diff --git a/stubs/inertia/routes/web.php b/stubs/inertia/routes/web.php index 5bfb84299..7570b628a 100644 --- a/stubs/inertia/routes/web.php +++ b/stubs/inertia/routes/web.php @@ -14,7 +14,7 @@ }); Route::middleware([ - 'auth:sanctum', + config('jetstream.guard') ? 'auth:'.config('jetstream.guard') : 'auth', config('jetstream.auth_session'), 'verified', ])->group(function () { diff --git a/stubs/livewire/resources/views/auth/oauth/authorize.blade.php b/stubs/livewire/resources/views/auth/oauth/authorize.blade.php new file mode 100644 index 000000000..186b5baec --- /dev/null +++ b/stubs/livewire/resources/views/auth/oauth/authorize.blade.php @@ -0,0 +1,59 @@ + + + + + + +
+

{{ $user->name }}

+

{{ $user->email }}

+
+ +
+ {{ __(':client is requesting permission to access your account.', ['client' => $client->name]) }} +
+ + @if (count($scopes) > 0) +
+

{{ __('This application will be able to:') }}

+ +
    + @foreach ($scopes as $scope) +
  • {{ $scope->description }}
  • + @endforeach +
+
+ @endif + +
+ + @csrf + + + + + + + {{ __('Authorize') }} + + + +
+ @csrf + @method('DELETE') + + + + + + + {{ __('Decline') }} + +
+ + + {{ __('Log into another account') }} + +
+
+
diff --git a/stubs/livewire/resources/views/auth/oauth/device/authorize.blade.php b/stubs/livewire/resources/views/auth/oauth/device/authorize.blade.php new file mode 100644 index 000000000..ea60267e5 --- /dev/null +++ b/stubs/livewire/resources/views/auth/oauth/device/authorize.blade.php @@ -0,0 +1,55 @@ + + + + + + +
+

{{ $user->name }}

+

{{ $user->email }}

+
+ +
+ {{ __(':client is requesting permission to access your account.', ['client' => $client->name]) }} +
+ + @if (count($scopes) > 0) +
+

{{ __('This application will be able to:') }}

+ +
    + @foreach ($scopes as $scope) +
  • {{ $scope->description }}
  • + @endforeach +
+
+ @endif + +
+
+ @csrf + + + + + + + {{ __('Authorize') }} + +
+ +
+ @csrf + @method('DELETE') + + + + + + + {{ __('Decline') }} + +
+
+
+
diff --git a/stubs/livewire/resources/views/auth/oauth/device/user-code.blade.php b/stubs/livewire/resources/views/auth/oauth/device/user-code.blade.php new file mode 100644 index 000000000..bcd1c4f92 --- /dev/null +++ b/stubs/livewire/resources/views/auth/oauth/device/user-code.blade.php @@ -0,0 +1,36 @@ + + + + + + + @if (session('status') === 'authorization-approved') +
+ {{ __('Success! Continue on your device.') }} +
+ @elseif(session('status') === 'authorization-denied') +
+ {{ __('Denied! Device authorization canceled.') }} +
+ @endif + +
+ {{ __('Enter the code displayed on your device.') }} +
+ + + +
+
+ + +
+ +
+ + {{ __('Continue') }} + +
+
+
+
diff --git a/stubs/livewire/resources/views/navigation-menu.blade.php b/stubs/livewire/resources/views/navigation-menu.blade.php index fc5b22dbf..a6a0433bb 100644 --- a/stubs/livewire/resources/views/navigation-menu.blade.php +++ b/stubs/livewire/resources/views/navigation-menu.blade.php @@ -108,6 +108,12 @@ @endif + @if (Laravel\Jetstream\Jetstream::hasOAuthFeatures()) + + {{ __('OAuth Apps') }} + + @endif +
@@ -171,6 +177,12 @@ @endif + @if (Laravel\Jetstream\Jetstream::hasOAuthFeatures()) + + {{ __('OAuth Apps') }} + + @endif +
@csrf diff --git a/stubs/livewire/resources/views/oauth/index.blade.php b/stubs/livewire/resources/views/oauth/index.blade.php new file mode 100644 index 000000000..88794827b --- /dev/null +++ b/stubs/livewire/resources/views/oauth/index.blade.php @@ -0,0 +1,15 @@ + + +

+ {{ __('OAuth Apps') }} +

+
+ +
+
+ @livewire('oauth.oauth-connection-manager') + + @livewire('oauth.oauth-app-manager') +
+
+
diff --git a/stubs/livewire/resources/views/oauth/oauth-app-manager.blade.php b/stubs/livewire/resources/views/oauth/oauth-app-manager.blade.php new file mode 100644 index 000000000..9f3ccea5e --- /dev/null +++ b/stubs/livewire/resources/views/oauth/oauth-app-manager.blade.php @@ -0,0 +1,202 @@ +
+ + + + {{ __('Register OAuth App') }} + + + + {{ __('You may register an OAuth client to use our application\'s API.') }} + + + + +
+ + + +
+ + +
+ + + +
+ + +
+ + +
+
+ + + + {{ __('Registered.') }} + + + + {{ __('Register') }} + + +
+ + @if ($this->apps->isNotEmpty()) + + + +
+ + + {{ __('Manage OAuth Apps') }} + + + + {{ __('You may delete any of your existing registered apps if they are no longer needed.') }} + + + + +
+ @foreach ($this->apps as $app) +
+
+ {{ $app->name }} + + – {{ $app->confidential() ? __('Confidential') : __('Public') }} + +
+ +
+
+ {{ __('Created at') }} {{ $app->created_at->toFormattedDateString() }} +
+ + + + +
+
+ @endforeach +
+
+
+
+ @endif + + + + + {{ __('Client Credentials') }} + + + +
+
+ {{ __('Please copy your new client credentials.') }} + + @if ($clientCredentials['secret']) + {{ __('For your security, client secret won\'t be shown again.') }} + @endif +
+ +
+ + +
+ + @if ($clientCredentials['secret']) +
+ + +
+ @endif +
+
+ + + + {{ __('Close') }} + + +
+ + + + + {{ __('Delete OAuth App') }} + + + + {{ __('Are you sure you would like to delete this app?') }} + + + + + {{ __('Cancel') }} + + + + {{ __('Delete') }} + + + + + + + + {{ __('OAuth App Management') }} + + + +
+ +
+ + +
+ + +
+ + + +
+ + +
+ + + +
+
+
+ + + + {{ __('Cancel') }} + + + + {{ __('Save') }} + + +
+
diff --git a/stubs/livewire/resources/views/oauth/oauth-connection-manager.blade.php b/stubs/livewire/resources/views/oauth/oauth-connection-manager.blade.php new file mode 100644 index 000000000..63c66896f --- /dev/null +++ b/stubs/livewire/resources/views/oauth/oauth-connection-manager.blade.php @@ -0,0 +1,67 @@ +
+ @if ($this->connections->isNotEmpty()) + +
+ + + {{ __('Manage Authorized Apps') }} + + + + {{ __('Keep track of your connections with third-party apps and services. You may delete the access you\'ve given to any of your existing authorized apps if they are no longer needed.') }} + + + + +
+ @foreach ($this->connections as $connection) +
+
+
+ {{ $connection['client']->name }} +
+
+ {{ implode(', ', $connection['scopes']) }} +
+
+ +
+
+ {{ $connection['tokens_count'] }} {{ __('Tokens') }} +
+ + +
+
+ @endforeach +
+
+
+
+ + + @endif + + + + + {{ __('Delete OAuth Connection') }} + + + + {{ __('Are you sure you would like to delete all connections you have with this app?') }} + + + + + {{ __('Cancel') }} + + + + {{ __('Delete') }} + + + +
diff --git a/stubs/livewire/resources/views/passport-api/api-token-manager.blade.php b/stubs/livewire/resources/views/passport-api/api-token-manager.blade.php new file mode 100644 index 000000000..36ab325d4 --- /dev/null +++ b/stubs/livewire/resources/views/passport-api/api-token-manager.blade.php @@ -0,0 +1,144 @@ +
+ + + + {{ __('Create API Token') }} + + + + {{ __('Personal access tokens allow secure authentication to our application\'s API for your personal use.') }} + + + + +
+ + + +
+ + + @if (count($scopes = Laravel\Passport\Passport::scopes()) > 0) +
+
+ {{ __('Scopes') }} + +
+ @foreach ($scopes as $scope) + + @endforeach +
+
+
+ @endif +
+ + + + {{ __('Created.') }} + + + + {{ __('Create') }} + + +
+ + @if ($this->tokens->isNotEmpty()) + + + +
+ + + {{ __('Manage API Tokens') }} + + + + {{ __('You may delete any of your existing personal access tokens if they are no longer needed.') }} + + + + +
+ @foreach ($this->tokens as $token) +
+
+
+ {{ $token->name }} +
+
+ {{ implode(', ', $token->scopes) }} +
+
+ +
+
+ {{ __('Issued') }} {{ $token->created_at->diffForHumans() }} +
+ +
+ {{ __('Expires in') }} {{ $token->expires_at->longAbsoluteDiffForHumans() }} +
+ + +
+
+ @endforeach +
+
+
+
+ @endif + + + + + {{ __('Personal Access Token') }} + + + +
+ {{ __('Please copy your new personal access token. For your security, it won\'t be shown again.') }} +
+ + +
+ + + + {{ __('Close') }} + + +
+ + + + + {{ __('Delete API Token') }} + + + + {{ __('Are you sure you would like to delete this API token?') }} + + + + + {{ __('Cancel') }} + + + + {{ __('Delete') }} + + + +
diff --git a/stubs/livewire/resources/views/passport-api/index.blade.php b/stubs/livewire/resources/views/passport-api/index.blade.php new file mode 100644 index 000000000..5f6be47ab --- /dev/null +++ b/stubs/livewire/resources/views/passport-api/index.blade.php @@ -0,0 +1,13 @@ + + +

+ {{ __('API Tokens') }} +

+
+ +
+
+ @livewire('api.api-token-manager') +
+
+