void;
+ onSecondaryClick: () => void;
+ isLoading?: boolean;
+ primaryType?: "button" | "submit";
+ primaryTestId?: string;
+ secondaryTestId?: string;
+ fullWidth?: boolean;
+}
+
+export function ModalButtonGroup({
+ primaryText,
+ secondaryText,
+ onPrimaryClick,
+ onSecondaryClick,
+ isLoading = false,
+ primaryType = "button",
+ primaryTestId,
+ secondaryTestId,
+ fullWidth = false,
+}: ModalButtonGroupProps) {
+ const { t } = useTranslation();
+ const closeText = secondaryText ?? t(I18nKey.BUTTON$CLOSE);
+
+ return (
+
+
+ {isLoading ? (
+
+ ) : (
+ primaryText
+ )}
+
+
+ {closeText}
+
+
+ );
+}
diff --git a/frontend/src/components/shared/modals/org-modal.tsx b/frontend/src/components/shared/modals/org-modal.tsx
new file mode 100644
index 0000000000..054fc1f673
--- /dev/null
+++ b/frontend/src/components/shared/modals/org-modal.tsx
@@ -0,0 +1,90 @@
+import React from "react";
+import { ModalBackdrop } from "./modal-backdrop";
+import { ModalBody } from "./modal-body";
+import { ModalButtonGroup } from "./modal-button-group";
+
+interface OrgModalProps {
+ testId?: string;
+ title: string;
+ description?: React.ReactNode;
+ children?: React.ReactNode;
+ primaryButtonText: string;
+ secondaryButtonText?: string;
+ onPrimaryClick?: () => void;
+ onClose: () => void;
+ isLoading?: boolean;
+ primaryButtonType?: "button" | "submit";
+ primaryButtonTestId?: string;
+ secondaryButtonTestId?: string;
+ ariaLabel?: string;
+ asForm?: boolean;
+ formAction?: (formData: FormData) => void;
+ fullWidthButtons?: boolean;
+}
+
+export function OrgModal({
+ testId,
+ title,
+ description,
+ children,
+ primaryButtonText,
+ secondaryButtonText,
+ onPrimaryClick,
+ onClose,
+ isLoading = false,
+ primaryButtonType = "button",
+ primaryButtonTestId,
+ secondaryButtonTestId,
+ ariaLabel,
+ asForm = false,
+ formAction,
+ fullWidthButtons = false,
+}: OrgModalProps) {
+ const content = (
+ <>
+
+
{title}
+ {description && (
+
{description}
+ )}
+ {children}
+
+
+ >
+ );
+
+ const modalBodyClassName =
+ "items-start rounded-xl p-6 w-sm flex flex-col gap-4 bg-base-secondary border border-tertiary";
+
+ return (
+
+ {asForm ? (
+
+ ) : (
+
+ {content}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/v1/chat/event-message.tsx b/frontend/src/components/v1/chat/event-message.tsx
index 0c55a9a0b4..fa6299e57a 100644
--- a/frontend/src/components/v1/chat/event-message.tsx
+++ b/frontend/src/components/v1/chat/event-message.tsx
@@ -126,7 +126,6 @@ const renderUserMessageWithSkillReady = (
);
} catch (error) {
// If skill ready event creation fails, just render the user message
- // Failed to create skill ready event, fallback to user message
return (
,
+ },
+ {
+ to: "/settings/org",
+ text: "Organization",
+ icon:
,
+ },
];
export const OSS_NAV_ITEMS: SettingsNavItem[] = [
diff --git a/frontend/src/context/use-selected-organization.ts b/frontend/src/context/use-selected-organization.ts
new file mode 100644
index 0000000000..28c58ec4c4
--- /dev/null
+++ b/frontend/src/context/use-selected-organization.ts
@@ -0,0 +1,28 @@
+import { useRevalidator } from "react-router";
+import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
+
+interface SetOrganizationIdOptions {
+ /** Skip route revalidation. Useful for initial auto-selection to avoid duplicate API calls. */
+ skipRevalidation?: boolean;
+}
+
+export const useSelectedOrganizationId = () => {
+ const revalidator = useRevalidator();
+ const { organizationId, setOrganizationId: setOrganizationIdStore } =
+ useSelectedOrganizationStore();
+
+ const setOrganizationId = (
+ newOrganizationId: string | null,
+ options?: SetOrganizationIdOptions,
+ ) => {
+ setOrganizationIdStore(newOrganizationId);
+ // Revalidate route to ensure the latest orgId is used.
+ // This is useful for redirecting the user away from admin-only org pages.
+ // Skip revalidation for initial auto-selection to avoid duplicate API calls.
+ if (!options?.skipRevalidation) {
+ revalidator.revalidate();
+ }
+ };
+
+ return { organizationId, setOrganizationId };
+};
diff --git a/frontend/src/hooks/mutation/use-delete-organization.ts b/frontend/src/hooks/mutation/use-delete-organization.ts
new file mode 100644
index 0000000000..e8d41da277
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-delete-organization.ts
@@ -0,0 +1,36 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useNavigate } from "react-router";
+import { organizationService } from "#/api/organization-service/organization-service.api";
+import { useSelectedOrganizationId } from "#/context/use-selected-organization";
+
+export const useDeleteOrganization = () => {
+ const queryClient = useQueryClient();
+ const navigate = useNavigate();
+ const { organizationId, setOrganizationId } = useSelectedOrganizationId();
+
+ return useMutation({
+ mutationFn: () => {
+ if (!organizationId) throw new Error("Organization ID is required");
+ return organizationService.deleteOrganization({ orgId: organizationId });
+ },
+ onSuccess: () => {
+ // Remove stale cache BEFORE clearing the selected organization.
+ // This prevents useAutoSelectOrganization from using the old currentOrgId
+ // when it runs during the re-render triggered by setOrganizationId(null).
+ // Using removeQueries (not invalidateQueries) ensures stale data is gone immediately.
+ queryClient.removeQueries({
+ queryKey: ["organizations"],
+ exact: true,
+ });
+ queryClient.removeQueries({
+ queryKey: ["organizations", organizationId],
+ });
+
+ // Now clear the selected organization - useAutoSelectOrganization will
+ // wait for fresh data since the cache is empty
+ setOrganizationId(null);
+
+ navigate("/");
+ },
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-invite-members-batch.ts b/frontend/src/hooks/mutation/use-invite-members-batch.ts
new file mode 100644
index 0000000000..d82287da2c
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-invite-members-batch.ts
@@ -0,0 +1,38 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { organizationService } from "#/api/organization-service/organization-service.api";
+import { useSelectedOrganizationId } from "#/context/use-selected-organization";
+import { I18nKey } from "#/i18n/declaration";
+import {
+ displayErrorToast,
+ displaySuccessToast,
+} from "#/utils/custom-toast-handlers";
+import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
+
+export const useInviteMembersBatch = () => {
+ const queryClient = useQueryClient();
+ const { organizationId } = useSelectedOrganizationId();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: ({ emails }: { emails: string[] }) => {
+ if (!organizationId) {
+ throw new Error("Organization ID is required");
+ }
+ return organizationService.inviteMembers({
+ orgId: organizationId,
+ emails,
+ });
+ },
+ onSuccess: () => {
+ displaySuccessToast(t(I18nKey.ORG$INVITE_MEMBERS_SUCCESS));
+ queryClient.invalidateQueries({
+ queryKey: ["organizations", "members", organizationId],
+ });
+ },
+ onError: (error) => {
+ const errorMessage = retrieveAxiosErrorMessage(error);
+ displayErrorToast(errorMessage || t(I18nKey.ORG$INVITE_MEMBERS_ERROR));
+ },
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-remove-member.ts b/frontend/src/hooks/mutation/use-remove-member.ts
new file mode 100644
index 0000000000..583fe5a415
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-remove-member.ts
@@ -0,0 +1,38 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { organizationService } from "#/api/organization-service/organization-service.api";
+import { useSelectedOrganizationId } from "#/context/use-selected-organization";
+import { I18nKey } from "#/i18n/declaration";
+import {
+ displayErrorToast,
+ displaySuccessToast,
+} from "#/utils/custom-toast-handlers";
+import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
+
+export const useRemoveMember = () => {
+ const queryClient = useQueryClient();
+ const { organizationId } = useSelectedOrganizationId();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: ({ userId }: { userId: string }) => {
+ if (!organizationId) {
+ throw new Error("Organization ID is required");
+ }
+ return organizationService.removeMember({
+ orgId: organizationId,
+ userId,
+ });
+ },
+ onSuccess: () => {
+ displaySuccessToast(t(I18nKey.ORG$REMOVE_MEMBER_SUCCESS));
+ queryClient.invalidateQueries({
+ queryKey: ["organizations", "members", organizationId],
+ });
+ },
+ onError: (error) => {
+ const errorMessage = retrieveAxiosErrorMessage(error);
+ displayErrorToast(errorMessage || t(I18nKey.ORG$REMOVE_MEMBER_ERROR));
+ },
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-switch-organization.ts b/frontend/src/hooks/mutation/use-switch-organization.ts
new file mode 100644
index 0000000000..45fadedaf4
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-switch-organization.ts
@@ -0,0 +1,36 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useMatch, useNavigate } from "react-router";
+import { organizationService } from "#/api/organization-service/organization-service.api";
+import { useSelectedOrganizationId } from "#/context/use-selected-organization";
+
+export const useSwitchOrganization = () => {
+ const queryClient = useQueryClient();
+ const { setOrganizationId } = useSelectedOrganizationId();
+ const navigate = useNavigate();
+ const conversationMatch = useMatch("/conversations/:conversationId");
+
+ return useMutation({
+ mutationFn: (orgId: string) =>
+ organizationService.switchOrganization({ orgId }),
+ onSuccess: (_, orgId) => {
+ // Invalidate the target org's /me query to ensure fresh data on every switch
+ queryClient.invalidateQueries({
+ queryKey: ["organizations", orgId, "me"],
+ });
+ // Update local state
+ setOrganizationId(orgId);
+ // Invalidate settings for the new org context
+ queryClient.invalidateQueries({ queryKey: ["settings"] });
+ // Invalidate conversations to fetch data for the new org context
+ queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
+ // Remove all individual conversation queries to clear any stale/null data
+ // from the previous org context
+ queryClient.removeQueries({ queryKey: ["user", "conversation"] });
+
+ // Redirect to home if on a conversation page since org context has changed
+ if (conversationMatch) {
+ navigate("/");
+ }
+ },
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-unified-start-conversation.ts b/frontend/src/hooks/mutation/use-unified-start-conversation.ts
index 778ba25359..fafa0f98dd 100644
--- a/frontend/src/hooks/mutation/use-unified-start-conversation.ts
+++ b/frontend/src/hooks/mutation/use-unified-start-conversation.ts
@@ -33,6 +33,20 @@ export const useUnifiedResumeConversationSandbox = () => {
providers?: Provider[];
version?: "V0" | "V1";
}) => {
+ // Guard: If conversation is no longer in cache and no explicit version provided,
+ // skip the mutation. This handles race conditions like org switching where cache
+ // is cleared before the mutation executes.
+ // We return undefined (not throw) to avoid triggering the global MutationCache.onError
+ // handler which would display an error toast to the user.
+ const cachedConversation = queryClient.getQueryData([
+ "user",
+ "conversation",
+ variables.conversationId,
+ ]);
+ if (!cachedConversation && !variables.version) {
+ return undefined;
+ }
+
// Use provided version or fallback to cache lookup
const version =
variables.version ||
diff --git a/frontend/src/hooks/mutation/use-update-member-role.ts b/frontend/src/hooks/mutation/use-update-member-role.ts
new file mode 100644
index 0000000000..cb398c6246
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-update-member-role.ts
@@ -0,0 +1,46 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+import { organizationService } from "#/api/organization-service/organization-service.api";
+import { OrganizationUserRole } from "#/types/org";
+import { useSelectedOrganizationId } from "#/context/use-selected-organization";
+import { I18nKey } from "#/i18n/declaration";
+import {
+ displayErrorToast,
+ displaySuccessToast,
+} from "#/utils/custom-toast-handlers";
+import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
+
+export const useUpdateMemberRole = () => {
+ const queryClient = useQueryClient();
+ const { organizationId } = useSelectedOrganizationId();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: async ({
+ userId,
+ role,
+ }: {
+ userId: string;
+ role: OrganizationUserRole;
+ }) => {
+ if (!organizationId) {
+ throw new Error("Organization ID is required to update member role");
+ }
+ return organizationService.updateMember({
+ orgId: organizationId,
+ userId,
+ role,
+ });
+ },
+ onSuccess: () => {
+ displaySuccessToast(t(I18nKey.ORG$UPDATE_ROLE_SUCCESS));
+ queryClient.invalidateQueries({
+ queryKey: ["organizations", "members", organizationId],
+ });
+ },
+ onError: (error) => {
+ const errorMessage = retrieveAxiosErrorMessage(error);
+ displayErrorToast(errorMessage || t(I18nKey.ORG$UPDATE_ROLE_ERROR));
+ },
+ });
+};
diff --git a/frontend/src/hooks/mutation/use-update-organization.ts b/frontend/src/hooks/mutation/use-update-organization.ts
new file mode 100644
index 0000000000..a7ba9f1dfc
--- /dev/null
+++ b/frontend/src/hooks/mutation/use-update-organization.ts
@@ -0,0 +1,28 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { organizationService } from "#/api/organization-service/organization-service.api";
+import { useSelectedOrganizationId } from "#/context/use-selected-organization";
+
+export const useUpdateOrganization = () => {
+ const queryClient = useQueryClient();
+ const { organizationId } = useSelectedOrganizationId();
+
+ return useMutation({
+ mutationFn: (name: string) => {
+ if (!organizationId) throw new Error("Organization ID is required");
+ return organizationService.updateOrganization({
+ orgId: organizationId,
+ name,
+ });
+ },
+ onSuccess: () => {
+ // Invalidate the specific organization query
+ queryClient.invalidateQueries({
+ queryKey: ["organizations", organizationId],
+ });
+ // Invalidate the organizations list to refresh org-selector
+ queryClient.invalidateQueries({
+ queryKey: ["organizations"],
+ });
+ },
+ });
+};
diff --git a/frontend/src/hooks/organizations/use-permissions.ts b/frontend/src/hooks/organizations/use-permissions.ts
new file mode 100644
index 0000000000..f6f1e4f0c9
--- /dev/null
+++ b/frontend/src/hooks/organizations/use-permissions.ts
@@ -0,0 +1,17 @@
+import { useMemo } from "react";
+import { OrganizationUserRole } from "#/types/org";
+import { rolePermissions, PermissionKey } from "#/utils/org/permissions";
+
+export const usePermission = (role: OrganizationUserRole) => {
+ /* Memoize permissions for the role */
+ const currentPermissions = useMemo
(
+ () => rolePermissions[role],
+ [role],
+ );
+
+ /* Check if the user has a specific permission */
+ const hasPermission = (permission: PermissionKey): boolean =>
+ currentPermissions.includes(permission);
+
+ return { hasPermission };
+};
diff --git a/frontend/src/hooks/query/use-me.ts b/frontend/src/hooks/query/use-me.ts
new file mode 100644
index 0000000000..137bb151ec
--- /dev/null
+++ b/frontend/src/hooks/query/use-me.ts
@@ -0,0 +1,18 @@
+import { useQuery } from "@tanstack/react-query";
+import { useConfig } from "./use-config";
+import { organizationService } from "#/api/organization-service/organization-service.api";
+import { useSelectedOrganizationId } from "#/context/use-selected-organization";
+
+export const useMe = () => {
+ const { data: config } = useConfig();
+ const { organizationId } = useSelectedOrganizationId();
+
+ const isSaas = config?.app_mode === "saas";
+
+ return useQuery({
+ queryKey: ["organizations", organizationId, "me"],
+ queryFn: () => organizationService.getMe({ orgId: organizationId! }),
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ enabled: isSaas && !!organizationId,
+ });
+};
diff --git a/frontend/src/hooks/query/use-organization-members-count.ts b/frontend/src/hooks/query/use-organization-members-count.ts
new file mode 100644
index 0000000000..9917f390fe
--- /dev/null
+++ b/frontend/src/hooks/query/use-organization-members-count.ts
@@ -0,0 +1,24 @@
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { organizationService } from "#/api/organization-service/organization-service.api";
+import { useSelectedOrganizationId } from "#/context/use-selected-organization";
+
+interface UseOrganizationMembersCountParams {
+ email?: string;
+}
+
+export const useOrganizationMembersCount = ({
+ email,
+}: UseOrganizationMembersCountParams = {}) => {
+ const { organizationId } = useSelectedOrganizationId();
+
+ return useQuery({
+ queryKey: ["organizations", "members", "count", organizationId, email],
+ queryFn: () =>
+ organizationService.getOrganizationMembersCount({
+ orgId: organizationId!,
+ email: email || undefined,
+ }),
+ enabled: !!organizationId,
+ placeholderData: keepPreviousData,
+ });
+};
diff --git a/frontend/src/hooks/query/use-organization-members.ts b/frontend/src/hooks/query/use-organization-members.ts
new file mode 100644
index 0000000000..559f8598e2
--- /dev/null
+++ b/frontend/src/hooks/query/use-organization-members.ts
@@ -0,0 +1,30 @@
+import { keepPreviousData, useQuery } from "@tanstack/react-query";
+import { organizationService } from "#/api/organization-service/organization-service.api";
+import { useSelectedOrganizationId } from "#/context/use-selected-organization";
+
+interface UseOrganizationMembersParams {
+ page?: number;
+ limit?: number;
+ email?: string;
+}
+
+export const useOrganizationMembers = ({
+ page = 1,
+ limit = 10,
+ email,
+}: UseOrganizationMembersParams = {}) => {
+ const { organizationId } = useSelectedOrganizationId();
+
+ return useQuery({
+ queryKey: ["organizations", "members", organizationId, page, limit, email],
+ queryFn: () =>
+ organizationService.getOrganizationMembers({
+ orgId: organizationId!,
+ page,
+ limit,
+ email: email || undefined,
+ }),
+ enabled: !!organizationId,
+ placeholderData: keepPreviousData,
+ });
+};
diff --git a/frontend/src/hooks/query/use-organization-payment-info.tsx b/frontend/src/hooks/query/use-organization-payment-info.tsx
new file mode 100644
index 0000000000..736673704d
--- /dev/null
+++ b/frontend/src/hooks/query/use-organization-payment-info.tsx
@@ -0,0 +1,16 @@
+import { useQuery } from "@tanstack/react-query";
+import { organizationService } from "#/api/organization-service/organization-service.api";
+import { useSelectedOrganizationId } from "#/context/use-selected-organization";
+
+export const useOrganizationPaymentInfo = () => {
+ const { organizationId } = useSelectedOrganizationId();
+
+ return useQuery({
+ queryKey: ["organizations", organizationId, "payment"],
+ queryFn: () =>
+ organizationService.getOrganizationPaymentInfo({
+ orgId: organizationId!,
+ }),
+ enabled: !!organizationId,
+ });
+};
diff --git a/frontend/src/hooks/query/use-organization.ts b/frontend/src/hooks/query/use-organization.ts
new file mode 100644
index 0000000000..337eb8601a
--- /dev/null
+++ b/frontend/src/hooks/query/use-organization.ts
@@ -0,0 +1,14 @@
+import { useQuery } from "@tanstack/react-query";
+import { organizationService } from "#/api/organization-service/organization-service.api";
+import { useSelectedOrganizationId } from "#/context/use-selected-organization";
+
+export const useOrganization = () => {
+ const { organizationId } = useSelectedOrganizationId();
+
+ return useQuery({
+ queryKey: ["organizations", organizationId],
+ queryFn: () =>
+ organizationService.getOrganization({ orgId: organizationId! }),
+ enabled: !!organizationId,
+ });
+};
diff --git a/frontend/src/hooks/query/use-organizations.ts b/frontend/src/hooks/query/use-organizations.ts
new file mode 100644
index 0000000000..33d2b82e1f
--- /dev/null
+++ b/frontend/src/hooks/query/use-organizations.ts
@@ -0,0 +1,32 @@
+import { useQuery } from "@tanstack/react-query";
+import { organizationService } from "#/api/organization-service/organization-service.api";
+import { useIsAuthed } from "./use-is-authed";
+import { useConfig } from "./use-config";
+
+export const useOrganizations = () => {
+ const { data: userIsAuthenticated } = useIsAuthed();
+ const { data: config } = useConfig();
+
+ // Organizations are a SaaS-only feature - disable in OSS mode
+ const isOssMode = config?.app_mode === "oss";
+
+ return useQuery({
+ queryKey: ["organizations"],
+ queryFn: organizationService.getOrganizations,
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ enabled: !!userIsAuthenticated && !isOssMode,
+ select: (data) => ({
+ // Sort organizations with personal workspace first, then alphabetically by name
+ organizations: [...data.items].sort((a, b) => {
+ const aIsPersonal = a.is_personal ?? false;
+ const bIsPersonal = b.is_personal ?? false;
+ if (aIsPersonal && !bIsPersonal) return -1;
+ if (!aIsPersonal && bIsPersonal) return 1;
+ return (a.name ?? "").localeCompare(b.name ?? "", undefined, {
+ sensitivity: "base",
+ });
+ }),
+ currentOrgId: data.currentOrgId,
+ }),
+ });
+};
diff --git a/frontend/src/hooks/use-auto-select-organization.ts b/frontend/src/hooks/use-auto-select-organization.ts
new file mode 100644
index 0000000000..fd226a8ba2
--- /dev/null
+++ b/frontend/src/hooks/use-auto-select-organization.ts
@@ -0,0 +1,33 @@
+import React from "react";
+import { useSelectedOrganizationId } from "#/context/use-selected-organization";
+import { useOrganizations } from "#/hooks/query/use-organizations";
+
+/**
+ * Hook that automatically selects an organization when:
+ * - No organization is currently selected in the frontend store
+ * - Organizations data is available
+ *
+ * Selection priority:
+ * 1. Backend's current_org_id (user's last selected organization, persisted server-side)
+ * 2. First organization in the list (fallback for new users)
+ *
+ * This hook should be called from a component that always renders (e.g., root layout)
+ * to ensure organization selection happens even when the OrgSelector component is hidden.
+ */
+export function useAutoSelectOrganization() {
+ const { organizationId, setOrganizationId } = useSelectedOrganizationId();
+ const { data } = useOrganizations();
+ const organizations = data?.organizations;
+ const currentOrgId = data?.currentOrgId;
+
+ React.useEffect(() => {
+ if (!organizationId && organizations && organizations.length > 0) {
+ // Prefer backend's current_org_id (last selected org), fall back to first org
+ const initialOrgId = currentOrgId ?? organizations[0].id;
+ // Skip revalidation for initial auto-selection to avoid duplicate API calls.
+ // Revalidation is only needed when user explicitly switches organizations
+ // to redirect away from admin-only pages they may no longer have access to.
+ setOrganizationId(initialOrgId, { skipRevalidation: true });
+ }
+ }, [organizationId, organizations, currentOrgId, setOrganizationId]);
+}
diff --git a/frontend/src/hooks/use-org-type-and-access.ts b/frontend/src/hooks/use-org-type-and-access.ts
new file mode 100644
index 0000000000..5f49b9220c
--- /dev/null
+++ b/frontend/src/hooks/use-org-type-and-access.ts
@@ -0,0 +1,22 @@
+import { useSelectedOrganizationId } from "#/context/use-selected-organization";
+import { useOrganizations } from "#/hooks/query/use-organizations";
+
+export const useOrgTypeAndAccess = () => {
+ const { organizationId } = useSelectedOrganizationId();
+ const { data } = useOrganizations();
+ const organizations = data?.organizations;
+
+ const selectedOrg = organizations?.find((org) => org.id === organizationId);
+ const isPersonalOrg = selectedOrg?.is_personal === true;
+ // Team org = any org that is not explicitly marked as personal (includes undefined)
+ const isTeamOrg = !!selectedOrg && !selectedOrg.is_personal;
+ const canViewOrgRoutes = isTeamOrg && !!organizationId;
+
+ return {
+ selectedOrg,
+ isPersonalOrg,
+ isTeamOrg,
+ canViewOrgRoutes,
+ organizationId,
+ };
+};
diff --git a/frontend/src/hooks/use-settings-nav-items.ts b/frontend/src/hooks/use-settings-nav-items.ts
index fa0187251d..236d086ff6 100644
--- a/frontend/src/hooks/use-settings-nav-items.ts
+++ b/frontend/src/hooks/use-settings-nav-items.ts
@@ -1,14 +1,60 @@
import { useConfig } from "#/hooks/query/use-config";
-import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
-import { isSettingsPageHidden } from "#/routes/settings";
+import {
+ SAAS_NAV_ITEMS,
+ OSS_NAV_ITEMS,
+ SettingsNavItem,
+} from "#/constants/settings-nav";
+import { OrganizationUserRole } from "#/types/org";
+import { isBillingHidden } from "#/utils/org/billing-visibility";
+import { isSettingsPageHidden } from "#/utils/settings-utils";
+import { useMe } from "./query/use-me";
+import { usePermission } from "./organizations/use-permissions";
+import { useOrgTypeAndAccess } from "./use-org-type-and-access";
-export function useSettingsNavItems() {
+/**
+ * Build Settings navigation items based on:
+ * - app mode (saas / oss)
+ * - feature flags
+ * - active user's role
+ * - org type (personal vs team)
+ * @returns Settings Nav Items []
+ */
+export function useSettingsNavItems(): SettingsNavItem[] {
const { data: config } = useConfig();
+ const { data: user } = useMe();
+ const userRole: OrganizationUserRole = user?.role ?? "member";
+ const { hasPermission } = usePermission(userRole);
+ const { isPersonalOrg, isTeamOrg, organizationId } = useOrgTypeAndAccess();
+ const shouldHideBilling = isBillingHidden(
+ config,
+ hasPermission("view_billing"),
+ );
const isSaasMode = config?.app_mode === "saas";
const featureFlags = config?.feature_flags;
- const items = isSaasMode ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
+ let items = isSaasMode ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
- return items.filter((item) => !isSettingsPageHidden(item.to, featureFlags));
+ // First apply feature flag-based hiding
+ items = items.filter((item) => !isSettingsPageHidden(item.to, featureFlags));
+
+ // Hide billing when billing is not accessible OR when in team org
+ if (shouldHideBilling || isTeamOrg) {
+ items = items.filter((item) => item.to !== "/settings/billing");
+ }
+
+ // Hide org routes for personal orgs, missing permissions, or no org selected
+ if (!hasPermission("view_billing") || !organizationId || isPersonalOrg) {
+ items = items.filter((item) => item.to !== "/settings/org");
+ }
+
+ if (
+ !hasPermission("invite_user_to_organization") ||
+ !organizationId ||
+ isPersonalOrg
+ ) {
+ items = items.filter((item) => item.to !== "/settings/org-members");
+ }
+
+ return items;
}
diff --git a/frontend/src/hooks/use-should-hide-org-selector.ts b/frontend/src/hooks/use-should-hide-org-selector.ts
new file mode 100644
index 0000000000..27a5c6009c
--- /dev/null
+++ b/frontend/src/hooks/use-should-hide-org-selector.ts
@@ -0,0 +1,16 @@
+import { useOrganizations } from "#/hooks/query/use-organizations";
+import { useConfig } from "#/hooks/query/use-config";
+
+export function useShouldHideOrgSelector() {
+ const { data: config } = useConfig();
+ const { data } = useOrganizations();
+ const organizations = data?.organizations;
+
+ // Always hide in OSS mode - organizations are a SaaS feature
+ if (config?.app_mode === "oss") {
+ return true;
+ }
+
+ // In SaaS mode, hide if user only has one personal org
+ return organizations?.length === 1 && organizations[0]?.is_personal === true;
+}
diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts
index b968be1ec9..aac6e1b0f6 100644
--- a/frontend/src/i18n/declaration.ts
+++ b/frontend/src/i18n/declaration.ts
@@ -213,6 +213,7 @@ export enum I18nKey {
BUTTON$END_SESSION = "BUTTON$END_SESSION",
BUTTON$LAUNCH = "BUTTON$LAUNCH",
BUTTON$CANCEL = "BUTTON$CANCEL",
+ BUTTON$ADD = "BUTTON$ADD",
EXIT_PROJECT$CONFIRM = "EXIT_PROJECT$CONFIRM",
EXIT_PROJECT$TITLE = "EXIT_PROJECT$TITLE",
LANGUAGE$LABEL = "LANGUAGE$LABEL",
@@ -734,6 +735,11 @@ export enum I18nKey {
TASKS$TASK_SUGGESTIONS_INFO = "TASKS$TASK_SUGGESTIONS_INFO",
TASKS$TASK_SUGGESTIONS_TOOLTIP = "TASKS$TASK_SUGGESTIONS_TOOLTIP",
PAYMENT$SPECIFY_AMOUNT_USD = "PAYMENT$SPECIFY_AMOUNT_USD",
+ PAYMENT$ERROR_INVALID_NUMBER = "PAYMENT$ERROR_INVALID_NUMBER",
+ PAYMENT$ERROR_NEGATIVE_AMOUNT = "PAYMENT$ERROR_NEGATIVE_AMOUNT",
+ PAYMENT$ERROR_MINIMUM_AMOUNT = "PAYMENT$ERROR_MINIMUM_AMOUNT",
+ PAYMENT$ERROR_MAXIMUM_AMOUNT = "PAYMENT$ERROR_MAXIMUM_AMOUNT",
+ PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER = "PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER",
GIT$BITBUCKET_TOKEN_HELP_LINK = "GIT$BITBUCKET_TOKEN_HELP_LINK",
GIT$BITBUCKET_TOKEN_SEE_MORE_LINK = "GIT$BITBUCKET_TOKEN_SEE_MORE_LINK",
GIT$BITBUCKET_DC_TOKEN_HELP_LINK = "GIT$BITBUCKET_DC_TOKEN_HELP_LINK",
@@ -780,6 +786,7 @@ export enum I18nKey {
COMMON$PERSONAL = "COMMON$PERSONAL",
COMMON$REPOSITORIES = "COMMON$REPOSITORIES",
COMMON$ORGANIZATIONS = "COMMON$ORGANIZATIONS",
+ COMMON$ORGANIZATION = "COMMON$ORGANIZATION",
COMMON$ADD_MICROAGENT = "COMMON$ADD_MICROAGENT",
COMMON$CREATED_ON = "COMMON$CREATED_ON",
MICROAGENT_MANAGEMENT$LEARN_THIS_REPO = "MICROAGENT_MANAGEMENT$LEARN_THIS_REPO",
@@ -906,6 +913,7 @@ export enum I18nKey {
ACTION$CONFIRM_DELETE = "ACTION$CONFIRM_DELETE",
ACTION$CONFIRM_STOP = "ACTION$CONFIRM_STOP",
ACTION$CONFIRM_CLOSE = "ACTION$CONFIRM_CLOSE",
+ ACTION$CONFIRM_UPDATE = "ACTION$CONFIRM_UPDATE",
AGENT_STATUS$AGENT_STOPPED = "AGENT_STATUS$AGENT_STOPPED",
AGENT_STATUS$ERROR_OCCURRED = "AGENT_STATUS$ERROR_OCCURRED",
AGENT_STATUS$INITIALIZING = "AGENT_STATUS$INITIALIZING",
@@ -1005,6 +1013,46 @@ export enum I18nKey {
COMMON$PLAN_AGENT_DESCRIPTION = "COMMON$PLAN_AGENT_DESCRIPTION",
PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED = "PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED",
OBSERVATION_MESSAGE$SKILL_READY = "OBSERVATION_MESSAGE$SKILL_READY",
+ ORG$ORGANIZATION_NAME = "ORG$ORGANIZATION_NAME",
+ ORG$NEXT = "ORG$NEXT",
+ ORG$INVITE_USERS_DESCRIPTION = "ORG$INVITE_USERS_DESCRIPTION",
+ ORG$EMAILS = "ORG$EMAILS",
+ ORG$STATUS_INVITED = "ORG$STATUS_INVITED",
+ ORG$ROLE_ADMIN = "ORG$ROLE_ADMIN",
+ ORG$ROLE_MEMBER = "ORG$ROLE_MEMBER",
+ ORG$ROLE_OWNER = "ORG$ROLE_OWNER",
+ ORG$REMOVE = "ORG$REMOVE",
+ ORG$CONFIRM_REMOVE_MEMBER = "ORG$CONFIRM_REMOVE_MEMBER",
+ ORG$REMOVE_MEMBER_WARNING = "ORG$REMOVE_MEMBER_WARNING",
+ ORG$REMOVE_MEMBER_ERROR = "ORG$REMOVE_MEMBER_ERROR",
+ ORG$REMOVE_MEMBER_SUCCESS = "ORG$REMOVE_MEMBER_SUCCESS",
+ ORG$CONFIRM_UPDATE_ROLE = "ORG$CONFIRM_UPDATE_ROLE",
+ ORG$UPDATE_ROLE_WARNING = "ORG$UPDATE_ROLE_WARNING",
+ ORG$UPDATE_ROLE_SUCCESS = "ORG$UPDATE_ROLE_SUCCESS",
+ ORG$UPDATE_ROLE_ERROR = "ORG$UPDATE_ROLE_ERROR",
+ ORG$INVITE_MEMBERS_SUCCESS = "ORG$INVITE_MEMBERS_SUCCESS",
+ ORG$INVITE_MEMBERS_ERROR = "ORG$INVITE_MEMBERS_ERROR",
+ ORG$DUPLICATE_EMAILS_ERROR = "ORG$DUPLICATE_EMAILS_ERROR",
+ ORG$NO_EMAILS_ADDED_HINT = "ORG$NO_EMAILS_ADDED_HINT",
+ ORG$ACCOUNT = "ORG$ACCOUNT",
+ ORG$INVITE_TEAM = "ORG$INVITE_TEAM",
+ ORG$MANAGE_TEAM = "ORG$MANAGE_TEAM",
+ ORG$CHANGE_ORG_NAME = "ORG$CHANGE_ORG_NAME",
+ ORG$MODIFY_ORG_NAME_DESCRIPTION = "ORG$MODIFY_ORG_NAME_DESCRIPTION",
+ ORG$ADD_CREDITS = "ORG$ADD_CREDITS",
+ ORG$CREDITS = "ORG$CREDITS",
+ ORG$ADD = "ORG$ADD",
+ ORG$BILLING_INFORMATION = "ORG$BILLING_INFORMATION",
+ ORG$CHANGE = "ORG$CHANGE",
+ ORG$DELETE_ORGANIZATION = "ORG$DELETE_ORGANIZATION",
+ ORG$DELETE_ORGANIZATION_WARNING = "ORG$DELETE_ORGANIZATION_WARNING",
+ ORG$DELETE_ORGANIZATION_WARNING_WITH_NAME = "ORG$DELETE_ORGANIZATION_WARNING_WITH_NAME",
+ ORG$DELETE_ORGANIZATION_ERROR = "ORG$DELETE_ORGANIZATION_ERROR",
+ ACCOUNT_SETTINGS$SETTINGS = "ACCOUNT_SETTINGS$SETTINGS",
+ ORG$MANAGE_ORGANIZATION_MEMBERS = "ORG$MANAGE_ORGANIZATION_MEMBERS",
+ ORG$SELECT_ORGANIZATION_PLACEHOLDER = "ORG$SELECT_ORGANIZATION_PLACEHOLDER",
+ ORG$PERSONAL_WORKSPACE = "ORG$PERSONAL_WORKSPACE",
+ ORG$ENTER_NEW_ORGANIZATION_NAME = "ORG$ENTER_NEW_ORGANIZATION_NAME",
CONVERSATION$SHOW_SKILLS = "CONVERSATION$SHOW_SKILLS",
SKILLS_MODAL$TITLE = "SKILLS_MODAL$TITLE",
CONVERSATION$SHARE_PUBLICLY = "CONVERSATION$SHARE_PUBLICLY",
@@ -1022,6 +1070,15 @@ export enum I18nKey {
CONVERSATION$NO_HISTORY_AVAILABLE = "CONVERSATION$NO_HISTORY_AVAILABLE",
CONVERSATION$SHARED_CONVERSATION = "CONVERSATION$SHARED_CONVERSATION",
CONVERSATION$LINK_COPIED = "CONVERSATION$LINK_COPIED",
+ COMMON$TYPE_EMAIL_AND_PRESS_SPACE = "COMMON$TYPE_EMAIL_AND_PRESS_SPACE",
+ ORG$INVITE_ORG_MEMBERS = "ORG$INVITE_ORG_MEMBERS",
+ ORG$MANAGE_ORGANIZATION = "ORG$MANAGE_ORGANIZATION",
+ ORG$ORGANIZATION_MEMBERS = "ORG$ORGANIZATION_MEMBERS",
+ ORG$ALL_ORGANIZATION_MEMBERS = "ORG$ALL_ORGANIZATION_MEMBERS",
+ ORG$SEARCH_BY_EMAIL = "ORG$SEARCH_BY_EMAIL",
+ ORG$NO_MEMBERS_FOUND = "ORG$NO_MEMBERS_FOUND",
+ ORG$NO_MEMBERS_MATCHING_FILTER = "ORG$NO_MEMBERS_MATCHING_FILTER",
+ ORG$FAILED_TO_LOAD_MEMBERS = "ORG$FAILED_TO_LOAD_MEMBERS",
ONBOARDING$STEP1_TITLE = "ONBOARDING$STEP1_TITLE",
ONBOARDING$STEP1_SUBTITLE = "ONBOARDING$STEP1_SUBTITLE",
ONBOARDING$SOFTWARE_ENGINEER = "ONBOARDING$SOFTWARE_ENGINEER",
diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json
index 1817da31f7..129784a357 100644
--- a/frontend/src/i18n/translation.json
+++ b/frontend/src/i18n/translation.json
@@ -3407,6 +3407,22 @@
"de": "Abbrechen",
"uk": "Скасувати"
},
+ "BUTTON$ADD": {
+ "en": "Add",
+ "ja": "追加",
+ "zh-CN": "添加",
+ "zh-TW": "新增",
+ "ko-KR": "추가",
+ "no": "Legg til",
+ "it": "Aggiungi",
+ "pt": "Adicionar",
+ "es": "Añadir",
+ "ar": "إضافة",
+ "fr": "Ajouter",
+ "tr": "Ekle",
+ "de": "Hinzufügen",
+ "uk": "Додати"
+ },
"EXIT_PROJECT$CONFIRM": {
"en": "Exit Project",
"ja": "プロジェクトを終了",
@@ -11747,6 +11763,86 @@
"de": "Geben Sie einen USD-Betrag zum Hinzufügen an - min $10",
"uk": "Вкажіть суму в доларах США для додавання - мін $10"
},
+ "PAYMENT$ERROR_INVALID_NUMBER": {
+ "en": "Please enter a valid number",
+ "ja": "有効な数値を入力してください",
+ "zh-CN": "请输入有效数字",
+ "zh-TW": "請輸入有效數字",
+ "ko-KR": "유효한 숫자를 입력하세요",
+ "no": "Vennligst skriv inn et gyldig tall",
+ "it": "Inserisci un numero valido",
+ "pt": "Por favor, insira um número válido",
+ "es": "Por favor, ingrese un número válido",
+ "ar": "يرجى إدخال رقم صحيح",
+ "fr": "Veuillez entrer un nombre valide",
+ "tr": "Lütfen geçerli bir sayı girin",
+ "de": "Bitte geben Sie eine gültige Zahl ein",
+ "uk": "Будь ласка, введіть дійсне число"
+ },
+ "PAYMENT$ERROR_NEGATIVE_AMOUNT": {
+ "en": "Amount cannot be negative",
+ "ja": "金額は負の値にできません",
+ "zh-CN": "金额不能为负数",
+ "zh-TW": "金額不能為負數",
+ "ko-KR": "금액은 음수일 수 없습니다",
+ "no": "Beløpet kan ikke være negativt",
+ "it": "L'importo non può essere negativo",
+ "pt": "O valor não pode ser negativo",
+ "es": "El monto no puede ser negativo",
+ "ar": "لا يمكن أن يكون المبلغ سالبًا",
+ "fr": "Le montant ne peut pas être négatif",
+ "tr": "Tutar negatif olamaz",
+ "de": "Der Betrag darf nicht negativ sein",
+ "uk": "Сума не може бути від'ємною"
+ },
+ "PAYMENT$ERROR_MINIMUM_AMOUNT": {
+ "en": "Minimum amount is $10",
+ "ja": "最小金額は$10です",
+ "zh-CN": "最低金额为$10",
+ "zh-TW": "最低金額為$10",
+ "ko-KR": "최소 금액은 $10입니다",
+ "no": "Minimumsbeløpet er $10",
+ "it": "L'importo minimo è $10",
+ "pt": "O valor mínimo é $10",
+ "es": "El monto mínimo es $10",
+ "ar": "الحد الأدنى للمبلغ هو 10 دولارات",
+ "fr": "Le montant minimum est de 10 $",
+ "tr": "Minimum tutar $10'dur",
+ "de": "Der Mindestbetrag beträgt 10 $",
+ "uk": "Мінімальна сума становить $10"
+ },
+ "PAYMENT$ERROR_MAXIMUM_AMOUNT": {
+ "en": "Maximum amount is $25,000",
+ "ja": "最大金額は$25,000です",
+ "zh-CN": "最高金额为$25,000",
+ "zh-TW": "最高金額為$25,000",
+ "ko-KR": "최대 금액은 $25,000입니다",
+ "no": "Maksimalbeløpet er $25,000",
+ "it": "L'importo massimo è $25,000",
+ "pt": "O valor máximo é $25,000",
+ "es": "El monto máximo es $25,000",
+ "ar": "الحد الأقصى للمبلغ هو 25,000 دولار",
+ "fr": "Le montant maximum est de 25 000 $",
+ "tr": "Maksimum tutar $25,000'dur",
+ "de": "Der Höchstbetrag beträgt 25.000 $",
+ "uk": "Максимальна сума становить $25,000"
+ },
+ "PAYMENT$ERROR_MUST_BE_WHOLE_NUMBER": {
+ "en": "Amount must be a whole number",
+ "ja": "金額は整数である必要があります",
+ "zh-CN": "金额必须是整数",
+ "zh-TW": "金額必須是整數",
+ "ko-KR": "금액은 정수여야 합니다",
+ "no": "Beløpet må være et heltall",
+ "it": "L'importo deve essere un numero intero",
+ "pt": "O valor deve ser um número inteiro",
+ "es": "El monto debe ser un número entero",
+ "ar": "يجب أن يكون المبلغ رقمًا صحيحًا",
+ "fr": "Le montant doit être un nombre entier",
+ "tr": "Tutar tam sayı olmalıdır",
+ "de": "Der Betrag muss eine ganze Zahl sein",
+ "uk": "Сума повинна бути цілим числом"
+ },
"GIT$BITBUCKET_TOKEN_HELP_LINK": {
"en": "Bitbucket token help link",
"ja": "Bitbucketトークンヘルプリンク",
@@ -12483,6 +12579,22 @@
"de": "Organisationen",
"uk": "Організації"
},
+ "COMMON$ORGANIZATION": {
+ "en": "Organization",
+ "ja": "組織",
+ "zh-CN": "组织",
+ "zh-TW": "組織",
+ "ko-KR": "조직",
+ "no": "Organisasjon",
+ "it": "Organizzazione",
+ "pt": "Organização",
+ "es": "Organización",
+ "ar": "المؤسسة",
+ "fr": "Organisation",
+ "tr": "Organizasyon",
+ "de": "Organisation",
+ "uk": "Організація"
+ },
"COMMON$ADD_MICROAGENT": {
"en": "Add Microagent",
"ja": "マイクロエージェントを追加",
@@ -14499,6 +14611,22 @@
"tr": "Kapatmayı Onayla",
"uk": "Підтвердити закриття"
},
+ "ACTION$CONFIRM_UPDATE": {
+ "en": "Confirm Update",
+ "ja": "更新を確認",
+ "zh-CN": "确认更新",
+ "zh-TW": "確認更新",
+ "ko-KR": "업데이트 확인",
+ "fr": "Confirmer la mise à jour",
+ "es": "Confirmar actualización",
+ "de": "Aktualisierung bestätigen",
+ "it": "Conferma aggiornamento",
+ "pt": "Confirmar atualização",
+ "ar": "تأكيد التحديث",
+ "no": "Bekreft oppdatering",
+ "tr": "Güncellemeyi Onayla",
+ "uk": "Підтвердити оновлення"
+ },
"AGENT_STATUS$AGENT_STOPPED": {
"en": "Agent stopped",
"ja": "エージェントが停止しました。",
@@ -16083,6 +16211,646 @@
"de": "Fähigkeit bereit",
"uk": "Навичка готова"
},
+ "ORG$ORGANIZATION_NAME": {
+ "en": "Organization Name",
+ "ja": "組織名",
+ "zh-CN": "组织名称",
+ "zh-TW": "組織名稱",
+ "ko-KR": "조직 이름",
+ "no": "Organisasjonsnavn",
+ "it": "Nome organizzazione",
+ "pt": "Nome da organização",
+ "es": "Nombre de la organización",
+ "ar": "اسم المنظمة",
+ "fr": "Nom de l'organisation",
+ "tr": "Organizasyon Adı",
+ "de": "Organisationsname",
+ "uk": "Назва організації"
+ },
+ "ORG$NEXT": {
+ "en": "Next",
+ "ja": "次へ",
+ "zh-CN": "下一步",
+ "zh-TW": "下一步",
+ "ko-KR": "다음",
+ "no": "Neste",
+ "it": "Avanti",
+ "pt": "Próximo",
+ "es": "Siguiente",
+ "ar": "التالي",
+ "fr": "Suivant",
+ "tr": "İleri",
+ "de": "Weiter",
+ "uk": "Далі"
+ },
+ "ORG$INVITE_USERS_DESCRIPTION": {
+ "en": "Invite colleagues using their email address",
+ "ja": "メールアドレスを使用して同僚を招待",
+ "zh-CN": "使用电子邮件地址邀请同事",
+ "zh-TW": "使用電子郵件地址邀請同事",
+ "ko-KR": "이메일 주소로 동료 초대",
+ "no": "Inviter kolleger med e-postadresse",
+ "it": "Invita colleghi usando il loro indirizzo email",
+ "pt": "Convide colegas usando o endereço de email",
+ "es": "Invita a colegas usando su dirección de correo",
+ "ar": "دعوة الزملاء باستخدام عنوان بريدهم الإلكتروني",
+ "fr": "Invitez des collègues en utilisant leur adresse email",
+ "tr": "E-posta adresi kullanarak meslektaşlarını davet et",
+ "de": "Laden Sie Kollegen per E-Mail-Adresse ein",
+ "uk": "Запросіть колег за їхньою електронною адресою"
+ },
+ "ORG$EMAILS": {
+ "en": "Emails",
+ "ja": "メール",
+ "zh-CN": "电子邮件",
+ "zh-TW": "電子郵件",
+ "ko-KR": "이메일",
+ "no": "E-poster",
+ "it": "Email",
+ "pt": "E-mails",
+ "es": "Correos electrónicos",
+ "ar": "رسائل البريد الإلكتروني",
+ "fr": "E-mails",
+ "tr": "E-postalar",
+ "de": "E-Mails",
+ "uk": "Електронні листи"
+ },
+ "ORG$STATUS_INVITED": {
+ "en": "invited",
+ "ja": "招待済み",
+ "zh-CN": "已邀请",
+ "zh-TW": "已邀請",
+ "ko-KR": "초대됨",
+ "no": "invitert",
+ "it": "invitato",
+ "pt": "convidado",
+ "es": "invitado",
+ "ar": "تمت الدعوة",
+ "fr": "invité",
+ "tr": "davet edildi",
+ "de": "eingeladen",
+ "uk": "запрошений"
+ },
+ "ORG$ROLE_ADMIN": {
+ "en": "admin",
+ "ja": "管理者",
+ "zh-CN": "管理员",
+ "zh-TW": "管理員",
+ "ko-KR": "관리자",
+ "no": "admin",
+ "it": "admin",
+ "pt": "admin",
+ "es": "admin",
+ "ar": "مدير",
+ "fr": "admin",
+ "tr": "yönetici",
+ "de": "Admin",
+ "uk": "адміністратор"
+ },
+ "ORG$ROLE_MEMBER": {
+ "en": "member",
+ "ja": "メンバー",
+ "zh-CN": "成员",
+ "zh-TW": "成員",
+ "ko-KR": "멤버",
+ "no": "medlem",
+ "it": "membro",
+ "pt": "membro",
+ "es": "miembro",
+ "ar": "عضو",
+ "fr": "membre",
+ "tr": "üye",
+ "de": "Mitglied",
+ "uk": "учасник"
+ },
+ "ORG$ROLE_OWNER": {
+ "en": "owner",
+ "ja": "所有者",
+ "zh-CN": "所有者",
+ "zh-TW": "所有者",
+ "ko-KR": "소유자",
+ "no": "eier",
+ "it": "proprietario",
+ "pt": "proprietário",
+ "es": "propietario",
+ "ar": "المالك",
+ "fr": "propriétaire",
+ "tr": "sahip",
+ "de": "Eigentümer",
+ "uk": "власник"
+ },
+ "ORG$REMOVE": {
+ "en": "remove",
+ "ja": "削除",
+ "zh-CN": "移除",
+ "zh-TW": "移除",
+ "ko-KR": "제거",
+ "no": "fjern",
+ "it": "rimuovi",
+ "pt": "remover",
+ "es": "eliminar",
+ "ar": "إزالة",
+ "fr": "supprimer",
+ "tr": "kaldır",
+ "de": "entfernen",
+ "uk": "видалити"
+ },
+ "ORG$CONFIRM_REMOVE_MEMBER": {
+ "en": "Confirm Remove Member",
+ "ja": "メンバー削除の確認",
+ "zh-CN": "确认移除成员",
+ "zh-TW": "確認移除成員",
+ "ko-KR": "멤버 제거 확인",
+ "no": "Bekreft fjerning av medlem",
+ "it": "Conferma rimozione membro",
+ "pt": "Confirmar remoção de membro",
+ "es": "Confirmar eliminación de miembro",
+ "ar": "تأكيد إزالة العضو",
+ "fr": "Confirmer la suppression du membre",
+ "tr": "Üye kaldırma onayı",
+ "de": "Mitglied entfernen bestätigen",
+ "uk": "Підтвердити видалення учасника"
+ },
+ "ORG$REMOVE_MEMBER_WARNING": {
+ "en": "Are you sure you want to remove {{email}} from this organization? This action cannot be undone.",
+ "ja": "{{email}} をこの組織から削除してもよろしいですか?この操作は元に戻せません。",
+ "zh-CN": "您确定要将 {{email}} 从此组织中移除吗?此操作无法撤消。",
+ "zh-TW": "您確定要將 {{email}} 從此組織中移除嗎?此操作無法撤消。",
+ "ko-KR": "이 조직에서 {{email}}을(를) 제거하시겠습니까? 이 작업은 취소할 수 없습니다.",
+ "no": "Er du sikker på at du vil fjerne {{email}} fra denne organisasjonen? Denne handlingen kan ikke angres.",
+ "it": "Sei sicuro di voler rimuovere {{email}} da questa organizzazione? Questa azione non può essere annullata.",
+ "pt": "Tem certeza de que deseja remover {{email}} desta organização? Esta ação não pode ser desfeita.",
+ "es": "¿Está seguro de que desea eliminar a {{email}} de esta organización? Esta acción no se puede deshacer.",
+ "ar": "هل أنت متأكد من أنك تريد إزالة {{email}} من هذه المنظمة؟ لا يمكن التراجع عن هذا الإجراء.",
+ "fr": "Êtes-vous sûr de vouloir supprimer {{email}} de cette organisation ? Cette action ne peut pas être annulée.",
+ "tr": "{{email}} kullanıcısını bu organizasyondan kaldırmak istediğinizden emin misiniz? Bu işlem geri alınamaz.",
+ "de": "Sind Sie sicher, dass Sie {{email}} aus dieser Organisation entfernen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
+ "uk": "Ви впевнені, що хочете видалити {{email}} з цієї організації? Цю дію неможливо скасувати."
+ },
+ "ORG$REMOVE_MEMBER_ERROR": {
+ "en": "Failed to remove member from organization. Please try again.",
+ "ja": "組織からメンバーを削除できませんでした。もう一度お試しください。",
+ "zh-CN": "无法从组织中移除成员。请重试。",
+ "zh-TW": "無法從組織中移除成員。請重試。",
+ "ko-KR": "조직에서 멤버를 제거하지 못했습니다. 다시 시도해 주세요.",
+ "no": "Kunne ikke fjerne medlem fra organisasjonen. Vennligst prøv igjen.",
+ "it": "Impossibile rimuovere il membro dall'organizzazione. Riprova.",
+ "pt": "Falha ao remover membro da organização. Por favor, tente novamente.",
+ "es": "No se pudo eliminar el miembro de la organización. Por favor, inténtelo de nuevo.",
+ "ar": "فشل في إزالة العضو من المنظمة. يرجى المحاولة مرة أخرى.",
+ "fr": "Échec de la suppression du membre de l'organisation. Veuillez réessayer.",
+ "tr": "Üye organizasyondan kaldırılamadı. Lütfen tekrar deneyin.",
+ "de": "Mitglied konnte nicht aus der Organisation entfernt werden. Bitte versuchen Sie es erneut.",
+ "uk": "Не вдалося видалити учасника з організації. Будь ласка, спробуйте ще раз."
+ },
+ "ORG$REMOVE_MEMBER_SUCCESS": {
+ "en": "Member removed successfully",
+ "ja": "メンバーを削除しました",
+ "zh-CN": "成员已成功移除",
+ "zh-TW": "成員已成功移除",
+ "ko-KR": "멤버가 성공적으로 제거되었습니다",
+ "no": "Medlem fjernet",
+ "it": "Membro rimosso con successo",
+ "pt": "Membro removido com sucesso",
+ "es": "Miembro eliminado correctamente",
+ "ar": "تمت إزالة العضو بنجاح",
+ "fr": "Membre supprimé avec succès",
+ "tr": "Üye başarıyla kaldırıldı",
+ "de": "Mitglied erfolgreich entfernt",
+ "uk": "Учасника успішно видалено"
+ },
+ "ORG$CONFIRM_UPDATE_ROLE": {
+ "en": "Confirm Role Update",
+ "ja": "役割の更新を確認",
+ "zh-CN": "确认更新角色",
+ "zh-TW": "確認更新角色",
+ "ko-KR": "역할 업데이트 확인",
+ "no": "Bekreft rolleoppdatering",
+ "it": "Conferma aggiornamento ruolo",
+ "pt": "Confirmar atualização de função",
+ "es": "Confirmar actualización de rol",
+ "ar": "تأكيد تحديث الدور",
+ "fr": "Confirmer la mise à jour du rôle",
+ "tr": "Rol güncellemesini onayla",
+ "de": "Rollenaktualisierung bestätigen",
+ "uk": "Підтвердити оновлення ролі"
+ },
+ "ORG$UPDATE_ROLE_WARNING": {
+ "en": "Are you sure you want to change the role of {{email}} to {{role}}?",
+ "ja": "{{email}} の役割を {{role}} に変更してもよろしいですか?",
+ "zh-CN": "您确定要将 {{email}} 的角色更改为 {{role}} 吗?",
+ "zh-TW": "您確定要將 {{email}} 的角色更改為 {{role}} 嗎?",
+ "ko-KR": "{{email}}의 역할을 {{role}}(으)로 변경하시겠습니까?",
+ "no": "Er du sikker på at du vil endre rollen til {{email}} til {{role}}?",
+ "it": "Sei sicuro di voler cambiare il ruolo di {{email}} in {{role}}?",
+ "pt": "Tem certeza de que deseja alterar a função de {{email}} para {{role}}?",
+ "es": "¿Está seguro de que desea cambiar el rol de {{email}} a {{role}}?",
+ "ar": "هل أنت متأكد من أنك تريد تغيير دور {{email}} إلى {{role}}؟",
+ "fr": "Êtes-vous sûr de vouloir changer le rôle de {{email}} en {{role}} ?",
+ "tr": "{{email}} kullanıcısının rolünü {{role}} olarak değiştirmek istediğinizden emin misiniz?",
+ "de": "Sind Sie sicher, dass Sie die Rolle von {{email}} auf {{role}} ändern möchten?",
+ "uk": "Ви впевнені, що хочете змінити роль {{email}} на {{role}}?"
+ },
+ "ORG$UPDATE_ROLE_SUCCESS": {
+ "en": "Role updated successfully",
+ "ja": "役割を更新しました",
+ "zh-CN": "角色已成功更新",
+ "zh-TW": "角色已成功更新",
+ "ko-KR": "역할이 성공적으로 업데이트되었습니다",
+ "no": "Rolle oppdatert",
+ "it": "Ruolo aggiornato con successo",
+ "pt": "Função atualizada com sucesso",
+ "es": "Rol actualizado correctamente",
+ "ar": "تم تحديث الدور بنجاح",
+ "fr": "Rôle mis à jour avec succès",
+ "tr": "Rol başarıyla güncellendi",
+ "de": "Rolle erfolgreich aktualisiert",
+ "uk": "Роль успішно оновлено"
+ },
+ "ORG$UPDATE_ROLE_ERROR": {
+ "en": "Failed to update role. Please try again.",
+ "ja": "役割の更新に失敗しました。もう一度お試しください。",
+ "zh-CN": "更新角色失败。请重试。",
+ "zh-TW": "更新角色失敗。請重試。",
+ "ko-KR": "역할 업데이트에 실패했습니다. 다시 시도해 주세요.",
+ "no": "Kunne ikke oppdatere rolle. Vennligst prøv igjen.",
+ "it": "Impossibile aggiornare il ruolo. Riprova.",
+ "pt": "Falha ao atualizar função. Por favor, tente novamente.",
+ "es": "No se pudo actualizar el rol. Por favor, inténtelo de nuevo.",
+ "ar": "فشل في تحديث الدور. يرجى المحاولة مرة أخرى.",
+ "fr": "Échec de la mise à jour du rôle. Veuillez réessayer.",
+ "tr": "Rol güncellenemedi. Lütfen tekrar deneyin.",
+ "de": "Rolle konnte nicht aktualisiert werden. Bitte versuchen Sie es erneut.",
+ "uk": "Не вдалося оновити роль. Будь ласка, спробуйте ще раз."
+ },
+ "ORG$INVITE_MEMBERS_SUCCESS": {
+ "en": "Invitations sent successfully",
+ "ja": "招待を送信しました",
+ "zh-CN": "邀请发送成功",
+ "zh-TW": "邀請發送成功",
+ "ko-KR": "초대가 성공적으로 전송되었습니다",
+ "no": "Invitasjoner sendt",
+ "it": "Inviti inviati con successo",
+ "pt": "Convites enviados com sucesso",
+ "es": "Invitaciones enviadas correctamente",
+ "ar": "تم إرسال الدعوات بنجاح",
+ "fr": "Invitations envoyées avec succès",
+ "tr": "Davetler başarıyla gönderildi",
+ "de": "Einladungen erfolgreich gesendet",
+ "uk": "Запрошення успішно надіслано"
+ },
+ "ORG$INVITE_MEMBERS_ERROR": {
+ "en": "Failed to send invitations. Please try again.",
+ "ja": "招待の送信に失敗しました。もう一度お試しください。",
+ "zh-CN": "发送邀请失败。请重试。",
+ "zh-TW": "發送邀請失敗。請重試。",
+ "ko-KR": "초대 전송에 실패했습니다. 다시 시도해 주세요.",
+ "no": "Kunne ikke sende invitasjoner. Vennligst prøv igjen.",
+ "it": "Impossibile inviare gli inviti. Riprova.",
+ "pt": "Falha ao enviar convites. Por favor, tente novamente.",
+ "es": "No se pudieron enviar las invitaciones. Por favor, inténtelo de nuevo.",
+ "ar": "فشل في إرسال الدعوات. يرجى المحاولة مرة أخرى.",
+ "fr": "Échec de l'envoi des invitations. Veuillez réessayer.",
+ "tr": "Davetler gönderilemedi. Lütfen tekrar deneyin.",
+ "de": "Einladungen konnten nicht gesendet werden. Bitte versuchen Sie es erneut.",
+ "uk": "Не вдалося надіслати запрошення. Будь ласка, спробуйте ще раз."
+ },
+ "ORG$DUPLICATE_EMAILS_ERROR": {
+ "en": "Duplicate email addresses are not allowed",
+ "ja": "重複するメールアドレスは許可されていません",
+ "zh-CN": "不允许重复的电子邮件地址",
+ "zh-TW": "不允許重複的電子郵件地址",
+ "ko-KR": "중복된 이메일 주소는 허용되지 않습니다",
+ "no": "Dupliserte e-postadresser er ikke tillatt",
+ "it": "Gli indirizzi email duplicati non sono consentiti",
+ "pt": "Endereços de e-mail duplicados não são permitidos",
+ "es": "No se permiten direcciones de correo electrónico duplicadas",
+ "ar": "لا يُسمح بعناوين البريد الإلكتروني المكررة",
+ "fr": "Les adresses e-mail en double ne sont pas autorisées",
+ "tr": "Yinelenen e-posta adreslerine izin verilmiyor",
+ "de": "Doppelte E-Mail-Adressen sind nicht erlaubt",
+ "uk": "Дублікати електронних адрес не допускаються"
+ },
+ "ORG$NO_EMAILS_ADDED_HINT": {
+ "en": "Please type emails and then press space.",
+ "ja": "メールアドレスを入力してからスペースを押してください。",
+ "zh-CN": "请输入邮箱然后按空格键。",
+ "zh-TW": "請輸入電子郵件然後按空白鍵。",
+ "ko-KR": "이메일을 입력한 후 스페이스바를 눌러주세요.",
+ "no": "Skriv inn e-post og trykk mellomrom.",
+ "it": "Digita le email e poi premi spazio.",
+ "pt": "Digite os e-mails e pressione espaço.",
+ "es": "Escriba los correos electrónicos y luego presione espacio.",
+ "ar": "يرجى كتابة البريد الإلكتروني ثم الضغط على مفتاح المسافة.",
+ "fr": "Veuillez saisir les e-mails puis appuyer sur espace.",
+ "tr": "Lütfen e-postaları yazın ve ardından boşluk tuşuna basın.",
+ "de": "Bitte geben Sie E-Mails ein und drücken Sie dann die Leertaste.",
+ "uk": "Будь ласка, введіть електронні адреси та натисніть пробіл."
+ },
+ "ORG$ACCOUNT": {
+ "en": "Account",
+ "ja": "アカウント",
+ "zh-CN": "账户",
+ "zh-TW": "帳戶",
+ "ko-KR": "계정",
+ "no": "Konto",
+ "it": "Account",
+ "pt": "Conta",
+ "es": "Cuenta",
+ "ar": "الحساب",
+ "fr": "Compte",
+ "tr": "Hesap",
+ "de": "Konto",
+ "uk": "Обліковий запис"
+ },
+ "ORG$INVITE_TEAM": {
+ "en": "Invite Team",
+ "ja": "チームを招待",
+ "zh-CN": "邀请团队",
+ "zh-TW": "邀請團隊",
+ "ko-KR": "팀 초대",
+ "no": "Inviter team",
+ "it": "Invita team",
+ "pt": "Convidar equipe",
+ "es": "Invitar equipo",
+ "ar": "دعوة الفريق",
+ "fr": "Inviter l'équipe",
+ "tr": "Takım Davet Et",
+ "de": "Team einladen",
+ "uk": "Запросити команду"
+ },
+ "ORG$MANAGE_TEAM": {
+ "en": "Manage Team",
+ "ja": "チーム管理",
+ "zh-CN": "管理团队",
+ "zh-TW": "管理團隊",
+ "ko-KR": "팀 관리",
+ "no": "Administrer team",
+ "it": "Gestisci team",
+ "pt": "Gerenciar equipe",
+ "es": "Administrar equipo",
+ "ar": "إدارة الفريق",
+ "fr": "Gérer l'équipe",
+ "tr": "Takımı Yönet",
+ "de": "Team verwalten",
+ "uk": "Керувати командою"
+ },
+ "ORG$CHANGE_ORG_NAME": {
+ "en": "Change Organization Name",
+ "ja": "組織名を変更",
+ "zh-CN": "更改组织名称",
+ "zh-TW": "更改組織名稱",
+ "ko-KR": "조직 이름 변경",
+ "no": "Endre organisasjonsnavn",
+ "it": "Cambia nome dell'organizzazione",
+ "pt": "Alterar nome da organização",
+ "es": "Cambiar nombre de la organización",
+ "ar": "تغيير اسم المنظمة",
+ "fr": "Changer le nom de l'organisation",
+ "tr": "Organizasyon Adını Değiştir",
+ "de": "Organisationsnamen ändern",
+ "uk": "Змінити назву організації"
+ },
+ "ORG$MODIFY_ORG_NAME_DESCRIPTION": {
+ "en": "Modify your Organization Name and Save",
+ "ja": "組織名を変更して保存します",
+ "zh-CN": "修改你的组织名称并保存",
+ "zh-TW": "修改您的組織名稱並儲存",
+ "ko-KR": "조직 이름을 수정하고 저장하세요",
+ "no": "Endre organisasjonsnavnet ditt og lagre",
+ "it": "Modifica il nome della tua organizzazione e salva",
+ "pt": "Modifique o nome da sua organização e salve",
+ "es": "Modifica el nombre de tu organización y guarda",
+ "ar": "قم بتعديل اسم المنظمة الخاصة بك وحفظه",
+ "fr": "Modifiez le nom de votre organisation et enregistrez",
+ "tr": "Organizasyon adınızı değiştirin ve kaydedin",
+ "de": "Ändern Sie den Namen Ihrer Organisation und speichern Sie ihn",
+ "uk": "Змініть назву вашої організації та збережіть"
+ },
+ "ORG$ADD_CREDITS": {
+ "en": "Add Credits",
+ "ja": "クレジットを追加",
+ "zh-CN": "添加积分",
+ "zh-TW": "新增點數",
+ "ko-KR": "크레딧 추가",
+ "no": "Legg til kreditter",
+ "it": "Aggiungi crediti",
+ "pt": "Adicionar créditos",
+ "es": "Añadir créditos",
+ "ar": "إضافة رصيد",
+ "fr": "Ajouter des crédits",
+ "tr": "Kredi Ekle",
+ "de": "Credits hinzufügen",
+ "uk": "Додати кредити"
+ },
+ "ORG$CREDITS": {
+ "en": "Credits",
+ "ja": "クレジット",
+ "zh-CN": "积分",
+ "zh-TW": "點數",
+ "ko-KR": "크레딧",
+ "no": "Kreditter",
+ "it": "Crediti",
+ "pt": "Créditos",
+ "es": "Créditos",
+ "ar": "الرصيد",
+ "fr": "Crédits",
+ "tr": "Krediler",
+ "de": "Credits",
+ "uk": "Кредити"
+ },
+ "ORG$ADD": {
+ "en": "+ Add",
+ "ja": "+ 追加",
+ "zh-CN": "+ 添加",
+ "zh-TW": "+ 新增",
+ "ko-KR": "+ 추가",
+ "no": "+ Legg til",
+ "it": "+ Aggiungi",
+ "pt": "+ Adicionar",
+ "es": "+ Añadir",
+ "ar": "+ إضافة",
+ "fr": "+ Ajouter",
+ "tr": "+ Ekle",
+ "de": "+ Hinzufügen",
+ "uk": "+ Додати"
+ },
+ "ORG$BILLING_INFORMATION": {
+ "en": "Billing Information",
+ "ja": "請求情報",
+ "zh-CN": "账单信息",
+ "zh-TW": "帳單資訊",
+ "ko-KR": "결제 정보",
+ "no": "Faktureringsinformasjon",
+ "it": "Informazioni di fatturazione",
+ "pt": "Informações de cobrança",
+ "es": "Información de facturación",
+ "ar": "معلومات الفوترة",
+ "fr": "Informations de facturation",
+ "tr": "Fatura Bilgisi",
+ "de": "Rechnungsinformationen",
+ "uk": "Платіжна інформація"
+ },
+ "ORG$CHANGE": {
+ "en": "Change",
+ "ja": "変更",
+ "zh-CN": "更改",
+ "zh-TW": "變更",
+ "ko-KR": "변경",
+ "no": "Endre",
+ "it": "Modifica",
+ "pt": "Alterar",
+ "es": "Cambiar",
+ "ar": "تغيير",
+ "fr": "Modifier",
+ "tr": "Değiştir",
+ "de": "Ändern",
+ "uk": "Змінити"
+ },
+ "ORG$DELETE_ORGANIZATION": {
+ "en": "Delete Organization",
+ "ja": "組織を削除",
+ "zh-CN": "删除组织",
+ "zh-TW": "刪除組織",
+ "ko-KR": "조직 삭제",
+ "no": "Slett organisasjon",
+ "it": "Elimina organizzazione",
+ "pt": "Excluir organização",
+ "es": "Eliminar organización",
+ "ar": "حذف المنظمة",
+ "fr": "Supprimer l'organisation",
+ "tr": "Organizasyonu Sil",
+ "de": "Organisation löschen",
+ "uk": "Видалити організацію"
+ },
+ "ORG$DELETE_ORGANIZATION_WARNING": {
+ "en": "Are you sure you want to delete this organization? This action cannot be undone.",
+ "ja": "この組織を削除してもよろしいですか?この操作は元に戻せません。",
+ "zh-CN": "您确定要删除此组织吗?此操作无法撤消。",
+ "zh-TW": "您確定要刪除此組織嗎?此操作無法撤銷。",
+ "ko-KR": "이 조직을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
+ "no": "Er du sikker på at du vil slette denne organisasjonen? Denne handlingen kan ikke angres.",
+ "it": "Sei sicuro di voler eliminare questa organizzazione? Questa azione non può essere annullata.",
+ "pt": "Tem certeza de que deseja excluir esta organização? Esta ação não pode ser desfeita.",
+ "es": "¿Está seguro de que desea eliminar esta organización? Esta acción no se puede deshacer.",
+ "ar": "هل أنت متأكد من أنك تريد حذف هذه المنظمة؟ لا يمكن التراجع عن هذا الإجراء.",
+ "fr": "Êtes-vous sûr de vouloir supprimer cette organisation ? Cette action ne peut pas être annulée.",
+ "tr": "Bu organizasyonu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
+ "de": "Sind Sie sicher, dass Sie diese Organisation löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
+ "uk": "Ви впевнені, що хочете видалити цю організацію? Цю дію не можна скасувати."
+ },
+ "ORG$DELETE_ORGANIZATION_WARNING_WITH_NAME": {
+ "en": "Are you sure you want to delete the \"{{name}}\" organization? This action cannot be undone.",
+ "ja": "「{{name}}」組織を削除してもよろしいですか?この操作は元に戻せません。",
+ "zh-CN": "您确定要删除\"{{name}}\"组织吗?此操作无法撤消。",
+ "zh-TW": "您確定要刪除「{{name}}」組織嗎?此操作無法撤銷。",
+ "ko-KR": "\"{{name}}\" 조직을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.",
+ "no": "Er du sikker på at du vil slette organisasjonen \"{{name}}\"? Denne handlingen kan ikke angres.",
+ "it": "Sei sicuro di voler eliminare l'organizzazione \"{{name}}\"? Questa azione non può essere annullata.",
+ "pt": "Tem certeza de que deseja excluir a organização \"{{name}}\"? Esta ação não pode ser desfeita.",
+ "es": "¿Está seguro de que desea eliminar la organización \"{{name}}\"? Esta acción no se puede deshacer.",
+ "ar": "هل أنت متأكد من أنك تريد حذف المنظمة \"{{name}}\"؟ لا يمكن التراجع عن هذا الإجراء.",
+ "fr": "Êtes-vous sûr de vouloir supprimer l'organisation \\u00AB {{name}} \\u00BB ? Cette action ne peut pas être annulée.",
+ "tr": "\"{{name}}\" organizasyonunu silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
+ "de": "Sind Sie sicher, dass Sie die Organisation \\u201E{{name}}\\u201C löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
+ "uk": "Ви впевнені, що хочете видалити організацію \\u00AB{{name}}\\u00BB? Цю дію не можна скасувати."
+ },
+ "ORG$DELETE_ORGANIZATION_ERROR": {
+ "en": "Failed to delete organization",
+ "ja": "組織の削除に失敗しました",
+ "zh-CN": "删除组织失败",
+ "zh-TW": "刪除組織失敗",
+ "ko-KR": "조직 삭제에 실패했습니다",
+ "no": "Kunne ikke slette organisasjonen",
+ "it": "Impossibile eliminare l'organizzazione",
+ "pt": "Falha ao excluir a organização",
+ "es": "Error al eliminar la organización",
+ "ar": "فشل في حذف المنظمة",
+ "fr": "Échec de la suppression de l'organisation",
+ "tr": "Organizasyon silinemedi",
+ "de": "Organisation konnte nicht gelöscht werden",
+ "uk": "Не вдалося видалити організацію"
+ },
+ "ACCOUNT_SETTINGS$SETTINGS": {
+ "en": "Settings",
+ "ja": "設定",
+ "zh-CN": "设置",
+ "zh-TW": "設定",
+ "ko-KR": "설정",
+ "no": "Innstillinger",
+ "it": "Impostazioni",
+ "pt": "Configurações",
+ "es": "Configuración",
+ "ar": "الإعدادات",
+ "fr": "Paramètres",
+ "tr": "Ayarlar",
+ "de": "Einstellungen",
+ "uk": "Налаштування"
+ },
+ "ORG$MANAGE_ORGANIZATION_MEMBERS": {
+ "en": "Manage Organization Members",
+ "ja": "組織メンバーの管理",
+ "zh-CN": "管理组织成员",
+ "zh-TW": "管理組織成員",
+ "ko-KR": "조직 구성원 관리",
+ "no": "Administrer organisasjonsmedlemmer",
+ "it": "Gestisci membri dell'organizzazione",
+ "pt": "Gerenciar membros da organização",
+ "es": "Gestionar miembros de la organización",
+ "ar": "إدارة أعضاء المنظمة",
+ "fr": "Gérer les membres de l'organisation",
+ "tr": "Organizasyon Üyelerini Yönet",
+ "de": "Organisationsmitglieder verwalten",
+ "uk": "Керувати учасниками організації"
+ },
+ "ORG$SELECT_ORGANIZATION_PLACEHOLDER": {
+ "en": "Please select an organization",
+ "ja": "組織を選択してください",
+ "zh-CN": "请选择一个组织",
+ "zh-TW": "請選擇一個組織",
+ "ko-KR": "조직을 선택해 주세요",
+ "no": "Vennligst velg en organisasjon",
+ "it": "Seleziona un'organizzazione",
+ "pt": "Por favor, selecione uma organização",
+ "es": "Por favor, seleccione una organización",
+ "ar": "يرجى اختيار منظمة",
+ "fr": "Veuillez sélectionner une organisation",
+ "tr": "Lütfen bir organizasyon seçin",
+ "de": "Bitte wählen Sie eine Organisation",
+ "uk": "Будь ласка, виберіть організацію"
+ },
+ "ORG$PERSONAL_WORKSPACE": {
+ "en": "Personal Workspace",
+ "ja": "個人ワークスペース",
+ "zh-CN": "个人工作区",
+ "zh-TW": "個人工作區",
+ "ko-KR": "개인 워크스페이스",
+ "no": "Personlig arbeidsområde",
+ "it": "Area di lavoro personale",
+ "pt": "Área de trabalho pessoal",
+ "es": "Espacio de trabajo personal",
+ "ar": "مساحة العمل الشخصية",
+ "fr": "Espace de travail personnel",
+ "tr": "Kişisel çalışma alanı",
+ "de": "Persönlicher Arbeitsbereich",
+ "uk": "Особистий робочий простір"
+ },
+ "ORG$ENTER_NEW_ORGANIZATION_NAME": {
+ "en": "Enter new organization name",
+ "ja": "新しい組織名を入力してください",
+ "zh-CN": "请输入新的组织名称",
+ "zh-TW": "請輸入新的組織名稱",
+ "ko-KR": "새 조직 이름을 입력하세요",
+ "no": "Skriv inn nytt organisasjonsnavn",
+ "it": "Inserisci il nuovo nome dell'organizzazione",
+ "pt": "Digite o novo nome da organização",
+ "es": "Ingrese el nuevo nombre de la organización",
+ "ar": "أدخل اسم المنظمة الجديد",
+ "fr": "Entrez le nouveau nom de l'organisation",
+ "tr": "Yeni organizasyon adını girin",
+ "de": "Geben Sie den neuen Organisationsnamen ein",
+ "uk": "Введіть нову назву організації"
+ },
"CONVERSATION$SHOW_SKILLS": {
"en": "Show Available Skills",
"ja": "利用可能なスキルを表示",
@@ -16355,6 +17123,150 @@
"de": "Link in die Zwischenablage kopiert",
"uk": "Посилання скопійовано в буфер обміну"
},
+ "COMMON$TYPE_EMAIL_AND_PRESS_SPACE": {
+ "en": "Type email and press Space",
+ "ja": "メールアドレスを入力してスペースキーを押してください",
+ "zh-CN": "输入邮箱并按空格键",
+ "zh-TW": "輸入電子郵件並按空白鍵",
+ "ko-KR": "이메일을 입력하고 스페이스바를 누르세요",
+ "no": "Skriv inn e-post og trykk på mellomromstasten",
+ "it": "Digita l'e-mail e premi Spazio",
+ "pt": "Digite o e-mail e pressione Espaço",
+ "es": "Escribe el correo electrónico y pulsa Espacio",
+ "ar": "اكتب البريد الإلكتروني واضغط على مفتاح المسافة",
+ "fr": "Tapez l'e-mail et appuyez sur Espace",
+ "tr": "E-postu yazıp Boşluk tuşuna basın",
+ "de": "E-Mail eingeben und Leertaste drücken",
+ "uk": "Введіть e-mail і натисніть Пробіл"
+ },
+ "ORG$INVITE_ORG_MEMBERS": {
+ "en": "Invite Organization Members",
+ "ja": "組織メンバーを招待",
+ "zh-CN": "邀请组织成员",
+ "zh-TW": "邀請組織成員",
+ "ko-KR": "조직 구성원 초대",
+ "no": "Inviter organisasjonsmedlemmer",
+ "it": "Invita membri dell'organizzazione",
+ "pt": "Convidar membros da organização",
+ "es": "Invitar a miembros de la organización",
+ "ar": "دعوة أعضاء المنظمة",
+ "fr": "Inviter des membres de l'organisation",
+ "tr": "Organizasyon üyelerini davet et",
+ "de": "Organisationsmitglieder einladen",
+ "uk": "Запросити членів організації"
+ },
+ "ORG$MANAGE_ORGANIZATION": {
+ "en": "Manage Organization",
+ "ja": "組織を管理",
+ "zh-CN": "管理组织",
+ "zh-TW": "管理組織",
+ "ko-KR": "조직 관리",
+ "no": "Administrer organisasjon",
+ "it": "Gestisci organizzazione",
+ "pt": "Gerenciar organização",
+ "es": "Gestionar organización",
+ "ar": "إدارة المنظمة",
+ "fr": "Gérer l'organisation",
+ "tr": "Organizasyonu yönet",
+ "de": "Organisation verwalten",
+ "uk": "Керувати організацією"
+ },
+ "ORG$ORGANIZATION_MEMBERS": {
+ "en": "Organization Members",
+ "ja": "組織メンバー",
+ "zh-CN": "组织成员",
+ "zh-TW": "組織成員",
+ "ko-KR": "조직 구성원",
+ "no": "Organisasjonsmedlemmer",
+ "it": "Membri dell'organizzazione",
+ "pt": "Membros da organização",
+ "es": "Miembros de la organización",
+ "ar": "أعضاء المنظمة",
+ "fr": "Membres de l'organisation",
+ "tr": "Organizasyon Üyeleri",
+ "de": "Organisationsmitglieder",
+ "uk": "Члени організації"
+ },
+ "ORG$ALL_ORGANIZATION_MEMBERS": {
+ "en": "All Organization Members",
+ "ja": "全ての組織メンバー",
+ "zh-CN": "所有组织成员",
+ "zh-TW": "所有組織成員",
+ "ko-KR": "모든 조직 구성원",
+ "no": "Alle organisasjonsmedlemmer",
+ "it": "Tutti i membri dell'organizzazione",
+ "pt": "Todos os membros da organização",
+ "es": "Todos los miembros de la organización",
+ "ar": "جميع أعضاء المنظمة",
+ "fr": "Tous les membres de l'organisation",
+ "tr": "Tüm organizasyon üyeleri",
+ "de": "Alle Organisationsmitglieder",
+ "uk": "Усі члени організації"
+ },
+ "ORG$SEARCH_BY_EMAIL": {
+ "en": "Search by email...",
+ "ja": "メールで検索...",
+ "zh-CN": "按邮箱搜索...",
+ "zh-TW": "按電郵搜尋...",
+ "ko-KR": "이메일로 검색...",
+ "no": "Søk etter e-post...",
+ "it": "Cerca per email...",
+ "pt": "Pesquisar por email...",
+ "es": "Buscar por correo electrónico...",
+ "ar": "البحث بالبريد الإلكتروني...",
+ "fr": "Rechercher par e-mail...",
+ "tr": "E-posta ile ara...",
+ "de": "Nach E-Mail suchen...",
+ "uk": "Пошук за електронною поштою..."
+ },
+ "ORG$NO_MEMBERS_FOUND": {
+ "en": "No members found",
+ "ja": "メンバーが見つかりません",
+ "zh-CN": "未找到成员",
+ "zh-TW": "未找到成員",
+ "ko-KR": "멤버를 찾을 수 없습니다",
+ "no": "Ingen medlemmer funnet",
+ "it": "Nessun membro trovato",
+ "pt": "Nenhum membro encontrado",
+ "es": "No se encontraron miembros",
+ "ar": "لم يتم العثور على أعضاء",
+ "fr": "Aucun membre trouvé",
+ "tr": "Üye bulunamadı",
+ "de": "Keine Mitglieder gefunden",
+ "uk": "Членів не знайдено"
+ },
+ "ORG$NO_MEMBERS_MATCHING_FILTER": {
+ "en": "No members match your search",
+ "ja": "検索に一致するメンバーはいません",
+ "zh-CN": "没有符合搜索条件的成员",
+ "zh-TW": "沒有符合搜尋條件的成員",
+ "ko-KR": "검색과 일치하는 멤버가 없습니다",
+ "no": "Ingen medlemmer samsvarer med søket ditt",
+ "it": "Nessun membro corrisponde alla tua ricerca",
+ "pt": "Nenhum membro corresponde à sua pesquisa",
+ "es": "Ningún miembro coincide con tu búsqueda",
+ "ar": "لا يوجد أعضاء يطابقون بحثك",
+ "fr": "Aucun membre ne correspond à votre recherche",
+ "tr": "Aramanızla eşleşen üye bulunamadı",
+ "de": "Keine Mitglieder entsprechen Ihrer Suche",
+ "uk": "Жодний член не відповідає вашому пошуку"
+ },
+ "ORG$FAILED_TO_LOAD_MEMBERS": {
+ "en": "Failed to load members",
+ "ja": "メンバーの読み込みに失敗しました",
+ "zh-CN": "加载成员失败",
+ "zh-TW": "載入成員失敗",
+ "ko-KR": "멤버를 불러오지 못했습니다",
+ "no": "Kunne ikke laste medlemmer",
+ "it": "Impossibile caricare i membri",
+ "pt": "Falha ao carregar membros",
+ "es": "Error al cargar miembros",
+ "ar": "فشل تحميل الأعضاء",
+ "fr": "Échec du chargement des membres",
+ "tr": "Üyeler yüklenemedi",
+ "de": "Mitglieder konnten nicht geladen werden",
+ "uk": "Не вдалося завантажити членів"
+ },
"ONBOARDING$STEP1_TITLE": {
"en": "What's your role?",
"ja": "あなたの役割は?",
diff --git a/frontend/src/icons/admin.svg b/frontend/src/icons/admin.svg
new file mode 100644
index 0000000000..89004b4913
--- /dev/null
+++ b/frontend/src/icons/admin.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/icons/loading-outer.svg b/frontend/src/icons/loading-outer.svg
index aebe42c8e5..4c2d56aff0 100644
--- a/frontend/src/icons/loading-outer.svg
+++ b/frontend/src/icons/loading-outer.svg
@@ -1,4 +1,4 @@
diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts
index 6936b283e9..64a8574c14 100644
--- a/frontend/src/mocks/handlers.ts
+++ b/frontend/src/mocks/handlers.ts
@@ -3,6 +3,7 @@ import { BILLING_HANDLERS } from "./billing-handlers";
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
import { SECRETS_HANDLERS } from "./secrets-handlers";
+import { ORG_HANDLERS } from "./org-handlers";
import { GIT_REPOSITORY_HANDLERS } from "./git-repository-handlers";
import {
SETTINGS_HANDLERS,
@@ -15,6 +16,7 @@ import { FEEDBACK_HANDLERS } from "./feedback-handlers";
import { ANALYTICS_HANDLERS } from "./analytics-handlers";
export const handlers = [
+ ...ORG_HANDLERS,
...API_KEYS_HANDLERS,
...BILLING_HANDLERS,
...FILE_SERVICE_HANDLERS,
diff --git a/frontend/src/mocks/org-handlers.ts b/frontend/src/mocks/org-handlers.ts
new file mode 100644
index 0000000000..9b7cd930ae
--- /dev/null
+++ b/frontend/src/mocks/org-handlers.ts
@@ -0,0 +1,556 @@
+import { http, HttpResponse } from "msw";
+import {
+ Organization,
+ OrganizationMember,
+ OrganizationUserRole,
+ UpdateOrganizationMemberParams,
+} from "#/types/org";
+
+const MOCK_ME: Omit = {
+ user_id: "99",
+ email: "me@acme.org",
+ llm_api_key: "**********",
+ max_iterations: 20,
+ llm_model: "gpt-4",
+ llm_api_key_for_byor: null,
+ llm_base_url: "https://api.openai.com",
+ status: "active",
+};
+
+export const createMockOrganization = (
+ id: string,
+ name: string,
+ credits: number,
+ is_personal?: boolean,
+): Organization => ({
+ id,
+ name,
+ contact_name: "Contact Name",
+ contact_email: "contact@example.com",
+ conversation_expiration: 86400,
+ agent: "default-agent",
+ default_max_iterations: 20,
+ security_analyzer: "standard",
+ confirmation_mode: false,
+ default_llm_model: "gpt-5-1",
+ default_llm_api_key_for_byor: "*********",
+ default_llm_base_url: "https://api.example-llm.com",
+ remote_runtime_resource_factor: 2,
+ enable_default_condenser: true,
+ billing_margin: 0.15,
+ enable_proactive_conversation_starters: true,
+ sandbox_base_container_image: "ghcr.io/example/sandbox-base:latest",
+ sandbox_runtime_container_image: "ghcr.io/example/sandbox-runtime:latest",
+ org_version: 0,
+ mcp_config: {
+ tools: [],
+ settings: {},
+ },
+ search_api_key: null,
+ sandbox_api_key: null,
+ max_budget_per_task: 25.0,
+ enable_solvability_analysis: false,
+ v1_enabled: true,
+ credits,
+ is_personal,
+});
+
+// Named mock organizations for test convenience
+export const MOCK_PERSONAL_ORG = createMockOrganization(
+ "1",
+ "Personal Workspace",
+ 100,
+ true,
+);
+export const MOCK_TEAM_ORG_ACME = createMockOrganization(
+ "2",
+ "Acme Corp",
+ 1000,
+);
+export const MOCK_TEAM_ORG_BETA = createMockOrganization("3", "Beta LLC", 500);
+export const MOCK_TEAM_ORG_ALLHANDS = createMockOrganization(
+ "4",
+ "All Hands AI",
+ 750,
+);
+
+export const INITIAL_MOCK_ORGS: Organization[] = [
+ MOCK_PERSONAL_ORG,
+ MOCK_TEAM_ORG_ACME,
+ MOCK_TEAM_ORG_BETA,
+ MOCK_TEAM_ORG_ALLHANDS,
+];
+
+const INITIAL_MOCK_MEMBERS: Record = {
+ "1": [
+ {
+ org_id: "1",
+ user_id: "99",
+ email: "me@acme.org",
+ role: "owner",
+ llm_api_key: "**********",
+ max_iterations: 20,
+ llm_model: "gpt-4",
+ llm_api_key_for_byor: null,
+ llm_base_url: "https://api.openai.com",
+ status: "active",
+ },
+ ],
+ "2": [
+ {
+ org_id: "2",
+ user_id: "1",
+ email: "alice@acme.org",
+ role: "owner",
+ llm_api_key: "**********",
+ max_iterations: 20,
+ llm_model: "gpt-4",
+ llm_api_key_for_byor: null,
+ llm_base_url: "https://api.openai.com",
+ status: "active",
+ },
+ {
+ org_id: "1",
+ user_id: "2",
+ email: "bob@acme.org",
+ role: "admin",
+ llm_api_key: "**********",
+ max_iterations: 20,
+ llm_model: "gpt-4",
+ llm_api_key_for_byor: null,
+ llm_base_url: "https://api.openai.com",
+ status: "active",
+ },
+ {
+ org_id: "1",
+ user_id: "3",
+ email: "charlie@acme.org",
+ role: "member",
+ llm_api_key: "**********",
+ max_iterations: 20,
+ llm_model: "gpt-4",
+ llm_api_key_for_byor: null,
+ llm_base_url: "https://api.openai.com",
+ status: "active",
+ },
+ ],
+ "3": [
+ {
+ org_id: "2",
+ user_id: "4",
+ email: "tony@gamma.org",
+ role: "member",
+ llm_api_key: "**********",
+ max_iterations: 20,
+ llm_model: "gpt-4",
+ llm_api_key_for_byor: null,
+ llm_base_url: "https://api.openai.com",
+ status: "active",
+ },
+ {
+ org_id: "2",
+ user_id: "5",
+ email: "evan@gamma.org",
+ role: "admin",
+ llm_api_key: "**********",
+ max_iterations: 20,
+ llm_model: "gpt-4",
+ llm_api_key_for_byor: null,
+ llm_base_url: "https://api.openai.com",
+ status: "active",
+ },
+ ],
+ "4": [
+ {
+ org_id: "3",
+ user_id: "6",
+ email: "robert@all-hands.dev",
+ role: "owner",
+ llm_api_key: "**********",
+ max_iterations: 20,
+ llm_model: "gpt-4",
+ llm_api_key_for_byor: null,
+ llm_base_url: "https://api.openai.com",
+ status: "active",
+ },
+ {
+ org_id: "3",
+ user_id: "7",
+ email: "ray@all-hands.dev",
+ role: "admin",
+ llm_api_key: "**********",
+ max_iterations: 20,
+ llm_model: "gpt-4",
+ llm_api_key_for_byor: null,
+ llm_base_url: "https://api.openai.com",
+ status: "active",
+ },
+ {
+ org_id: "3",
+ user_id: "8",
+ email: "chuck@all-hands.dev",
+ role: "member",
+ llm_api_key: "**********",
+ max_iterations: 20,
+ llm_model: "gpt-4",
+ llm_api_key_for_byor: null,
+ llm_base_url: "https://api.openai.com",
+ status: "active",
+ },
+ {
+ org_id: "3",
+ user_id: "9",
+ email: "stephan@all-hands.dev",
+ role: "member",
+ llm_api_key: "**********",
+ max_iterations: 20,
+ llm_model: "gpt-4",
+ llm_api_key_for_byor: null,
+ llm_base_url: "https://api.openai.com",
+ status: "active",
+ },
+ {
+ org_id: "3",
+ user_id: "10",
+ email: "tim@all-hands.dev",
+ role: "member",
+ llm_api_key: "**********",
+ max_iterations: 20,
+ llm_model: "gpt-4",
+ llm_api_key_for_byor: null,
+ llm_base_url: "https://api.openai.com",
+ status: "invited",
+ },
+ ],
+};
+
+export const ORGS_AND_MEMBERS: Record = {
+ "1": INITIAL_MOCK_MEMBERS["1"].map((member) => ({ ...member })),
+ "2": INITIAL_MOCK_MEMBERS["2"].map((member) => ({ ...member })),
+ "3": INITIAL_MOCK_MEMBERS["3"].map((member) => ({ ...member })),
+ "4": INITIAL_MOCK_MEMBERS["4"].map((member) => ({ ...member })),
+};
+
+const orgs = new Map(INITIAL_MOCK_ORGS.map((org) => [org.id, org]));
+
+export const resetOrgMockData = () => {
+ // Reset organizations to initial state
+ orgs.clear();
+ INITIAL_MOCK_ORGS.forEach((org) => {
+ orgs.set(org.id, { ...org });
+ });
+};
+
+export const resetOrgsAndMembersMockData = () => {
+ // Reset ORGS_AND_MEMBERS to initial state
+ // Note: This is needed since ORGS_AND_MEMBERS is mutated by updateMember
+ Object.keys(INITIAL_MOCK_MEMBERS).forEach((orgId) => {
+ ORGS_AND_MEMBERS[orgId] = INITIAL_MOCK_MEMBERS[orgId].map((member) => ({
+ ...member,
+ }));
+ });
+};
+
+export const ORG_HANDLERS = [
+ http.get("/api/organizations/:orgId/me", ({ params }) => {
+ const orgId = params.orgId?.toString();
+ if (!orgId || !ORGS_AND_MEMBERS[orgId]) {
+ return HttpResponse.json(
+ { error: "Organization not found" },
+ { status: 404 },
+ );
+ }
+
+ let role: OrganizationUserRole = "member";
+ switch (orgId) {
+ case "1": // Personal Workspace
+ role = "owner";
+ break;
+ case "2": // Acme Corp
+ role = "owner";
+ break;
+ case "3": // Beta LLC
+ role = "member";
+ break;
+ case "4": // All Hands AI
+ role = "admin";
+ break;
+ default:
+ role = "member";
+ }
+
+ const me: OrganizationMember = {
+ ...MOCK_ME,
+ org_id: orgId,
+ role,
+ };
+ return HttpResponse.json(me);
+ }),
+
+ http.get("/api/organizations/:orgId/members", ({ params, request }) => {
+ const orgId = params.orgId?.toString();
+ if (!orgId || !ORGS_AND_MEMBERS[orgId]) {
+ return HttpResponse.json(
+ { error: "Organization not found" },
+ { status: 404 },
+ );
+ }
+
+ // Parse query parameters
+ const url = new URL(request.url);
+ const pageIdParam = url.searchParams.get("page_id");
+ const limitParam = url.searchParams.get("limit");
+ const emailFilter = url.searchParams.get("email");
+
+ const offset = pageIdParam ? parseInt(pageIdParam, 10) : 0;
+ const limit = limitParam ? parseInt(limitParam, 10) : 10;
+
+ let members = ORGS_AND_MEMBERS[orgId];
+
+ // Apply email filter if provided
+ if (emailFilter) {
+ members = members.filter((member) =>
+ member.email.toLowerCase().includes(emailFilter.toLowerCase()),
+ );
+ }
+
+ const paginatedMembers = members.slice(offset, offset + limit);
+ const currentPage = Math.floor(offset / limit) + 1;
+
+ return HttpResponse.json({
+ items: paginatedMembers,
+ current_page: currentPage,
+ per_page: limit,
+ });
+ }),
+
+ http.get("/api/organizations/:orgId/members/count", ({ params, request }) => {
+ const orgId = params.orgId?.toString();
+ if (!orgId || !ORGS_AND_MEMBERS[orgId]) {
+ return HttpResponse.json(
+ { error: "Organization not found" },
+ { status: 404 },
+ );
+ }
+
+ // Parse query parameters
+ const url = new URL(request.url);
+ const emailFilter = url.searchParams.get("email");
+
+ let members = ORGS_AND_MEMBERS[orgId];
+
+ // Apply email filter if provided
+ if (emailFilter) {
+ members = members.filter((member) =>
+ member.email.toLowerCase().includes(emailFilter.toLowerCase()),
+ );
+ }
+
+ return HttpResponse.json(members.length);
+ }),
+
+ http.get("/api/organizations", () => {
+ const organizations = Array.from(orgs.values());
+ // Return the first org as the current org for mock purposes
+ const currentOrgId = organizations.length > 0 ? organizations[0].id : null;
+ return HttpResponse.json({
+ items: organizations,
+ current_org_id: currentOrgId,
+ });
+ }),
+
+ http.patch("/api/organizations/:orgId", async ({ request, params }) => {
+ const { name } = (await request.json()) as {
+ name: string;
+ };
+ const orgId = params.orgId?.toString();
+
+ if (!name) {
+ return HttpResponse.json({ error: "Name is required" }, { status: 400 });
+ }
+
+ if (!orgId) {
+ return HttpResponse.json(
+ { error: "Organization ID is required" },
+ { status: 400 },
+ );
+ }
+
+ const existingOrg = orgs.get(orgId);
+ if (!existingOrg) {
+ return HttpResponse.json(
+ { error: "Organization not found" },
+ { status: 404 },
+ );
+ }
+
+ const updatedOrg: Organization = {
+ ...existingOrg,
+ name,
+ };
+ orgs.set(orgId, updatedOrg);
+
+ return HttpResponse.json(updatedOrg, { status: 201 });
+ }),
+
+ http.get("/api/organizations/:orgId", ({ params }) => {
+ const orgId = params.orgId?.toString();
+
+ if (orgId) {
+ const org = orgs.get(orgId);
+ if (org) return HttpResponse.json(org);
+ }
+
+ return HttpResponse.json(
+ { error: "Organization not found" },
+ { status: 404 },
+ );
+ }),
+
+ http.delete("/api/organizations/:orgId", ({ params }) => {
+ const orgId = params.orgId?.toString();
+
+ if (orgId && orgs.has(orgId) && ORGS_AND_MEMBERS[orgId]) {
+ orgs.delete(orgId);
+ delete ORGS_AND_MEMBERS[orgId];
+ return HttpResponse.json(
+ { message: "Organization deleted" },
+ { status: 204 },
+ );
+ }
+
+ return HttpResponse.json(
+ { error: "Organization not found" },
+ { status: 404 },
+ );
+ }),
+
+ http.get("/api/organizations/:orgId/payment", ({ params }) => {
+ const orgId = params.orgId?.toString();
+
+ if (orgId) {
+ const org = orgs.get(orgId);
+ if (org) {
+ return HttpResponse.json({
+ cardNumber: "**** **** **** 1234", // Mocked payment info
+ });
+ }
+ }
+
+ return HttpResponse.json(
+ { error: "Organization not found" },
+ { status: 404 },
+ );
+ }),
+
+ http.patch(
+ "/api/organizations/:orgId/members/:userId",
+ async ({ request, params }) => {
+ const updateData =
+ (await request.json()) as UpdateOrganizationMemberParams;
+ const orgId = params.orgId?.toString();
+ const userId = params.userId?.toString();
+
+ if (!orgId || !ORGS_AND_MEMBERS[orgId]) {
+ return HttpResponse.json(
+ { error: "Organization not found" },
+ { status: 404 },
+ );
+ }
+
+ const member = ORGS_AND_MEMBERS[orgId].find((m) => m.user_id === userId);
+ if (!member) {
+ return HttpResponse.json(
+ { error: "Member not found" },
+ { status: 404 },
+ );
+ }
+
+ // Update member with any provided fields
+ const newMember: OrganizationMember = {
+ ...member,
+ ...updateData,
+ };
+ const newMembers = ORGS_AND_MEMBERS[orgId].map((m) =>
+ m.user_id === userId ? newMember : m,
+ );
+ ORGS_AND_MEMBERS[orgId] = newMembers;
+
+ return HttpResponse.json(newMember, { status: 200 });
+ },
+ ),
+
+ http.delete("/api/organizations/:orgId/members/:userId", ({ params }) => {
+ const { orgId, userId } = params;
+
+ if (!orgId || !userId || !ORGS_AND_MEMBERS[orgId as string]) {
+ return HttpResponse.json(
+ { error: "Organization or member not found" },
+ { status: 404 },
+ );
+ }
+
+ // Remove member from organization
+ const members = ORGS_AND_MEMBERS[orgId as string];
+ const updatedMembers = members.filter(
+ (member) => member.user_id !== userId,
+ );
+ ORGS_AND_MEMBERS[orgId as string] = updatedMembers;
+
+ return HttpResponse.json({ message: "Member removed" }, { status: 200 });
+ }),
+
+ http.post("/api/organizations/:orgId/switch", ({ params }) => {
+ const orgId = params.orgId?.toString();
+
+ if (orgId) {
+ const org = orgs.get(orgId);
+ if (org) return HttpResponse.json(org);
+ }
+
+ return HttpResponse.json(
+ { error: "Organization not found" },
+ { status: 404 },
+ );
+ }),
+
+ http.post(
+ "/api/organizations/:orgId/members/invite",
+ async ({ request, params }) => {
+ const { emails } = (await request.json()) as { emails: string[] };
+ const orgId = params.orgId?.toString();
+
+ if (!emails || emails.length === 0) {
+ return HttpResponse.json(
+ { error: "Emails are required" },
+ { status: 400 },
+ );
+ }
+
+ if (!orgId || !ORGS_AND_MEMBERS[orgId]) {
+ return HttpResponse.json(
+ { error: "Organization not found" },
+ { status: 404 },
+ );
+ }
+
+ const members = Array.from(ORGS_AND_MEMBERS[orgId]);
+ const newMembers: OrganizationMember[] = emails.map((email, index) => ({
+ org_id: orgId,
+ user_id: String(members.length + index + 1),
+ email,
+ role: "member" as const,
+ llm_api_key: "**********",
+ max_iterations: 20,
+ llm_model: "gpt-4",
+ llm_api_key_for_byor: null,
+ llm_base_url: "https://api.openai.com",
+ status: "invited" as const,
+ }));
+
+ ORGS_AND_MEMBERS[orgId] = [...members, ...newMembers];
+
+ return HttpResponse.json(newMembers, { status: 201 });
+ },
+ ),
+];
diff --git a/frontend/src/mocks/settings-handlers.ts b/frontend/src/mocks/settings-handlers.ts
index 8534789831..19079e3655 100644
--- a/frontend/src/mocks/settings-handlers.ts
+++ b/frontend/src/mocks/settings-handlers.ts
@@ -3,6 +3,37 @@ import { WebClientConfig } from "#/api/option-service/option.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Provider, Settings } from "#/types/settings";
+/**
+ * Creates a mock WebClientConfig with all required fields.
+ * Use this helper to create test config objects with sensible defaults.
+ */
+export const createMockWebClientConfig = (
+ overrides: Partial = {},
+): WebClientConfig => ({
+ app_mode: "oss",
+ posthog_client_key: "test-posthog-key",
+ feature_flags: {
+ enable_billing: false,
+ hide_llm_settings: false,
+ enable_jira: false,
+ enable_jira_dc: false,
+ enable_linear: false,
+ hide_users_page: false,
+ hide_billing_page: false,
+ hide_integrations_page: false,
+ ...overrides.feature_flags,
+ },
+ providers_configured: [],
+ maintenance_start_time: null,
+ auth_url: null,
+ recaptcha_site_key: null,
+ faulty_models: [],
+ error_message: null,
+ updated_at: new Date().toISOString(),
+ github_app_slug: null,
+ ...overrides,
+});
+
export const MOCK_DEFAULT_USER_SETTINGS: Settings = {
llm_model: DEFAULT_SETTINGS.llm_model,
llm_base_url: DEFAULT_SETTINGS.llm_base_url,
@@ -73,8 +104,8 @@ export const SETTINGS_HANDLERS = [
app_mode: mockSaas ? "saas" : "oss",
posthog_client_key: "fake-posthog-client-key",
feature_flags: {
- enable_billing: false,
- hide_llm_settings: mockSaas,
+ enable_billing: mockSaas,
+ hide_llm_settings: false,
enable_jira: false,
enable_jira_dc: false,
enable_linear: false,
diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts
index b50091dc3c..ba401dae9d 100644
--- a/frontend/src/routes.ts
+++ b/frontend/src/routes.ts
@@ -20,6 +20,8 @@ export default [
route("billing", "routes/billing.tsx"),
route("secrets", "routes/secrets-settings.tsx"),
route("api-keys", "routes/api-keys.tsx"),
+ route("org-members", "routes/manage-organization-members.tsx"),
+ route("org", "routes/manage-org.tsx"),
]),
route("conversations/:conversationId", "routes/conversation.tsx"),
route("microagent-management", "routes/microagent-management.tsx"),
diff --git a/frontend/src/routes/api-keys.tsx b/frontend/src/routes/api-keys.tsx
index e5d733ecb7..2bae9a4a9f 100644
--- a/frontend/src/routes/api-keys.tsx
+++ b/frontend/src/routes/api-keys.tsx
@@ -1,5 +1,8 @@
import React from "react";
import { ApiKeysManager } from "#/components/features/settings/api-keys-manager";
+import { createPermissionGuard } from "#/utils/org/permission-guard";
+
+export const clientLoader = createPermissionGuard("manage_api_keys");
function ApiKeysScreen() {
return (
diff --git a/frontend/src/routes/app-settings.tsx b/frontend/src/routes/app-settings.tsx
index eafd225bca..8226488468 100644
--- a/frontend/src/routes/app-settings.tsx
+++ b/frontend/src/routes/app-settings.tsx
@@ -19,6 +19,11 @@ import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"
import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton";
import { useConfig } from "#/hooks/query/use-config";
import { parseMaxBudgetPerTask } from "#/utils/settings-utils";
+import { createPermissionGuard } from "#/utils/org/permission-guard";
+
+export const clientLoader = createPermissionGuard(
+ "manage_application_settings",
+);
function AppSettingsScreen() {
const posthog = usePostHog();
diff --git a/frontend/src/routes/billing.tsx b/frontend/src/routes/billing.tsx
index 05d23fe276..195d8eb0c1 100644
--- a/frontend/src/routes/billing.tsx
+++ b/frontend/src/routes/billing.tsx
@@ -1,4 +1,4 @@
-import { useSearchParams } from "react-router";
+import { redirect, useSearchParams } from "react-router";
import React from "react";
import { useTranslation } from "react-i18next";
import { PaymentForm } from "#/components/features/payment/payment-form";
@@ -8,11 +8,53 @@ import {
} from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
import { useTracking } from "#/hooks/use-tracking";
+import { useMe } from "#/hooks/query/use-me";
+import { usePermission } from "#/hooks/organizations/use-permissions";
+import { getActiveOrganizationUser } from "#/utils/org/permission-checks";
+import { rolePermissions } from "#/utils/org/permissions";
+import { isBillingHidden } from "#/utils/org/billing-visibility";
+import { queryClient } from "#/query-client-config";
+import OptionService from "#/api/option-service/option-service.api";
+import { WebClientConfig } from "#/api/option-service/option.types";
+import { getFirstAvailablePath } from "#/utils/settings-utils";
+
+export const clientLoader = async () => {
+ let config = queryClient.getQueryData(["web-client-config"]);
+ if (!config) {
+ config = await OptionService.getConfig();
+ queryClient.setQueryData(["web-client-config"], config);
+ }
+
+ const isSaas = config?.app_mode === "saas";
+ const featureFlags = config?.feature_flags;
+
+ const getFallbackPath = () =>
+ getFirstAvailablePath(isSaas, featureFlags) ?? "/settings";
+
+ const user = await getActiveOrganizationUser();
+
+ if (!user) {
+ return redirect(getFallbackPath());
+ }
+
+ const userRole = user.role ?? "member";
+
+ if (
+ isBillingHidden(config, rolePermissions[userRole].includes("view_billing"))
+ ) {
+ return redirect(getFallbackPath());
+ }
+
+ return null;
+};
function BillingSettingsScreen() {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const { trackCreditsPurchased } = useTracking();
+ const { data: me } = useMe();
+ const { hasPermission } = usePermission(me?.role ?? "member");
+ const canAddCredits = !!me && hasPermission("add_credits");
const checkoutStatus = searchParams.get("checkout");
React.useEffect(() => {
@@ -38,7 +80,7 @@ function BillingSettingsScreen() {
}
}, [checkoutStatus, searchParams, setSearchParams, t, trackCreditsPurchased]);
- return ;
+ return ;
}
export default BillingSettingsScreen;
diff --git a/frontend/src/routes/git-settings.tsx b/frontend/src/routes/git-settings.tsx
index 1b07e081dc..7061dbe303 100644
--- a/frontend/src/routes/git-settings.tsx
+++ b/frontend/src/routes/git-settings.tsx
@@ -1,6 +1,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useConfig } from "#/hooks/query/use-config";
+import { createPermissionGuard } from "#/utils/org/permission-guard";
import { useSettings } from "#/hooks/query/use-settings";
import { BrandButton } from "#/components/features/settings/brand-button";
import { useLogout } from "#/hooks/mutation/use-logout";
@@ -26,6 +27,8 @@ import { useUserProviders } from "#/hooks/use-user-providers";
import { ProjectManagementIntegration } from "#/components/features/settings/project-management/project-management-integration";
import { Typography } from "#/ui/typography";
+export const clientLoader = createPermissionGuard("manage_integrations");
+
function GitSettingsScreen() {
const { t } = useTranslation();
diff --git a/frontend/src/routes/llm-settings.tsx b/frontend/src/routes/llm-settings.tsx
index d28bfa661b..d9489ec35a 100644
--- a/frontend/src/routes/llm-settings.tsx
+++ b/frontend/src/routes/llm-settings.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { AxiosError } from "axios";
import { useSearchParams } from "react-router";
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
+import { createPermissionGuard } from "#/utils/org/permission-guard";
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
import { useSettings } from "#/hooks/query/use-settings";
@@ -28,6 +29,8 @@ import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { getProviderId } from "#/utils/map-provider";
import { DEFAULT_OPENHANDS_MODEL } from "#/utils/verified-models";
+import { useMe } from "#/hooks/query/use-me";
+import { usePermission } from "#/hooks/organizations/use-permissions";
interface OpenHandsApiKeyHelpProps {
testId: string;
@@ -69,6 +72,13 @@ function LlmSettingsScreen() {
const { data: resources } = useAIConfigOptions();
const { data: settings, isLoading, isFetching } = useSettings();
const { data: config } = useConfig();
+ const { data: me } = useMe();
+ const { hasPermission } = usePermission(me?.role ?? "member");
+
+ // In OSS mode, user has full access (no permission restrictions)
+ // In SaaS mode, check role-based permissions (members can only view, owners and admins can edit)
+ const isOssMode = config?.app_mode === "oss";
+ const isReadOnly = isOssMode ? false : !hasPermission("edit_llm_settings");
const [view, setView] = React.useState<"basic" | "advanced">("basic");
@@ -499,6 +509,7 @@ function LlmSettingsScreen() {
defaultIsToggled={view === "advanced"}
onToggle={handleToggleAdvancedSettings}
isToggled={view === "advanced"}
+ isDisabled={isReadOnly}
>
{t(I18nKey.SETTINGS$ADVANCED)}
@@ -516,6 +527,7 @@ function LlmSettingsScreen() {
onChange={handleModelIsDirty}
onDefaultValuesChanged={onDefaultValuesChanged}
wrapperClassName="!flex-col !gap-6"
+ isDisabled={isReadOnly}
/>
{(settings.llm_model?.startsWith("openhands/") ||
currentSelectedModel?.startsWith("openhands/")) && (
@@ -534,6 +546,7 @@ function LlmSettingsScreen() {
className="w-full max-w-[680px]"
placeholder={settings.llm_api_key_set ? "" : ""}
onChange={handleApiKeyIsDirty}
+ isDisabled={isReadOnly}
startContent={
settings.llm_api_key_set && (
@@ -566,6 +579,7 @@ function LlmSettingsScreen() {
type="text"
className="w-full max-w-[680px]"
onChange={handleCustomModelIsDirty}
+ isDisabled={isReadOnly}
/>
{(settings.llm_model?.startsWith("openhands/") ||
currentSelectedModel?.startsWith("openhands/")) && (
@@ -581,6 +595,7 @@ function LlmSettingsScreen() {
type="text"
className="w-full max-w-[680px]"
onChange={handleBaseUrlIsDirty}
+ isDisabled={isReadOnly}
/>
{!shouldUseOpenHandsKey && (
@@ -593,6 +608,7 @@ function LlmSettingsScreen() {
className="w-full max-w-[680px]"
placeholder={settings.llm_api_key_set ? "" : ""}
onChange={handleApiKeyIsDirty}
+ isDisabled={isReadOnly}
startContent={
settings.llm_api_key_set && (
@@ -647,6 +663,7 @@ function LlmSettingsScreen() {
defaultSelectedKey={settings.agent}
isClearable={false}
onInputChange={handleAgentIsDirty}
+ isDisabled={isReadOnly}
wrapperClassName="w-full max-w-[680px]"
/>
)}
@@ -666,7 +683,7 @@ function LlmSettingsScreen() {
DEFAULT_SETTINGS.condenser_max_size
)?.toString()}
onChange={(value) => handleCondenserMaxSizeIsDirty(value)}
- isDisabled={!settings.enable_default_condenser}
+ isDisabled={isReadOnly || !settings.enable_default_condenser}
className="w-full max-w-[680px] capitalize"
/>
@@ -679,6 +696,7 @@ function LlmSettingsScreen() {
name="enable-memory-condenser-switch"
defaultIsToggled={settings.enable_default_condenser}
onToggle={handleEnableDefaultCondenserIsDirty}
+ isDisabled={isReadOnly}
>
{t(I18nKey.SETTINGS$ENABLE_MEMORY_CONDENSATION)}
@@ -691,6 +709,7 @@ function LlmSettingsScreen() {
onToggle={handleConfirmationModeIsDirty}
defaultIsToggled={settings.confirmation_mode}
isBeta
+ isDisabled={isReadOnly}
>
{t(I18nKey.SETTINGS$CONFIRMATION_MODE)}
@@ -716,6 +735,7 @@ function LlmSettingsScreen() {
)}
selectedKey={selectedSecurityAnalyzer || "none"}
isClearable={false}
+ isDisabled={isReadOnly}
onSelectionChange={(key) => {
const newValue = key?.toString() || "";
setSelectedSecurityAnalyzer(newValue);
@@ -746,20 +766,26 @@ function LlmSettingsScreen() {
)}