Add organization settings page

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Jonathan
2025-05-27 14:14:20 +02:00
committed by Sacha Al Himdani
parent 61b1a24149
commit 6862841459
16 changed files with 767 additions and 13 deletions

View File

@@ -31,6 +31,7 @@
"react-relay": "^19.0.0", "react-relay": "^19.0.0",
"react-router": "^7.6.0", "react-router": "^7.6.0",
"relay-runtime": "^19.0.0", "relay-runtime": "^19.0.0",
"usehooks-ts": "^3.1.1",
"zod": "^3.25.17" "zod": "^3.25.17"
}, },
"devDependencies": { "devDependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -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
}
}
}
`;

View File

@@ -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;

View File

@@ -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<OrganizationGraph_ViewQuery>;
};
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<SettingsPageFragment$key>(
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<HTMLInputElement> = (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 (
<div className="space-y-6">
<PageHeader title={__("Settings")} />
{/* Organization settings */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-medium">
{__("Organization details")}
</h2>
{isUpdating && <Spinner />}
</div>
<Card padded className="space-y-4">
<div>
<Label>{__("Organization logo")}</Label>
<div className="flex w-max items-center gap-4">
<Avatar
src={organization.logoUrl}
name={organization.name}
size="xl"
/>
<FileButton
disabled={isUpdating}
onChange={updateOrganizationLogo}
variant="secondary"
className="ml-auto"
>
{__("Change logo")}
</FileButton>
</div>
</div>
<Field
readOnly={isUpdating}
name="name"
type="text"
defaultValue={organization.name}
label={__("Organization name")}
placeholder={__("Organization name")}
onChange={(e) => updateOrganizationName(e.currentTarget.value)}
/>
</Card>
</div>
{/* Integrations */}
<div className="space-y-4">
<h2 className="text-base font-medium">{__("Integrations")}</h2>
<Card padded>
<Connectors
organizationId={organization.id}
connectors={organization.connectors.edges.map((edge) => edge.node)}
/>
</Card>
</div>
</div>
);
}
function Connectors(props: {
organizationId: string;
connectors: NodeOf<SettingsPageFragment$data["connectors"]>[];
}) {
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 (
<div className="space-y-2">
{connectors.map((connector) => (
<Card key={connector.id} padded className="flex items-center gap-3">
<div>
<img src={`/${connector.id}.png`} alt="" />
</div>
<div className="mr-auto">
<h3 className="text-base font-semibold">{connector.name}</h3>
<p className="text-sm text-txt-tertiary">
{connector.createdAt
? sprintf(
__("Connected on %s"),
dateTimeFormat(connector.createdAt)
)
: connector.description}
</p>
</div>
{connector.createdAt ? (
<div>
<Badge variant="success" size="md">
{__("Connected")}
</Badge>
</div>
) : (
<Button variant="secondary" asChild>
<a href={getUrl(connector.id)}>{__("Connect")}</a>
</Button>
)}
</Card>
))}
</div>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -168,7 +168,18 @@ function PolicyRow({
return ( return (
<> <>
<Tr to={`/organizations/${organizationId}/policies/${policy.id}`}> <Tr to={`/organizations/${organizationId}/policies/${policy.id}`}>
<Td>{policy.title}</Td> <Td>
<div className="flex gap-4 items-center">
<img
src="/policy.png"
alt=""
width={28}
height={36}
className="border-4 border-highlight rounded box-content"
/>
{policy.title}
</div>
</Td>
<Td> <Td>
<Badge variant={isDraft ? "neutral" : "success"}> <Badge variant={isDraft ? "neutral" : "success"}>
{isDraft ? __("Draft") : __("Published")} {isDraft ? __("Draft") : __("Published")}

View File

@@ -7,13 +7,17 @@ import {
import { MainLayout } from "./layouts/MainLayout"; import { MainLayout } from "./layouts/MainLayout";
import { AuthLayout, CenteredLayout, CenteredLayoutSkeleton } from "@probo/ui"; import { AuthLayout, CenteredLayout, CenteredLayoutSkeleton } from "@probo/ui";
import { lazy, Suspense, type FC, type LazyExoticComponent } from "react"; 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 { 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 { useCleanup } from "./hooks/useDelayedEffect.ts";
import { riskRoutes } from "./routes/riskRoutes.ts"; import { riskRoutes } from "./routes/riskRoutes.ts";
import { measureRoutes } from "./routes/measureRoutes.ts"; import { measureRoutes } from "./routes/measureRoutes.ts";
import { policiesRoutes } from "./routes/policiesRoutes.ts"; import { policiesRoutes } from "./routes/policiesRoutes.ts";
import { organizationViewQuery } from "./hooks/graph/OrganizationGraph.ts";
function ErrorBoundary() { function ErrorBoundary() {
const error = useRouteError(); const error = useRouteError();
@@ -64,6 +68,15 @@ const routes = [
Component: MainLayout, Component: MainLayout,
ErrorBoundary: ErrorBoundary, ErrorBoundary: ErrorBoundary,
children: [ children: [
{
path: "settings",
fallback: PageSkeleton,
queryLoader: ({ organizationId }) =>
loadQuery(relayEnvironment, organizationViewQuery, {
organizationId,
}),
Component: lazy(() => import("./pages/organizations/SettingsPage")),
},
{ {
path: "vendors", path: "vendors",
fallback: PageSkeleton, fallback: PageSkeleton,

View File

@@ -1,7 +1,7 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
readonly API_URL: string; readonly VITE_API_URL: string;
} }
interface ImportMeta { interface ImportMeta {

View File

@@ -1,7 +1,7 @@
probod: probod:
api: api:
cors: cors:
allowed-origins: ["http://localhost:3000"] allowed-origins: ["http://localhost:5173"]
aws: aws:
access-key-id: "probod" access-key-id: "probod"
secret-access-key: "thisisnotasecret" secret-access-key: "thisisnotasecret"

View File

@@ -6,20 +6,21 @@ import type {
} from "react"; } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { tv, type VariantProps } from "tailwind-variants"; import { tv, type VariantProps } from "tailwind-variants";
import { Slot } from "../Slot";
export const button = tv({ 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", 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: { variants: {
variant: { variant: {
primary: 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: 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: 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: quaternary:
"bg-highlight text-txt-primary hover:bg-highlight-hover active:bg-highlight-pressed", "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: { disabled: {
true: "opacity-60 cursor-default", true: "opacity-60 cursor-default",
@@ -48,6 +49,7 @@ type Props = PropsWithChildren<
| "quaternary" | "quaternary"
| "danger"; | "danger";
to?: string; to?: string;
asChild?: boolean;
} & VariantProps<typeof button> } & VariantProps<typeof button>
> & > &
( (
@@ -56,15 +58,27 @@ type Props = PropsWithChildren<
); );
export const Button = (props: Props) => { export const Button = (props: Props) => {
const Component = props.to ? Link : "button";
const { const {
icon: IconComponent, icon: IconComponent,
iconAfter: IconAfterComponent, iconAfter: IconAfterComponent,
children, children,
onClick, onClick,
variant, variant,
asChild,
...componentProps ...componentProps
} = props; } = props;
if (asChild) {
return (
<Slot
{...componentProps}
className={button({ ...props, variant, empty: !children })}
>
{children}
</Slot>
);
}
const Component = props.to ? Link : "button";
return ( return (
// @ts-expect-error Component is too dynamic // @ts-expect-error Component is too dynamic
<Component <Component

View File

@@ -12,7 +12,7 @@ const card = tv({
base: "border border-border-low bg-level-1 rounded-2xl", base: "border border-border-low bg-level-1 rounded-2xl",
variants: { variants: {
padded: { padded: {
true: "p-6", true: "p-4",
}, },
}, },
}); });

View File

@@ -241,9 +241,12 @@
} }
} }
*:focus-visible { a:focus-visible,
box-shadow: var(--shadow-focus); button:focus-visible {
outline: none; outline: none;
&:not(:hover) {
box-shadow: var(--shadow-focus);
}
} }
::-webkit-scrollbar { ::-webkit-scrollbar {