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
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -72,29 +72,22 @@ v6 = {
|
|||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "type",
|
"name": "createdAt",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v7 = {
|
v7 = {
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "createdAt",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
v8 = {
|
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "updatedAt",
|
"name": "updatedAt",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v9 = {
|
v8 = {
|
||||||
"kind": "Literal",
|
"kind": "Literal",
|
||||||
"name": "first",
|
"name": "first",
|
||||||
"value": 20
|
"value": 20
|
||||||
},
|
},
|
||||||
v10 = {
|
v9 = {
|
||||||
"kind": "Literal",
|
"kind": "Literal",
|
||||||
"name": "orderBy",
|
"name": "orderBy",
|
||||||
"value": {
|
"value": {
|
||||||
@@ -102,39 +95,39 @@ v10 = {
|
|||||||
"field": "CREATED_AT"
|
"field": "CREATED_AT"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
v11 = [
|
v10 = [
|
||||||
(v9/*: any*/),
|
(v8/*: any*/),
|
||||||
(v10/*: any*/)
|
(v9/*: any*/)
|
||||||
],
|
],
|
||||||
v12 = {
|
v11 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "totalCount",
|
"name": "totalCount",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v13 = {
|
v12 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "fullName",
|
"name": "fullName",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v14 = {
|
v13 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "role",
|
"name": "role",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v15 = {
|
v14 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "cursor",
|
"name": "cursor",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v16 = {
|
v15 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"concreteType": "PageInfo",
|
"concreteType": "PageInfo",
|
||||||
@@ -173,7 +166,7 @@ v16 = {
|
|||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v17 = {
|
v16 = {
|
||||||
"kind": "ClientExtension",
|
"kind": "ClientExtension",
|
||||||
"selections": [
|
"selections": [
|
||||||
{
|
{
|
||||||
@@ -185,7 +178,7 @@ v17 = {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
v18 = [
|
v17 = [
|
||||||
{
|
{
|
||||||
"kind": "Literal",
|
"kind": "Literal",
|
||||||
"name": "filter",
|
"name": "filter",
|
||||||
@@ -196,8 +189,8 @@ v18 = [
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(v9/*: any*/),
|
(v8/*: any*/),
|
||||||
(v10/*: any*/)
|
(v9/*: any*/)
|
||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
"fragment": {
|
"fragment": {
|
||||||
@@ -332,7 +325,13 @@ return {
|
|||||||
"name": "dnsRecords",
|
"name": "dnsRecords",
|
||||||
"plural": true,
|
"plural": true,
|
||||||
"selections": [
|
"selections": [
|
||||||
(v6/*: any*/),
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "type",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
(v3/*: any*/),
|
(v3/*: any*/),
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
@@ -358,8 +357,8 @@ return {
|
|||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
|
(v6/*: any*/),
|
||||||
(v7/*: any*/),
|
(v7/*: any*/),
|
||||||
(v8/*: any*/),
|
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
@@ -370,60 +369,17 @@ return {
|
|||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
|
(v6/*: any*/),
|
||||||
(v7/*: any*/),
|
(v7/*: any*/),
|
||||||
(v8/*: any*/),
|
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": [
|
"args": (v10/*: any*/),
|
||||||
{
|
|
||||||
"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*/),
|
|
||||||
"concreteType": "MembershipConnection",
|
"concreteType": "MembershipConnection",
|
||||||
"kind": "LinkedField",
|
"kind": "LinkedField",
|
||||||
"name": "memberships",
|
"name": "memberships",
|
||||||
"plural": false,
|
"plural": false,
|
||||||
"selections": [
|
"selections": [
|
||||||
(v12/*: any*/),
|
(v11/*: any*/),
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
@@ -441,7 +397,7 @@ return {
|
|||||||
"plural": false,
|
"plural": false,
|
||||||
"selections": [
|
"selections": [
|
||||||
(v2/*: any*/),
|
(v2/*: any*/),
|
||||||
(v13/*: any*/),
|
(v12/*: any*/),
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
@@ -449,24 +405,24 @@ return {
|
|||||||
"name": "emailAddress",
|
"name": "emailAddress",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
(v14/*: any*/),
|
(v13/*: any*/),
|
||||||
(v7/*: any*/),
|
(v6/*: any*/),
|
||||||
(v4/*: any*/)
|
(v4/*: any*/)
|
||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
(v15/*: any*/)
|
(v14/*: any*/)
|
||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
(v16/*: any*/),
|
(v15/*: any*/),
|
||||||
(v17/*: any*/)
|
(v16/*: any*/)
|
||||||
],
|
],
|
||||||
"storageKey": "memberships(first:20,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
|
"storageKey": "memberships(first:20,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": (v11/*: any*/),
|
"args": (v10/*: any*/),
|
||||||
"filters": [
|
"filters": [
|
||||||
"orderBy"
|
"orderBy"
|
||||||
],
|
],
|
||||||
@@ -477,13 +433,13 @@ return {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": (v18/*: any*/),
|
"args": (v17/*: any*/),
|
||||||
"concreteType": "InvitationConnection",
|
"concreteType": "InvitationConnection",
|
||||||
"kind": "LinkedField",
|
"kind": "LinkedField",
|
||||||
"name": "invitations",
|
"name": "invitations",
|
||||||
"plural": false,
|
"plural": false,
|
||||||
"selections": [
|
"selections": [
|
||||||
(v12/*: any*/),
|
(v11/*: any*/),
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
@@ -502,8 +458,8 @@ return {
|
|||||||
"selections": [
|
"selections": [
|
||||||
(v2/*: any*/),
|
(v2/*: any*/),
|
||||||
(v5/*: any*/),
|
(v5/*: any*/),
|
||||||
|
(v12/*: any*/),
|
||||||
(v13/*: any*/),
|
(v13/*: any*/),
|
||||||
(v14/*: any*/),
|
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
@@ -525,23 +481,23 @@ return {
|
|||||||
"name": "acceptedAt",
|
"name": "acceptedAt",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
(v7/*: any*/),
|
(v6/*: any*/),
|
||||||
(v4/*: any*/)
|
(v4/*: any*/)
|
||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
(v15/*: any*/)
|
(v14/*: any*/)
|
||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
(v16/*: any*/),
|
(v15/*: any*/),
|
||||||
(v17/*: any*/)
|
(v16/*: any*/)
|
||||||
],
|
],
|
||||||
"storageKey": "invitations(filter:{\"statuses\":[\"PENDING\",\"EXPIRED\"]},first:20,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
|
"storageKey": "invitations(filter:{\"statuses\":[\"PENDING\",\"EXPIRED\"]},first:20,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": (v18/*: any*/),
|
"args": (v17/*: any*/),
|
||||||
"filters": [
|
"filters": [
|
||||||
"orderBy",
|
"orderBy",
|
||||||
"filter"
|
"filter"
|
||||||
@@ -561,12 +517,12 @@ return {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"cacheID": "daa371367edee014ac05cb9712ce360d",
|
"cacheID": "3d38000d9d2105ef8ae3d56edf31b372",
|
||||||
"id": null,
|
"id": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"name": "OrganizationGraph_ViewQuery",
|
"name": "OrganizationGraph_ViewQuery",
|
||||||
"operationKind": "query",
|
"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
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -37,6 +37,17 @@ export type TrustCenterGraphQuery$data = {
|
|||||||
};
|
};
|
||||||
readonly id?: string;
|
readonly id?: string;
|
||||||
readonly name?: 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 trustCenter?: {
|
||||||
readonly active: boolean;
|
readonly active: boolean;
|
||||||
readonly createdAt: any;
|
readonly createdAt: any;
|
||||||
@@ -248,6 +259,56 @@ v10 = [
|
|||||||
(v7/*: any*/)
|
(v7/*: any*/)
|
||||||
],
|
],
|
||||||
v11 = {
|
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,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
@@ -392,7 +453,8 @@ return {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"storageKey": "vendors(first:100)"
|
"storageKey": "vendors(first:100)"
|
||||||
}
|
},
|
||||||
|
(v11/*: any*/)
|
||||||
],
|
],
|
||||||
"type": "Organization",
|
"type": "Organization",
|
||||||
"abstractKey": null
|
"abstractKey": null
|
||||||
@@ -472,7 +534,7 @@ return {
|
|||||||
"name": "documentType",
|
"name": "documentType",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
(v11/*: any*/),
|
(v12/*: any*/),
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": [
|
"args": [
|
||||||
@@ -589,7 +651,7 @@ return {
|
|||||||
"name": "state",
|
"name": "state",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
(v11/*: any*/),
|
(v12/*: any*/),
|
||||||
(v5/*: any*/)
|
(v5/*: any*/)
|
||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
@@ -650,7 +712,8 @@ return {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"storageKey": "vendors(first:100)"
|
"storageKey": "vendors(first:100)"
|
||||||
}
|
},
|
||||||
|
(v11/*: any*/)
|
||||||
],
|
],
|
||||||
"type": "Organization",
|
"type": "Organization",
|
||||||
"abstractKey": null
|
"abstractKey": null
|
||||||
@@ -661,16 +724,16 @@ return {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"params": {
|
"params": {
|
||||||
"cacheID": "89f1657de6e2c073609a2c69d7b2d747",
|
"cacheID": "06cd2015e9fc2e65d35ae3153e587c58",
|
||||||
"id": null,
|
"id": null,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"name": "TrustCenterGraphQuery",
|
"name": "TrustCenterGraphQuery",
|
||||||
"operationKind": "query",
|
"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;
|
export default node;
|
||||||
|
|||||||
@@ -32,10 +32,7 @@ import { organizationViewQuery } from "/hooks/graph/OrganizationGraph";
|
|||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
import { SortableTable, SortableTh } from "/components/SortableTable";
|
import { SortableTable, SortableTh } from "/components/SortableTable";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import type {
|
import type { SettingsPageFragment$key } from "./__generated__/SettingsPageFragment.graphql";
|
||||||
SettingsPageFragment$data,
|
|
||||||
SettingsPageFragment$key,
|
|
||||||
} from "./__generated__/SettingsPageFragment.graphql";
|
|
||||||
import type {
|
import type {
|
||||||
SettingsPageMembershipsFragment$data,
|
SettingsPageMembershipsFragment$data,
|
||||||
SettingsPageMembershipsFragment$key
|
SettingsPageMembershipsFragment$key
|
||||||
@@ -98,16 +95,6 @@ const organizationFragment = graphql`
|
|||||||
}
|
}
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
connectors(first: 100) {
|
|
||||||
edges {
|
|
||||||
node {
|
|
||||||
id
|
|
||||||
name
|
|
||||||
type
|
|
||||||
createdAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -702,17 +689,6 @@ export default function SettingsPage({ queryRef }: Props) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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">
|
<div className="space-y-4">
|
||||||
<h2 className="text-base font-medium">{__("Custom Domain")}</h2>
|
<h2 className="text-base font-medium">{__("Custom Domain")}</h2>
|
||||||
<CustomDomainManager
|
<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`
|
const removeMemberMutation = graphql`
|
||||||
mutation SettingsPage_RemoveMemberMutation(
|
mutation SettingsPage_RemoveMemberMutation(
|
||||||
$input: RemoveMemberInput!
|
$input: RemoveMemberInput!
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @generated SignedSource<<8477f42a02a102a4ce8540b794ac4289>>
|
* @generated SignedSource<<cf0ce3993d747999dfbe2ca60b509023>>
|
||||||
* @lightSyntaxTransform
|
* @lightSyntaxTransform
|
||||||
* @nogrep
|
* @nogrep
|
||||||
*/
|
*/
|
||||||
@@ -12,16 +12,6 @@ import { ReaderFragment } from 'relay-runtime';
|
|||||||
export type SSLStatus = "ACTIVE" | "EXPIRED" | "FAILED" | "PENDING" | "PROVISIONING" | "RENEWING";
|
export type SSLStatus = "ACTIVE" | "EXPIRED" | "FAILED" | "PENDING" | "PROVISIONING" | "RENEWING";
|
||||||
import { FragmentRefs } from "relay-runtime";
|
import { FragmentRefs } from "relay-runtime";
|
||||||
export type SettingsPageFragment$data = {
|
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 createdAt: any;
|
||||||
readonly customDomain: {
|
readonly customDomain: {
|
||||||
readonly createdAt: any;
|
readonly createdAt: any;
|
||||||
@@ -70,20 +60,13 @@ v1 = {
|
|||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v2 = {
|
v2 = {
|
||||||
"alias": null,
|
|
||||||
"args": null,
|
|
||||||
"kind": "ScalarField",
|
|
||||||
"name": "type",
|
|
||||||
"storageKey": null
|
|
||||||
},
|
|
||||||
v3 = {
|
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
"name": "createdAt",
|
"name": "createdAt",
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
v4 = {
|
v3 = {
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
"kind": "ScalarField",
|
"kind": "ScalarField",
|
||||||
@@ -171,7 +154,13 @@ return {
|
|||||||
"name": "dnsRecords",
|
"name": "dnsRecords",
|
||||||
"plural": true,
|
"plural": true,
|
||||||
"selections": [
|
"selections": [
|
||||||
(v2/*: any*/),
|
{
|
||||||
|
"alias": null,
|
||||||
|
"args": null,
|
||||||
|
"kind": "ScalarField",
|
||||||
|
"name": "type",
|
||||||
|
"storageKey": null
|
||||||
|
},
|
||||||
(v1/*: any*/),
|
(v1/*: any*/),
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
@@ -197,8 +186,8 @@ return {
|
|||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
|
(v2/*: any*/),
|
||||||
(v3/*: any*/),
|
(v3/*: any*/),
|
||||||
(v4/*: any*/),
|
|
||||||
{
|
{
|
||||||
"alias": null,
|
"alias": null,
|
||||||
"args": null,
|
"args": null,
|
||||||
@@ -209,57 +198,14 @@ return {
|
|||||||
],
|
],
|
||||||
"storageKey": null
|
"storageKey": null
|
||||||
},
|
},
|
||||||
(v3/*: any*/),
|
(v2/*: any*/),
|
||||||
(v4/*: any*/),
|
(v3/*: 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)"
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"type": "Organization",
|
"type": "Organization",
|
||||||
"abstractKey": null
|
"abstractKey": null
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
(node as any).hash = "b3b152b507befd6b6918a12972e05f14";
|
(node as any).hash = "1e64bcccf8ef3a8ead79b3446e8a3ccd";
|
||||||
|
|
||||||
export default node;
|
export default node;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useState } from "react";
|
|||||||
import { useOrganizationId } from "/hooks/useOrganizationId";
|
import { useOrganizationId } from "/hooks/useOrganizationId";
|
||||||
import { Outlet, useLocation, Link } from "react-router";
|
import { Outlet, useLocation, Link } from "react-router";
|
||||||
import { TrustCenterReferencesSection } from "/components/trustCenter/TrustCenterReferencesSection";
|
import { TrustCenterReferencesSection } from "/components/trustCenter/TrustCenterReferencesSection";
|
||||||
|
import { SlackConnections } from "../../../components/organizations/SlackConnection";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
queryRef: PreloadedQuery<TrustCenterGraphQuery>;
|
queryRef: PreloadedQuery<TrustCenterGraphQuery>;
|
||||||
@@ -257,6 +258,16 @@ export default function TrustCenterPage({ queryRef }: Props) {
|
|||||||
<TrustCenterReferencesSection trustCenterId={organization.trustCenter.id} />
|
<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">
|
<div className="space-y-4">
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<TabItem
|
<TabItem
|
||||||
|
|||||||
15
cfg/dev.yaml
15
cfg/dev.yaml
@@ -62,6 +62,9 @@ probod:
|
|||||||
addr: "localhost:1025"
|
addr: "localhost:1025"
|
||||||
tls-required: false
|
tls-required: false
|
||||||
|
|
||||||
|
slack:
|
||||||
|
sender-interval: 60
|
||||||
|
|
||||||
openai:
|
openai:
|
||||||
api-key: "thisisnotasecret"
|
api-key: "thisisnotasecret"
|
||||||
temperature: 0.1
|
temperature: 0.1
|
||||||
@@ -76,3 +79,15 @@ probod:
|
|||||||
email: "admin@getprobo.com"
|
email: "admin@getprobo.com"
|
||||||
key-type: "EC256"
|
key-type: "EC256"
|
||||||
insecure-tls: true
|
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
|
ProtocolType string
|
||||||
|
|
||||||
Connector interface {
|
Connector interface {
|
||||||
Initiate(ctx context.Context, connectorID string, organizationID gid.GID, r *http.Request) (string, error)
|
Initiate(ctx context.Context, provider string, organizationID gid.GID, r *http.Request) (string, error)
|
||||||
Complete(ctx context.Context, connectorID string, organizationID gid.GID, r *http.Request) (Connection, error)
|
Complete(ctx context.Context, r *http.Request) (Connection, *gid.GID, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
Connection interface {
|
Connection interface {
|
||||||
@@ -41,20 +41,28 @@ type (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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 {
|
default:
|
||||||
case ProtocolOAuth2:
|
var conn OAuth2Connection
|
||||||
var conn OAuth2Connection
|
if err := json.Unmarshal(data, &conn); err != nil {
|
||||||
if err := json.Unmarshal(data, &conn); err != nil {
|
return nil, fmt.Errorf("cannot unmarshal oauth2 connection: %w", err)
|
||||||
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
|
package connector
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -46,7 +48,7 @@ type (
|
|||||||
|
|
||||||
OAuth2State struct {
|
OAuth2State struct {
|
||||||
OrganizationID string `json:"oid"`
|
OrganizationID string `json:"oid"`
|
||||||
ConnectorID string `json:"cid"`
|
Provider string `json:"provider"`
|
||||||
}
|
}
|
||||||
|
|
||||||
OAuth2Connection struct {
|
OAuth2Connection struct {
|
||||||
@@ -66,29 +68,33 @@ var (
|
|||||||
OAuth2TokenTTL = 10 * time.Minute
|
OAuth2TokenTTL = 10 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
func (c *OAuth2Connector) Initiate(ctx context.Context, connectorID string, organizationID gid.GID, r *http.Request) (string, error) {
|
func (c *OAuth2Connector) Initiate(ctx context.Context, provider string, organizationID gid.GID, r *http.Request) (string, error) {
|
||||||
stateData := OAuth2State{OrganizationID: organizationID.String(), ConnectorID: connectorID}
|
stateData := OAuth2State{
|
||||||
|
OrganizationID: organizationID.String(),
|
||||||
|
Provider: provider,
|
||||||
|
}
|
||||||
state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, stateData)
|
state, err := statelesstoken.NewToken(c.ClientSecret, OAuth2TokenType, OAuth2TokenTTL, stateData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("cannot create state token: %w", err)
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("cannot parse redirect URI: %w", err)
|
return "", fmt.Errorf("cannot parse redirect URI: %w", err)
|
||||||
}
|
}
|
||||||
|
q := redirectURIParsed.Query()
|
||||||
redirectQuery := url.Values{}
|
q.Set("provider", provider)
|
||||||
redirectQuery.Set("organization_id", organizationID.String())
|
if continueURL := r.URL.Query().Get("continue"); continueURL != "" {
|
||||||
redirectQuery.Set("connector_id", connectorID)
|
q.Set("continue", continueURL)
|
||||||
redirectQuery.Set("continue", r.URL.Query().Get("continue"))
|
}
|
||||||
|
redirectURIParsed.RawQuery = q.Encode()
|
||||||
redirectURI.RawQuery = redirectQuery.Encode()
|
redirectURI = redirectURIParsed.String()
|
||||||
|
|
||||||
authCodeQuery := url.Values{}
|
authCodeQuery := url.Values{}
|
||||||
authCodeQuery.Set("state", state)
|
authCodeQuery.Set("state", state)
|
||||||
authCodeQuery.Set("client_id", c.ClientID)
|
authCodeQuery.Set("client_id", c.ClientID)
|
||||||
authCodeQuery.Set("redirect_uri", redirectURI.String())
|
authCodeQuery.Set("redirect_uri", redirectURI)
|
||||||
authCodeQuery.Set("response_type", "code")
|
authCodeQuery.Set("response_type", "code")
|
||||||
authCodeQuery.Set("scope", strings.Join(c.Scopes, " "))
|
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
|
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")
|
code := r.URL.Query().Get("code")
|
||||||
if 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")
|
stateToken := r.URL.Query().Get("state")
|
||||||
if state == "" {
|
if stateToken == "" {
|
||||||
return nil, fmt.Errorf("no state in request")
|
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 {
|
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() {
|
if payload.Data.Provider != provider {
|
||||||
return nil, fmt.Errorf("invalid organization ID")
|
return nil, nil, fmt.Errorf("provider mismatch: state has %q, query has %q", payload.Data.Provider, provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Data.ConnectorID != connectorID {
|
organizationID, err := gid.ParseGID(payload.Data.OrganizationID)
|
||||||
return nil, fmt.Errorf("invalid connector ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
redirectURI, err := url.Parse(c.RedirectURI)
|
|
||||||
if err != nil {
|
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{}
|
redirectURI := c.RedirectURI
|
||||||
redirectQuery.Set("organization_id", organizationID.String())
|
redirectURIParsed, err := url.Parse(redirectURI)
|
||||||
redirectQuery.Set("connector_id", connectorID)
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("cannot parse redirect URI: %w", err)
|
||||||
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()
|
||||||
|
|
||||||
tokenRequestData := url.Values{}
|
tokenRequestData := url.Values{}
|
||||||
tokenRequestData.Set("client_id", c.ClientID)
|
tokenRequestData.Set("client_id", c.ClientID)
|
||||||
tokenRequestData.Set("client_secret", c.ClientSecret)
|
tokenRequestData.Set("client_secret", c.ClientSecret)
|
||||||
tokenRequestData.Set("code", code)
|
tokenRequestData.Set("code", code)
|
||||||
tokenRequestData.Set("redirect_uri", redirectURI.String())
|
tokenRequestData.Set("redirect_uri", redirectURI)
|
||||||
tokenRequestData.Set("grant_type", "authorization_code")
|
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 {
|
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")
|
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)
|
tokenResp, err := http.DefaultClient.Do(tokenRequest)
|
||||||
if err != nil {
|
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()
|
defer tokenResp.Body.Close()
|
||||||
|
|
||||||
if tokenResp.StatusCode != http.StatusOK {
|
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 {
|
body, err := io.ReadAll(tokenResp.Body)
|
||||||
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)
|
|
||||||
if err != nil {
|
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{
|
var oauth2Conn OAuth2Connection
|
||||||
AccessToken: token.AccessToken,
|
var buf bytes.Buffer
|
||||||
RefreshToken: token.RefreshToken,
|
buf.Write(body)
|
||||||
ExpiresAt: token.ExpiresAt,
|
err = json.NewDecoder(&buf).Decode(&oauth2Conn)
|
||||||
Scope: token.Scope,
|
if err != nil {
|
||||||
TokenType: token.TokenType,
|
return nil, nil, fmt.Errorf("cannot decode token response: %w", err)
|
||||||
}, nil
|
}
|
||||||
|
|
||||||
|
if provider == SlackProvider {
|
||||||
|
return ParseSlackTokenResponse(body, oauth2Conn, organizationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &oauth2Conn, &organizationID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *OAuth2Connection) Type() ProtocolType {
|
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()
|
cr.Lock()
|
||||||
defer cr.Unlock()
|
defer cr.Unlock()
|
||||||
if _, ok := cr.connectors[connectorID]; ok {
|
if _, ok := cr.connectors[provider]; ok {
|
||||||
return fmt.Errorf("connector %q already registered", connectorID)
|
return fmt.Errorf("connector %q already registered", provider)
|
||||||
}
|
}
|
||||||
cr.connectors[connectorID] = connector
|
cr.connectors[provider] = connector
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *ConnectorRegistry) Get(connectorID string) (Connector, error) {
|
func (cr *ConnectorRegistry) Get(provider string) (Connector, error) {
|
||||||
cr.RLock()
|
cr.RLock()
|
||||||
defer cr.RUnlock()
|
defer cr.RUnlock()
|
||||||
connector, ok := cr.connectors[connectorID]
|
connector, ok := cr.connectors[provider]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("connector %q not found", connectorID)
|
return nil, fmt.Errorf("connector %q not found", provider)
|
||||||
}
|
}
|
||||||
return connector, nil
|
return connector, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cr *ConnectorRegistry) Initiate(ctx context.Context, connectorID string, organizationID gid.GID, r *http.Request) (string, error) {
|
func (cr *ConnectorRegistry) Initiate(ctx context.Context, provider string, organizationID gid.GID, r *http.Request) (string, error) {
|
||||||
connector, err := cr.Get(connectorID)
|
connector, err := cr.Get(provider)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("cannot initiate connector: %w", err)
|
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) {
|
func (cr *ConnectorRegistry) Complete(ctx context.Context, provider string, r *http.Request) (Connection, *gid.GID, error) {
|
||||||
connector, err := cr.Get(connectorID)
|
connector, err := cr.Get(provider)
|
||||||
if err != nil {
|
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 (
|
type (
|
||||||
Connector struct {
|
Connector struct {
|
||||||
ID gid.GID `db:"id"`
|
ID gid.GID `db:"id"`
|
||||||
OrganizationID gid.GID `db:"organization_id"`
|
OrganizationID gid.GID `db:"organization_id"`
|
||||||
Name string `db:"name"`
|
Provider ConnectorProvider `db:"provider"`
|
||||||
Type connector.ProtocolType `db:"type"`
|
Protocol ConnectorProtocol `db:"protocol"`
|
||||||
Connection connector.Connection `db:"-"`
|
Settings map[string]any `db:"settings"`
|
||||||
EncryptedConnection []byte `db:"encrypted_connection"`
|
Connection connector.Connection `db:"-"`
|
||||||
CreatedAt time.Time `db:"created_at"`
|
EncryptedConnection []byte `db:"encrypted_connection"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
Connectors []*Connector
|
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,
|
ctx context.Context,
|
||||||
conn pg.Conn,
|
conn pg.Conn,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
cursor *page.Cursor[ConnectorOrderField],
|
cursor *page.Cursor[ConnectorOrderField],
|
||||||
encryptionKey cipher.EncryptionKey,
|
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 {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
name,
|
provider,
|
||||||
type,
|
protocol,
|
||||||
|
settings,
|
||||||
encrypted_connection,
|
encrypted_connection,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
@@ -67,12 +264,14 @@ WHERE
|
|||||||
%s
|
%s
|
||||||
AND organization_id = @organization_id
|
AND organization_id = @organization_id
|
||||||
AND %s
|
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}
|
args := pgx.StrictNamedArgs{"organization_id": organizationID}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
maps.Copy(args, filter.SQLArguments())
|
||||||
maps.Copy(args, cursor.SQLArguments())
|
maps.Copy(args, cursor.SQLArguments())
|
||||||
|
|
||||||
rows, err := conn.Query(ctx, q, args)
|
rows, err := conn.Query(ctx, q, args)
|
||||||
@@ -90,110 +289,122 @@ WHERE
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Connector) CursorKey(orderBy ConnectorOrderField) page.CursorKey {
|
func (c *Connectors) loadAllByOrganizationID(
|
||||||
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(
|
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Conn,
|
conn pg.Conn,
|
||||||
scope Scoper,
|
scope Scoper,
|
||||||
encryptionKey cipher.EncryptionKey,
|
organizationID gid.GID,
|
||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
INSERT INTO
|
SELECT
|
||||||
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
|
|
||||||
id,
|
id,
|
||||||
organization_id,
|
organization_id,
|
||||||
name,
|
provider,
|
||||||
type,
|
protocol,
|
||||||
|
settings,
|
||||||
encrypted_connection,
|
encrypted_connection,
|
||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
|
FROM
|
||||||
|
connectors
|
||||||
|
WHERE
|
||||||
|
%s
|
||||||
|
AND organization_id = @organization_id
|
||||||
|
ORDER BY
|
||||||
|
created_at ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
if c.Connection == nil {
|
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||||
return fmt.Errorf("connection is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
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)
|
connectors, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Connector])
|
||||||
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])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot collect connectors: %w", err)
|
return fmt.Errorf("cannot collect connectors: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
decryptedConnection, err := cipher.Decrypt(cnnctr.EncryptedConnection, encryptionKey)
|
*c = connectors
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot decrypt connection: %w", err)
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cnnctr.Connection, err = connector.UnmarshalConnection(cnnctr.Type, decryptedConnection)
|
func (c *Connectors) loadAllByOrganizationIDProtocolAndProvider(
|
||||||
if err != nil {
|
ctx context.Context,
|
||||||
return fmt.Errorf("cannot unmarshal connection: %w", err)
|
conn pg.Conn,
|
||||||
}
|
scope Scoper,
|
||||||
|
organizationID gid.GID,
|
||||||
*c = cnnctr
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ type (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
ConnectorOrderFieldCreatedAt ConnectorOrderField = "CREATED_AT"
|
ConnectorOrderFieldCreatedAt ConnectorOrderField = "CREATED_AT"
|
||||||
ConnectorOrderFieldName ConnectorOrderField = "NAME"
|
ConnectorOrderFieldProvider ConnectorOrderField = "PROVIDER"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (p ConnectorOrderField) Column() string {
|
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
|
CustomDomainEntityType
|
||||||
InvitationEntityType
|
InvitationEntityType
|
||||||
MembershipEntityType
|
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
|
package probo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/getprobo/probo/pkg/connector"
|
"github.com/getprobo/probo/pkg/connector"
|
||||||
@@ -26,15 +28,19 @@ import (
|
|||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
welcomeTemplate = template.Must(template.ParseFS(Templates, "templates/welcome.txt.tmpl"))
|
||||||
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
ConnectorService struct {
|
ConnectorService struct {
|
||||||
svc *TenantService
|
svc *TenantService
|
||||||
}
|
}
|
||||||
|
|
||||||
CreateOrUpdateConnectorRequest struct {
|
CreateConnectorRequest struct {
|
||||||
OrganizationID gid.GID
|
OrganizationID gid.GID
|
||||||
Name string
|
Provider coredata.ConnectorProvider
|
||||||
Type connector.ProtocolType
|
Protocol coredata.ConnectorProtocol
|
||||||
Connection connector.Connection
|
Connection connector.Connection
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -43,19 +49,20 @@ func (s *ConnectorService) ListForOrganizationID(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
cursor *page.Cursor[coredata.ConnectorOrderField],
|
cursor *page.Cursor[coredata.ConnectorOrderField],
|
||||||
|
filter *coredata.ConnectorProviderFilter,
|
||||||
) (*page.Page[*coredata.Connector, coredata.ConnectorOrderField], error) {
|
) (*page.Page[*coredata.Connector, coredata.ConnectorOrderField], error) {
|
||||||
var connectors coredata.Connectors
|
var connectors coredata.Connectors
|
||||||
|
|
||||||
err := s.svc.pg.WithConn(
|
err := s.svc.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(conn pg.Conn) error {
|
func(conn pg.Conn) error {
|
||||||
return connectors.LoadWithoutDecryptedConnectionByOrganizationID(
|
return connectors.LoadByOrganizationIDWithoutDecryptedConnection(
|
||||||
ctx,
|
ctx,
|
||||||
conn,
|
conn,
|
||||||
s.svc.scope,
|
s.svc.scope,
|
||||||
organizationID,
|
organizationID,
|
||||||
cursor,
|
cursor,
|
||||||
s.svc.encryptionKey,
|
filter,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -67,34 +74,34 @@ func (s *ConnectorService) ListForOrganizationID(
|
|||||||
return page.NewPage(connectors, cursor), nil
|
return page.NewPage(connectors, cursor), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ConnectorService) CreateOrUpdate(
|
func (s *ConnectorService) Create(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req CreateOrUpdateConnectorRequest,
|
req CreateConnectorRequest,
|
||||||
) (*coredata.Connector, error) {
|
) (*coredata.Connector, error) {
|
||||||
if req.OrganizationID == gid.Nil {
|
if req.OrganizationID == gid.Nil {
|
||||||
return nil, fmt.Errorf("organization ID is required")
|
return nil, fmt.Errorf("organization ID is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Name == "" {
|
if req.Provider == "" {
|
||||||
return nil, fmt.Errorf("connector name is required")
|
return nil, fmt.Errorf("connector provider is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Type == "" {
|
if req.Protocol == "" {
|
||||||
return nil, fmt.Errorf("connector type is required")
|
return nil, fmt.Errorf("connector protocol is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Connection == nil {
|
if req.Connection == nil {
|
||||||
return nil, fmt.Errorf("connection configuration is required")
|
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()
|
now := time.Now()
|
||||||
|
|
||||||
connector := &coredata.Connector{
|
newConnector := &coredata.Connector{
|
||||||
ID: connectorID,
|
ID: id,
|
||||||
OrganizationID: req.OrganizationID,
|
OrganizationID: req.OrganizationID,
|
||||||
Name: req.Name,
|
Provider: req.Provider,
|
||||||
Type: req.Type,
|
Protocol: req.Protocol,
|
||||||
Connection: req.Connection,
|
Connection: req.Connection,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
@@ -103,8 +110,36 @@ func (s *ConnectorService) CreateOrUpdate(
|
|||||||
err := s.svc.pg.WithConn(
|
err := s.svc.pg.WithConn(
|
||||||
ctx,
|
ctx,
|
||||||
func(conn pg.Conn) error {
|
func(conn pg.Conn) error {
|
||||||
if err := connector.Upsert(ctx, conn, s.svc.scope, s.svc.encryptionKey); err != nil {
|
if err := newConnector.Insert(ctx, conn, s.svc.scope, s.svc.encryptionKey); err != nil {
|
||||||
return fmt.Errorf("cannot upsert connector: %w", err)
|
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
|
return nil
|
||||||
@@ -115,5 +150,5 @@ func (s *ConnectorService) CreateOrUpdate(
|
|||||||
return nil, err
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/getprobo/probo/pkg/connector"
|
"github.com/getprobo/probo/pkg/connector"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
connectorConfig struct {
|
connectorConfig struct {
|
||||||
Name string `json:"name"`
|
Provider string `json:"provider"`
|
||||||
Type connector.ProtocolType `json:"type"`
|
Protocol connector.ProtocolType `json:"protocol"`
|
||||||
Config connector.Connector `json:"-"`
|
Config connector.Connector `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
connectorConfigOAuth2 struct {
|
connectorConfigOAuth2 struct {
|
||||||
@@ -40,19 +41,19 @@ type (
|
|||||||
|
|
||||||
func (c *connectorConfig) UnmarshalJSON(data []byte) error {
|
func (c *connectorConfig) UnmarshalJSON(data []byte) error {
|
||||||
var tmp struct {
|
var tmp struct {
|
||||||
Name string `json:"name"`
|
Provider string `json:"provider"`
|
||||||
Type connector.ProtocolType `json:"type"`
|
Protocol string `json:"protocol"`
|
||||||
RawConfig json.RawMessage `json:"config"`
|
RawConfig json.RawMessage `json:"config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||||
return fmt.Errorf("cannot unmarshal connector config: %w", err)
|
return fmt.Errorf("cannot unmarshal connector config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Name = tmp.Name
|
c.Provider = strings.ToUpper(tmp.Provider)
|
||||||
c.Type = tmp.Type
|
c.Protocol = connector.ProtocolType(strings.ToUpper(tmp.Protocol))
|
||||||
|
|
||||||
switch tmp.Type {
|
switch c.Protocol {
|
||||||
case connector.ProtocolOAuth2:
|
case connector.ProtocolOAuth2:
|
||||||
var config connectorConfigOAuth2
|
var config connectorConfigOAuth2
|
||||||
if err := json.Unmarshal(tmp.RawConfig, &config); err != nil {
|
if err := json.Unmarshal(tmp.RawConfig, &config); err != nil {
|
||||||
@@ -70,7 +71,7 @@ func (c *connectorConfig) UnmarshalJSON(data []byte) error {
|
|||||||
|
|
||||||
c.Config = &oauth2Connector
|
c.Config = &oauth2Connector
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown connector type: %q", tmp.Type)
|
return fmt.Errorf("unknown connector protocol: %q", c.Protocol)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import (
|
|||||||
"github.com/getprobo/probo/pkg/saferedirect"
|
"github.com/getprobo/probo/pkg/saferedirect"
|
||||||
"github.com/getprobo/probo/pkg/server"
|
"github.com/getprobo/probo/pkg/server"
|
||||||
"github.com/getprobo/probo/pkg/server/api"
|
"github.com/getprobo/probo/pkg/server/api"
|
||||||
|
"github.com/getprobo/probo/pkg/slack"
|
||||||
"github.com/getprobo/probo/pkg/trust"
|
"github.com/getprobo/probo/pkg/trust"
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"go.gearno.de/kit/httpclient"
|
"go.gearno.de/kit/httpclient"
|
||||||
@@ -72,6 +73,7 @@ type (
|
|||||||
TrustCenter trustCenterConfig `json:"trust-center"`
|
TrustCenter trustCenterConfig `json:"trust-center"`
|
||||||
AWS awsConfig `json:"aws"`
|
AWS awsConfig `json:"aws"`
|
||||||
Mailer mailerConfig `json:"mailer"`
|
Mailer mailerConfig `json:"mailer"`
|
||||||
|
Slack slackConfig `json:"slack"`
|
||||||
Connectors []connectorConfig `json:"connectors"`
|
Connectors []connectorConfig `json:"connectors"`
|
||||||
OpenAI openaiConfig `json:"openai"`
|
OpenAI openaiConfig `json:"openai"`
|
||||||
ChromeDPAddr string `json:"chrome-dp-addr"`
|
ChromeDPAddr string `json:"chrome-dp-addr"`
|
||||||
@@ -143,6 +145,9 @@ func New() *Implm {
|
|||||||
Addr: "localhost:1025",
|
Addr: "localhost:1025",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Slack: slackConfig{
|
||||||
|
SenderInterval: 60,
|
||||||
|
},
|
||||||
CustomDomains: customDomainsConfig{
|
CustomDomains: customDomainsConfig{
|
||||||
RenewalInterval: 3600,
|
RenewalInterval: 3600,
|
||||||
ProvisionInterval: 30,
|
ProvisionInterval: 30,
|
||||||
@@ -204,6 +209,7 @@ func (impl *Implm) Run(
|
|||||||
return fmt.Errorf("cannot get trust auth token secret bytes: %w", err)
|
return fmt.Errorf("cannot get trust auth token secret bytes: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
awsConfig := awsconfig.NewConfig(
|
awsConfig := awsconfig.NewConfig(
|
||||||
l,
|
l,
|
||||||
httpclient.DefaultPooledClient(
|
httpclient.DefaultPooledClient(
|
||||||
@@ -239,7 +245,7 @@ func (impl *Implm) Run(
|
|||||||
|
|
||||||
defaultConnectorRegistry := connector.NewConnectorRegistry()
|
defaultConnectorRegistry := connector.NewConnectorRegistry()
|
||||||
for _, connector := range impl.cfg.Connectors {
|
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)
|
return fmt.Errorf("cannot register connector: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,6 +344,7 @@ func (impl *Implm) Run(
|
|||||||
pgClient,
|
pgClient,
|
||||||
s3Client,
|
s3Client,
|
||||||
impl.cfg.AWS.Bucket,
|
impl.cfg.AWS.Bucket,
|
||||||
|
impl.cfg.Hostname,
|
||||||
impl.cfg.EncryptionKey,
|
impl.cfg.EncryptionKey,
|
||||||
impl.cfg.TrustAuth.TokenSecret,
|
impl.cfg.TrustAuth.TokenSecret,
|
||||||
authService,
|
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())
|
exportJobExporterCtx, stopExportJobExporter := context.WithCancel(context.Background())
|
||||||
wg.Go(
|
wg.Go(
|
||||||
func() {
|
func() {
|
||||||
@@ -430,6 +449,7 @@ func (impl *Implm) Run(
|
|||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|
||||||
stopMailer()
|
stopMailer()
|
||||||
|
stopSlackSender()
|
||||||
stopExportJobExporter()
|
stopExportJobExporter()
|
||||||
stopApiServer()
|
stopApiServer()
|
||||||
stopTrustCenterServer()
|
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/go-chi/chi/v5"
|
||||||
"github.com/vektah/gqlparser/v2/gqlerror"
|
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||||
"go.gearno.de/crypto/uuid"
|
"go.gearno.de/crypto/uuid"
|
||||||
|
"go.gearno.de/kit/httpserver"
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -218,7 +219,12 @@ func NewMux(
|
|||||||
r.Post("/auth/reset-password", ResetPasswordHandler(authSvc, authCfg))
|
r.Post("/auth/reset-password", ResetPasswordHandler(authSvc, authCfg))
|
||||||
|
|
||||||
r.Get("/connectors/initiate", WithSession(authSvc, authzSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
|
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"))
|
organizationID, err := gid.ParseGID(r.URL.Query().Get("organization_id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(fmt.Errorf("failed to parse organization id: %w", err))
|
panic(fmt.Errorf("failed to parse organization id: %w", err))
|
||||||
@@ -226,34 +232,53 @@ func NewMux(
|
|||||||
|
|
||||||
_ = GetTenantService(r.Context(), proboSvc, organizationID.TenantID())
|
_ = 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 {
|
if err != nil {
|
||||||
panic(fmt.Errorf("cannot initiate connector: %w", err))
|
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) {
|
r.Get("/connectors/complete", func(w http.ResponseWriter, r *http.Request) {
|
||||||
connectorID := r.URL.Query().Get("connector_id")
|
provider := r.URL.Query().Get("provider")
|
||||||
organizationID, err := gid.ParseGID(r.URL.Query().Get("organization_id"))
|
if provider == "" {
|
||||||
if err != nil {
|
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("missing provider parameter"))
|
||||||
panic(fmt.Errorf("failed to parse organization id: %w", err))
|
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 {
|
if err != nil {
|
||||||
panic(fmt.Errorf("failed to complete connector: %w", err))
|
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(),
|
r.Context(),
|
||||||
probo.CreateOrUpdateConnectorRequest{
|
probo.CreateConnectorRequest{
|
||||||
OrganizationID: organizationID,
|
OrganizationID: *organizationID,
|
||||||
Name: connectorID,
|
Provider: connectorProvider,
|
||||||
Type: connector.ProtocolType(connection.Type()),
|
Protocol: coredata.ConnectorProtocol(connection.Type()),
|
||||||
Connection: connection,
|
Connection: connection,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -261,8 +286,13 @@ func NewMux(
|
|||||||
panic(fmt.Errorf("failed to create or update connector: %w", err))
|
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.Get("/", playground.Handler("GraphQL", "/api/console/v1/query"))
|
||||||
r.Post("/query", graphqlHandler(logger, proboSvc, authSvc, authzSvc, authCfg, customDomainCname))
|
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
|
enum DataSensitivity
|
||||||
@goModel(model: "github.com/getprobo/probo/pkg/coredata.DataSensitivity") {
|
@goModel(model: "github.com/getprobo/probo/pkg/coredata.DataSensitivity") {
|
||||||
NONE
|
NONE
|
||||||
@@ -1473,11 +1459,6 @@ input OrganizationOrder {
|
|||||||
field: OrganizationOrderField!
|
field: OrganizationOrderField!
|
||||||
}
|
}
|
||||||
|
|
||||||
input ConnectorOrder {
|
|
||||||
field: ConnectorOrderField!
|
|
||||||
direction: OrderDirection!
|
|
||||||
}
|
|
||||||
|
|
||||||
input DocumentVersionOrder
|
input DocumentVersionOrder
|
||||||
@goModel(
|
@goModel(
|
||||||
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.DocumentVersionOrderBy"
|
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.DocumentVersionOrderBy"
|
||||||
@@ -1621,13 +1602,12 @@ type Organization implements Node {
|
|||||||
filter: InvitationFilter
|
filter: InvitationFilter
|
||||||
): InvitationConnection! @goField(forceResolver: true)
|
): InvitationConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
connectors(
|
slackConnections(
|
||||||
first: Int
|
first: Int
|
||||||
after: CursorKey
|
after: CursorKey
|
||||||
last: Int
|
last: Int
|
||||||
before: CursorKey
|
before: CursorKey
|
||||||
orderBy: ConnectorOrder
|
): SlackConnectionConnection! @goField(forceResolver: true)
|
||||||
): ConnectorConnection! @goField(forceResolver: true)
|
|
||||||
|
|
||||||
frameworks(
|
frameworks(
|
||||||
first: Int
|
first: Int
|
||||||
@@ -1808,14 +1788,24 @@ type Invitation implements Node {
|
|||||||
organization: Organization! @goField(forceResolver: true)
|
organization: Organization! @goField(forceResolver: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Connector implements Node {
|
type SlackConnection {
|
||||||
id: ID!
|
id: ID!
|
||||||
name: String!
|
channel: String
|
||||||
type: String!
|
channelId: String
|
||||||
createdAt: Datetime!
|
createdAt: Datetime!
|
||||||
updatedAt: Datetime!
|
updatedAt: Datetime!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SlackConnectionConnection {
|
||||||
|
edges: [SlackConnectionEdge!]!
|
||||||
|
pageInfo: PageInfo!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlackConnectionEdge {
|
||||||
|
cursor: CursorKey!
|
||||||
|
node: SlackConnection!
|
||||||
|
}
|
||||||
|
|
||||||
type People implements Node {
|
type People implements Node {
|
||||||
id: ID!
|
id: ID!
|
||||||
fullName: String!
|
fullName: String!
|
||||||
@@ -2635,15 +2625,6 @@ type VendorServiceEdge {
|
|||||||
node: VendorService!
|
node: VendorService!
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConnectorConnection {
|
|
||||||
edges: [ConnectorEdge!]!
|
|
||||||
pageInfo: PageInfo!
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConnectorEdge {
|
|
||||||
cursor: CursorKey!
|
|
||||||
node: Connector!
|
|
||||||
}
|
|
||||||
|
|
||||||
type VendorRiskAssessmentConnection {
|
type VendorRiskAssessmentConnection {
|
||||||
edges: [VendorRiskAssessmentEdge!]!
|
edges: [VendorRiskAssessmentEdge!]!
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -19,36 +19,42 @@ import (
|
|||||||
"github.com/getprobo/probo/pkg/page"
|
"github.com/getprobo/probo/pkg/page"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
func NewSlackConnectionConnection(p *page.Page[*coredata.Connector, coredata.ConnectorOrderField]) *SlackConnectionConnection {
|
||||||
ConnectorOrderBy OrderBy[coredata.ConnectorOrderField]
|
var edges = make([]*SlackConnectionEdge, len(p.Data))
|
||||||
)
|
|
||||||
|
|
||||||
func NewConnectorConnection(p *page.Page[*coredata.Connector, coredata.ConnectorOrderField]) *ConnectorConnection {
|
|
||||||
var edges = make([]*ConnectorEdge, len(p.Data))
|
|
||||||
|
|
||||||
for i := range edges {
|
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,
|
Edges: edges,
|
||||||
PageInfo: NewPageInfo(p),
|
PageInfo: NewPageInfo(p),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConnectorEdge(c *coredata.Connector, orderBy coredata.ConnectorOrderField) *ConnectorEdge {
|
func NewSlackConnectionEdge(c *coredata.Connector, orderBy coredata.ConnectorOrderField) *SlackConnectionEdge {
|
||||||
return &ConnectorEdge{
|
return &SlackConnectionEdge{
|
||||||
Cursor: c.CursorKey(orderBy),
|
Cursor: c.CursorKey(orderBy),
|
||||||
Node: NewConnector(c),
|
Node: NewSlackConnection(c),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConnector(c *coredata.Connector) *Connector {
|
func NewSlackConnection(c *coredata.Connector) *SlackConnection {
|
||||||
return &Connector{
|
conn := &SlackConnection{
|
||||||
ID: c.ID,
|
ID: c.ID,
|
||||||
Name: c.Name,
|
|
||||||
Type: string(c.Type),
|
|
||||||
CreatedAt: c.CreatedAt,
|
CreatedAt: c.CreatedAt,
|
||||||
UpdatedAt: c.UpdatedAt,
|
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"`
|
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 {
|
type ContinualImprovement struct {
|
||||||
ID gid.GID `json:"id"`
|
ID gid.GID `json:"id"`
|
||||||
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
|
SnapshotID *gid.GID `json:"snapshotId,omitempty"`
|
||||||
@@ -1374,7 +1348,7 @@ type Organization struct {
|
|||||||
HeadquarterAddress *string `json:"headquarterAddress,omitempty"`
|
HeadquarterAddress *string `json:"headquarterAddress,omitempty"`
|
||||||
Memberships *MembershipConnection `json:"memberships"`
|
Memberships *MembershipConnection `json:"memberships"`
|
||||||
Invitations *InvitationConnection `json:"invitations"`
|
Invitations *InvitationConnection `json:"invitations"`
|
||||||
Connectors *ConnectorConnection `json:"connectors"`
|
SlackConnections *SlackConnectionConnection `json:"slackConnections"`
|
||||||
Frameworks *FrameworkConnection `json:"frameworks"`
|
Frameworks *FrameworkConnection `json:"frameworks"`
|
||||||
Controls *ControlConnection `json:"controls"`
|
Controls *ControlConnection `json:"controls"`
|
||||||
Vendors *VendorConnection `json:"vendors"`
|
Vendors *VendorConnection `json:"vendors"`
|
||||||
@@ -1590,6 +1564,24 @@ type Session struct {
|
|||||||
ExpiresAt time.Time `json:"expiresAt"`
|
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 {
|
type Snapshot struct {
|
||||||
ID gid.GID `json:"id"`
|
ID gid.GID `json:"id"`
|
||||||
Organization *Organization `json:"organization"`
|
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
|
return types.NewInvitationConnection(page, r, obj.ID, filter), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connectors is the resolver for the connectors field.
|
// SlackConnections is the resolver for the slackConnections 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) {
|
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())
|
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||||
|
|
||||||
|
// Filter for Slack connectors only
|
||||||
|
slackProvider := coredata.ConnectorProviderSlack
|
||||||
|
filter := coredata.NewConnectorProviderFilter(&slackProvider)
|
||||||
|
|
||||||
pageOrderBy := page.OrderBy[coredata.ConnectorOrderField]{
|
pageOrderBy := page.OrderBy[coredata.ConnectorOrderField]{
|
||||||
Field: coredata.ConnectorOrderFieldCreatedAt,
|
Field: coredata.ConnectorOrderFieldCreatedAt,
|
||||||
Direction: page.OrderDirectionDesc,
|
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)
|
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 {
|
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.
|
// 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
|
proboSvc *probo.Service
|
||||||
encryptionKey cipher.EncryptionKey
|
encryptionKey cipher.EncryptionKey
|
||||||
tokenSecret string
|
tokenSecret string
|
||||||
|
hostname string
|
||||||
auth *auth.Service
|
auth *auth.Service
|
||||||
html2pdfConverter *html2pdf.Converter
|
html2pdfConverter *html2pdf.Converter
|
||||||
fileManager *filemanager.Service
|
fileManager *filemanager.Service
|
||||||
@@ -47,6 +48,7 @@ type (
|
|||||||
proboSvc *probo.Service
|
proboSvc *probo.Service
|
||||||
encryptionKey cipher.EncryptionKey
|
encryptionKey cipher.EncryptionKey
|
||||||
tokenSecret string
|
tokenSecret string
|
||||||
|
hostname string
|
||||||
auth *auth.Service
|
auth *auth.Service
|
||||||
html2pdfConverter *html2pdf.Converter
|
html2pdfConverter *html2pdf.Converter
|
||||||
fileManager *filemanager.Service
|
fileManager *filemanager.Service
|
||||||
@@ -66,6 +68,7 @@ func NewService(
|
|||||||
pgClient *pg.Client,
|
pgClient *pg.Client,
|
||||||
s3Client *s3.Client,
|
s3Client *s3.Client,
|
||||||
bucket string,
|
bucket string,
|
||||||
|
hostname string,
|
||||||
encryptionKey cipher.EncryptionKey,
|
encryptionKey cipher.EncryptionKey,
|
||||||
tokenSecret string,
|
tokenSecret string,
|
||||||
auth *auth.Service,
|
auth *auth.Service,
|
||||||
@@ -78,6 +81,7 @@ func NewService(
|
|||||||
bucket: bucket,
|
bucket: bucket,
|
||||||
encryptionKey: encryptionKey,
|
encryptionKey: encryptionKey,
|
||||||
tokenSecret: tokenSecret,
|
tokenSecret: tokenSecret,
|
||||||
|
hostname: hostname,
|
||||||
auth: auth,
|
auth: auth,
|
||||||
html2pdfConverter: html2pdfConverter,
|
html2pdfConverter: html2pdfConverter,
|
||||||
fileManager: fileManagerService,
|
fileManager: fileManagerService,
|
||||||
@@ -93,6 +97,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
|
|||||||
proboSvc: s.proboSvc,
|
proboSvc: s.proboSvc,
|
||||||
encryptionKey: s.encryptionKey,
|
encryptionKey: s.encryptionKey,
|
||||||
tokenSecret: s.tokenSecret,
|
tokenSecret: s.tokenSecret,
|
||||||
|
hostname: s.hostname,
|
||||||
auth: s.auth,
|
auth: s.auth,
|
||||||
html2pdfConverter: s.html2pdfConverter,
|
html2pdfConverter: s.html2pdfConverter,
|
||||||
fileManager: s.fileManager,
|
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
|
package trust
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/getprobo/probo/pkg/auth"
|
||||||
"github.com/getprobo/probo/pkg/coredata"
|
"github.com/getprobo/probo/pkg/coredata"
|
||||||
"github.com/getprobo/probo/pkg/gid"
|
"github.com/getprobo/probo/pkg/gid"
|
||||||
"github.com/getprobo/probo/pkg/auth"
|
|
||||||
"go.gearno.de/kit/pg"
|
"go.gearno.de/kit/pg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
accessRequestTemplate = template.Must(template.ParseFS(Templates, "templates/access-request.txt.tmpl"))
|
||||||
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
TrustCenterAccessService struct {
|
TrustCenterAccessService struct {
|
||||||
svc *TenantService
|
svc *TenantService
|
||||||
auth *auth.Service
|
auth *auth.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +51,7 @@ type (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
TokenTypeTrustCenterAccess = "trust_center_access"
|
TokenTypeTrustCenterAccess = "trust_center_access"
|
||||||
|
TrustCenterAccessURLFormat = "https://%s/organizations/%s/trust-center/access"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s TrustCenterAccessService) ValidateToken(
|
func (s TrustCenterAccessService) ValidateToken(
|
||||||
@@ -74,9 +81,9 @@ func (s TrustCenterAccessService) Request(
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
var access *coredata.TrustCenterAccess
|
var access *coredata.TrustCenterAccess
|
||||||
|
var trustCenter *coredata.TrustCenter
|
||||||
|
|
||||||
err := s.svc.pg.WithTx(ctx, func(tx pg.Conn) error {
|
err := s.svc.pg.WithTx(ctx, func(tx pg.Conn) error {
|
||||||
var trustCenter *coredata.TrustCenter
|
|
||||||
var organizationID gid.GID
|
var organizationID gid.GID
|
||||||
trustCenter = &coredata.TrustCenter{}
|
trustCenter = &coredata.TrustCenter{}
|
||||||
if err := trustCenter.LoadByID(ctx, tx, s.svc.scope, req.TrustCenterID); err != nil {
|
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)
|
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
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -330,3 +341,42 @@ func filterExistingIDs(allIDs []gid.GID, existingIDs []gid.GID) []gid.GID {
|
|||||||
|
|
||||||
return newIDs
|
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