diff --git a/packages/console/src/ds-components/Uploader/FileUploader/index.module.scss b/packages/console/src/ds-components/Uploader/FileUploader/index.module.scss index 0e15e5bbe..71158830a 100644 --- a/packages/console/src/ds-components/Uploader/FileUploader/index.module.scss +++ b/packages/console/src/ds-components/Uploader/FileUploader/index.module.scss @@ -4,6 +4,9 @@ border: 1px dashed var(--color-border); border-radius: 8px; padding: _.unit(3.25); + transition-property: outline, border; + transition-timing-function: ease-in-out; + transition-duration: 0.2s; > input { display: none; @@ -18,6 +21,9 @@ width: 20px; height: 20px; color: var(--color-text-secondary); + transition-property: color; + transition-timing-function: ease-in-out; + transition-duration: 0.2s; } .uploadingIcon { @@ -60,4 +66,3 @@ border-color: var(--color-error); } } - diff --git a/packages/console/src/pages/OrganizationDetails/Settings/Branding.tsx b/packages/console/src/pages/OrganizationDetails/Settings/Branding.tsx new file mode 100644 index 000000000..75c8ca347 --- /dev/null +++ b/packages/console/src/pages/OrganizationDetails/Settings/Branding.tsx @@ -0,0 +1,71 @@ +import { Theme } from '@logto/schemas'; +import { noop } from '@silverhand/essentials'; +import { Controller, type UseFormReturn } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +import FormCard from '@/components/FormCard'; +import ColorPicker from '@/ds-components/ColorPicker'; +import FormField from '@/ds-components/FormField'; +import ImageUploader from '@/ds-components/Uploader/ImageUploader'; + +import * as styles from './index.module.scss'; +import { type FormData } from './utils'; + +type Props = { + readonly form: UseFormReturn; +}; + +function Branding({ form }: Props) { + const { + control, + formState: { errors }, + setError, + clearErrors, + watch, + } = form; + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + return ( + +
+ {Object.values(Theme).map((theme) => ( +
+ + ( + + )} + /> + + + ( + { + onChange(''); + }} + /> + )} + /> + +
+ ))} +
+
+ ); +} + +export default Branding; diff --git a/packages/console/src/pages/OrganizationDetails/Settings/index.module.scss b/packages/console/src/pages/OrganizationDetails/Settings/index.module.scss index e3064f255..15444cf5c 100644 --- a/packages/console/src/pages/OrganizationDetails/Settings/index.module.scss +++ b/packages/console/src/pages/OrganizationDetails/Settings/index.module.scss @@ -22,6 +22,12 @@ gap: _.unit(3); } +.branding { + section + section { + margin-top: _.unit(6); + } +} + .mfaWarning { margin-top: _.unit(3); } diff --git a/packages/console/src/pages/OrganizationDetails/Settings/index.tsx b/packages/console/src/pages/OrganizationDetails/Settings/index.tsx index e324157f4..a3e6b2337 100644 --- a/packages/console/src/pages/OrganizationDetails/Settings/index.tsx +++ b/packages/console/src/pages/OrganizationDetails/Settings/index.tsx @@ -20,6 +20,7 @@ import { trySubmitSafe } from '@/utils/form'; import { type OrganizationDetailsOutletContext } from '../types'; +import Branding from './Branding'; import JitSettings from './JitSettings'; import * as styles from './index.module.scss'; import { assembleData, isJsonObject, normalizeData, type FormData } from './utils'; @@ -136,6 +137,7 @@ function Settings() { )} + diff --git a/packages/phrases/src/locales/en/translation/admin-console/organization-details.ts b/packages/phrases/src/locales/en/translation/admin-console/organization-details.ts index e15c948df..57ea3a211 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/organization-details.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/organization-details.ts @@ -38,6 +38,20 @@ const organization_details = { custom_data_tip: 'Custom data is a JSON object that can be used to store additional data associated with the organization.', invalid_json_object: 'Invalid JSON object.', + branding: { + title: 'Branding', + description: + 'Customize the branding of the organization. The branding can be used in the sign-in experience or for your own reference.', + light: { + primary_color: 'Primary color', + logo: 'Organization logo', + }, + dark: { + primary_color: 'Primary color (dark)', + logo: 'Organization logo (dark)', + }, + logo_upload_description: 'Click or drop an image to upload', + }, jit: { title: 'Just-in-time provisioning', description: diff --git a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/index.ts b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/index.ts index cdc6e007f..d9fdeded9 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/index.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/index.ts @@ -23,7 +23,7 @@ const sign_in_exp = { color: { title: 'COLOR', primary_color: 'Brand color', - dark_primary_color: 'Brand color (Dark)', + dark_primary_color: 'Brand color (dark)', dark_mode: 'Enable dark mode', dark_mode_description: 'Your app will have an auto-generated dark mode theme based on your brand color and Logto algorithm. You are free to customize.', @@ -36,7 +36,7 @@ const sign_in_exp = { favicon: 'Favicon', logo_image_url: 'App logo image URL', logo_image_url_placeholder: 'https://your.cdn.domain/logo.png', - dark_logo_image_url: 'App logo image URL (Dark)', + dark_logo_image_url: 'App logo image URL (dark)', dark_logo_image_url_placeholder: 'https://your.cdn.domain/logo-dark.png', logo_image: 'App logo', dark_logo_image: 'App logo (Dark)', diff --git a/packages/schemas/alterations/next-1720253939-add-organization-branding.ts b/packages/schemas/alterations/next-1720253939-add-organization-branding.ts new file mode 100644 index 000000000..974edec5f --- /dev/null +++ b/packages/schemas/alterations/next-1720253939-add-organization-branding.ts @@ -0,0 +1,18 @@ +import { sql } from '@silverhand/slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + alter table organizations add column branding jsonb not null default '{}'::jsonb; + `); + }, + down: async (pool) => { + await pool.query(sql` + alter table organizations drop column branding; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/src/foundations/jsonb-types/index.ts b/packages/schemas/src/foundations/jsonb-types/index.ts index 601855d1f..89318469e 100644 --- a/packages/schemas/src/foundations/jsonb-types/index.ts +++ b/packages/schemas/src/foundations/jsonb-types/index.ts @@ -8,6 +8,7 @@ export * from './sentinel.js'; export * from './users.js'; export * from './sso-connector.js'; export * from './applications.js'; +export * from './organization.js'; export { configurableConnectorMetadataGuard, diff --git a/packages/schemas/src/foundations/jsonb-types/organization.ts b/packages/schemas/src/foundations/jsonb-types/organization.ts new file mode 100644 index 000000000..f031a516e --- /dev/null +++ b/packages/schemas/src/foundations/jsonb-types/organization.ts @@ -0,0 +1,43 @@ +import { hexColorRegEx } from '@logto/core-kit'; +import { z } from 'zod'; + +import { type Theme } from '../../types/theme.js'; +import { type ToZodObject } from '../../utils/zod.js'; + +/** Organization branding data for a specific theme. */ +export type OrganizationBrandingTheme = Partial<{ + logoUrl: string; + /** + * The primary color of the organization. Should be a hex color. + * + * @example + * '#ff0000' + * '#f00' + */ + primaryColor: string; +}>; + +/** Zod representation of {@link OrganizationBrandingTheme}. */ +export const organizationBrandingThemeGuard = z + .object({ + logoUrl: z.string().url(), + primaryColor: z.string().regex(hexColorRegEx), + }) + .partial() satisfies ToZodObject; + +/** Organization branding data for all themes. */ +export type OrganizationBranding = Partial< + Record & { + /** URL to the favicon of the organization. */ + faviconUrl: string; + } +>; + +/** Zod representation of {@link OrganizationBranding}. */ +export const organizationBrandingGuard = z + .object({ + light: organizationBrandingThemeGuard, + dark: organizationBrandingThemeGuard, + faviconUrl: z.string().url(), + }) + .partial() satisfies ToZodObject; diff --git a/packages/schemas/tables/application_sign_in_experiences.sql b/packages/schemas/tables/application_sign_in_experiences.sql index 0eedea00b..b11244314 100644 --- a/packages/schemas/tables/application_sign_in_experiences.sql +++ b/packages/schemas/tables/application_sign_in_experiences.sql @@ -10,6 +10,5 @@ create table application_sign_in_experiences ( terms_of_use_url varchar(2048), privacy_policy_url varchar(2048), display_name varchar(256), - primary key (tenant_id, application_id) ); diff --git a/packages/schemas/tables/organizations.sql b/packages/schemas/tables/organizations.sql index 1cde09f08..bc12f8722 100644 --- a/packages/schemas/tables/organizations.sql +++ b/packages/schemas/tables/organizations.sql @@ -14,6 +14,8 @@ create table organizations ( custom_data jsonb /* @use JsonObject */ not null default '{}'::jsonb, /** Whether multi-factor authentication configuration is required for the members of the organization. */ is_mfa_required boolean not null default false, + /** The organization's branding configuration. */ + branding jsonb /* @use OrganizationBranding */ not null default '{}'::jsonb, /** When the organization was created. */ created_at timestamptz not null default(now()), primary key (id)