diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 156209320..000000000 --- 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 6d24a2bd4..deaff2d07 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 ec84b0702..087db453d 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 000000000..5b6affdf1 --- /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 830207d67..966ea02b7 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -48,7 +48,7 @@ protected function unauthenticated($request, AuthenticationException $exception) { 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 505d2d966..a5cd5aaaf 100644 --- a/app/Http/Controllers/Auth/AppSumoAuthController.php +++ b/app/Http/Controllers/Auth/AppSumoAuthController.php @@ -28,10 +28,10 @@ public function handleCallback(Request $request) // 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 @@ private function attachLicense(License $license) { 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 62e6423a7..3481d8c64 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 75d1a90d7..dca04b18e 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 @@ public function export(string $id) 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 @@ public function submissionFile($id, $fileName) ], 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 1c624e0f1..712073efe 100644 --- a/app/Http/Controllers/SitemapController.php +++ b/app/Http/Controllers/SitemapController.php @@ -9,62 +9,24 @@ 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 50456f13c..000000000 --- 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 4205b3668..8cc0213aa 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -45,8 +45,8 @@ public function checkout($pricing, $plan, $trial = null) $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 556e49c18..1ae7aebc1 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -4,7 +4,6 @@ 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 513b77e8a..b481f9f08 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 7fc10ef4d..8cbc86b75 100644 --- a/app/Http/Middleware/AuthenticateJWT.php +++ b/app/Http/Middleware/AuthenticateJWT.php @@ -8,6 +8,7 @@ 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 @@ public function handle(Request $request, Closure $next) // 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 9553f27a2..31f4b0e8c 100644 --- a/app/Http/Middleware/CustomDomainRestriction.php +++ b/app/Http/Middleware/CustomDomainRestriction.php @@ -11,7 +11,7 @@ class CustomDomainRestriction { - const CUSTOM_DOMAIN_HEADER = "User-Custom-Domain"; + const CUSTOM_DOMAIN_HEADER = "x-custom-domain"; /** * Handle an incoming request. @@ -27,7 +27,8 @@ public function handle(Request $request, Closure $next) 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 @@ public function handle(Request $request, Closure $next) 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 e571fc4de..000000000 --- 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 6018e9e7e..ad2b93268 100644 --- a/app/Http/Resources/FormSubmissionResource.php +++ b/app/Http/Resources/FormSubmissionResource.php @@ -50,7 +50,11 @@ private function generateFileLinks() 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 8361f0419..198bb2c59 100644 --- a/app/Jobs/Form/StoreFormSubmissionJob.php +++ b/app/Jobs/Form/StoreFormSubmissionJob.php @@ -164,14 +164,14 @@ private function storeFile(?string $value) 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 edfea0d1d..53c403953 100644 --- a/app/Models/Forms/Form.php +++ b/app/Models/Forms/Form.php @@ -157,12 +157,12 @@ public function getShareUrlAttribute() 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 59c42e3e1..45fa38fcb 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 9edd12f3a..526053138 100644 --- a/app/Notifications/ResetPassword.php +++ b/app/Notifications/ResetPassword.php @@ -17,7 +17,7 @@ public function toMail($notifiable) { 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 f50a93faa..dcb3970ee 100644 --- a/app/Notifications/Subscription/FailedPaymentNotification.php +++ b/app/Notifications/Subscription/FailedPaymentNotification.php @@ -36,6 +36,6 @@ public function toMail($notifiable) ->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 faee4fa37..989bcffe3 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 @@ public function boot() $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 559de29cf..f7529f5d8 100644 --- a/app/Service/Forms/Webhooks/DiscordHandler.php +++ b/app/Service/Forms/Webhooks/DiscordHandler.php @@ -27,7 +27,7 @@ protected function getWebhookData(): array $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 5b2faf607..f237efaba 100644 --- a/app/Service/Forms/Webhooks/SlackHandler.php +++ b/app/Service/Forms/Webhooks/SlackHandler.php @@ -27,7 +27,7 @@ protected function getWebhookData(): array $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 000000000..e287b476e --- /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 000000000..fa37fd415 --- /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 000000000..e276856f8 --- /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 000000000..3bfa0e8f1 --- /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 000000000..44afd998e --- /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 0b041e6f8..4d904dc70 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 ea162cfef..5eef87806 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 000000000..3859127bf --- /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 000000000..6929602e4 --- /dev/null +++ b/client/components/forms/TextAreaInput.vue @@ -0,0 +1,56 @@ +