Google Sheet - OAuth "client" powered integrations (#415)

* fix `helpers.php`

* fix `.eslintrc.cjs`

* spreadsheet manager

* fetch providers. set `oauth_id` for integrations

* create spreadsheet on integration create event

* connect OAuth accounts

* display actions. connect account if missing

* cleanup

* handle form field change

* map integration data object to `SpreadsheetData`

* validate request

* wip

* redirect to integrations page

* fix refresh token

* add helper text

* add extra integration info

* refactor

* refresh google token

* fix validation

* add tests

* Fix linting issue

* Update composer lock file

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Boris Lepikhin 2024-06-05 06:35:46 -07:00 committed by GitHub
parent 03d695c74e
commit 24d33a9ebb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 3383 additions and 298 deletions

4
.phpactor.json Normal file
View File

@ -0,0 +1,4 @@
{
"$schema": "/Applications/Tinkerwell.app/Contents/Resources/phpactor/phpactor.schema.json",
"language_server_phpstan.enabled": false
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Events\Models;
use App\Models\Integration\FormIntegration;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FormIntegrationCreated
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(
public FormIntegration $formIntegration
) {
}
}

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Forms\Integration;
use App\Http\Controllers\Controller;
use App\Http\Requests\Integration\FormIntegrationsRequest;
use App\Http\Resources\FormIntegrationResource;
use App\Models\Forms\Form;
use App\Models\Integration\FormIntegration;
@ -19,7 +20,12 @@ class FormIntegrationsController extends Controller
$form = Form::findOrFail((int)$id);
$this->authorize('view', $form);
return FormIntegration::where('form_id', $form->id)->get();
$integrations = FormIntegration::query()
->where('form_id', $form->id)
->with('provider.user')
->get();
return FormIntegrationResource::collection($integrations);
}
public function create(FormIntegrationsRequest $request, string $id)
@ -27,15 +33,19 @@ class FormIntegrationsController extends Controller
$form = Form::findOrFail((int)$id);
$this->authorize('update', $form);
/** @var FormIntegration $formIntegration */
$formIntegration = FormIntegration::create(
array_merge([
'form_id' => $form->id,
], $request->toIntegrationData())
);
$formIntegration->refresh();
$formIntegration->load('provider.user');
return $this->success([
'message' => 'Form Integration was created.',
'form_integration' => $formIntegration
'form_integration' => FormIntegrationResource::make($formIntegration)
]);
}
@ -46,10 +56,11 @@ class FormIntegrationsController extends Controller
$formIntegration = FormIntegration::findOrFail((int)$integrationid);
$formIntegration->update($request->toIntegrationData());
$formIntegration->load('provider.user');
return $this->success([
'message' => 'Form Integration was updated.',
'form_integration' => $formIntegration
'form_integration' => FormIntegrationResource::make($formIntegration)
]);
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\OAuth;
use App\Http\Controllers\Controller;
use App\Http\Resources\OAuthProviderResource;
use App\Integrations\OAuth\OAuthProviderService;
use App\Models\OAuthProvider;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class OAuthProviderController extends Controller
{
public function index()
{
/** @var \App\Models\User $user */
$user = Auth::user();
$providers = $user->oauthProviders()->get();
return OAuthProviderResource::collection($providers);
}
public function connect(Request $request, OAuthProviderService $service)
{
$userId = Auth::id();
cache()->put("oauth-intention:{$userId}", $request->input('intention'), 60 * 5);
return response()->json([
'url' => $service->getDriver()->getRedirectUrl(),
]);
}
public function handleRedirect(OAuthProviderService $service)
{
$driverUser = $service->getDriver()->getUser();
$provider = OAuthProvider::query()
->updateOrCreate(
[
'user_id' => Auth::id(),
'provider' => $service,
'provider_user_id' => $driverUser->getId(),
],
[
'access_token' => $driverUser->token,
'refresh_token' => $driverUser->refreshToken,
'name' => $driverUser->getName(),
'email' => $driverUser->getEmail(),
]
);
return OAuthProviderResource::make($provider);
}
public function destroy(OAuthProvider $provider)
{
$this->authorize('delete', $provider);
$provider->delete();
return response()->json();
}
}

View File

