diff --git a/src/.env.example b/src/.env.example --- a/src/.env.example +++ b/src/.env.example @@ -161,6 +161,10 @@ #PASSPORT_PROXY_OAUTH_CLIENT_ID= #PASSPORT_PROXY_OAUTH_CLIENT_SECRET= +# Generate with ./artisan passport:client --password +#PASSPORT_COMPANIONAPP_OAUTH_CLIENT_ID= +#PASSPORT_COMPANIONAPP_OAUTH_CLIENT_SECRET= + PASSPORT_PRIVATE_KEY= PASSPORT_PUBLIC_KEY= diff --git a/src/app/Http/Controllers/API/V4/CompanionAppsController.php b/src/app/Http/Controllers/API/V4/CompanionAppsController.php --- a/src/app/Http/Controllers/API/V4/CompanionAppsController.php +++ b/src/app/Http/Controllers/API/V4/CompanionAppsController.php @@ -2,11 +2,14 @@ namespace App\Http\Controllers\API\V4; -use App\Http\Controllers\Controller; +use App\Http\Controllers\ResourceController; +use App\Utils; +use App\Tenant; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; +use BaconQrCode; -class CompanionAppsController extends Controller +class CompanionAppsController extends ResourceController { /** * Register a companion app. @@ -55,4 +58,138 @@ return response()->json(['status' => 'success']); } + + + /** + * Generate a QR-code image for a string + * + * @param string $data data to encode + * + * @return string + */ + private static function generateQRCode($data) + { + $renderer_style = new BaconQrCode\Renderer\RendererStyle\RendererStyle(300, 1); + $renderer_image = new BaconQrCode\Renderer\Image\SvgImageBackEnd(); + $renderer = new BaconQrCode\Renderer\ImageRenderer($renderer_style, $renderer_image); + $writer = new BaconQrCode\Writer($renderer); + + return 'data:image/svg+xml;base64,' . base64_encode($writer->writeString($data)); + } + + /** + * Delete a resource. + * + * @param string $id Resource identifier + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function destroy($id) + { + $result = \App\CompanionApp::find($id); + if (!$result) { + return $this->errorResponse(404); + } + + $user = $this->guard()->user(); + if ($user->id != $result->user_id) { + return $this->errorResponse(403); + } + + $result->delete(); + return response()->json(); + } + + /** + * List devices. + * + * The user-entitlements billed to the current user wallet(s) + * + * @return \Illuminate\Http\JsonResponse + */ + public function index() + { + $user = $this->guard()->user(); + $search = trim(request()->input('search')); + $page = intval(request()->input('page')) ?: 1; + $pageSize = 20; + $hasMore = false; + + $result = \App\CompanionApp::where('user_id', $user->id); + + $result = $result->orderBy('created_at') + ->limit($pageSize + 1) + ->offset($pageSize * ($page - 1)) + ->get(); + + if (count($result) > $pageSize) { + $result->pop(); + $hasMore = true; + } + + // Process the result + $result = $result->map( + function ($device) { + return $device->toArray(); + } + ); + + $result = [ + 'list' => $result, + 'count' => count($result), + 'hasMore' => $hasMore, + ]; + + return response()->json($result); + } + + /** + * Get the information about the specified companion app. + * + * @param string $id CompanionApp identifier + * + * @return \Illuminate\Http\JsonResponse + */ + public function show($id) + { + $result = \App\CompanionApp::find($id); + if (!$result) { + return $this->errorResponse(404); + } + + $user = $this->guard()->user(); + if ($user->id != $result->user_id) { + return $this->errorResponse(403); + } + + return response()->json($result->toArray()); + } + + /** + * Get the information about the specified companion app. + * + * @return \Illuminate\Http\JsonResponse|void + */ + public function pairing() + { + $user = $this->guard()->user(); + + $clientIdentifier = \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_id'); + $clientSecret = \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_secret'); + if (empty($clientIdentifier) || empty($clientSecret)) { + \Log::warning("Empty client identifier or secret. Can't generate qr-code."); + return $this->errorResponse(500); + } + + $response['qrcode'] = self::generateQRCode( + json_encode([ + "serverUrl" => Utils::serviceUrl('', $user->tenant_id), + "clientIdentifier" => \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_id'), + "clientSecret" => \App\Tenant::getConfig($user->tenant_id, 'auth.companion_app.client_secret'), + "username" => $user->email + ]) + ); + + return response()->json($response); + } } diff --git a/src/app/Http/Controllers/API/V4/UsersController.php b/src/app/Http/Controllers/API/V4/UsersController.php --- a/src/app/Http/Controllers/API/V4/UsersController.php +++ b/src/app/Http/Controllers/API/V4/UsersController.php @@ -192,6 +192,7 @@ 'enableSettings' => $isController, 'enableUsers' => $isController, 'enableWallets' => $isController, + 'enableCompanionapps' => $isController, ]; return array_merge($process, $result); diff --git a/src/composer.json b/src/composer.json --- a/src/composer.json +++ b/src/composer.json @@ -15,6 +15,7 @@ ], "require": { "php": "^7.3", + "bacon/bacon-qr-code": "^2.0", "barryvdh/laravel-dompdf": "^0.8.6", "doctrine/dbal": "^2.13", "dyrynda/laravel-nullable-fields": "*", diff --git a/src/config/auth.php b/src/config/auth.php --- a/src/config/auth.php +++ b/src/config/auth.php @@ -118,6 +118,11 @@ 'client_secret' => env('PASSPORT_PROXY_OAUTH_CLIENT_SECRET'), ], + 'companion_app' => [ + 'client_id' => env('PASSPORT_COMPANIONAPP_OAUTH_CLIENT_ID'), + 'client_secret' => env('PASSPORT_COMPANIONAPP_OAUTH_CLIENT_SECRET'), + ], + 'token_expiry_minutes' => env('OAUTH_TOKEN_EXPIRY', 60), 'refresh_token_expiry_minutes' => env('OAUTH_REFRESH_TOKEN_EXPIRY', 30 * 24 * 60), ]; diff --git a/src/database/seeds/local/OauthClientSeeder.php b/src/database/seeds/local/OauthClientSeeder.php --- a/src/database/seeds/local/OauthClientSeeder.php +++ b/src/database/seeds/local/OauthClientSeeder.php @@ -30,5 +30,20 @@ $client->id = \config('auth.proxy.client_id'); $client->save(); + + $companionAppClient = Passport::client()->forceFill([ + 'user_id' => null, + 'name' => "CompanionApp Password Grant Client", + 'secret' => \config('auth.companion_app.client_secret'), + 'provider' => 'users', + 'redirect' => 'https://' . \config('app.website_domain'), + 'personal_access_client' => 0, + 'password_client' => 1, + 'revoked' => false, + ]); + + $companionAppClient->id = \config('auth.companion_app.client_id'); + + $companionAppClient->save(); } } diff --git a/src/resources/js/fontawesome.js b/src/resources/js/fontawesome.js --- a/src/resources/js/fontawesome.js +++ b/src/resources/js/fontawesome.js @@ -21,7 +21,9 @@ faUniversity, faExclamationCircle, faInfoCircle, + faMinusCircle, faLock, + faMobile, faKey, faPlus, faSearch, @@ -57,7 +59,9 @@ faFolderOpen, faGlobe, faInfoCircle, + faMinusCircle, faLock, + faMobile, faKey, faPlus, faSearch, diff --git a/src/resources/js/user/routes.js b/src/resources/js/user/routes.js --- a/src/resources/js/user/routes.js +++ b/src/resources/js/user/routes.js @@ -8,6 +8,7 @@ // Note: you can pack multiple components into the same chunk, webpackChunkName // is also used to get a sensible file name instead of numbers +const CompanionAppComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/CompanionApp') const DashboardComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Dashboard') const DistlistInfoComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/Info') const DistlistListComponent = () => import(/* webpackChunkName: "../user/pages" */ '../../vue/Distlist/List') @@ -45,6 +46,12 @@ component: DistlistListComponent, meta: { requiresAuth: true, perm: 'distlists' } }, + { + path: '/companion', + name: 'companion', + component: CompanionAppComponent, + meta: { requiresAuth: true, perm: 'companionapps' } + }, { path: '/domain/:domain', name: 'domain', diff --git a/src/resources/lang/en/ui.php b/src/resources/lang/en/ui.php --- a/src/resources/lang/en/ui.php +++ b/src/resources/lang/en/ui.php @@ -42,6 +42,7 @@ 'beta' => "beta", 'distlists' => "Distribution lists", 'chat' => "Video chat", + 'companion' => "Companion app", 'domains' => "Domains", 'invitations' => "Invitations", 'profile' => "Your profile", diff --git a/src/resources/vue/CompanionApp.vue b/src/resources/vue/CompanionApp.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/CompanionApp.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/resources/vue/Dashboard.vue b/src/resources/vue/Dashboard.vue --- a/src/resources/vue/Dashboard.vue +++ b/src/resources/vue/Dashboard.vue @@ -38,6 +38,9 @@ {{ $t('dashboard.webmail') }} + + {{ $t('dashboard.companion') }} + diff --git a/src/resources/vue/Widgets/CompanionappList.vue b/src/resources/vue/Widgets/CompanionappList.vue new file mode 100644 --- /dev/null +++ b/src/resources/vue/Widgets/CompanionappList.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -63,13 +63,15 @@ 'prefix' => $prefix . 'api/v4' ], function () { - Route::post('companion/register', 'API\V4\CompanionAppsController@register'); - Route::post('auth-attempts/{id}/confirm', 'API\V4\AuthAttemptsController@confirm'); Route::post('auth-attempts/{id}/deny', 'API\V4\AuthAttemptsController@deny'); Route::get('auth-attempts/{id}/details', 'API\V4\AuthAttemptsController@details'); Route::get('auth-attempts', 'API\V4\AuthAttemptsController@index'); + Route::apiResource('companion', 'API\V4\CompanionAppsController'); + Route::get('companion/pairing', 'API\V4\CompanionAppsController@pairing'); + Route::post('companion/register', 'API\V4\CompanionAppsController@register'); + Route::apiResource('domains', 'API\V4\DomainsController'); Route::get('domains/{id}/confirm', 'API\V4\DomainsController@confirm'); Route::get('domains/{id}/skus', 'API\V4\SkusController@domainSkus');