diff --git a/apps/console2/package.json b/apps/console2/package.json index 1476a64db..c163e176f 100644 --- a/apps/console2/package.json +++ b/apps/console2/package.json @@ -31,6 +31,7 @@ "react-relay": "^19.0.0", "react-router": "^7.6.0", "relay-runtime": "^19.0.0", + "usehooks-ts": "^3.1.1", "zod": "^3.25.17" }, "devDependencies": { diff --git a/apps/console2/public/github.png b/apps/console2/public/github.png new file mode 100644 index 000000000..b1532569d Binary files /dev/null and b/apps/console2/public/github.png differ diff --git a/apps/console2/public/policy.png b/apps/console2/public/policy.png new file mode 100644 index 000000000..68b641266 Binary files /dev/null and b/apps/console2/public/policy.png differ diff --git a/apps/console2/public/slack.png b/apps/console2/public/slack.png new file mode 100644 index 000000000..76e0345a5 Binary files /dev/null and b/apps/console2/public/slack.png differ diff --git a/apps/console2/src/hooks/graph/OrganizationGraph.ts b/apps/console2/src/hooks/graph/OrganizationGraph.ts new file mode 100644 index 000000000..6f924e88b --- /dev/null +++ b/apps/console2/src/hooks/graph/OrganizationGraph.ts @@ -0,0 +1,13 @@ +import { graphql } from "relay-runtime"; + +export const organizationViewQuery = graphql` + query OrganizationGraph_ViewQuery($organizationId: ID!) { + node(id: $organizationId) { + ... on Organization { + id + name + ...SettingsPageFragment + } + } + } +`; diff --git a/apps/console2/src/hooks/graph/__generated__/OrganizationGraph_ViewQuery.graphql.ts b/apps/console2/src/hooks/graph/__generated__/OrganizationGraph_ViewQuery.graphql.ts new file mode 100644 index 000000000..681041f79 --- /dev/null +++ b/apps/console2/src/hooks/graph/__generated__/OrganizationGraph_ViewQuery.graphql.ts @@ -0,0 +1,203 @@ +/** + * @generated SignedSource<<987fdfe9c961d800699430963c5dba55>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type OrganizationGraph_ViewQuery$variables = { + organizationId: string; +}; +export type OrganizationGraph_ViewQuery$data = { + readonly node: { + readonly id?: string; + readonly name?: string; + readonly " $fragmentSpreads": FragmentRefs<"SettingsPageFragment">; + }; +}; +export type OrganizationGraph_ViewQuery = { + response: OrganizationGraph_ViewQuery$data; + variables: OrganizationGraph_ViewQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "organizationId" + } +], +v1 = [ + { + "kind": "Variable", + "name": "id", + "variableName": "organizationId" + } +], +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v3 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "OrganizationGraph_ViewQuery", + "selections": [ + { + "alias": null, + "args": (v1/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "kind": "InlineFragment", + "selections": [ + (v2/*: any*/), + (v3/*: any*/), + { + "args": null, + "kind": "FragmentSpread", + "name": "SettingsPageFragment" + } + ], + "type": "Organization", + "abstractKey": null + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "OrganizationGraph_ViewQuery", + "selections": [ + { + "alias": null, + "args": (v1/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + }, + (v2/*: any*/), + { + "kind": "InlineFragment", + "selections": [ + (v3/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "logoUrl", + "storageKey": null + }, + { + "alias": null, + "args": [ + { + "kind": "Literal", + "name": "first", + "value": 100 + } + ], + "concreteType": "ConnectorConnection", + "kind": "LinkedField", + "name": "connectors", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "ConnectorEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Connector", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v2/*: any*/), + (v3/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "type", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": "connectors(first:100)" + } + ], + "type": "Organization", + "abstractKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "66677d1e24f46cd551b4201d60c5e0a2", + "id": null, + "metadata": {}, + "name": "OrganizationGraph_ViewQuery", + "operationKind": "query", + "text": "query OrganizationGraph_ViewQuery(\n $organizationId: ID!\n) {\n node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n name\n ...SettingsPageFragment\n }\n id\n }\n}\n\nfragment SettingsPageFragment on Organization {\n id\n name\n logoUrl\n connectors(first: 100) {\n edges {\n node {\n id\n name\n type\n createdAt\n }\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "196e8c1fc9c2e0b3c76c8b338ed5c7f7"; + +export default node; diff --git a/apps/console2/src/pages/organizations/SettingsPage.tsx b/apps/console2/src/pages/organizations/SettingsPage.tsx new file mode 100644 index 000000000..8706ff8e0 --- /dev/null +++ b/apps/console2/src/pages/organizations/SettingsPage.tsx @@ -0,0 +1,246 @@ +import { + Avatar, + Badge, + Button, + Card, + Field, + FileButton, + IconPlusLarge, + Label, + PageHeader, + Spinner, + useToast, +} from "@probo/ui"; +import { useTranslate } from "@probo/i18n"; +import type { PreloadedQuery } from "react-relay"; +import type { OrganizationGraph_ViewQuery } from "/hooks/graph/__generated__/OrganizationGraph_ViewQuery.graphql"; +import { useFragment, useMutation, usePreloadedQuery } from "react-relay"; +import { organizationViewQuery } from "/hooks/graph/OrganizationGraph"; +import { useDebounceCallback } from "usehooks-ts"; +import { graphql } from "relay-runtime"; +import type { + SettingsPageFragment$data, + SettingsPageFragment$key, +} from "./__generated__/SettingsPageFragment.graphql"; +import type { ChangeEventHandler } from "react"; +import { groupBy, sprintf } from "@probo/helpers"; +import type { NodeOf } from "/types"; + +type Props = { + queryRef: PreloadedQuery; +}; + +const organizationFragment = graphql` + fragment SettingsPageFragment on Organization { + id + name + logoUrl + connectors(first: 100) { + edges { + node { + id + name + type + createdAt + } + } + } + } +`; + +const updateOrganizationMutation = graphql` + mutation SettingsPage_UpdateMutation($input: UpdateOrganizationInput!) { + updateOrganization(input: $input) { + organization { + id + name + logoUrl + } + } + } +`; + +export default function SettingsPage({ queryRef }: Props) { + const { __ } = useTranslate(); + const organizationKey = usePreloadedQuery( + organizationViewQuery, + queryRef + ).node; + const { toast } = useToast(); + const organization = useFragment( + organizationFragment, + organizationKey + ); + const [updateOrganization, isUpdating] = useMutation( + updateOrganizationMutation + ); + + const updateOrganizationName = useDebounceCallback((name: string) => { + if (!name) { + return ""; + } + updateOrganization({ + variables: { + input: { + organizationId: organization.id, + name, + }, + }, + }); + }, 500); + + const updateOrganizationLogo: ChangeEventHandler = (e) => { + const file = e.target.files?.[0]; + if (!file) { + return; + } + updateOrganization({ + variables: { + input: { + organizationId: organization.id, + logo: null, + }, + }, + uploadables: { + "input.logo": file, + }, + onError(error) { + toast({ + title: __("Failed to update organization logo"), + description: error.message || __("Please try again."), + variant: "error", + }); + }, + }); + }; + + return ( +
+ + + {/* Organization settings */} +
+
+

+ {__("Organization details")} +

+ {isUpdating && } +
+ +
+ +
+ + + {__("Change logo")} + +
+
+ updateOrganizationName(e.currentTarget.value)} + /> +
+
+ + {/* Integrations */} +
+

{__("Integrations")}

+ + edge.node)} + /> + +
+
+ ); +} + +function Connectors(props: { + organizationId: string; + connectors: NodeOf[]; +}) { + const { __, dateTimeFormat } = useTranslate(); + const fakeconnectors = [ + { + id: "github", + name: "GitHub", + type: "oauth2", + createdAt: new Date(), + }, + ] satisfies typeof props.connectors; + const connectors = [ + { + id: "github", + name: "GitHub", + type: "oauth2", + description: __("Connect to GitHub repositories and issues"), + ...fakeconnectors.find((connector) => connector.id === "github"), + }, + { + id: "slack", + name: "Slack", + type: "oauth2", + description: __("Connect to Slack workspace and channels"), + ...fakeconnectors.find((connector) => connector.id === "slack"), + }, + ]; + + const getUrl = (connectorId: string) => { + const baseUrl = import.meta.env.VITE_API_URL || window.location.origin; + const url = new URL("/api/console/v1/connectors/initiate", baseUrl); + url.searchParams.append("organization_id", props.organizationId); + url.searchParams.append("connector_id", connectorId); + url.searchParams.append("continue", window.location.href); + return url.toString(); + }; + + return ( +
+ {connectors.map((connector) => ( + +
+ +
+
+

{connector.name}

+

+ {connector.createdAt + ? sprintf( + __("Connected on %s"), + dateTimeFormat(connector.createdAt) + ) + : connector.description} +

+
+ {connector.createdAt ? ( +
+ + {__("Connected")} + +
+ ) : ( + + )} +
+ ))} +
+ ); +} diff --git a/apps/console2/src/pages/organizations/__generated__/SettingsPageFragment.graphql.ts b/apps/console2/src/pages/organizations/__generated__/SettingsPageFragment.graphql.ts new file mode 100644 index 000000000..ae6dabe92 --- /dev/null +++ b/apps/console2/src/pages/organizations/__generated__/SettingsPageFragment.graphql.ts @@ -0,0 +1,127 @@ +/** + * @generated SignedSource<<02bed9e8295eda48e8fa5c0ced3ca1f6>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ReaderFragment } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type SettingsPageFragment$data = { + readonly connectors: { + readonly edges: ReadonlyArray<{ + readonly node: { + readonly createdAt: any; + readonly id: string; + readonly name: string; + readonly type: string; + }; + }>; + }; + readonly id: string; + readonly logoUrl: string | null | undefined; + readonly name: string; + readonly " $fragmentType": "SettingsPageFragment"; +}; +export type SettingsPageFragment$key = { + readonly " $data"?: SettingsPageFragment$data; + readonly " $fragmentSpreads": FragmentRefs<"SettingsPageFragment">; +}; + +const node: ReaderFragment = (function(){ +var v0 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v1 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null +}; +return { + "argumentDefinitions": [], + "kind": "Fragment", + "metadata": null, + "name": "SettingsPageFragment", + "selections": [ + (v0/*: any*/), + (v1/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "logoUrl", + "storageKey": null + }, + { + "alias": null, + "args": [ + { + "kind": "Literal", + "name": "first", + "value": 100 + } + ], + "concreteType": "ConnectorConnection", + "kind": "LinkedField", + "name": "connectors", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "ConnectorEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Connector", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v0/*: any*/), + (v1/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "type", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": "connectors(first:100)" + } + ], + "type": "Organization", + "abstractKey": null +}; +})(); + +(node as any).hash = "ecff581e2f458947e8e8b830fa3d5005"; + +export default node; diff --git a/apps/console2/src/pages/organizations/__generated__/SettingsPage_UpdateMutation.graphql.ts b/apps/console2/src/pages/organizations/__generated__/SettingsPage_UpdateMutation.graphql.ts new file mode 100644 index 000000000..5eeb00c44 --- /dev/null +++ b/apps/console2/src/pages/organizations/__generated__/SettingsPage_UpdateMutation.graphql.ts @@ -0,0 +1,123 @@ +/** + * @generated SignedSource<<6dc30c35c9ce7367967d235b1a259179>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type UpdateOrganizationInput = { + logo?: any | null | undefined; + name?: string | null | undefined; + organizationId: string; +}; +export type SettingsPage_UpdateMutation$variables = { + input: UpdateOrganizationInput; +}; +export type SettingsPage_UpdateMutation$data = { + readonly updateOrganization: { + readonly organization: { + readonly id: string; + readonly logoUrl: string | null | undefined; + readonly name: string; + }; + }; +}; +export type SettingsPage_UpdateMutation = { + response: SettingsPage_UpdateMutation$data; + variables: SettingsPage_UpdateMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "UpdateOrganizationPayload", + "kind": "LinkedField", + "name": "updateOrganization", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Organization", + "kind": "LinkedField", + "name": "organization", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "logoUrl", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "SettingsPage_UpdateMutation", + "selections": (v1/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "SettingsPage_UpdateMutation", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "ac8d86332fabe20dd7ea8fef4207b75e", + "id": null, + "metadata": {}, + "name": "SettingsPage_UpdateMutation", + "operationKind": "mutation", + "text": "mutation SettingsPage_UpdateMutation(\n $input: UpdateOrganizationInput!\n) {\n updateOrganization(input: $input) {\n organization {\n id\n name\n logoUrl\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "85f76939223253c1d102d05cd74f7538"; + +export default node; diff --git a/apps/console2/src/pages/organizations/policies/PoliciesPage.tsx b/apps/console2/src/pages/organizations/policies/PoliciesPage.tsx index 3e4e29b69..be2adb955 100644 --- a/apps/console2/src/pages/organizations/policies/PoliciesPage.tsx +++ b/apps/console2/src/pages/organizations/policies/PoliciesPage.tsx @@ -168,7 +168,18 @@ function PolicyRow({ return ( <> - {policy.title} + +
+ + {policy.title} +
+ {isDraft ? __("Draft") : __("Published")} diff --git a/apps/console2/src/routes.tsx b/apps/console2/src/routes.tsx index 2471321b9..5d8b2f450 100644 --- a/apps/console2/src/routes.tsx +++ b/apps/console2/src/routes.tsx @@ -7,13 +7,17 @@ import { import { MainLayout } from "./layouts/MainLayout"; import { AuthLayout, CenteredLayout, CenteredLayoutSkeleton } from "@probo/ui"; import { lazy, Suspense, type FC, type LazyExoticComponent } from "react"; -import { UnAuthenticatedError } from "./providers/RelayProviders"; +import { + relayEnvironment, + UnAuthenticatedError, +} from "./providers/RelayProviders"; import { PageSkeleton } from "./components/skeletons/PageSkeleton.tsx"; -import { type PreloadedQuery } from "react-relay"; +import { loadQuery, type PreloadedQuery } from "react-relay"; import { useCleanup } from "./hooks/useDelayedEffect.ts"; import { riskRoutes } from "./routes/riskRoutes.ts"; import { measureRoutes } from "./routes/measureRoutes.ts"; import { policiesRoutes } from "./routes/policiesRoutes.ts"; +import { organizationViewQuery } from "./hooks/graph/OrganizationGraph.ts"; function ErrorBoundary() { const error = useRouteError(); @@ -64,6 +68,15 @@ const routes = [ Component: MainLayout, ErrorBoundary: ErrorBoundary, children: [ + { + path: "settings", + fallback: PageSkeleton, + queryLoader: ({ organizationId }) => + loadQuery(relayEnvironment, organizationViewQuery, { + organizationId, + }), + Component: lazy(() => import("./pages/organizations/SettingsPage")), + }, { path: "vendors", fallback: PageSkeleton, diff --git a/apps/console2/src/vite-env.d.ts b/apps/console2/src/vite-env.d.ts index 5697923dd..29c29a188 100644 --- a/apps/console2/src/vite-env.d.ts +++ b/apps/console2/src/vite-env.d.ts @@ -1,7 +1,7 @@ /// interface ImportMetaEnv { - readonly API_URL: string; + readonly VITE_API_URL: string; } interface ImportMeta { diff --git a/cfg/dev.yaml b/cfg/dev.yaml index 6cb96885e..2cd7a9cf1 100644 --- a/cfg/dev.yaml +++ b/cfg/dev.yaml @@ -1,7 +1,7 @@ probod: api: cors: - allowed-origins: ["http://localhost:3000"] + allowed-origins: ["http://localhost:5173"] aws: access-key-id: "probod" secret-access-key: "thisisnotasecret" diff --git a/packages/ui/src/Atoms/Button/Button.tsx b/packages/ui/src/Atoms/Button/Button.tsx index 4d2ad8d27..29b59bd72 100644 --- a/packages/ui/src/Atoms/Button/Button.tsx +++ b/packages/ui/src/Atoms/Button/Button.tsx @@ -6,20 +6,21 @@ import type { } from "react"; import { Link } from "react-router"; import { tv, type VariantProps } from "tailwind-variants"; +import { Slot } from "../Slot"; export const button = tv({ base: "flex items-center justify-center gap-[6px] px-3 py-2 rounded-full cursor-pointer text-sm font-medium h-8 focus:outline-none", variants: { variant: { primary: - "bg-primary text-invert hover:bg-primary-hover shadow-base hover:shadow-hover active:bg-primary-pressed focus:shadow-focus", + "bg-primary text-invert hover:bg-primary-hover shadow-base hover:shadow-hover active:bg-primary-pressed", secondary: - "bg-secondary text-txt-primary hover:bg-secondary-hover shadow-base hover:shadow-hover active:bg-secondary-pressed focus:shadow-focus border border-border-low", + "bg-secondary text-txt-primary hover:bg-secondary-hover shadow-base hover:shadow-hover active:bg-secondary-pressed border border-border-low", tertiary: - "bg-tertiary text-txt-primary hover:bg-tertiary-hover active:bg-tertiary-pressed focus:shadow-focus", + "bg-tertiary text-txt-primary hover:bg-tertiary-hover active:bg-tertiary-pressed", quaternary: "bg-highlight text-txt-primary hover:bg-highlight-hover active:bg-highlight-pressed", - danger: "bg-danger-plain text-txt-invert hover:bg-danger-hover shadow-base hover:shadow-hover active:bg-danger-pressed focus:shadow-focus border border-border-danger", + danger: "bg-danger-plain text-txt-invert hover:bg-danger-hover shadow-base hover:shadow-hover active:bg-danger-pressed border border-border-danger", }, disabled: { true: "opacity-60 cursor-default", @@ -48,6 +49,7 @@ type Props = PropsWithChildren< | "quaternary" | "danger"; to?: string; + asChild?: boolean; } & VariantProps > & ( @@ -56,15 +58,27 @@ type Props = PropsWithChildren< ); export const Button = (props: Props) => { - const Component = props.to ? Link : "button"; const { icon: IconComponent, iconAfter: IconAfterComponent, children, onClick, variant, + asChild, ...componentProps } = props; + if (asChild) { + return ( + + {children} + + ); + } + + const Component = props.to ? Link : "button"; return ( // @ts-expect-error Component is too dynamic