From 7ad29808e55f2f623e8555fe25001e3b5c3cd7ce Mon Sep 17 00:00:00 2001 From: Rohil Surana Date: Tue, 17 Mar 2026 13:43:22 +0530 Subject: [PATCH 01/11] feat(web): add runtime terminology customization for admin UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a context-based terminology system that allows deployments to customize entity labels (e.g. Organization → Workspace) and URL paths across the entire admin UI via the /configs endpoint. - Add AdminConfigContext, useAdminTerminology hook, and useAdminPaths hook - Replace all hardcoded entity labels in 44 admin view files - Make route paths dynamic based on terminology config - Add Vite dev server middleware to serve configs.dev.json locally --- web/apps/admin/configs.dev.json | 32 ++++ .../admin/src/components/Sidebar/index.tsx | 167 +++++++++--------- web/apps/admin/src/contexts/App.tsx | 5 +- .../admin/src/pages/admins/AdminsPage.tsx | 5 +- .../src/pages/organizations/list/index.tsx | 6 +- web/apps/admin/src/pages/users/UsersPage.tsx | 9 +- web/apps/admin/src/routes.tsx | 20 ++- web/apps/admin/src/utils/constants.ts | 25 +++ web/apps/admin/vite.config.ts | 31 +++- web/sdk/admin/contexts/AdminConfigContext.tsx | 33 ++++ web/sdk/admin/hooks/useAdminPaths.ts | 49 +++++ web/sdk/admin/hooks/useAdminTerminology.ts | 69 ++++++++ web/sdk/admin/index.ts | 17 ++ web/sdk/admin/utils/constants.ts | 22 +++ web/sdk/admin/views/admins/columns.tsx | 8 +- web/sdk/admin/views/admins/index.tsx | 4 +- web/sdk/admin/views/audit-logs/columns.tsx | 12 +- web/sdk/admin/views/audit-logs/index.tsx | 5 +- .../views/audit-logs/sidepanel-details.tsx | 10 +- web/sdk/admin/views/invoices/columns.tsx | 11 +- web/sdk/admin/views/invoices/index.tsx | 7 +- .../organizations/details/apis/columns.tsx | 11 +- .../details/apis/details-dialog.tsx | 4 +- .../organizations/details/apis/index.tsx | 7 +- .../details/edit/organization.tsx | 22 +-- .../organizations/details/invoices/index.tsx | 7 +- .../organizations/details/layout/index.tsx | 8 +- .../details/layout/invite-users-dialog.tsx | 6 +- .../organizations/details/layout/navbar.tsx | 36 ++-- .../details/members/remove-member.tsx | 14 +- .../details/projects/columns.tsx | 13 +- .../organizations/details/projects/index.tsx | 14 +- .../projects/members/remove-member.tsx | 14 +- .../details/projects/rename-project.tsx | 12 +- .../projects/use-add-project-members.tsx | 4 +- .../details/security/block-organization.tsx | 6 +- .../details/side-panel/index.tsx | 4 +- .../organizations/details/tokens/columns.tsx | 11 +- .../organizations/details/tokens/index.tsx | 9 +- .../admin/views/organizations/list/create.tsx | 24 +-- .../admin/views/organizations/list/index.tsx | 15 +- .../admin/views/organizations/list/navbar.tsx | 6 +- .../views/users/details/layout/navbar.tsx | 12 +- .../users/details/layout/suspend-user.tsx | 8 +- .../users/details/security/block-user.tsx | 16 +- .../views/users/details/user-details.tsx | 8 +- .../admin/views/users/list/invite-users.tsx | 10 +- web/sdk/admin/views/users/list/list.tsx | 13 +- 48 files changed, 631 insertions(+), 230 deletions(-) create mode 100644 web/apps/admin/configs.dev.json create mode 100644 web/sdk/admin/contexts/AdminConfigContext.tsx create mode 100644 web/sdk/admin/hooks/useAdminPaths.ts create mode 100644 web/sdk/admin/hooks/useAdminTerminology.ts diff --git a/web/apps/admin/configs.dev.json b/web/apps/admin/configs.dev.json new file mode 100644 index 000000000..b73f88453 --- /dev/null +++ b/web/apps/admin/configs.dev.json @@ -0,0 +1,32 @@ +{ + "title": "Frontier Admin", + "app_url": "localhost:5173", + "token_product_id": "token", + "organization_types": [], + "webhooks": { + "enable_delete": false + }, + "terminology": { + "organization": { + "singular": "Workspace", + "plural": "Workspaces" + }, + "project": { + "singular": "Project", + "plural": "Projects" + }, + "team": { + "singular": "Team", + "plural": "Teams" + }, + "member": { + "singular": "Member", + "plural": "Members" + }, + "user": { + "singular": "User", + "plural": "Users" + }, + "appName": "Frontier Admin" + } +} diff --git a/web/apps/admin/src/components/Sidebar/index.tsx b/web/apps/admin/src/components/Sidebar/index.tsx index f735b3c64..09f365ab1 100644 --- a/web/apps/admin/src/components/Sidebar/index.tsx +++ b/web/apps/admin/src/components/Sidebar/index.tsx @@ -26,6 +26,7 @@ import CpuChipIcon from "~/assets/icons/cpu-chip.svg?react"; import { AppContext } from "~/contexts/App"; import { MoonIcon, SunIcon } from "@radix-ui/react-icons"; import { Link, useLocation } from "react-router-dom"; +import { useAdminTerminology, useAdminPaths } from "@raystack/frontier/admin"; export type NavigationItemsTypes = { to?: string; @@ -36,90 +37,96 @@ export type NavigationItemsTypes = { const BRAND_NAME = "Frontier"; -const navigationItems: NavigationItemsTypes[] = [ - { - name: "Organizations", - to: `/organizations`, - icon: , - }, - { - name: "Users", - to: `/users`, - icon: , - }, - { - name: "Audit Logs", - to: `/audit-logs`, - icon: , - }, - { - name: "Invoices", - to: `/invoices`, - icon: , - }, - { - name: "Authorization", - subItems: [ - { - name: "Roles", - to: `/roles`, - icon: , - }, - ], - }, - { - name: "Billing", - subItems: [ - { - name: "Products", - to: `/products`, - icon: , - }, - { - name: "Plans", - to: `/plans`, - icon: , - }, - ], - }, - { - name: "Features", - subItems: [ - { - name: "Webhooks", - to: `/webhooks`, - icon: , - }, - ], - }, - { - name: "Settings", - subItems: [ - { - name: "Preferences", - to: `/preferences`, - icon: , - }, - { - name: "Admins", - to: `/super-admins`, - icon: , - }, - ], - }, - // { - // name: "Projects", - // to: `/projects`, - // }, +const useNavigationItems = (): NavigationItemsTypes[] => { + const t = useAdminTerminology(); + const paths = useAdminPaths(); - // { - // name: "Groups", - // to: `/groups`, - // }, -]; + return [ + { + name: t.organization({ plural: true, case: "capital" }), + to: `/${paths.organizations}`, + icon: , + }, + { + name: t.user({ plural: true, case: "capital" }), + to: `/${paths.users}`, + icon: , + }, + { + name: "Audit Logs", + to: `/audit-logs`, + icon: , + }, + { + name: "Invoices", + to: `/invoices`, + icon: , + }, + { + name: "Authorization", + subItems: [ + { + name: "Roles", + to: `/roles`, + icon: , + }, + ], + }, + { + name: "Billing", + subItems: [ + { + name: "Products", + to: `/products`, + icon: , + }, + { + name: "Plans", + to: `/plans`, + icon: , + }, + ], + }, + { + name: "Features", + subItems: [ + { + name: "Webhooks", + to: `/webhooks`, + icon: , + }, + ], + }, + { + name: "Settings", + subItems: [ + { + name: "Preferences", + to: `/preferences`, + icon: , + }, + { + name: "Admins", + to: `/super-admins`, + icon: , + }, + ], + }, + // { + // name: t.project({ plural: true, case: "capital" }), + // to: `/projects`, + // }, + + // { + // name: t.team({ plural: true, case: "capital" }), + // to: `/groups`, + // }, + ]; +}; export default function IAMSidebar() { const location = useLocation(); + const navigationItems = useNavigationItems(); const isActive = (navlink?: string) => { const firstPathPart = location.pathname.split("/")[1]; diff --git a/web/apps/admin/src/contexts/App.tsx b/web/apps/admin/src/contexts/App.tsx index 0492b1f4f..e62880d02 100644 --- a/web/apps/admin/src/contexts/App.tsx +++ b/web/apps/admin/src/contexts/App.tsx @@ -11,6 +11,7 @@ import { type User, } from "@raystack/proton/frontier"; import { Config, defaultConfig } from "~/utils/constants"; +import { AdminConfigProvider } from "@raystack/frontier/admin"; interface AppContextValue { isAdmin: boolean; @@ -62,7 +63,9 @@ export const AppContextProvider: React.FC = function ({ config, user, }}> - {children} + + {children} + ); }; diff --git a/web/apps/admin/src/pages/admins/AdminsPage.tsx b/web/apps/admin/src/pages/admins/AdminsPage.tsx index 8bc110cec..c82e4d79b 100644 --- a/web/apps/admin/src/pages/admins/AdminsPage.tsx +++ b/web/apps/admin/src/pages/admins/AdminsPage.tsx @@ -1,12 +1,13 @@ -import { AdminsView } from "@raystack/frontier/admin"; +import { AdminsView, useAdminPaths } from "@raystack/frontier/admin"; import { useNavigate } from "react-router-dom"; export function AdminsPage() { const navigate = useNavigate(); + const paths = useAdminPaths(); return ( navigate(`/organizations/${orgId}`)} + onNavigateToOrg={(orgId: string) => navigate(`/${paths.organizations}/${orgId}`)} /> ); } diff --git a/web/apps/admin/src/pages/organizations/list/index.tsx b/web/apps/admin/src/pages/organizations/list/index.tsx index 2ccbcb8c7..6d5e0079d 100644 --- a/web/apps/admin/src/pages/organizations/list/index.tsx +++ b/web/apps/admin/src/pages/organizations/list/index.tsx @@ -1,5 +1,6 @@ import { OrganizationListView, + useAdminPaths, } from "@raystack/frontier/admin"; import { useCallback, useContext, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -17,6 +18,7 @@ async function loadCountries(): Promise { export function OrganizationListPage() { const navigate = useNavigate(); const { config } = useContext(AppContext); + const paths = useAdminPaths(); const [countries, setCountries] = useState([]); useEffect(() => { @@ -24,8 +26,8 @@ export function OrganizationListPage() { }, []); const onNavigateToOrg = useCallback( - (id: string) => navigate(`/organizations/${id}`), - [navigate], + (id: string) => navigate(`/${paths.organizations}/${id}`), + [navigate, paths.organizations], ); const onExportCsv = useCallback(async () => { diff --git a/web/apps/admin/src/pages/users/UsersPage.tsx b/web/apps/admin/src/pages/users/UsersPage.tsx index 31fc92f4d..b82a684fe 100644 --- a/web/apps/admin/src/pages/users/UsersPage.tsx +++ b/web/apps/admin/src/pages/users/UsersPage.tsx @@ -1,4 +1,4 @@ -import { UsersView } from "@raystack/frontier/admin"; +import { UsersView, useAdminPaths } from "@raystack/frontier/admin"; import { useCallback } from "react"; import { useParams, useNavigate, useLocation } from "react-router-dom"; import { clients } from "~/connect/clients"; @@ -10,6 +10,7 @@ export function UsersPage() { const { userId } = useParams(); const navigate = useNavigate(); const location = useLocation(); + const paths = useAdminPaths(); const onExportUsers = useCallback(async () => { await exportCsvFromStream(adminClient.exportUsers, {}, "users.csv"); @@ -17,15 +18,15 @@ export function UsersPage() { const onNavigateToUser = useCallback( (id: string) => { - navigate(`/users/${id}/security`); + navigate(`/${paths.users}/${id}/security`); }, - [navigate], + [navigate, paths.users], ); return ( navigate("/users")} + onCloseDetail={() => navigate(`/${paths.users}`)} onExportUsers={onExportUsers} onNavigateToUser={onNavigateToUser} currentPath={location.pathname} diff --git a/web/apps/admin/src/routes.tsx b/web/apps/admin/src/routes.tsx index 8519c9a15..ef93a0478 100644 --- a/web/apps/admin/src/routes.tsx +++ b/web/apps/admin/src/routes.tsx @@ -31,6 +31,7 @@ import { OrganizationInvoicesView, OrganizationTokensView, OrganizationApisView, + useAdminPaths, } from "@raystack/frontier/admin"; import { UsersPage } from "./pages/users/UsersPage"; @@ -40,6 +41,7 @@ import { AuditLogsPage } from "./pages/audit-logs/AuditLogsPage"; export default memo(function AppRoutes() { const { isAdmin, isLoading, user } = useContext(AppContext); + const paths = useAdminPaths(); const isUserEmpty = R.either(R.isEmpty, R.isNil)(user); @@ -58,20 +60,20 @@ export default memo(function AppRoutes() { ) : isAdmin ? ( }> - } /> - } /> + } /> + } /> }> - } /> - } /> + } /> + } /> } /> - } /> + } /> } /> } /> } /> - }> + }> } /> } /> @@ -85,7 +87,7 @@ export default memo(function AppRoutes() { }> } /> - + }> } /> @@ -102,7 +104,7 @@ export default memo(function AppRoutes() { } /> } /> - } /> + } /> ) : ( diff --git a/web/apps/admin/src/utils/constants.ts b/web/apps/admin/src/utils/constants.ts index c3b6d473a..ded60ed1a 100644 --- a/web/apps/admin/src/utils/constants.ts +++ b/web/apps/admin/src/utils/constants.ts @@ -17,6 +17,20 @@ export interface WebhooksConfig { enable_delete: boolean; } +export interface EntityTerminologies { + singular: string; + plural: string; +} + +export interface AdminTerminologyConfig { + organization?: EntityTerminologies; + project?: EntityTerminologies; + team?: EntityTerminologies; + member?: EntityTerminologies; + user?: EntityTerminologies; + appName?: string; +} + export interface Config { title: string; logo?: string; @@ -24,8 +38,18 @@ export interface Config { token_product_id?: string; organization_types?: string[]; webhooks?: WebhooksConfig; + terminology?: AdminTerminologyConfig; } +export const defaultTerminology: Required = { + organization: { singular: "Organization", plural: "Organizations" }, + project: { singular: "Project", plural: "Projects" }, + team: { singular: "Team", plural: "Teams" }, + member: { singular: "Member", plural: "Members" }, + user: { singular: "User", plural: "Users" }, + appName: "Frontier Admin", +}; + export const defaultConfig: Config = { title: "Frontier Admin", app_url: "example.com", @@ -34,6 +58,7 @@ export const defaultConfig: Config = { webhooks: { enable_delete: false, }, + terminology: defaultTerminology, }; export const NULL_DATE = "0001-01-01T00:00:00Z"; diff --git a/web/apps/admin/vite.config.ts b/web/apps/admin/vite.config.ts index a3113598e..1052d8835 100644 --- a/web/apps/admin/vite.config.ts +++ b/web/apps/admin/vite.config.ts @@ -1,10 +1,37 @@ import react from "@vitejs/plugin-react-swc"; import dotenv from "dotenv"; -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import svgr from "vite-plugin-svgr"; +import fs from "node:fs"; +import path from "node:path"; dotenv.config(); +/** + * Vite plugin that serves a local JSON file at `/configs` during development. + * Edit `configs.dev.json` in the project root to change the config + * (including terminology overrides like organization → workspace). + */ +function devConfigsPlugin(): Plugin { + return { + name: "dev-configs", + configureServer(server) { + server.middlewares.use("/configs", (_req, res) => { + const configPath = path.resolve(__dirname, "configs.dev.json"); + try { + const content = fs.readFileSync(configPath, "utf-8"); + // Re-read on every request so changes are picked up without restart + JSON.parse(content); // validate JSON + res.setHeader("Content-Type", "application/json"); + res.end(content); + } catch { + res.statusCode = 500; + res.end(JSON.stringify({ error: "Failed to read configs.dev.json" })); + } + }); + }, + }; +} // https://vitejs.dev/config/ export default defineConfig(() => { @@ -31,7 +58,7 @@ export default defineConfig(() => { allow: [".."], }, }, - plugins: [react(), svgr(), tsconfigPaths()], + plugins: [devConfigsPlugin(), react(), svgr(), tsconfigPaths()], define: { "process.env": process.env, }, diff --git a/web/sdk/admin/contexts/AdminConfigContext.tsx b/web/sdk/admin/contexts/AdminConfigContext.tsx new file mode 100644 index 000000000..faf139fd6 --- /dev/null +++ b/web/sdk/admin/contexts/AdminConfigContext.tsx @@ -0,0 +1,33 @@ +import { createContext, ReactNode, useContext } from "react"; +import { merge } from "lodash"; +import { Config, defaultConfig, defaultTerminology } from "../utils/constants"; + +const AdminConfigContext = createContext(defaultConfig); + +export interface AdminConfigProviderProps { + children: ReactNode; + config?: Config; +} + +export const AdminConfigProvider: React.FC = ({ + children, + config = {}, +}) => { + const mergedConfig: Config = merge({}, defaultConfig, config); + + // Ensure terminology is always present with defaults + mergedConfig.terminology = merge({}, defaultTerminology, config.terminology); + + return ( + + {children} + + ); +}; + +export const useAdminConfig = () => { + const context = useContext(AdminConfigContext); + return context || defaultConfig; +}; + +export { AdminConfigContext }; diff --git a/web/sdk/admin/hooks/useAdminPaths.ts b/web/sdk/admin/hooks/useAdminPaths.ts new file mode 100644 index 000000000..adb07d0e8 --- /dev/null +++ b/web/sdk/admin/hooks/useAdminPaths.ts @@ -0,0 +1,49 @@ +import { useAdminConfig } from "../contexts/AdminConfigContext"; +import { defaultTerminology } from "../utils/constants"; + +/** + * Converts a terminology plural label into a URL-safe slug. + * e.g. "Organizations" → "organizations", "Workspaces" → "workspaces" + */ +function toSlug(text: string): string { + return text + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, ""); +} + +export interface AdminPaths { + /** URL slug for the organization entity, e.g. "organizations" or "workspaces" */ + organizations: string; + /** URL slug for the user entity, e.g. "users" or "people" */ + users: string; + /** URL slug for the project entity, e.g. "projects" or "repos" */ + projects: string; + /** URL slug for the member entity, e.g. "members" or "participants" */ + members: string; + /** URL slug for the team entity, e.g. "teams" or "groups" */ + teams: string; +} + +export const useAdminPaths = (): AdminPaths => { + const config = useAdminConfig(); + const terminology = config.terminology || defaultTerminology; + + return { + organizations: toSlug( + terminology.organization?.plural || defaultTerminology.organization.plural + ), + users: toSlug( + terminology.user?.plural || defaultTerminology.user.plural + ), + projects: toSlug( + terminology.project?.plural || defaultTerminology.project.plural + ), + members: toSlug( + terminology.member?.plural || defaultTerminology.member.plural + ), + teams: toSlug( + terminology.team?.plural || defaultTerminology.team.plural + ), + }; +}; diff --git a/web/sdk/admin/hooks/useAdminTerminology.ts b/web/sdk/admin/hooks/useAdminTerminology.ts new file mode 100644 index 000000000..091ed5dab --- /dev/null +++ b/web/sdk/admin/hooks/useAdminTerminology.ts @@ -0,0 +1,69 @@ +import { useAdminConfig } from "../contexts/AdminConfigContext"; +import { defaultTerminology } from "../utils/constants"; + +export interface TerminologyOptions { + plural?: boolean; + case?: "lower" | "upper" | "capital"; +} + +export interface TerminologyEntity { + (options?: TerminologyOptions): string; +} + +const applyCase = ( + text: string, + caseType?: "lower" | "upper" | "capital" +): string => { + switch (caseType) { + case "lower": + return text.toLowerCase(); + case "upper": + return text.toUpperCase(); + case "capital": + return text.charAt(0).toUpperCase() + text.slice(1).toLowerCase(); + default: + return text; + } +}; + +const createEntity = (singular: string, plural: string): TerminologyEntity => { + return ({ + plural: isPlural = false, + case: caseType, + }: TerminologyOptions = {}) => { + const text = isPlural ? plural : singular; + return applyCase(text, caseType); + }; +}; + +export const useAdminTerminology = () => { + const config = useAdminConfig(); + const terminology = config.terminology || defaultTerminology; + + return { + organization: createEntity( + terminology.organization?.singular || defaultTerminology.organization.singular, + terminology.organization?.plural || defaultTerminology.organization.plural + ), + project: createEntity( + terminology.project?.singular || defaultTerminology.project.singular, + terminology.project?.plural || defaultTerminology.project.plural + ), + team: createEntity( + terminology.team?.singular || defaultTerminology.team.singular, + terminology.team?.plural || defaultTerminology.team.plural + ), + member: createEntity( + terminology.member?.singular || defaultTerminology.member.singular, + terminology.member?.plural || defaultTerminology.member.plural + ), + user: createEntity( + terminology.user?.singular || defaultTerminology.user.singular, + terminology.user?.plural || defaultTerminology.user.plural + ), + appName: createEntity( + terminology.appName || defaultTerminology.appName, + terminology.appName || defaultTerminology.appName + ), + }; +}; diff --git a/web/sdk/admin/index.ts b/web/sdk/admin/index.ts index 4746ee360..ad2111a43 100644 --- a/web/sdk/admin/index.ts +++ b/web/sdk/admin/index.ts @@ -22,6 +22,17 @@ export { OrganizationInvoicesView } from "./views/organizations/details/invoices export { OrganizationTokensView } from "./views/organizations/details/tokens"; export { OrganizationApisView } from "./views/organizations/details/apis"; +// context exports +export { + AdminConfigProvider, + useAdminConfig, + type AdminConfigProviderProps, +} from "./contexts/AdminConfigContext"; + +// hook exports +export { useAdminTerminology } from "./hooks/useAdminTerminology"; +export { useAdminPaths, type AdminPaths } from "./hooks/useAdminPaths"; + // utils exports export { getConnectNextPageParam, @@ -33,3 +44,9 @@ export { transformDataTableQueryToRQLRequest, type TransformOptions, } from "./utils/transform-query"; +export { + type Config, + type AdminTerminologyConfig, + defaultConfig, + defaultTerminology, +} from "./utils/constants"; diff --git a/web/sdk/admin/utils/constants.ts b/web/sdk/admin/utils/constants.ts index 46c670d27..5f3fde2d6 100644 --- a/web/sdk/admin/utils/constants.ts +++ b/web/sdk/admin/utils/constants.ts @@ -1,3 +1,5 @@ +import { EntityTerminologies } from "../../shared/types"; + export const SCOPES = { ORG: "app/organization", PROJECT: "app/project", @@ -15,16 +17,36 @@ export const DEFAULT_ROLES = { export const NULL_DATE = "0001-01-01T00:00:00Z"; +export interface AdminTerminologyConfig { + organization?: EntityTerminologies; + project?: EntityTerminologies; + team?: EntityTerminologies; + member?: EntityTerminologies; + user?: EntityTerminologies; + appName?: string; +} + export interface Config { title?: string; app_url?: string; token_product_id?: string; organization_types?: string[]; + terminology?: AdminTerminologyConfig; } +export const defaultTerminology: Required = { + organization: { singular: "Organization", plural: "Organizations" }, + project: { singular: "Project", plural: "Projects" }, + team: { singular: "Team", plural: "Teams" }, + member: { singular: "Member", plural: "Members" }, + user: { singular: "User", plural: "Users" }, + appName: "Frontier Admin", +}; + export const defaultConfig: Config = { title: "Frontier Admin", app_url: "example.com", token_product_id: "token", organization_types: [], + terminology: defaultTerminology, }; diff --git a/web/sdk/admin/views/admins/columns.tsx b/web/sdk/admin/views/admins/columns.tsx index 400b881ac..b03d7fd19 100644 --- a/web/sdk/admin/views/admins/columns.tsx +++ b/web/sdk/admin/views/admins/columns.tsx @@ -1,12 +1,16 @@ import { Text, type DataTableColumnDef } from "@raystack/apsara"; import type { ServiceUser, User } from "@raystack/proton/frontier"; +import { TerminologyEntity } from "../../hooks/useAdminTerminology"; export const getColumns: (options?: { onNavigateToOrg?: (orgId: string) => void; + t?: { + organization: TerminologyEntity; + }; }) => DataTableColumnDef< User | ServiceUser, unknown ->[] = ({ onNavigateToOrg } = {}) => { +>[] = ({ onNavigateToOrg, t } = {}) => { return [ { header: "Title", @@ -36,7 +40,7 @@ export const getColumns: (options?: { }, }, { - header: "Organization", + header: t?.organization({ case: "capital" }) || "Organization", accessorKey: "orgId", cell: (info) => { const org_id = info.getValue() as string; diff --git a/web/sdk/admin/views/admins/index.tsx b/web/sdk/admin/views/admins/index.tsx index c334e61db..87b92ad00 100644 --- a/web/sdk/admin/views/admins/index.tsx +++ b/web/sdk/admin/views/admins/index.tsx @@ -5,6 +5,7 @@ import { useQuery } from "@connectrpc/connect-query"; import { AdminServiceQueries } from "@raystack/proton/frontier"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { PageHeader } from "../../components/PageHeader"; +import { useAdminTerminology } from "../../hooks/useAdminTerminology"; const pageHeader = { title: "Super Admins", @@ -26,6 +27,7 @@ export type AdminsViewProps = { }; export default function AdminsView({ onNavigateToOrg }: AdminsViewProps = {}) { + const t = useAdminTerminology(); const { data: platformUsersData, isLoading, @@ -35,7 +37,7 @@ export default function AdminsView({ onNavigateToOrg }: AdminsViewProps = {}) { staleTime: Infinity, }); - const columns = getColumns({ onNavigateToOrg }); + const columns = getColumns({ onNavigateToOrg, t }); const data = [ ...(platformUsersData?.users || []), ...(platformUsersData?.serviceusers || []), diff --git a/web/sdk/admin/views/audit-logs/columns.tsx b/web/sdk/admin/views/audit-logs/columns.tsx index 559cc1241..396642c1d 100644 --- a/web/sdk/admin/views/audit-logs/columns.tsx +++ b/web/sdk/admin/views/audit-logs/columns.tsx @@ -14,13 +14,19 @@ import { import { ACTOR_TYPES, getActionBadgeColor } from "./util"; import { ComponentPropsWithoutRef } from "react"; import ActorCell from "./actor-cell"; +import { TerminologyEntity } from "../../hooks/useAdminTerminology"; interface getColumnsOptions { groupCountMap: Record>; + t: { + organization: TerminologyEntity; + user: TerminologyEntity; + }; } export const getColumns = ({ groupCountMap, + t, }: getColumnsOptions): DataTableColumnDef[] => { return [ { @@ -43,14 +49,14 @@ export const getColumns = ({ cell: () => null, filterType: "multiselect", filterOptions: [ - { label: "User", value: ACTOR_TYPES.USER }, - { label: "Service User", value: ACTOR_TYPES.SERVICE_USER }, + { label: t.user({ case: "capital" }), value: ACTOR_TYPES.USER }, + { label: `Service ${t.user({ case: "capital" })}`, value: ACTOR_TYPES.SERVICE_USER }, { label: "System", value: ACTOR_TYPES.SYSTEM }, ], }, { accessorKey: "orgName", - header: "Organization", + header: t.organization({ case: "capital" }), classNames: { cell: styles["org-column"], header: styles["org-column"], diff --git a/web/sdk/admin/views/audit-logs/index.tsx b/web/sdk/admin/views/audit-logs/index.tsx index b1e2ef087..a894079be 100644 --- a/web/sdk/admin/views/audit-logs/index.tsx +++ b/web/sdk/admin/views/audit-logs/index.tsx @@ -28,6 +28,7 @@ import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import SidePanelDetails from "./sidepanel-details"; import { useQueryClient } from "@tanstack/react-query"; import { AUDIT_LOG_QUERY_KEY } from "./util"; +import { useAdminTerminology } from "../../hooks/useAdminTerminology"; const NoAuditLogs = () => { return ( @@ -70,6 +71,7 @@ export type AuditLogsViewProps = { }; export default function AuditLogsView({ appName, onExportCsv, onNavigate }: AuditLogsViewProps = {}) { + const t = useAdminTerminology(); const queryClient = useQueryClient(); const [tableQuery, setTableQuery] = useDebouncedState<{ query: DataTableQuery; @@ -153,8 +155,9 @@ export default function AuditLogsView({ appName, onExportCsv, onNavigate }: Audi groupCountMap: infiniteData ? getGroupCountMapFromFirstPage(infiniteData) : {}, + t, }), - [infiniteData], + [infiniteData, t], ); const loading = isLoading || isFetchingNextPage; diff --git a/web/sdk/admin/views/audit-logs/sidepanel-details.tsx b/web/sdk/admin/views/audit-logs/sidepanel-details.tsx index 261912603..2bc6a083a 100644 --- a/web/sdk/admin/views/audit-logs/sidepanel-details.tsx +++ b/web/sdk/admin/views/audit-logs/sidepanel-details.tsx @@ -17,6 +17,8 @@ import ActorCell from "./actor-cell"; import SidepanelListItemLink from "./sidepanel-list-link"; import { isZeroUUID } from "../../utils/helper"; import SidepanelListId from "./sidepanel-list-id"; +import { useAdminTerminology } from "../../hooks/useAdminTerminology"; +import { useAdminPaths } from "../../hooks/useAdminPaths"; type SidePanelDetailsProps = Partial & { onClose: () => void; @@ -38,6 +40,8 @@ export default function SidePanelDetails({ onNavigate, ...rest }: SidePanelDetailsProps) { + const t = useAdminTerminology(); + const paths = useAdminPaths(); const { actor, event, resource, occurredAt, id, orgId, orgName, target } = rest; const date = dayjs(timestampToDate(occurredAt)); @@ -68,7 +72,7 @@ export default function SidePanelDetails({ Overview @@ -76,8 +80,8 @@ export default function SidePanelDetails({ {orgName || "-"} diff --git a/web/sdk/admin/views/invoices/columns.tsx b/web/sdk/admin/views/invoices/columns.tsx index c5f3e94c8..ab4405087 100644 --- a/web/sdk/admin/views/invoices/columns.tsx +++ b/web/sdk/admin/views/invoices/columns.tsx @@ -6,8 +6,15 @@ import { TimeStamp, timestampToDate, } from "../../utils/connect-timestamp"; +import { TerminologyEntity } from "../../hooks/useAdminTerminology"; -export const getColumns = (): DataTableColumnDef< +interface GetColumnsOptions { + t: { + organization: TerminologyEntity; + }; +} + +export const getColumns = ({ t }: GetColumnsOptions): DataTableColumnDef< SearchInvoicesResponse_Invoice, unknown >[] => { @@ -42,7 +49,7 @@ export const getColumns = (): DataTableColumnDef< }, { accessorKey: "orgTitle", - header: "Organization", + header: t.organization({ case: "capital" }), cell: ({ row, getValue }) => { return getValue() as string; }, diff --git a/web/sdk/admin/views/invoices/index.tsx b/web/sdk/admin/views/invoices/index.tsx index cdc5e264c..81ec0d0d1 100644 --- a/web/sdk/admin/views/invoices/index.tsx +++ b/web/sdk/admin/views/invoices/index.tsx @@ -19,8 +19,10 @@ import { } from "../../utils/connect-pagination"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { transformDataTableQueryToRQLRequest } from "../../utils/transform-query"; +import { useAdminTerminology } from "../../hooks/useAdminTerminology"; const NoInvoices = () => { + const t = useAdminTerminology(); return ( { subHeading: styles["empty-state-subheading"], }} heading="No invoices found" - subHeading="Start billing to organizations to populate the table" + subHeading={`Start billing to ${t.organization({ plural: true, case: "lower" })} to populate the table`} icon={} /> ); @@ -46,6 +48,7 @@ export type InvoicesViewProps = { }; export default function InvoicesView({ appName }: InvoicesViewProps = {}) { + const t = useAdminTerminology(); const [tableQuery, setTableQuery] = useState(INITIAL_QUERY); const query = transformDataTableQueryToRQLRequest(tableQuery, { @@ -95,7 +98,7 @@ export default function InvoicesView({ appName }: InvoicesViewProps = {}) { } }; - const columns = getColumns(); + const columns = getColumns({ t }); const loading = isLoading || isFetchingNextPage; diff --git a/web/sdk/admin/views/organizations/details/apis/columns.tsx b/web/sdk/admin/views/organizations/details/apis/columns.tsx index 95b39e355..eba9b5d05 100644 --- a/web/sdk/admin/views/organizations/details/apis/columns.tsx +++ b/web/sdk/admin/views/organizations/details/apis/columns.tsx @@ -3,12 +3,16 @@ import dayjs from "dayjs"; import { NULL_DATE } from "../../../../utils/constants"; import styles from "./apis.module.css"; import type { - SearchOrganizationServiceUsersResponse_OrganizationServiceUser, - SearchOrganizationServiceUsersResponse_Project + SearchOrganizationServiceUsersResponse_OrganizationServiceUser, + SearchOrganizationServiceUsersResponse_Project } from "@raystack/proton/frontier"; +import { TerminologyEntity } from "../../../../hooks/useAdminTerminology"; interface ColumnOptions { groupCountMap: Record>; + t: { + project: TerminologyEntity; + }; } export function getColumns( @@ -17,6 +21,7 @@ export function getColumns( SearchOrganizationServiceUsersResponse_OrganizationServiceUser, unknown >[] { + const { t } = options; return [ { accessorKey: "title", @@ -33,7 +38,7 @@ SearchOrganizationServiceUsersResponse_OrganizationServiceUser, }, { accessorKey: "projects", - header: "Projects", + header: t.project({ plural: true, case: "capital" }), cell: ({ getValue }) => { const value = getValue() as SearchOrganizationServiceUsersResponse_Project[]; diff --git a/web/sdk/admin/views/organizations/details/apis/details-dialog.tsx b/web/sdk/admin/views/organizations/details/apis/details-dialog.tsx index a490d2731..f989afbd7 100644 --- a/web/sdk/admin/views/organizations/details/apis/details-dialog.tsx +++ b/web/sdk/admin/views/organizations/details/apis/details-dialog.tsx @@ -11,6 +11,7 @@ import { } from "@raystack/proton/frontier"; import { create } from "@bufbuild/protobuf"; import { timestampToDayjs } from "../../../../utils/connect-timestamp"; +import { useAdminTerminology } from "../../../../hooks/useAdminTerminology"; interface ServiceUserDetailsDialogProps { onClose: () => void; @@ -21,6 +22,7 @@ export const ServiceUserDetailsDialog = ({ serviceUser, onClose, }: ServiceUserDetailsDialogProps) => { + const t = useAdminTerminology(); const { id = "", orgId = "", title = "" } = serviceUser || {}; const projectsRequest = useMemo( @@ -100,7 +102,7 @@ export const ServiceUserDetailsDialog = ({ : ""} - Projects{" "} + {t.project({ plural: true, case: "capital" })}{" "} {!isProjectLoading && projects.length > 0 ? `(${projects.length})` : ""} diff --git a/web/sdk/admin/views/organizations/details/apis/index.tsx b/web/sdk/admin/views/organizations/details/apis/index.tsx index f1eaa9ae9..12a7f0fbc 100644 --- a/web/sdk/admin/views/organizations/details/apis/index.tsx +++ b/web/sdk/admin/views/organizations/details/apis/index.tsx @@ -19,6 +19,7 @@ import { } from "../../../../utils/connect-pagination"; import { transformDataTableQueryToRQLRequest } from "../../../../utils/transform-query"; import { useDebounceValue } from "usehooks-ts"; +import { useAdminTerminology } from "../../../../hooks/useAdminTerminology"; const NoCredentials = () => { return ( @@ -61,6 +62,7 @@ const TRANSFORM_OPTIONS = { }; export function OrganizationApisView() { + const t = useAdminTerminology(); const { organization, search } = useContext(OrganizationContext); const organizationId = organization?.id || ""; const { @@ -86,7 +88,7 @@ export function OrganizationApisView() { null, ); - const title = `API | ${organization?.title} | Organizations`; + const title = `API | ${organization?.title} | ${t.organization({ plural: true, case: "capital" })}`; useEffect(() => { setSearchVisibility(true); @@ -162,8 +164,9 @@ export function OrganizationApisView() { groupCountMap: infiniteData ? getGroupCountMapFromFirstPage(infiniteData) : {}, + t, }), - [infiniteData], + [infiniteData, t], ); return ( diff --git a/web/sdk/admin/views/organizations/details/edit/organization.tsx b/web/sdk/admin/views/organizations/details/edit/organization.tsx index fbfedf193..423764a30 100644 --- a/web/sdk/admin/views/organizations/details/edit/organization.tsx +++ b/web/sdk/admin/views/organizations/details/edit/organization.tsx @@ -21,6 +21,7 @@ import { useMutation, createConnectQueryKey, useTransport } from "@connectrpc/co import { useQueryClient } from "@tanstack/react-query"; import { FrontierServiceQueries, UpdateOrganizationRequestSchema, type Organization, OrganizationSchema } from "@raystack/proton/frontier"; import { create, type JsonObject } from "@bufbuild/protobuf"; +import { useAdminTerminology } from "../../../../hooks/useAdminTerminology"; const orgUpdateSchema = z .object({ @@ -77,6 +78,7 @@ function getDefaultValue(organization: Organization, industries: string[]) { } export function EditOrganizationPanel({ onClose }: { onClose: () => void }) { + const t = useAdminTerminology(); const { organization, appUrl, countries: countriesFromContext = [], organizationTypes: industries = [] } = useContext(OrganizationContext); const [countries, setCountries] = useState(countriesFromContext); const queryClient = useQueryClient(); @@ -122,12 +124,12 @@ export function EditOrganizationPanel({ onClose }: { onClose: () => void }) { useEffect(() => { if (mutationError) { if (mutationError.message?.includes("already exists")) { - setError("name", { message: "Organization name already exists" }); + setError("name", { message: `${t.organization({ case: "capital" })} name already exists` }); } else { console.error("Unable to update organization:", mutationError); } } - }, [mutationError, setError]); + }, [mutationError, setError, t]); async function onSubmit(data: OrgUpdateSchema) { try { @@ -164,7 +166,7 @@ export function EditOrganizationPanel({ onClose }: { onClose: () => void }) { className={styles["side-panel"]} > void }) { style={{ width: "100%" }} > - Pick a logo for your organization + Pick a logo for your {t.organization({ case: "lower" })} ); }} /> - + void }) { return ( {showOtherTypeField ? ( diff --git a/web/sdk/admin/views/organizations/list/index.tsx b/web/sdk/admin/views/organizations/list/index.tsx index f0486ca21..dc78b0dd8 100644 --- a/web/sdk/admin/views/organizations/list/index.tsx +++ b/web/sdk/admin/views/organizations/list/index.tsx @@ -24,16 +24,18 @@ import { import { transformDataTableQueryToRQLRequest } from "../../../utils/transform-query"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { useDebouncedState } from "@raystack/apsara/hooks"; +import { useAdminTerminology } from "../../../hooks/useAdminTerminology"; const NoOrganizations = () => { + const t = useAdminTerminology(); return ( } /> ); @@ -68,6 +70,7 @@ export const OrganizationListView = ({ appUrl, countries = [], }: OrganizationListViewProps = {}) => { + const t = useAdminTerminology(); const [showCreatePanel, setShowCreatePanel] = useState(false); const [tableQuery, setTableQuery] = useDebouncedState( @@ -161,13 +164,13 @@ export const OrganizationListView = ({ if (isError) { return ( <> - + } - heading="Error Loading Organizations" + heading={`Error Loading ${t.organization({ plural: true, case: "capital" })}`} subHeading={ error?.message || - "Something went wrong while loading organizations. Please try again." + `Something went wrong while loading ${t.organization({ plural: true, case: "lower" })}. Please try again.` } /> @@ -194,7 +197,7 @@ export const OrganizationListView = ({ }} /> ) : null} - + { + const t = useAdminTerminology(); const [showSearch, setShowSearch] = useState(searchQuery ? true : false); const [isDownloading, setIsDownloading] = useState(false); @@ -58,7 +60,7 @@ export const OrganizationsNavabar = ({ - Organizations + {t.organization({ plural: true, case: "capital" })} @@ -69,7 +71,7 @@ export const OrganizationsNavabar = ({ data-test-id="admin-create-organization-btn" onClick={openCreatePanel} > - New Organization + New {t.organization({ case: "capital" })} {showSearch ? ( diff --git a/web/sdk/admin/views/users/details/layout/navbar.tsx b/web/sdk/admin/views/users/details/layout/navbar.tsx index 1fe65abde..6363c2bdd 100644 --- a/web/sdk/admin/views/users/details/layout/navbar.tsx +++ b/web/sdk/admin/views/users/details/layout/navbar.tsx @@ -11,6 +11,8 @@ import UserIcon from "../../../../assets/icons/UsersIcon"; import styles from "./navbar.module.css"; import { getUserName } from "../../util"; import { useUser } from "../user-context"; +import { useAdminTerminology } from "../../../../hooks/useAdminTerminology"; +import { useAdminPaths } from "../../../../hooks/useAdminPaths"; interface UserDetailsNavbarProps { toggleSidePanel: () => void; @@ -24,22 +26,24 @@ export const UserDetailsNavbar = ({ onNavigate, }: UserDetailsNavbarProps) => { const { user } = useUser(); + const t = useAdminTerminology(); + const paths = useAdminPaths(); - const links = [{ name: "Security", path: `/users/${user?.id}/security` }]; + const links = [{ name: "Security", path: `/${paths.users}/${user?.id}/security` }]; return (