Custom login base domain using GUI whitelabel themes (#13412)

This commit is contained in:
Henrique Sato 2026-07-02 15:19:31 -03:00 committed by GitHub
parent 5c4bc486d2
commit 6f30b0d583
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 120 additions and 23 deletions

View File

@ -1408,6 +1408,7 @@ public class ApiConstants {
public static final String CSS = "css";
public static final String JSON_CONFIGURATION = "jsonconfiguration";
public static final String LOGIN_BASE_DOMAIN = "loginbasedomain";
public static final String COMMON_NAMES = "commonnames";

View File

@ -57,6 +57,10 @@ public class CreateGuiThemeCmd extends BaseCmd {
"wildcard) separated by comma that can retrieve the theme; e.g.: *acme.com,acme2.com")
private String commonNames;
@Parameter(name = ApiConstants.LOGIN_BASE_DOMAIN, type = CommandType.STRING, length = 65535, description = "The ACS domain to be used as base " +
"for the login when accessing the GUI through the common name defined in the theme. If a common name is not defined, this parameter is ignored on the GUI.")
private String loginBaseDomain;
@Parameter(name = ApiConstants.DOMAIN_IDS, type = CommandType.STRING, length = 65535, description = "A set of domain UUIDs (also known as ID for " +
"the end-user) separated by comma that can retrieve the theme.")
private String domainIds;
@ -93,6 +97,10 @@ public class CreateGuiThemeCmd extends BaseCmd {
return commonNames;
}
public String getLoginBaseDomain() {
return loginBaseDomain;
}
public String getDomainIds() {
return domainIds;
}

View File

@ -60,6 +60,10 @@ public class UpdateGuiThemeCmd extends BaseCmd {
"wildcard) separated by comma that can retrieve the theme; e.g.: *acme.com,acme2.com")
private String commonNames;
@Parameter(name = ApiConstants.LOGIN_BASE_DOMAIN, type = CommandType.STRING, length = 65535, description = "The ACS domain to be used as base for " +
"the login when accessing the GUI through the common name defined in the theme. If a common name is not defined, this parameter is ignored on the GUI.")
private String loginBaseDomain;
@Parameter(name = ApiConstants.DOMAIN_IDS, type = CommandType.STRING, length = 65535, description = "A set of domain UUIDs (also known as ID for " +
"the end-user) separated by comma that can retrieve the theme.")
private String domainIds;
@ -96,6 +100,10 @@ public class UpdateGuiThemeCmd extends BaseCmd {
return jsonConfiguration;
}
public String getLoginBaseDomain() {
return loginBaseDomain;
}
public String getCommonNames() {
return commonNames;
}

View File

@ -52,6 +52,11 @@ public class GuiThemeResponse extends BaseResponse {
@Param(description = "A set of Common Names (CN) (fixed or wildcard) separated by comma that can retrieve the theme; e.g.: *acme.com,acme2.com")
private String commonNames;
@SerializedName(ApiConstants.LOGIN_BASE_DOMAIN)
@Param(description = "The ACS domain to be used as base for the login when accessing the GUI through the common name defined in the theme. If a " +
"common name is not defined, this parameter is ignored on the GUI.")
private String loginBaseDomain;
@SerializedName(ApiConstants.DOMAIN_IDS)
@Param(description = "A set of domain UUIDs (also known as ID for the end-user) separated by comma that can retrieve the theme.")
private String domainIds;
@ -176,4 +181,12 @@ public class GuiThemeResponse extends BaseResponse {
public void setRemoved(Date removed) {
this.removed = removed;
}
public String getLoginBaseDomain() {
return loginBaseDomain;
}
public void setLoginBaseDomain(String loginBaseDomain) {
this.loginBaseDomain = loginBaseDomain;
}
}

View File

@ -44,4 +44,6 @@ public interface GuiThemeJoin extends InternalIdentity, Identity {
Date getCreated();
Date getRemoved();
String getLoginBaseDomain();
}

View File

@ -63,6 +63,9 @@ public class GuiThemeJoinVO implements GuiThemeJoin {
@Column(name = "is_public")
private boolean isPublic;
@Column(name = "login_base_domain")
private String loginBaseDomain;
@Column(name = GenericDao.CREATED_COLUMN, nullable = false)
@Temporal(value = TemporalType.TIMESTAMP)
private Date created;
@ -138,4 +141,9 @@ public class GuiThemeJoinVO implements GuiThemeJoin {
public Date getRemoved() {
return removed;
}
@Override
public String getLoginBaseDomain() {
return loginBaseDomain;
}
}

View File

@ -59,6 +59,9 @@ public class GuiThemeVO implements GuiTheme {
@Column(name = "recursive_domains")
private boolean recursiveDomains = false;
@Column(name = "login_base_domain", length = 65535)
private String loginBaseDomain;
@Column(name = GenericDao.CREATED_COLUMN, nullable = false)
@Temporal(value = TemporalType.TIMESTAMP)
private Date created;
@ -71,7 +74,8 @@ public class GuiThemeVO implements GuiTheme {
}
public GuiThemeVO(String name, String description, String css, String jsonConfiguration, boolean recursiveDomains, boolean isPublic, Date created, Date removed) {
public GuiThemeVO(String name, String description, String css, String jsonConfiguration, boolean recursiveDomains,
boolean isPublic, Date created, String loginBaseDomain, Date removed) {
this.name = name;
this.description = description;
this.css = css;
@ -79,6 +83,7 @@ public class GuiThemeVO implements GuiTheme {
this.recursiveDomains = recursiveDomains;
this.isPublic = isPublic;
this.created = created;
this.loginBaseDomain = loginBaseDomain;
this.removed = removed;
}
@ -186,4 +191,8 @@ public class GuiThemeVO implements GuiTheme {
public String toString() {
return ReflectionToStringBuilderUtils.reflectOnlySelectedFields(this, "uuid", "name", "description", "isPublic", "recursiveDomains");
}
public void setLoginBaseDomain(String loginBaseDomain) {
this.loginBaseDomain = loginBaseDomain;
}
}

View File

@ -451,6 +451,9 @@ SELECT uuid(), role_id, 'quotaResourceStatement', permission, sort_order
FROM cloud.role_permissions rp
WHERE rule = 'quotaStatement' AND NOT EXISTS(SELECT 1 FROM cloud.role_permissions rp_ WHERE rp.role_id = rp_.role_id AND rp_.rule = 'quotaResourceStatement');
--- Gui theme login base domain
CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.gui_themes', 'login_base_domain', 'TEXT DEFAULT NULL');
-- Add description for secondary IP addresses
CALL `cloud`.`IDEMPOTENT_ADD_COLUMN`('cloud.nic_secondary_ips', 'description', 'VARCHAR(2048) DEFAULT NULL');

View File

@ -27,6 +27,7 @@ SELECT
`cloud`.`gui_themes`.`description` AS `description`,
`cloud`.`gui_themes`.`css` AS `css`,
`cloud`.`gui_themes`.`json_configuration` AS `json_configuration`,
`cloud`.`gui_themes`.`login_base_domain` AS `login_base_domain`,
(SELECT group_concat(gtd.`value` separator ',') FROM `cloud`.`gui_themes_details` gtd WHERE gtd.`type` = 'commonName' AND gtd.gui_theme_id = `cloud`.`gui_themes`.`id`) common_names,
(SELECT group_concat(gtd.`value` separator ',') FROM `cloud`.`gui_themes_details` gtd WHERE gtd.`type` = 'domain' AND gtd.gui_theme_id = `cloud`.`gui_themes`.`id`) domains,
(SELECT group_concat(gtd.`value` separator ',') FROM `cloud`.`gui_themes_details` gtd WHERE gtd.`type` = 'account' AND gtd.gui_theme_id = `cloud`.`gui_themes`.`id`) accounts,

View File

@ -5721,6 +5721,7 @@ protected Map<String, ResourceIcon> getResourceIconsUsingOsCategory(List<Templat
guiThemeResponse.setJsonConfiguration(guiThemeJoin.getJsonConfiguration());
guiThemeResponse.setCss(guiThemeJoin.getCss());
guiThemeResponse.setLoginBaseDomain(guiThemeJoin.getLoginBaseDomain());
guiThemeResponse.setResponseName("guithemes");
return guiThemeResponse;

View File

@ -39,6 +39,7 @@ import org.apache.cloudstack.gui.theme.dao.GuiThemeDao;
import org.apache.cloudstack.gui.theme.dao.GuiThemeDetailsDao;
import org.apache.cloudstack.gui.theme.dao.GuiThemeJoinDao;
import org.apache.cloudstack.gui.theme.json.config.validator.JsonConfigValidator;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@ -126,21 +127,18 @@ public class GuiThemeServiceImpl implements GuiThemeService {
String providedAccountIds = cmd.getAccountIds();
boolean isPublic = cmd.getPublic();
Boolean recursiveDomains = cmd.getRecursiveDomains();
String baseDomainName = cmd.getLoginBaseDomain();
CallContext.current().setEventDetails(String.format("Name: %s, AccountIDs: %s, DomainIDs: %s, RecursiveDomains: %s, CommonNames: %s", name, providedAccountIds,
providedDomainIds, recursiveDomains, commonNames));
if (StringUtils.isAllBlank(css, jsonConfiguration)) {
throw new CloudRuntimeException("Either the `css` or `jsonConfiguration` parameter must be informed.");
}
validateParameters(jsonConfiguration, providedDomainIds, providedAccountIds, commonNames, null);
validateParameters(css, jsonConfiguration, providedDomainIds, providedAccountIds, commonNames, baseDomainName, null);
if (shouldSetGuiThemeToPrivate(providedDomainIds, providedAccountIds)) {
isPublic = false;
}
GuiThemeVO guiThemeVO = new GuiThemeVO(name, description, css, jsonConfiguration, recursiveDomains, isPublic, new Date(), null);
GuiThemeVO guiThemeVO = new GuiThemeVO(name, description, css, jsonConfiguration, recursiveDomains, isPublic, new Date(), cmd.getLoginBaseDomain(), null);
guiThemeDao.persist(guiThemeVO);
persistGuiThemeDetails(guiThemeVO.getId(), commonNames, providedDomainIds, providedAccountIds);
return guiThemeJoinDao.findById(guiThemeVO.getId());
@ -224,7 +222,11 @@ public class GuiThemeServiceImpl implements GuiThemeService {
return guiThemeJoinDao.listGuiThemes(id, name, commonName, domainUuid, accountUuid, listAll, showRemoved, showPublic);
}
protected void validateParameters(String jsonConfig, String domainIds, String accountIds, String commonNames, Long idOfThemeToBeUpdated) {
protected void validateParameters(String css, String jsonConfig, String domainIds, String accountIds, String commonNames, String loginBaseDomain, Long idOfThemeToBeUpdated) {
if (StringUtils.isAllBlank(css, jsonConfig, loginBaseDomain)) {
throw new CloudRuntimeException("At least one of the `css`, `jsonconfiguration`, or `loginbasedomain` parameters must be informed.");
}
if (isConsideredDefaultTheme(commonNames, domainIds, accountIds)) {
checkIfDefaultThemeIsAllowed(commonNames, domainIds, accountIds, idOfThemeToBeUpdated);
}
@ -232,6 +234,7 @@ public class GuiThemeServiceImpl implements GuiThemeService {
validateObjectUuids(accountIds, Account.class);
validateObjectUuids(domainIds, Domain.class);
jsonConfigValidator.validateJsonConfiguration(jsonConfig);
validateLoginBaseDomain(loginBaseDomain, commonNames);
}
/**
@ -254,6 +257,12 @@ public class GuiThemeServiceImpl implements GuiThemeService {
}
}
protected void validateLoginBaseDomain(String loginBaseDomain, String commonNames) {
if (loginBaseDomain != null && StringUtils.isBlank(commonNames)) {
throw new CloudRuntimeException("Parameter `loginBaseDomain` must be provided with `commonNames`.");
}
}
@Override
@ActionEvent(eventType = EventTypes.EVENT_GUI_THEME_UPDATE, eventDescription = "Updating GUI theme")
public GuiThemeJoin updateGuiTheme(UpdateGuiThemeCmd cmd) {
@ -267,23 +276,24 @@ public class GuiThemeServiceImpl implements GuiThemeService {
String commonNames = cmd.getCommonNames() == null ? guiThemeJoinVO.getCommonNames() : cmd.getCommonNames();
String providedDomainIds = cmd.getDomainIds() == null ? guiThemeJoinVO.getDomains() : cmd.getDomainIds();
String providedAccountIds = cmd.getAccountIds() == null ? guiThemeJoinVO.getAccounts() : cmd.getAccountIds();
String baseDomainName = ObjectUtils.defaultIfNull(cmd.getLoginBaseDomain(), guiThemeJoinVO.getLoginBaseDomain());
Boolean isPublic = cmd.getIsPublic();
Boolean recursiveDomains = cmd.getRecursiveDomains();
CallContext.current().setEventDetails(String.format("ID: %s, Name: %s, AccountIDs: %s, DomainIDs: %s, RecursiveDomains: %s, CommonNames: %s", guiThemeId, name,
providedAccountIds, providedDomainIds, recursiveDomains, commonNames));
validateParameters(jsonConfiguration, providedDomainIds, providedAccountIds, commonNames, guiThemeId);
validateParameters(css, jsonConfiguration, providedDomainIds, providedAccountIds, commonNames, baseDomainName, guiThemeId);
if (shouldSetGuiThemeToPrivate(providedDomainIds, providedAccountIds)) {
isPublic = false;
}
return persistGuiTheme(guiThemeId, name, description, css, jsonConfiguration, commonNames, providedDomainIds, providedAccountIds, isPublic, recursiveDomains);
return persistGuiTheme(guiThemeId, name, description, css, jsonConfiguration, commonNames, providedDomainIds, providedAccountIds, isPublic, recursiveDomains, baseDomainName);
}
protected GuiThemeJoinVO persistGuiTheme(Long guiThemeId, String name, String description, String css, String jsonConfiguration, String commonNames, String providedDomainIds,
String providedAccountIds, Boolean isPublic, Boolean recursiveDomains){
String providedAccountIds, Boolean isPublic, Boolean recursiveDomains, String loginBaseDomain){
return Transaction.execute((TransactionCallback<GuiThemeJoinVO>) status -> {
GuiThemeVO guiThemeVO = guiThemeDao.findById(guiThemeId);
@ -311,6 +321,10 @@ public class GuiThemeServiceImpl implements GuiThemeService {
guiThemeVO.setRecursiveDomains(recursiveDomains);
}
if (loginBaseDomain != null) {
guiThemeVO.setLoginBaseDomain(loginBaseDomain);
}
logger.trace("Persisting GUI theme [{}] with CSS [{}] and JSON configuration [{}].", guiThemeVO, guiThemeVO.getCss(), guiThemeVO.getJsonConfiguration());
guiThemeDao.persist(guiThemeVO);

View File

@ -65,6 +65,7 @@ public class GuiThemeServiceImplTest {
private static final String ACCOUNT_IDS = "4,5,6";
private static final String LOGIN_BASE_DOMAIN = "acmedomain";
private static final String BLANK_STRING = "";
@Test
@ -172,6 +173,26 @@ public class GuiThemeServiceImplTest {
guiThemeServiceSpy.validateObjectUuids(ACCOUNT_IDS, Account.class);
}
@Test
public void validateLoginBaseDomainTestBaseDomainIsNullCommonNamesIsNullShouldNotThrowCloudRuntimeException() {
guiThemeServiceSpy.validateLoginBaseDomain(null, null);
}
@Test
public void validateLoginBaseDomainTestBaseDomainIsNullCommonNamesIsNotNullShouldNotThrowCloudRuntimeException() {
guiThemeServiceSpy.validateLoginBaseDomain(null, COMMON_NAME);
}
@Test
public void validateLoginBaseDomainTestBaseDomainIsNotNullCommonNamesIsNotNullShouldNotThrowCloudRuntimeException() {
guiThemeServiceSpy.validateLoginBaseDomain(LOGIN_BASE_DOMAIN, COMMON_NAME);
}
@Test(expected = CloudRuntimeException.class)
public void validateLoginBaseDomainTestBaseDomainIsNotNullCommonNamesIsNullShouldNotThrowCloudRuntimeException() {
guiThemeServiceSpy.validateLoginBaseDomain(LOGIN_BASE_DOMAIN, null);
}
@Test
public void checkIfDefaultThemeIsAllowedTestThemeIsNotConsideredDefault() {
Mockito.when(guiThemeServiceSpy.isConsideredDefaultTheme(Mockito.anyString(), Mockito.anyString(), Mockito.anyString())).thenReturn(false);

View File

@ -63,6 +63,11 @@ async function applyDynamicCustomization (response) {
jsonConfig = JSON.parse(response?.jsonconfiguration)
}
vueProps.$config.loginBaseDomain = ''
if (response?.loginbasedomain) {
vueProps.$config.loginBaseDomain = response.loginbasedomain
}
// Sets custom GUI fields only if is not nullish.
vueProps.$config.appTitle = jsonConfig?.appTitle ?? vueProps.$config.appTitle
vueProps.$config.footer = jsonConfig?.footer ?? vueProps.$config.footer

View File

@ -390,11 +390,20 @@ export default {
},
handleDomain () {
const values = toRaw(this.form)
if (!values.domain) {
this.$store.commit('SET_DOMAIN_USED_TO_LOGIN', '/')
} else {
this.$store.commit('SET_DOMAIN_USED_TO_LOGIN', values.domain)
const domain = this.getLoginDomain(values.domain)
this.$store.commit('SET_DOMAIN_USED_TO_LOGIN', domain)
},
getLoginDomain (domain) {
if (this.$config.loginBaseDomain) {
if (domain) {
return this.$config.loginBaseDomain + '/' + domain
}
return this.$config.loginBaseDomain
}
if (domain) {
return domain
}
return '/'
},
getGitHubUrl (from) {
const rootURl = 'https://github.com/login/oauth/authorize'
@ -457,10 +466,7 @@ export default {
delete loginParams.username
loginParams[!this.state.loginType ? 'email' : 'username'] = values.username
loginParams.password = values.password
loginParams.domain = values.domain
if (!loginParams.domain) {
loginParams.domain = '/'
}
loginParams.domain = this.getLoginDomain(values.domain)
this.Login(loginParams)
.then((res) => this.loginSuccess(res))
.catch(err => {
@ -489,10 +495,7 @@ export default {
loginParams.email = this.email
loginParams.provider = provider
loginParams.secretcode = this.secretcode
loginParams.domain = values.domain
if (!loginParams.domain) {
loginParams.domain = '/'
}
loginParams.domain = this.getLoginDomain(values.domain)
this.OauthLogin(loginParams)
.then((res) => this.loginSuccess(res))
.catch(err => {