diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 15620932..00000000 --- a/.eslintrc.js +++ /dev/null @@ -1,16 +0,0 @@ -module.exports = { - root: true, - parser: 'vue-eslint-parser', - parserOptions: { - parser: '@babel/eslint-parser', - ecmaVersion: 2018, - sourceType: 'module' - }, - extends: [ - 'plugin:vue/recommended', - 'standard' - ], - rules: { - 'vue/max-attributes-per-line': 'off' - } -} diff --git a/.github/workflows/laravel.yml b/.github/workflows/laravel.yml index 6d24a2bd..deaff2d0 100644 --- a/.github/workflows/laravel.yml +++ b/.github/workflows/laravel.yml @@ -86,13 +86,6 @@ jobs: restore-keys: | ${{ runner.os }}-composer- - - uses: actions/cache@v2 - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -128,6 +121,31 @@ jobs: path: storage/logs/laravel.log retention-days: 3 + build-nuxt-app: + runs-on: ubuntu-latest + name: Build the Nuxt app + defaults: + run: + working-directory: ./client + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v1 + with: + node-version: '20' + + - uses: actions/cache@v2 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Prepare the environment + run: cp .env.example .env + - name: Install npm dependencies run: npm install --no-audit --no-progress --silent diff --git a/README.md b/README.md index ec84b070..087db453 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ It takes 1 minute to try out the builder for free. You'll have high availability ### Docker installation 🐳 -There's a `Dockerfile` for building a self-contained docker image including databases, webservers etc. +> ⚠️ **Warning**: the Docker setup is currently not working as we're migrating the front-end to Nuxt. [Track progress here](https://github.com/JhumanJ/OpnForm/issues/283). This can be built and run locally but is also hosted publicly on docker hub at `jhumanj/opnform` and is generally best run directly from there. @@ -154,8 +154,11 @@ First, let's work with the codebase and its dependencies. # Get the code! git clone git@github.com:JhumanJ/OpnForm.git && cd OpnForm -# Install PHP and JS dependencies -composer install && npm install +# Install PHP dependencies +composer install + + # Install JS dependencies +cd client && npm install # Compile assets (see the scripts section in package.json) npm run dev # or build @@ -186,7 +189,8 @@ Now, create an S3 bucket (or equivalent). Create an IAM user with access to this OpnForm is a standard web application built with: - [Laravel](https://laravel.com/) PHP framework -- [Vue.js](https://vuejs.org/) front-end framework +- [NuxtJs](https://nuxt.com/) Front-end SSR framework +- [Vue.js 3](https://vuejs.org/) Front-end framework - [TailwindCSS](https://tailwindcss.com/) ## Contribute diff --git a/amplify.yml b/amplify.yml new file mode 100644 index 00000000..5b6affdf --- /dev/null +++ b/amplify.yml @@ -0,0 +1,17 @@ +version: 1 +frontend: + phases: + preBuild: + commands: + - cd client + - npm ci + build: + commands: + - npm run build + artifacts: + baseDirectory: client/.amplify-hosting + files: + - '**/*' + cache: + paths: + - client/node_modules/**/* diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 830207d6..966ea02b 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -48,7 +48,7 @@ class Handler extends ExceptionHandler { return $request->expectsJson() ? response()->json(['message' => $exception->getMessage()], 401) - : redirect()->guest(url('/login')); + : redirect(front_url('login')); } public function report(Throwable $exception) diff --git a/app/Http/Controllers/Auth/AppSumoAuthController.php b/app/Http/Controllers/Auth/AppSumoAuthController.php index 505d2d96..a5cd5aaa 100644 --- a/app/Http/Controllers/Auth/AppSumoAuthController.php +++ b/app/Http/Controllers/Auth/AppSumoAuthController.php @@ -28,10 +28,10 @@ class AppSumoAuthController extends Controller // otherwise start login flow by passing the encrypted license key id if (is_null($license->user_id)) { - return redirect(url('/register?appsumo_license='.encrypt($license->id))); + return redirect(front_url('/register?appsumo_license='.encrypt($license->id))); } - return redirect(url('/register?appsumo_error=1')); + return redirect(front_url('/register?appsumo_error=1')); } private function retrieveAccessToken(string $requestCode): string @@ -82,11 +82,11 @@ class AppSumoAuthController extends Controller if (is_null($license->user_id)) { $license->user_id = Auth::id(); $license->save(); - return redirect(url('/home?appsumo_connect=1')); + return redirect(front_url('/home?appsumo_connect=1')); } // Licensed already attached - return redirect(url('/home?appsumo_error=1')); + return redirect(front_url('/home?appsumo_error=1')); } /** diff --git a/app/Http/Controllers/Content/FileUploadController.php b/app/Http/Controllers/Content/FileUploadController.php index 62e6423a..3481d8c6 100644 --- a/app/Http/Controllers/Content/FileUploadController.php +++ b/app/Http/Controllers/Content/FileUploadController.php @@ -17,6 +17,7 @@ class FileUploadController extends Controller */ public function upload(Request $request) { + $request->validate(['file' => 'required|file']); $uuid = (string) Str::uuid(); $path = $request->file('file')->storeAs(PublicFormController::TMP_FILE_UPLOAD_PATH, $uuid); diff --git a/app/Http/Controllers/Forms/FormSubmissionController.php b/app/Http/Controllers/Forms/FormSubmissionController.php index 75d1a90d..dca04b18 100644 --- a/app/Http/Controllers/Forms/FormSubmissionController.php +++ b/app/Http/Controllers/Forms/FormSubmissionController.php @@ -15,7 +15,8 @@ class FormSubmissionController extends Controller { public function __construct() { - $this->middleware('auth'); + $this->middleware('auth', ['except' => ['submissionFile']]); + $this->middleware('signed', ['only' => ['submissionFile']]); } public function submissions(string $id) @@ -51,9 +52,6 @@ class FormSubmissionController extends Controller public function submissionFile($id, $fileName) { - $form = Form::findOrFail((int) $id); - $this->authorize('view', $form); - $fileName = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $id).'/' .urldecode($fileName); @@ -63,8 +61,12 @@ class FormSubmissionController extends Controller ], 404); } + if (config('filesystems.default') !== 's3') { + return response()->file(Storage::path($fileName)); + } + return redirect( - Storage::temporaryUrl($fileName, now()->addMinute()) + Storage::temporaryUrl($fileName, now()->addMinute()) ); } } diff --git a/app/Http/Controllers/SitemapController.php b/app/Http/Controllers/SitemapController.php index 1c624e0f..712073ef 100644 --- a/app/Http/Controllers/SitemapController.php +++ b/app/Http/Controllers/SitemapController.php @@ -9,62 +9,24 @@ use App\Models\Template; class SitemapController extends Controller { - /** - * Contains route name and the associated priority - * - * @var array - */ - protected $urls = [ - ['/', 1], - ['/pricing', 0.9], - ['/privacy-policy', 0.5], - ['/terms-conditions', 0.5], - ['/login', 0.4], - ['/register', 0.4], - ['/password/reset', 0.3], - ['/form-templates', 0.9], - ]; - public function getSitemap(Request $request) + public function index(Request $request) { - $sitemap = Sitemap::create(); - foreach ($this->urls as $url) { - $sitemap->add($this->createUrl($url[0], $url[1])); - } - $this->addTemplatesUrls($sitemap); - $this->addTemplatesTypesUrls($sitemap); - $this->addTemplatesIndustriesUrls($sitemap); - - return $sitemap->toResponse($request); + return [ + ...$this->getTemplatesUrls() + ]; } - private function createUrl($url, $priority, $frequency = 'daily') + private function getTemplatesUrls() { - return Url::create($url)->setPriority($priority)->setChangeFrequency($frequency); - } - - private function addTemplatesUrls(Sitemap $sitemap) - { - Template::where('publicly_listed', true)->chunk(100, function ($templates) use ($sitemap) { + $urls = []; + Template::where('publicly_listed', true)->chunk(100, function ($templates) use (&$urls) { foreach ($templates as $template) { - $sitemap->add($this->createUrl('/form-templates/' . $template->slug, 0.8)); + $urls[] = [ + 'loc' => '/templates/' . $template->slug + ]; } }); - } - - private function addTemplatesTypesUrls(Sitemap $sitemap) - { - $types = json_decode(file_get_contents(resource_path('data/forms/templates/types.json')), true); - foreach ($types as $type) { - $sitemap->add($this->createUrl('/form-templates/types/' . $type['slug'], 0.7)); - } - } - - private function addTemplatesIndustriesUrls(Sitemap $sitemap) - { - $industries = json_decode(file_get_contents(resource_path('data/forms/templates/industries.json')), true); - foreach ($industries as $industry) { - $sitemap->add($this->createUrl('/form-templates/industries/' . $industry['slug'], 0.7)); - } + return $urls; } } diff --git a/app/Http/Controllers/SpaController.php b/app/Http/Controllers/SpaController.php deleted file mode 100644 index 50456f13..00000000 --- a/app/Http/Controllers/SpaController.php +++ /dev/null @@ -1,18 +0,0 @@ - (new SeoMetaResolver($request))->getMetas(), - ]); - } -} diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index 4205b366..8cc0213a 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -45,8 +45,8 @@ class SubscriptionController extends Controller $checkout = $checkoutBuilder ->collectTaxIds() ->checkout([ - 'success_url' => url('/subscriptions/success'), - 'cancel_url' => url('/subscriptions/error'), + 'success_url' => front_url('/subscriptions/success'), + 'cancel_url' => front_url('/subscriptions/error'), 'billing_address_collection' => 'required', 'customer_update' => [ 'address' => 'auto', diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 556e49c1..1ae7aebc 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -4,7 +4,6 @@ namespace App\Http; use App\Http\Middleware\AuthenticateJWT; use App\Http\Middleware\CustomDomainRestriction; -use App\Http\Middleware\EmbeddableForms; use App\Http\Middleware\IsAdmin; use App\Http\Middleware\IsNotSubscribed; use App\Http\Middleware\IsSubscribed; @@ -20,9 +19,9 @@ class Kernel extends HttpKernel * @var array */ protected $middleware = [ - // \App\Http\Middleware\TrustHosts::class, +// \App\Http\Middleware\TrustHosts::class, \App\Http\Middleware\TrustProxies::class, - \Fruitcake\Cors\HandleCors::class, + \Illuminate\Http\Middleware\HandleCors::class, \App\Http\Middleware\PreventRequestsDuringMaintenance::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \App\Http\Middleware\TrimStrings::class, @@ -46,16 +45,14 @@ class Kernel extends HttpKernel \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\VerifyCsrfToken::class, \Illuminate\Routing\Middleware\SubstituteBindings::class, - EmbeddableForms::class ], 'spa' => [ \Illuminate\Routing\Middleware\SubstituteBindings::class, - EmbeddableForms::class ], 'api' => [ - 'throttle:60,1', + 'throttle:100,1', \Illuminate\Routing\Middleware\SubstituteBindings::class, \App\Http\Middleware\EncryptCookies::class, \Illuminate\Session\Middleware\StartSession::class, diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index 513b77e8..b481f9f0 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -15,7 +15,7 @@ class Authenticate extends Middleware protected function redirectTo($request) { if (! $request->expectsJson()) { - return redirect('/login'); + return redirect(front_url('login')); } } } diff --git a/app/Http/Middleware/AuthenticateJWT.php b/app/Http/Middleware/AuthenticateJWT.php index 7fc10ef4..8cbc86b7 100644 --- a/app/Http/Middleware/AuthenticateJWT.php +++ b/app/Http/Middleware/AuthenticateJWT.php @@ -8,6 +8,7 @@ use Tymon\JWTAuth\Exceptions\JWTException; class AuthenticateJWT { + const API_SERVER_SECRET_HEADER_NAME = 'x-api-secret'; /** * Verifies the JWT token and validates the IP and User Agent @@ -24,6 +25,13 @@ class AuthenticateJWT // Validate IP and User Agent if ($payload) { + if ($frontApiSecret = $request->header(self::API_SERVER_SECRET_HEADER_NAME)) { + // If it's a trusted SSR request, skip the rest + if ($frontApiSecret === config('app.front_api_secret')) { + return $next($request); + } + } + $error = null; if (!\Hash::check($request->ip(), $payload->get('ip'))) { $error = 'Origin IP is invalid'; diff --git a/app/Http/Middleware/CustomDomainRestriction.php b/app/Http/Middleware/CustomDomainRestriction.php index 9553f27a..31f4b0e8 100644 --- a/app/Http/Middleware/CustomDomainRestriction.php +++ b/app/Http/Middleware/CustomDomainRestriction.php @@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Builder; class CustomDomainRestriction { - const CUSTOM_DOMAIN_HEADER = "User-Custom-Domain"; + const CUSTOM_DOMAIN_HEADER = "x-custom-domain"; /** * Handle an incoming request. @@ -27,7 +27,8 @@ class CustomDomainRestriction return response()->json([ 'success' => false, 'message' => 'Invalid domain', - ], 400); + 'error' => 'invalid_domain', + ], 420); } // Check if domain is different from current domain @@ -41,7 +42,8 @@ class CustomDomainRestriction return response()->json([ 'success' => false, 'message' => 'Unknown domain', - ], 400); + 'error' => 'invalid_domain', + ], 420); } Workspace::addGlobalScope('domain-restricted', function (Builder $builder) use ($workspace) { diff --git a/app/Http/Middleware/EmbeddableForms.php b/app/Http/Middleware/EmbeddableForms.php deleted file mode 100644 index e571fc4d..00000000 --- a/app/Http/Middleware/EmbeddableForms.php +++ /dev/null @@ -1,36 +0,0 @@ -expectsJson() || $request->wantsJson()) { - return $next($request); - } - - $response = $next($request); - - if (!str_starts_with($request->url(), url('/forms/'))) { - if ($response instanceof Response) { - $response->header('X-Frame-Options', 'SAMEORIGIN'); - } elseif ($response instanceof \Symfony\Component\HttpFoundation\Response) { - $response->headers->set('X-Frame-Options', 'SAMEORIGIN'); - } - } - - return $response; - } -} diff --git a/app/Http/Resources/FormSubmissionResource.php b/app/Http/Resources/FormSubmissionResource.php index 6018e9e7..ad2b9326 100644 --- a/app/Http/Resources/FormSubmissionResource.php +++ b/app/Http/Resources/FormSubmissionResource.php @@ -50,7 +50,11 @@ class FormSubmissionResource extends JsonResource return $file !== null && $file; })->map(function ($file) { return [ - 'file_url' => route('open.forms.submissions.file', [$this->form_id, $file]), + 'file_url' => \URL::signedRoute( + 'open.forms.submissions.file', + [$this->form_id, $file], + now()->addMinutes(10) + ), 'file_name' => $file, ]; }); diff --git a/app/Jobs/Form/StoreFormSubmissionJob.php b/app/Jobs/Form/StoreFormSubmissionJob.php index 8361f041..198bb2c5 100644 --- a/app/Jobs/Form/StoreFormSubmissionJob.php +++ b/app/Jobs/Form/StoreFormSubmissionJob.php @@ -164,14 +164,14 @@ class StoreFormSubmissionJob implements ShouldQueue return null; } - if(filter_var($value, FILTER_VALIDATE_URL) !== FALSE && str_contains($value, parse_url(config('app.url'))['host'])) { // In case of prefill we have full url so convert to s3 + if(filter_var($value, FILTER_VALIDATE_URL) !== false && str_contains($value, parse_url(config('app.url'))['host'])) { // In case of prefill we have full url so convert to s3 $fileName = basename($value); $path = FormController::ASSETS_UPLOAD_PATH . '/' . $fileName; $newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id); Storage::move($path, $newPath.'/'.$fileName); return $fileName; } - + if($this->isSkipForUpload($value)) { return $value; } diff --git a/app/Models/Forms/Form.php b/app/Models/Forms/Form.php index edfea0d1..53c40395 100644 --- a/app/Models/Forms/Form.php +++ b/app/Models/Forms/Form.php @@ -157,12 +157,12 @@ class Form extends Model implements CachableAttributes if ($this->custom_domain) { return 'https://' . $this->custom_domain . '/forms/' . $this->slug; } - return url('/forms/' . $this->slug); + return front_url('/forms/' . $this->slug); } public function getEditUrlAttribute() { - return url('/forms/' . $this->slug . '/show'); + return front_url('/forms/' . $this->slug . '/show'); } public function getSubmissionsCountAttribute() diff --git a/app/Models/Template.php b/app/Models/Template.php index 59c42e3e..45fa38fc 100644 --- a/app/Models/Template.php +++ b/app/Models/Template.php @@ -48,7 +48,7 @@ class Template extends Model public function getShareUrlAttribute() { - return url('/form-templates/'.$this->slug); + return front_url('/form-templates/'.$this->slug); } public function setDescriptionAttribute($value) diff --git a/app/Notifications/ResetPassword.php b/app/Notifications/ResetPassword.php index 9edd12f3..52605313 100644 --- a/app/Notifications/ResetPassword.php +++ b/app/Notifications/ResetPassword.php @@ -17,7 +17,7 @@ class ResetPassword extends Notification { return (new MailMessage) ->line('You are receiving this email because we received a password reset request for your account.') - ->action('Reset Password', url('password/reset/'.$this->token).'?email='.urlencode($notifiable->email)) + ->action('Reset Password', front_url('password/reset/'.$this->token).'?email='.urlencode($notifiable->email)) ->line('If you did not request a password reset, no further action is required.'); } } diff --git a/app/Notifications/Subscription/FailedPaymentNotification.php b/app/Notifications/Subscription/FailedPaymentNotification.php index f50a93fa..dcb3970e 100644 --- a/app/Notifications/Subscription/FailedPaymentNotification.php +++ b/app/Notifications/Subscription/FailedPaymentNotification.php @@ -36,6 +36,6 @@ class FailedPaymentNotification extends Notification implements ShouldQueue ->line(__('Please go to OpenForm, click on your name on the top right corner, and click on "Billing". You will then be able to update your card details. To avoid any service disruption, you can reply to this email whenever you updated your card details, and we\'ll manually attempt to charge your card.')) - ->action(__('Go to OpenForm'), url('/')); + ->action(__('Go to OpenForm'), front_url('/')); } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index faee4fa3..989bcffe 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -19,15 +19,6 @@ class RouteServiceProvider extends ServiceProvider */ public const HOME = '/home'; - /** - * The controller namespace for the application. - * - * When present, controller route declarations will automatically be prefixed with this namespace. - * - * @var string|null - */ - // protected $namespace = 'App\\Http\\Controllers'; - /** * Define your route model bindings, pattern filters, etc. * @@ -39,19 +30,9 @@ class RouteServiceProvider extends ServiceProvider $this->registerGlobalRouteParamConstraints(); $this->routes(function () { - - Route::prefix('api') - ->middleware('api') + Route::middleware('api') ->namespace($this->namespace) ->group(base_path('routes/api.php')); - - Route::middleware('web') - ->namespace($this->namespace) - ->group(base_path('routes/web.php')); - - Route::middleware('spa') - ->namespace($this->namespace) - ->group(base_path('routes/spa.php')); }); } diff --git a/app/Service/Forms/Webhooks/DiscordHandler.php b/app/Service/Forms/Webhooks/DiscordHandler.php index 559de29c..f7529f5d 100644 --- a/app/Service/Forms/Webhooks/DiscordHandler.php +++ b/app/Service/Forms/Webhooks/DiscordHandler.php @@ -27,7 +27,7 @@ class DiscordHandler extends AbstractWebhookHandler $externalLinks[] = '[**🔗 Open Form**](' . $this->form->share_url . ')'; } if(Arr::get($settings, 'link_edit_form', true)){ - $editFormURL = url('forms/' . $this->form->slug . '/show'); + $editFormURL = front_url('forms/' . $this->form->slug . '/show'); $externalLinks[] = '[**✍️ Edit Form**](' . $editFormURL . ')'; } if (Arr::get($settings, 'link_edit_submission', true) && $this->form->editable_submissions) { diff --git a/app/Service/Forms/Webhooks/SlackHandler.php b/app/Service/Forms/Webhooks/SlackHandler.php index 5b2faf60..f237efab 100644 --- a/app/Service/Forms/Webhooks/SlackHandler.php +++ b/app/Service/Forms/Webhooks/SlackHandler.php @@ -27,7 +27,7 @@ class SlackHandler extends AbstractWebhookHandler $externalLinks[] = '*<' . $this->form->share_url . '|🔗 Open Form>*'; } if(Arr::get($settings, 'link_edit_form', true)){ - $editFormURL = url('forms/' . $this->form->slug . '/show'); + $editFormURL = front_url('forms/' . $this->form->slug . '/show'); $externalLinks[] = '*<' . $editFormURL . '|✍️ Edit Form>*'; } if (Arr::get($settings, 'link_edit_submission', true) && $this->form->editable_submissions) { diff --git a/app/helpers.php b/app/helpers.php new file mode 100644 index 00000000..e287b476 --- /dev/null +++ b/app/helpers.php @@ -0,0 +1,11 @@ + +
+ + + + + + + + + + + + +
+ + + diff --git a/client/components/forms/CheckboxInput.vue b/client/components/forms/CheckboxInput.vue new file mode 100644 index 00000000..fa37fd41 --- /dev/null +++ b/client/components/forms/CheckboxInput.vue @@ -0,0 +1,46 @@ + + + diff --git a/client/components/forms/CodeInput.client.vue b/client/components/forms/CodeInput.client.vue new file mode 100644 index 00000000..e276856f --- /dev/null +++ b/client/components/forms/CodeInput.client.vue @@ -0,0 +1,63 @@ + + + diff --git a/client/components/forms/ColorInput.vue b/client/components/forms/ColorInput.vue new file mode 100644 index 00000000..3bfa0e8f --- /dev/null +++ b/client/components/forms/ColorInput.vue @@ -0,0 +1,45 @@ + + + diff --git a/client/components/forms/DateInput.vue b/client/components/forms/DateInput.vue new file mode 100644 index 00000000..44afd998 --- /dev/null +++ b/client/components/forms/DateInput.vue @@ -0,0 +1,187 @@ + + + diff --git a/resources/js/components/forms/FileInput.vue b/client/components/forms/FileInput.vue similarity index 82% rename from resources/js/components/forms/FileInput.vue rename to client/components/forms/FileInput.vue index 0b041e6f..4d904dc7 100644 --- a/resources/js/components/forms/FileInput.vue +++ b/client/components/forms/FileInput.vue @@ -1,21 +1,10 @@ - - - - - + + + diff --git a/resources/js/components/forms/ScaleInput.vue b/client/components/forms/ScaleInput.vue similarity index 68% rename from resources/js/components/forms/ScaleInput.vue rename to client/components/forms/ScaleInput.vue index ea162cfe..5eef8780 100644 --- a/resources/js/components/forms/ScaleInput.vue +++ b/client/components/forms/ScaleInput.vue @@ -1,14 +1,10 @@ \ No newline at end of file + diff --git a/client/components/forms/SignatureInput.vue b/client/components/forms/SignatureInput.vue new file mode 100644 index 00000000..3859127b --- /dev/null +++ b/client/components/forms/SignatureInput.vue @@ -0,0 +1,62 @@ + + + diff --git a/client/components/forms/TextAreaInput.vue b/client/components/forms/TextAreaInput.vue new file mode 100644 index 00000000..6929602e --- /dev/null +++ b/client/components/forms/TextAreaInput.vue @@ -0,0 +1,56 @@ +