Add slack integration

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-10-15 23:41:59 +02:00
parent 8302b11614
commit de004ce8d7
38 changed files with 2621 additions and 1578 deletions

View File

@@ -0,0 +1,87 @@
import { Badge, Button, Card } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { sprintf } from "@probo/helpers";
import type { TrustCenterGraphQuery$data } from "/hooks/graph/__generated__/TrustCenterGraphQuery.graphql";
type Props = {
organizationId: string;
slackConnections: NonNullable<TrustCenterGraphQuery$data["organization"]["slackConnections"]>["edges"][number]["node"][];
};
export function SlackConnections({ organizationId, slackConnections: connectedSlackConnections }: Props) {
const { __, dateTimeFormat } = useTranslate();
const slackConnectionDefinitions = [
{
id: "SLACK",
name: "Slack",
protocol: "OAUTH2",
description: __("Manage your trust center access with slack"),
},
];
const slackConnections = slackConnectionDefinitions.map((def) => {
const connected = connectedSlackConnections.find((c) => c.id);
return {
...def,
createdAt: connected?.createdAt,
channel: connected?.channel,
channelId: connected?.channelId,
};
});
const getUrl = (provider: 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", organizationId);
url.searchParams.append("provider", provider);
const trustCenterUrl = `/organizations/${organizationId}/trust-center`;
url.searchParams.append("continue", trustCenterUrl);
const finalUrl = url.toString();
return finalUrl;
};
return (
<div className="space-y-2">
{slackConnections.map((slackConnection) => (
<Card key={slackConnection.id} padded className="flex items-center gap-3">
<div>
<img src={`/${slackConnection.id.toLowerCase()}.png`} alt="" />
</div>
<div className="mr-auto">
<h3 className="text-base font-semibold">{slackConnection.name}</h3>
<p className="text-sm text-txt-tertiary">
{slackConnection.createdAt ? (
<>
{sprintf(
__("Connected on %s"),
dateTimeFormat(slackConnection.createdAt)
)}
{slackConnection.channel && (
<>
{" • "}
{sprintf(__("Channel: %s"), slackConnection.channel)}
</>
)}
</>
) : (
slackConnection.description
)}
</p>
</div>
{slackConnection.createdAt ? (
<div>
<Badge variant="success" size="md">
{__("Connected")}
</Badge>
</div>
) : (
<Button variant="secondary" asChild>
<a href={getUrl(slackConnection.id)}>{__("Connect")}</a>
</Button>
)}
</Card>
))}
</div>
);
}

View File

