diff --git a/.gitattributes b/.gitattributes index ccc5c1871..2cc76dc72 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -pkg/server/api/console/v1/v1_resolver.go linguist-generated=false +pkg/server/api/console/v1/v1_resolver.go linguist-detectable diff --git a/apps/console/package.json b/apps/console/package.json index f21835b5f..6f8c77c89 100644 --- a/apps/console/package.json +++ b/apps/console/package.json @@ -18,6 +18,7 @@ "@probo/hooks": "1.0.0", "@probo/i18n": "1.0.0", "@probo/react-lazy": "1.0.0", + "@probo/relay": "^1.0.0", "@probo/routes": "^1.0.0", "@probo/ui": "1.0.0", "@probo/vendors": "0.0.1", diff --git a/apps/console/src/components/PageError.tsx b/apps/console/src/components/PageError.tsx index 0e9ff3356..4b64f1682 100644 --- a/apps/console/src/components/PageError.tsx +++ b/apps/console/src/components/PageError.tsx @@ -2,7 +2,7 @@ import { useLocation, useRouteError } from "react-router"; import { IconPageCross } from "@probo/ui"; import { useTranslate } from "@probo/i18n"; import { useEffect, useRef } from "react"; -import { AuthenticationRequiredError } from "/providers/RelayProviders"; +import { AuthenticationRequiredError } from "@probo/relay"; const classNames = { wrapper: "py-10 text-center space-y-2 ", diff --git a/apps/console/src/environments.ts b/apps/console/src/environments.ts new file mode 100644 index 000000000..2e1ac42c4 --- /dev/null +++ b/apps/console/src/environments.ts @@ -0,0 +1,18 @@ +import { Environment, Network, RecordSource, Store } from "relay-runtime"; +import { makeFetchQuery } from "@probo/relay"; + +const source = new RecordSource(); +const store = new Store(source, { + queryCacheExpirationTime: 1 * 60 * 1000, + gcReleaseBufferSize: 20, +}); + +export const consoleEnvironment = new Environment({ + network: Network.create(makeFetchQuery("/api/console/v1/query")), + store, +}); + +export const connectEnvironment = new Environment({ + network: Network.create(makeFetchQuery("/api/connect/v1/query")), + store, +}); diff --git a/apps/console/src/layouts/EmployeeLayout.tsx b/apps/console/src/layouts/EmployeeLayout.tsx index 359a85b7b..82b7a7173 100644 --- a/apps/console/src/layouts/EmployeeLayout.tsx +++ b/apps/console/src/layouts/EmployeeLayout.tsx @@ -32,7 +32,6 @@ import type { EmployeeLayoutQuery as EmployeeLayoutQueryType } from "./__generat import { Suspense, useState, useEffect, useMemo, use } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { PageError } from "/components/PageError"; -import { buildEndpoint } from "/providers/RelayProviders"; import { PermissionsProvider } from "/providers/PermissionsProvider"; import { PermissionsContext } from "/providers/PermissionsContext"; @@ -71,11 +70,7 @@ export function EmployeeLayout() { ); } -function EmployeeLayoutContent({ - organizationId, -}: { - organizationId: string; -}) { +function EmployeeLayoutContent({ organizationId }: { organizationId: string }) { const data = useLazyLoadQuery(EmployeeLayoutQuery, { organizationId, }); @@ -352,7 +347,7 @@ function UserDropdown({ organizationId }: { organizationId: string }) { ) => { e.preventDefault(); - fetch(buildEndpoint("/connect/logout"), { + fetch("/connect/logout", { method: "DELETE", headers: { "Content-Type": "application/json", diff --git a/apps/console/src/layouts/MainLayout.tsx b/apps/console/src/layouts/MainLayout.tsx index aa9766d35..6ca0ed74d 100644 --- a/apps/console/src/layouts/MainLayout.tsx +++ b/apps/console/src/layouts/MainLayout.tsx @@ -50,7 +50,6 @@ import { graphql } from "relay-runtime"; import type { MainLayoutQuery as MainLayoutQueryType } from "./__generated__/MainLayoutQuery.graphql"; import { PageError } from "/components/PageError"; import { PermissionsProvider } from "/providers/PermissionsProvider"; -import { buildEndpoint } from "/providers/RelayProviders"; import { PermissionsContext } from "/providers/PermissionsContext"; const MainLayoutQuery = graphql` @@ -283,7 +282,7 @@ function UserDropdown({ organizationId }: { organizationId: string }) { ) => { e.preventDefault(); - fetch(buildEndpoint("/connect/logout"), { + fetch("/connect/logout", { method: "DELETE", headers: { "Content-Type": "application/json", @@ -371,7 +370,6 @@ interface InvitationsResponse { invitations: Invitation[]; } - function OrganizationSelector({ currentOrganization, }: { diff --git a/apps/console/src/layouts/PublicTrustCenterLayout.tsx b/apps/console/src/layouts/PublicTrustCenterLayout.tsx index 7d9e821b3..9c51cdbaa 100644 --- a/apps/console/src/layouts/PublicTrustCenterLayout.tsx +++ b/apps/console/src/layouts/PublicTrustCenterLayout.tsx @@ -1,7 +1,6 @@ import { Outlet } from "react-router"; import { Logo, Button, IconArrowBoxLeft, useToast } from "@probo/ui"; import { useTranslate } from "@probo/i18n"; -import { buildEndpoint } from "/providers/RelayProviders"; import type { ReactNode } from "react"; type Props = { @@ -11,18 +10,23 @@ type Props = { isAuthenticated?: boolean; }; -export function PublicTrustCenterLayout({ organizationName, organizationLogo, children, isAuthenticated }: Props) { +export function PublicTrustCenterLayout({ + organizationName, + organizationLogo, + children, + isAuthenticated, +}: Props) { const { __ } = useTranslate(); const { toast } = useToast(); - const handleLogout = async () => { + const handleLogout = async () => { try { - const response = await fetch(buildEndpoint('/api/trust/v1/auth/logout'), { - method: 'DELETE', + const response = await fetch("/api/trust/v1/auth/logout", { + method: "DELETE", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, - credentials: 'include', + credentials: "include", }); if (!response.ok) { diff --git a/apps/console/src/main.tsx b/apps/console/src/main.tsx index 879d96c1a..1a490ab75 100644 --- a/apps/console/src/main.tsx +++ b/apps/console/src/main.tsx @@ -1,7 +1,6 @@ import { createRoot } from "react-dom/client"; import "./index.css"; import { App } from "./App"; -import { RelayProvider } from "./providers/RelayProviders"; import { TranslatorProvider } from "./providers/TranslatorProvider"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -15,10 +14,8 @@ const queryClient = new QueryClient({ createRoot(document.getElementById("root")!).render( - - - - - - , + + + + ); diff --git a/apps/console/src/pages/APIKeysPage.tsx b/apps/console/src/pages/APIKeysPage.tsx index c0bd6661f..60d17001b 100644 --- a/apps/console/src/pages/APIKeysPage.tsx +++ b/apps/console/src/pages/APIKeysPage.tsx @@ -31,7 +31,7 @@ import { formatDate } from "@probo/helpers"; import { useFormWithSchema } from "/hooks/useFormWithSchema"; import { Controller } from "react-hook-form"; import { z } from "zod"; -import { UnAuthenticatedError } from "/providers/RelayProviders"; +import { UnAuthenticatedError } from "@probo/relay"; interface APIKey { id: string; @@ -56,10 +56,14 @@ interface Organization { const createSchema = z.object({ name: z.string().min(1, "Name is required"), expiresIn: z.enum(["1month", "3months", "6months", "1year"]), - organizations: z.array(z.object({ - organizationId: z.string(), - role: z.string(), - })).min(1, "At least one organization is required"), + organizations: z + .array( + z.object({ + organizationId: z.string(), + role: z.string(), + }) + ) + .min(1, "At least one organization is required"), }); type CreateFormData = z.infer; @@ -79,19 +83,24 @@ export default function APIKeysPage() { const [isCreating, setIsCreating] = useState(false); const [isUpdating, setIsUpdating] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const [selectedOrganizations, setSelectedOrganizations] = useState([]); - const [organizationRoles, setOrganizationRoles] = useState>({}); + const [selectedOrganizations, setSelectedOrganizations] = useState( + [] + ); + const [organizationRoles, setOrganizationRoles] = useState< + Record + >({}); const [editingAPIKey, setEditingAPIKey] = useState(null); const [editingName, setEditingName] = useState(""); const [error, setError] = useState(null); - const { formState, handleSubmit, reset, control, setValue, register } = useFormWithSchema(createSchema, { - defaultValues: { - name: new Date().toISOString().split('T')[0], - expiresIn: "1month", - organizations: [], - }, - }); + const { formState, handleSubmit, reset, control, setValue, register } = + useFormWithSchema(createSchema, { + defaultValues: { + name: new Date().toISOString().split("T")[0], + expiresIn: "1month", + organizations: [], + }, + }); if (error) { throw error; @@ -99,14 +108,16 @@ export default function APIKeysPage() { const fetchAPIKeys = async () => { try { - const response = await fetch('/connect/api-keys', { credentials: 'include' }); + const response = await fetch("/connect/api-keys", { + credentials: "include", + }); if (!response.ok) { - throw new Error('Failed to fetch API keys'); + throw new Error("Failed to fetch API keys"); } const data: { apiKeys: APIKey[] } = await response.json(); setApiKeys(data.apiKeys); } catch (err) { - console.error('Failed to fetch API keys:', err); + console.error("Failed to fetch API keys:", err); toast({ title: __("Error"), description: __("Failed to load API keys"), @@ -119,32 +130,40 @@ export default function APIKeysPage() { const fetchData = async () => { try { const [apiKeysResponse, organizationsResponse] = await Promise.all([ - fetch('/connect/api-keys', { credentials: 'include' }), - fetch('/connect/organizations?role=OWNER', { credentials: 'include' }), + fetch("/connect/api-keys", { credentials: "include" }), + fetch("/connect/organizations?role=OWNER", { + credentials: "include", + }), ]); - if (apiKeysResponse.status === 401 || organizationsResponse.status === 401) { + if ( + apiKeysResponse.status === 401 || + organizationsResponse.status === 401 + ) { setError(new UnAuthenticatedError()); return; } if (!apiKeysResponse.ok) { - throw new Error('Failed to fetch API keys'); + throw new Error("Failed to fetch API keys"); } if (!organizationsResponse.ok) { - throw new Error('Failed to fetch organizations'); + throw new Error("Failed to fetch organizations"); } const apiKeysData: { apiKeys: APIKey[] } = await apiKeysResponse.json(); - const orgsData: { organizations: Organization[] } = await organizationsResponse.json(); + const orgsData: { organizations: Organization[] } = + await organizationsResponse.json(); - const authenticatedOrgs = orgsData.organizations.filter(org => org.authStatus === "authenticated"); + const authenticatedOrgs = orgsData.organizations.filter( + (org) => org.authStatus === "authenticated" + ); setApiKeys(apiKeysData.apiKeys); setOrganizations(authenticatedOrgs); } catch (err) { - console.error('Failed to fetch data:', err); + console.error("Failed to fetch data:", err); toast({ title: __("Error"), description: __("Failed to load data"), @@ -179,12 +198,12 @@ export default function APIKeysPage() { setIsCreating(true); try { - const response = await fetch('/connect/api-keys', { - method: 'POST', + const response = await fetch("/connect/api-keys", { + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, - credentials: 'include', + credentials: "include", body: JSON.stringify({ name: formData.name, expiresAt: expiresAt.toISOString(), @@ -193,7 +212,7 @@ export default function APIKeysPage() { }); if (!response.ok) { - throw new Error('Failed to create API key'); + throw new Error("Failed to create API key"); } const data: { apiKey: APIKey; key: string } = await response.json(); @@ -224,9 +243,9 @@ export default function APIKeysPage() { const handleEdit = (apiKey: APIKey) => { setEditingAPIKey(apiKey); setEditingName(apiKey.name); - const orgIds = apiKey.organizations.map(org => org.organizationId); + const orgIds = apiKey.organizations.map((org) => org.organizationId); const roles: Record = {}; - apiKey.organizations.forEach(org => { + apiKey.organizations.forEach((org) => { roles[org.organizationId] = org.role; }); setSelectedOrganizations(orgIds); @@ -239,16 +258,16 @@ export default function APIKeysPage() { setIsUpdating(true); try { - const response = await fetch('/connect/api-keys', { - method: 'PUT', + const response = await fetch("/connect/api-keys", { + method: "PUT", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, - credentials: 'include', + credentials: "include", body: JSON.stringify({ id: editingAPIKey.id, name: editingName, - organizations: selectedOrganizations.map(id => ({ + organizations: selectedOrganizations.map((id) => ({ organizationId: id, role: organizationRoles[id] || "FULL", })), @@ -256,7 +275,7 @@ export default function APIKeysPage() { }); if (!response.ok) { - throw new Error('Failed to update API key'); + throw new Error("Failed to update API key"); } await fetchAPIKeys(); @@ -286,20 +305,20 @@ export default function APIKeysPage() { async () => { setIsDeleting(true); try { - const response = await fetch('/connect/api-keys', { - method: 'DELETE', + const response = await fetch("/connect/api-keys", { + method: "DELETE", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, - credentials: 'include', + credentials: "include", body: JSON.stringify({ id }), }); if (!response.ok) { - throw new Error('Failed to delete API key'); + throw new Error("Failed to delete API key"); } - setApiKeys(apiKeys.filter(key => key.id !== id)); + setApiKeys(apiKeys.filter((key) => key.id !== id)); toast({ title: __("Success"), description: __("API Key deleted successfully"), @@ -317,7 +336,9 @@ export default function APIKeysPage() { } }, { - message: __(`Are you sure you want to delete the API key "${name}"? This action cannot be undone.`), + message: __( + `Are you sure you want to delete the API key "${name}"? This action cannot be undone.` + ), } ); }; @@ -326,11 +347,11 @@ export default function APIKeysPage() { setIsLoadingKey(true); try { const response = await fetch(`/connect/api-keys/${id}`, { - credentials: 'include', + credentials: "include", }); if (!response.ok) { - throw new Error('Failed to load API key'); + throw new Error("Failed to load API key"); } const data: { key: string } = await response.json(); @@ -400,7 +421,7 @@ export default function APIKeysPage() { return ( -
+

{apiKey.name}

@@ -464,7 +485,9 @@ export default function APIKeysPage() { {__("Create an API key")}

- {__("Generate a new API key for programmatic access to your organization")} + {__( + "Generate a new API key for programmatic access to your organization" + )}

)}
@@ -556,9 +582,7 @@ export default function APIKeysPage() { {__("Name")} {__("Role")} -
- {__("Access")} -
+
{__("Access")}
@@ -570,30 +594,33 @@ export default function APIKeysPage() { {org.name}
- -
- {selectedOrganizations.includes(org.id) ? ( - - ) : ( - - )} -
- + +
+ {selectedOrganizations.includes(org.id) ? ( + + ) : ( + + )} +
+
id !== org.id); + newSelected = selectedOrganizations.filter( + (id) => id !== org.id + ); delete newRoles[org.id]; } setSelectedOrganizations(newSelected); setOrganizationRoles(newRoles); setValue( "organizations", - newSelected.map(id => ({ + newSelected.map((id) => ({ organizationId: id, - role: newRoles[id] || "FULL" + role: newRoles[id] || "FULL", })) ); }} @@ -660,14 +692,15 @@ export default function APIKeysPage() { type="button" variant="tertiary" onClick={() => { - const allSelected = selectedOrganizations.length === organizations.length; + const allSelected = + selectedOrganizations.length === organizations.length; if (allSelected) { setSelectedOrganizations([]); setOrganizationRoles({}); } else { - const allOrgIds = organizations.map(org => org.id); + const allOrgIds = organizations.map((org) => org.id); const newRoles: Record = {}; - allOrgIds.forEach(id => { + allOrgIds.forEach((id) => { newRoles[id] = organizationRoles[id] || "FULL"; }); setSelectedOrganizations(allOrgIds); @@ -676,7 +709,9 @@ export default function APIKeysPage() { }} className="text-xs h-7 min-h-7" > - {selectedOrganizations.length === organizations.length ? __("Clear All") : __("Select All")} + {selectedOrganizations.length === organizations.length + ? __("Clear All") + : __("Select All")} )}
@@ -692,9 +727,7 @@ export default function APIKeysPage() { {__("Name")} {__("Role")} -
- {__("Access")} -
+
{__("Access")}
@@ -712,7 +745,10 @@ export default function APIKeysPage() {