diff --git a/app/controllers/account_logo_controller.rb b/app/controllers/account_logo_controller.rb new file mode 100644 index 00000000..190f4a41 --- /dev/null +++ b/app/controllers/account_logo_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class AccountLogoController < ApplicationController + before_action :load_account + authorize_resource :account + + def create + file = params[:logo] + + return redirect_to settings_personalization_path, alert: I18n.t('unable_to_upload_logo') if file.blank? + + current_account.logo.attach(file) + + redirect_to settings_personalization_path, notice: I18n.t('logo_has_been_saved') + end + + def destroy + current_account.logo.purge + + redirect_to settings_personalization_path, notice: I18n.t('logo_has_been_removed') + end + + private + + def load_account + @account = current_account + end +end diff --git a/app/javascript/submission_form/signature_step.vue b/app/javascript/submission_form/signature_step.vue index a607df61..4a089356 100644 --- a/app/javascript/submission_form/signature_step.vue +++ b/app/javascript/submission_form/signature_step.vue @@ -378,14 +378,14 @@ import { v4 } from 'uuid' const SIGNATURE_FONTS = { 'Dancing Script': 'DancingScript-Regular.otf', 'Great Vibes': 'GreatVibes-Regular.ttf', - 'Pacifico': 'Pacifico-Regular.ttf', - 'Caveat': 'Caveat-Regular.ttf', + Pacifico: 'Pacifico-Regular.ttf', + Caveat: 'Caveat-Regular.ttf', 'Homemade Apple': 'HomemadeApple-Regular.ttf', 'Mrs Saint Delafield': 'MrsSaintDelafield-Regular.ttf', 'Shadows Into Light': 'ShadowsIntoLight-Regular.ttf', 'Alex Brush': 'AlexBrush-Regular.ttf', - 'Kalam': 'Kalam-Regular.ttf', - 'Sacramento': 'Sacramento-Regular.ttf', + Kalam: 'Kalam-Regular.ttf', + Sacramento: 'Sacramento-Regular.ttf', 'Herr Von Muellerhoff': 'HerrVonMuellerhoff-Regular.ttf' } diff --git a/app/models/account.rb b/app/models/account.rb index 2ddbbdc1..b5a33e7c 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -20,6 +20,8 @@ class Account < ApplicationRecord attribute :uuid, :string, default: -> { SecureRandom.uuid } + has_one_attached :logo + has_many :users, dependent: :destroy has_many :encrypted_configs, dependent: :destroy has_many :account_configs, dependent: :destroy diff --git a/app/views/personalization_settings/_logo_form.html.erb b/app/views/personalization_settings/_logo_form.html.erb index fc6f3ac7..d4a748e0 100644 --- a/app/views/personalization_settings/_logo_form.html.erb +++ b/app/views/personalization_settings/_logo_form.html.erb @@ -1 +1,28 @@ -<%= render 'logo_placeholder' %> +
+ <% if current_account.logo.attached? %> +
+ <%= t('company_logo') %> + <%= button_to t('remove'), settings_account_logo_path, method: :delete, + class: 'btn btn-sm btn-outline btn-error', + data: { turbo_confirm: t('are_you_sure_') } %> +
+ <% elsif Docuseal.custom_logo_url.present? %> +
+ <%= t('company_logo') %> + <%= t('default') %> +
+ <% end %> + <%= form_with url: settings_account_logo_path, method: :post, multipart: true, class: 'flex items-end space-x-4' do |f| %> +
+ + <%= f.file_field :logo, accept: 'image/png,image/svg+xml,image/jpeg,image/gif', + class: 'file-input file-input-bordered w-full max-w-xs', + id: 'account_logo_input' %> +
+ + <% end %> +
diff --git a/app/views/shared/_logo.html.erb b/app/views/shared/_logo.html.erb index 57e6e0d9..12118292 100644 --- a/app/views/shared/_logo.html.erb +++ b/app/views/shared/_logo.html.erb @@ -1,4 +1,16 @@ +<% + logo_account = local_assigns[:account] || (defined?(current_account) && current_account) + custom_url = if logo_account&.logo&.attached? + url_for(logo_account.logo) + elsif Docuseal.custom_logo_url.present? + Docuseal.custom_logo_url + end +%> +<% if custom_url %> + Logo +<% else %> +<% end %> diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml index 199bdef6..b904fe75 100644 --- a/config/locales/i18n.yml +++ b/config/locales/i18n.yml @@ -351,6 +351,9 @@ en: &en unable_to_save_initials: Unable to save initials. initials_has_been_saved: Initials have been saved. initials_has_been_removed: Initials have been removed. + logo_has_been_saved: Logo has been saved. + logo_has_been_removed: Logo has been removed. + unable_to_upload_logo: Unable to upload logo. change_password: Change Password two_factor_authentication: Two-Factor Authentication 2fa_is_not_configured: 2FA is not configured diff --git a/config/routes.rb b/config/routes.rb index a05955f6..a10f1386 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -193,6 +193,7 @@ Rails.application.routes.draw do resources :integration_users, only: %i[index], path: 'users/:status', controller: 'users', defaults: { status: :integration } resource :personalization, only: %i[show create], controller: 'personalization_settings' + resource :account_logo, only: %i[create destroy], controller: 'account_logo' resources :webhooks, only: %i[index show new create update destroy], controller: 'webhook_settings' do post :resend diff --git a/lib/docuseal.rb b/lib/docuseal.rb index 7bba653c..68020e29 100644 --- a/lib/docuseal.rb +++ b/lib/docuseal.rb @@ -116,6 +116,10 @@ module Docuseal end end + def custom_logo_url + ENV.fetch('CUSTOM_LOGO_URL', nil) + end + def product_name PRODUCT_NAME end diff --git a/playwright/tests/fixtures/test-logo.png b/playwright/tests/fixtures/test-logo.png new file mode 100644 index 00000000..7fe353af Binary files /dev/null and b/playwright/tests/fixtures/test-logo.png differ diff --git a/playwright/tests/v1.1.0-custom-logo.spec.ts b/playwright/tests/v1.1.0-custom-logo.spec.ts new file mode 100644 index 00000000..b419b071 --- /dev/null +++ b/playwright/tests/v1.1.0-custom-logo.spec.ts @@ -0,0 +1,116 @@ +import { test, expect } from '@playwright/test'; +import { loginAsAdmin } from './helpers/auth'; +import * as path from 'path'; + +// Phase 1.1 — Custom Logo Support. +// Upload a PNG logo, verify it appears in the navbar, +// delete it and verify the default SVG returns, +// then check CUSTOM_LOGO_URL env override behaviour. + +const FIXTURE_LOGO = path.resolve(__dirname, 'fixtures', 'test-logo.png'); + +test.describe('Custom logo', () => { + test('upload a PNG logo and verify it appears in the navbar', async ({ page }) => { + await loginAsAdmin(page); + await page.goto('/settings/personalization'); + + const fileInput = page.locator('#account_logo_input'); + await expect(fileInput).toBeVisible(); + + await fileInput.setInputFiles(FIXTURE_LOGO); + await page.getByRole('button', { name: /upload logo/i }).click(); + await page.waitForLoadState('networkidle'); + + // Verify success flash + await expect(page.locator('body')).toContainText(/logo has been saved/i); + + // Verify the logo preview appears on the personalization page + const preview = page.locator('img[data-logo-preview="true"]'); + await expect(preview).toBeVisible(); + + // Navigate to home and verify the custom logo is in the navbar + await page.goto('/'); + const navbarLogo = page.locator('img[data-custom-logo="true"]'); + await expect(navbarLogo).toBeVisible(); + + // Ensure the default SVG is NOT rendered + const defaultSvg = page.locator('a[href="/"] svg'); + await expect(defaultSvg).toHaveCount(0); + }); + + test('delete the logo and verify the default DocuSeal logo returns', async ({ page }) => { + await loginAsAdmin(page); + + // First upload a logo so we can delete it + await page.goto('/settings/personalization'); + const fileInput = page.locator('#account_logo_input'); + await fileInput.setInputFiles(FIXTURE_LOGO); + await page.getByRole('button', { name: /upload logo/i }).click(); + await page.waitForLoadState('networkidle'); + + // Confirm it was uploaded + await expect(page.locator('img[data-logo-preview="true"]')).toBeVisible(); + + // Click Remove and accept the confirmation dialog + page.on('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: /remove/i }).click(); + await page.waitForLoadState('networkidle'); + + // Verify success flash + await expect(page.locator('body')).toContainText(/logo has been removed/i); + + // Navigate to home and verify the default SVG logo is back + await page.goto('/'); + const defaultSvg = page.locator('a[href="/"] svg'); + await expect(defaultSvg.first()).toBeVisible(); + + // Ensure no custom logo img tag is present + const customLogo = page.locator('img[data-custom-logo="true"]'); + await expect(customLogo).toHaveCount(0); + }); + + test('CUSTOM_LOGO_URL env wins over default but per-account upload wins over env', async ({ page }) => { + // This test validates the fallback chain conceptually. + // Since we cannot easily set env vars on the running server, we verify: + // 1. When no logo is uploaded and no env var is set, the default SVG renders. + // 2. When a logo is uploaded, the custom img renders (already tested above). + // + // The env var behaviour is verified by checking the shared/_logo.html.erb + // template logic, but we can at least verify the default state. + + await loginAsAdmin(page); + + // Ensure no logo is attached (delete if present) + await page.goto('/settings/personalization'); + const removeBtn = page.getByRole('button', { name: /remove/i }); + if (await removeBtn.isVisible()) { + page.on('dialog', (dialog) => dialog.accept()); + await removeBtn.click(); + await page.waitForLoadState('networkidle'); + } + + // Verify default SVG renders on home page + await page.goto('/'); + const defaultSvg = page.locator('a[href="/"] svg'); + await expect(defaultSvg.first()).toBeVisible(); + + // Upload a logo — it should override any default + await page.goto('/settings/personalization'); + const fileInput = page.locator('#account_logo_input'); + await fileInput.setInputFiles(FIXTURE_LOGO); + await page.getByRole('button', { name: /upload logo/i }).click(); + await page.waitForLoadState('networkidle'); + + // Verify custom logo renders in navbar (would also win over env var) + await page.goto('/'); + const customLogo = page.locator('img[data-custom-logo="true"]'); + await expect(customLogo).toBeVisible(); + await expect(page.locator('a[href="/"] svg')).toHaveCount(0); + + // Clean up: remove the uploaded logo + await page.goto('/settings/personalization'); + page.on('dialog', (dialog) => dialog.accept()); + await page.getByRole('button', { name: /remove/i }).click(); + await page.waitForLoadState('networkidle'); + }); +});