@@ -57,6 +57,17 @@ export const trustCenterQuery = graphql`
}
}
}
slackConnections(first: 100) {
edges {
node {
id
channel
channelId
createdAt
updatedAt
}
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<e08c5c7fa8cd8bec9755ef2afe810797>>
* @generated SignedSource<<e961390ac138b066af89b5b02169d66e>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -72,29 +72,22 @@ v6 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "type",
"name": "createdAt",
"storageKey": null
},
v7 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
v8 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
v9 = {
v8 = {
"kind": "Literal",
"name": "first",
"value": 20
},
v10 = {
v9 = {
"kind": "Literal",
"name": "orderBy",
"value": {
@@ -102,39 +95,39 @@ v10 = {
"field": "CREATED_AT"
}
},
v11 = [
(v9/*: any*/),
(v10/*: any*/)
v10 = [
(v8/*: any*/),
(v9/*: any*/)
],
v12 = {
v11 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "totalCount",
"storageKey": null
},
v13 = {
v12 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
v14 = {
v13 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "role",
"storageKey": null
},
v15 = {
v14 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
},
v16 = {
v15 = {
"alias": null,
"args": null,
"concreteType": "PageInfo",
@@ -173,7 +166,7 @@ v16 = {
],
"storageKey": null
},
v17 = {
v16 = {
"kind": "ClientExtension",
"selections": [
{
@@ -185,7 +178,7 @@ v17 = {
}
]
},
v18 = [
v17 = [
{
"kind": "Literal",
"name": "filter",
@@ -196,8 +189,8 @@ v18 = [
]
}
},
(v9/*: any*/),
(v10/*: any*/)
(v8/*: any*/),
(v9/*: any*/)
];
return {
"fragment": {
@@ -332,7 +325,13 @@ return {
"name": "dnsRecords",
"plural": true,
"selections": [
(v6/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "type",
"storageKey": null
},
(v3/*: any*/),
{
"alias": null,
@@ -358,8 +357,8 @@ return {
],
"storageKey": null
},
(v6/*: any*/),
(v7/*: any*/),
(v8/*: any*/),
{
"alias": null,
"args": null,
@@ -370,60 +369,17 @@ return {
],
"storageKey": null
},
(v6/*: any*/),
(v7/*: any*/),
(v8/*: any*/),
{
"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*/),
(v6/*: any*/),
(v7/*: any*/)
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "connectors(first:100)"
},
{
"alias": null,
"args": (v11/*: any*/),
"args": (v10/*: any*/),
"concreteType": "MembershipConnection",
"kind": "LinkedField",
"name": "memberships",
"plural": false,
"selections": [
(v12/*: any*/),
(v11/*: any*/),
{
"alias": null,
"args": null,
@@ -441,7 +397,7 @@ return {
"plural": false,
"selections": [
(v2/*: any*/),
(v13/*: any*/),
(v12/*: any*/),
{
"alias": null,
"args": null,
@@ -449,24 +405,24 @@ return {
"name": "emailAddress",
"storageKey": null
},
(v14/*: any*/),
(v7/*: any*/),
(v13/*: any*/),
(v6/*: any*/),
(v4/*: any*/)
],
"storageKey": null
},
(v15/*: any*/)
(v14/*: any*/)
],
"storageKey": null
},
(v16/*: any*/),
(v17/*: any*/)
(v15/*: any*/),
(v16/*: any*/)
],
"storageKey": "memberships(first:20,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
},
{
"alias": null,
"args": (v11/*: any*/),
"args": (v10/*: any*/),
"filters": [
"orderBy"
],
@@ -477,13 +433,13 @@ return {
},
{
"alias": null,
"args": (v18/*: any*/),
"args": (v17/*: any*/),
"concreteType": "InvitationConnection",
"kind": "LinkedField",
"name": "invitations",
"plural": false,
"selections": [
(v12/*: any*/),
(v11/*: any*/),
{
"alias": null,
"args": null,
@@ -502,8 +458,8 @@ return {
"selections": [
(v2/*: any*/),
(v5/*: any*/),
(v12/*: any*/),
(v13/*: any*/),
(v14/*: any*/),
{
"alias": null,
"args": null,
@@ -525,23 +481,23 @@ return {
"name": "acceptedAt",
"storageKey": null
},
(v7/*: any*/),
(v6/*: any*/),
(v4/*: any*/)
],
"storageKey": null
},
(v15/*: any*/)
(v14/*: any*/)
],
"storageKey": null
},
(v16/*: any*/),
(v17/*: any*/)
(v15/*: any*/),
(v16/*: any*/)
],
"storageKey": "invitations(filter:{\"statuses\":[\"PENDING\",\"EXPIRED\"]},first:20,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
},
{
"alias": null,
"args": (v18/*: any*/),
"args": (v17/*: any*/),
"filters": [
"orderBy",
"filter"
@@ -561,12 +517,12 @@ return {
]
},
"params": {
"cacheID": "daa371367edee014ac05cb9712ce360d",
"cacheID": "3d38000d9d2105ef8ae3d56edf31b372",
"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 ...SettingsPageMembershipsFragment\n ...SettingsPageInvitationsFragment\n }\n id\n }\n}\n\nfragment SettingsPageFragment on Organization {\n id\n name\n logoUrl\n horizontalLogoUrl\n description\n websiteUrl\n email\n headquarterAddress\n customDomain {\n id\n domain\n sslStatus\n dnsRecords {\n type\n name\n value\n ttl\n purpose\n }\n createdAt\n updatedAt\n sslExpiresAt\n }\n createdAt\n updatedAt\n connectors(first: 100) {\n edges {\n node {\n id\n name\n type\n createdAt\n }\n }\n }\n}\n\nfragment SettingsPageInvitationsFragment on Organization {\n invitations(first: 20, orderBy: {direction: ASC, field: CREATED_AT}, filter: {statuses: [PENDING, EXPIRED]}) {\n totalCount\n edges {\n node {\n id\n email\n fullName\n role\n status\n expiresAt\n acceptedAt\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment SettingsPageMembershipsFragment on Organization {\n memberships(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n fullName\n emailAddress\n role\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
"text": "query OrganizationGraph_ViewQuery(\n $organizationId: ID!\n) {\n node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n name\n ...SettingsPageFragment\n ...SettingsPageMembershipsFragment\n ...SettingsPageInvitationsFragment\n }\n id\n }\n}\n\nfragment SettingsPageFragment on Organization {\n id\n name\n logoUrl\n horizontalLogoUrl\n description\n websiteUrl\n email\n headquarterAddress\n customDomain {\n id\n domain\n sslStatus\n dnsRecords {\n type\n name\n value\n ttl\n purpose\n }\n createdAt\n updatedAt\n sslExpiresAt\n }\n createdAt\n updatedAt\n}\n\nfragment SettingsPageInvitationsFragment on Organization {\n invitations(first: 20, orderBy: {direction: ASC, field: CREATED_AT}, filter: {statuses: [PENDING, EXPIRED]}) {\n totalCount\n edges {\n node {\n id\n email\n fullName\n role\n status\n expiresAt\n acceptedAt\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment SettingsPageMembershipsFragment on Organization {\n memberships(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n fullName\n emailAddress\n role\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
}
};
})();

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<8e92507c4ea06441e81f19bdcd6d4feb>>
* @generated SignedSource<<6d55d37aa2ddfae16f8fc829840113d4>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -37,6 +37,17 @@ export type TrustCenterGraphQuery$data = {
};
readonly id?: string;
readonly name?: string;
readonly slackConnections?: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly channel: string | null | undefined;
readonly channelId: string | null | undefined;
readonly createdAt: any;
readonly id: string;
readonly updatedAt: any;
};
}>;
};
readonly trustCenter?: {
readonly active: boolean;
readonly createdAt: any;
@@ -248,6 +259,56 @@ v10 = [
(v7/*: any*/)
],
v11 = {
"alias": null,
"args": (v10/*: any*/),
"concreteType": "SlackConnectionConnection",
"kind": "LinkedField",
"name": "slackConnections",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "SlackConnectionEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "SlackConnection",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "channel",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "channelId",
"storageKey": null
},
(v5/*: any*/),
(v6/*: any*/)
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "slackConnections(first:100)"
},
v12 = {
"alias": null,
"args": null,
"kind": "ScalarField",
@@ -392,7 +453,8 @@ return {
}
],
"storageKey": "vendors(first:100)"
}
},
(v11/*: any*/)
],
"type": "Organization",
"abstractKey": null
@@ -472,7 +534,7 @@ return {
"name": "documentType",
"storageKey": null
},
(v11/*: any*/),
(v12/*: any*/),
{
"alias": null,
"args": [
@@ -589,7 +651,7 @@ return {
"name": "state",
"storageKey": null
},
(v11/*: any*/),
(v12/*: any*/),
(v5/*: any*/)
],
"storageKey": null
@@ -650,7 +712,8 @@ return {
}
],
"storageKey": "vendors(first:100)"
}
},
(v11/*: any*/)
],
"type": "Organization",
"abstractKey": null
@@ -661,16 +724,16 @@ return {
]
},
"params": {
"cacheID": "89f1657de6e2c073609a2c69d7b2d747",
"cacheID": "06cd2015e9fc2e65d35ae3153e587c58",
"id": null,
"metadata": {},
"name": "TrustCenterGraphQuery",
"operationKind": "query",
"text": "query TrustCenterGraphQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n name\n customDomain {\n id\n domain\n }\n trustCenter {\n id\n active\n ndaFileName\n ndaFileUrl\n createdAt\n updatedAt\n references(first: 100, orderBy: {field: CREATED_AT, direction: DESC}) {\n edges {\n node {\n id\n name\n description\n websiteUrl\n logoUrl\n createdAt\n updatedAt\n }\n }\n }\n }\n documents(first: 100) {\n edges {\n node {\n id\n ...TrustCenterDocumentsCardFragment\n }\n }\n }\n audits(first: 100) {\n edges {\n node {\n id\n ...TrustCenterAuditsCardFragment\n }\n }\n }\n vendors(first: 100) {\n edges {\n node {\n id\n ...TrustCenterVendorsCardFragment\n }\n }\n }\n }\n id\n }\n}\n\nfragment TrustCenterAuditsCardFragment on Audit {\n id\n name\n framework {\n name\n id\n }\n validFrom\n validUntil\n state\n trustCenterVisibility\n createdAt\n}\n\nfragment TrustCenterDocumentsCardFragment on Document {\n id\n title\n createdAt\n documentType\n trustCenterVisibility\n versions(first: 1) {\n edges {\n node {\n id\n status\n }\n }\n }\n}\n\nfragment TrustCenterVendorsCardFragment on Vendor {\n id\n name\n category\n description\n showOnTrustCenter\n createdAt\n}\n"
"text": "query TrustCenterGraphQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n name\n customDomain {\n id\n domain\n }\n trustCenter {\n id\n active\n ndaFileName\n ndaFileUrl\n createdAt\n updatedAt\n references(first: 100, orderBy: {field: CREATED_AT, direction: DESC}) {\n edges {\n node {\n id\n name\n description\n websiteUrl\n logoUrl\n createdAt\n updatedAt\n }\n }\n }\n }\n documents(first: 100) {\n edges {\n node {\n id\n ...TrustCenterDocumentsCardFragment\n }\n }\n }\n audits(first: 100) {\n edges {\n node {\n id\n ...TrustCenterAuditsCardFragment\n }\n }\n }\n vendors(first: 100) {\n edges {\n node {\n id\n ...TrustCenterVendorsCardFragment\n }\n }\n }\n slackConnections(first: 100) {\n edges {\n node {\n id\n channel\n channelId\n createdAt\n updatedAt\n }\n }\n }\n }\n id\n }\n}\n\nfragment TrustCenterAuditsCardFragment on Audit {\n id\n name\n framework {\n name\n id\n }\n validFrom\n validUntil\n state\n trustCenterVisibility\n createdAt\n}\n\nfragment TrustCenterDocumentsCardFragment on Document {\n id\n title\n createdAt\n documentType\n trustCenterVisibility\n versions(first: 1) {\n edges {\n node {\n id\n status\n }\n }\n }\n}\n\nfragment TrustCenterVendorsCardFragment on Vendor {\n id\n name\n category\n description\n showOnTrustCenter\n createdAt\n}\n"
}
};
})();
(node as any).hash = "60725f4e10d6e720a09a883430854ada";
(node as any).hash = "7ef9a5adc70dff604a149ea156e52edc";
export default node;

View File

@@ -32,10 +32,7 @@ import { organizationViewQuery } from "/hooks/graph/OrganizationGraph";
import { graphql } from "relay-runtime";
import { SortableTable, SortableTh } from "/components/SortableTable";
import clsx from "clsx";
import type {
SettingsPageFragment$data,
SettingsPageFragment$key,
} from "./__generated__/SettingsPageFragment.graphql";
import type { SettingsPageFragment$key } from "./__generated__/SettingsPageFragment.graphql";
import type {
SettingsPageMembershipsFragment$data,
SettingsPageMembershipsFragment$key
@@ -98,16 +95,6 @@ const organizationFragment = graphql`
}
createdAt
updatedAt
connectors(first: 100) {
edges {
node {
id
name
type
createdAt
}
}
}
}
`;
@@ -702,17 +689,6 @@ export default function SettingsPage({ queryRef }: Props) {
</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 className="space-y-4">
<h2 className="text-base font-medium">{__("Custom Domain")}</h2>
<CustomDomainManager
@@ -752,80 +728,6 @@ export default function SettingsPage({ queryRef }: Props) {
);
}
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>
);
}
const removeMemberMutation = graphql`
mutation SettingsPage_RemoveMemberMutation(
$input: RemoveMemberInput!

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<8477f42a02a102a4ce8540b794ac4289>>
* @generated SignedSource<<cf0ce3993d747999dfbe2ca60b509023>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -12,16 +12,6 @@ import { ReaderFragment } from 'relay-runtime';
export type SSLStatus = "ACTIVE" | "EXPIRED" | "FAILED" | "PENDING" | "PROVISIONING" | "RENEWING";
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 createdAt: any;
readonly customDomain: {
readonly createdAt: any;
@@ -70,20 +60,13 @@ v1 = {
"storageKey": null
},
v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "type",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
v4 = {
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
@@ -171,7 +154,13 @@ return {
"name": "dnsRecords",
"plural": true,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "type",
"storageKey": null
},
(v1/*: any*/),
{
"alias": null,
@@ -197,8 +186,8 @@ return {
],
"storageKey": null
},
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
{
"alias": null,
"args": null,
@@ -209,57 +198,14 @@ return {
],
"storageKey": null
},
(v3/*: any*/),
(v4/*: any*/),
{
"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*/),
(v2/*: any*/),
(v3/*: any*/)
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": "connectors(first:100)"
}
(v2/*: any*/),
(v3/*: any*/)
],
"type": "Organization",
"abstractKey": null
};
})();
(node as any).hash = "b3b152b507befd6b6918a12972e05f14";
(node as any).hash = "1e64bcccf8ef3a8ead79b3446e8a3ccd";
export default node;

View File

@@ -20,6 +20,7 @@ import { useState } from "react";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { Outlet, useLocation, Link } from "react-router";
import { TrustCenterReferencesSection } from "/components/trustCenter/TrustCenterReferencesSection";
import { SlackConnections } from "../../../components/organizations/SlackConnection";
type Props = {
queryRef: PreloadedQuery<TrustCenterGraphQuery>;
@@ -257,6 +258,16 @@ export default function TrustCenterPage({ queryRef }: Props) {
<TrustCenterReferencesSection trustCenterId={organization.trustCenter.id} />
)}
<div className="space-y-4">
<h2 className="text-base font-medium">{__("Integrations")}</h2>
<Card padded>
<SlackConnections
organizationId={organization.id!}
slackConnections={organization.slackConnections?.edges.map((edge) => edge.node) ?? []}
/>
</Card>
</div>
<div className="space-y-4">
<Tabs>
<TabItem

View File

@@ -62,6 +62,9 @@ probod:
addr: "localhost:1025"
tls-required: false
slack:
sender-interval: 60
openai:
api-key: "thisisnotasecret"
temperature: 0.1
@@ -76,3 +79,15 @@ probod:
email: "admin@getprobo.com"
key-type: "EC256"
insecure-tls: true
connectors:
- provider: "slack"
protocol: "oauth2"
config:
client-id: "slack-client-id"
client-secret: "thisisnotasecret"
redirect-uri: "http://localhost:8080/api/console/v1/connectors/complete"
auth-url: "https://slack.com/oauth/v2/authorize"
token-url: "https://slack.com/api/oauth.v2.access"
scopes:
- "incoming-webhook"

View File

