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? %>
+
+
 %>)
+ <%= 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('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 %>
+
+<% 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');
+ });
+});