Add slack integration
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -57,6 +57,17 @@ export const trustCenterQuery = graphql`
|
||||
}
|
||||
}
|
||||
}
|
||||
slackConnections(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
channel
|
||||
channelId
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
15
cfg/dev.yaml
15
cfg/dev.yaml
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
134
pkg/connector/slack.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type (
|
||||
|
||||
const (
|
||||
ConnectorOrderFieldCreatedAt ConnectorOrderField = "CREATED_AT"
|
||||
ConnectorOrderFieldName ConnectorOrderField = "NAME"
|
||||
ConnectorOrderFieldProvider ConnectorOrderField = "PROVIDER"
|
||||
)
|
||||
|
||||
func (p ConnectorOrderField) Column() string {
|
||||
|
||||
54
pkg/coredata/connector_protocol.go
Normal file
54
pkg/coredata/connector_protocol.go
Normal 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
|
||||
}
|
||||
54
pkg/coredata/connector_provider.go
Normal file
54
pkg/coredata/connector_provider.go
Normal 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
|
||||
}
|
||||
54
pkg/coredata/connector_provider_filter.go
Normal file
54
pkg/coredata/connector_provider_filter.go
Normal 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
|
||||
)
|
||||
`
|
||||
}
|
||||
@@ -61,4 +61,5 @@ const (
|
||||
CustomDomainEntityType
|
||||
InvitationEntityType
|
||||
MembershipEntityType
|
||||
SlackMessageEntityType
|
||||
)
|
||||
|
||||
25
pkg/coredata/migrations/20251016T174950Z.sql
Normal file
25
pkg/coredata/migrations/20251016T174950Z.sql
Normal 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;
|
||||
143
pkg/coredata/slack_message.go
Normal file
143
pkg/coredata/slack_message.go
Normal 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
|
||||
}
|
||||
@@ -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
24
pkg/probo/templates.go
Normal 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
|
||||
)
|
||||
8
pkg/probo/templates/welcome.txt.tmpl
Normal file
8
pkg/probo/templates/welcome.txt.tmpl
Normal 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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
21
pkg/probod/slack_config.go
Normal file
21
pkg/probod/slack_config.go
Normal 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"`
|
||||
}
|
||||
)
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
105
pkg/slack/client.go
Normal 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
177
pkg/slack/sender.go
Normal 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
|
||||
}
|
||||
@@ -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
24
pkg/trust/templates.go
Normal 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
|
||||
)
|
||||
7
pkg/trust/templates/access-request.txt.tmpl
Normal file
7
pkg/trust/templates/access-request.txt.tmpl
Normal file
@@ -0,0 +1,7 @@
|
||||
*New Trust Center Access Request*
|
||||
|
||||
*Organization:* {{.OrganizationName}}
|
||||
*Requested by:* {{.RequesterName}}
|
||||
*Email:* {{.RequesterEmail}}
|
||||
|
||||
<{{.ConsoleUrl}}|View Access Requests>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user