@@ -27,8 +27,8 @@ type (
ProtocolType string
Connector interface {
Initiate(ctx context.Context, connectorID string, organizationID gid.GID, r *http.Request) (string, error)
Complete(ctx context.Context, connectorID string, organizationID gid.GID, r *http.Request) (Connection, error)
Initiate(ctx context.Context, provider string, organizationID gid.GID, r *http.Request) (string, error)
Complete(ctx context.Context, r *http.Request) (Connection, *gid.GID, error)
}
Connection interface {
@@ -41,20 +41,28 @@ type (
)
const (
ProtocolOAuth2 ProtocolType = "oauth2"
ProtocolOAuth2 ProtocolType = "OAUTH2"
)
func UnmarshalConnection(prtcl ProtocolType, data []byte) (Connection, error) {
func UnmarshalConnection(protocol string, provider string, data []byte) (Connection, error) {
switch protocol {
case string(ProtocolOAuth2):
switch provider {
case SlackProvider:
var slackConn SlackConnection
if err := json.Unmarshal(data, &slackConn); err != nil {
return nil, fmt.Errorf("cannot unmarshal slack connection: %w", err)
}
return &slackConn, nil
switch prtcl {
case ProtocolOAuth2:
var conn OAuth2Connection
if err := json.Unmarshal(data, &conn); err != nil {
return nil, fmt.Errorf("cannot unmarshal oauth2 connection: %w", err)
default:
var conn OAuth2Connection
if err := json.Unmarshal(data, &conn); err != nil {
return nil, fmt.Errorf("cannot unmarshal oauth2 connection: %w", err)
}
return &conn, nil
}
return &conn, nil
}
return nil, fmt.Errorf("unknown connection type: %s", prtcl)
return nil, fmt.Errorf("unknown connection protocol: %s", protocol)
}

View File

@@ -15,9 +15,11 @@
package connector
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
@@ -46,7 +48,7 @@ type (
OAuth2State struct {
OrganizationID string `json:"oid"`
ConnectorID string `json:"cid"`
Provider string `json:"provider"`
}
OAuth2Connection struct {
@@ -66,29 +68,33 @@ var (
OAuth2TokenTTL = 10 * time.Minute
)
func (c *OAuth2Connector) Initiate(ctx context.Context, connectorID string, organizationID gid.GID, r *http.Request) (string, error) {
stateData := OAuth2State{OrganizationID: organizationID.String(), ConnectorID: connectorID}
func (c *OAuth2Connector) Initiate(ctx context.Context, provider string, organizationID gid.GID, r *http.Request) (string, error) {
stateData := OAuth2State{
OrganizationID: organizationID.String(),
Provider: provider,
}
state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, stateData)
if err != nil {
return "", fmt.Errorf("cannot create state token: %w", err)
}
redirectURI, err := url.Parse(c.RedirectURI)
redirectURI := c.RedirectURI
redirectURIParsed, err := url.Parse(redirectURI)
if err != nil {
return "", fmt.Errorf("cannot parse redirect URI: %w", err)
}
redirectQuery := url.Values{}
redirectQuery.Set("organization_id", organizationID.String())
redirectQuery.Set("connector_id", connectorID)
redirectQuery.Set("continue", r.URL.Query().Get("continue"))
redirectURI.RawQuery = redirectQuery.Encode()
q := redirectURIParsed.Query()
q.Set("provider", provider)
if continueURL := r.URL.Query().Get("continue"); continueURL != "" {
q.Set("continue", continueURL)
}
redirectURIParsed.RawQuery = q.Encode()
redirectURI = redirectURIParsed.String()
authCodeQuery := url.Values{}
authCodeQuery.Set("state", state)
authCodeQuery.Set("client_id", c.ClientID)
authCodeQuery.Set("redirect_uri", redirectURI.String())
authCodeQuery.Set("redirect_uri", redirectURI)
authCodeQuery.Set("response_type", "code")
authCodeQuery.Set("scope", strings.Join(c.Scopes, " "))
@@ -102,51 +108,59 @@ func (c *OAuth2Connector) Initiate(ctx context.Context, connectorID string, orga
return u.String(), nil
}
func (c *OAuth2Connector) Complete(ctx context.Context, connectorID string, organizationID gid.GID, r *http.Request) (Connection, error) {
func (c *OAuth2Connector) Complete(ctx context.Context, r *http.Request) (Connection, *gid.GID, error) {
provider := r.URL.Query().Get("provider")
if provider == "" {
return nil, nil, fmt.Errorf("missing provider in query parameters")
}
code := r.URL.Query().Get("code")
if code == "" {
return nil, fmt.Errorf("no code in request")
return nil, nil, fmt.Errorf("no code in request")
}
state := r.URL.Query().Get("state")
if state == "" {
return nil, fmt.Errorf("no state in request")
stateToken := r.URL.Query().Get("state")
if stateToken == "" {
return nil, nil, fmt.Errorf("no state in request")
}
payload, err := statelesstoken.ValidateToken[OAuth2State](c.ClientSecret, OAuth2TokenType, state)
payload, err := statelesstoken.ValidateToken[OAuth2State](c.ClientSecret, OAuth2TokenType, stateToken)
if err != nil {
return nil, fmt.Errorf("cannot validate state token: %w", err)
return nil, nil, fmt.Errorf("cannot validate state token: %w", err)
}
if payload.Data.OrganizationID != organizationID.String() {
return nil, fmt.Errorf("invalid organization ID")
if payload.Data.Provider != provider {
return nil, nil, fmt.Errorf("provider mismatch: state has %q, query has %q", payload.Data.Provider, provider)
}
if payload.Data.ConnectorID != connectorID {
return nil, fmt.Errorf("invalid connector ID")
}
redirectURI, err := url.Parse(c.RedirectURI)
organizationID, err := gid.ParseGID(payload.Data.OrganizationID)
if err != nil {
return nil, fmt.Errorf("cannot parse redirect URI: %w", err)
return nil, nil, fmt.Errorf("cannot parse organization ID: %w", err)
}
redirectQuery := url.Values{}
redirectQuery.Set("organization_id", organizationID.String())
redirectQuery.Set("connector_id", connectorID)
redirectURI.RawQuery = redirectQuery.Encode()
redirectURI := c.RedirectURI
redirectURIParsed, err := url.Parse(redirectURI)
if err != nil {
return nil, nil, fmt.Errorf("cannot parse redirect URI: %w", err)
}
q := redirectURIParsed.Query()
q.Set("provider", provider)
if continueURL := r.URL.Query().Get("continue"); continueURL != "" {
q.Set("continue", continueURL)
}
redirectURIParsed.RawQuery = q.Encode()
redirectURI = redirectURIParsed.String()
tokenRequestData := url.Values{}
tokenRequestData.Set("client_id", c.ClientID)
tokenRequestData.Set("client_secret", c.ClientSecret)
tokenRequestData.Set("code", code)
tokenRequestData.Set("redirect_uri", redirectURI.String())
tokenRequestData.Set("redirect_uri", redirectURI)
tokenRequestData.Set("grant_type", "authorization_code")
tokenRequest, err := http.NewRequestWithContext(ctx, "POST", c.TokenURL, strings.NewReader(tokenRequestData.Encode()))
tokenRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.TokenURL, strings.NewReader(tokenRequestData.Encode()))
if err != nil {
return nil, fmt.Errorf("cannot create token request: %w", err)
return nil, nil, fmt.Errorf("cannot create token request: %w", err)
}
tokenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
@@ -155,35 +169,32 @@ func (c *OAuth2Connector) Complete(ctx context.Context, connectorID string, orga
tokenResp, err := http.DefaultClient.Do(tokenRequest)
if err != nil {
return nil, fmt.Errorf("cannot post token URL: %w", err)
return nil, nil, fmt.Errorf("cannot post token URL: %w", err)
}
defer tokenResp.Body.Close()
if tokenResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token response status: %d", tokenResp.StatusCode)
return nil, nil, fmt.Errorf("token response status: %d", tokenResp.StatusCode)
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt time.Time `json:"expires_at"`
Scope string `json:"scope"`
TokenType string `json:"token_type"`
}
var token tokenResponse
err = json.NewDecoder(tokenResp.Body).Decode(&token)
body, err := io.ReadAll(tokenResp.Body)
if err != nil {
return nil, fmt.Errorf("cannot decode token response: %w", err)
return nil, nil, fmt.Errorf("cannot read token response body: %w", err)
}
return &OAuth2Connection{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
ExpiresAt: token.ExpiresAt,
Scope: token.Scope,
TokenType: token.TokenType,
}, nil
var oauth2Conn OAuth2Connection
var buf bytes.Buffer
buf.Write(body)
err = json.NewDecoder(&buf).Decode(&oauth2Conn)
if err != nil {
return nil, nil, fmt.Errorf("cannot decode token response: %w", err)
}
if provider == SlackProvider {
return ParseSlackTokenResponse(body, oauth2Conn, organizationID)
}
return &oauth2Conn, &organizationID, nil
}
func (c *OAuth2Connection) Type() ProtocolType {

View File

@@ -36,40 +36,40 @@ func NewConnectorRegistry() *ConnectorRegistry {
}
}
func (cr *ConnectorRegistry) Register(connectorID string, connector Connector) error {
func (cr *ConnectorRegistry) Register(provider string, connector Connector) error {
cr.Lock()
defer cr.Unlock()
if _, ok := cr.connectors[connectorID]; ok {
return fmt.Errorf("connector %q already registered", connectorID)
if _, ok := cr.connectors[provider]; ok {
return fmt.Errorf("connector %q already registered", provider)
}
cr.connectors[connectorID] = connector
cr.connectors[provider] = connector
return nil
}
func (cr *ConnectorRegistry) Get(connectorID string) (Connector, error) {
func (cr *ConnectorRegistry) Get(provider string) (Connector, error) {
cr.RLock()
defer cr.RUnlock()
connector, ok := cr.connectors[connectorID]
connector, ok := cr.connectors[provider]
if !ok {
return nil, fmt.Errorf("connector %q not found", connectorID)
return nil, fmt.Errorf("connector %q not found", provider)
}
return connector, nil
}
func (cr *ConnectorRegistry) Initiate(ctx context.Context, connectorID string, organizationID gid.GID, r *http.Request) (string, error) {
connector, err := cr.Get(connectorID)
func (cr *ConnectorRegistry) Initiate(ctx context.Context, provider string, organizationID gid.GID, r *http.Request) (string, error) {
connector, err := cr.Get(provider)
if err != nil {
return "", fmt.Errorf("cannot initiate connector: %w", err)
}
return connector.Initiate(ctx, connectorID, organizationID, r)
return connector.Initiate(ctx, provider, organizationID, r)
}
func (cr *ConnectorRegistry) Complete(ctx context.Context, connectorID string, organizationID gid.GID, r *http.Request) (Connection, error) {
connector, err := cr.Get(connectorID)
func (cr *ConnectorRegistry) Complete(ctx context.Context, provider string, r *http.Request) (Connection, *gid.GID, error) {
connector, err := cr.Get(provider)
if err != nil {
return nil, fmt.Errorf("cannot complete connector: %w", err)
return nil, nil, fmt.Errorf("cannot complete connector: %w", err)
}
return connector.Complete(ctx, connectorID, organizationID, r)
return connector.Complete(ctx, r)
}

134
pkg/connector/slack.go Normal file
View File

@@ -0,0 +1,134 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package connector
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/getprobo/probo/pkg/gid"
)
type (
SlackConnection struct {
OAuth2Connection
Settings SlackSettings `json:"settings"`
}
SlackSettings struct {
WebhookURL string `json:"webhook_url,omitempty"` // Encrypted
Channel string `json:"channel,omitempty"`
ChannelID string `json:"channel_id,omitempty"`
}
IncomingWebhook struct {
URL string `json:"url"`
Channel string `json:"channel"`
ChannelID string `json:"channel_id"`
}
SlackTokenResponse struct {
IncomingWebhook *IncomingWebhook `json:"incoming_webhook,omitempty"`
}
)
const (
SlackProvider = "SLACK"
)
var _ Connection = (*SlackConnection)(nil)
func (c *SlackConnection) Type() ProtocolType {
return ProtocolOAuth2
}
func (c *SlackConnection) Client(ctx context.Context) (*http.Client, error) {
return c.OAuth2Connection.Client(ctx)
}
func (c SlackConnection) MarshalJSON() ([]byte, error) {
return json.Marshal(&struct {
Type string `json:"type"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
TokenType string `json:"token_type"`
Scope string `json:"scope,omitempty"`
WebhookURL string `json:"webhook_url,omitempty"`
}{
Type: string(ProtocolOAuth2),
AccessToken: c.OAuth2Connection.AccessToken,
RefreshToken: c.OAuth2Connection.RefreshToken,
ExpiresAt: c.OAuth2Connection.ExpiresAt,
TokenType: c.OAuth2Connection.TokenType,
Scope: c.OAuth2Connection.Scope,
WebhookURL: c.Settings.WebhookURL,
})
}
func (c *SlackConnection) UnmarshalJSON(data []byte) error {
aux := &struct {
Type string `json:"type"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
TokenType string `json:"token_type"`
Scope string `json:"scope,omitempty"`
WebhookURL string `json:"webhook_url,omitempty"`
}{}
if err := json.Unmarshal(data, aux); err != nil {
return err
}
c.OAuth2Connection = OAuth2Connection{
AccessToken: aux.AccessToken,
RefreshToken: aux.RefreshToken,
ExpiresAt: aux.ExpiresAt,
TokenType: aux.TokenType,
Scope: aux.Scope,
}
c.Settings.WebhookURL = aux.WebhookURL
return nil
}
func ParseSlackTokenResponse(body []byte, oauth2Conn OAuth2Connection, organizationID gid.GID) (*SlackConnection, *gid.GID, error) {
var slackResponse SlackTokenResponse
var buf bytes.Buffer
buf.Write(body)
if err := json.NewDecoder(&buf).Decode(&slackResponse); err != nil {
return nil, nil, fmt.Errorf("cannot decode Slack token response: %w", err)
}
if slackResponse.IncomingWebhook == nil {
return nil, nil, fmt.Errorf("incoming webhook is required for Slack")
}
settings := SlackSettings{
WebhookURL: slackResponse.IncomingWebhook.URL,
Channel: slackResponse.IncomingWebhook.Channel,
ChannelID: slackResponse.IncomingWebhook.ChannelID,
}
return &SlackConnection{
OAuth2Connection: oauth2Conn,
Settings: settings,
}, &organizationID, nil
}

View File

@@ -31,33 +31,230 @@ import (
type (
Connector struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Name string `db:"name"`
Type connector.ProtocolType `db:"type"`
Connection connector.Connection `db:"-"`
EncryptedConnection []byte `db:"encrypted_connection"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Provider ConnectorProvider `db:"provider"`
Protocol ConnectorProtocol `db:"protocol"`
Settings map[string]any `db:"settings"`
Connection connector.Connection `db:"-"`
EncryptedConnection []byte `db:"encrypted_connection"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Connectors []*Connector
)
func (c *Connectors) LoadWithoutDecryptedConnectionByOrganizationID(
func (c *Connector) CursorKey(orderBy ConnectorOrderField) page.CursorKey {
switch orderBy {
case ConnectorOrderFieldCreatedAt:
return page.CursorKey{ID: c.ID, Value: c.CreatedAt}
case ConnectorOrderFieldProvider:
return page.CursorKey{ID: c.ID, Value: c.Provider}
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (c *Connectors) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[ConnectorOrderField],
encryptionKey cipher.EncryptionKey,
filter *ConnectorProviderFilter,
) error {
if err := c.loadByOrganizationIDWithPagination(ctx, conn, scope, organizationID, cursor, filter); err != nil {
return fmt.Errorf("cannot load connectors by organization ID: %w", err)
}
if err := c.decryptConnections(encryptionKey); err != nil {
return fmt.Errorf("cannot decrypt connections: %w", err)
}
return nil
}
func (c *Connectors) LoadAllByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
encryptionKey cipher.EncryptionKey,
) error {
if err := c.loadAllByOrganizationID(ctx, conn, scope, organizationID); err != nil {
return fmt.Errorf("cannot load all connectors by organization ID: %w", err)
}
if err := c.decryptConnections(encryptionKey); err != nil {
return fmt.Errorf("cannot decrypt connections: %w", err)
}
return nil
}
func (c *Connectors) LoadAllByOrganizationIDProtocolAndProvider(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
protocol ConnectorProtocol,
provider ConnectorProvider,
encryptionKey cipher.EncryptionKey,
) error {
if err := c.loadAllByOrganizationIDProtocolAndProvider(ctx, conn, scope, organizationID, protocol, provider); err != nil {
return fmt.Errorf("cannot load all connectors by organization ID, protocol and provider: %w", err)
}
if err := c.decryptConnections(encryptionKey); err != nil {
return fmt.Errorf("cannot decrypt connections: %w", err)
}
return nil
}
func (c *Connectors) LoadByOrganizationIDWithoutDecryptedConnection(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[ConnectorOrderField],
filter *ConnectorProviderFilter,
) error {
return c.loadByOrganizationIDWithPagination(ctx, conn, scope, organizationID, cursor, filter)
}
func (c *Connectors) LoadAllByOrganizationIDWithoutDecryptedConnection(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) error {
return c.loadAllByOrganizationID(ctx, conn, scope, organizationID)
}
func (c *Connector) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
encryptionKey cipher.EncryptionKey,
) error {
q := `
INSERT INTO connectors (
id,
tenant_id,
organization_id,
provider,
protocol,
settings,
encrypted_connection,
created_at,
updated_at
) VALUES (
@id,
@tenant_id,
@organization_id,
@provider,
@protocol,
@settings,
@encrypted_connection,
@created_at,
@updated_at
)
`
if c.Connection == nil {
return fmt.Errorf("connection is nil")
}
c.extractSlackSettings()
connection, err := json.Marshal(c.Connection)
if err != nil {
return fmt.Errorf("cannot marshal connection: %w", err)
}
encryptedConnection, err := cipher.Encrypt(connection, encryptionKey)
if err != nil {
return fmt.Errorf("cannot encrypt connection: %w", err)
}
args := pgx.StrictNamedArgs{
"id": c.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": c.OrganizationID,
"provider": c.Provider,
"protocol": c.Protocol,
"settings": c.Settings,
"encrypted_connection": encryptedConnection,
"created_at": c.CreatedAt,
"updated_at": c.UpdatedAt,
}
_, err = conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert connector: %w", err)
}
c.EncryptedConnection = encryptedConnection
c.populateSlackSettings()
return nil
}
func (c *Connector) populateSlackSettings() {
if c.Provider != ConnectorProviderSlack {
return
}
slackConn, ok := c.Connection.(*connector.SlackConnection)
if !ok {
return
}
if channel, ok := c.Settings["channel"].(string); ok {
slackConn.Settings.Channel = channel
}
if channelID, ok := c.Settings["channel_id"].(string); ok {
slackConn.Settings.ChannelID = channelID
}
}
func (c *Connector) extractSlackSettings() {
if c.Provider != ConnectorProviderSlack {
return
}
slackConn, ok := c.Connection.(*connector.SlackConnection)
if !ok {
return
}
c.Settings = make(map[string]any)
if slackConn.Settings.Channel != "" {
c.Settings["channel"] = slackConn.Settings.Channel
}
if slackConn.Settings.ChannelID != "" {
c.Settings["channel_id"] = slackConn.Settings.ChannelID
}
}
func (c *Connectors) loadByOrganizationIDWithPagination(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
cursor *page.Cursor[ConnectorOrderField],
filter *ConnectorProviderFilter,
) error {
q := `
SELECT
id,
organization_id,
name,
type,
provider,
protocol,
settings,
encrypted_connection,
created_at,
updated_at
@@ -67,12 +264,14 @@ WHERE
%s
AND organization_id = @organization_id
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), cursor.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, q, args)
@@ -90,110 +289,122 @@ WHERE
return nil
}
func (c *Connector) CursorKey(orderBy ConnectorOrderField) page.CursorKey {
switch orderBy {
case ConnectorOrderFieldCreatedAt:
return page.CursorKey{ID: c.ID, Value: c.CreatedAt}
case ConnectorOrderFieldName:
return page.CursorKey{ID: c.ID, Value: c.Name}
}
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
func (c *Connector) Upsert(
func (c *Connectors) loadAllByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
encryptionKey cipher.EncryptionKey,
organizationID gid.GID,
) error {
q := `
INSERT INTO
connectors (
id,
tenant_id,
organization_id,
name,
type,
encrypted_connection,
created_at,
updated_at
)
VALUES (
@id,
@tenant_id,
@organization_id,
@name,
@type,
@encrypted_connection,
@created_at,
@updated_at
)
ON CONFLICT (organization_id, name) DO UPDATE SET
tenant_id = @tenant_id,
organization_id = @organization_id,
type = @type,
encrypted_connection = @encrypted_connection,
updated_at = @updated_at
RETURNING
SELECT
id,
organization_id,
name,
type,
provider,
protocol,
settings,
encrypted_connection,
created_at,
updated_at
FROM
connectors
WHERE
%s
AND organization_id = @organization_id
ORDER BY
created_at ASC
`
if c.Connection == nil {
return fmt.Errorf("connection is nil")
}
q = fmt.Sprintf(q, scope.SQLFragment())
connection, err := json.Marshal(c.Connection)
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot marshal connection: %w", err)
return fmt.Errorf("cannot query connectors: %w", err)
}
encryptedConnection, err := cipher.Encrypt(connection, encryptionKey)
if err != nil {
return fmt.Errorf("cannot encrypt connection: %w", err)
}
rows, err := conn.Query(
ctx,
q,
pgx.StrictNamedArgs{
"id": c.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": c.OrganizationID,
"name": c.Name,
"type": c.Type,
"encrypted_connection": encryptedConnection,
"created_at": c.CreatedAt,
"updated_at": c.UpdatedAt,
},
)
if err != nil {
return err
}
defer rows.Close()
cnnctr, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Connector])
connectors, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Connector])
if err != nil {
return fmt.Errorf("cannot collect connectors: %w", err)
}
decryptedConnection, err := cipher.Decrypt(cnnctr.EncryptedConnection, encryptionKey)
if err != nil {
return fmt.Errorf("cannot decrypt connection: %w", err)
}
cnnctr.Connection, err = connector.UnmarshalConnection(cnnctr.Type, decryptedConnection)
if err != nil {
return fmt.Errorf("cannot unmarshal connection: %w", err)
}
*c = cnnctr
*c = connectors
return nil
}
func (c *Connectors) loadAllByOrganizationIDProtocolAndProvider(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
protocol ConnectorProtocol,
provider ConnectorProvider,
) error {
q := `
SELECT
id,
organization_id,
provider,
protocol,
settings,
encrypted_connection,
created_at,
updated_at
FROM
connectors
WHERE
%s
AND organization_id = @organization_id
AND protocol = @protocol
AND provider = @provider
ORDER BY
created_at ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": organizationID,
"protocol": protocol,
"provider": provider,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query connectors: %w", err)
}
connectors, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Connector])
if err != nil {
return fmt.Errorf("cannot collect connectors: %w", err)
}
*c = connectors
return nil
}
func (c *Connectors) decryptConnections(encryptionKey cipher.EncryptionKey) error {
for _, cnnctr := range *c {
if len(cnnctr.EncryptedConnection) == 0 {
continue
}
decryptedConnection, err := cipher.Decrypt(cnnctr.EncryptedConnection, encryptionKey)
if err != nil {
return fmt.Errorf("cannot decrypt connection for %s: %w", cnnctr.Provider, err)
}
cnnctr.Connection, err = connector.UnmarshalConnection(cnnctr.Protocol.String(), cnnctr.Provider.String(), decryptedConnection)
if err != nil {
return fmt.Errorf("cannot unmarshal connection for %s: %w", cnnctr.Provider, err)
}
cnnctr.populateSlackSettings()
}
return nil
}

View File

@@ -20,7 +20,7 @@ type (
const (
ConnectorOrderFieldCreatedAt ConnectorOrderField = "CREATED_AT"
ConnectorOrderFieldName ConnectorOrderField = "NAME"
ConnectorOrderFieldProvider ConnectorOrderField = "PROVIDER"
)
func (p ConnectorOrderField) Column() string {

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"database/sql/driver"
"fmt"
)
type ConnectorProtocol string
const (
ConnectorProtocolOAuth2 ConnectorProtocol = "OAUTH2"
)
func (cp ConnectorProtocol) String() string {
return string(cp)
}
func (cp *ConnectorProtocol) Scan(value any) error {
var s string
switch v := value.(type) {
case string:
s = v
case []byte:
s = string(v)
default:
return fmt.Errorf("unsupported type for ConnectorProtocol: %T", value)
}
switch s {
case "OAUTH2":
*cp = ConnectorProtocolOAuth2
default:
return fmt.Errorf("invalid ConnectorProtocol value: %q", s)
}
return nil
}
func (cp ConnectorProtocol) Value() (driver.Value, error) {
return cp.String(), nil
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"database/sql/driver"
"fmt"
)
type ConnectorProvider string
const (
ConnectorProviderSlack ConnectorProvider = "SLACK"
)
func (cp ConnectorProvider) String() string {
return string(cp)
}
func (cp *ConnectorProvider) Scan(value any) error {
var s string
switch v := value.(type) {
case string:
s = v
case []byte:
s = string(v)
default:
return fmt.Errorf("unsupported type for ConnectorProvider: %T", value)
}
switch s {
case "SLACK":
*cp = ConnectorProviderSlack
default:
return fmt.Errorf("invalid ConnectorProvider value: %q", s)
}
return nil
}
func (cp ConnectorProvider) Value() (driver.Value, error) {
return cp.String(), nil
}

View File

@@ -0,0 +1,54 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"github.com/jackc/pgx/v5"
)
type (
ConnectorProviderFilter struct {
provider *ConnectorProvider
}
)
func NewConnectorProviderFilter(provider *ConnectorProvider) *ConnectorProviderFilter {
return &ConnectorProviderFilter{
provider: provider,
}
}
func (f *ConnectorProviderFilter) SQLArguments() pgx.NamedArgs {
args := pgx.NamedArgs{}
if f.provider != nil {
args["provider"] = *f.provider
}
return args
}
func (f *ConnectorProviderFilter) SQLFragment() string {
return `
(
CASE
WHEN @provider::connector_provider IS NULL THEN
TRUE
ELSE
provider = @provider::connector_provider
END
)
`
}

View File

@@ -61,4 +61,5 @@ const (
CustomDomainEntityType
InvitationEntityType
MembershipEntityType
SlackMessageEntityType
)

View File

@@ -0,0 +1,25 @@
CREATE TYPE connector_protocol AS ENUM ('OAUTH2');
CREATE TYPE connector_provider AS ENUM ('SLACK');
ALTER TABLE connectors DROP COLUMN type;
ALTER TABLE connectors DROP COLUMN name;
ALTER TABLE connectors ADD COLUMN protocol connector_protocol NOT NULL;
ALTER TABLE connectors ADD COLUMN provider connector_provider NOT NULL;
ALTER TABLE connectors ADD COLUMN settings JSONB;
DROP INDEX IF EXISTS idx_connectors_organization_id_name;
CREATE TABLE slack_messages (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
sent_at TIMESTAMP WITH TIME ZONE,
error TEXT,
CONSTRAINT fk_slack_messages_organization_id FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE
);
CREATE INDEX ON slack_messages (sent_at) WHERE sent_at IS NULL AND error IS NULL;

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"errors"
"fmt"
"time"
"github.com/getprobo/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
)
type (
SlackMessage struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Body string `db:"body"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
SentAt *time.Time `db:"sent_at"`
Error *string `db:"error"`
}
ErrNoUnsentSlackMessage struct{}
)
func (e ErrNoUnsentSlackMessage) Error() string {
return "no unsent slack message found"
}
func NewSlackMessage(
scope Scoper,
organizationID gid.GID,
body string,
) *SlackMessage {
now := time.Now()
return &SlackMessage{
ID: gid.New(scope.GetTenantID(), SlackMessageEntityType),
OrganizationID: organizationID,
Body: body,
CreatedAt: now,
UpdatedAt: now,
}
}
func (s *SlackMessage) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO slack_messages (id, tenant_id, organization_id, body, created_at, updated_at)
VALUES (@id, @tenant_id, @organization_id, @body, @created_at, @updated_at)
`
args := pgx.StrictNamedArgs{
"id": s.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": s.OrganizationID,
"body": s.Body,
"created_at": s.CreatedAt,
"updated_at": s.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert slack message: %w", err)
}
return nil
}
func (s *SlackMessage) LoadNextUnsentForUpdate(
ctx context.Context,
conn pg.Conn,
) error {
q := `
SELECT id, organization_id, body, created_at, updated_at, sent_at, error
FROM slack_messages
WHERE sent_at IS NULL AND error IS NULL
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE
`
rows, err := conn.Query(ctx, q)
if err != nil {
return fmt.Errorf("cannot query slack messages: %w", err)
}
message, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[SlackMessage])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNoUnsentSlackMessage{}
}
return fmt.Errorf("cannot collect slack message: %w", err)
}
*s = message
return nil
}
func (s *SlackMessage) Update(
ctx context.Context,
conn pg.Conn,
) error {
q := `
UPDATE slack_messages
SET sent_at = @sent_at, updated_at = @updated_at, error = @error
WHERE id = @id
`
args := pgx.StrictNamedArgs{
"id": s.ID,
"sent_at": s.SentAt,
"updated_at": s.UpdatedAt,
"error": s.Error,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update slack message: %w", err)
}
return nil
}

View File

@@ -15,8 +15,10 @@
package probo
import (
"bytes"
"context"
"fmt"
"text/template"
"time"
"github.com/getprobo/probo/pkg/connector"
@@ -26,15 +28,19 @@ import (
"go.gearno.de/kit/pg"
)
var (
welcomeTemplate = template.Must(template.ParseFS(Templates, "templates/welcome.txt.tmpl"))
)
type (
ConnectorService struct {
svc *TenantService
}
CreateOrUpdateConnectorRequest struct {
CreateConnectorRequest struct {
OrganizationID gid.GID
Name string
Type connector.ProtocolType
Provider coredata.ConnectorProvider
Protocol coredata.ConnectorProtocol
Connection connector.Connection
}
)
@@ -43,19 +49,20 @@ func (s *ConnectorService) ListForOrganizationID(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.ConnectorOrderField],
filter *coredata.ConnectorProviderFilter,
) (*page.Page[*coredata.Connector, coredata.ConnectorOrderField], error) {
var connectors coredata.Connectors
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return connectors.LoadWithoutDecryptedConnectionByOrganizationID(
return connectors.LoadByOrganizationIDWithoutDecryptedConnection(
ctx,
conn,
s.svc.scope,
organizationID,
cursor,
s.svc.encryptionKey,
filter,
)
},
)
@@ -67,34 +74,34 @@ func (s *ConnectorService) ListForOrganizationID(
return page.NewPage(connectors, cursor), nil
}
func (s *ConnectorService) CreateOrUpdate(
func (s *ConnectorService) Create(
ctx context.Context,
req CreateOrUpdateConnectorRequest,
req CreateConnectorRequest,
) (*coredata.Connector, error) {
if req.OrganizationID == gid.Nil {
return nil, fmt.Errorf("organization ID is required")
}
if req.Name == "" {
return nil, fmt.Errorf("connector name is required")
if req.Provider == "" {
return nil, fmt.Errorf("connector provider is required")
}
if req.Type == "" {
return nil, fmt.Errorf("connector type is required")
if req.Protocol == "" {
return nil, fmt.Errorf("connector protocol is required")
}
if req.Connection == nil {
return nil, fmt.Errorf("connection configuration is required")
}
connectorID := gid.New(s.svc.scope.GetTenantID(), coredata.ConnectorEntityType)
id := gid.New(s.svc.scope.GetTenantID(), coredata.ConnectorEntityType)
now := time.Now()
connector := &coredata.Connector{
ID: connectorID,
newConnector := &coredata.Connector{
ID: id,
OrganizationID: req.OrganizationID,
Name: req.Name,
Type: req.Type,
Provider: req.Provider,
Protocol: req.Protocol,
Connection: req.Connection,
CreatedAt: now,
UpdatedAt: now,
@@ -103,8 +110,36 @@ func (s *ConnectorService) CreateOrUpdate(
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := connector.Upsert(ctx, conn, s.svc.scope, s.svc.encryptionKey); err != nil {
return fmt.Errorf("cannot upsert connector: %w", err)
if err := newConnector.Insert(ctx, conn, s.svc.scope, s.svc.encryptionKey); err != nil {
return fmt.Errorf("cannot create connector: %w", err)
}
if req.Provider == coredata.ConnectorProviderSlack {
slackConn, ok := req.Connection.(*connector.SlackConnection)
if ok && slackConn.Settings.Channel != "" {
var organization coredata.Organization
if err := organization.LoadByID(ctx, conn, s.svc.scope, req.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
data := struct {
OrganizationName string
ChannelName string
}{
OrganizationName: organization.Name,
ChannelName: slackConn.Settings.Channel,
}
var buf bytes.Buffer
if err := welcomeTemplate.Execute(&buf, data); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
slackMessage := coredata.NewSlackMessage(s.svc.scope, req.OrganizationID, buf.String())
if err := slackMessage.Insert(ctx, conn, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert slack message: %w", err)
}
}
}
return nil
@@ -115,5 +150,5 @@ func (s *ConnectorService) CreateOrUpdate(
return nil, err
}
return connector, nil
return newConnector, nil
}

24
pkg/probo/templates.go Normal file
View File

@@ -0,0 +1,24 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package probo
import (
"embed"
)
var (
//go:embed templates/*.tmpl
Templates embed.FS
)

View File

@@ -0,0 +1,8 @@
*Welcome to Probo app!*
This channel is now connected to your Probo platform. You'll receive notifications here for:
• New trust center access requests
*Organization:* {{.OrganizationName}}
*Channel:* {{.ChannelName}}

View File

@@ -17,15 +17,16 @@ package probod
import (
"encoding/json"
"fmt"
"strings"
"github.com/getprobo/probo/pkg/connector"
)
type (
connectorConfig struct {
Name string `json:"name"`
Type connector.ProtocolType `json:"type"`
Config connector.Connector `json:"-"`
Provider string `json:"provider"`
Protocol connector.ProtocolType `json:"protocol"`
Config connector.Connector `json:"-"`
}
connectorConfigOAuth2 struct {
@@ -40,19 +41,19 @@ type (
func (c *connectorConfig) UnmarshalJSON(data []byte) error {
var tmp struct {
Name string `json:"name"`
Type connector.ProtocolType `json:"type"`
RawConfig json.RawMessage `json:"config"`
Provider string `json:"provider"`
Protocol string `json:"protocol"`
RawConfig json.RawMessage `json:"config"`
}
if err := json.Unmarshal(data, &tmp); err != nil {
return fmt.Errorf("cannot unmarshal connector config: %w", err)
}
c.Name = tmp.Name
c.Type = tmp.Type
c.Provider = strings.ToUpper(tmp.Provider)
c.Protocol = connector.ProtocolType(strings.ToUpper(tmp.Protocol))
switch tmp.Type {
switch c.Protocol {
case connector.ProtocolOAuth2:
var config connectorConfigOAuth2
if err := json.Unmarshal(tmp.RawConfig, &config); err != nil {
@@ -70,7 +71,7 @@ func (c *connectorConfig) UnmarshalJSON(data []byte) error {
c.Config = &oauth2Connector
default:
return fmt.Errorf("unknown connector type: %q", tmp.Type)
return fmt.Errorf("unknown connector protocol: %q", c.Protocol)
}
return nil

View File

@@ -45,6 +45,7 @@ import (
"github.com/getprobo/probo/pkg/saferedirect"
"github.com/getprobo/probo/pkg/server"
"github.com/getprobo/probo/pkg/server/api"
"github.com/getprobo/probo/pkg/slack"
"github.com/getprobo/probo/pkg/trust"
"github.com/prometheus/client_golang/prometheus"
"go.gearno.de/kit/httpclient"
@@ -72,6 +73,7 @@ type (
TrustCenter trustCenterConfig `json:"trust-center"`
AWS awsConfig `json:"aws"`
Mailer mailerConfig `json:"mailer"`
Slack slackConfig `json:"slack"`
Connectors []connectorConfig `json:"connectors"`
OpenAI openaiConfig `json:"openai"`
ChromeDPAddr string `json:"chrome-dp-addr"`
@@ -143,6 +145,9 @@ func New() *Implm {
Addr: "localhost:1025",
},
},
Slack: slackConfig{
SenderInterval: 60,
},
CustomDomains: customDomainsConfig{
RenewalInterval: 3600,
ProvisionInterval: 30,
@@ -204,6 +209,7 @@ func (impl *Implm) Run(
return fmt.Errorf("cannot get trust auth token secret bytes: %w", err)
}
awsConfig := awsconfig.NewConfig(
l,
httpclient.DefaultPooledClient(
@@ -239,7 +245,7 @@ func (impl *Implm) Run(
defaultConnectorRegistry := connector.NewConnectorRegistry()
for _, connector := range impl.cfg.Connectors {
if err := defaultConnectorRegistry.Register(connector.Name, connector.Config); err != nil {
if err := defaultConnectorRegistry.Register(connector.Provider, connector.Config); err != nil {
return fmt.Errorf("cannot register connector: %w", err)
}
}
@@ -338,6 +344,7 @@ func (impl *Implm) Run(
pgClient,
s3Client,
impl.cfg.AWS.Bucket,
impl.cfg.Hostname,
impl.cfg.EncryptionKey,
impl.cfg.TrustAuth.TokenSecret,
authService,
@@ -408,6 +415,18 @@ func (impl *Implm) Run(
},
)
slackSenderCtx, stopSlackSender := context.WithCancel(context.Background())
slackSender := slack.NewSender(pgClient, l.Named("slack-sender"), impl.cfg.EncryptionKey, slack.Config{
Interval: time.Duration(impl.cfg.Slack.SenderInterval) * time.Second,
})
wg.Go(
func() {
if err := slackSender.Run(slackSenderCtx); err != nil {
cancel(fmt.Errorf("slack sender crashed: %w", err))
}
},
)
exportJobExporterCtx, stopExportJobExporter := context.WithCancel(context.Background())
wg.Go(
func() {
@@ -430,6 +449,7 @@ func (impl *Implm) Run(
<-ctx.Done()
stopMailer()
stopSlackSender()
stopExportJobExporter()
stopApiServer()
stopTrustCenterServer()

View File

@@ -0,0 +1,21 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package probod
type (
slackConfig struct {
SenderInterval int `json:"sender-interval"`
}
)

View File

@@ -44,6 +44,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
)
@@ -218,7 +219,12 @@ func NewMux(
r.Post("/auth/reset-password", ResetPasswordHandler(authSvc, authCfg))
r.Get("/connectors/initiate", WithSession(authSvc, authzSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
connectorID := r.URL.Query().Get("connector_id")
provider := r.URL.Query().Get("provider")
if provider != "SLACK" {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("unsupported provider"))
return
}
organizationID, err := gid.ParseGID(r.URL.Query().Get("organization_id"))
if err != nil {
panic(fmt.Errorf("failed to parse organization id: %w", err))
@@ -226,34 +232,53 @@ func NewMux(
_ = GetTenantService(r.Context(), proboSvc, organizationID.TenantID())
redirectURL, err := connectorRegistry.Initiate(r.Context(), connectorID, organizationID, r)
redirectURL, err := connectorRegistry.Initiate(r.Context(), provider, organizationID, r)
if err != nil {
panic(fmt.Errorf("cannot initiate connector: %w", err))
}
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
// Allow external redirects for Slack OAuth only for now
slackSafeRedirect := &saferedirect.SafeRedirect{AllowedHost: "slack.com"}
slackSafeRedirect.Redirect(w, r, redirectURL, "/", http.StatusSeeOther)
}))
r.Get("/connectors/complete", WithSession(authSvc, authzSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
connectorID := r.URL.Query().Get("connector_id")
organizationID, err := gid.ParseGID(r.URL.Query().Get("organization_id"))
if err != nil {
panic(fmt.Errorf("failed to parse organization id: %w", err))
r.Get("/connectors/complete", func(w http.ResponseWriter, r *http.Request) {
provider := r.URL.Query().Get("provider")
if provider == "" {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("missing provider parameter"))
return
}
connection, err := connectorRegistry.Complete(r.Context(), connectorID, organizationID, r)
var connectorProvider coredata.ConnectorProvider
switch provider {
case "SLACK":
connectorProvider = coredata.ConnectorProviderSlack
default:
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("unsupported provider"))
return
}
stateToken := r.URL.Query().Get("state")
if stateToken == "" {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("missing state parameter"))
return
}
connection, organizationID, err := connectorRegistry.Complete(r.Context(), provider, r)
if err != nil {
panic(fmt.Errorf("failed to complete connector: %w", err))
}
svc := GetTenantService(r.Context(), proboSvc, organizationID.TenantID())
continueURL := r.URL.Query().Get("continue")
_, err = svc.Connectors.CreateOrUpdate(
svc := proboSvc.WithTenant(organizationID.TenantID())
_, err = svc.Connectors.Create(
r.Context(),
probo.CreateOrUpdateConnectorRequest{
OrganizationID: organizationID,
Name: connectorID,
Type: connector.ProtocolType(connection.Type()),
probo.CreateConnectorRequest{
OrganizationID: *organizationID,
Provider: connectorProvider,
Protocol: coredata.ConnectorProtocol(connection.Type()),
Connection: connection,
},
)
@@ -261,8 +286,13 @@ func NewMux(
panic(fmt.Errorf("failed to create or update connector: %w", err))
}
safeRedirect.RedirectFromQuery(w, r, "continue", "/", http.StatusSeeOther)
}))
if continueURL != "" {
safeRedirect.Redirect(w, r, continueURL, "/", http.StatusSeeOther)
} else {
redirectURL := fmt.Sprintf("/organizations/%s", organizationID.String())
safeRedirect.Redirect(w, r, redirectURL, "/", http.StatusSeeOther)
}
})
r.Get("/", playground.Handler("GraphQL", "/api/console/v1/query"))
r.Post("/query", graphqlHandler(logger, proboSvc, authSvc, authzSvc, authCfg, customDomainCname))

View File

@@ -555,20 +555,6 @@ enum OrganizationOrderField
)
}
enum ConnectorOrderField
@goModel(
model: "github.com/getprobo/probo/pkg/coredata.ConnectorOrderField"
) {
CREATED_AT
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.ConnectorOrderFieldCreatedAt"
)
NAME
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.ConnectorOrderFieldName"
)
}
enum DataSensitivity
@goModel(model: "github.com/getprobo/probo/pkg/coredata.DataSensitivity") {
NONE
@@ -1473,11 +1459,6 @@ input OrganizationOrder {
field: OrganizationOrderField!
}
input ConnectorOrder {
field: ConnectorOrderField!
direction: OrderDirection!
}
input DocumentVersionOrder
@goModel(
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.DocumentVersionOrderBy"
@@ -1621,13 +1602,12 @@ type Organization implements Node {
filter: InvitationFilter
): InvitationConnection! @goField(forceResolver: true)
connectors(
slackConnections(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: ConnectorOrder
): ConnectorConnection! @goField(forceResolver: true)
): SlackConnectionConnection! @goField(forceResolver: true)
frameworks(
first: Int
@@ -1808,14 +1788,24 @@ type Invitation implements Node {
organization: Organization! @goField(forceResolver: true)
}
type Connector implements Node {
type SlackConnection {
id: ID!
name: String!
type: String!
channel: String
channelId: String
createdAt: Datetime!
updatedAt: Datetime!
}
type SlackConnectionConnection {
edges: [SlackConnectionEdge!]!
pageInfo: PageInfo!
}
type SlackConnectionEdge {
cursor: CursorKey!
node: SlackConnection!
}
type People implements Node {
id: ID!
fullName: String!
@@ -2635,15 +2625,6 @@ type VendorServiceEdge {
node: VendorService!
}
type ConnectorConnection {
edges: [ConnectorEdge!]!
pageInfo: PageInfo!
}
type ConnectorEdge {
cursor: CursorKey!
node: Connector!
}
type VendorRiskAssessmentConnection {
edges: [VendorRiskAssessmentEdge!]!

File diff suppressed because it is too large Load Diff

View File

@@ -19,36 +19,42 @@ import (
"github.com/getprobo/probo/pkg/page"
)
type (
ConnectorOrderBy OrderBy[coredata.ConnectorOrderField]
)
func NewConnectorConnection(p *page.Page[*coredata.Connector, coredata.ConnectorOrderField]) *ConnectorConnection {
var edges = make([]*ConnectorEdge, len(p.Data))
func NewSlackConnectionConnection(p *page.Page[*coredata.Connector, coredata.ConnectorOrderField]) *SlackConnectionConnection {
var edges = make([]*SlackConnectionEdge, len(p.Data))
for i := range edges {
edges[i] = NewConnectorEdge(p.Data[i], p.Cursor.OrderBy.Field)
edges[i] = NewSlackConnectionEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &ConnectorConnection{
return &SlackConnectionConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}
func NewConnectorEdge(c *coredata.Connector, orderBy coredata.ConnectorOrderField) *ConnectorEdge {
return &ConnectorEdge{
func NewSlackConnectionEdge(c *coredata.Connector, orderBy coredata.ConnectorOrderField) *SlackConnectionEdge {
return &SlackConnectionEdge{
Cursor: c.CursorKey(orderBy),
Node: NewConnector(c),
Node: NewSlackConnection(c),
}
}
func NewConnector(c *coredata.Connector) *Connector {
return &Connector{
func NewSlackConnection(c *coredata.Connector) *SlackConnection {
conn := &SlackConnection{
ID: c.ID,
Name: c.Name,
Type: string(c.Type),
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
// Extract channel information from settings
if len(c.Settings) > 0 {
if channel, ok := c.Settings["channel"].(string); ok && channel != "" {
conn.Channel = &channel
}
if channelID, ok := c.Settings["channel_id"].(string); ok && channelID != "" {
conn.ChannelID = &channelID
}
}
return conn
}

View File

@@ -146,32 +146,6 @@ type ConfirmEmailPayload struct {
Success bool `json:"success"`
}
type Connector struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Connector) IsNode() {}
func (this Connector) GetID() gid.GID { return this.ID }
type ConnectorConnection struct {
Edges []*ConnectorEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type ConnectorEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *Connector `json:"node"`
}
type ConnectorOrder struct {
Field coredata.ConnectorOrderField `json:"field"`
Direction page.OrderDirection `json:"direction"`
}
type ContinualImprovement struct {
ID gid.GID `json:"id"`
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
@@ -1374,7 +1348,7 @@ type Organization struct {
HeadquarterAddress *string `json:"headquarterAddress,omitempty"`
Memberships *MembershipConnection `json:"memberships"`
Invitations *InvitationConnection `json:"invitations"`
Connectors *ConnectorConnection `json:"connectors"`
SlackConnections *SlackConnectionConnection `json:"slackConnections"`
Frameworks *FrameworkConnection `json:"frameworks"`
Controls *ControlConnection `json:"controls"`
Vendors *VendorConnection `json:"vendors"`
@@ -1590,6 +1564,24 @@ type Session struct {
ExpiresAt time.Time `json:"expiresAt"`
}
type SlackConnection struct {
ID gid.GID `json:"id"`
Channel *string `json:"channel,omitempty"`
ChannelID *string `json:"channelId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type SlackConnectionConnection struct {
Edges []*SlackConnectionEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type SlackConnectionEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *SlackConnection `json:"node"`
}
type Snapshot struct {
ID gid.GID `json:"id"`
Organization *Organization `json:"organization"`

View File

@@ -3666,29 +3666,27 @@ func (r *organizationResolver) Invitations(ctx context.Context, obj *types.Organ
return types.NewInvitationConnection(page, r, obj.ID, filter), nil
}
// Connectors is the resolver for the connectors field.
func (r *organizationResolver) Connectors(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.ConnectorOrder) (*types.ConnectorConnection, error) {
// SlackConnections is the resolver for the slackConnections field.
func (r *organizationResolver) SlackConnections(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.SlackConnectionConnection, error) {
prb := r.ProboService(ctx, obj.ID.TenantID())
// Filter for Slack connectors only
slackProvider := coredata.ConnectorProviderSlack
filter := coredata.NewConnectorProviderFilter(&slackProvider)
pageOrderBy := page.OrderBy[coredata.ConnectorOrderField]{
Field: coredata.ConnectorOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.ConnectorOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := prb.Connectors.ListForOrganizationID(ctx, obj.ID, cursor)
page, err := prb.Connectors.ListForOrganizationID(ctx, obj.ID, cursor, filter)
if err != nil {
panic(fmt.Errorf("cannot list organization connectors: %w", err))
panic(fmt.Errorf("cannot list organization slack connections: %w", err))
}
return types.NewConnectorConnection(page), nil
return types.NewSlackConnectionConnection(page), nil
}
// Frameworks is the resolver for the frameworks field.

105
pkg/slack/client.go Normal file
View File

@@ -0,0 +1,105 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package slack
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
type (
Client struct {
webhookURL string
httpClient *http.Client
}
webhookMessage struct {
Text string `json:"text,omitempty"`
Blocks []block `json:"blocks,omitempty"`
}
block struct {
Type string `json:"type"`
Text *textItem `json:"text,omitempty"`
}
textItem struct {
Type string `json:"type"`
Text string `json:"text"`
}
)
func NewClient(webhookURL string, httpClient *http.Client) *Client {
return &Client{
webhookURL: webhookURL,
httpClient: httpClient,
}
}
func (c *Client) PostMessage(ctx context.Context, text string) error {
msg := webhookMessage{
Text: text,
Blocks: []block{
{
Type: "section",
Text: &textItem{
Type: "mrkdwn",
Text: text,
},
},
},
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(msg); err != nil {
return fmt.Errorf("cannot marshal message: %w", err)
}
body := buf.Bytes()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.webhookURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("cannot create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("cannot send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("unexpected status code: %d, failed to read response body: %w", resp.StatusCode, err)
}
var errorResponse map[string]any
var buf bytes.Buffer
buf.Write(body)
if err := json.NewDecoder(&buf).Decode(&errorResponse); err != nil {
return fmt.Errorf("unexpected status code: %d, response body: %s", resp.StatusCode, string(body))
}
return fmt.Errorf("unexpected status code: %d, response: %+v", resp.StatusCode, errorResponse)
}
return nil
}

177
pkg/slack/sender.go Normal file
View File

@@ -0,0 +1,177 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package slack
import (
"context"
"errors"
"fmt"
"net/http"
"time"
"github.com/getprobo/probo/pkg/connector"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/crypto/cipher"
"go.gearno.de/kit/httpclient"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
)
type (
Sender struct {
pg *pg.Client
logger *log.Logger
encryptionKey cipher.EncryptionKey
interval time.Duration
httpClient *http.Client
}
Config struct {
Interval time.Duration
}
)
func NewSender(pg *pg.Client, logger *log.Logger, encryptionKey cipher.EncryptionKey, cfg Config) *Sender {
httpClientOpts := []httpclient.Option{
httpclient.WithLogger(logger),
}
httpClient := httpclient.DefaultPooledClient(httpClientOpts...)
return &Sender{
pg: pg,
logger: logger,
encryptionKey: encryptionKey,
interval: cfg.Interval,
httpClient: httpClient,
}
}
func (s *Sender) Run(ctx context.Context) error {
LOOP:
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(s.interval):
ctx := context.Background()
if err := s.batchSendMessages(ctx); err != nil {
s.logger.ErrorCtx(ctx, "cannot send slack message", log.Error(err))
}
goto LOOP
}
}
func (s *Sender) batchSendMessages(ctx context.Context) error {
for {
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) (err error) {
message := &coredata.SlackMessage{}
defer func() {
if r := recover(); r != nil {
panicErr := fmt.Sprintf("panic recovered: %v", r)
message.Error = &panicErr
message.UpdatedAt = time.Now()
if updateErr := message.Update(ctx, tx); updateErr != nil {
s.logger.ErrorCtx(ctx, "cannot update slack message after panic", log.Error(updateErr))
}
s.logger.ErrorCtx(ctx, "panic while sending slack message", log.String("error", panicErr), log.String("message_id", message.ID.String()))
err = fmt.Errorf("panic recovered: %v", r)
}
}()
err = message.LoadNextUnsentForUpdate(ctx, tx)
if err != nil {
return err
}
if sendErr := s.sendMessage(ctx, tx, message); sendErr != nil {
errorMsg := sendErr.Error()
message.Error = &errorMsg
message.UpdatedAt = time.Now()
if err := message.Update(ctx, tx); err != nil {
return fmt.Errorf("cannot update slack message with error: %w", err)
}
s.logger.ErrorCtx(ctx, "error sending slack message", log.Error(sendErr), log.String("message_id", message.ID.String()))
return nil
}
now := time.Now()
message.SentAt = &now
message.UpdatedAt = now
if err := message.Update(ctx, tx); err != nil {
return fmt.Errorf("cannot update slack message: %w", err)
}
return nil
},
)
if errors.Is(err, coredata.ErrNoUnsentSlackMessage{}) {
return nil
}
if err != nil {
return err
}
}
}
func (s *Sender) sendMessage(ctx context.Context, tx pg.Conn, message *coredata.SlackMessage) error {
tenantID := message.ID.TenantID()
scope := coredata.NewScope(tenantID)
var connectors coredata.Connectors
if err := connectors.LoadAllByOrganizationIDProtocolAndProvider(
ctx,
tx,
scope,
message.OrganizationID,
coredata.ConnectorProtocolOAuth2,
coredata.ConnectorProviderSlack,
s.encryptionKey,
); err != nil {
return fmt.Errorf("cannot load slack connectors: %w", err)
}
if len(connectors) == 0 {
return fmt.Errorf("no slack connectors configured for organization")
}
for _, c := range connectors {
slackConn, ok := c.Connection.(*connector.SlackConnection)
if !ok {
return fmt.Errorf("slack connector must have SlackConnection type")
}
if slackConn.Settings.WebhookURL == "" {
return fmt.Errorf("slack connector %s has no webhook URL", c.ID)
}
client := NewClient(slackConn.Settings.WebhookURL, s.httpClient)
if err := client.PostMessage(ctx, message.Body); err != nil {
return fmt.Errorf("failed to post message to Slack: %w", err)
}
}
return nil
}

View File

@@ -34,6 +34,7 @@ type (
proboSvc *probo.Service
encryptionKey cipher.EncryptionKey
tokenSecret string
hostname string
auth *auth.Service
html2pdfConverter *html2pdf.Converter
fileManager *filemanager.Service
@@ -47,6 +48,7 @@ type (
proboSvc *probo.Service
encryptionKey cipher.EncryptionKey
tokenSecret string
hostname string
auth *auth.Service
html2pdfConverter *html2pdf.Converter
fileManager *filemanager.Service
@@ -66,6 +68,7 @@ func NewService(
pgClient *pg.Client,
s3Client *s3.Client,
bucket string,
hostname string,
encryptionKey cipher.EncryptionKey,
tokenSecret string,
auth *auth.Service,
@@ -78,6 +81,7 @@ func NewService(
bucket: bucket,
encryptionKey: encryptionKey,
tokenSecret: tokenSecret,
hostname: hostname,
auth: auth,
html2pdfConverter: html2pdfConverter,
fileManager: fileManagerService,
@@ -93,6 +97,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
proboSvc: s.proboSvc,
encryptionKey: s.encryptionKey,
tokenSecret: s.tokenSecret,
hostname: s.hostname,
auth: s.auth,
html2pdfConverter: s.html2pdfConverter,
fileManager: s.fileManager,

24
pkg/trust/templates.go Normal file
View File

@@ -0,0 +1,24 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package trust
import (
"embed"
)
var (
//go:embed templates/*.tmpl
Templates embed.FS
)

View File

@@ -0,0 +1,7 @@
*New Trust Center Access Request*
*Organization:* {{.OrganizationName}}
*Requested by:* {{.RequesterName}}
*Email:* {{.RequesterEmail}}
<{{.ConsoleUrl}}|View Access Requests>

View File

@@ -15,22 +15,28 @@
package trust
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/mail"
"text/template"
"time"
"github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/auth"
"go.gearno.de/kit/pg"
)
var (
accessRequestTemplate = template.Must(template.ParseFS(Templates, "templates/access-request.txt.tmpl"))
)
type (
TrustCenterAccessService struct {
svc *TenantService
svc *TenantService
auth *auth.Service
}
@@ -45,6 +51,7 @@ type (
const (
TokenTypeTrustCenterAccess = "trust_center_access"
TrustCenterAccessURLFormat = "https://%s/organizations/%s/trust-center/access"
)
func (s TrustCenterAccessService) ValidateToken(
@@ -74,9 +81,9 @@ func (s TrustCenterAccessService) Request(
now := time.Now()
var access *coredata.TrustCenterAccess
var trustCenter *coredata.TrustCenter
err := s.svc.pg.WithTx(ctx, func(tx pg.Conn) error {
var trustCenter *coredata.TrustCenter
var organizationID gid.GID
trustCenter = &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, tx, s.svc.scope, req.TrustCenterID); err != nil {
@@ -168,6 +175,10 @@ func (s TrustCenterAccessService) Request(
return fmt.Errorf("cannot bulk insert trust center report accesses: %w", err)
}
if err := s.queueSlackNotification(ctx, tx, organizationID, access.Name, access.Email); err != nil {
return fmt.Errorf("cannot queue slack notification: %w", err)
}
return nil
})
@@ -330,3 +341,42 @@ func filterExistingIDs(allIDs []gid.GID, existingIDs []gid.GID) []gid.GID {
return newIDs
}
func (s TrustCenterAccessService) queueSlackNotification(
ctx context.Context,
tx pg.Conn,
organizationID gid.GID,
requesterName string,
requesterEmail string,
) error {
var organization coredata.Organization
if err := organization.LoadByID(ctx, tx, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
consoleURL := fmt.Sprintf(TrustCenterAccessURLFormat, s.svc.hostname, organizationID)
data := struct {
OrganizationName string
RequesterName string
RequesterEmail string
ConsoleUrl string
}{
OrganizationName: organization.Name,
RequesterName: requesterName,
RequesterEmail: requesterEmail,
ConsoleUrl: consoleURL,
}
var buf bytes.Buffer
if err := accessRequestTemplate.Execute(&buf, data); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
slackMessage := coredata.NewSlackMessage(s.svc.scope, organizationID, buf.String())
if err := slackMessage.Insert(ctx, tx, s.svc.scope); err != nil {
return fmt.Errorf("cannot insert slack message: %w", err)
}
return nil
}