Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Core/Core.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
02F6EF3B28D9B8EC00835477 /* CourseCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */; };
02F6EF4A28D9F0A700835477 /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6EF4928D9F0A700835477 /* DateExtension.swift */; };
0604C9AA2B22FACF00AD5DBF /* UIComponentsConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */; };
12624ABABF5AE7FBF7B4F5E5 /* TenantsConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FC6455FBE49B22E32CB777A /* TenantsConfig.swift */; };
064987932B4D69FF0071642A /* DragAndDropCssInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878A2B4D69FE0071642A /* DragAndDropCssInjection.swift */; };
064987942B4D69FF0071642A /* WebviewInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878B2B4D69FE0071642A /* WebviewInjection.swift */; };
064987952B4D69FF0071642A /* SurveyCssInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878C2B4D69FE0071642A /* SurveyCssInjection.swift */; };
Expand Down Expand Up @@ -290,6 +291,7 @@
02F6EF4928D9F0A700835477 /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = "<group>"; };
043DD0B526F919DFA1C5E600 /* Pods-App-Core-CoreTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.releaseprod.xcconfig"; sourceTree = "<group>"; };
0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIComponentsConfig.swift; sourceTree = "<group>"; };
2FC6455FBE49B22E32CB777A /* TenantsConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TenantsConfig.swift; sourceTree = "<group>"; };
0649878A2B4D69FE0071642A /* DragAndDropCssInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DragAndDropCssInjection.swift; sourceTree = "<group>"; };
0649878B2B4D69FE0071642A /* WebviewInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebviewInjection.swift; sourceTree = "<group>"; };
0649878C2B4D69FE0071642A /* SurveyCssInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SurveyCssInjection.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -889,6 +891,7 @@
isa = PBXGroup;
children = (
0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */,
2FC6455FBE49B22E32CB777A /* TenantsConfig.swift */,
0727876F28D23411002E9142 /* Config.swift */,
DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */,
A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */,
Expand Down Expand Up @@ -1301,6 +1304,7 @@
0233D56F2AF13EB200BAC8BD /* StarRatingView.swift in Sources */,
029A13302C2479E7005FB830 /* OfflineSyncInteractor.swift in Sources */,
0604C9AA2B22FACF00AD5DBF /* UIComponentsConfig.swift in Sources */,
12624ABABF5AE7FBF7B4F5E5 /* TenantsConfig.swift in Sources */,
027BD3B82909476200392132 /* DismissKeyboardTapViewModifier.swift in Sources */,
0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */,
07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */,
Expand Down
265 changes: 265 additions & 0 deletions Core/Core/Configuration/Config/TenantsConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
//
// TenantsConfig.swift
// Core
//
// Created by Rawan Matar on 31/08/2026.
//

import Foundation
import OEXFoundation

// MARK: - TenantProvider

/// Read-only, thread-safe access to the currently selected tenant.
public protocol TenantProvider: Sendable {
/// `nil` when no tenant is selected (e.g. single-tenant deployment).
var currentTenant: Tenant? { get }
}

// MARK: - Tenant

private enum TenantKeys: String, RawStringExtractable {
case key = "KEY"
case name
case tenantName = "TENANT_NAME"
case color
case oAuthClientId = "OAUTH_CLIENT_ID"
case baseURL = "API_HOST_URL"
case baseURLHiddenLogin = "API_HOST_URL_HIDDEN_LOGIN"
case baseSSOURL = "SSO_URL"
case successfulSSOLoginURL = "SSO_URL_SUCCESSFUL_LOGIN"
case environmentDisplayName = "ENVIRONMENT_DISPLAY_NAME"
case isSwitchTenantLoginEnabled = "IS_SWITCH_TENANT_LOGIN_ENABLED"
case uiComponents = "UI_COMPONENTS"
case logoURL = "LOGO_URL"
case headerBackgroundURL = "HEADER_BACKGROUND_URL"
case theme = "THEME"
}

/// Per-mode (`light`/`dark`) color overrides parsed from the tenant's `THEME` block.
/// Missing keys fall back to `ThemeColorSet.derived(fromHex:light:dark:)`.
public struct TenantThemeColors: Codable, Sendable, Equatable {
public let light: [String: String]
public let dark: [String: String]

public init(light: [String: String], dark: [String: String]) {
self.light = light
self.dark = dark
}
}

public struct Tenant: Codable, Identifiable, Sendable, Equatable, Hashable {
/// Same as `key`.
public var id: String { key }

/// Machine-readable identifier, e.g. "niepd". Falls back to a slugified `name`.
public let key: String
public let name: String
public let tenantName: String
public let color: String
public let oAuthClientId: String
public let baseURL: URL
/// Falls back to `baseURL` if not set.
public let baseURLHiddenLogin: URL
public let baseSSOURL: URL?
public let successfulSSOLoginURL: URL?
public let environmentDisplayName: String?
public let isSwitchTenantLoginEnabled: Bool
public let uiComponents: UIComponentsConfig
/// Remote URL, or a bundled asset name. `nil` keeps the default logo.
public let logoURLString: String?
/// Remote URL only. `nil` keeps the default header background.
public let headerBackgroundURLString: String?
/// `nil` derives the palette from `color` instead.
public let themeColors: TenantThemeColors?

public static func == (lhs: Tenant, rhs: Tenant) -> Bool {
lhs.key == rhs.key
}

public func hash(into hasher: inout Hasher) {
hasher.combine(key)
}

/// Fails if required fields (`name`, `API_HOST_URL`, `OAUTH_CLIENT_ID`) are missing.
public init?(dictionary: [String: Any]) {
guard let name = dictionary[TenantKeys.name] as? String, !name.isEmpty,
let urlString = dictionary[TenantKeys.baseURL] as? String,
let baseURL = URL(string: urlString),
let oAuthClientId = dictionary[TenantKeys.oAuthClientId] as? String, !oAuthClientId.isEmpty
else { return nil }

self.name = name
self.baseURL = baseURL
self.oAuthClientId = oAuthClientId

if let explicitKey = dictionary[TenantKeys.key] as? String, !explicitKey.isEmpty {
self.key = explicitKey
} else {
self.key = Tenant.slugify(name)
}

var languageCode = "en"
if let langCode = Locale.preferredLanguages.first?.prefix(2) {
languageCode = String(langCode)
}
if let tenantNameDict = dictionary[TenantKeys.tenantName] as? [String: Any] {
self.tenantName = (tenantNameDict[languageCode] as? String)
?? (tenantNameDict["en"] as? String)
?? name
} else {
self.tenantName = name
}

self.color = dictionary[TenantKeys.color] as? String ?? "#007AFF"
self.isSwitchTenantLoginEnabled = dictionary[TenantKeys.isSwitchTenantLoginEnabled] as? Bool ?? false

if let hiddenLoginString = dictionary[TenantKeys.baseURLHiddenLogin] as? String,
let hiddenLoginURL = URL(string: hiddenLoginString) {
self.baseURLHiddenLogin = hiddenLoginURL
} else {
self.baseURLHiddenLogin = baseURL
}

if let ssoString = dictionary[TenantKeys.baseSSOURL] as? String {
self.baseSSOURL = URL(string: ssoString)
} else {
self.baseSSOURL = nil
}

if let successString = dictionary[TenantKeys.successfulSSOLoginURL] as? String {
self.successfulSSOLoginURL = URL(string: successString)
} else {
self.successfulSSOLoginURL = nil
}

self.environmentDisplayName = dictionary[TenantKeys.environmentDisplayName] as? String

if let uiDict = dictionary[TenantKeys.uiComponents] as? [String: Any] {
self.uiComponents = UIComponentsConfig(dictionary: uiDict)
} else {
self.uiComponents = UIComponentsConfig(dictionary: [:])
}

self.logoURLString = dictionary[TenantKeys.logoURL] as? String
self.headerBackgroundURLString = dictionary[TenantKeys.headerBackgroundURL] as? String

if let themeDict = dictionary[TenantKeys.theme] as? [String: Any] {
self.themeColors = TenantThemeColors(
light: themeDict["light"] as? [String: String] ?? [:],
dark: themeDict["dark"] as? [String: String] ?? [:]
)
} else {
self.themeColors = nil
}
}

private static func slugify(_ name: String) -> String {
name.lowercased()
.folding(options: .diacriticInsensitive, locale: .current)
.replacingOccurrences(of: "[^a-z0-9]+", with: "-", options: .regularExpression)
.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
}

// MARK: Codable
// `uiComponents` and remote-media fields aren't persisted; re-populated from
// `TenantStore.tenantsConfig` right after decode.
private enum CodingKeys: String, CodingKey {
case key, name, tenantName, color, oAuthClientId
case baseURL, baseURLHiddenLogin, baseSSOURL, successfulSSOLoginURL
case environmentDisplayName, isSwitchTenantLoginEnabled
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
key = try container.decode(String.self, forKey: .key)
name = try container.decode(String.self, forKey: .name)
tenantName = try container.decode(String.self, forKey: .tenantName)
color = try container.decode(String.self, forKey: .color)
oAuthClientId = try container.decode(String.self, forKey: .oAuthClientId)
baseURL = try container.decode(URL.self, forKey: .baseURL)
baseURLHiddenLogin = try container.decode(URL.self, forKey: .baseURLHiddenLogin)
baseSSOURL = try container.decodeIfPresent(URL.self, forKey: .baseSSOURL)
successfulSSOLoginURL = try container.decodeIfPresent(URL.self, forKey: .successfulSSOLoginURL)
environmentDisplayName = try container.decodeIfPresent(String.self, forKey: .environmentDisplayName)
isSwitchTenantLoginEnabled = try container.decode(Bool.self, forKey: .isSwitchTenantLoginEnabled)
uiComponents = UIComponentsConfig(dictionary: [:])
logoURLString = nil
headerBackgroundURLString = nil
themeColors = nil
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(key, forKey: .key)
try container.encode(name, forKey: .name)
try container.encode(tenantName, forKey: .tenantName)
try container.encode(color, forKey: .color)
try container.encode(oAuthClientId, forKey: .oAuthClientId)
try container.encode(baseURL, forKey: .baseURL)
try container.encode(baseURLHiddenLogin, forKey: .baseURLHiddenLogin)
try container.encodeIfPresent(baseSSOURL, forKey: .baseSSOURL)
try container.encodeIfPresent(successfulSSOLoginURL, forKey: .successfulSSOLoginURL)
try container.encodeIfPresent(environmentDisplayName, forKey: .environmentDisplayName)
try container.encode(isSwitchTenantLoginEnabled, forKey: .isSwitchTenantLoginEnabled)
}
}

// MARK: - TenantsConfig

public struct TenantsConfig: Sendable {
public let tenants: [Tenant]

public init() {
self.tenants = []
}

/// - Parameter fallback: base `config.yaml` values used to fill fields a
/// `TENANTS` entry omits (tenant-level wins).
public init(array: [[String: Any]], fallback: [String: Any] = [:]) {
// Drop invalid entries instead of producing a half-populated Tenant.
self.tenants = array.compactMap { dict in
let merged = fallback.merging(dict) { _, tenantValue in tenantValue }
guard let tenant = Tenant(dictionary: merged) else {
#if DEBUG
print("⚠️ TenantsConfig: skipping malformed TENANTS entry: \(dict["name"] ?? "<unknown>")")
#endif
return nil
}
return tenant
}
}

public func tenant(withKey key: String) -> Tenant? {
tenants.first { $0.key == key }
}
}

#if DEBUG
public extension Tenant {
static func mock(
key: String = "mock",
name: String = "Test",
tenantName: String = "Test",
color: String = "#fff",
oAuthClientId: String = "mockClientId",
baseURL: URL = URL(string: "https://mock.base.url")!
) -> Tenant {
Tenant(dictionary: [
"KEY": key,
"name": name,
"TENANT_NAME": ["en": tenantName],
"color": color,
"OAUTH_CLIENT_ID": oAuthClientId,
"API_HOST_URL": baseURL.absoluteString
])!
}
}

public final class TenantProviderMock: TenantProvider, @unchecked Sendable {
public var currentTenant: Tenant?
public init(currentTenant: Tenant? = .mock()) {
self.currentTenant = currentTenant
}
}
#endif
3 changes: 2 additions & 1 deletion Core/Core/Configuration/Config/UIComponentsConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ private enum Keys: String, RawStringExtractable {
case samlSSODefaultLoginButton = "SAML_SSO_DEFAULT_LOGIN_BUTTON"
}

public class UIComponentsConfig: NSObject {
// `@unchecked Sendable`: properties are set once in `init` and never mutated.
public class UIComponentsConfig: NSObject, @unchecked Sendable {
public var courseDropDownNavigationEnabled: Bool
public var courseUnitProgressEnabled: Bool
public var loginRegistrationEnabled: Bool
Expand Down
79 changes: 79 additions & 0 deletions default_config/dev/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,82 @@ UI_COMPONENTS:
LOGIN_REGISTRATION_ENABLED: true
SAML_SSO_LOGIN_ENABLED: false
SAML_SSO_DEFAULT_LOGIN_BUTTON: false


# Multi-tenant configuration. Each TENANTS entry overrides the top-level
# values above for that tenant; fields it omits fall back to them via
# TenantsConfig.init(array:fallback:) — see
# Core/Core/Configuration/Config/TenantsConfig.swift.
TENANTS:
-
KEY: "example-tenant"
name: "Example Tenant"
TENANT_NAME:
ar: "مثال المستأجر"
en: "Example Tenant"
color: "#3C68FF"
ENVIRONMENT_DISPLAY_NAME: "Example Tenant"
OAUTH_CLIENT_ID: 'example-oauth-client-id'
API_HOST_URL: 'https://example-tenant.example.com'
API_HOST_URL_HIDDEN_LOGIN: 'https://example-tenant-hidden.example.com'
SSO_URL: 'https://example-tenant.example.com/auth/login/tpa-saml/?auth_entry=login'
SSO_URL_SUCCESSFUL_LOGIN: "https://example-tenant.example.com/auth/login/tpa-saml/auth/complete/tpa-saml"
SSO_BUTTON_TITLE:
ar: "الدخول عبر SSO"
en: "Sign in with SSO"
IS_SWITCH_TENANT_LOGIN_ENABLED: true

LOGO_URL: "exampleTenantAppLogo"
THEME:
light:
accent_color: "#3C68FF"
accent_x_color: "#1C355E"
alert: "#FF3D71"
avatar_stroke: "#3D4964"
background: "#FFFFFF"
background_stroke: "#FFFFFF"
card_view_background: "#CCD3DF"
card_view_stroke: "#CCD3DF"
certificate_foreground: "#2EB865"
comment_cell_background: "#F5F5F5"
next_week_timeline_color: "#97A5BB"
past_due_timeline_color: "#FFC94D"
this_week_timeline_color: "#3D4964"
today_timeline_color: "#42AAFF"
upcoming_timeline_color: "#CCD4E0"
shadow_color: "#000000"
snackbar_error_color: "#EB4F4F"
snackbar_info_color: "#42AAFF"
snackbar_text_color: "#FFFFFF"
styled_button_text: "#FFFFFF"
text_primary: "#18202E"
text_secondary: "#31CAB4"
text_secondary_light: "#97A5BB"
text_input_background: "#FFFFFF"
text_input_stroke: "#97A5BB"
text_input_unfocused_background: "#F9FAFB"
text_input_unfocused_stroke: "#CCD4E0"
warning: "#FFC248"
white: "#FFFFFF"
on_progress: "#F0CC00"
progress_done: "#BBE6D7"
progress_skip: "#DDD9DA"
dates_section_background: "#F9FAFB"
dates_section_stroke: "#CCD4E0"
navigation_bar_tint_color: "#FFFFFF"
secondary_button_border_color: "#3C68FF"
secondary_button_text_color: "#3C68FF"
success: "#30A171"
primary_button_text_color: "#FFFFFF"
toggle_switch_color: "#3C68FF"
text_input_text_color: "#19212F"
text_input_placeholder_color: "#97A5BB"
info_color: "#3C68FF"
irreversible_alert: "#FF3D71"

UI_COMPONENTS:
COURSE_UNIT_PROGRESS_ENABLED: false
COURSE_NESTED_LIST_ENABLED: false
LOGIN_REGISTRATION_ENABLED: true
SAML_SSO_LOGIN_ENABLED: false
SAML_SSO_DEFAULT_LOGIN_BUTTON: false
Loading
Loading