@ -21,9 +21,9 @@ class FormIntegrationsRequest extends FormRequest
// Load integration class, and get rules
$integration = FormIntegration::getIntegration($request->integration_id);
if ($integration && isset($integration['file_name']) && class_exists(
'App\Service\Forms\Integrations\\' . $integration['file_name']
'App\Integrations\Handlers\\' . $integration['file_name']
)) {
$this->integrationClassName = 'App\Service\Forms\Integrations\\' . $integration['file_name'];
$this->integrationClassName = 'App\Integrations\Handlers\\' . $integration['file_name'];
$this->loadIntegrationRules();
return;
}
@ -40,9 +40,13 @@ class FormIntegrationsRequest extends FormRequest
{
return array_merge([
'integration_id' => ['required', Rule::in(array_keys(FormIntegration::getAllIntegrations()))],
'oauth_id' => [
$this->isOAuthRequired() ? 'required' : 'nullable',
Rule::exists('oauth_providers', 'id')
],
'settings' => 'present|array',
'status' => 'required|boolean',
'logic' => [new IntegrationLogicRule()]
'logic' => [new IntegrationLogicRule()],
], $this->integrationRules);
}
@ -53,16 +57,24 @@ class FormIntegrationsRequest extends FormRequest
*/
public function attributes()
{
$attributes = $this->integrationClassName::getValidationAttributes();
$fields = [];
foreach ($this->rules() as $key => $value) {
$fields[$key] = Str::of($key)
$fields[$key] = $attributes[$key] ?? Str::of($key)
->replace('settings.', '')
->headline();
->headline()
->toString();
}
return $fields;
}
protected function isOAuthRequired(): bool
{
return $this->integrationClassName::isOAuthRequired();
}
private function loadIntegrationRules()
{
foreach ($this->integrationClassName::getValidationRules() as $key => $value) {
@ -78,7 +90,8 @@ class FormIntegrationsRequest extends FormRequest
)) ? FormIntegration::STATUS_ACTIVE : FormIntegration::STATUS_INACTIVE,
'integration_id' => $this->validated('integration_id'),
'data' => $this->validated('settings') ?? [],
'logic' => $this->validated('logic') ?? []
'logic' => $this->validated('logic') ?? [],
'oauth_id' => $this->validated('oauth_id'),
]);
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property \App\Models\Integration\FormIntegration $resource
*/
class FormIntegrationResource extends JsonResource
{
public function toArray($request)
{
return [
...parent::toArray($request),
'provider' => OAuthProviderResource::make($this->resource->provider),
];
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Auth;
/**
* @property \App\Models\OAuthProvider $resource
*/
class OAuthProviderResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
public function toArray($request)
{
$userId = Auth::id();
$intention = cache()->get("oauth-intention:{$userId}");
return [
'id' => $this->resource->id,
'provider' => $this->resource->provider,
'name' => $this->resource->name,
'email' => $this->resource->email,
'intention' => $intention,
'user' => $this->whenLoaded(
'user',
fn () => OAuthProviderUserResource::make($this->resource->user),
null,
),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property \App\Models\User $resource
*/
class OAuthProviderUserResource extends JsonResource
{
public function toArray($request)
{
return [
'name' => $this->resource->name,
'email' => $this->resource->email,
];
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Integrations\Data;
use Spatie\LaravelData\Data;
class SpreadsheetData extends Data
{
public function __construct(
public string $url = '',
public string $spreadsheet_id = '',
public ?array $columns = []
) {
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Integrations\Google;
use App\Integrations\Google\Sheets\SpreadsheetManager;
use App\Models\Integration\FormIntegration;
use Google\Client as Client;
class Google
{
protected Client $client;
protected ?string $token;
protected ?string $refreshToken;
public function __construct(
protected FormIntegration $formIntegration
) {
$this->client = new Client();
$this->client->setClientId(config('services.google.client_id'));
$this->client->setClientSecret(config('services.google.client_secret'));
$this->client->setAccessToken([
'access_token' => $this->formIntegration->provider->access_token,
'created' => $this->formIntegration->provider->updated_at->getTimestamp(),
'expires_in' => 3600,
]);
}
public function getClient(): Client
{
if($this->client->isAccessTokenExpired()) {
$this->refreshToken();
}
return $this->client;
}
public function refreshToken(): static
{
$this->client->refreshToken($this->formIntegration->provider->refresh_token);
$token = $this->client->getAccessToken();
$this->formIntegration->provider->update([
'access_token' => $token['access_token'],
'refresh_token' => $token['refresh_token'],
]);
return $this;
}
public function sheets(): SpreadsheetManager
{
return new SpreadsheetManager($this, $this->formIntegration);
}
}

View File

@ -0,0 +1,189 @@
<?php
namespace App\Integrations\Google\Sheets;
use App\Integrations\Data\SpreadsheetData;
use App\Integrations\Google\Google;
use App\Models\Forms\Form;
use App\Models\Integration\FormIntegration;
use App\Service\Forms\FormSubmissionFormatter;
use Google\Service\Sheets;
use Google\Service\Sheets\BatchUpdateValuesRequest;
use Google\Service\Sheets\Spreadsheet;
use Google\Service\Sheets\ValueRange;
use Illuminate\Support\Arr;
class SpreadsheetManager
{
protected Sheets $driver;
protected SpreadsheetData $data;
public function __construct(
protected Google $google,
protected FormIntegration $integration
) {
$this->driver = new Sheets($google->getClient());
$this->data = empty($this->integration->data)
? new SpreadsheetData()
: new SpreadsheetData(
url: $this->integration->data->url,
spreadsheet_id: $this->integration->data->spreadsheet_id,
columns: array_map(
fn ($column) => (array) $column,
$this->integration->data->columns
)
);
}
protected function convertToArray(mixed $object): array
{
return is_scalar($object) || is_null($object)
? $object
: $this->convertToArray((array) $object);
}
public function get(string $id): Spreadsheet
{
$spreadsheet = $this->driver
->spreadsheets
->get($id);
return $spreadsheet;
}
public function create(Form $form): Spreadsheet
{
$body = new Spreadsheet([
'properties' => [
'title' => $form->title
]
]);
$spreadsheet = $this->driver->spreadsheets->create($body);
$this->data->url = $spreadsheet->spreadsheetUrl;
$this->data->spreadsheet_id = $spreadsheet->spreadsheetId;
$this->data->columns = [];
$this->updateHeaders($spreadsheet->spreadsheetId);
return $spreadsheet;
}
public function buildColumns(): array
{
$properties = $this->integration->form->properties;
foreach ($properties as $property) {
$key = Arr::first(
array_keys($this->data->columns),
fn (int $key) => $this->data->columns[$key]['id'] === $property['id']
);
$column = Arr::only($property, ['id', 'name']);
if (!is_null($key)) {
$this->data->columns[$key] = $column;
} else {
$this->data->columns[] = $column;
}
}
$this->integration->update([
'data' => $this->data,
]);
return $this->data->columns;
}
public function updateHeaders(string $id): static
{
$columns = $this->buildColumns();
$headers = array_map(
fn ($column) => $column['name'],
$columns
);
return $this->setHeaders($id, $headers);
}
protected function setHeaders(string $id, array $headers): static
{
$valueRange = new ValueRange([
'values' => [$headers],
]);
$valueRange->setRange(
$this->buildRange($headers)
);
$body = new BatchUpdateValuesRequest([
'valueInputOption' => 'RAW',
'data' => [$valueRange]
]);
$this->driver
->spreadsheets_values
->batchUpdate($id, $body);
return $this;
}
public function buildRow(array $submissionData): array
{
$formatter = (new FormSubmissionFormatter($this->integration->form, $submissionData))->outputStringsOnly();
$fields = $formatter->getFieldsWithValue();
return collect($this->data->columns)
->map(function (array $column) use ($fields) {
$field = Arr::first($fields, fn ($field) => $field['id'] === $column['id']);
return $field ? $field['value'] : '';
})
->toArray();
}
public function submit(array $submissionData): static
{
$this->updateHeaders($this->data->spreadsheet_id);
$row = $this->buildRow($submissionData);
$this->addRow(
$this->data->spreadsheet_id,
$row
);
return $this;
}
public function addRow(string $id, array $values): static
{
$valueRange = new ValueRange([
'values' => [$values],
]);
$params = [
'valueInputOption' => 'RAW',
];
$this->driver
->spreadsheets_values
->append(
$id,
$this->buildRange($values),
$valueRange,
$params
);
return $this;
}
protected function buildRange(array $values): string
{
return "A1:" . chr(64 + count($values)) . "1";
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\Forms\Integrations;
namespace App\Integrations\Handlers;
use App\Models\Integration\FormIntegration;
use App\Events\Forms\FormSubmitted;
@ -96,6 +96,11 @@ abstract class AbstractIntegrationHandler
}
}
public function created(): void
{
//
}
/**
* Default handle. Can be changed in child classes.
*/
@ -110,6 +115,16 @@ abstract class AbstractIntegrationHandler
abstract public static function getValidationRules(): array;
public static function isOAuthRequired(): bool
{
return false;
}
public static function getValidationAttributes(): array
{
return [];
}
public static function formatData(array $data): array
{
return $data;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\Forms\Integrations;
namespace App\Integrations\Handlers;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Support\Arr;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\Forms\Integrations;
namespace App\Integrations\Handlers;
use App\Rules\OneEmailPerLine;
use Illuminate\Support\Facades\Log;

View File

@ -0,0 +1,18 @@
<?php
namespace App\Integrations\Handlers\Events;
use App\Models\Integration\FormIntegration;
class AbstractIntegrationCreated
{
public function __construct(
protected FormIntegration $formIntegration
) {
}
public function handle(): void
{
//
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Integrations\Handlers\Events;
use App\Integrations\Google\Google;
use App\Models\Integration\FormIntegration;
class GoogleSheetsIntegrationCreated extends AbstractIntegrationCreated
{
protected Google $client;
public function __construct(
protected FormIntegration $formIntegration
) {
parent::__construct($formIntegration);
$this->client = new Google($formIntegration);
}
public function handle(): void
{
$this->client->sheets()
->create($this->formIntegration->form);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Integrations\Handlers;
use App\Events\Forms\FormSubmitted;
use App\Integrations\Google\Google;
use App\Models\Integration\FormIntegration;
use Exception;
use Illuminate\Support\Facades\Log;
class GoogleSheetsIntegration extends AbstractIntegrationHandler
{
protected Google $client;
public function __construct(
protected FormSubmitted $event,
protected FormIntegration $formIntegration,
protected array $integration
) {
parent::__construct($event, $formIntegration, $integration);
$this->client = new Google($formIntegration);
}
public static function getValidationRules(): array
{
return [
];
}
public static function isOAuthRequired(): bool
{
return true;
}
public static function getValidationAttributes(): array
{
return [
'oauth_id' => 'Google Account',
];
}
public function handle(): void
{
if (!$this->shouldRun()) {
return;
}
Log::debug('Creating Google Spreadsheet record', [
'spreadsheet_id' => $this->getSpreadsheetId(),
'form_id' => $this->form->id,
'form_slug' => $this->form->slug,
]);
$this->client->sheets()->submit($this->submissionData);
}
protected function getSpreadsheetId(): string
{
if(!isset($this->integrationData->spreadsheet_id)) {
throw new Exception('The spreadsheed is not instantiated');
}
return $this->integrationData->spreadsheet_id;
}
protected function shouldRun(): bool
{
return parent::shouldRun() && $this->formIntegration->oauth_id && $this->getSpreadsheetId();
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\Forms\Integrations;
namespace App\Integrations\Handlers;
use App\Service\Forms\FormSubmissionFormatter;
use Illuminate\Support\Arr;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\Forms\Integrations;
namespace App\Integrations\Handlers;
use App\Mail\Forms\SubmissionConfirmationMail;
use Illuminate\Support\Facades\Mail;

View File

@ -1,6 +1,6 @@
<?php
namespace App\Service\Forms\Integrations;
namespace App\Integrations\Handlers;
class WebhookIntegration extends AbstractIntegrationHandler
{

View File

@ -0,0 +1,11 @@
<?php
namespace App\Integrations\OAuth\Drivers\Contracts;
use Laravel\Socialite\Contracts\User;
interface OAuthDriver
{
public function getRedirectUrl(): string;
public function getUser(): User;
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Integrations\OAuth\Drivers;
use App\Integrations\OAuth\Drivers\Contracts\OAuthDriver;
use Google\Service\Sheets;
use Laravel\Socialite\Contracts\User;
use Laravel\Socialite\Facades\Socialite;
use Laravel\Socialite\Two\GoogleProvider;
class OAuthGoogleDriver implements OAuthDriver
{
protected GoogleProvider $provider;
public function __construct()
{
$this->provider = Socialite::driver('google');
}
public function getRedirectUrl(): string
{
return $this->provider
->scopes([Sheets::DRIVE_FILE])
->stateless()
->with([
'access_type' => 'offline',
'prompt' => 'consent select_account'
])
->redirect()
->getTargetUrl();
}
public function getUser(): User
{
return $this->provider
->stateless()
->user();
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace App\Integrations\OAuth;
use App\Integrations\OAuth\Drivers\Contracts\OAuthDriver;
use App\Integrations\OAuth\Drivers\OAuthGoogleDriver;
enum OAuthProviderService: string
{
case Google = 'google';
public function getDriver(): OAuthDriver
{
return match($this) {
self::Google => new OAuthGoogleDriver()
};
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Listeners\Forms;
use App\Events\Models\FormIntegrationCreated;
use App\Models\Integration\FormIntegration;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
class FormIntegrationCreatedHandler implements ShouldQueue
{
use InteractsWithQueue;
public function handle(FormIntegrationCreated $event)
{
$integration = FormIntegration::getIntegration($event->formIntegration->integration_id);
if(!$integration) {
return;
}
if(!isset($integration['file_name'])) {
return;
}
$className = 'App\Integrations\Handlers\Events\\' . $integration['file_name'] . 'Created';
if(!class_exists($className)) {
return;
}
/** @var \App\Integrations\Handlers\Events\AbstractIntegrationCreated $eventHandler */
$eventHandler = new $className($event->formIntegration);
$eventHandler->handle();
}
}

View File

@ -4,7 +4,7 @@ namespace App\Listeners\Forms;
use App\Events\Forms\FormSubmitted;
use App\Models\Integration\FormIntegration;
use App\Service\Forms\Integrations\AbstractIntegrationHandler;
use App\Integrations\Handlers\AbstractIntegrationHandler;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
@ -35,9 +35,9 @@ class NotifyFormSubmission implements ShouldQueue
): AbstractIntegrationHandler {
$integration = FormIntegration::getIntegration($formIntegration->integration_id);
if ($integration && isset($integration['file_name']) && class_exists(
'App\Service\Forms\Integrations\\' . $integration['file_name']
'App\Integrations\Handlers\\' . $integration['file_name']
)) {
$className = 'App\Service\Forms\Integrations\\' . $integration['file_name'];
$className = 'App\Integrations\Handlers\\' . $integration['file_name'];
return new $className($event, $formIntegration, $integration);
}
throw new \Exception('Unknown Integration!');

View File

@ -2,7 +2,9 @@
namespace App\Models\Integration;
use App\Events\Models\FormIntegrationCreated;
use App\Models\Forms\Form;
use App\Models\OAuthProvider;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -26,6 +28,10 @@ class FormIntegration extends Model
'logic' => 'object'
];
protected $dispatchesEvents = [
'created' => FormIntegrationCreated::class,
];
/**
* Relationships
*/
@ -39,6 +45,11 @@ class FormIntegration extends Model
return $this->hasMany(FormIntegrationsEvent::class, 'integration_id');
}
public function provider()
{
return $this->belongsTo(OAuthProvider::class, 'oauth_id');
}
public static function getAllIntegrations()
{
return json_decode(file_get_contents(resource_path('data/forms/integrations.json')), true);

View File

@ -2,10 +2,14 @@
namespace App\Models;
use App\Integrations\OAuth\OAuthProviderService;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class OAuthProvider extends Model
{
use HasFactory;
/**
* The table associated with the model.
*
@ -29,6 +33,11 @@ class OAuthProvider extends Model
'access_token', 'refresh_token',
];
protected $casts = [
'provider' => OAuthProviderService::class,
'token_expires_at' => 'datetime',
];
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/

View File

@ -187,4 +187,5 @@ class Workspace extends Model implements CachableAttributes
{
return $this->hasMany(Form::class);
}
}

View File

@ -0,0 +1,82 @@
<?php
namespace App\Policies;
use App\Models\OAuthProvider;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class OAuthProviderPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view any models.
*
* @return mixed
*/
public function viewAny(User $user)
{
return true;
}
/**
* Determine whether the user can view the model.
*
* @return mixed
*/
public function view(User $user, OAuthProvider $provider)
{
return $provider->user()->is($user);
}
/**
* Determine whether the user can create models.
*
* @return mixed
*/
public function create(User $user)
{
return true;
}
/**
* Determine whether the user can update the model.
*
* @return mixed
*/
public function update(User $user, OAuthProvider $provider)
{
return $provider->user()->is($user);
}
/**
* Determine whether the user can delete the model.
*
* @return mixed
*/
public function delete(User $user, OAuthProvider $provider)
{
return $provider->user()->is($user);
}
/**
* Determine whether the user can restore the model.
*
* @return mixed
*/
public function restore(User $user, OAuthProvider $provider)
{
return $provider->user()->is($user);
}
/**
* Determine whether the user can permanently delete the model.
*
* @return mixed
*/
public function forceDelete(User $user, OAuthProvider $provider)
{
return $provider->user()->is($user);
}
}

View File

@ -4,10 +4,12 @@ namespace App\Providers;
use App\Models\Forms\Form;
use App\Models\Integration\FormZapierWebhook;
use App\Models\OAuthProvider;
use App\Models\Template;
use App\Models\Workspace;
use App\Policies\FormPolicy;
use App\Policies\Integration\FormZapierWebhookPolicy;
use App\Policies\OAuthProviderPolicy;
use App\Policies\TemplatePolicy;
use App\Policies\WorkspacePolicy;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
@ -24,6 +26,7 @@ class AuthServiceProvider extends ServiceProvider
Workspace::class => WorkspacePolicy::class,
FormZapierWebhook::class => FormZapierWebhookPolicy::class,
Template::class => TemplatePolicy::class,
OAuthProvider::class => OAuthProviderPolicy::class,
];
/**

View File

@ -4,9 +4,11 @@ namespace App\Providers;
use App\Events\Forms\FormSubmitted;
use App\Events\Models\FormCreated;
use App\Events\Models\FormIntegrationCreated;
use App\Events\Models\FormIntegrationsEventCreated;
use App\Events\SubscriptionCreated;
use App\Listeners\Forms\FormCreationConfirmation;
use App\Listeners\Forms\FormIntegrationCreatedHandler;
use App\Listeners\Forms\FormIntegrationsEventListener;
use App\Listeners\Forms\NotifyFormSubmission;
use App\Listeners\HandleSubscriptionCreated;
@ -31,6 +33,9 @@ class EventServiceProvider extends ServiceProvider
FormSubmitted::class => [
NotifyFormSubmission::class
],
FormIntegrationCreated::class => [
FormIntegrationCreatedHandler::class,
],
FormIntegrationsEventCreated::class => [
FormIntegrationsEventListener::class,
],

View File

@ -1,5 +1,6 @@
<?php
if(!function_exists('front_url')) {
function front_url($path = '')
{
$baseUrl = config('app.front_url');
@ -9,3 +10,4 @@ function front_url($path = '')
return rtrim($baseUrl, '/').'/'.ltrim($path, '/');
}
}

View File

@ -1,19 +1,18 @@
module.exports = {
root: true,
extends: ['@nuxt/eslint-config'],
parser: 'vue-eslint-parser',
extends: ["@nuxt/eslint-config"],
parser: "vue-eslint-parser",
env: {
browser: true,
node: true,
},
extends: ['@nuxt/eslint-config'],
rules: {
'vue/require-default-prop': 'off',
'vue/no-mutating-props': 'off',
semi: ['error', 'never'],
'vue/no-v-html': 'off',
'prefer-rest-params': 'off',
'vue/valid-template-root': 'off',
'no-undef': 'off',
"vue/require-default-prop": "off",
"vue/no-mutating-props": "off",
semi: ["error", "never"],
"vue/no-v-html": "off",
"prefer-rest-params": "off",
"vue/valid-template-root": "off",
"no-undef": "off",
},
}
};

View File

@ -5,7 +5,39 @@
:form="form"
>
<div class="my-5">
Coming Soon...
<select-input
v-if="providers.length"
v-model="integrationData.oauth_id"
name="provider"
:options="providers"
option-key="id"
emit-key="id"
:required="true"
label="Select Google Account"
>
<template #help>
<InputHelp>
<span>
Add an entry to spreadsheets on each form submission.
<NuxtLink
:to="{ name: 'settings-connections' }"
>
Click here
</NuxtLink>
to connect another account.
</span>
</InputHelp>
</template>
</select-input>
<v-button
v-else
color="white"
:loading="providersStore.loading"
@click.prevent="connect"
>
Connect Google account
</v-button>
</div>
</IntegrationWrapper>
</template>
@ -19,4 +51,11 @@ const props = defineProps({
integrationData: { type: Object, required: true },
formIntegrationId: { type: Number, required: false, default: null },
})
const providersStore = useOAuthProvidersStore()
const providers = computed(() => providersStore.getAll.filter(provider => provider.provider == 'google'))
function connect() {
providersStore.connect('google', true)
}
</script>

View File

@ -0,0 +1,28 @@
<template>
<div class="flex flex-1 items-center">
<div class="space-y-1">
<div class="font-medium mr-2">
{{ integration.provider.user.name }}
</div>
<div class="text-sm">
{{ integration.provider.user.email }}
</div>
</div>
<div class="ml-auto">
<v-button
:href="integration.data.url"
target="_blank"
color="white"
>
Open spreadsheet
</v-button>
</div>
</div>
</template>
<script setup>
defineProps({
integration: Object,
})
</script>

View File

@ -2,7 +2,7 @@
<div
class="text-gray-500 border shadow rounded-md p-5 mt-4 relative flex items-center"
>
<div class="flex-grow flex items-center">
<div class="flex items-center">
<div
class="mr-4"
:class="{
@ -32,9 +32,16 @@
</div>
</div>
<div class="grow flex items-center gap-4 pl-4">
<component
:is="actionsComponent"
v-if="actionsComponent"
:integration="integration"
/>
<div
v-if="loadingDelete"
class="pr-4 pt-2"
class="pr-4 pt-2 ml-auto"
>
<Loader class="h-6 w-6 mx-auto" />
</div>
@ -123,6 +130,8 @@
Delete Integration
</a>
</dropdown>
</div>
<IntegrationModal
v-if="form && integration && integrationTypeInfo"
:form="form"
@ -170,6 +179,14 @@ const showIntegrationModal = ref(false)
const showIntegrationEventsModal = ref(false)
const loadingDelete = ref(false)
const actionsComponent = computed(() => {
if(integrationTypeInfo.value.actions_file_name) {
return resolveComponent(integrationTypeInfo.value.actions_file_name)
}
return null
})
const deleteFormIntegration = (integrationid) => {
alert.confirm("Do you really want to delete this form integration?", () => {
opnFetch(

View File

@ -51,6 +51,7 @@ const props = defineProps({
integrationKey: { type: String, required: true },
integration: { type: Object, required: true },
formIntegrationId: { type: Number, required: false, default: null },
providers: { type: Array, required: true }
})
const alert = useAlert()
@ -92,6 +93,7 @@ const initIntegrationData = () => {
? formIntegration.value.logic
: null
: null,
oauth_id: formIntegration.value?.oauth_id ?? null,
})
}
initIntegrationData()

View File

@ -0,0 +1,109 @@
<template>
<div
class="text-gray-500 border shadow rounded-md p-5 mt-4 relative flex items-center"
>
<div class="flex-grow flex items-center">
<div
class="mr-4 text-blue-500"
>
<Icon
name="mdi:google"
size="32px"
/>
</div>
<div>
<div class="flex space-x-3 font-semibold mr-2">
{{ provider.name }}
</div>
<div>
{{ provider.email }}
</div>
</div>
</div>
<div class="flex items-center gap-4">
<dropdown
class="inline"
>
<template #trigger="{ toggle }">
<v-button
color="white"
@click="toggle"
>
<svg
class="w-4 h-4 inline -mt-1"
viewBox="0 0 16 4"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.00016 2.83366C8.4604 2.83366 8.8335 2.46056 8.8335 2.00033C8.8335 1.54009 8.4604 1.16699 8.00016 1.16699C7.53993 1.16699 7.16683 1.54009 7.16683 2.00033C7.16683 2.46056 7.53993 2.83366 8.00016 2.83366Z"
stroke="#344054"
stroke-width="1.66667"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M13.8335 2.83366C14.2937 2.83366 14.6668 2.46056 14.6668 2.00033C14.6668 1.54009 14.2937 1.16699 13.8335 1.16699C13.3733 1.16699 13.0002 1.54009 13.0002 2.00033C13.0002 2.46056 13.3733 2.83366 13.8335 2.83366Z"
stroke="#344054"
stroke-width="1.66667"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M2.16683 2.83366C2.62707 2.83366 3.00016 2.46056 3.00016 2.00033C3.00016 1.54009 2.62707 1.16699 2.16683 1.16699C1.70659 1.16699 1.3335 1.54009 1.3335 2.00033C1.3335 2.46056 1.70659 2.83366 2.16683 2.83366Z"
stroke="#344054"
stroke-width="1.66667"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</v-button>
</template>
<a
v-track.delete_provider_click="{
provider_id: provider.id,
}"
href="#"
class="flex px-4 py-2 text-md text-red-600 hover:bg-red-50 hover:no-underline items-center"
@click.prevent="disconnect"
>
<Icon
name="heroicons:trash"
class="w-5 h-5 mr-2"
/>
Disconnect
</a>
</dropdown>
</div>
</div>
</template>
<script setup>
const props = defineProps({
provider: Object
})
const providersStore = useOAuthProvidersStore()
const alert = useAlert()
function disconnect() {
alert.confirm("Do you really want to disconnect this account?", () => {
opnFetch(`/settings/providers/${props.provider.id}`, {
method: 'DELETE'
})
.then(() => {
providersStore.remove(props.provider.id)
})
.catch((error) => {
try {
alert.error(error.data.message)
} catch (e) {
alert.error("An error occurred while disconnecting an account")
}
})
})
}
</script>

View File

@ -0,0 +1,81 @@
<template>
<modal
:show="show"
max-width="lg"
@close="emit('close')"
>
<template #icon>
<svg
class="w-8 h-8"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 8V16M8 12H16M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<template #title>
Connect account
</template>
<div class="px-4">
<div
v-for="service in services"
:key="service.name"
role="button"
class="bg-gray-50 border border-gray-200 rounded-md transition-colors p-4 pb-2 items-center justify-center w-[170px] h-[110px] flex flex-col relative"
:class="{
'hover:bg-blue-50 group cursor-pointer': service.enabled,
'cursor-not-allowed': !service.enabled,
}"
@click="connect(service)"
>
<div class="flex justify-center">
<div class="h-10 w-10 text-gray-500 group-hover:text-blue-500 transition-colors flex items-center">
<Icon
:name="service.icon"
class=""
size="40px"
/>
</div>
</div>
<div class="flex-grow flex items-center">
<div class="text-gray-400 font-medium text-sm text-center">
{{ service.title }}
</div>
</div>
</div>
</div>
</modal>
</template>
<script setup>
defineProps({
show: Boolean
})
const emit = defineEmits(['close'])
const providersStore = useOAuthProvidersStore()
const services = [
{
name: 'google',
title: 'Google',
icon: 'mdi:google',
enabled: true
},
]
function connect(service) {
providersStore.connect(service.name)
}
</script>

View File

@ -48,7 +48,7 @@
"icon": "mdi:google-spreadsheet",
"section_name": "Databases",
"file_name": "GoogleSheetsIntegration",
"is_pro": true,
"coming_soon": true
"actions_file_name": "GoogleSheetsIntegrationActions",
"is_pro": false
}
}

View File

@ -84,6 +84,8 @@ useOpnSeoMeta({
const alert = useAlert()
const oAuthProvidersStore = useOAuthProvidersStore()
const formIntegrationsStore = useFormIntegrationsStore()
const integrationsLoading = computed(() => formIntegrationsStore.loading)
const integrations = computed(
@ -102,6 +104,7 @@ const selectedIntegration = ref(null)
onMounted(() => {
formIntegrationsStore.fetchFormIntegrations(props.form.id)
oAuthProvidersStore.fetchOAuthProviders(props.form.workspace_id)
})
const openIntegrationModal = (itemKey) => {

View File

@ -66,6 +66,10 @@ const tabsList = computed(() => {
name: "Workspace Settings",
route: "settings-workspace",
},
{
name: "Connections",
route: "settings-connections",
},
{
name: "Password",
route: "settings-password",

View File

@ -0,0 +1,116 @@
<template>
<div>
<div class="flex items-center gap-y-4 flex-wrap-reverse">
<div class="flex-grow">
<h3 class="font-semibold text-2xl text-gray-900">
Connections
</h3>
<small class="text-gray-600">Manage your external connections.</small>
</div>
<v-button
color="outline-blue"
:loading="loading"
@click="providerModal = true"
>
<svg
class="inline -mt-1 mr-1 h-4 w-4"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.99996 1.16699V12.8337M1.16663 7.00033H12.8333"
stroke="currentColor"
stroke-width="1.67"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Connect new account
</v-button>
</div>
<div
v-if="loading"
class="w-full text-blue-500 text-center"
>
<Loader class="h-10 w-10 p-5" />
</div>
<div class="py-6">
<SettingsProviderCard
v-for="provider in providers"
:key="provider.id"
:provider="provider"
/>
</div>
<SettingsProviderModal
:show="providerModal"
@close="providerModal = false"
/>
</div>
</template>
<script setup>
useOpnSeoMeta({
title: "Connections",
})
definePageMeta({
middleware: "auth",
alias: [
'/settings/connections/callback/:service'
]
})
const router = useRouter()
const route = useRoute()
const alert = useAlert()
const providerModal = ref(false)
const providersStore = useOAuthProvidersStore()
const providers = computed(() => providersStore.getAll)
const loading = computed(() => providersStore.loading)
function handleCallback() {
const code = route.query.code
const service = route.params.service
if(!code || !service) {
router.push('/settings/connections')
return
}
opnFetch(`/settings/providers/callback/${service}`, {
method: 'POST',
params: {
code
}
})
.then((data) => {
if(!data.intention) {
router.push('/settings/connections')
}
else {
router.push(data.intention)
}
})
.catch((error) => {
try {
alert.error(error.data.message)
} catch (e) {
alert.error("An error occurred while connecting an account")
}
router.push('/settings/connections')
})
}
onMounted(() => {
handleCallback()
providersStore.fetchOAuthProviders()
})
</script>

57
client/stores/oauth_providers.js vendored Normal file
View File

@ -0,0 +1,57 @@
import { defineStore } from "pinia"
import { useContentStore } from "~/composables/stores/useContentStore.js"
export const providersEndpoint = "/open/providers"
export const useOAuthProvidersStore = defineStore("oauth_providers", () => {
const contentStore = useContentStore()
const alert = useAlert()
const fetchOAuthProviders = () => {
contentStore.resetState()
contentStore.startLoading()
return opnFetch(providersEndpoint).then(
(data) => {
contentStore.save(data)
contentStore.stopLoading()
},
)
}
const connect = (service, redirect = false) => {
contentStore.resetState()
contentStore.startLoading()
const intention = new URL(window.location.href).pathname
opnFetch(`/settings/providers/connect/${service}`, {
method: 'POST',
body: {
...redirect ? { intention } : {},
}
})
.then((data) => {
window.location.href = data.url
})
.catch((error) => {
try {
alert.error(error.data.message)
} catch (e) {
alert.error("An error occurred while connecting an account")
}
})
.finally(() => {
contentStore.stopLoading()
})
}
const providers = computed(() => contentStore.getAll.value)
return {
...contentStore,
fetchOAuthProviders,
providers,
connect
}
})

View File

@ -17,6 +17,7 @@
"aws/aws-sdk-php": "^3.183",
"doctrine/dbal": "^3.4",
"giggsey/libphonenumber-for-php": "^8.13",
"google/apiclient": "^2.15.0",
"guzzlehttp/guzzle": "^7.0.1",
"jhumanj/laravel-model-stats": "^0.4.0",
"laravel/cashier": "^13.4",
@ -33,6 +34,7 @@
"openai-php/client": "^0.6.4",
"propaganistas/laravel-disposable-email": "^2.2",
"sentry/sentry-laravel": "^2.11.0",
"spatie/laravel-data": "^3.12",
"spatie/laravel-sitemap": "^6.0",
"spatie/laravel-sluggable": "^3.0",
"stevebauman/purify": "^v6.2.0",

1863
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -64,4 +64,10 @@ return [
],
'crisp_website_id' => env('CRISP_WEBSITE_ID'),
'google' => [
'client_id' => env('GOOGLE_CLIENT_ID'),
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
'redirect' => env('GOOGLE_REDIRECT_URL'),
]
];

View File

@ -0,0 +1,21 @@
<?php
namespace Database\Factories\Integration;
use App\Models\Integration\FormIntegration;
use Illuminate\Database\Eloquent\Factories\Factory;
class FormIntegrationFactory extends Factory
{
protected $model = FormIntegration::class;
public function definition()
{
return [
'integration_id' => 'i_test',
'status' => 'active',
'logic' => [],
'data' => [],
];
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace Database\Factories;
use App\Models\OAuthProvider;
use Illuminate\Database\Eloquent\Factories\Factory;
class OAuthProviderFactory extends Factory
{
protected $model = OAuthProvider::class;
public function definition()
{
return [
'provider' => 'google',
'provider_user_id' => 'u_test',
'email' => 'user@example.com',
'name' => 'user',
'access_token' => 'ac_test',
'refresh_token' => 're_test',
];
}
}

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('oauth_providers', function (Blueprint $table) {
$table->string('email')->after('provider_user_id')->nullable();
$table->string('name')->after('email')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('oauth_providers', function (Blueprint $table) {
$table->dropColumn('email');
$table->dropColumn('name');
});
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('oauth_providers', function (Blueprint $table) {
$table->timestamp('token_expires_at')->nullable()->after('refresh_token');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('oauth_providers', function (Blueprint $table) {
$table->dropColumn('token_expires_at');
});
}
};

View File

@ -15,6 +15,7 @@ use App\Http\Controllers\Forms\Integration\FormIntegrationsEventController;
use App\Http\Controllers\Forms\Integration\FormZapierWebhookController;
use App\Http\Controllers\Forms\PublicFormController;
use App\Http\Controllers\Forms\RecordController;
use App\Http\Controllers\OAuth\OAuthProviderController;
use App\Http\Controllers\Settings\PasswordController;
use App\Http\Controllers\Settings\ProfileController;
use App\Http\Controllers\SubscriptionController;
@ -42,8 +43,16 @@ Route::group(['middleware' => 'auth:api'], function () {
Route::get('user', [UserController::class, 'current'])->name('user.current');
Route::delete('user', [UserController::class, 'deleteAccount']);
Route::patch('settings/profile', [ProfileController::class, 'update']);
Route::patch('settings/password', [PasswordController::class, 'update']);
Route::prefix('/settings')->name('settings.')->group(function () {
Route::patch('/profile', [ProfileController::class, 'update']);
Route::patch('/password', [PasswordController::class, 'update']);
Route::prefix('/providers')->name('providers.')->group(function () {
Route::post('/connect/{service}', [OAuthProviderController::class, 'connect'])->name('connect');
Route::post('/callback/{service}', [OAuthProviderController::class, 'handleRedirect'])->name('callback');
Route::delete('/{provider}', [OAuthProviderController::class, 'destroy'])->name('destroy');
});
});
Route::prefix('subscription')->name('subscription.')->group(function () {
Route::put('/update-customer-details', [SubscriptionController::class, 'updateStripeDetails'])->name('update-stripe-details');
@ -55,6 +64,8 @@ Route::group(['middleware' => 'auth:api'], function () {
});
Route::prefix('open')->name('open.')->group(function () {
Route::get('/providers', [OAuthProviderController::class, 'index'])->name('index');
Route::get('/forms', [FormController::class, 'indexAll'])->name('forms.index-all');
Route::get('/forms/{slug}', [FormController::class, 'show'])->name('forms.show');

View File

@ -0,0 +1,164 @@
<?php
use App\Integrations\Data\SpreadsheetData;
use App\Integrations\Google\Google;
use App\Integrations\Google\Sheets\SpreadsheetManager;
use App\Models\Integration\FormIntegration;
use App\Models\OAuthProvider;
use function PHPUnit\Framework\assertCount;
use function PHPUnit\Framework\assertEquals;
use function PHPUnit\Framework\assertSame;
test('build columns', function () {
/** @var \App\Models\User $user */
$user = $this->createUser();
/** @var \App\Models\Workspace $workspace */
$workspace = $this->createUserWorkspace($user);
/** @var \App\Models\Forms $form */
$form = $this->createForm($user, $workspace);
/** @var \App\Models\OAuthProvider $provider */
$provider = OAuthProvider::factory()
->for($user)
->create();
/** @var FormIntegration $integration */
$integration = FormIntegration::factory()
->for($form)
->for($provider, 'provider')
->create([
'data' => new SpreadsheetData(
url: 'https://google.com',
spreadsheet_id: 'sp_test',
columns: []
)
]);
$google = new Google($integration);
$manager = new SpreadsheetManager($google, $integration);
$columns = $manager->buildColumns();
assertCount(14, $columns);
foreach($columns as $key => $column) {
assertEquals($form->properties[$key]['id'], $column['id']);
assertEquals($form->properties[$key]['name'], $column['name']);
}
});
test('update columns', function () {
/** @var \App\Models\User $user */
$user = $this->createUser();
/** @var \App\Models\Workspace $workspace */
$workspace = $this->createUserWorkspace($user);
/** @var \App\Models\Forms $form */
$form = $this->createForm($user, $workspace);
$form->update([
'properties' => [
['id' => '000', 'name' => 'First'],
['id' => '001', 'name' => 'Second'],
]
]);
/** @var \App\Models\OAuthProvider $provider */
$provider = OAuthProvider::factory()
->for($user)
->create();
/** @var FormIntegration $integration */
$integration = FormIntegration::factory()
->for($form)
->for($provider, 'provider')
->create([
'data' => new SpreadsheetData(
url: 'https://google.com',
spreadsheet_id: 'sp_test',
columns: [
['id' => '000', 'name' => 'First'],
['id' => '001', 'name' => 'Second'],
]
)
]);
$google = new Google($integration);
$manager = new SpreadsheetManager($google, $integration);
$manager->buildColumns();
$form->update([
'properties' => [
['id' => '000', 'name' => 'First name'],
['id' => '002', 'name' => 'Email'],
]
]);
$integration->refresh();
$columns = $manager->buildColumns();
assertCount(3, $columns);
assertEquals('First name', $columns[0]['name']);
assertEquals('Second', $columns[1]['name']);
assertEquals('Email', $columns[2]['name']);
});
test('build row', function () {
/** @var \App\Models\User $user */
$user = $this->createUser();
/** @var \App\Models\Workspace $workspace */
$workspace = $this->createUserWorkspace($user);
/** @var \App\Models\Forms $form */
$form = $this->createForm($user, $workspace);
$form->update([
'properties' => [
['id' => '000', 'name' => 'First', 'type' => 'text'],
['id' => '001', 'name' => 'Second', 'type' => 'text'],
['id' => '002', 'name' => 'Third', 'type' => 'text'],
]
]);
/** @var \App\Models\OAuthProvider $provider */
$provider = OAuthProvider::factory()
->for($user)
->create();
/** @var FormIntegration $integration */
$integration = FormIntegration::factory()
->for($form)
->for($provider, 'provider')
->create([
'data' => new SpreadsheetData(
url: 'https://google.com',
spreadsheet_id: 'sp_test',
columns: [
['id' => '000', 'name' => 'First'],
['id' => '001', 'name' => 'Second'],
['id' => '002', 'name' => 'Third'],
]
)
]);
$google = new Google($integration);
$manager = new SpreadsheetManager($google, $integration);
$submission = [
'002' => 'Third value',
'000' => 'First value',
];
$row = $manager->buildRow($submission);
assertSame(['First value', '', 'Third value'], $row);
});