Merge branch 'main' into vaxi/mobile-sdk-sonar-fixes

# Conflicts:
#	packages/ios/Demo/Demo/AppDelegate.swift
#	packages/ios/FormbricksSDK/FormbricksSDK/Networking/Base/APIClient.swift
This commit is contained in:
Peter Pesti-Varga
2025-04-18 14:19:41 +02:00
24 changed files with 509 additions and 323 deletions

View File

@@ -22,7 +22,7 @@ export const OrganizationSettingsNavbar = ({
loading,
}: OrganizationSettingsNavbarProps) => {
const pathname = usePathname();
const { isMember } = getAccessFlags(membershipRole);
const { isMember, isOwner } = getAccessFlags(membershipRole);
const isPricingDisabled = isMember;
const { t } = useTranslate();
@@ -59,6 +59,7 @@ export const OrganizationSettingsNavbar = ({
label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/api-keys`,
current: pathname?.includes("/api-keys"),
hidden: !isOwner,
},
];

View File

@@ -25,7 +25,7 @@ export const deleteApiKeyAction = authenticatedActionClient
access: [
{
type: "organization",
roles: ["owner", "manager"],
roles: ["owner"],
},
],
});
@@ -47,7 +47,7 @@ export const createApiKeyAction = authenticatedActionClient
access: [
{
type: "organization",
roles: ["owner", "manager"],
roles: ["owner"],
},
],
});
@@ -69,7 +69,7 @@ export const updateApiKeyAction = authenticatedActionClient
access: [
{
type: "organization",
roles: ["owner", "manager"],
roles: ["owner"],
},
],
});

View File

@@ -2,7 +2,6 @@ import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmen
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectsByOrganizationId } from "@/modules/organization/settings/api-keys/lib/projects";
import { Alert } from "@/modules/ui/components/alert";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
@@ -19,7 +18,9 @@ export const APIKeysPage = async (props) => {
const projects = await getProjectsByOrganizationId(organization.id);
const isReadOnly = currentUserMembership.role !== "owner" && currentUserMembership.role !== "manager";
const isNotOwner = currentUserMembership.role !== "owner";
if (isNotOwner) throw new Error(t("common.not_authorized"));
return (
<PageContentWrapper>
@@ -31,22 +32,16 @@ export const APIKeysPage = async (props) => {
activeId="api-keys"
/>
</PageHeader>
{isReadOnly ? (
<Alert variant="warning">
{t("environments.settings.api_keys.only_organization_owners_and_managers_can_manage_api_keys")}
</Alert>
) : (
<SettingsCard
title={t("common.api_keys")}
description={t("environments.settings.api_keys.api_keys_description")}>
<ApiKeyList
organizationId={organization.id}
locale={locale}
isReadOnly={isReadOnly}
projects={projects}
/>
</SettingsCard>
)}
<SettingsCard
title={t("common.api_keys")}
description={t("environments.settings.api_keys.api_keys_description")}>
<ApiKeyList
organizationId={organization.id}
locale={locale}
isReadOnly={isNotOwner}
projects={projects}
/>
</SettingsCard>
</PageContentWrapper>
);
};

View File

@@ -3,18 +3,8 @@ import FormbricksSDK
class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
let config = FormbricksConfig.Builder(appUrl: "[appUrl]", environmentId: "[environmentId]")
.setLogLevel(.debug)
.build()
Formbricks.setup(with: config)
Formbricks.logout()
Formbricks.setUserId(UUID().uuidString)
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
return true
}
}

View File

@@ -1,24 +0,0 @@
import SwiftUI
import FormbricksSDK
struct ContentView: View {
var body: some View {
VStack {
Spacer()
HStack {
Spacer()
Button("Click me!") {
Formbricks.track("click_demo_button")
}
Spacer()
}
.padding()
Spacer()
}
}
}
#Preview {
ContentView()
}

View File

@@ -7,7 +7,7 @@ struct DemoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
SetupView()
}
}
}

View File

@@ -0,0 +1,72 @@
import SwiftUI
import FormbricksSDK
struct SetupView: View {
@State private var isSetup = false
@State private var isLoading = false
var body: some View {
VStack(spacing: 20) {
if !isSetup {
if isLoading {
ProgressView("Setting up...")
.padding()
} else {
Button("Setup Formbricks SDK") {
isLoading = true
let config = FormbricksConfig.Builder(appUrl: "[appUrl]", environmentId: "[environmentId]")
.setLogLevel(.debug)
.build()
// Simulate async setup delay
DispatchQueue.global().async {
Formbricks.setup(with: config)
Formbricks.setUserId(UUID().uuidString)
DispatchQueue.main.async {
isSetup = true
isLoading = false
}
}
}
.padding()
}
} else {
Button("Call Formbricks.track") {
Formbricks.track("click_demo_button")
}
.padding()
Button("Call Formbricks.setAttribute") {
Formbricks.setAttribute("test@example.com", forKey: "user_email")
}
.padding()
Button("Call Formbricks.setAttributes") {
Formbricks.setAttributes(["user_name": "John Doe", "user_age": "30"])
}
.padding()
Button("Call Formbricks.setLanguage") {
Formbricks.setLanguage("vi")
}.padding()
Button("Call Formbricks.logout") {
Formbricks.logout()
}.padding()
Button("Call Formbricks.cleanup") {
Formbricks.cleanup(waitForOperations: true) {
print(">>> Cleanup complete")
isSetup = false
}
}
.padding()
}
}
.navigationTitle("Setup SDK")
}
}
#Preview {
SetupView()
}

View File

@@ -9,9 +9,12 @@ import Network
static internal var language: String = "default"
static internal var isInitialized: Bool = false
static internal var apiQueue = OperationQueue()
static internal var logger = Logger()
static internal var service = FormbricksService()
static internal var userManager: UserManager?
static internal var presentSurveyManager: PresentSurveyManager?
static internal var surveyManager: SurveyManager?
static internal var apiQueue: OperationQueue? = OperationQueue()
static internal var logger: Logger?
static internal var service = FormbricksService()
// make this class not instantiatable outside of the SDK
internal override init() {
@@ -36,30 +39,39 @@ import Network
Formbricks.setup(with: config)
```
*/
@objc public static func setup(with config: FormbricksConfig) {
@objc public static func setup(with config: FormbricksConfig, force: Bool = false) {
logger = Logger()
if (force == true) {
isInitialized = false
}
guard !isInitialized else {
Formbricks.logger.error(FormbricksSDKError(type: .sdkIsAlreadyInitialized).message)
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsAlreadyInitialized).message)
return
}
self.appUrl = config.appUrl
self.environmentId = config.environmentId
self.logger.logLevel = config.logLevel
self.logger?.logLevel = config.logLevel
userManager = UserManager()
if let userId = config.userId {
UserManager.shared.set(userId: userId)
userManager?.set(userId: userId)
}
if let attributes = config.attributes {
UserManager.shared.set(attributes: attributes)
userManager?.set(attributes: attributes)
}
if let language = config.attributes?["language"] {
UserManager.shared.set(language: language)
userManager?.set(language: language)
self.language = language
}
presentSurveyManager = PresentSurveyManager()
surveyManager = SurveyManager.create(userManager: userManager!, presentSurveyManager: presentSurveyManager!)
userManager?.surveyManager = surveyManager
SurveyManager.shared.refreshEnvironmentIfNeeded()
UserManager.shared.syncUserStateIfNeeded()
surveyManager?.refreshEnvironmentIfNeeded(force: force)
userManager?.syncUserStateIfNeeded()
self.isInitialized = true
}
@@ -75,11 +87,11 @@ import Network
*/
@objc public static func setUserId(_ userId: String) {
guard Formbricks.isInitialized else {
Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
UserManager.shared.set(userId: userId)
userManager?.set(userId: userId)
}
/**
@@ -93,11 +105,11 @@ import Network
*/
@objc public static func setAttribute(_ attribute: String, forKey key: String) {
guard Formbricks.isInitialized else {
Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
UserManager.shared.add(attribute: attribute, forKey: key)
userManager?.add(attribute: attribute, forKey: key)
}
/**
@@ -111,11 +123,11 @@ import Network
*/
@objc public static func setAttributes(_ attributes: [String : String]) {
guard Formbricks.isInitialized else {
Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
UserManager.shared.set(attributes: attributes)
userManager?.set(attributes: attributes)
}
/**
@@ -129,12 +141,16 @@ import Network
*/
@objc public static func setLanguage(_ language: String) {
guard Formbricks.isInitialized else {
Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
if (Formbricks.language == language) {
return
}
Formbricks.language = language
UserManager.shared.set(language: language)
userManager?.set(language: language)
}
/**
@@ -148,15 +164,15 @@ import Network
*/
@objc public static func track(_ action: String) {
guard Formbricks.isInitialized else {
Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
Formbricks.isInternetAvailabile { available in
if available {
SurveyManager.shared.track(action)
surveyManager?.track(action)
} else {
Formbricks.logger.warning(FormbricksSDKError.init(type: .networkError).message)
Formbricks.logger?.warning(FormbricksSDKError.init(type: .networkError).message)
}
}
@@ -173,11 +189,58 @@ import Network
*/
@objc public static func logout() {
guard Formbricks.isInitialized else {
Formbricks.logger.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
return
}
UserManager.shared.logout()
userManager?.logout()
}
/**
Cleans up the SDK. This will clear the user attributes, the user id and the environment state.
The SDK must be initialized before calling this method.
If `waitForOperations` is set to `true`, it will wait for all operations to finish before cleaning up.
If `waitForOperations` is set to `false`, it will clean up immediately.
You can also provide a completion block that will be called when the cleanup is finished.
Example:
```swift
Formbricks.cleanup()
Formbricks.cleanup(waitForOperations: true) {
// Cleanup completed
}
```
*/
@objc public static func cleanup(waitForOperations: Bool = false, completion: (() -> Void)? = nil) {
if waitForOperations, let queue = apiQueue {
DispatchQueue.global(qos: .background).async {
queue.waitUntilAllOperationsAreFinished()
performCleanup()
DispatchQueue.main.async {
completion?()
}
}
} else {
apiQueue?.cancelAllOperations()
performCleanup()
completion?()
}
}
private static func performCleanup() {
userManager?.cleanupUpdateQueue()
presentSurveyManager?.dismissView()
presentSurveyManager = nil
userManager = nil
surveyManager = nil
apiQueue = nil
isInitialized = false
appUrl = nil
environmentId = nil
logger = nil
language = "default"
}
}

View File

@@ -52,8 +52,8 @@ private extension Logger {
logString.append(messageListString)
}
if logLevel == .error || logLevel.rawValue >= self.logLevel.rawValue {
DispatchQueue.main.async {
let str = logString + "\(self.emoji)\n"
DispatchQueue.main.async { [weak self] in
let str = logString + "\(self?.emoji ?? "")\n"
print(str)
}
}

View File

@@ -2,9 +2,8 @@ import SwiftUI
/// Presents a survey webview to the window's root
final class PresentSurveyManager {
static let shared = PresentSurveyManager()
private init() {
/*
init() {
/*
This empty initializer prevents external instantiation of the PresentSurveyManager class.
The class serves as a namespace for the present method, so instance creation is not needed and should be restricted.
*/
@@ -15,9 +14,10 @@ final class PresentSurveyManager {
/// Present the webview
func present(environmentResponse: EnvironmentResponse, id: String) {
DispatchQueue.main.async {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if let window = UIApplication.safeKeyWindow {
let view = FormbricksView(viewModel: FormbricksViewModel(environmentResponse: environmentResponse, surveyId: id))
let view = FormbricksView(viewModel: FormbricksViewModel(environmentResponse: environmentResponse, surveyId: id))
let vc = UIHostingController(rootView: view)
vc.modalPresentationStyle = .overCurrentContext
vc.view.backgroundColor = UIColor.gray.withAlphaComponent(0.6)
@@ -34,4 +34,8 @@ final class PresentSurveyManager {
func dismissView() {
viewController?.dismiss(animated: true)
}
deinit {
Formbricks.logger?.debug("Deinitializing \(self)")
}
}

View File

@@ -3,12 +3,16 @@ import SwiftUI
/// The SurveyManager is responsible for managing the surveys that are displayed to the user.
/// Filtering surveys based on the user's segments, responses, and displays.
final class SurveyManager {
static let shared = SurveyManager()
private init() {
/*
This empty initializer prevents external instantiation of the SurveyManager class.
The class serves as a namespace for the shared instance, so instance creation is not needed and should be restricted.
*/
private let userManager: UserManager
private let presentSurveyManager: PresentSurveyManager
private init(userManager: UserManager, presentSurveyManager: PresentSurveyManager) {
self.userManager = userManager
self.presentSurveyManager = presentSurveyManager
}
static func create(userManager: UserManager, presentSurveyManager: PresentSurveyManager) -> SurveyManager {
return SurveyManager(userManager: userManager, presentSurveyManager: presentSurveyManager)
}
private static let environmentResponseObjectKey = "environmentResponseObjectKey"
@@ -26,15 +30,15 @@ final class SurveyManager {
guard let environment = environmentResponse else { return }
guard let surveys = environment.data.data.surveys else { return }
let displays = UserManager.shared.displays ?? []
let responses = UserManager.shared.responses ?? []
let segments = UserManager.shared.segments ?? []
let displays = userManager.displays ?? []
let responses = userManager.responses ?? []
let segments = userManager.segments ?? []
filteredSurveys = filterSurveysBasedOnDisplayType(surveys, displays: displays, responses: responses)
filteredSurveys = filterSurveysBasedOnRecontactDays(filteredSurveys, defaultRecontactDays: environment.data.data.project.recontactDays)
// If we have a user, we do more filtering
if UserManager.shared.userId != nil {
if userManager.userId != nil {
if segments.isEmpty {
filteredSurveys = []
return
@@ -48,22 +52,23 @@ final class SurveyManager {
/// Handles the display percentage and the delay of the survey.
func track(_ action: String) {
guard !isShowingSurvey else { return }
let actionClasses = environmentResponse?.data.data.actionClasses ?? []
let codeActionClasses = actionClasses.filter { $0.type == "code" }
let actionClass = codeActionClasses.first { $0.key == action }
let firstSurveyWithActionClass = filteredSurveys.first { survey in
return survey.triggers?.contains(where: { $0.actionClass?.name == actionClass?.name }) ?? false
}
// Display percentage
let shouldDisplay = shouldDisplayBasedOnPercentage(firstSurveyWithActionClass?.displayPercentage)
// Display and delay it if needed
if let surveyId = firstSurveyWithActionClass?.id, shouldDisplay {
isShowingSurvey = true
let timeout = firstSurveyWithActionClass?.delay ?? 0
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) {
self.showSurvey(withId: surveyId)
DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in
self?.showSurvey(withId: surveyId)
}
}
}
@@ -74,7 +79,7 @@ extension SurveyManager {
/// Checks if the environment state needs to be refreshed based on its `expiresAt` property, and if so, refreshes it, starts the refresh timer, and filters the surveys.
func refreshEnvironmentIfNeeded(force: Bool = false) {
if let environmentResponse = environmentResponse, environmentResponse.data.expiresAt.timeIntervalSinceNow > 0, !force {
Formbricks.logger.debug("Environment state is still valid until \(environmentResponse.data.expiresAt)")
Formbricks.logger?.debug("Environment state is still valid until \(environmentResponse.data.expiresAt)")
filterSurveys()
return
}
@@ -88,7 +93,7 @@ extension SurveyManager {
self?.filterSurveys()
case .failure:
self?.hasApiError = true
Formbricks.logger.error(FormbricksSDKError(type: .unableToRefreshEnvironment).message)
Formbricks.logger?.error(FormbricksSDKError(type: .unableToRefreshEnvironment).message)
self?.startErrorTimer()
}
}
@@ -96,12 +101,12 @@ extension SurveyManager {
/// Posts a survey response to the Formbricks API.
func postResponse(surveyId: String) {
UserManager.shared.onResponse(surveyId: surveyId)
userManager.onResponse(surveyId: surveyId)
}
/// Creates a new display for the survey. It is called when the survey is displayed to the user.
func onNewDisplay(surveyId: String) {
UserManager.shared.onDisplay(surveyId: surveyId)
userManager.onDisplay(surveyId: surveyId)
}
}
@@ -110,7 +115,7 @@ extension SurveyManager {
/// Dismisses the presented survey window.
func dismissSurveyWebView() {
isShowingSurvey = false
PresentSurveyManager.shared.dismissView()
presentSurveyManager.dismissView()
}
}
@@ -120,7 +125,7 @@ private extension SurveyManager {
/// The view controller is presented over the current context.
func showSurvey(withId id: String) {
if let environmentResponse = environmentResponse {
PresentSurveyManager.shared.present(environmentResponse: environmentResponse, id: id)
presentSurveyManager.present(environmentResponse: environmentResponse, id: id)
}
}
@@ -142,9 +147,9 @@ private extension SurveyManager {
return
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) {
Formbricks.logger.debug("Refreshing environment state.")
self.refreshEnvironmentIfNeeded(force: true)
DispatchQueue.global().asyncAfter(deadline: .now() + timeout) { [weak self] in
Formbricks.logger?.debug("Refreshing environment state.")
self?.refreshEnvironmentIfNeeded(force: true)
}
}
@@ -166,7 +171,7 @@ extension SurveyManager {
if let data = UserDefaults.standard.data(forKey: SurveyManager.environmentResponseObjectKey) {
return try? JSONDecoder().decode(EnvironmentResponse.self, from: data)
} else {
Formbricks.logger.error(FormbricksSDKError(type: .unableToRetrieveEnvironment).message)
Formbricks.logger?.error(FormbricksSDKError(type: .unableToRetrieveEnvironment).message)
return nil
}
}
@@ -175,7 +180,7 @@ extension SurveyManager {
UserDefaults.standard.set(data, forKey: SurveyManager.environmentResponseObjectKey)
backingEnvironmentResponse = newValue
} else {
Formbricks.logger.error(FormbricksSDKError(type: .unableToPersistEnvironment).message)
Formbricks.logger?.error(FormbricksSDKError(type: .unableToPersistEnvironment).message)
}
}
}
@@ -207,7 +212,7 @@ private extension SurveyManager {
}
default:
Formbricks.logger.error(FormbricksSDKError(type: .invalidDisplayOption).message)
Formbricks.logger?.error(FormbricksSDKError(type: .invalidDisplayOption).message)
return false
}
@@ -218,7 +223,7 @@ private extension SurveyManager {
/// Filters the surveys based on the recontact days and the `lastDisplayedAt` date.
func filterSurveysBasedOnRecontactDays(_ surveys: [Survey], defaultRecontactDays: Int?) -> [Survey] {
surveys.filter { survey in
guard let lastDisplayedAt = UserManager.shared.lastDisplayedAt else { return true }
guard let lastDisplayedAt = userManager.lastDisplayedAt else { return true }
let recontactDays = survey.recontactDays ?? defaultRecontactDays
if let recontactDays = recontactDays {

View File

@@ -1,13 +1,11 @@
import Foundation
/// Store and manage user state and sync with the server when needed.
final class UserManager {
static let shared = UserManager()
private init() {
/*
This empty initializer prevents external instantiation of the UserManager class.
The class serves as a namespace for the user state, so instance creation is not needed and should be restricted.
*/
final class UserManager: UserManagerSyncable {
weak var surveyManager: SurveyManager?
init(surveyManager: SurveyManager? = nil) {
self.surveyManager = surveyManager
}
private static let userIdKey = "userIdKey"
@@ -28,26 +26,30 @@ final class UserManager {
private var backingLastDisplayedAt: Date?
private var backingExpiresAt: Date?
lazy private var updateQueue: UpdateQueue? = {
return UpdateQueue(userManager: self)
}()
internal var syncTimer: Timer?
/// Starts an update queue with the given user id.
func set(userId: String) {
UpdateQueue.current.set(userId: userId)
updateQueue?.set(userId: userId)
}
/// Starts an update queue with the given attribute.
func add(attribute: String, forKey key: String) {
UpdateQueue.current.add(attribute: attribute, forKey: key)
updateQueue?.add(attribute: attribute, forKey: key)
}
/// Starts an update queue with the given attributes.
func set(attributes: [String: String]) {
UpdateQueue.current.set(attributes: attributes)
updateQueue?.set(attributes: attributes)
}
/// Starts an update queue with the given language..
func set(language: String) {
UpdateQueue.current.set(language: language)
updateQueue?.set(language: language)
}
/// Saves `surveyId` to the `displays` property and the current date to the `lastDisplayedAt` property.
@@ -57,7 +59,7 @@ final class UserManager {
newDisplays.append(Display(surveyId: surveyId, createdAt: DateFormatter.isoFormatter.string(from: lastDisplayedAt)))
displays = newDisplays
self.lastDisplayedAt = lastDisplayedAt
SurveyManager.shared.filterSurveys()
surveyManager?.filterSurveys()
}
/// Saves `surveyId` to the `responses` property.
@@ -65,7 +67,7 @@ final class UserManager {
var newResponses = responses ?? []
newResponses.append(surveyId)
responses = newResponses
SurveyManager.shared.filterSurveys()
surveyManager?.filterSurveys()
}
/// Syncs the user state with the server if the user id is set and the expiration date has passed.
@@ -80,7 +82,7 @@ final class UserManager {
syncUser(withId: id)
}
/// Syncs the user state with the server, calls the `SurveyManager.shared.filterSurveys()` method and starts the sync timer.
/// Syncs the user state with the server, calls the `self?.surveyManager?.filterSurveys()` method and starts the sync timer.
func syncUser(withId id: String, attributes: [String: String]? = nil) {
service.postUser(id: id, attributes: attributes) { [weak self] result in
switch result {
@@ -92,11 +94,12 @@ final class UserManager {
self?.responses = userResponse.data.state?.data?.responses
self?.lastDisplayedAt = userResponse.data.state?.data?.lastDisplayAt
self?.expiresAt = userResponse.data.state?.expiresAt
UpdateQueue.current.reset()
SurveyManager.shared.filterSurveys()
self?.updateQueue?.reset()
self?.surveyManager?.filterSurveys()
self?.startSyncTimer()
case .failure(let error):
Formbricks.logger.error(error)
Formbricks.logger?.error(error)
}
}
}
@@ -104,18 +107,31 @@ final class UserManager {
/// Logs out the user and clears the user state.
func logout() {
UserDefaults.standard.removeObject(forKey: UserManager.userIdKey)
UserDefaults.standard.removeObject(forKey: UserManager.contactIdKey)
UserDefaults.standard.removeObject(forKey: UserManager.segmentsKey)
UserDefaults.standard.removeObject(forKey: UserManager.displaysKey)
UserDefaults.standard.removeObject(forKey: UserManager.responsesKey)
UserDefaults.standard.removeObject(forKey: UserManager.lastDisplayedAtKey)
UserDefaults.standard.removeObject(forKey: UserManager.expiresAtKey)
backingUserId = nil
backingContactId = nil
backingSegments = nil
backingDisplays = nil
backingResponses = nil
backingLastDisplayedAt = nil
backingExpiresAt = nil
UpdateQueue.current.reset()
updateQueue?.reset()
Formbricks.logger?.debug("Successfully logged out user and reset the user state.")
}
func cleanupUpdateQueue() {
updateQueue?.cleanup()
updateQueue = nil // Release the instance so memory can be reclaimed.
}
deinit {
Formbricks.logger?.debug("Deinitializing \(self)")
}
}

View File

@@ -1,68 +1,122 @@
import Foundation
protocol UserManagerSyncable: AnyObject {
func syncUser(withId id: String, attributes: [String: String]?)
}
/// Update queue. This class is used to queue updates to the user.
/// The given properties will be sent to the backend and updated in the user object when the debounce interval is reached.
final class UpdateQueue {
private static var debounceInterval: TimeInterval = 0.5
static var current = UpdateQueue()
private let semaphore = DispatchSemaphore(value: 1)
private let syncQueue = DispatchQueue(label: "com.formbricks.updateQueue")
private var userId: String?
private var attributes: [String : String]?
private var language: String?
private var timer: Timer?
private weak var userManager: UserManagerSyncable?
init(userManager: UserManagerSyncable) {
self.userManager = userManager
}
func set(userId: String) {
semaphore.wait()
self.userId = userId
startDebounceTimer()
syncQueue.sync {
self.userId = userId
startDebounceTimer()
}
}
func set(attributes: [String : String]) {
semaphore.wait()
self.attributes = attributes
startDebounceTimer()
syncQueue.sync {
self.attributes = attributes
startDebounceTimer()
}
}
func add(attribute: String, forKey key: String) {
semaphore.wait()
if var attr = self.attributes {
attr[key] = attribute
self.attributes = attr
} else {
self.attributes = [key: attribute]
}
startDebounceTimer()
syncQueue.sync {
if var attr = self.attributes {
attr[key] = attribute
self.attributes = attr
} else {
self.attributes = [key: attribute]
}
startDebounceTimer()
}
}
func set(language: String) {
semaphore.wait()
add(attribute: "language", forKey: language)
startDebounceTimer()
syncQueue.sync {
self.language = language
// Check if we have an effective userId
let effectiveUserId = self.userId ?? Formbricks.userManager?.userId
if effectiveUserId != nil {
// If we have a userId, set attributes
self.attributes = ["language": language]
} else {
// If no userId, just update locally without API call
Formbricks.logger?.debug("UpdateQueue - updating language locally: \(language)")
}
startDebounceTimer()
}
}
func reset() {
userId = nil
attributes = nil
language = nil
syncQueue.sync {
userId = nil
attributes = nil
language = nil
}
}
deinit {
Formbricks.logger?.debug("Deinitializing \(self)")
}
}
private extension UpdateQueue {
func startDebounceTimer() {
timer?.invalidate()
timer = Timer.scheduledTimer(timeInterval: UpdateQueue.debounceInterval, target: self, selector: #selector(commit), userInfo: nil, repeats: false)
semaphore.signal()
timer = nil
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.timer = Timer.scheduledTimer(timeInterval: UpdateQueue.debounceInterval,
target: self,
selector: #selector(self.commit),
userInfo: nil,
repeats: false)
}
}
@objc func commit() {
guard let userId = userId else {
Formbricks.logger.error(FormbricksSDKError(type: .userIdIsNotSetYet).message)
let effectiveUserId: String? = self.userId ?? Formbricks.userManager?.userId ?? nil
guard let userId = effectiveUserId else {
Formbricks.logger?.error(FormbricksSDKError(type: .userIdIsNotSetYet).message)
return
}
Formbricks.logger.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(attributes ?? [:])")
UserManager.shared.syncUser(withId: userId, attributes: attributes)
Formbricks.logger?.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(attributes ?? [:])")
userManager?.syncUser(withId: userId, attributes: attributes)
}
}
// Add a function to to stop the timer for cleanup
extension UpdateQueue {
func cleanup() {
syncQueue.sync {
timer?.invalidate()
timer = nil
userId = nil
attributes = nil
language = nil
}
}
}

View File

@@ -20,6 +20,6 @@ private extension FormbricksService {
/// Creates the APIClient operation and adds it to the queue
func execute<Request: CodableRequest>(_ request: Request, withCompletion completion: @escaping (ResultType<Request.Response>) -> Void) {
let operation = APIClient(request: request, completion: completion)
Formbricks.apiQueue.addOperation(operation)
Formbricks.apiQueue?.addOperation(operation)
}
}

View File

@@ -64,7 +64,7 @@ private extension FormbricksViewModel {
}
const script = document.createElement("script");
script.src = "\(FormbricksEnvironment.surveyScriptUrl)";
script.src = "\(Formbricks.appUrl ?? "http://localhost:3000")/js/surveys.umd.cjs";
script.async = true;
script.onload = () => loadSurvey();
script.onerror = (error) => {
@@ -89,7 +89,7 @@ private class WebViewData {
data["languageCode"] = Formbricks.language
data["appUrl"] = Formbricks.appUrl
data["environmentId"] = Formbricks.environmentId
data["contactId"] = UserManager.shared.contactId
data["contactId"] = Formbricks.userManager?.contactId
data["isWebEnvironment"] = false
let hasCustomStyling = environmentResponse.data.data.surveys?.first(where: { $0.id == surveyId })?.styling != nil
@@ -103,7 +103,7 @@ private class WebViewData {
let jsonData = try JSONSerialization.data(withJSONObject: data, options: [])
return String(data: jsonData, encoding: .utf8)?.replacingOccurrences(of: "\\\"", with: "'")
} catch {
Formbricks.logger.error(error.message)
Formbricks.logger?.error(error.message)
return nil
}
}

View File

@@ -39,6 +39,16 @@ struct SurveyWebView: UIViewRepresentable {
return Coordinator()
}
/// Called automatically by SwiftUI when the view is torn down.
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
let userContentController = uiView.configuration.userContentController
userContentController.removeScriptMessageHandler(forName: "logging")
userContentController.removeScriptMessageHandler(forName: "jsMessage")
uiView.navigationDelegate = nil
uiView.uiDelegate = nil
Formbricks.logger?.debug("SurveyWebView: Dismantled")
}
/// Clean up cookies and website data.
func clean() {
@@ -98,22 +108,22 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
Formbricks.logger.debug(message.body)
Formbricks.logger?.debug(message.body)
if let body = message.body as? String, let data = body.data(using: .utf8), let obj = try? JSONDecoder().decode(JsMessageData.self, from: data) {
switch obj.event {
/// Happens when the user submits an answer.
case .onResponseCreated:
SurveyManager.shared.postResponse(surveyId: surveyId)
Formbricks.surveyManager?.postResponse(surveyId: surveyId)
/// Happens when a survey is shown.
case .onDisplayCreated:
SurveyManager.shared.onNewDisplay(surveyId: surveyId)
Formbricks.surveyManager?.onNewDisplay(surveyId: surveyId)
/// Happens when the user closes the survey view with the close button.
case .onClose:
SurveyManager.shared.dismissSurveyWebView()
Formbricks.surveyManager?.dismissSurveyWebView()
/// Happens when the survey wants to open an external link in the default browser.
case .onOpenExternalURL:
@@ -123,11 +133,11 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
/// Happens when the survey library fails to load.
case .onSurveyLibraryLoadError:
SurveyManager.shared.dismissSurveyWebView()
Formbricks.surveyManager?.dismissSurveyWebView()
}
} else {
Formbricks.logger.error("\(FormbricksSDKError(type: .invalidJavascriptMessage).message): \(message.body)")
Formbricks.logger?.error("\(FormbricksSDKError(type: .invalidJavascriptMessage).message): \(message.body)")
}
}
}
@@ -136,7 +146,7 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
/// Handle and send console.log messages from the Javascript to the local logger.
final class LoggingMessageHandler: NSObject, WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
Formbricks.logger.debug(message.body)
Formbricks.logger?.debug(message.body)
}
}

View File

@@ -1,10 +1,3 @@
//
// FormbricksSDKTests.swift
// FormbricksSDKTests
//
// Created by Peter Pesti-Varga on 2025. 02. 03..
//
import XCTest
@testable import FormbricksSDK
@@ -15,128 +8,153 @@ final class FormbricksSDKTests: XCTestCase {
let userId = "6CCCE716-6783-4D0F-8344-9C7DFA43D8F7"
let surveyID = "cm6ovw6j7000gsf0kduf4oo4i"
let mockService = MockFormbricksService()
override func setUpWithError() throws {
UserManager.shared.logout()
SurveyManager.shared.service = mockService
UserManager.shared.service = mockService
SurveyManager.shared.environmentResponse = nil
}
func testFormbricks() throws {
// Everything should be in the default state
// Everything should be in the default state before initialization.
XCTAssertFalse(Formbricks.isInitialized)
XCTAssertEqual(SurveyManager.shared.filteredSurveys.count, 0)
XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
XCTAssertNil(SurveyManager.shared.environmentResponse)
XCTAssertNil(UserManager.shared.syncTimer)
XCTAssertNil(UserManager.shared.userId)
XCTAssertNil(Formbricks.surveyManager)
XCTAssertNil(Formbricks.userManager)
XCTAssertEqual(Formbricks.language, "default")
// Use methods before init should have no effect
// User manager default state: there is no user yet.
XCTAssertNil(Formbricks.userManager?.displays)
XCTAssertNil(Formbricks.userManager?.responses)
XCTAssertNil(Formbricks.userManager?.segments)
// Use methods before init should have no effect.
Formbricks.setUserId("userId")
Formbricks.setLanguage("de")
Formbricks.setAttributes(["testA" : "testB"])
Formbricks.setAttribute("test", forKey: "testKey")
XCTAssertNil(UserManager.shared.userId)
XCTAssertNil(Formbricks.userManager?.userId)
XCTAssertEqual(Formbricks.language, "default")
// Setup the SDK using your new instance-based design.
// This creates new instances for both the UserManager and SurveyManager.
Formbricks.setup(with: FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId)
.set(attributes: ["a": "b"])
.add(attribute: "test", forKey: "key")
.setLogLevel(.debug)
.build())
// Set up the service dependency on both managers.
Formbricks.userManager?.service = mockService
Formbricks.surveyManager?.service = mockService
// Call the setup and initialize the SDK
Formbricks.setup(with:
FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId)
.set(attributes: ["a":"b"])
.add(attribute: "test", forKey: "key")
.setLogLevel(.debug)
.build()
)
// Should be ignored, becuase we don't have user ID yet
Formbricks.setAttributes(["testA" : "testB"])
Formbricks.setAttribute("test", forKey: "testKey")
XCTAssertNil(UserManager.shared.userId)
// Verify the base variables are set properly
XCTAssertTrue(Formbricks.isInitialized)
XCTAssertEqual(Formbricks.appUrl, appUrl)
XCTAssertEqual(Formbricks.environmentId, environmentId)
// User manager default state. There is no user yet.
XCTAssertNil(FormbricksSDK.UserManager.shared.displays)
XCTAssertNil(FormbricksSDK.UserManager.shared.responses)
XCTAssertNil(FormbricksSDK.UserManager.shared.segments)
// Check error state handling
mockService.isErrorResponseNeeded = true
XCTAssertFalse(SurveyManager.shared.hasApiError)
SurveyManager.shared.refreshEnvironmentIfNeeded(force: true)
XCTAssertTrue(SurveyManager.shared.hasApiError)
mockService.isErrorResponseNeeded = false
// Authenticate the user
Formbricks.setUserId(userId)
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 2.0)
XCTAssertEqual(UserManager.shared.userId, userId)
// User refresh timer should be set
XCTAssertNotNil(UserManager.shared.syncTimer)
// The environment should be fetched already
XCTAssertNotNil(SurveyManager.shared.environmentResponse)
// Check if the filter method works properly
XCTAssertEqual(SurveyManager.shared.filteredSurveys.count, 1)
// Make sure we don't show any survey
XCTAssertNotNil(SurveyManager.shared.filteredSurveys)
XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
// Track an unknown event, shouldn't show the survey
Formbricks.track("unknown_event")
XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
// Track a known event, thus, the survey should be shown.
Formbricks.track("click_demo_button")
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
XCTAssertTrue(SurveyManager.shared.isShowingSurvey)
// "Dismiss" the webview
SurveyManager.shared.dismissSurveyWebView()
XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
// Validate display and response
SurveyManager.shared.postResponse(surveyId: surveyID)
SurveyManager.shared.onNewDisplay(surveyId: surveyID)
XCTAssertEqual(UserManager.shared.responses?.count, 1)
XCTAssertEqual(UserManager.shared.displays?.count, 1)
// Track a valid event, but the survey should not shown, because we already gave a response.
Formbricks.track("click_demo_button")
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
// Validate logout
XCTAssertNotNil(UserManager.shared.userId)
XCTAssertNotNil(UserManager.shared.lastDisplayedAt)
XCTAssertNotNil(UserManager.shared.responses)
XCTAssertNotNil(UserManager.shared.displays)
XCTAssertNotNil(UserManager.shared.segments)
XCTAssertNotNil(UserManager.shared.expiresAt)
Formbricks.logout()
XCTAssertNil(UserManager.shared.userId)
XCTAssertNil(UserManager.shared.lastDisplayedAt)
XCTAssertNil(UserManager.shared.responses)
XCTAssertNil(UserManager.shared.displays)
XCTAssertNil(UserManager.shared.segments)
XCTAssertNil(UserManager.shared.expiresAt)
// Clear the responses
Formbricks.logout()
SurveyManager.shared.filterSurveys()
Formbricks.track("click_demo_button")
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
XCTAssertTrue(SurveyManager.shared.isShowingSurvey)
SurveyManager.shared.dismissSurveyWebView()
XCTAssertFalse(SurveyManager.shared.isShowingSurvey)
XCTAssertTrue(Formbricks.isInitialized)
XCTAssertEqual(Formbricks.appUrl, appUrl)
XCTAssertEqual(Formbricks.environmentId, environmentId)
// Check error state handling.
mockService.isErrorResponseNeeded = true
XCTAssertFalse(Formbricks.surveyManager?.hasApiError ?? false)
Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true)
XCTAssertTrue(Formbricks.surveyManager?.hasApiError ?? false)
mockService.isErrorResponseNeeded = false
Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true)
// Authenticate the user.
Formbricks.setUserId(userId)
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 2.0)
XCTAssertEqual(Formbricks.userManager?.userId, userId)
// User refresh timer should be set.
XCTAssertNotNil(Formbricks.userManager?.syncTimer)
// The environment should be fetched.
XCTAssertNotNil(Formbricks.surveyManager?.environmentResponse)
// Check if the filter method works properly.
XCTAssertEqual(Formbricks.surveyManager?.filteredSurveys.count, 1)
// Verify that were not showing any survey initially.
XCTAssertNotNil(Formbricks.surveyManager?.filteredSurveys)
XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false)
// Track an unknown eventsurvey should not be shown.
Formbricks.track("unknown_event")
XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false)
// Track a known eventthe survey should be shown.
Formbricks.track("click_demo_button")
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
XCTAssertTrue(Formbricks.surveyManager?.isShowingSurvey ?? false)
// "Dismiss" the webview.
Formbricks.surveyManager?.dismissSurveyWebView()
XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false)
// Validate display and response.
Formbricks.surveyManager?.postResponse(surveyId: surveyID)
Formbricks.surveyManager?.onNewDisplay(surveyId: surveyID)
XCTAssertEqual(Formbricks.userManager?.responses?.count, 1)
XCTAssertEqual(Formbricks.userManager?.displays?.count, 1)
// Track a valid event, but survey should not be shown because a response was already submitted.
Formbricks.track("click_demo_button")
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
XCTAssertFalse(Formbricks.surveyManager?.isShowingSurvey ?? false)
// Validate logout.
XCTAssertNotNil(Formbricks.userManager?.userId)
XCTAssertNotNil(Formbricks.userManager?.lastDisplayedAt)
XCTAssertNotNil(Formbricks.userManager?.responses)
XCTAssertNotNil(Formbricks.userManager?.displays)
XCTAssertNotNil(Formbricks.userManager?.segments)
XCTAssertNotNil(Formbricks.userManager?.expiresAt)
Formbricks.logout()
XCTAssertNil(Formbricks.userManager?.userId)
XCTAssertNil(Formbricks.userManager?.lastDisplayedAt)
XCTAssertNil(Formbricks.userManager?.responses)
XCTAssertNil(Formbricks.userManager?.displays)
XCTAssertNil(Formbricks.userManager?.segments)
XCTAssertNil(Formbricks.userManager?.expiresAt)
// Clear the responses and verify survey behavior.
Formbricks.logout()
Formbricks.surveyManager?.filterSurveys()
Formbricks.track("click_demo_button")
_ = XCTWaiter.wait(for: [expectation(description: "Wait for a seconds")], timeout: 1.0)
XCTAssertTrue(Formbricks.surveyManager?.isShowingSurvey ?? false)
// Test the cleanup
Formbricks.cleanup()
XCTAssertNil(Formbricks.userManager)
XCTAssertNil(Formbricks.surveyManager)
XCTAssertNil(Formbricks.apiQueue)
XCTAssertNil(Formbricks.presentSurveyManager)
XCTAssertFalse(Formbricks.isInitialized)
XCTAssertNil(Formbricks.appUrl)
XCTAssertNil(Formbricks.environmentId)
XCTAssertNil(Formbricks.logger)
}
func testCleanupWithCompletion() {
// Setup the SDK
let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId)
.setLogLevel(.debug)
.build()
Formbricks.setup(with: config)
XCTAssertTrue(Formbricks.isInitialized)
// Set expectation for cleanup completion
let cleanupExpectation = expectation(description: "Cleanup completed")
Formbricks.cleanup(waitForOperations: true) {
cleanupExpectation.fulfill()
}
wait(for: [cleanupExpectation], timeout: 2.0)
// Validate cleanup: all main properties should be nil or false
XCTAssertNil(Formbricks.userManager)
XCTAssertNil(Formbricks.surveyManager)
XCTAssertNil(Formbricks.presentSurveyManager)
XCTAssertNil(Formbricks.apiQueue)
XCTAssertFalse(Formbricks.isInitialized)
XCTAssertNil(Formbricks.appUrl)
XCTAssertNil(Formbricks.environmentId)
XCTAssertNil(Formbricks.logger)
}
}

View File

@@ -5,7 +5,7 @@ To run the project, open `Formbricks.xcworkspace` in **Xcode**. The workspace co
- **FormbricksSDK**: The SDK package.
- **Demo**: A demo application to exercise the SDK.
Before launching the `Demo` app, update the mandatory variables in `AppDelegate`:
Before launching the `Demo` app, update the mandatory variables in `SetupView`:
```swift
let config = FormbricksConfig.Builder(appUrl: "[APP_URL]", environmentId: "[ENVIRONMENT_ID]")
@@ -13,15 +13,9 @@ let config = FormbricksConfig.Builder(appUrl: "[APP_URL]", environmentId: "[ENVI
.build()
```
Once these values are properly set, the demo app can be launched.
The demo app consists of a single view, `ContentView`. It is a SwiftUI view with a single button.
The button's action should be updated according to the survey actions:
Once these values are properly set, launch the demo app. The demo app uses a single view, `SetupView`, as the initial screen. In this view, tap the **Setup Formbricks SDK** button to initialize the SDK.
```swift
Formbricks.track("click_demo_button")
```
Replace `"click_demo_button"` with the desired action.
Once setup is complete, you can use the **Call Formbricks.track** button to trigger your survey and the **Call Formbricks.cleanup** button to reset the SDK and return to the setup screen.
---
@@ -33,9 +27,9 @@ You can generate developer documentation for the SDK by pressing **Shift + Comma
## Unit Tests
The SDK includes a unit test to verify the Manager's functionality. To run it:
The SDK includes unit tests to verify the functionality of various components. To run them:
1. Select the `Test Navigator` tab in Xcode.
2. Run the `testFormbricks()` method.
2. Run the desired test methods.
The coverage report can be found in the `Report Navigator` tab.

View File

@@ -478,7 +478,6 @@
"password_changed_email_heading": "Passwort geändert",
"password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.",
"password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert",
"powered_by_formbricks": "Unterstützt von Formbricks",
"privacy_policy": "Datenschutzerklärung",
"reject": "Ablehnen",
"render_email_response_value_file_upload_response_link_not_included": "Link zur hochgeladenen Datei ist aus Datenschutzgründen nicht enthalten",
@@ -995,8 +994,7 @@
"api_keys": {
"add_api_key": "API-Schlüssel hinzufügen",
"add_permission": "Berechtigung hinzufügen",
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen",
"only_organization_owners_and_managers_can_manage_api_keys": "Nur Organisationsinhaber und -manager können API-Schlüssel verwalten"
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
},
"billing": {
"10000_monthly_responses": "10,000 monatliche Antworten",

View File

@@ -478,7 +478,6 @@
"password_changed_email_heading": "Password changed",
"password_changed_email_text": "Your password has been changed successfully.",
"password_reset_notify_email_subject": "Your Formbricks password has been changed",
"powered_by_formbricks": "Powered by Formbricks",
"privacy_policy": "Privacy Policy",
"reject": "Reject",
"render_email_response_value_file_upload_response_link_not_included": "Link to uploaded file is not included for data privacy reasons",
@@ -995,8 +994,7 @@
"api_keys": {
"add_api_key": "Add API key",
"add_permission": "Add permission",
"api_keys_description": "Manage API keys to access Formbricks management APIs",
"only_organization_owners_and_managers_can_manage_api_keys": "Only organization owners and managers can manage API keys"
"api_keys_description": "Manage API keys to access Formbricks management APIs"
},
"billing": {
"10000_monthly_responses": "10000 Monthly Responses",

View File

@@ -478,7 +478,6 @@
"password_changed_email_heading": "Mot de passe changé",
"password_changed_email_text": "Votre mot de passe a été changé avec succès.",
"password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé",
"powered_by_formbricks": "Propulsé par Formbricks",
"privacy_policy": "Politique de confidentialité",
"reject": "Rejeter",
"render_email_response_value_file_upload_response_link_not_included": "Le lien vers le fichier téléchargé n'est pas inclus pour des raisons de confidentialité des données",
@@ -995,8 +994,7 @@
"api_keys": {
"add_api_key": "Ajouter une clé API",
"add_permission": "Ajouter une permission",
"api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks",
"only_organization_owners_and_managers_can_manage_api_keys": "Seuls les propriétaires et les gestionnaires de l'organisation peuvent gérer les clés API"
"api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Réponses Mensuelles",

View File

@@ -478,7 +478,6 @@
"password_changed_email_heading": "Senha alterada",
"password_changed_email_text": "Sua senha foi alterada com sucesso.",
"password_reset_notify_email_subject": "Sua senha Formbricks foi alterada",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"render_email_response_value_file_upload_response_link_not_included": "O link para o arquivo enviado não está incluído por motivos de privacidade de dados",
@@ -995,8 +994,7 @@
"api_keys": {
"add_api_key": "Adicionar chave de API",
"add_permission": "Adicionar permissão",
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks",
"only_organization_owners_and_managers_can_manage_api_keys": "Apenas proprietários e gerentes da organização podem gerenciar chaves de API"
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Respostas Mensais",

View File

@@ -478,7 +478,6 @@
"password_changed_email_heading": "Palavra-passe alterada",
"password_changed_email_text": "A sua palavra-passe foi alterada com sucesso.",
"password_reset_notify_email_subject": "A sua palavra-passe do Formbricks foi alterada",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"render_email_response_value_file_upload_response_link_not_included": "O link para o ficheiro carregado não está incluído por razões de privacidade de dados",
@@ -995,8 +994,7 @@
"api_keys": {
"add_api_key": "Adicionar chave API",
"add_permission": "Adicionar permissão",
"api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks",
"only_organization_owners_and_managers_can_manage_api_keys": "Apenas os proprietários e gestores da organização podem gerir chaves API"
"api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks"
},
"billing": {
"10000_monthly_responses": "10000 Respostas Mensais",

View File

@@ -478,7 +478,6 @@
"password_changed_email_heading": "密碼已變更",
"password_changed_email_text": "您的密碼已成功變更。",
"password_reset_notify_email_subject": "您的 Formbricks 密碼已變更",
"powered_by_formbricks": "由 Formbricks 提供技術支援",
"privacy_policy": "隱私權政策",
"reject": "拒絕",
"render_email_response_value_file_upload_response_link_not_included": "由於資料隱私原因,未包含上傳檔案的連結",
@@ -995,8 +994,7 @@
"api_keys": {
"add_api_key": "新增 API 金鑰",
"add_permission": "新增權限",
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API",
"only_organization_owners_and_managers_can_manage_api_keys": "只有組織擁有者和管理員才能管理 API 金鑰"
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
},
"billing": {
"10000_monthly_responses": "10000 個每月回應",