Zapier integration (#491)
* create zapier app * install sanctum * move OAuthProviderController * make `api-external` middleware * add zapier endpoints * add tests * token management * zapier event handler * add policy * use `slug` instead of `id` * wip * check policies * change api prefix to `external` * ui tweaks * validate token abilities * open zapier URL * zapier ui tweaks * update zap * Fix linting * Added sample endpoints + minor UI changes * Run PHP code linter --------- Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
parent
7ad62fb3ea
commit
517bccc695
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
enum AccessTokenAbility: string
|
||||
{
|
||||
case ManageIntegrations = 'manage-integrations';
|
||||
case ListForms = 'list-forms';
|
||||
case ListWorkspaces = 'list-workspaces';
|
||||
|
||||
public static function values(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (AccessTokenAbility $case) => $case->value,
|
||||
static::cases()
|
||||
);
|
||||
}
|
||||
|
||||
public static function allowed(array $abilities): array
|
||||
{
|
||||
return Arr::where(
|
||||
$abilities,
|
||||
fn (string $ability) => in_array($ability, static::values())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,6 @@ class FormZapierWebhookController extends Controller
|
|||
*/
|
||||
public function __construct()
|
||||
{
|
||||
// $this->middleware('subscribed');
|
||||
$this->middleware('auth');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Integrations\Zapier;
|
||||
|
||||
use App\Http\Requests\Integration\Zapier\PollSubmissionRequest;
|
||||
use App\Http\Requests\Zapier\CreateIntegrationRequest;
|
||||
use App\Http\Requests\Zapier\DeleteIntegrationRequest;
|
||||
use App\Integrations\Handlers\ZapierIntegration;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Tests\Helpers\FormSubmissionDataFactory;
|
||||
|
||||
class IntegrationController
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function store(CreateIntegrationRequest $request)
|
||||
{
|
||||
$form = $request->form();
|
||||
|
||||
$this->authorize('view', $form);
|
||||
|
||||
$form->integrations()
|
||||
->create([
|
||||
'integration_id' => 'zapier',
|
||||
'status' => 'active',
|
||||
'data' => [
|
||||
'hook_url' => $request->input('hookUrl'),
|
||||
],
|
||||
]);
|
||||
|
||||
return response()->json();
|
||||
}
|
||||
|
||||
public function destroy(DeleteIntegrationRequest $request)
|
||||
{
|
||||
$form = $request->form();
|
||||
|
||||
$this->authorize('view', $form);
|
||||
|
||||
$form
|
||||
->integrations()
|
||||
->where('data->hook_url', $request->input('hookUrl'))
|
||||
->delete();
|
||||
|
||||
return response()->json();
|
||||
}
|
||||
|
||||
public function poll(PollSubmissionRequest $request)
|
||||
{
|
||||
$form = $request->form();
|
||||
|
||||
$this->authorize('view', $form);
|
||||
|
||||
$lastSubmission = $form->submissions()->latest()->first();
|
||||
if (!$lastSubmission) {
|
||||
// Generate fake data when no previous submissions
|
||||
$submissionData = (new FormSubmissionDataFactory($form))->asFormSubmissionData()->createSubmissionData();
|
||||
}
|
||||
|
||||
return [ZapierIntegration::formatWebhookData($form, $submissionData ?? $lastSubmission->data)];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Integrations\Zapier;
|
||||
|
||||
use App\Http\Requests\Zapier\ListFormsRequest;
|
||||
use App\Http\Resources\Zapier\FormResource;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
class ListFormsController
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function __invoke(ListFormsRequest $request)
|
||||
{
|
||||
$workspace = $request->workspace();
|
||||
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
return FormResource::collection(
|
||||
$workspace->forms()->get()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Integrations\Zapier;
|
||||
|
||||
use App\Http\Resources\Zapier\WorkspaceResource;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ListWorkspacesController
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function __invoke()
|
||||
{
|
||||
$this->authorize('viewAny', Workspace::class);
|
||||
|
||||
return WorkspaceResource::collection(
|
||||
Auth::user()->workspaces()->get()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Integrations\Zapier;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class ValidateAuthController
|
||||
{
|
||||
public function __invoke()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
return [
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\OAuth;
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\OAuthProviderResource;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Enums\AccessTokenAbility;
|
||||
use App\Http\Requests\CreateTokenRequest;
|
||||
use App\Http\Resources\TokenResource;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class TokenController
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function index()
|
||||
{
|
||||
return TokenResource::collection(
|
||||
Auth::user()->tokens()->get()
|
||||
);
|
||||
}
|
||||
|
||||
public function store(CreateTokenRequest $request)
|
||||
{
|
||||
$token = Auth::user()->createToken(
|
||||
$request->input('name'),
|
||||
AccessTokenAbility::allowed($request->input('abilities'))
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'token' => $token->plainTextToken,
|
||||
]);
|
||||
}
|
||||
|
||||
public function destroy(PersonalAccessToken $token)
|
||||
{
|
||||
$this->authorize('delete', $token);
|
||||
|
||||
$token->delete();
|
||||
|
||||
return response()->json();
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,11 @@ class Kernel extends HttpKernel
|
|||
SelfHostedCredentialsMiddleware::class,
|
||||
ImpersonationMiddleware::class,
|
||||
],
|
||||
|
||||
'api-external' => [
|
||||
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
|
||||
\Illuminate\Routing\Middleware\SubstituteBindings::class,
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -90,5 +95,8 @@ class Kernel extends HttpKernel
|
|||
|
||||
'pro-form' => \App\Http\Middleware\Form\ProForm::class,
|
||||
'protected-form' => \App\Http\Middleware\Form\ProtectedForm::class,
|
||||
|
||||
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
|
||||
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CreateTokenRequest extends FormRequest
|
||||
{
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
],
|
||||
'abilities' => [
|
||||
'nullable',
|
||||
'array'
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Integration\Zapier;
|
||||
|
||||
use App\Models\Forms\Form;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class PollSubmissionRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'form_id' => [
|
||||
'required',
|
||||
Rule::exists(Form::getModel()->getTable(), 'slug'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function form(): Form
|
||||
{
|
||||
return Form::query()
|
||||
->where('slug', $this->input('form_id'))
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Zapier;
|
||||
|
||||
use App\Models\Forms\Form;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CreateIntegrationRequest extends FormRequest
|
||||
{
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'form_id' => [
|
||||
'required',
|
||||
Rule::exists(Form::getModel()->getTable(), 'slug'),
|
||||
],
|
||||
'hookUrl' => [
|
||||
'required',
|
||||
'url',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function form(): Form
|
||||
{
|
||||
return Form::query()
|
||||
->where('slug', $this->input('form_id'))
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Zapier;
|
||||
|
||||
use App\Models\Forms\Form;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class DeleteIntegrationRequest extends FormRequest
|
||||
{
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'form_id' => [
|
||||
'required',
|
||||
Rule::exists(Form::getModel()->getTable(), 'slug'),
|
||||
],
|
||||
'hookUrl' => [
|
||||
'required',
|
||||
'url',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function form(): Form
|
||||
{
|
||||
return Form::query()
|
||||
->where('slug', $this->input('form_id'))
|
||||
->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Zapier;
|
||||
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ListFormsRequest extends FormRequest
|
||||
{
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'workspace_id' => [
|
||||
'required',
|
||||
Rule::exists(Workspace::getModel()->getTable(), 'id'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function workspace(): Workspace
|
||||
{
|
||||
return Workspace::findOrFail($this->input('workspace_id'));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @property \Laravel\Sanctum\PersonalAccessToken $resource
|
||||
*/
|
||||
class TokenResource extends JsonResource
|
||||
{
|
||||
public function toArray($request)
|
||||
{
|
||||
return [
|
||||
'id' => $this->resource->id,
|
||||
'name' => $this->resource->name,
|
||||
'abilities' => $this->resource->abilities,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources\Zapier;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @property \App\Models\Forms\Form $resource
|
||||
*/
|
||||
class FormResource extends JsonResource
|
||||
{
|
||||
public function toArray($request)
|
||||
{
|
||||
return [
|
||||
'id' => $this->resource->slug,
|
||||
'name' => $this->resource->title,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources\Zapier;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @property \App\Models\Workspace $resource
|
||||
*/
|
||||
class WorkspaceResource extends JsonResource
|
||||
{
|
||||
public function toArray($request)
|
||||
{
|
||||
return [
|
||||
'id' => $this->resource->id,
|
||||
'name' => $this->resource->name,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Integrations\Handlers;
|
|||
|
||||
use App\Models\Integration\FormIntegration;
|
||||
use App\Events\Forms\FormSubmitted;
|
||||
use App\Models\Forms\Form;
|
||||
use App\Models\Integration\FormIntegrationsEvent;
|
||||
use App\Service\Forms\FormSubmissionFormatter;
|
||||
use App\Service\Forms\FormLogicConditionChecker;
|
||||
|
|
@ -34,7 +35,7 @@ abstract class AbstractIntegrationHandler
|
|||
|
||||
protected function logicConditionsMet(): bool
|
||||
{
|
||||
if (!$this->formIntegration->logic) {
|
||||
if (!$this->formIntegration->logic || empty((array) $this->formIntegration->logic)) {
|
||||
return true;
|
||||
}
|
||||
return FormLogicConditionChecker::conditionsMet(
|
||||
|
|
@ -58,27 +59,7 @@ abstract class AbstractIntegrationHandler
|
|||
*/
|
||||
protected function getWebhookData(): array
|
||||
{
|
||||
$formatter = (new FormSubmissionFormatter($this->form, $this->submissionData))
|
||||
->useSignedUrlForFiles()
|
||||
->showHiddenFields();
|
||||
|
||||
$formattedData = [];
|
||||
foreach ($formatter->getFieldsWithValue() as $field) {
|
||||
$formattedData[$field['name']] = $field['value'];
|
||||
}
|
||||
|
||||
$data = [
|
||||
'form_title' => $this->form->title,
|
||||
'form_slug' => $this->form->slug,
|
||||
'submission' => $formattedData,
|
||||
];
|
||||
if ($this->form->is_pro && $this->form->editable_submissions) {
|
||||
$data['edit_link'] = $this->form->share_url . '?submission_id=' . Hashids::encode(
|
||||
$this->submissionData['submission_id']
|
||||
);
|
||||
}
|
||||
|
||||
return $data;
|
||||
return self::formatWebhookData($this->form, $this->submissionData);
|
||||
}
|
||||
|
||||
final public function run(): void
|
||||
|
|
@ -125,8 +106,40 @@ abstract class AbstractIntegrationHandler
|
|||
return [];
|
||||
}
|
||||
|
||||
public static function formatData(array $data): array
|
||||
public static function formatWebhookData(Form $form, array $submissionData): array
|
||||
{
|
||||
$formatter = (new FormSubmissionFormatter($form, $submissionData))
|
||||
->useSignedUrlForFiles()
|
||||
->showHiddenFields();
|
||||
|
||||
// Old format - kept for retro-compatibility
|
||||
$oldFormatData = [];
|
||||
foreach ($formatter->getFieldsWithValue() as $field) {
|
||||
$oldFormatData[$field['name']] = $field['value'];
|
||||
}
|
||||
|
||||
// New format using ID
|
||||
$formattedData = [];
|
||||
foreach ($formatter->getFieldsWithValue() as $field) {
|
||||
$formattedData[$field['id']] = [
|
||||
'value' => $field['value'],
|
||||
'name' => $field['name'],
|
||||
];
|
||||
}
|
||||
|
||||
$data = [
|
||||
'form_title' => $form->title,
|
||||
'form_slug' => $form->slug,
|
||||
'submission' => $oldFormatData,
|
||||
'data' => $formattedData,
|
||||
'message' => 'Please do not use the `submission` field. It is deprecated and will be removed in the future.'
|
||||
];
|
||||
if ($form->is_pro && $form->editable_submissions) {
|
||||
$data['edit_link'] = $form->share_url . '?submission_id=' . Hashids::encode(
|
||||
$submissionData['submission_id']
|
||||
);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
|
@ -143,4 +156,12 @@ abstract class AbstractIntegrationHandler
|
|||
'message' => $e->getMessage()
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Used in FormIntegrationRequest to format integration
|
||||
*/
|
||||
public static function formatData(array $data): array
|
||||
{
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Integrations\Handlers;
|
||||
|
||||
use App\Events\Forms\FormSubmitted;
|
||||
use App\Models\Integration\FormIntegration;
|
||||
use Exception;
|
||||
|
||||
class ZapierIntegration extends AbstractIntegrationHandler
|
||||
{
|
||||
public function __construct(
|
||||
protected FormSubmitted $event,
|
||||
protected FormIntegration $formIntegration,
|
||||
protected array $integration
|
||||
) {
|
||||
parent::__construct($event, $formIntegration, $integration);
|
||||
}
|
||||
|
||||
public static function getValidationRules(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public static function isOAuthRequired(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function getWebhookUrl(): string
|
||||
{
|
||||
if (!isset($this->integrationData->hook_url)) {
|
||||
throw new Exception('The webhook URL is missing');
|
||||
}
|
||||
|
||||
return $this->integrationData->hook_url;
|
||||
}
|
||||
|
||||
protected function shouldRun(): bool
|
||||
{
|
||||
return parent::shouldRun() && $this->getWebhookUrl();
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Cashier\Billable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Tymon\JWTAuth\Contracts\JWTSubject;
|
||||
|
||||
class User extends Authenticatable implements JWTSubject
|
||||
|
|
@ -16,6 +17,7 @@ class User extends Authenticatable implements JWTSubject
|
|||
use Billable;
|
||||
use HasFactory;
|
||||
use Notifiable;
|
||||
use HasApiTokens;
|
||||
|
||||
public const ROLE_ADMIN = 'admin';
|
||||
public const ROLE_USER = 'user';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class PersonalAccessTokenPolicy
|
||||
{
|
||||
public function delete(User $user, PersonalAccessToken $token)
|
||||
{
|
||||
return $token->tokenable()->is($user);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,9 +10,11 @@ use App\Models\Workspace;
|
|||
use App\Policies\FormPolicy;
|
||||
use App\Policies\Integration\FormZapierWebhookPolicy;
|
||||
use App\Policies\OAuthProviderPolicy;
|
||||
use App\Policies\PersonalAccessTokenPolicy;
|
||||
use App\Policies\TemplatePolicy;
|
||||
use App\Policies\WorkspacePolicy;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class AuthServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
|
@ -27,6 +29,7 @@ class AuthServiceProvider extends ServiceProvider
|
|||
FormZapierWebhook::class => FormZapierWebhookPolicy::class,
|
||||
Template::class => TemplatePolicy::class,
|
||||
OAuthProvider::class => OAuthProviderPolicy::class,
|
||||
PersonalAccessToken::class => PersonalAccessTokenPolicy::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ class RouteServiceProvider extends ServiceProvider
|
|||
Route::middleware('api')
|
||||
->namespace($this->namespace)
|
||||
->group(base_path('routes/api.php'));
|
||||
|
||||
Route::middleware('api-external')
|
||||
->namespace($this->namespace)
|
||||
->group(base_path('routes/api-external.php'));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<v-checkbox
|
||||
:id="id ? id : name"
|
||||
v-model="compVal"
|
||||
:value="value"
|
||||
:disabled="disabled ? true : null"
|
||||
:name="name"
|
||||
:color="color"
|
||||
|
|
@ -48,6 +49,7 @@ export default {
|
|||
components: { InputWrapper, VCheckbox },
|
||||
props: {
|
||||
...inputProps,
|
||||
value: { type: [Boolean, String, Number, Object], required: false },
|
||||
},
|
||||
|
||||
setup(props, context) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
<input
|
||||
:id="id || name"
|
||||
v-model="internalValue"
|
||||
:value="value"
|
||||
:name="name"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-500 w-10 h-10 cursor-pointer checkbox"
|
||||
|
|
@ -32,6 +33,7 @@ const props = defineProps({
|
|||
id: { type: String, default: null },
|
||||
name: { type: String, default: "checkbox" },
|
||||
modelValue: { type: [Boolean, String], default: false },
|
||||
value: { type: [Boolean, String, Number, Object], required: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
theme: {
|
||||
type: Object, default: () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="flex flex-wrap sm:flex-nowrap my-4 w-full">
|
||||
<div
|
||||
class="w-full sm:w-auto border border-gray-300 rounded-md p-2 flex-grow select-all bg-gray-100"
|
||||
class="flex-1 truncate sm:w-auto border border-gray-300 rounded-md p-2 flex-grow select-all bg-gray-100"
|
||||
>
|
||||
<p class="select-all text-gray-900">
|
||||
{{ content }}
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
<v-button
|
||||
color="light-gray"
|
||||
class="w-full"
|
||||
@click="copyToClipboard"
|
||||
@click.prevent="copyToClipboard"
|
||||
>
|
||||
<slot name="icon">
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@
|
|||
</v-button>
|
||||
</template>
|
||||
<a
|
||||
v-if="integrationTypeInfo.is_editable !== false"
|
||||
v-track.edit_form_integration_click="{
|
||||
form_slug: form.slug,
|
||||
form_integration_id: integration.id,
|
||||
|
|
@ -106,6 +107,21 @@
|
|||
/>
|
||||
Edit
|
||||
</a>
|
||||
<a
|
||||
v-else-if="integrationTypeInfo.url"
|
||||
v-track.edit_form_integration_click="{
|
||||
form_slug: form.slug,
|
||||
form_integration_id: integration.id,
|
||||
}"
|
||||
:href="integrationTypeInfo.url"
|
||||
class="flex px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline hover:text-gray-900 items-center"
|
||||
>
|
||||
<Icon
|
||||
name="heroicons:pencil"
|
||||
class="w-5 h-5 mr-2"
|
||||
/>
|
||||
Edit on {{ integrationTypeInfo.name }}
|
||||
</a>
|
||||
<a
|
||||
v-track.past_events_form_integration_click="{
|
||||
form_slug: form.slug,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<UTooltip
|
||||
:text="tooltipText"
|
||||
:prevent="!unavailable"
|
||||
:prevent="!unavailable || !tooltipText"
|
||||
>
|
||||
<div
|
||||
v-track.new_integration_click="{ name: integration.id }"
|
||||
|
|
@ -32,6 +32,11 @@
|
|||
>
|
||||
(coming soon)</span>
|
||||
</div>
|
||||
<Icon
|
||||
v-if="integration.is_external"
|
||||
class="inline h-4 w-4 ml-1 inline text-gray-500"
|
||||
name="heroicons:arrow-top-right-on-square-20-solid"
|
||||
/>
|
||||
</div>
|
||||
<pro-tag
|
||||
v-if="integration?.is_pro === true"
|
||||
|
|
@ -42,8 +47,10 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
const emit = defineEmits(["select"])
|
||||
import { computed } from 'vue'
|
||||
import { useWorkspacesStore } from '@/stores/workspaces'
|
||||
|
||||
const emit = defineEmits(["select"])
|
||||
const props = defineProps({
|
||||
integration: {
|
||||
type: Object,
|
||||
|
|
@ -51,22 +58,25 @@ const props = defineProps({
|
|||
},
|
||||
})
|
||||
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
const currentWorkspace = computed(() => workspacesStore.getCurrent)
|
||||
|
||||
const unavailable = computed(() => {
|
||||
return (
|
||||
props.integration.coming_soon || props.integration.requires_subscription
|
||||
props.integration.coming_soon ||
|
||||
(props.integration.requires_subscription && !currentWorkspace.value.is_pro)
|
||||
)
|
||||
})
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (props.integration.coming_soon) return "This integration is coming soon"
|
||||
if (props.integration.requires_subscription)
|
||||
if (props.integration.requires_subscription && !currentWorkspace.value.is_pro )
|
||||
return "You need a subscription to use this integration."
|
||||
return ""
|
||||
return null
|
||||
})
|
||||
|
||||
const onClick = () => {
|
||||
if (props.integration.coming_soon || props.integration.requires_subscription)
|
||||
return
|
||||
if (unavailable.value) return
|
||||
emit("select", props.integration.id)
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<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>
|
||||
<div class="flex space-x-3 font-semibold mr-2">
|
||||
{{ token.name }}
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<span
|
||||
v-for="(ability, index) in token.abilities"
|
||||
:key="index"
|
||||
>
|
||||
{{ accessTokenStore.getAbility(ability).title }}
|
||||
<template v-if="index !== token.abilities.length - 1">
|
||||
,
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<dropdown
|
||||
class="inline"
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<v-button
|
||||
color="white"
|
||||
@click="toggle"
|
||||
>
|
||||
<Icon
|
||||
name="heroicons:ellipsis-horizontal"
|
||||
class="w-4 h-4 -mt-1"
|
||||
/>
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
class="flex px-4 py-2 text-md text-red-600 hover:bg-red-50 hover:no-underline items-center"
|
||||
@click.prevent="destroy"
|
||||
>
|
||||
<Icon
|
||||
name="heroicons:trash"
|
||||
class="w-5 h-5 mr-2"
|
||||
/>
|
||||
|
||||
Delete
|
||||
</a>
|
||||
</dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
token: Object
|
||||
})
|
||||
|
||||
const accessTokenStore = useAccessTokenStore()
|
||||
const alert = useAlert()
|
||||
|
||||
function destroy() {
|
||||
alert.confirm("Do you really want to delete this token?", () => {
|
||||
opnFetch(`/settings/tokens/${props.token.id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
.then(() => {
|
||||
accessTokenStore.remove(props.token.id)
|
||||
})
|
||||
.catch((error) => {
|
||||
try {
|
||||
alert.error(error.data.message)
|
||||
} catch (e) {
|
||||
alert.error("An error occurred while disconnecting an account")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
<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>
|
||||
Create an access token
|
||||
</template>
|
||||
|
||||
<div class="px-4">
|
||||
<form
|
||||
@submit.prevent="createToken"
|
||||
@keydown="form.onKeydown($event)"
|
||||
>
|
||||
<div v-if="!token">
|
||||
<text-input
|
||||
name="name"
|
||||
class="mt-4"
|
||||
:form="form"
|
||||
:required="true"
|
||||
label="Name"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="input-label">Abilities</label>
|
||||
|
||||
<div class="mt-2">
|
||||
<checkbox-input
|
||||
v-for="ability in abilities"
|
||||
:key="ability.name"
|
||||
v-model="form.abilities"
|
||||
:value="ability.name"
|
||||
:label="ability.title"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UAlert
|
||||
v-if="token"
|
||||
icon="i-heroicons-key-20-solid"
|
||||
color="green"
|
||||
variant="subtle"
|
||||
title="Copy your access token"
|
||||
description="Your token will only be shown once. Make sure to save it safely."
|
||||
/>
|
||||
|
||||
<div class="flex">
|
||||
<copy-content
|
||||
v-if="token"
|
||||
:content="token"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon
|
||||
name="heroicons:link"
|
||||
class="w-4 h-4 -mt-1 text-blue-600 mr-3"
|
||||
/>
|
||||
</template>
|
||||
Copy Token
|
||||
</copy-content>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-6">
|
||||
<v-button
|
||||
v-if="!token"
|
||||
:loading="form.busy"
|
||||
class="w-full my-3"
|
||||
>
|
||||
Save
|
||||
</v-button>
|
||||
|
||||
<v-button
|
||||
v-else
|
||||
class="w-full my-3"
|
||||
@click.prevent="emit('close')"
|
||||
>
|
||||
Close
|
||||
</v-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import CopyContent from "../open/forms/components/CopyContent.vue"
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const accessTokenStore = useAccessTokenStore()
|
||||
const abilities = computed(() => accessTokenStore.abilities)
|
||||
|
||||
const form = useForm({
|
||||
name: "",
|
||||
abilities: abilities.value.map(ability => ability.name),
|
||||
})
|
||||
|
||||
const token = ref('')
|
||||
|
||||
function createToken() {
|
||||
form.post("/settings/tokens").then((data) => {
|
||||
// workspacesStore.save(data.workspace)
|
||||
// workspacesStore.currentId = data.workspace.id
|
||||
// workspaceModal.value = false
|
||||
|
||||
token.value = data.token
|
||||
accessTokenStore.fetchTokens()
|
||||
|
||||
useAlert().success(
|
||||
"Access token successfully created!",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.show, () => {
|
||||
form.name = ''
|
||||
form.abilities = abilities.value.map(ability => ability.name),
|
||||
token.value = ''
|
||||
})
|
||||
</script>
|
||||
|
|
@ -31,17 +31,19 @@
|
|||
"webhook": {
|
||||
"name": "Webhook Notification",
|
||||
"icon": "material-symbols:webhook",
|
||||
"section_name": "Notifications",
|
||||
"section_name": "Automation",
|
||||
"file_name": "WebhookIntegration",
|
||||
"is_pro": false
|
||||
},
|
||||
"zapier": {
|
||||
"name": "Zapier Integration",
|
||||
"name": "Zapier",
|
||||
"icon": "cib:zapier",
|
||||
"section_name": "Notifications",
|
||||
"section_name": "Automation",
|
||||
"file_name": "ZapierIntegration",
|
||||
"is_pro": true,
|
||||
"coming_soon": true
|
||||
"is_pro": false,
|
||||
"is_external": true,
|
||||
"is_editable": false,
|
||||
"url": "https://zapier.com/app/zaps"
|
||||
},
|
||||
"google_sheets": {
|
||||
"name": "Google Sheets",
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
v-for="(sectionItem, sectionItemKey) in section"
|
||||
:key="sectionItemKey"
|
||||
:integration="sectionItem"
|
||||
@select="openIntegrationModal"
|
||||
@select="openIntegration"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -107,11 +107,22 @@ onMounted(() => {
|
|||
oAuthProvidersStore.fetchOAuthProviders(props.form.workspace_id)
|
||||
})
|
||||
|
||||
const openIntegrationModal = (itemKey) => {
|
||||
if (!itemKey || !integrations.value.has(itemKey))
|
||||
const openIntegration = (itemKey) => {
|
||||
if (!itemKey || !integrations.value.has(itemKey)) {
|
||||
return alert.error("Integration not found")
|
||||
if (integrations.value.get(itemKey).coming_soon)
|
||||
}
|
||||
|
||||
const integration = integrations.value.get(itemKey)
|
||||
|
||||
if (integration.coming_soon) {
|
||||
return alert.warning("This integration is not available yet")
|
||||
}
|
||||
|
||||
if(integration.is_external && integration.url) {
|
||||
window.open(integration.url, '_blank')
|
||||
return
|
||||
}
|
||||
|
||||
selectedIntegrationKey.value = itemKey
|
||||
selectedIntegration.value = integrations.value.get(
|
||||
selectedIntegrationKey.value,
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@ const tabsList = computed(() => {
|
|||
name: "Workspace Settings",
|
||||
route: "settings-workspace",
|
||||
},
|
||||
{
|
||||
name: "Access Tokens",
|
||||
route: "settings-access-tokens",
|
||||
},
|
||||
{
|
||||
name: "Connections",
|
||||
route: "settings-connections",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
<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">
|
||||
Access Tokens
|
||||
</h3>
|
||||
<small class="text-gray-600">Manage your access tokens keys.</small>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
label="Create new token"
|
||||
icon="i-heroicons-plus"
|
||||
:loading="loading"
|
||||
@click="accessTokenModal = true"
|
||||
/>
|
||||
</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">
|
||||
<SettingsAccessTokenCard
|
||||
v-for="token in tokens"
|
||||
:key="token.id"
|
||||
:token="token"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsAccessTokenModal
|
||||
:show="accessTokenModal"
|
||||
@close="accessTokenModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
useOpnSeoMeta({
|
||||
title: "Access Tokens",
|
||||
})
|
||||
|
||||
const accessTokenModal = ref(false)
|
||||
const accessTokenStore = useAccessTokenStore()
|
||||
const tokens = computed(() => accessTokenStore.getAll)
|
||||
const loading = computed(() => accessTokenStore.loading)
|
||||
|
||||
onMounted(() => {
|
||||
accessTokenStore.fetchTokens()
|
||||
})
|
||||
</script>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { defineStore } from "pinia"
|
||||
import { useContentStore } from "~/composables/stores/useContentStore.js"
|
||||
|
||||
export const useAccessTokenStore = defineStore("access_tokens", () => {
|
||||
const contentStore = useContentStore()
|
||||
|
||||
const abilities = [
|
||||
{
|
||||
title: 'Manage integrations',
|
||||
name: 'manage-integrations',
|
||||
},
|
||||
{
|
||||
title: 'List forms',
|
||||
name: 'list-forms',
|
||||
},
|
||||
{
|
||||
title: 'List workspaces',
|
||||
name: 'list-workspaces',
|
||||
},
|
||||
]
|
||||
|
||||
const fetchTokens = () => {
|
||||
contentStore.resetState()
|
||||
contentStore.startLoading()
|
||||
|
||||
return opnFetch('/settings/tokens').then(
|
||||
(data) => {
|
||||
contentStore.save(data)
|
||||
contentStore.stopLoading()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const tokens = computed(() => contentStore.getAll.value)
|
||||
|
||||
const getAbility = (name) => {
|
||||
return abilities.find(ability => ability.name == name)
|
||||
}
|
||||
|
||||
return {
|
||||
...contentStore,
|
||||
fetchTokens,
|
||||
tokens,
|
||||
abilities,
|
||||
getAbility
|
||||
}
|
||||
})
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
"laravel/cashier": "*",
|
||||
"laravel/framework": "^11.9",
|
||||
"laravel/horizon": "*",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/socialite": "*",
|
||||
"laravel/tinker": "^2.9",
|
||||
"laravel/ui": "*",
|
||||
|
|
@ -48,6 +49,7 @@
|
|||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "*",
|
||||
"pestphp/pest": "^2.0",
|
||||
"pestphp/pest-plugin-laravel": "^2.4",
|
||||
"spatie/laravel-ignition": "*",
|
||||
"spatie/laravel-ray": "*"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "d0db593e303db3e95ccf31168c9b66c7",
|
||||
"content-hash": "b30e4ad9df3f7463d73bf25b757fd616",
|
||||
"packages": [
|
||||
{
|
||||
"name": "amphp/amp",
|
||||
|
|
@ -3501,6 +3501,70 @@
|
|||
},
|
||||
"time": "2024-06-17T13:58:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/sanctum",
|
||||
"version": "v4.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/sanctum.git",
|
||||
"reference": "9cfc0ce80cabad5334efff73ec856339e8ec1ac1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/sanctum/zipball/9cfc0ce80cabad5334efff73ec856339e8ec1ac1",
|
||||
"reference": "9cfc0ce80cabad5334efff73ec856339e8ec1ac1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"illuminate/console": "^11.0",
|
||||
"illuminate/contracts": "^11.0",
|
||||
"illuminate/database": "^11.0",
|
||||
"illuminate/support": "^11.0",
|
||||
"php": "^8.2",
|
||||
"symfony/console": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.6",
|
||||
"orchestra/testbench": "^9.0",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"phpunit/phpunit": "^10.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Sanctum\\SanctumServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Sanctum\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
|
||||
"keywords": [
|
||||
"auth",
|
||||
"laravel",
|
||||
"sanctum"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/sanctum/issues",
|
||||
"source": "https://github.com/laravel/sanctum"
|
||||
},
|
||||
"time": "2024-04-10T19:39:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/serializable-closure",
|
||||
"version": "v1.3.3",
|
||||
|
|
@ -12951,6 +13015,80 @@
|
|||
],
|
||||
"time": "2024-01-26T09:46:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "pestphp/pest-plugin-laravel",
|
||||
"version": "v2.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/pestphp/pest-plugin-laravel.git",
|
||||
"reference": "53df51169a7f9595e06839cce638c73e59ace5e8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/pestphp/pest-plugin-laravel/zipball/53df51169a7f9595e06839cce638c73e59ace5e8",
|
||||
"reference": "53df51169a7f9595e06839cce638c73e59ace5e8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"laravel/framework": "^10.48.9|^11.5.0",
|
||||
"pestphp/pest": "^2.34.7",
|
||||
"php": "^8.1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/dusk": "^7.13.0",
|
||||
"orchestra/testbench": "^8.22.3|^9.0.4",
|
||||
"pestphp/pest-dev-tools": "^2.16.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Pest\\Laravel\\PestServiceProvider"
|
||||
]
|
||||
},
|
||||
"pest": {
|
||||
"plugins": [
|
||||
"Pest\\Laravel\\Plugin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Autoload.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Pest\\Laravel\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "The Pest Laravel Plugin",
|
||||
"keywords": [
|
||||
"framework",
|
||||
"laravel",
|
||||
"pest",
|
||||
"php",
|
||||
"test",
|
||||
"testing",
|
||||
"unit"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/pestphp/pest-plugin-laravel/tree/v2.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.paypal.com/paypalme/enunomaduro",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nunomaduro",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-04-27T10:41:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phar-io/manifest",
|
||||
"version": "2.0.4",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Stateful Domains
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Requests from the following domains / hosts will receive stateful API
|
||||
| authentication cookies. Typically, these should include your local
|
||||
| and production domains which access your API via a frontend SPA.
|
||||
|
|
||||
*/
|
||||
|
||||
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
|
||||
'%s%s',
|
||||
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
|
||||
Sanctum::currentApplicationUrlWithPort()
|
||||
))),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This array contains the authentication guards that will be checked when
|
||||
| Sanctum is trying to authenticate a request. If none of these guards
|
||||
| are able to authenticate the request, Sanctum will use the bearer
|
||||
| token that's present on an incoming request for authentication.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expiration Minutes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the number of minutes until an issued token will be
|
||||
| considered expired. This will override any values set in the token's
|
||||
| "expires_at" attribute, but first-party sessions are not affected.
|
||||
|
|
||||
*/
|
||||
|
||||
'expiration' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Token Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Sanctum can prefix new tokens in order to take advantage of numerous
|
||||
| security scanning initiatives maintained by open source platforms
|
||||
| that notify developers if they commit tokens into repositories.
|
||||
|
|
||||
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
||||
|
|
||||
*/
|
||||
|
||||
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sanctum Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When authenticating your first-party SPA with Sanctum you may need to
|
||||
| customize some of the middleware Sanctum uses while processing the
|
||||
| request. You may change the middleware listed below as required.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => [
|
||||
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
|
||||
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
|
||||
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class () extends Migration {
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->morphs('tokenable');
|
||||
$table->string('name');
|
||||
$table->string('token', 64)->unique();
|
||||
$table->text('abilities')->nullable();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('personal_access_tokens');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Typescript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# environment variables file
|
||||
.env
|
||||
.environment
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# zapier app config
|
||||
.zapierapprc
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# Zapier
|
||||
|
||||
Install Zapier
|
||||
|
||||
```
|
||||
npm install -g zapier-platform-cli
|
||||
```
|
||||
|
||||
Install dependencies
|
||||
|
||||
```
|
||||
cd `zapier`
|
||||
npm install
|
||||
```
|
||||
|
||||
Login to Zapier
|
||||
|
||||
```
|
||||
zapier login
|
||||
```
|
||||
|
||||
Register the app
|
||||
|
||||
```
|
||||
zapier register [TITLE]
|
||||
```
|
||||
|
||||
Publish the app
|
||||
|
||||
```
|
||||
zapier push
|
||||
```
|
||||
|
||||
Set the base URL to receive webhooks from Zapier. The version usually looks like 1.0.0.
|
||||
|
||||
```
|
||||
zapier env:set [VERSION] BASE_URL=[BASE_URL]
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- Create an access token: http://localhost:3000/settings/access-tokens
|
||||
- Create a Zap
|
||||
- Authenticate using your token
|
||||
- Submit a form
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
module.exports = {
|
||||
type: 'custom',
|
||||
test: {
|
||||
removeMissingValuesFrom: { body: false, params: false },
|
||||
url: '{{process.env.BASE_URL}}/external/zapier/validate',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
helpText:
|
||||
'Enter your API key, located at https://opnform.com/settings/access-tokens',
|
||||
computed: false,
|
||||
key: 'api_key',
|
||||
required: true,
|
||||
label: 'API Key',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
connectionLabel: '{{bundle.inputData.name}} {{bundle.inputData.email}}',
|
||||
customConfig: {},
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
const authentication = require('./authentication');
|
||||
const newSubmissionTrigger = require('./triggers/new_submission.js');
|
||||
const listWorkspacesTrigger = require('./triggers/list_workspaces.js');
|
||||
const listFormsTrigger = require('./triggers/list_forms.js');
|
||||
|
||||
module.exports = {
|
||||
version: require('./package.json').version,
|
||||
platformVersion: require('zapier-platform-core').version,
|
||||
requestTemplate: {
|
||||
headers: {
|
||||
Authorization: 'Bearer {{bundle.authData.api_key}}',
|
||||
'X-API-KEY': '{{bundle.authData.api_key}}',
|
||||
},
|
||||
params: { api_key: '{{bundle.authData.api_key}}' },
|
||||
body: {},
|
||||
},
|
||||
authentication: authentication,
|
||||
searches: {},
|
||||
triggers: {
|
||||
[newSubmissionTrigger.key]: newSubmissionTrigger,
|
||||
[listWorkspacesTrigger.key]: listWorkspacesTrigger,
|
||||
[listFormsTrigger.key]: listFormsTrigger,
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "opnform-test-1",
|
||||
"version": "1.0.1",
|
||||
"description": "opnform-test-1",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "jest --testTimeout 10000"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=v18",
|
||||
"npm": ">=5.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"zapier-platform-core": "15.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.6.0"
|
||||
},
|
||||
"private": true,
|
||||
"zapier": {
|
||||
"convertedByCLIVersion": "15.8.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
const zapier = require('zapier-platform-core');
|
||||
|
||||
// Use this to make test calls into your app:
|
||||
const App = require('../../index');
|
||||
const appTester = zapier.createAppTester(App);
|
||||
// read the `.env` file into the environment, if available
|
||||
zapier.tools.env.inject();
|
||||
|
||||
describe('triggers.list_forms', () => {
|
||||
it('should run', async () => {
|
||||
const bundle = { inputData: {} };
|
||||
|
||||
const results = await appTester(
|
||||
App.triggers['list_forms'].operation.perform,
|
||||
bundle
|
||||
);
|
||||
expect(results).toBeDefined();
|
||||
// TODO: add more assertions
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
const zapier = require('zapier-platform-core');
|
||||
|
||||
// Use this to make test calls into your app:
|
||||
const App = require('../../index');
|
||||
const appTester = zapier.createAppTester(App);
|
||||
// read the `.env` file into the environment, if available
|
||||
zapier.tools.env.inject();
|
||||
|
||||
describe('triggers.list_workspaces', () => {
|
||||
it('should run', async () => {
|
||||
const bundle = { inputData: {} };
|
||||
|
||||
const results = await appTester(
|
||||
App.triggers['list_workspaces'].operation.perform,
|
||||
bundle
|
||||
);
|
||||
expect(results).toBeDefined();
|
||||
// TODO: add more assertions
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
const zapier = require('zapier-platform-core');
|
||||
|
||||
// Use this to make test calls into your app:
|
||||
const App = require('../../index');
|
||||
const appTester = zapier.createAppTester(App);
|
||||
// read the `.env` file into the environment, if available
|
||||
zapier.tools.env.inject();
|
||||
|
||||
describe('triggers.new_submission', () => {
|
||||
it('should run', async () => {
|
||||
const bundle = { inputData: {} };
|
||||
|
||||
const results = await appTester(
|
||||
App.triggers['new_submission'].operation.perform,
|
||||
bundle
|
||||
);
|
||||
expect(results).toBeDefined();
|
||||
// TODO: add more assertions
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
module.exports = {
|
||||
operation: {
|
||||
perform: {
|
||||
headers: { Accept: 'application/json' },
|
||||
params: {
|
||||
api_key: '{{bundle.authData.api_key}}',
|
||||
workspace_id: '{{bundle.inputData.workspace_id}}',
|
||||
},
|
||||
removeMissingValuesFrom: { body: false, params: false },
|
||||
url: '{{process.env.BASE_URL}}/external/zapier/forms',
|
||||
},
|
||||
inputFields: [
|
||||
{
|
||||
key: 'workspace_id',
|
||||
type: 'string',
|
||||
dynamic: 'list_workspaces.id.name',
|
||||
label: 'Workspace',
|
||||
required: true,
|
||||
list: false,
|
||||
altersDynamicFields: false,
|
||||
},
|
||||
],
|
||||
sample: { id: 'my-form', name: 'My Form' },
|
||||
outputFields: [
|
||||
{ key: 'id', label: 'ID', type: 'string' },
|
||||
{ key: 'name', label: 'Name', type: 'string' },
|
||||
],
|
||||
canPaginate: false,
|
||||
},
|
||||
display: {
|
||||
description: 'Get the list of all forms',
|
||||
hidden: true,
|
||||
label: 'List Forms',
|
||||
},
|
||||
key: 'list_forms',
|
||||
noun: 'Form',
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
module.exports = {
|
||||
operation: {
|
||||
perform: {
|
||||
headers: { Accept: 'application/json' },
|
||||
removeMissingValuesFrom: { body: false, params: false },
|
||||
url: '{{process.env.BASE_URL}}/external/zapier/workspaces',
|
||||
},
|
||||
sample: { id: 1, name: 'My Workspace' },
|
||||
outputFields: [
|
||||
{ key: 'id', label: 'ID', type: 'integer' },
|
||||
{ key: 'name', label: 'Name', type: 'string' },
|
||||
],
|
||||
},
|
||||
display: {
|
||||
description: "Get the list of all user's workspaces",
|
||||
hidden: true,
|
||||
label: 'List Workspaces',
|
||||
},
|
||||
key: 'list_workspaces',
|
||||
noun: 'Workspace',
|
||||
};
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
const perform = async (z, bundle) => {
|
||||
return [bundle.cleanedRequest];
|
||||
};
|
||||
|
||||
const performList = async (z, bundle) => {
|
||||
// Replace with the actual URL that returns recent submissions
|
||||
const response = await z.request({
|
||||
url: `${process.env.BASE_URL}/external/zapier/submissions/recent`,
|
||||
params: {
|
||||
form_id: bundle.inputData.form_id,
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure the structure of the response matches the webhook data structure
|
||||
return response.data;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
operation: {
|
||||
perform: perform,
|
||||
performList: performList,
|
||||
sample: {
|
||||
"form_title": "Your form title",
|
||||
"form_slug": "your-form-slug-og4lhg"
|
||||
},
|
||||
inputFields: [
|
||||
{
|
||||
key: 'workspace_id',
|
||||
type: 'string',
|
||||
label: 'Workspace',
|
||||
dynamic: 'list_workspaces.id.name',
|
||||
required: true,
|
||||
list: false,
|
||||
altersDynamicFields: true,
|
||||
},
|
||||
{
|
||||
key: 'form_id',
|
||||
type: 'string',
|
||||
label: 'Form',
|
||||
dynamic: 'list_forms.id.name',
|
||||
required: true,
|
||||
list: false,
|
||||
altersDynamicFields: false,
|
||||
},
|
||||
],
|
||||
type: 'hook',
|
||||
performUnsubscribe: {
|
||||
body: {
|
||||
hookUrl: '{{bundle.subscribeData.id}}',
|
||||
form_id: '{{bundle.inputData.form_id}}',
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
method: 'DELETE',
|
||||
removeMissingValuesFrom: { body: false, params: false },
|
||||
url: '{{process.env.BASE_URL}}/external/zapier/webhook',
|
||||
},
|
||||
performSubscribe: {
|
||||
body: {
|
||||
hookUrl: '{{bundle.targetUrl}}',
|
||||
form_id: '{{bundle.inputData.form_id}}',
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
removeMissingValuesFrom: { body: false, params: false },
|
||||
url: '{{process.env.BASE_URL}}/external/zapier/webhook',
|
||||
},
|
||||
},
|
||||
display: {
|
||||
description: 'Triggers when a new submission is created.',
|
||||
hidden: false,
|
||||
label: 'New Submission',
|
||||
},
|
||||
key: 'new_submission',
|
||||
noun: 'Submission',
|
||||
};
|
||||
|
|
@ -31,17 +31,19 @@
|
|||
"webhook": {
|
||||
"name": "Webhook Notification",
|
||||
"icon": "material-symbols:webhook",
|
||||
"section_name": "Notifications",
|
||||
"section_name": "Automation",
|
||||
"file_name": "WebhookIntegration",
|
||||
"is_pro": false
|
||||
},
|
||||
"zapier": {
|
||||
"name": "Zapier Integration",
|
||||
"name": "Zapier",
|
||||
"icon": "cib:zapier",
|
||||
"section_name": "Notifications",
|
||||
"section_name": "Automation",
|
||||
"file_name": "ZapierIntegration",
|
||||
"is_pro": true,
|
||||
"coming_soon": true
|
||||
"is_pro": false,
|
||||
"is_external": true,
|
||||
"is_editable": false,
|
||||
"url": "https://zapier.com/app/zaps"
|
||||
},
|
||||
"google_sheets": {
|
||||
"name": "Google Sheets",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* External API calls
|
||||
*/
|
||||
|
||||
use App\Http\Controllers\Integrations\Zapier;
|
||||
use App\Http\Controllers\Integrations\Zapier\ListFormsController;
|
||||
use App\Http\Controllers\Integrations\Zapier\ListWorkspacesController;
|
||||
|
||||
Route::prefix('external')
|
||||
->middleware('auth:sanctum')
|
||||
->group(function () {
|
||||
Route::prefix('zapier')->name('zapier.')->group(function () {
|
||||
Route::get('validate', Zapier\ValidateAuthController::class)
|
||||
->name('validate');
|
||||
|
||||
// Set and delete webhooks / manage integrations
|
||||
Route::middleware('ability:manage-integrations')
|
||||
->name('webhooks.')
|
||||
->group(function () {
|
||||
Route::post('webhook', [Zapier\IntegrationController::class, 'store'])
|
||||
->name('store');
|
||||
|
||||
Route::delete('webhook', [Zapier\IntegrationController::class, 'destroy'])
|
||||
->name('destroy');
|
||||
Route::get('submissions/recent', [Zapier\IntegrationController::class, 'poll'])->name('poll');
|
||||
});
|
||||
|
||||
Route::get('workspaces', ListWorkspacesController::class)
|
||||
->middleware('ability:list-workspaces')
|
||||
->name('workspaces');
|
||||
|
||||
Route::get('forms', ListFormsController::class)
|
||||
->middleware('ability:list-forms')
|
||||
->name('forms');
|
||||
});
|
||||
});
|
||||
|
|
@ -15,9 +15,10 @@ 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\OAuthProviderController;
|
||||
use App\Http\Controllers\Settings\PasswordController;
|
||||
use App\Http\Controllers\Settings\ProfileController;
|
||||
use App\Http\Controllers\Settings\TokenController;
|
||||
use App\Http\Controllers\SubscriptionController;
|
||||
use App\Http\Controllers\TemplateController;
|
||||
use App\Http\Controllers\UserInviteController;
|
||||
|
|
@ -51,6 +52,12 @@ Route::group(['middleware' => 'auth:api'], function () {
|
|||
Route::patch('/profile', [ProfileController::class, 'update']);
|
||||
Route::patch('/password', [PasswordController::class, 'update']);
|
||||
|
||||
Route::prefix('/tokens')->name('tokens.')->group(function () {
|
||||
Route::get('/', [TokenController::class, 'index'])->name('index');
|
||||
Route::post('/', [TokenController::class, 'store'])->name('store');
|
||||
Route::delete('{token}', [TokenController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use App\Mail\Forms\SubmissionConfirmationMail;
|
|||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
it('creates confirmation emails with the submitted data', function () {
|
||||
$this->withoutExceptionHandling();
|
||||
$user = $this->actingAsUser();
|
||||
$workspace = $this->createUserWorkspace($user);
|
||||
$form = $this->createForm($user, $workspace);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,244 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Integration\FormIntegration;
|
||||
use App\Models\User;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\Helpers\FormSubmissionDataFactory;
|
||||
|
||||
use function Pest\Laravel\assertDatabaseCount;
|
||||
use function Pest\Laravel\delete;
|
||||
use function Pest\Laravel\post;
|
||||
use function PHPUnit\Framework\assertEquals;
|
||||
|
||||
test('create an integration', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = createUserWorkspace($user);
|
||||
|
||||
$form = createForm($user, $workspace, ['title' => 'First form']);
|
||||
|
||||
Sanctum::actingAs(
|
||||
$user,
|
||||
['manage-integrations']
|
||||
);
|
||||
|
||||
$this->withoutExceptionHandling();
|
||||
post(route('zapier.webhooks.store'), [
|
||||
'form_id' => $form->slug,
|
||||
'hookUrl' => $hookUrl = 'https://zapier.com/hook/test'
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
assertDatabaseCount('form_integrations', 1);
|
||||
|
||||
$integration = FormIntegration::first();
|
||||
|
||||
assertEquals($form->id, $integration->form_id);
|
||||
assertEquals($hookUrl, $integration->data->hook_url);
|
||||
});
|
||||
|
||||
test('cannot create an integration without a corresponding ability', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = createUserWorkspace($user);
|
||||
|
||||
$form = createForm($user, $workspace, ['title' => 'First form']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
post(route('zapier.webhooks.store'), [
|
||||
'form_id' => $form->slug,
|
||||
'hookUrl' => 'https://zapier.com/hook/test'
|
||||
])
|
||||
->assertForbidden();
|
||||
|
||||
assertDatabaseCount('form_integrations', 0);
|
||||
});
|
||||
|
||||
test('cannot create an integration for other users form', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user2 = User::factory()->create();
|
||||
$workspace = createUserWorkspace($user2);
|
||||
|
||||
$form = createForm($user2, $workspace, ['title' => 'First form']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
post(route('zapier.webhooks.store'), [
|
||||
'form_id' => $form->slug,
|
||||
'hookUrl' => 'https://zapier.com/hook/test'
|
||||
])
|
||||
->assertForbidden();
|
||||
|
||||
assertDatabaseCount('form_integrations', 0);
|
||||
});
|
||||
|
||||
test('delete an integration', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = createUserWorkspace($user);
|
||||
|
||||
$form = createForm($user, $workspace, ['title' => 'First form']);
|
||||
|
||||
Sanctum::actingAs(
|
||||
$user,
|
||||
['manage-integrations']
|
||||
);
|
||||
|
||||
$integration = FormIntegration::factory()
|
||||
->for($form)
|
||||
->create([
|
||||
'data' => [
|
||||
'hook_url' => $hookUrl = 'https://zapier.com/hook/test'
|
||||
]
|
||||
]);
|
||||
|
||||
assertDatabaseCount('form_integrations', 1);
|
||||
|
||||
delete(route('zapier.webhooks.destroy', $integration), [
|
||||
'form_id' => $form->slug,
|
||||
'hookUrl' => $hookUrl,
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
assertDatabaseCount('form_integrations', 0);
|
||||
});
|
||||
|
||||
test('cannot delete an integration with an incorrect hook url', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = createUserWorkspace($user);
|
||||
|
||||
$form = createForm($user, $workspace, ['title' => 'First form']);
|
||||
|
||||
Sanctum::actingAs(
|
||||
$user,
|
||||
['manage-integrations']
|
||||
);
|
||||
|
||||
$integration = FormIntegration::factory()
|
||||
->for($form)
|
||||
->create([
|
||||
'data' => [
|
||||
'hook_url' => 'https://zapier.com/hook/test'
|
||||
]
|
||||
]);
|
||||
|
||||
delete(route('zapier.webhooks.destroy', $integration), [
|
||||
'form_id' => $form->slug,
|
||||
'hookUrl' => 'https://google.com',
|
||||
])
|
||||
->assertOk();
|
||||
|
||||
assertDatabaseCount('form_integrations', 1);
|
||||
});
|
||||
|
||||
test('poll for the latest submission', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = createUserWorkspace($user);
|
||||
$form = createForm($user, $workspace, [
|
||||
'properties' => [
|
||||
[
|
||||
'id' => 'title',
|
||||
'name' => 'Name',
|
||||
'type' => 'text',
|
||||
'hidden' => false,
|
||||
'required' => true,
|
||||
'logic' => [
|
||||
'conditions' => null,
|
||||
'actions' => [],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'age',
|
||||
'name' => 'Age',
|
||||
'type' => 'number',
|
||||
'hidden' => false,
|
||||
'required' => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Create a submission for the form
|
||||
$formData = FormSubmissionDataFactory::generateSubmissionData($form);
|
||||
|
||||
$this->postJson(route('forms.answer', $form->slug), $formData)
|
||||
->assertSuccessful()
|
||||
->assertJson([
|
||||
'type' => 'success',
|
||||
'message' => 'Form submission saved.',
|
||||
]);
|
||||
|
||||
// Create a webhook integration for the form
|
||||
$integration = FormIntegration::factory()
|
||||
->for($form)
|
||||
->create([
|
||||
'data' => [
|
||||
'hook_url' => 'https://zapier.com/hook/test'
|
||||
]
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user, ['view', 'manage-integrations']);
|
||||
|
||||
// Call the poll endpoint
|
||||
$response = $this->getJson(route('zapier.webhooks.poll', ['form_id' => $form->slug]));
|
||||
|
||||
// Assert the response status is OK
|
||||
$response->assertOk();
|
||||
|
||||
// Decode the response data
|
||||
$responseData = $response->json()[0];
|
||||
$receivedData = collect($responseData['data'])->values()->pluck('value')->toArray();
|
||||
|
||||
$this->assertEmpty(array_diff(array_values($formData), $receivedData));
|
||||
});
|
||||
|
||||
test('make up a submission when polling without any submission', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = createUserWorkspace($user);
|
||||
$form = createForm($user, $workspace, [
|
||||
'properties' => [
|
||||
[
|
||||
'id' => 'title',
|
||||
'name' => 'Name',
|
||||
'type' => 'text',
|
||||
'hidden' => false,
|
||||
'required' => true,
|
||||
'logic' => [
|
||||
'conditions' => null,
|
||||
'actions' => [],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 'age',
|
||||
'name' => 'Age',
|
||||
'type' => 'number',
|
||||
'hidden' => false,
|
||||
'required' => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Create a webhook integration for the form
|
||||
$integration = FormIntegration::factory()
|
||||
->for($form)
|
||||
->create([
|
||||
'data' => [
|
||||
'hook_url' => 'https://zapier.com/hook/test'
|
||||
]
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($user, ['view', 'manage-integrations']);
|
||||
|
||||
// Call the poll endpoint
|
||||
$this->withoutExceptionHandling();
|
||||
$response = $this->getJson(route('zapier.webhooks.poll', ['form_id' => $form->slug]));
|
||||
// Assert the response status is OK
|
||||
$response->assertOk();
|
||||
|
||||
// Decode the response data
|
||||
$responseData = $response->json()[0];
|
||||
|
||||
ray($responseData);
|
||||
|
||||
ray($responseData);
|
||||
$this->assertNotEmpty($responseData['data']);
|
||||
$this->assertTrue(count($responseData['data']) == 2);
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\get;
|
||||
|
||||
test('list all forms of a given workspace', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = createUserWorkspace($user);
|
||||
|
||||
$form1 = createForm($user, $workspace, ['title' => 'First form']);
|
||||
$form2 = createForm($user, $workspace, ['title' => 'Second form']);
|
||||
|
||||
Sanctum::actingAs(
|
||||
$user,
|
||||
['list-forms']
|
||||
);
|
||||
|
||||
get(route('zapier.forms', ['workspace_id' => $workspace->id]))
|
||||
->assertOk()
|
||||
->assertJsonCount(2)
|
||||
->assertJson([
|
||||
[
|
||||
'id' => $form1->slug,
|
||||
'name' => $form1->title,
|
||||
],
|
||||
[
|
||||
'id' => $form2->slug,
|
||||
'name' => $form2->title,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('cannot list forms without a corresponding ability', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = createUserWorkspace($user);
|
||||
|
||||
$form1 = createForm($user, $workspace, ['title' => 'First form']);
|
||||
$form2 = createForm($user, $workspace, ['title' => 'Second form']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
get(route('zapier.forms', ['workspace_id' => $workspace->id]))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('cannot other users forms', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user2 = User::factory()->create();
|
||||
$workspace = createUserWorkspace($user2);
|
||||
|
||||
$form1 = createForm($user, $workspace, ['title' => 'First form']);
|
||||
$form2 = createForm($user, $workspace, ['title' => 'Second form']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
get(route('zapier.forms', ['workspace_id' => $workspace->id]))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\get;
|
||||
|
||||
test('list all workspaces of a user', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = createUserWorkspace($user);
|
||||
|
||||
$anotherUser = User::factory()->create();
|
||||
$anotherWorkspace = createUserWorkspace($anotherUser);
|
||||
|
||||
Sanctum::actingAs(
|
||||
$user,
|
||||
['list-workspaces']
|
||||
);
|
||||
|
||||
get(route('zapier.workspaces'))
|
||||
->assertOk()
|
||||
->assertJsonCount(1)
|
||||
->assertJson([
|
||||
[
|
||||
'id' => $workspace->id,
|
||||
'name' => $workspace->name,
|
||||
]
|
||||
]);
|
||||
});
|
||||
|
||||
test('cannot list workspaces without a corresponding ability', function () {
|
||||
$user = User::factory()->create();
|
||||
$workspace = createUserWorkspace($user);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
get(route('zapier.workspaces'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\get;
|
||||
|
||||
test('validate auth', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
get(route('zapier.validate'))
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
]);
|
||||
});
|
||||
|
||||
test('cannot validate auth with incorrect credentials', function () {
|
||||
get(route('zapier.validate'))
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
|
@ -9,6 +9,12 @@ class FormSubmissionDataFactory
|
|||
{
|
||||
private ?Faker\Generator $faker;
|
||||
|
||||
/**
|
||||
* If true, then format expected by answer endpoint
|
||||
* otherwise, format of answer as we store it in the FormSubmission's data
|
||||
*/
|
||||
private bool $answerFormat = true;
|
||||
|
||||
public function __construct(private Form $form)
|
||||
{
|
||||
$this->faker = Faker\Factory::create();
|
||||
|
|
@ -19,6 +25,12 @@ class FormSubmissionDataFactory
|
|||
return (new self($form))->createSubmissionData($data);
|
||||
}
|
||||
|
||||
public function asFormSubmissionData()
|
||||
{
|
||||
$this->answerFormat = false;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function createSubmissionData($mergeData = [])
|
||||
{
|
||||
$data = [];
|
||||
|
|
@ -68,9 +80,23 @@ class FormSubmissionDataFactory
|
|||
$data[$property['id']] = $value;
|
||||
});
|
||||
|
||||
if (!$this->answerFormat) {
|
||||
$data = $this->formatAsSubmissionData($data);
|
||||
}
|
||||
|
||||
return array_merge($data, $mergeData);
|
||||
}
|
||||
|
||||
private function formatAsSubmissionData($data)
|
||||
{
|
||||
collect($this->form->properties)->each(function ($property) use (&$data) {
|
||||
if ($property['type'] === 'phone_number') {
|
||||
$data[$property['id']] = '+33749119783';
|
||||
}
|
||||
});
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function generateSelectValue($property)
|
||||
{
|
||||
$values = [];
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@
|
|||
|
|
||||
*/
|
||||
|
||||
use App\Models\Forms\Form;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
uses(\Tests\TestCase::class)->in('Feature');
|
||||
|
||||
/*
|
||||
|
|
@ -38,3 +42,13 @@ expect()->extend('toBeOne', function () {
|
|||
| global functions to help you to reduce the number of lines of code in your test files.
|
||||
|
|
||||
*/
|
||||
|
||||
function createUserWorkspace(User $user): Workspace
|
||||
{
|
||||
return test()->createUserWorkspace($user);
|
||||
}
|
||||
|
||||
function createForm(User $user, Workspace $workspace, array $data = []): Form
|
||||
{
|
||||
return test()->createForm($user, $workspace, $data);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue