Refactor invitation system

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-10-10 01:11:09 +02:00
parent 0f96b8518f
commit 165eb267f2
39 changed files with 2730 additions and 1194 deletions

View File

@@ -45,14 +45,15 @@ const schema = z.object({
type Props = PropsWithChildren & { type Props = PropsWithChildren & {
connectionId?: string; connectionId?: string;
onRefetch: () => void;
}; };
export function InviteUserDialog({ children, connectionId }: Props) { export function InviteUserDialog({ children, connectionId, onRefetch }: Props) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const organizationId = useOrganizationId(); const organizationId = useOrganizationId();
const [inviteUser, isInviting] = useMutationWithToasts(inviteMutation, { const [inviteUser, isInviting] = useMutationWithToasts(inviteMutation, {
successMessage: __("User invited successfully"), successMessage: __("Invitation sent successfully"),
errorMessage: __("Failed to invite user"), errorMessage: __("Failed to send invitation"),
}); });
const { register, handleSubmit, formState, reset, control } = useFormWithSchema( const { register, handleSubmit, formState, reset, control } = useFormWithSchema(
schema, schema,
@@ -72,9 +73,10 @@ export function InviteUserDialog({ children, connectionId }: Props) {
}, },
connections: connectionId ? [connectionId] : ["SettingsPageInvitations_invitations"], connections: connectionId ? [connectionId] : ["SettingsPageInvitations_invitations"],
}, },
onSuccess: () => { onCompleted: () => {
reset(); reset();
dialogRef.current?.close(); dialogRef.current?.close();
onRefetch();
}, },
}); });
}); });

View File

@@ -147,7 +147,7 @@ export const paginatedVendorsFragment = graphql`
`; `;
export const vendorNodeQuery = graphql` export const vendorNodeQuery = graphql`
query VendorGraphNodeQuery($vendorId: ID!, $organizationId: ID!) { query VendorGraphNodeQuery($vendorId: ID!) {
node(id: $vendorId) { node(id: $vendorId) {
... on Vendor { ... on Vendor {
id id
@@ -166,9 +166,6 @@ export const vendorNodeQuery = graphql`
viewer { viewer {
user { user {
id id
people(organizationId: $organizationId) {
id
}
} }
} }
} }

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<fdfadfbc4f5fdecf5f70a8c92d3ded88>> * @generated SignedSource<<cd9f55de7564e6b9e142c91a79bd9d9d>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -11,7 +11,6 @@
import { ConcreteRequest } from 'relay-runtime'; import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime"; import { FragmentRefs } from "relay-runtime";
export type VendorGraphNodeQuery$variables = { export type VendorGraphNodeQuery$variables = {
organizationId: string;
vendorId: string; vendorId: string;
}; };
export type VendorGraphNodeQuery$data = { export type VendorGraphNodeQuery$data = {
@@ -25,9 +24,6 @@ export type VendorGraphNodeQuery$data = {
readonly viewer: { readonly viewer: {
readonly user: { readonly user: {
readonly id: string; readonly id: string;
readonly people: {
readonly id: string;
} | null | undefined;
}; };
}; };
}; };
@@ -37,153 +33,132 @@ export type VendorGraphNodeQuery = {
}; };
const node: ConcreteRequest = (function(){ const node: ConcreteRequest = (function(){
var v0 = { var v0 = [
"defaultValue": null, {
"kind": "LocalArgument",
"name": "organizationId"
},
v1 = {
"defaultValue": null, "defaultValue": null,
"kind": "LocalArgument", "kind": "LocalArgument",
"name": "vendorId" "name": "vendorId"
}, }
v2 = [ ],
v1 = [
{ {
"kind": "Variable", "kind": "Variable",
"name": "id", "name": "id",
"variableName": "vendorId" "variableName": "vendorId"
} }
], ],
v3 = { v2 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "id", "name": "id",
"storageKey": null "storageKey": null
}, },
v4 = { v3 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "snapshotId", "name": "snapshotId",
"storageKey": null "storageKey": null
}, },
v5 = { v4 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "name", "name": "name",
"storageKey": null "storageKey": null
}, },
v6 = { v5 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "websiteUrl", "name": "websiteUrl",
"storageKey": null "storageKey": null
}, },
v7 = [ v6 = [
(v3/*: any*/) (v2/*: any*/)
], ],
v8 = { v7 = {
"alias": null, "alias": null,
"args": null, "args": null,
"concreteType": "User", "concreteType": "User",
"kind": "LinkedField", "kind": "LinkedField",
"name": "user", "name": "user",
"plural": false, "plural": false,
"selections": [ "selections": (v6/*: any*/),
(v3/*: any*/),
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "organizationId",
"variableName": "organizationId"
}
],
"concreteType": "People",
"kind": "LinkedField",
"name": "people",
"plural": false,
"selections": (v7/*: any*/),
"storageKey": null
}
],
"storageKey": null "storageKey": null
}, },
v9 = { v8 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "__typename", "name": "__typename",
"storageKey": null "storageKey": null
}, },
v10 = { v9 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "description", "name": "description",
"storageKey": null "storageKey": null
}, },
v11 = [ v10 = [
{ {
"kind": "Literal", "kind": "Literal",
"name": "first", "name": "first",
"value": 50 "value": 50
} }
], ],
v12 = { v11 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "validUntil", "name": "validUntil",
"storageKey": null "storageKey": null
}, },
v13 = { v12 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "fileName", "name": "fileName",
"storageKey": null "storageKey": null
}, },
v14 = { v13 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "cursor", "name": "cursor",
"storageKey": null "storageKey": null
}, },
v15 = { v14 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "endCursor", "name": "endCursor",
"storageKey": null "storageKey": null
}, },
v16 = { v15 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "hasNextPage", "name": "hasNextPage",
"storageKey": null "storageKey": null
}, },
v17 = { v16 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "hasPreviousPage", "name": "hasPreviousPage",
"storageKey": null "storageKey": null
}, },
v18 = { v17 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "startCursor", "name": "startCursor",
"storageKey": null "storageKey": null
}, },
v19 = { v18 = {
"alias": null, "alias": null,
"args": null, "args": null,
"concreteType": "PageInfo", "concreteType": "PageInfo",
@@ -191,14 +166,14 @@ v19 = {
"name": "pageInfo", "name": "pageInfo",
"plural": false, "plural": false,
"selections": [ "selections": [
(v14/*: any*/),
(v15/*: any*/), (v15/*: any*/),
(v16/*: any*/), (v16/*: any*/),
(v17/*: any*/), (v17/*: any*/)
(v18/*: any*/)
], ],
"storageKey": null "storageKey": null
}, },
v20 = { v19 = {
"kind": "ClientExtension", "kind": "ClientExtension",
"selections": [ "selections": [
{ {
@@ -210,26 +185,26 @@ v20 = {
} }
] ]
}, },
v21 = [ v20 = [
"orderBy" "orderBy"
], ],
v22 = { v21 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "createdAt", "name": "createdAt",
"storageKey": null "storageKey": null
}, },
v23 = { v22 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "updatedAt", "name": "updatedAt",
"storageKey": null "storageKey": null
}, },
v24 = [ v23 = [
(v3/*: any*/), (v2/*: any*/),
(v13/*: any*/), (v12/*: any*/),
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -244,22 +219,19 @@ v24 = [
"name": "validFrom", "name": "validFrom",
"storageKey": null "storageKey": null
}, },
(v12/*: any*/), (v11/*: any*/),
(v22/*: any*/) (v21/*: any*/)
]; ];
return { return {
"fragment": { "fragment": {
"argumentDefinitions": [ "argumentDefinitions": (v0/*: any*/),
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment", "kind": "Fragment",
"metadata": null, "metadata": null,
"name": "VendorGraphNodeQuery", "name": "VendorGraphNodeQuery",
"selections": [ "selections": [
{ {
"alias": null, "alias": null,
"args": (v2/*: any*/), "args": (v1/*: any*/),
"concreteType": null, "concreteType": null,
"kind": "LinkedField", "kind": "LinkedField",
"name": "node", "name": "node",
@@ -268,10 +240,10 @@ return {
{ {
"kind": "InlineFragment", "kind": "InlineFragment",
"selections": [ "selections": [
(v2/*: any*/),
(v3/*: any*/), (v3/*: any*/),
(v4/*: any*/), (v4/*: any*/),
(v5/*: any*/), (v5/*: any*/),
(v6/*: any*/),
{ {
"args": null, "args": null,
"kind": "FragmentSpread", "kind": "FragmentSpread",
@@ -322,7 +294,7 @@ return {
"name": "viewer", "name": "viewer",
"plural": false, "plural": false,
"selections": [ "selections": [
(v8/*: any*/) (v7/*: any*/)
], ],
"storageKey": null "storageKey": null
} }
@@ -332,30 +304,27 @@ return {
}, },
"kind": "Request", "kind": "Request",
"operation": { "operation": {
"argumentDefinitions": [ "argumentDefinitions": (v0/*: any*/),
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation", "kind": "Operation",
"name": "VendorGraphNodeQuery", "name": "VendorGraphNodeQuery",
"selections": [ "selections": [
{ {
"alias": null, "alias": null,
"args": (v2/*: any*/), "args": (v1/*: any*/),
"concreteType": null, "concreteType": null,
"kind": "LinkedField", "kind": "LinkedField",
"name": "node", "name": "node",
"plural": false, "plural": false,
"selections": [ "selections": [
(v9/*: any*/), (v8/*: any*/),
(v3/*: any*/), (v2/*: any*/),
{ {
"kind": "InlineFragment", "kind": "InlineFragment",
"selections": [ "selections": [
(v3/*: any*/),
(v4/*: any*/), (v4/*: any*/),
(v5/*: any*/), (v5/*: any*/),
(v6/*: any*/), (v9/*: any*/),
(v10/*: any*/),
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -447,7 +416,7 @@ return {
"kind": "LinkedField", "kind": "LinkedField",
"name": "businessOwner", "name": "businessOwner",
"plural": false, "plural": false,
"selections": (v7/*: any*/), "selections": (v6/*: any*/),
"storageKey": null "storageKey": null
}, },
{ {
@@ -457,12 +426,12 @@ return {
"kind": "LinkedField", "kind": "LinkedField",
"name": "securityOwner", "name": "securityOwner",
"plural": false, "plural": false,
"selections": (v7/*: any*/), "selections": (v6/*: any*/),
"storageKey": null "storageKey": null
}, },
{ {
"alias": null, "alias": null,
"args": (v11/*: any*/), "args": (v10/*: any*/),
"concreteType": "VendorComplianceReportConnection", "concreteType": "VendorComplianceReportConnection",
"kind": "LinkedField", "kind": "LinkedField",
"name": "complianceReports", "name": "complianceReports",
@@ -484,7 +453,7 @@ return {
"name": "node", "name": "node",
"plural": false, "plural": false,
"selections": [ "selections": [
(v3/*: any*/), (v2/*: any*/),
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -492,7 +461,7 @@ return {
"name": "reportDate", "name": "reportDate",
"storageKey": null "storageKey": null
}, },
(v12/*: any*/), (v11/*: any*/),
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -508,7 +477,7 @@ return {
"name": "file", "name": "file",
"plural": false, "plural": false,
"selections": [ "selections": [
(v13/*: any*/), (v12/*: any*/),
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -523,27 +492,27 @@ return {
"name": "size", "name": "size",
"storageKey": null "storageKey": null
}, },
(v3/*: any*/) (v2/*: any*/)
], ],
"storageKey": null "storageKey": null
}, },
(v9/*: any*/) (v8/*: any*/)
], ],
"storageKey": null "storageKey": null
}, },
(v14/*: any*/) (v13/*: any*/)
], ],
"storageKey": null "storageKey": null
}, },
(v19/*: any*/), (v18/*: any*/),
(v20/*: any*/) (v19/*: any*/)
], ],
"storageKey": "complianceReports(first:50)" "storageKey": "complianceReports(first:50)"
}, },
{ {
"alias": null, "alias": null,
"args": (v11/*: any*/), "args": (v10/*: any*/),
"filters": (v21/*: any*/), "filters": (v20/*: any*/),
"handle": "connection", "handle": "connection",
"key": "VendorComplianceTabFragment_complianceReports", "key": "VendorComplianceTabFragment_complianceReports",
"kind": "LinkedHandle", "kind": "LinkedHandle",
@@ -551,7 +520,7 @@ return {
}, },
{ {
"alias": null, "alias": null,
"args": (v11/*: any*/), "args": (v10/*: any*/),
"concreteType": "VendorContactConnection", "concreteType": "VendorContactConnection",
"kind": "LinkedField", "kind": "LinkedField",
"name": "contacts", "name": "contacts",
@@ -573,7 +542,7 @@ return {
"name": "node", "name": "node",
"plural": false, "plural": false,
"selections": [ "selections": [
(v3/*: any*/), (v2/*: any*/),
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -602,25 +571,25 @@ return {
"name": "role", "name": "role",
"storageKey": null "storageKey": null
}, },
(v21/*: any*/),
(v22/*: any*/), (v22/*: any*/),
(v23/*: any*/), (v8/*: any*/)
(v9/*: any*/)
], ],
"storageKey": null "storageKey": null
}, },
(v14/*: any*/) (v13/*: any*/)
], ],
"storageKey": null "storageKey": null
}, },
(v19/*: any*/), (v18/*: any*/),
(v20/*: any*/) (v19/*: any*/)
], ],
"storageKey": "contacts(first:50)" "storageKey": "contacts(first:50)"
}, },
{ {
"alias": null, "alias": null,
"args": (v11/*: any*/), "args": (v10/*: any*/),
"filters": (v21/*: any*/), "filters": (v20/*: any*/),
"handle": "connection", "handle": "connection",
"key": "VendorContactsTabFragment_contacts", "key": "VendorContactsTabFragment_contacts",
"kind": "LinkedHandle", "kind": "LinkedHandle",
@@ -628,7 +597,7 @@ return {
}, },
{ {
"alias": null, "alias": null,
"args": (v11/*: any*/), "args": (v10/*: any*/),
"concreteType": "VendorServiceConnection", "concreteType": "VendorServiceConnection",
"kind": "LinkedField", "kind": "LinkedField",
"name": "services", "name": "services",
@@ -650,28 +619,28 @@ return {
"name": "node", "name": "node",
"plural": false, "plural": false,
"selections": [ "selections": [
(v3/*: any*/), (v2/*: any*/),
(v5/*: any*/), (v4/*: any*/),
(v10/*: any*/), (v9/*: any*/),
(v21/*: any*/),
(v22/*: any*/), (v22/*: any*/),
(v23/*: any*/), (v8/*: any*/)
(v9/*: any*/)
], ],
"storageKey": null "storageKey": null
}, },
(v14/*: any*/) (v13/*: any*/)
], ],
"storageKey": null "storageKey": null
}, },
(v19/*: any*/), (v18/*: any*/),
(v20/*: any*/) (v19/*: any*/)
], ],
"storageKey": "services(first:50)" "storageKey": "services(first:50)"
}, },
{ {
"alias": null, "alias": null,
"args": (v11/*: any*/), "args": (v10/*: any*/),
"filters": (v21/*: any*/), "filters": (v20/*: any*/),
"handle": "connection", "handle": "connection",
"key": "VendorServicesTabFragment_services", "key": "VendorServicesTabFragment_services",
"kind": "LinkedHandle", "kind": "LinkedHandle",
@@ -679,7 +648,7 @@ return {
}, },
{ {
"alias": null, "alias": null,
"args": (v11/*: any*/), "args": (v10/*: any*/),
"concreteType": "VendorRiskAssessmentConnection", "concreteType": "VendorRiskAssessmentConnection",
"kind": "LinkedField", "kind": "LinkedField",
"name": "riskAssessments", "name": "riskAssessments",
@@ -701,8 +670,8 @@ return {
"name": "node", "name": "node",
"plural": false, "plural": false,
"selections": [ "selections": [
(v3/*: any*/), (v2/*: any*/),
(v22/*: any*/), (v21/*: any*/),
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -731,11 +700,11 @@ return {
"name": "notes", "name": "notes",
"storageKey": null "storageKey": null
}, },
(v9/*: any*/) (v8/*: any*/)
], ],
"storageKey": null "storageKey": null
}, },
(v14/*: any*/) (v13/*: any*/)
], ],
"storageKey": null "storageKey": null
}, },
@@ -747,21 +716,21 @@ return {
"name": "pageInfo", "name": "pageInfo",
"plural": false, "plural": false,
"selections": [ "selections": [
(v16/*: any*/),
(v15/*: any*/), (v15/*: any*/),
(v17/*: any*/), (v14/*: any*/),
(v18/*: any*/) (v16/*: any*/),
(v17/*: any*/)
], ],
"storageKey": null "storageKey": null
}, },
(v20/*: any*/) (v19/*: any*/)
], ],
"storageKey": "riskAssessments(first:50)" "storageKey": "riskAssessments(first:50)"
}, },
{ {
"alias": null, "alias": null,
"args": (v11/*: any*/), "args": (v10/*: any*/),
"filters": (v21/*: any*/), "filters": (v20/*: any*/),
"handle": "connection", "handle": "connection",
"key": "VendorRiskAssessmentTabFragment_riskAssessments", "key": "VendorRiskAssessmentTabFragment_riskAssessments",
"kind": "LinkedHandle", "kind": "LinkedHandle",
@@ -774,7 +743,7 @@ return {
"kind": "LinkedField", "kind": "LinkedField",
"name": "businessAssociateAgreement", "name": "businessAssociateAgreement",
"plural": false, "plural": false,
"selections": (v24/*: any*/), "selections": (v23/*: any*/),
"storageKey": null "storageKey": null
}, },
{ {
@@ -784,7 +753,7 @@ return {
"kind": "LinkedField", "kind": "LinkedField",
"name": "dataPrivacyAgreement", "name": "dataPrivacyAgreement",
"plural": false, "plural": false,
"selections": (v24/*: any*/), "selections": (v23/*: any*/),
"storageKey": null "storageKey": null
} }
], ],
@@ -802,24 +771,24 @@ return {
"name": "viewer", "name": "viewer",
"plural": false, "plural": false,
"selections": [ "selections": [
(v8/*: any*/), (v7/*: any*/),
(v3/*: any*/) (v2/*: any*/)
], ],
"storageKey": null "storageKey": null
} }
] ]
}, },
"params": { "params": {
"cacheID": "faee4f059ef9baab46d5f2420e36bb60", "cacheID": "b4e4f090951f025be6f359df3d2dc41a",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "VendorGraphNodeQuery", "name": "VendorGraphNodeQuery",
"operationKind": "query", "operationKind": "query",
"text": "query VendorGraphNodeQuery(\n $vendorId: ID!\n $organizationId: ID!\n) {\n node(id: $vendorId) {\n __typename\n ... on Vendor {\n id\n snapshotId\n name\n websiteUrl\n ...useVendorFormFragment\n ...VendorComplianceTabFragment\n ...VendorContactsTabFragment\n ...VendorServicesTabFragment\n ...VendorRiskAssessmentTabFragment\n ...VendorOverviewTabBusinessAssociateAgreementFragment\n ...VendorOverviewTabDataPrivacyAgreementFragment\n }\n id\n }\n viewer {\n user {\n id\n people(organizationId: $organizationId) {\n id\n }\n }\n id\n }\n}\n\nfragment VendorComplianceTabFragment on Vendor {\n complianceReports(first: 50) {\n edges {\n node {\n id\n ...VendorComplianceTabFragment_report\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment VendorComplianceTabFragment_report on VendorComplianceReport {\n id\n reportDate\n validUntil\n reportName\n file {\n fileName\n mimeType\n size\n id\n }\n}\n\nfragment VendorContactsTabFragment on Vendor {\n contacts(first: 50) {\n edges {\n node {\n id\n ...VendorContactsTabFragment_contact\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment VendorContactsTabFragment_contact on VendorContact {\n id\n fullName\n email\n phone\n role\n createdAt\n updatedAt\n}\n\nfragment VendorOverviewTabBusinessAssociateAgreementFragment on Vendor {\n businessAssociateAgreement {\n id\n fileName\n fileUrl\n validFrom\n validUntil\n createdAt\n }\n}\n\nfragment VendorOverviewTabDataPrivacyAgreementFragment on Vendor {\n dataPrivacyAgreement {\n id\n fileName\n fileUrl\n validFrom\n validUntil\n createdAt\n }\n}\n\nfragment VendorRiskAssessmentTabFragment on Vendor {\n id\n riskAssessments(first: 50) {\n edges {\n node {\n id\n ...VendorRiskAssessmentTabFragment_assessment\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n endCursor\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment VendorRiskAssessmentTabFragment_assessment on VendorRiskAssessment {\n id\n createdAt\n expiresAt\n dataSensitivity\n businessImpact\n notes\n}\n\nfragment VendorServicesTabFragment on Vendor {\n services(first: 50) {\n edges {\n node {\n id\n ...VendorServicesTabFragment_service\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment VendorServicesTabFragment_service on VendorService {\n id\n name\n description\n createdAt\n updatedAt\n}\n\nfragment useVendorFormFragment on Vendor {\n id\n name\n description\n category\n statusPageUrl\n termsOfServiceUrl\n privacyPolicyUrl\n serviceLevelAgreementUrl\n dataProcessingAgreementUrl\n websiteUrl\n legalName\n headquarterAddress\n certifications\n countries\n securityPageUrl\n trustPageUrl\n businessOwner {\n id\n }\n securityOwner {\n id\n }\n}\n" "text": "query VendorGraphNodeQuery(\n $vendorId: ID!\n) {\n node(id: $vendorId) {\n __typename\n ... on Vendor {\n id\n snapshotId\n name\n websiteUrl\n ...useVendorFormFragment\n ...VendorComplianceTabFragment\n ...VendorContactsTabFragment\n ...VendorServicesTabFragment\n ...VendorRiskAssessmentTabFragment\n ...VendorOverviewTabBusinessAssociateAgreementFragment\n ...VendorOverviewTabDataPrivacyAgreementFragment\n }\n id\n }\n viewer {\n user {\n id\n }\n id\n }\n}\n\nfragment VendorComplianceTabFragment on Vendor {\n complianceReports(first: 50) {\n edges {\n node {\n id\n ...VendorComplianceTabFragment_report\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment VendorComplianceTabFragment_report on VendorComplianceReport {\n id\n reportDate\n validUntil\n reportName\n file {\n fileName\n mimeType\n size\n id\n }\n}\n\nfragment VendorContactsTabFragment on Vendor {\n contacts(first: 50) {\n edges {\n node {\n id\n ...VendorContactsTabFragment_contact\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment VendorContactsTabFragment_contact on VendorContact {\n id\n fullName\n email\n phone\n role\n createdAt\n updatedAt\n}\n\nfragment VendorOverviewTabBusinessAssociateAgreementFragment on Vendor {\n businessAssociateAgreement {\n id\n fileName\n fileUrl\n validFrom\n validUntil\n createdAt\n }\n}\n\nfragment VendorOverviewTabDataPrivacyAgreementFragment on Vendor {\n dataPrivacyAgreement {\n id\n fileName\n fileUrl\n validFrom\n validUntil\n createdAt\n }\n}\n\nfragment VendorRiskAssessmentTabFragment on Vendor {\n id\n riskAssessments(first: 50) {\n edges {\n node {\n id\n ...VendorRiskAssessmentTabFragment_assessment\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n endCursor\n hasPreviousPage\n startCursor\n }\n }\n}\n\nfragment VendorRiskAssessmentTabFragment_assessment on VendorRiskAssessment {\n id\n createdAt\n expiresAt\n dataSensitivity\n businessImpact\n notes\n}\n\nfragment VendorServicesTabFragment on Vendor {\n services(first: 50) {\n edges {\n node {\n id\n ...VendorServicesTabFragment_service\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment VendorServicesTabFragment_service on VendorService {\n id\n name\n description\n createdAt\n updatedAt\n}\n\nfragment useVendorFormFragment on Vendor {\n id\n name\n description\n category\n statusPageUrl\n termsOfServiceUrl\n privacyPolicyUrl\n serviceLevelAgreementUrl\n dataProcessingAgreementUrl\n websiteUrl\n legalName\n headquarterAddress\n certifications\n countries\n securityPageUrl\n trustPageUrl\n businessOwner {\n id\n }\n securityOwner {\n id\n }\n}\n"
} }
}; };
})(); })();
(node as any).hash = "5b907e5cc3446745ff2414ee06839aed"; (node as any).hash = "0a13dd059626880c4a51f8a47b9c2423";
export default node; export default node;

View File

@@ -32,6 +32,8 @@ import {
IconPlusLarge, IconPlusLarge,
IconChevronDown, IconChevronDown,
Avatar, Avatar,
IconPeopleAdd,
Badge,
} from "@probo/ui"; } from "@probo/ui";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
@@ -85,6 +87,9 @@ const OrganizationSelectorFragment = graphql`
endCursor endCursor
} }
} }
invitations(first: 1, filter: {onlyPending: true}) {
totalCount
}
} }
`; `;
@@ -287,6 +292,7 @@ function OrganizationSelector({
); );
const organizations = data.organizations.edges.map((edge) => edge.node); const organizations = data.organizations.edges.map((edge) => edge.node);
const pendingInvitationsCount = data.invitations.totalCount;
const handleLoadMore = (e?: React.MouseEvent) => { const handleLoadMore = (e?: React.MouseEvent) => {
e?.preventDefault(); e?.preventDefault();
@@ -301,6 +307,7 @@ function OrganizationSelector({
}; };
return ( return (
<div className="flex items-center gap-1">
<Dropdown <Dropdown
toggle={ toggle={
<Button <Button
@@ -343,12 +350,36 @@ function OrganizationSelector({
)} )}
</div> </div>
<DropdownSeparator /> <DropdownSeparator />
<DropdownItem asChild icon={IconPlusLarge}> {pendingInvitationsCount > 0 && (
<DropdownItem asChild>
<Link to="/">
<IconPeopleAdd size={16} />
<span className="flex-1">{__("Invitations")}</span>
<Badge variant="info" size="sm">
{pendingInvitationsCount}
</Badge>
</Link>
</DropdownItem>
)}
<DropdownItem asChild>
<Link to="/organizations/new"> <Link to="/organizations/new">
<IconPlusLarge size={16} /> <IconPlusLarge size={16} />
{__("Add organization")} {__("Add organization")}
</Link> </Link>
</DropdownItem> </DropdownItem>
</Dropdown> </Dropdown>
{pendingInvitationsCount > 0 && (
<Link to="/" className="relative" title={__("Invitations")}>
<Button variant="tertiary" icon={IconPeopleAdd} />
<Badge
variant="info"
size="sm"
className="absolute -top-1 -right-1 min-w-[20px] h-5 flex items-center justify-center"
>
{pendingInvitationsCount}
</Badge>
</Link>
)}
</div>
); );
} }

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<92c96c7b8a58554e9fb99b86489e13d4>> * @generated SignedSource<<7288534d3b5e852c463af820a9001298>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -208,6 +208,37 @@ return {
"kind": "LinkedHandle", "kind": "LinkedHandle",
"name": "organizations" "name": "organizations"
}, },
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "filter",
"value": {
"onlyPending": true
}
},
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
"concreteType": "InvitationConnection",
"kind": "LinkedField",
"name": "invitations",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "totalCount",
"storageKey": null
}
],
"storageKey": "invitations(filter:{\"onlyPending\":true},first:1)"
},
(v4/*: any*/) (v4/*: any*/)
], ],
"storageKey": null "storageKey": null
@@ -215,16 +246,16 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "57fa65c9d5f23210b606fbf0b2005fb8", "cacheID": "77f3fbbca18dda24151bff9901c54626",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "MainLayoutOrganizationSelectorPaginationQuery", "name": "MainLayoutOrganizationSelectorPaginationQuery",
"operationKind": "query", "operationKind": "query",
"text": "query MainLayoutOrganizationSelectorPaginationQuery(\n $after: CursorKey\n $first: Int = 25\n) {\n viewer {\n ...MainLayout_OrganizationSelector_viewer_2HEEH6\n id\n }\n}\n\nfragment MainLayout_OrganizationSelector_viewer_2HEEH6 on Viewer {\n organizations(first: $first, after: $after, orderBy: {field: NAME, direction: ASC}) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n" "text": "query MainLayoutOrganizationSelectorPaginationQuery(\n $after: CursorKey\n $first: Int = 25\n) {\n viewer {\n ...MainLayout_OrganizationSelector_viewer_2HEEH6\n id\n }\n}\n\nfragment MainLayout_OrganizationSelector_viewer_2HEEH6 on Viewer {\n organizations(first: $first, after: $after, orderBy: {field: NAME, direction: ASC}) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n invitations(first: 1, filter: {onlyPending: true}) {\n totalCount\n }\n}\n"
} }
}; };
})(); })();
(node as any).hash = "0dc2841aeeb6ba5291cb45d8644ec4af"; (node as any).hash = "99a5cdcce2542bc7fba01d32bbd4648b";
export default node; export default node;

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<0719379cc0124f36eb1810ad24b2ae5a>> * @generated SignedSource<<cb335e1fd93543371a82adf23002f600>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -275,6 +275,37 @@ return {
"key": "MainLayout_OrganizationSelector_organizations", "key": "MainLayout_OrganizationSelector_organizations",
"kind": "LinkedHandle", "kind": "LinkedHandle",
"name": "organizations" "name": "organizations"
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "filter",
"value": {
"status": "PENDING"
}
},
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
"concreteType": "InvitationConnection",
"kind": "LinkedField",
"name": "invitations",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "totalCount",
"storageKey": null
}
],
"storageKey": "invitations(filter:{\"status\":\"PENDING\"},first:1)"
} }
], ],
"storageKey": null "storageKey": null
@@ -304,12 +335,12 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "6287440397ef427d84f8734b0953784c", "cacheID": "117a78fef8e7a8fe200baf1ebf2d2487",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "MainLayoutQuery", "name": "MainLayoutQuery",
"operationKind": "query", "operationKind": "query",
"text": "query MainLayoutQuery(\n $organizationId: ID!\n) {\n viewer {\n id\n user {\n fullName\n email\n id\n }\n ...MainLayout_OrganizationSelector_viewer\n }\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n name\n logoUrl\n }\n id\n }\n}\n\nfragment MainLayout_OrganizationSelector_viewer on Viewer {\n organizations(first: 25, orderBy: {field: NAME, direction: ASC}) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n}\n" "text": "query MainLayoutQuery(\n $organizationId: ID!\n) {\n viewer {\n id\n user {\n fullName\n email\n id\n }\n ...MainLayout_OrganizationSelector_viewer\n }\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n name\n logoUrl\n }\n id\n }\n}\n\nfragment MainLayout_OrganizationSelector_viewer on Viewer {\n organizations(first: 25, orderBy: {field: NAME, direction: ASC}) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n invitations(first: 1, filter: {status: PENDING}) {\n totalCount\n }\n}\n"
} }
}; };
})(); })();

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<2eda1054dbabd2bb5e45d8db5c7a01fc>> * @generated SignedSource<<bd521d62fb0f29ace7af511b527ef750>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -11,6 +11,9 @@
import { ReaderFragment } from 'relay-runtime'; import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime"; import { FragmentRefs } from "relay-runtime";
export type MainLayout_OrganizationSelector_viewer$data = { export type MainLayout_OrganizationSelector_viewer$data = {
readonly invitations: {
readonly totalCount: number;
};
readonly organizations: { readonly organizations: {
readonly edges: ReadonlyArray<{ readonly edges: ReadonlyArray<{
readonly node: { readonly node: {
@@ -178,6 +181,37 @@ return {
} }
], ],
"storageKey": "__MainLayout_OrganizationSelector_organizations_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"NAME\"})" "storageKey": "__MainLayout_OrganizationSelector_organizations_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"NAME\"})"
},
{
"alias": null,
"args": [
{
"kind": "Literal",
"name": "filter",
"value": {
"status": "PENDING"
}
},
{
"kind": "Literal",
"name": "first",
"value": 1
}
],
"concreteType": "InvitationConnection",
"kind": "LinkedField",
"name": "invitations",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "totalCount",
"storageKey": null
}
],
"storageKey": "invitations(filter:{\"status\":\"PENDING\"},first:1)"
} }
], ],
"type": "Viewer", "type": "Viewer",
@@ -185,6 +219,6 @@ return {
}; };
})(); })();
(node as any).hash = "0dc2841aeeb6ba5291cb45d8644ec4af"; (node as any).hash = "d1491a8228d455e2491724c03c7c30c1";
export default node; export default node;

View File

@@ -11,6 +11,8 @@ import {
IconPlusLarge, IconPlusLarge,
} from "@probo/ui"; } from "@probo/ui";
import { usePageTitle } from "@probo/hooks"; import { usePageTitle } from "@probo/hooks";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { formatDate } from "@probo/helpers";
const OrganizationsPageQuery = graphql` const OrganizationsPageQuery = graphql`
query OrganizationsPageQuery { query OrganizationsPageQuery {
@@ -25,6 +27,34 @@ const OrganizationsPageQuery = graphql`
} }
} }
} }
invitations(first: 1000, orderBy: {field: CREATED_AT, direction: DESC}, filter: {onlyPending: true}) @connection(key: "OrganizationsPage_invitations") {
__id
edges {
node {
id
email
fullName
role
expiresAt
acceptedAt
createdAt
organization {
id
name
}
}
}
}
}
}
`;
const acceptInvitationMutation = graphql`
mutation OrganizationsPage_AcceptInvitationMutation($input: AcceptInvitationInput!) {
acceptInvitation(input: $input) {
invitation {
id
}
} }
} }
`; `;
@@ -41,15 +71,40 @@ export default function OrganizationsPage() {
(edge) => edge.node (edge) => edge.node
); );
const pendingInvitations = data.viewer.invitations.edges.map(
(edge) => edge.node
);
const [acceptInvitation, isAccepting] = useMutationWithToasts(
acceptInvitationMutation,
{
successMessage: __("Invitation accepted successfully"),
errorMessage: __("Failed to accept invitation"),
}
);
const handleAcceptInvitation = (invitationId: string, organizationId: string) => {
acceptInvitation({
variables: {
input: {
invitationId,
},
},
onSuccess: () => {
navigate(`/organizations/${organizationId}`);
},
});
};
usePageTitle(__("Select an organization")); usePageTitle(__("Select an organization"));
useEffect(() => { useEffect(() => {
if (organizations.length === 1) { if (organizations.length === 1 && pendingInvitations.length === 0) {
navigate(`/organizations/${organizations[0].id}`); navigate(`/organizations/${organizations[0].id}`);
} else if (organizations.length === 0) { } else if (organizations.length === 0 && pendingInvitations.length === 0) {
navigate("/organizations/new"); navigate("/organizations/new");
} }
}, [organizations]); }, [organizations, pendingInvitations]);
return ( return (
<> <>
@@ -58,12 +113,36 @@ export default function OrganizationsPage() {
{__("Select an organization")} {__("Select an organization")}
</h1> </h1>
<div className="space-y-4 w-full"> <div className="space-y-4 w-full">
{pendingInvitations.length > 0 && (
<div className="space-y-3">
<h2 className="text-xl font-semibold">
{__("Pending invitations")}
</h2>
{pendingInvitations.map((invitation) => (
<InvitationCard
key={invitation.id}
invitation={invitation}
onAccept={handleAcceptInvitation}
isAccepting={isAccepting}
/>
))}
</div>
)}
{organizations.length > 0 && (
<div className="space-y-3">
{pendingInvitations.length > 0 && (
<h2 className="text-xl font-semibold">
{__("Your organizations")}
</h2>
)}
{organizations.map((organization) => ( {organizations.map((organization) => (
<OrganizationCard <OrganizationCard
key={organization.id} key={organization.id}
organization={organization} organization={organization}
/> />
))} ))}
</div>
)}
<Card padded> <Card padded>
<h2 className="text-xl font-semibold mb-1"> <h2 className="text-xl font-semibold mb-1">
{__("Create an organization")} {__("Create an organization")}
@@ -86,6 +165,51 @@ export default function OrganizationsPage() {
); );
} }
type InvitationCardProps = {
invitation: {
id: string;
email: string;
fullName: string;
role: string;
expiresAt: string;
createdAt: string;
organization: {
id: string;
name: string;
};
};
onAccept: (invitationId: string, organizationId: string) => void;
isAccepting: boolean;
};
function InvitationCard({ invitation, onAccept, isAccepting }: InvitationCardProps) {
const { __ } = useTranslate();
return (
<Card padded className="w-full">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-1">
<h3 className="text-lg font-semibold">
{invitation.organization.name}
</h3>
<p className="text-sm text-txt-secondary">
{__("Role")}: <span className="font-medium">{invitation.role}</span>
</p>
<p className="text-xs text-txt-tertiary">
{__("Invited on")} {formatDate(invitation.createdAt)}
</p>
</div>
<Button
onClick={() => onAccept(invitation.id, invitation.organization.id)}
disabled={isAccepting}
>
{isAccepting ? __("Accepting...") : __("Accept invitation")}
</Button>
</div>
</Card>
);
}
type OrganizationCardProps = { type OrganizationCardProps = {
organization: { organization: {
id: string; id: string;

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<84d0e43eced78862d1c82603e9c332e8>> * @generated SignedSource<<c25588b7506704dd23d9c8be8672f9ef>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -12,6 +12,24 @@ import { ConcreteRequest } from 'relay-runtime';
export type OrganizationsPageQuery$variables = Record<PropertyKey, never>; export type OrganizationsPageQuery$variables = Record<PropertyKey, never>;
export type OrganizationsPageQuery$data = { export type OrganizationsPageQuery$data = {
readonly viewer: { readonly viewer: {
readonly invitations: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly acceptedAt: any | null | undefined;
readonly createdAt: any;
readonly email: string;
readonly expiresAt: any;
readonly fullName: string;
readonly id: string;
readonly organization: {
readonly id: string;
readonly name: string;
};
readonly role: string;
};
}>;
};
readonly organizations: { readonly organizations: {
readonly __id: string; readonly __id: string;
readonly edges: ReadonlyArray<{ readonly edges: ReadonlyArray<{
@@ -45,59 +63,28 @@ v1 = {
"name": "id", "name": "id",
"storageKey": null "storageKey": null
}, },
v2 = [ v2 = {
{
"alias": null,
"args": null,
"concreteType": "OrganizationEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "name", "name": "name",
"storageKey": null "storageKey": null
}, },
{ v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
},
{
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "__typename", "name": "__typename",
"storageKey": null "storageKey": null
}
],
"storageKey": null
}, },
{ v4 = {
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "cursor", "name": "cursor",
"storageKey": null "storageKey": null
}
],
"storageKey": null
}, },
{ v5 = {
"alias": null, "alias": null,
"args": null, "args": null,
"concreteType": "PageInfo", "concreteType": "PageInfo",
@@ -122,7 +109,7 @@ v2 = [
], ],
"storageKey": null "storageKey": null
}, },
{ v6 = {
"kind": "ClientExtension", "kind": "ClientExtension",
"selections": [ "selections": [
{ {
@@ -133,15 +120,156 @@ v2 = [
"storageKey": null "storageKey": null
} }
] ]
} },
], v7 = [
v3 = [
{ {
"alias": null,
"args": null,
"concreteType": "OrganizationEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "logoUrl",
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
(v4/*: any*/)
],
"storageKey": null
},
(v5/*: any*/),
(v6/*: any*/)
],
v8 = {
"kind": "Literal",
"name": "filter",
"value": {
"onlyPending": true
}
},
v9 = {
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "DESC",
"field": "CREATED_AT"
}
},
v10 = [
{
"alias": null,
"args": null,
"concreteType": "InvitationEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Invitation",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "role",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "expiresAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "acceptedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "Organization",
"kind": "LinkedField",
"name": "organization",
"plural": false,
"selections": [
(v1/*: any*/),
(v2/*: any*/)
],
"storageKey": null
},
(v3/*: any*/)
],
"storageKey": null
},
(v4/*: any*/)
],
"storageKey": null
},
(v5/*: any*/),
(v6/*: any*/)
],
v11 = {
"kind": "Literal", "kind": "Literal",
"name": "first", "name": "first",
"value": 1000 "value": 1000
}, },
v12 = [
(v11/*: any*/),
(v0/*: any*/) (v0/*: any*/)
],
v13 = [
(v8/*: any*/),
(v11/*: any*/),
(v9/*: any*/)
]; ];
return { return {
"fragment": { "fragment": {
@@ -167,8 +295,21 @@ return {
"kind": "LinkedField", "kind": "LinkedField",
"name": "__OrganizationsPage_organizations_connection", "name": "__OrganizationsPage_organizations_connection",
"plural": false, "plural": false,
"selections": (v2/*: any*/), "selections": (v7/*: any*/),
"storageKey": "__OrganizationsPage_organizations_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"NAME\"})" "storageKey": "__OrganizationsPage_organizations_connection(orderBy:{\"direction\":\"ASC\",\"field\":\"NAME\"})"
},
{
"alias": "invitations",
"args": [
(v8/*: any*/),
(v9/*: any*/)
],
"concreteType": "InvitationConnection",
"kind": "LinkedField",
"name": "__OrganizationsPage_invitations_connection",
"plural": false,
"selections": (v10/*: any*/),
"storageKey": "__OrganizationsPage_invitations_connection(filter:{\"onlyPending\":true},orderBy:{\"direction\":\"DESC\",\"field\":\"CREATED_AT\"})"
} }
], ],
"storageKey": null "storageKey": null
@@ -193,17 +334,17 @@ return {
"selections": [ "selections": [
{ {
"alias": null, "alias": null,
"args": (v3/*: any*/), "args": (v12/*: any*/),
"concreteType": "OrganizationConnection", "concreteType": "OrganizationConnection",
"kind": "LinkedField", "kind": "LinkedField",
"name": "organizations", "name": "organizations",
"plural": false, "plural": false,
"selections": (v2/*: any*/), "selections": (v7/*: any*/),
"storageKey": "organizations(first:1000,orderBy:{\"direction\":\"ASC\",\"field\":\"NAME\"})" "storageKey": "organizations(first:1000,orderBy:{\"direction\":\"ASC\",\"field\":\"NAME\"})"
}, },
{ {
"alias": null, "alias": null,
"args": (v3/*: any*/), "args": (v12/*: any*/),
"filters": [ "filters": [
"orderBy" "orderBy"
], ],
@@ -212,6 +353,28 @@ return {
"kind": "LinkedHandle", "kind": "LinkedHandle",
"name": "organizations" "name": "organizations"
}, },
{
"alias": null,
"args": (v13/*: any*/),
"concreteType": "InvitationConnection",
"kind": "LinkedField",
"name": "invitations",
"plural": false,
"selections": (v10/*: any*/),
"storageKey": "invitations(filter:{\"onlyPending\":true},first:1000,orderBy:{\"direction\":\"DESC\",\"field\":\"CREATED_AT\"})"
},
{
"alias": null,
"args": (v13/*: any*/),
"filters": [
"orderBy",
"filter"
],
"handle": "connection",
"key": "OrganizationsPage_invitations",
"kind": "LinkedHandle",
"name": "invitations"
},
(v1/*: any*/) (v1/*: any*/)
], ],
"storageKey": null "storageKey": null
@@ -219,7 +382,7 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "1735764e6816660969c5f96922320ac5", "cacheID": "5675b3eb7810ef04bf531d7bf988c86c",
"id": null, "id": null,
"metadata": { "metadata": {
"connection": [ "connection": [
@@ -231,16 +394,25 @@ return {
"viewer", "viewer",
"organizations" "organizations"
] ]
},
{
"count": null,
"cursor": null,
"direction": "forward",
"path": [
"viewer",
"invitations"
]
} }
] ]
}, },
"name": "OrganizationsPageQuery", "name": "OrganizationsPageQuery",
"operationKind": "query", "operationKind": "query",
"text": "query OrganizationsPageQuery {\n viewer {\n organizations(first: 1000, orderBy: {field: NAME, direction: ASC}) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n id\n }\n}\n" "text": "query OrganizationsPageQuery {\n viewer {\n organizations(first: 1000, orderBy: {field: NAME, direction: ASC}) {\n edges {\n node {\n id\n name\n logoUrl\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n invitations(first: 1000, orderBy: {field: CREATED_AT, direction: DESC}, filter: {onlyPending: true}) {\n edges {\n node {\n id\n email\n fullName\n role\n expiresAt\n acceptedAt\n createdAt\n organization {\n id\n name\n }\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n id\n }\n}\n"
} }
}; };
})(); })();
(node as any).hash = "6fde39384e4678f88f17c34bbd30e684"; (node as any).hash = "a548f9c3a434e12c079fb2ff651822d2";
export default node; export default node;

View File

@@ -0,0 +1,105 @@
/**
* @generated SignedSource<<b883cd0523e827b2c71b89e1d249d6dd>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type AcceptInvitationInput = {
invitationId: string;
};
export type OrganizationsPage_AcceptInvitationMutation$variables = {
input: AcceptInvitationInput;
};
export type OrganizationsPage_AcceptInvitationMutation$data = {
readonly acceptInvitation: {
readonly invitation: {
readonly id: string;
};
};
};
export type OrganizationsPage_AcceptInvitationMutation = {
response: OrganizationsPage_AcceptInvitationMutation$data;
variables: OrganizationsPage_AcceptInvitationMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "AcceptInvitationPayload",
"kind": "LinkedField",
"name": "acceptInvitation",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Invitation",
"kind": "LinkedField",
"name": "invitation",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "OrganizationsPage_AcceptInvitationMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "OrganizationsPage_AcceptInvitationMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "cc4a442037edaa624948b5be0c009823",
"id": null,
"metadata": {},
"name": "OrganizationsPage_AcceptInvitationMutation",
"operationKind": "mutation",
"text": "mutation OrganizationsPage_AcceptInvitationMutation(\n $input: AcceptInvitationInput!\n) {\n acceptInvitation(input: $input) {\n invitation {\n id\n }\n }\n}\n"
}
};
})();
(node as any).hash = "190213ab6fdc068343a270b4fa94e160";
export default node;

View File

@@ -1,35 +1,49 @@
import { Link, useNavigate } from "react-router"; import { Link, useNavigate, useSearchParams } from "react-router";
import { Button, Field, useToast } from "@probo/ui"; import { Button, Field, useToast } from "@probo/ui";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { z } from "zod"; import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema"; import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { usePageTitle } from "@probo/hooks"; import { usePageTitle } from "@probo/hooks";
import { buildEndpoint } from "/providers/RelayProviders"; import { buildEndpoint } from "/providers/RelayProviders";
import { useEffect } from "react";
const schema = z.object({ const schema = z.object({
fullName: z.string().min(2),
password: z.string().min(8), password: z.string().min(8),
}); });
export default function ConfirmInvitationPage() { export default function SignupFromInvitationPage() {
const { __ } = useTranslate(); const { __ } = useTranslate();
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const { register, handleSubmit, formState } = useFormWithSchema( const [searchParams] = useSearchParams();
const { register, handleSubmit, formState, reset } = useFormWithSchema(
schema, schema,
{ {
defaultValues: { defaultValues: {
fullName: "",
password: "", password: "",
}, },
} }
); );
useEffect(() => {
const fullNameFromParams = searchParams.get("fullName") || "";
if (fullNameFromParams) {
reset({
fullName: fullNameFromParams,
password: "",
});
}
}, [searchParams, reset]);
const onSubmit = handleSubmit(async (data) => { const onSubmit = handleSubmit(async (data) => {
const searchParams = new URLSearchParams(location.search);
const token = searchParams.get("token"); const token = searchParams.get("token");
if (!token) { if (!token) {
toast({ toast({
title: __("Confirmation failed"), title: __("Signup failed"),
description: __("Invalid or missing invitation token"), description: __("Invalid or missing invitation token"),
variant: "error", variant: "error",
}); });
@@ -37,7 +51,7 @@ export default function ConfirmInvitationPage() {
} }
const response = await fetch( const response = await fetch(
buildEndpoint("/api/console/v1/auth/invitation"), buildEndpoint("/api/console/v1/auth/signup-from-invitation"),
{ {
method: "POST", method: "POST",
headers: { headers: {
@@ -47,16 +61,16 @@ export default function ConfirmInvitationPage() {
body: JSON.stringify({ body: JSON.stringify({
token: token, token: token,
password: data.password, password: data.password,
fullName: data.fullName,
}), }),
} }
); );
// Registration failed
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
toast({ toast({
title: __("Confirmation failed"), title: __("Signup failed"),
description: errorData.message || __("Confirmation failed"), description: errorData.message || __("Signup failed"),
variant: "error", variant: "error",
}); });
return; return;
@@ -64,24 +78,33 @@ export default function ConfirmInvitationPage() {
toast({ toast({
title: __("Success"), title: __("Success"),
description: __("Invitation confirmed successfully"), description: __("Account created successfully. Please accept your invitation to join the organization."),
variant: "success", variant: "success",
}); });
navigate("/", { replace: true }); navigate("/", { replace: true });
}); });
usePageTitle(__("Confirm invitation")); usePageTitle(__("Create your account"));
return ( return (
<div className="space-y-6 w-full max-w-md mx-auto"> <div className="space-y-6 w-full max-w-md mx-auto">
<div className="space-y-2 text-center"> <div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">{__("Confirm invitation")}</h1> <h1 className="text-3xl font-bold">{__("Create your account")}</h1>
<p className="text-txt-tertiary"> <p className="text-txt-tertiary">
{__("Enter your information to confirm your invitation")} {__("Set your password to join the organization")}
</p> </p>
</div> </div>
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={onSubmit} className="space-y-4">
<Field
label={__("Full Name")}
type="text"
placeholder={__("John Doe")}
{...register("fullName")}
required
error={formState.errors.fullName?.message}
/>
<Field <Field
label={__("Password")} label={__("Password")}
type="password" type="password"
@@ -93,8 +116,8 @@ export default function ConfirmInvitationPage() {
<Button type="submit" className="w-full" disabled={formState.isLoading}> <Button type="submit" className="w-full" disabled={formState.isLoading}>
{formState.isLoading {formState.isLoading
? __("Confirming invitation...") ? __("Creating account...")
: __("Confirm invitation")} : __("Create account")}
</Button> </Button>
</form> </form>

View File

@@ -50,13 +50,13 @@ import { sprintf } from "@probo/helpers";
import { useFormWithSchema } from "/hooks/useFormWithSchema"; import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { z } from "zod"; import { z } from "zod";
import type { NodeOf } from "/types"; import type { NodeOf } from "/types";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { useOrganizationId } from "/hooks/useOrganizationId"; import { useOrganizationId } from "/hooks/useOrganizationId";
import { InviteUserDialog } from "/components/organizations/InviteUserDialog"; import { InviteUserDialog } from "/components/organizations/InviteUserDialog";
import { useDeleteOrganizationMutation } from "/hooks/graph/OrganizationGraph"; import { useDeleteOrganizationMutation } from "/hooks/graph/OrganizationGraph";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { DeleteOrganizationDialog } from "/components/organizations/DeleteOrganizationDialog"; import { DeleteOrganizationDialog } from "/components/organizations/DeleteOrganizationDialog";
import { CustomDomainManager } from "/components/customDomains/CustomDomainManager"; import { CustomDomainManager } from "/components/customDomains/CustomDomainManager";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
const organizationSchema = z.object({ const organizationSchema = z.object({
name: z.string().min(1, "Organization name is required"), name: z.string().min(1, "Organization name is required"),
@@ -220,6 +220,7 @@ const deleteHorizontalLogoMutation = graphql`
export default function SettingsPage({ queryRef }: Props) { export default function SettingsPage({ queryRef }: Props) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const navigate = useNavigate(); const navigate = useNavigate();
const organizationId = useOrganizationId();
const organizationKey = usePreloadedQuery( const organizationKey = usePreloadedQuery(
organizationViewQuery, organizationViewQuery,
queryRef queryRef
@@ -240,6 +241,14 @@ export default function SettingsPage({ queryRef }: Props) {
organizationKey as SettingsPageInvitationsFragment$key organizationKey as SettingsPageInvitationsFragment$key
); );
const refetchMemberships = () => {
membershipsPagination.refetch({}, { fetchPolicy: 'network-only' });
};
const refetchInvitations = () => {
invitationsPagination.refetch({}, { fetchPolicy: 'network-only' });
};
const [updateOrganization] = useMutation(updateOrganizationMutation); const [updateOrganization] = useMutation(updateOrganizationMutation);
const [deleteHorizontalLogo, isDeletingHorizontalLogo] = useMutationWithToasts( const [deleteHorizontalLogo, isDeletingHorizontalLogo] = useMutationWithToasts(
deleteHorizontalLogoMutation, deleteHorizontalLogoMutation,
@@ -253,24 +262,6 @@ export default function SettingsPage({ queryRef }: Props) {
const invitations = invitationsPagination.data.invitations?.edges.map((edge) => edge.node) || []; const invitations = invitationsPagination.data.invitations?.edges.map((edge) => edge.node) || [];
const [activeTab, setActiveTab] = useState<"memberships" | "invitations">("memberships"); const [activeTab, setActiveTab] = useState<"memberships" | "invitations">("memberships");
const refetchMemberships = ({ order }: { order: { direction: string; field: string } }) => {
membershipsPagination.refetch({
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as "CREATED_AT" | "FULL_NAME" | "EMAIL_ADDRESS" | "ROLE"
}
});
};
const refetchInvitations = ({ order }: { order: { direction: string; field: string } }) => {
invitationsPagination.refetch({
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as "CREATED_AT" | "EXPIRES_AT" | "FULL_NAME" | "EMAIL" | "ROLE" | "STATUS" | "ACCEPTED_AT"
}
});
};
const { formState, handleSubmit, register, reset } = useFormWithSchema( const { formState, handleSubmit, register, reset } = useFormWithSchema(
organizationSchema, organizationSchema,
{ {
@@ -572,7 +563,10 @@ export default function SettingsPage({ queryRef }: Props) {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-base font-medium">{__("Workspace members")}</h2> <h2 className="text-base font-medium">{__("Workspace members")}</h2>
<InviteUserDialog connectionId={invitationsPagination.data.invitations?.__id}> <InviteUserDialog
connectionId={invitationsPagination.data.invitations?.__id}
onRefetch={refetchInvitations}
>
<Button variant="secondary">{__("Invite member")}</Button> <Button variant="secondary">{__("Invite member")}</Button>
</InviteUserDialog> </InviteUserDialog>
</div> </div>
@@ -603,7 +597,14 @@ export default function SettingsPage({ queryRef }: Props) {
{activeTab === "memberships" && ( {activeTab === "memberships" && (
<SortableTable <SortableTable
{...membershipsPagination} {...membershipsPagination}
refetch={refetchMemberships} refetch={({ order }: { order: { direction: string; field: string } }) => {
membershipsPagination.refetch({
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as "CREATED_AT" | "FULL_NAME" | "EMAIL_ADDRESS" | "ROLE"
}
});
}}
> >
<Thead> <Thead>
<Tr> <Tr>
@@ -623,7 +624,13 @@ export default function SettingsPage({ queryRef }: Props) {
</Tr> </Tr>
) : ( ) : (
memberships.map((membership) => ( memberships.map((membership) => (
<MembershipRow key={membership.id} membership={membership} /> <MembershipRow
key={membership.id}
membership={membership}
connectionId={membershipsPagination.data.memberships?.__id}
organizationId={organizationId}
onRefetch={refetchMemberships}
/>
)) ))
)} )}
</Tbody> </Tbody>
@@ -633,7 +640,14 @@ export default function SettingsPage({ queryRef }: Props) {
{activeTab === "invitations" && ( {activeTab === "invitations" && (
<SortableTable <SortableTable
{...invitationsPagination} {...invitationsPagination}
refetch={refetchInvitations} refetch={({ order }: { order: { direction: string; field: string } }) => {
invitationsPagination.refetch({
order: {
direction: order.direction as "ASC" | "DESC",
field: order.field as "CREATED_AT" | "EXPIRES_AT" | "FULL_NAME" | "EMAIL" | "ROLE" | "STATUS" | "ACCEPTED_AT"
}
});
}}
> >
<Thead> <Thead>
<Tr> <Tr>
@@ -659,6 +673,8 @@ export default function SettingsPage({ queryRef }: Props) {
key={invitation.id} key={invitation.id}
invitation={invitation} invitation={invitation}
connectionId={invitationsPagination.data.invitations?.__id} connectionId={invitationsPagination.data.invitations?.__id}
organizationId={organizationId}
onRefetch={refetchInvitations}
/> />
)) ))
)} )}
@@ -794,9 +810,12 @@ function Connectors(props: {
} }
const removeMemberMutation = graphql` const removeMemberMutation = graphql`
mutation SettingsPage_RemoveMemberMutation($input: RemoveMemberInput!) { mutation SettingsPage_RemoveMemberMutation(
$input: RemoveMemberInput!
$connections: [ID!]!
) {
removeMember(input: $input) { removeMember(input: $input) {
success deletedMemberId @deleteEdge(connections: $connections)
} }
} }
`; `;
@@ -804,16 +823,16 @@ const removeMemberMutation = graphql`
function InvitationRow(props: { function InvitationRow(props: {
invitation: NodeOf<SettingsPageInvitationsFragment$data["invitations"]>; invitation: NodeOf<SettingsPageInvitationsFragment$data["invitations"]>;
connectionId?: string; connectionId?: string;
organizationId: string;
onRefetch: () => void;
}) { }) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const confirm = useConfirm(); const confirm = useConfirm();
const [deleteInvitation, isDeleting] = useMutationWithToasts( const [deleteInvitation, isDeleting] = useMutationWithToasts(
deleteInvitationMutation, deleteInvitationMutation,
{ {
successMessage: sprintf( successMessage: __("Invitation deleted successfully"),
__("Invitation for %s deleted successfully"), errorMessage: __("Failed to delete invitation"),
props.invitation.fullName
),
} }
); );
@@ -830,6 +849,9 @@ function InvitationRow(props: {
}, },
connections: props.connectionId ? [props.connectionId] : [], connections: props.connectionId ? [props.connectionId] : [],
}, },
onCompleted: () => {
props.onRefetch();
},
}); });
}, },
{ {
@@ -885,15 +907,16 @@ function InvitationRow(props: {
); );
} }
function MembershipRow(props: { membership: NodeOf<SettingsPageMembershipsFragment$data["memberships"]> }) { function MembershipRow(props: {
membership: NodeOf<SettingsPageMembershipsFragment$data["memberships"]>;
connectionId?: string;
organizationId: string;
onRefetch: () => void;
}) {
const { __ } = useTranslate(); const { __ } = useTranslate();
const organizationId = useOrganizationId();
const [removeMember, isRemoving] = useMutationWithToasts(removeMemberMutation, { const [removeMember, isRemoving] = useMutationWithToasts(removeMemberMutation, {
successMessage: sprintf( successMessage: __("Member removed successfully"),
__("Member %s removed successfully"), errorMessage: __("Failed to remove member"),
props.membership.fullName
),
errorMessage: sprintf(__("Failed to remove member %s"), props.membership.fullName),
}); });
const confirm = useConfirm(); const confirm = useConfirm();
const [isRemoved, setIsRemoved] = useState(false); const [isRemoved, setIsRemoved] = useState(false);
@@ -909,11 +932,13 @@ function MembershipRow(props: { membership: NodeOf<SettingsPageMembershipsFragme
variables: { variables: {
input: { input: {
memberId: props.membership.id, memberId: props.membership.id,
organizationId: organizationId, organizationId: props.organizationId,
}, },
connections: props.connectionId ? [props.connectionId] : [],
}, },
onSuccess: () => { onCompleted: () => {
setIsRemoved(true); setIsRemoved(true);
props.onRefetch();
}, },
}); });
}, },

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<4f099d1ee6b4635ca8129eca77d3f7e8>> * @generated SignedSource<<09561c2c29459fc840773b414af6083a>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -9,6 +9,7 @@
// @ts-nocheck // @ts-nocheck
import { ReaderFragment } from 'relay-runtime'; import { ReaderFragment } from 'relay-runtime';
export type InvitationStatus = "ACCEPTED" | "EXPIRED" | "PENDING";
import { FragmentRefs } from "relay-runtime"; import { FragmentRefs } from "relay-runtime";
export type SettingsPageInvitationsFragment$data = { export type SettingsPageInvitationsFragment$data = {
readonly id: string; readonly id: string;
@@ -23,6 +24,7 @@ export type SettingsPageInvitationsFragment$data = {
readonly fullName: string; readonly fullName: string;
readonly id: string; readonly id: string;
readonly role: string; readonly role: string;
readonly status: InvitationStatus;
}; };
}>; }>;
readonly totalCount: number; readonly totalCount: number;
@@ -171,6 +173,13 @@ return {
"name": "role", "name": "role",
"storageKey": null "storageKey": null
}, },
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "status",
"storageKey": null
},
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -273,6 +282,6 @@ return {
}; };
})(); })();
(node as any).hash = "d971d93653991efde284ed2b87068698"; (node as any).hash = "f9a1ec38579cea21312ba0a20bb7394a";
export default node; export default node;

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<994eb713978ecef3329fd9873e4c744c>> * @generated SignedSource<<d210161405099a17aa25d06ca4563634>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -14,11 +14,12 @@ export type RemoveMemberInput = {
organizationId: string; organizationId: string;
}; };
export type SettingsPage_RemoveMemberMutation$variables = { export type SettingsPage_RemoveMemberMutation$variables = {
connections: ReadonlyArray<string>;
input: RemoveMemberInput; input: RemoveMemberInput;
}; };
export type SettingsPage_RemoveMemberMutation$data = { export type SettingsPage_RemoveMemberMutation$data = {
readonly removeMember: { readonly removeMember: {
readonly success: boolean; readonly deletedMemberId: string;
}; };
}; };
export type SettingsPage_RemoveMemberMutation = { export type SettingsPage_RemoveMemberMutation = {
@@ -27,67 +28,106 @@ export type SettingsPage_RemoveMemberMutation = {
}; };
const node: ConcreteRequest = (function(){ const node: ConcreteRequest = (function(){
var v0 = [ var v0 = {
{ "defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null, "defaultValue": null,
"kind": "LocalArgument", "kind": "LocalArgument",
"name": "input" "name": "input"
} },
], v2 = [
v1 = [
{
"alias": null,
"args": [
{ {
"kind": "Variable", "kind": "Variable",
"name": "input", "name": "input",
"variableName": "input" "variableName": "input"
} }
], ],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deletedMemberId",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "SettingsPage_RemoveMemberMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "RemoveMemberPayload", "concreteType": "RemoveMemberPayload",
"kind": "LinkedField", "kind": "LinkedField",
"name": "removeMember", "name": "removeMember",
"plural": false, "plural": false,
"selections": [ "selections": [
{ (v3/*: any*/)
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
], ],
"storageKey": null "storageKey": null
} }
]; ],
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "SettingsPage_RemoveMemberMutation",
"selections": (v1/*: any*/),
"type": "Mutation", "type": "Mutation",
"abstractKey": null "abstractKey": null
}, },
"kind": "Request", "kind": "Request",
"operation": { "operation": {
"argumentDefinitions": (v0/*: any*/), "argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation", "kind": "Operation",
"name": "SettingsPage_RemoveMemberMutation", "name": "SettingsPage_RemoveMemberMutation",
"selections": (v1/*: any*/) "selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "RemoveMemberPayload",
"kind": "LinkedField",
"name": "removeMember",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteEdge",
"key": "",
"kind": "ScalarHandle",
"name": "deletedMemberId",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
}, },
"params": { "params": {
"cacheID": "97e29046871ce8aab01abf98a62236fc", "cacheID": "e2dd0f4d7327ce3bc97754c85d3f700d",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "SettingsPage_RemoveMemberMutation", "name": "SettingsPage_RemoveMemberMutation",
"operationKind": "mutation", "operationKind": "mutation",
"text": "mutation SettingsPage_RemoveMemberMutation(\n $input: RemoveMemberInput!\n) {\n removeMember(input: $input) {\n success\n }\n}\n" "text": "mutation SettingsPage_RemoveMemberMutation(\n $input: RemoveMemberInput!\n) {\n removeMember(input: $input) {\n deletedMemberId\n }\n}\n"
} }
}; };
})(); })();
(node as any).hash = "f61071a0fb6f6554e56e79b6b04bc135"; (node as any).hash = "9909a8b95f8d8621ffdf02da34ec8da2";
export default node; export default node;

View File

@@ -74,8 +74,8 @@ const routes = [
Component: lazy(() => import("./pages/auth/ConfirmEmailPage")), Component: lazy(() => import("./pages/auth/ConfirmEmailPage")),
}, },
{ {
path: "confirm-invitation", path: "signup-from-invitation",
Component: lazy(() => import("./pages/auth/ConfirmInvitationPage")), Component: lazy(() => import("./pages/auth/SignupFromInvitationPage")),
}, },
{ {
path: "forgot-password", path: "forgot-password",

View File

@@ -30,10 +30,9 @@ export const vendorRoutes = [
{ {
path: "vendors/:vendorId", path: "vendors/:vendorId",
fallback: PageSkeleton, fallback: PageSkeleton,
queryLoader: ({ vendorId, organizationId }) => queryLoader: ({ vendorId }) =>
loadQuery(relayEnvironment, vendorNodeQuery, { loadQuery(relayEnvironment, vendorNodeQuery, {
vendorId, vendorId,
organizationId,
}), }),
Component: lazy( Component: lazy(
() => import("../pages/organizations/vendors/VendorDetailPage") () => import("../pages/organizations/vendors/VendorDetailPage")
@@ -95,10 +94,9 @@ export const vendorRoutes = [
{ {
path: "snapshots/:snapshotId/vendors/:vendorId", path: "snapshots/:snapshotId/vendors/:vendorId",
fallback: PageSkeleton, fallback: PageSkeleton,
queryLoader: ({ vendorId, organizationId }) => queryLoader: ({ vendorId }) =>
loadQuery(relayEnvironment, vendorNodeQuery, { loadQuery(relayEnvironment, vendorNodeQuery, {
vendorId, vendorId,
organizationId,
}), }),
Component: lazy( Component: lazy(
() => import("../pages/organizations/vendors/VendorDetailPage") () => import("../pages/organizations/vendors/VendorDetailPage")

View File

@@ -1,9 +1,7 @@
import logo from "../assets/android-chrome-512x512.png";
import { useTranslate } from "@probo/i18n";
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import { Logo } from "../Atoms/Logo/Logo";
export function AuthLayout() { export function AuthLayout() {
const { __ } = useTranslate();
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-2 min-h-screen text-txt-primary"> <div className="grid grid-cols-1 lg:grid-cols-2 min-h-screen text-txt-primary">
<div className="bg-level-0 flex flex-col items-center justify-center"> <div className="bg-level-0 flex flex-col items-center justify-center">
@@ -11,13 +9,9 @@ export function AuthLayout() {
<Outlet /> <Outlet />
</div> </div>
</div> </div>
<div className="hidden lg:flex bg-dialog text-invert text-5xl font-bold flex flex-col items-center justify-center p-8 text-txt-primary lg:p-10"> <div className="hidden lg:flex bg-dialog font-bold flex flex-col items-center justify-center p-8 text-txt-primary lg:p-10">
<div className="flex flex-col 2xl:flex-row-reverse items-center justify-center gap-4"> <div className="flex flex-col items-center justify-center gap-4">
<img src={logo} alt="Probo logo" className="h-auto w-96" /> <Logo withPicto className="w-[440px]" />
<span>
{__("Navigate compliance with confidence thanks to")}
<span className="text-txt-accent"> probo</span>
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -600,3 +600,107 @@ func (s Service) ResetPassword(ctx context.Context, tokenString string, newPassw
}, },
) )
} }
func (s Service) SignupFromInvitation(
ctx context.Context,
token string,
password string,
fullName string,
) (*coredata.User, *coredata.Session, error) {
payload, err := statelesstoken.ValidateToken[coredata.InvitationData](
s.tokenSecret,
"organization_invitation",
token,
)
if err != nil {
return nil, nil, &ErrInvalidTokenType{"invalid invitation token"}
}
invitationData := payload.Data
if len(password) < 8 || len(password) > 128 {
return nil, nil, &ErrInvalidPassword{minLength: 8, maxLength: 128}
}
if _, err := mail.ParseAddress(invitationData.Email); err != nil {
return nil, nil, &ErrInvalidEmail{invitationData.Email}
}
if fullName == "" {
fullName = invitationData.FullName
}
if fullName == "" {
return nil, nil, &ErrInvalidFullName{fullName}
}
hashedPassword, err := s.hp.HashPassword([]byte(password))
if err != nil {
return nil, nil, fmt.Errorf("cannot hash password: %w", err)
}
var user *coredata.User
var session *coredata.Session
scope := coredata.NewScope(invitationData.InvitationID.TenantID())
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, tx, scope, invitationData.InvitationID); err != nil {
var errInvitationNotFound *coredata.ErrInvitationNotFound
if errors.As(err, &errInvitationNotFound) {
return fmt.Errorf("invitation was deleted or no longer exists")
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.AcceptedAt != nil {
return fmt.Errorf("invitation already accepted")
}
if time.Now().After(invitation.ExpiresAt) {
return fmt.Errorf("invitation expired")
}
now := time.Now()
user = &coredata.User{
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
EmailAddress: invitationData.Email,
HashedPassword: hashedPassword,
EmailAddressVerified: true,
FullName: fullName,
CreatedAt: now,
UpdatedAt: now,
}
if err := user.Insert(ctx, tx); err != nil {
var errUserAlreadyExists *coredata.ErrUserAlreadyExists
if errors.As(err, &errUserAlreadyExists) {
return &ErrUserAlreadyExists{errUserAlreadyExists.Error()}
}
return fmt.Errorf("cannot insert user: %w", err)
}
session = &coredata.Session{
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
UserID: user.ID,
Data: coredata.SessionData{},
ExpiredAt: now.Add(24 * time.Hour * 7),
CreatedAt: now,
UpdatedAt: now,
}
if err := session.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert session: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return user, session, nil
}

View File

@@ -20,7 +20,8 @@ import (
_ "embed" _ "embed"
"errors" "errors"
"fmt" "fmt"
"html/template" "net/url"
"text/template"
"time" "time"
"github.com/getprobo/probo/pkg/coredata" "github.com/getprobo/probo/pkg/coredata"
@@ -41,6 +42,14 @@ type (
invitationTokenValidity time.Duration invitationTokenValidity time.Duration
} }
TenantAuthzService struct {
pg *pg.Client
hostname string
tokenSecret string
invitationTokenValidity time.Duration
scope coredata.Scoper
}
Role string Role string
) )
@@ -78,6 +87,18 @@ func NewService(
}, nil }, nil
} }
func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthzService {
return &TenantAuthzService{
pg: s.pg,
hostname: s.hostname,
tokenSecret: s.tokenSecret,
invitationTokenValidity: s.invitationTokenValidity,
scope: coredata.NewScope(tenantID),
}
}
// This method is on Service (not TenantAuthzService) because it operates across tenants
// and doesn't require tenant-scoped access.
func (s *Service) GetAllUserOrganizations( func (s *Service) GetAllUserOrganizations(
ctx context.Context, ctx context.Context,
userID gid.GID, userID gid.GID,
@@ -98,6 +119,8 @@ func (s *Service) GetAllUserOrganizations(
return organizations, err return organizations, err
} }
// This method is on Service (not TenantAuthzService) because it operates across tenants
// and doesn't require tenant-scoped access.
func (s *Service) GetUserOrganizations( func (s *Service) GetUserOrganizations(
ctx context.Context, ctx context.Context,
userID gid.GID, userID gid.GID,
@@ -115,7 +138,253 @@ func (s *Service) GetUserOrganizations(
return organizations, err return organizations, err
} }
func (s *Service) GetAllOrganizationInvitations( // This method is on Service (not TenantAuthzService) because the user accepting
// the invitation doesn't have tenant access yet.
func (s *Service) AcceptInvitation(
ctx context.Context,
token string,
userID gid.GID,
) error {
payload, err := statelesstoken.ValidateToken[coredata.InvitationData](
s.tokenSecret,
TokenTypeOrganizationInvitation,
token,
)
if err != nil {
return fmt.Errorf("invalid invitation token: %w", err)
}
invitationData := payload.Data
scope := coredata.NewScope(invitationData.InvitationID.TenantID())
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, tx, scope, invitationData.InvitationID); err != nil {
var errInvitationNotFound *coredata.ErrInvitationNotFound
if errors.As(err, &errInvitationNotFound) {
return fmt.Errorf("invitation was deleted or no longer exists")
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.AcceptedAt != nil {
return fmt.Errorf("invitation already accepted")
}
if time.Now().After(invitation.ExpiresAt) {
return fmt.Errorf("invitation expired")
}
now := time.Now()
membershipID := gid.New(scope.GetTenantID(), coredata.MembershipEntityType)
membership := &coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: invitation.OrganizationID,
Role: invitation.Role,
CreatedAt: now,
UpdatedAt: now,
}
if err := membership.Create(ctx, tx, scope); err != nil {
return fmt.Errorf("failed to add user to organization: %w", err)
}
invitation.AcceptedAt = &now
if err := invitation.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("failed to mark invitation as accepted: %w", err)
}
return nil
},
)
}
// This method is on Service (not TenantAuthzService) because the user accepting
// the invitation doesn't have tenant access yet.
func (s *Service) AcceptInvitationByID(
ctx context.Context,
invitationID gid.GID,
userID gid.GID,
) (*coredata.Invitation, error) {
var acceptedInvitation *coredata.Invitation
scope := coredata.NewScope(invitationID.TenantID())
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, tx, scope, invitationID); err != nil {
var errInvitationNotFound *coredata.ErrInvitationNotFound
if errors.As(err, &errInvitationNotFound) {
return fmt.Errorf("invitation was deleted or no longer exists")
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.AcceptedAt != nil {
return fmt.Errorf("invitation already accepted")
}
if time.Now().After(invitation.ExpiresAt) {
return fmt.Errorf("invitation expired")
}
user := &coredata.User{}
if err := user.LoadByID(ctx, tx, userID); err != nil {
return fmt.Errorf("cannot load user: %w", err)
}
if invitation.Email != user.EmailAddress {
return fmt.Errorf("invitation email does not match user email")
}
now := time.Now()
membershipID := gid.New(scope.GetTenantID(), coredata.MembershipEntityType)
membership := &coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: invitation.OrganizationID,
Role: invitation.Role,
CreatedAt: now,
UpdatedAt: now,
}
if err := membership.Create(ctx, tx, scope); err != nil {
return fmt.Errorf("failed to add user to organization: %w", err)
}
invitation.AcceptedAt = &now
if err := invitation.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("failed to mark invitation as accepted: %w", err)
}
acceptedInvitation = invitation
return nil
},
)
if err != nil {
return nil, err
}
return acceptedInvitation, nil
}
// This method is on Service (not TenantAuthzService) because the user viewing
// their invitations doesn't have tenant access yet, and it operates across multiple tenants.
func (s *Service) GetUserInvitations(
ctx context.Context,
email string,
cursor *page.Cursor[coredata.InvitationOrderField],
filter *coredata.InvitationFilter,
) (*page.Page[*coredata.Invitation, coredata.InvitationOrderField], error) {
var invitations coredata.Invitations
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := invitations.LoadByEmail(ctx, conn, email, cursor, filter); err != nil {
return fmt.Errorf("failed to load invitations: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(invitations, cursor), nil
}
// This method is on Service (not TenantAuthzService) because the user viewing
// their invitations doesn't have tenant access yet, and it operates across multiple tenants.
func (s *Service) CountUserInvitations(
ctx context.Context,
email string,
filter *coredata.InvitationFilter,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var invitations coredata.Invitations
var err error
count, err = invitations.CountByEmail(ctx, conn, email, filter)
return err
},
)
return count, err
}
// This method is on Service (not TenantAuthzService) because the user viewing
// the invitation organization doesn't have tenant access yet.
func (s *Service) GetOrganizationByInvitationID(
ctx context.Context,
invitationID gid.GID,
) (*coredata.Organization, error) {
scope := coredata.NewScope(invitationID.TenantID())
var organization coredata.Organization
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var invitation coredata.Invitation
if err := invitation.LoadByID(ctx, conn, scope, invitationID); err != nil {
return fmt.Errorf("failed to load invitation: %w", err)
}
if err := organization.LoadByID(ctx, conn, scope, invitation.OrganizationID); err != nil {
return fmt.Errorf("failed to load organization: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &organization, nil
}
// This method is on Service (not TenantAuthzService) because the user added to the organization
// doesn't have tenant access yet
func (s *Service) AddUserToOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
role string,
) error {
now := time.Now()
tenantID := orgID.TenantID()
membershipID := gid.New(tenantID, coredata.MembershipEntityType)
scope := coredata.NewScope(tenantID)
membership := &coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: orgID,
Role: role,
CreatedAt: now,
UpdatedAt: now,
}
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.Create(ctx, conn, scope); err != nil {
return fmt.Errorf("failed to add user to organization: %w", err)
}
return nil
},
)
}
func (s *TenantAuthzService) GetInvitationsByOrganizationID(
ctx context.Context, ctx context.Context,
orgID gid.GID, orgID gid.GID,
cursor *page.Cursor[coredata.InvitationOrderField], cursor *page.Cursor[coredata.InvitationOrderField],
@@ -125,7 +394,7 @@ func (s *Service) GetAllOrganizationInvitations(
err := s.pg.WithConn( err := s.pg.WithConn(
ctx, ctx,
func(conn pg.Conn) error { func(conn pg.Conn) error {
if err := invitations.LoadByOrganizationID(ctx, conn, orgID, cursor); err != nil { if err := invitations.LoadByOrganizationID(ctx, conn, s.scope, orgID, cursor); err != nil {
return fmt.Errorf("failed to load organization invitations: %w", err) return fmt.Errorf("failed to load organization invitations: %w", err)
} }
@@ -139,7 +408,7 @@ func (s *Service) GetAllOrganizationInvitations(
return page.NewPage(invitations, cursor), nil return page.NewPage(invitations, cursor), nil
} }
func (s *Service) CountOrganizationInvitations( func (s *TenantAuthzService) CountOrganizationInvitations(
ctx context.Context, ctx context.Context,
orgID gid.GID, orgID gid.GID,
) (int, error) { ) (int, error) {
@@ -149,7 +418,7 @@ func (s *Service) CountOrganizationInvitations(
func(conn pg.Conn) error { func(conn pg.Conn) error {
var invitations coredata.Invitations var invitations coredata.Invitations
var err error var err error
count, err = invitations.CountByOrganizationID(ctx, conn, orgID) count, err = invitations.CountByOrganizationID(ctx, conn, s.scope, orgID)
return err return err
}, },
) )
@@ -160,7 +429,27 @@ func (s *Service) CountOrganizationInvitations(
return count, nil return count, nil
} }
func (s *Service) DeleteInvitation( func (s *TenantAuthzService) GetInvitationByID(
ctx context.Context,
invitationID gid.GID,
) (*coredata.Invitation, error) {
invitation := &coredata.Invitation{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := invitation.LoadByID(ctx, conn, s.scope, invitationID); err != nil {
return fmt.Errorf("failed to load invitation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return invitation, nil
}
func (s *TenantAuthzService) DeleteInvitation(
ctx context.Context, ctx context.Context,
invitationID gid.GID, invitationID gid.GID,
) error { ) error {
@@ -168,11 +457,11 @@ func (s *Service) DeleteInvitation(
ctx, ctx,
func(conn pg.Conn) error { func(conn pg.Conn) error {
invitation := &coredata.Invitation{} invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, conn, invitationID); err != nil { if err := invitation.LoadByID(ctx, conn, s.scope, invitationID); err != nil {
return fmt.Errorf("failed to load invitation: %w", err) return fmt.Errorf("failed to load invitation: %w", err)
} }
if err := invitation.Delete(ctx, conn); err != nil { if err := invitation.Delete(ctx, conn, s.scope); err != nil {
return fmt.Errorf("failed to delete invitation: %w", err) return fmt.Errorf("failed to delete invitation: %w", err)
} }
@@ -181,7 +470,7 @@ func (s *Service) DeleteInvitation(
) )
} }
func (s *Service) GetAllOrganizationMemberships( func (s *TenantAuthzService) GetMembershipsByOrganizationID(
ctx context.Context, ctx context.Context,
orgID gid.GID, orgID gid.GID,
cursor *page.Cursor[coredata.MembershipOrderField], cursor *page.Cursor[coredata.MembershipOrderField],
@@ -191,7 +480,7 @@ func (s *Service) GetAllOrganizationMemberships(
err := s.pg.WithConn( err := s.pg.WithConn(
ctx, ctx,
func(conn pg.Conn) error { func(conn pg.Conn) error {
if err := memberships.LoadByOrganizationID(ctx, conn, orgID, cursor); err != nil { if err := memberships.LoadByOrganizationID(ctx, conn, s.scope, orgID, cursor); err != nil {
return fmt.Errorf("failed to load organization memberships: %w", err) return fmt.Errorf("failed to load organization memberships: %w", err)
} }
@@ -205,7 +494,7 @@ func (s *Service) GetAllOrganizationMemberships(
return page.NewPage(memberships, cursor), nil return page.NewPage(memberships, cursor), nil
} }
func (s *Service) CountOrganizationMemberships( func (s *TenantAuthzService) CountOrganizationMemberships(
ctx context.Context, ctx context.Context,
orgID gid.GID, orgID gid.GID,
) (int, error) { ) (int, error) {
@@ -215,7 +504,7 @@ func (s *Service) CountOrganizationMemberships(
func(conn pg.Conn) error { func(conn pg.Conn) error {
var memberships coredata.Memberships var memberships coredata.Memberships
var err error var err error
count, err = memberships.CountByOrganizationID(ctx, conn, orgID) count, err = memberships.CountByOrganizationID(ctx, conn, s.scope, orgID)
return err return err
}, },
) )
@@ -226,7 +515,28 @@ func (s *Service) CountOrganizationMemberships(
return count, nil return count, nil
} }
func (s *Service) CanUserAccessOrganization( func (s *TenantAuthzService) CountOrganizationUsers(
ctx context.Context,
orgID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var users coredata.Users
var err error
count, err = users.CountByOrganizationID(ctx, conn, s.scope, orgID)
return err
},
)
if err != nil {
return 0, fmt.Errorf("failed to count users: %w", err)
}
return count, nil
}
func (s *TenantAuthzService) CanUserAccessOrganization(
ctx context.Context, ctx context.Context,
userID gid.GID, userID gid.GID,
orgID gid.GID, orgID gid.GID,
@@ -238,7 +548,7 @@ func (s *Service) CanUserAccessOrganization(
err := s.pg.WithConn( err := s.pg.WithConn(
ctx, ctx,
func(conn pg.Conn) error { func(conn pg.Conn) error {
if err := membership.LoadByUserAndOrg(ctx, conn, userID, orgID); err != nil { if err := membership.LoadByUserAndOrg(ctx, conn, s.scope, userID, orgID); err != nil {
if _, ok := err.(coredata.ErrMembershipNotFound); ok { if _, ok := err.(coredata.ErrMembershipNotFound); ok {
return nil // Not an error, just no access return nil // Not an error, just no access
} }
@@ -256,7 +566,7 @@ func (s *Service) CanUserAccessOrganization(
return haveAccess, nil return haveAccess, nil
} }
func (s *Service) GetUserRoleInOrganization( func (s *TenantAuthzService) GetUserRoleInOrganization(
ctx context.Context, ctx context.Context,
userID gid.GID, userID gid.GID,
orgID gid.GID, orgID gid.GID,
@@ -266,7 +576,7 @@ func (s *Service) GetUserRoleInOrganization(
err := s.pg.WithConn( err := s.pg.WithConn(
ctx, ctx,
func(conn pg.Conn) error { func(conn pg.Conn) error {
if err := membership.LoadByUserAndOrg(ctx, conn, userID, orgID); err != nil { if err := membership.LoadByUserAndOrg(ctx, conn, s.scope, userID, orgID); err != nil {
return fmt.Errorf("failed to get user role: %w", err) return fmt.Errorf("failed to get user role: %w", err)
} }
return nil return nil
@@ -280,7 +590,7 @@ func (s *Service) GetUserRoleInOrganization(
return membership.Role, nil return membership.Role, nil
} }
func (s *Service) RemoveMemberFromOrganization( func (s *TenantAuthzService) RemoveMemberFromOrganization(
ctx context.Context, ctx context.Context,
orgID gid.GID, orgID gid.GID,
memberID gid.GID, memberID gid.GID,
@@ -290,7 +600,7 @@ func (s *Service) RemoveMemberFromOrganization(
return s.pg.WithTx( return s.pg.WithTx(
ctx, ctx,
func(tx pg.Conn) error { func(tx pg.Conn) error {
if err := membership.LoadByID(ctx, tx, memberID); err != nil { if err := membership.LoadByID(ctx, tx, s.scope, memberID); err != nil {
return fmt.Errorf("failed to load membership: %w", err) return fmt.Errorf("failed to load membership: %w", err)
} }
@@ -298,7 +608,7 @@ func (s *Service) RemoveMemberFromOrganization(
return fmt.Errorf("membership does not belong to organization") return fmt.Errorf("membership does not belong to organization")
} }
if err := membership.Delete(ctx, tx); err != nil { if err := membership.Delete(ctx, tx, s.scope); err != nil {
return fmt.Errorf("failed to delete membership: %w", err) return fmt.Errorf("failed to delete membership: %w", err)
} }
@@ -307,32 +617,7 @@ func (s *Service) RemoveMemberFromOrganization(
) )
} }
func (s *Service) AddUserToOrganization( func (s *TenantAuthzService) UpdateUserRole(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
role string,
) error {
membership := &coredata.Membership{
UserID: userID,
OrganizationID: orgID,
Role: role,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.Create(ctx, conn); err != nil {
return fmt.Errorf("failed to add user to organization: %w", err)
}
return nil
},
)
}
func (s *Service) UpdateUserRole(
ctx context.Context, ctx context.Context,
userID gid.GID, userID gid.GID,
orgID gid.GID, orgID gid.GID,
@@ -342,14 +627,14 @@ func (s *Service) UpdateUserRole(
ctx, ctx,
func(tx pg.Conn) error { func(tx pg.Conn) error {
membership := &coredata.Membership{} membership := &coredata.Membership{}
if err := membership.LoadByUserAndOrg(ctx, tx, userID, orgID); err != nil { if err := membership.LoadByUserAndOrg(ctx, tx, s.scope, userID, orgID); err != nil {
return fmt.Errorf("failed to find membership: %w", err) return fmt.Errorf("failed to find membership: %w", err)
} }
membership.Role = newRole membership.Role = newRole
membership.UpdatedAt = time.Now() membership.UpdatedAt = time.Now()
if err := membership.Update(ctx, tx); err != nil { if err := membership.Update(ctx, tx, s.scope); err != nil {
return fmt.Errorf("failed to update user role: %w", err) return fmt.Errorf("failed to update user role: %w", err)
} }
@@ -358,7 +643,7 @@ func (s *Service) UpdateUserRole(
) )
} }
func (s *Service) InviteUserToOrganization( func (s *TenantAuthzService) InviteUserToOrganization(
ctx context.Context, ctx context.Context,
organizationID gid.GID, organizationID gid.GID,
emailAddress string, emailAddress string,
@@ -380,12 +665,11 @@ func (s *Service) InviteUserToOrganization(
} }
organization := &coredata.Organization{} organization := &coredata.Organization{}
scope := coredata.NewScope(organizationID.TenantID()) if err := organization.LoadByID(ctx, tx, s.scope, organizationID); err != nil {
if err := organization.LoadByID(ctx, tx, scope, organizationID); err != nil {
return fmt.Errorf("failed to load organization: %w", err) return fmt.Errorf("failed to load organization: %w", err)
} }
invitationID := gid.New(organizationID.TenantID(), coredata.InvitationEntityType) invitationID := gid.New(s.scope.GetTenantID(), coredata.InvitationEntityType)
now := time.Now() now := time.Now()
invitation = &coredata.Invitation{ invitation = &coredata.Invitation{
ID: invitationID, ID: invitationID,
@@ -397,19 +681,20 @@ func (s *Service) InviteUserToOrganization(
CreatedAt: now, CreatedAt: now,
} }
body := bytes.NewBuffer(nil)
var err error
if userExists { if userExists {
membership := &coredata.Membership{ err = invitationEmailBodyTemplate.Execute(
UserID: user.ID, body,
OrganizationID: organizationID, map[string]string{
Role: role, "FullName": user.FullName,
CreatedAt: now, "OrganizationName": organization.Name,
UpdatedAt: now, "InvitationURL": fmt.Sprintf("https://%s/", s.hostname),
},
)
if err != nil {
return fmt.Errorf("failed to execute template: %w", err)
} }
if err := membership.Create(ctx, tx); err != nil {
return fmt.Errorf("failed to add user to organization: %w", err)
}
invitation.AcceptedAt = &now
} else { } else {
invitationData := coredata.InvitationData{ invitationData := coredata.InvitationData{
InvitationID: invitationID, InvitationID: invitationID,
@@ -429,18 +714,18 @@ func (s *Service) InviteUserToOrganization(
return fmt.Errorf("failed to generate invitation token: %w", err) return fmt.Errorf("failed to generate invitation token: %w", err)
} }
body := bytes.NewBuffer(nil)
err = invitationEmailBodyTemplate.Execute( err = invitationEmailBodyTemplate.Execute(
body, body,
map[string]string{ map[string]string{
"FullName": fullName, "FullName": fullName,
"OrganizationName": organization.Name, "OrganizationName": organization.Name,
"InvitationURL": fmt.Sprintf("https://%s/auth/confirm-invitation?token=%s", s.hostname, invitationToken), "InvitationURL": fmt.Sprintf("https://%s/auth/signup-from-invitation?token=%s&fullName=%s", s.hostname, invitationToken, url.QueryEscape(fullName)),
}, },
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to execute template: %w", err) return fmt.Errorf("failed to execute template: %w", err)
} }
}
email := coredata.NewEmail( email := coredata.NewEmail(
fullName, fullName,
@@ -452,9 +737,8 @@ func (s *Service) InviteUserToOrganization(
if err := email.Insert(ctx, tx); err != nil { if err := email.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email: %w", err) return fmt.Errorf("cannot insert email: %w", err)
} }
}
if err := invitation.Create(ctx, tx); err != nil { if err := invitation.Create(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot create invitation: %w", err) return fmt.Errorf("cannot create invitation: %w", err)
} }
@@ -468,66 +752,8 @@ func (s *Service) InviteUserToOrganization(
return invitation, nil return invitation, nil
} }
func (s *Service) AcceptInvitation(
ctx context.Context,
token string,
userID gid.GID,
) error {
payload, err := statelesstoken.ValidateToken[coredata.InvitationData](
s.tokenSecret,
TokenTypeOrganizationInvitation,
token,
)
if err != nil {
return fmt.Errorf("invalid invitation token: %w", err)
}
invitationData := payload.Data
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, tx, invitationData.InvitationID); err != nil {
var errInvitationNotFound *coredata.ErrInvitationNotFound
if errors.As(err, &errInvitationNotFound) {
return fmt.Errorf("invitation was deleted or no longer exists")
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.AcceptedAt != nil {
return fmt.Errorf("invitation already accepted")
}
if time.Now().After(invitation.ExpiresAt) {
return fmt.Errorf("invitation expired")
}
membership := &coredata.Membership{
UserID: userID,
OrganizationID: invitation.OrganizationID,
Role: invitation.Role,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := membership.Create(ctx, tx); err != nil {
return fmt.Errorf("failed to add user to organization: %w", err)
}
now := time.Now()
invitation.AcceptedAt = &now
if err := invitation.Update(ctx, tx); err != nil {
return fmt.Errorf("failed to mark invitation as accepted: %w", err)
}
return nil
},
)
}
// This is a placeholder for future permission system // This is a placeholder for future permission system
func (s *Service) HasPermission( func (s *TenantAuthzService) HasPermission(
ctx context.Context, ctx context.Context,
userID gid.GID, userID gid.GID,
orgID gid.GID, orgID gid.GID,
@@ -538,22 +764,3 @@ func (s *Service) HasPermission(
// In the future, this will check specific permissions based on role // In the future, this will check specific permissions based on role
return s.CanUserAccessOrganization(ctx, userID, orgID) return s.CanUserAccessOrganization(ctx, userID, orgID)
} }
func (s *Service) ListUserInvitations(
ctx context.Context,
email string,
) ([]*coredata.Invitation, error) {
var invitations coredata.Invitations
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := invitations.LoadByEmail(ctx, conn, email); err != nil {
return fmt.Errorf("failed to load invitations: %w", err)
}
return nil
},
)
return invitations, err
}

View File

@@ -163,7 +163,6 @@ JOIN snapshot_vendors sv ON sv.source_id = av.vendor_id
query = fmt.Sprintf(query, scope.SQLFragment()) query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID, "snapshot_id": snapshotID,
"organization_id": organizationID, "organization_id": organizationID,
} }

View File

@@ -158,7 +158,6 @@ JOIN snapshot_vendors sv ON sv.source_id = dv.vendor_id
query = fmt.Sprintf(query, scope.SQLFragment()) query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"snapshot_id": snapshotID, "snapshot_id": snapshotID,
"organization_id": organizationID, "organization_id": organizationID,
} }

View File

@@ -81,17 +81,17 @@ func (i Invitation) CursorKey(orderBy InvitationOrderField) page.CursorKey {
panic(fmt.Sprintf("unsupported order by: %s", orderBy)) panic(fmt.Sprintf("unsupported order by: %s", orderBy))
} }
// Tenant id scope is not applied because invitations are managed at the organization level and don't require tenant isolation. func (i *Invitation) Create(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (i *Invitation) Create(ctx context.Context, conn pg.Conn) error {
query := ` query := `
INSERT INTO authz_invitations ( INSERT INTO authz_invitations (
id, organization_id, email, full_name, role, expires_at, created_at tenant_id, id, organization_id, email, full_name, role, expires_at, created_at
) VALUES ( ) VALUES (
@id, @organization_id, @email, @full_name, @role, @expires_at, @created_at @tenant_id, @id, @organization_id, @email, @full_name, @role, @expires_at, @created_at
) )
` `
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"id": i.ID, "id": i.ID,
"organization_id": i.OrganizationID, "organization_id": i.OrganizationID,
"email": i.Email, "email": i.Email,
@@ -109,21 +109,24 @@ func (i *Invitation) Create(ctx context.Context, conn pg.Conn) error {
return nil return nil
} }
// Tenant id scope is not applied because we want to access invitations across all tenants for authentication purposes.
func (i *Invitation) LoadByID( func (i *Invitation) LoadByID(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
scope Scoper,
id gid.GID, id gid.GID,
) error { ) error {
query := ` query := `
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
FROM authz_invitations FROM authz_invitations
WHERE id = @id WHERE id = @id AND %s
` `
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"id": id, "id": id,
} }
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, query, args) rows, err := conn.Query(ctx, query, args)
if err != nil { if err != nil {
@@ -142,18 +145,20 @@ func (i *Invitation) LoadByID(
return nil return nil
} }
// Tenant id scope is not applied because invitations are managed at the organization level and don't require tenant isolation. func (i *Invitation) Update(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (i *Invitation) Update(ctx context.Context, conn pg.Conn) error {
query := ` query := `
UPDATE authz_invitations UPDATE authz_invitations
SET accepted_at = @accepted_at SET accepted_at = @accepted_at
WHERE id = @id WHERE id = @id AND %s
` `
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"id": i.ID, "id": i.ID,
"accepted_at": i.AcceptedAt, "accepted_at": i.AcceptedAt,
} }
maps.Copy(args, scope.SQLArguments())
result, err := conn.Exec(ctx, query, args) result, err := conn.Exec(ctx, query, args)
if err != nil { if err != nil {
@@ -167,16 +172,18 @@ func (i *Invitation) Update(ctx context.Context, conn pg.Conn) error {
return nil return nil
} }
// Tenant id scope is not applied because invitations are managed at the organization level and don't require tenant isolation. func (i *Invitation) Delete(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (i *Invitation) Delete(ctx context.Context, conn pg.Conn) error {
query := ` query := `
DELETE FROM authz_invitations DELETE FROM authz_invitations
WHERE id = @id WHERE id = @id AND %s
` `
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"id": i.ID, "id": i.ID,
} }
maps.Copy(args, scope.SQLArguments())
result, err := conn.Exec(ctx, query, args) result, err := conn.Exec(ctx, query, args)
if err != nil { if err != nil {
@@ -190,21 +197,30 @@ func (i *Invitation) Delete(ctx context.Context, conn pg.Conn) error {
return nil return nil
} }
// Tenant scope is not applied because this is used to query invitations across all tenants
// for a user who doesn't have tenant access yet (before accepting an invitation).
func (i *Invitations) LoadByEmail( func (i *Invitations) LoadByEmail(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
email string, email string,
cursor *page.Cursor[InvitationOrderField],
filter *InvitationFilter,
) error { ) error {
query := ` query := `
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
FROM authz_invitations FROM authz_invitations
WHERE email = @email AND accepted_at IS NULL WHERE email = @email
ORDER BY created_at DESC AND %s
AND %s
` `
query = fmt.Sprintf(query, filter.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"email": email, "email": email,
} }
maps.Copy(args, filter.SQLArguments())
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, query, args) rows, err := conn.Query(ctx, query, args)
if err != nil { if err != nil {
@@ -223,19 +239,23 @@ func (i *Invitations) LoadByEmail(
func (i *Invitations) LoadByOrganizationID( func (i *Invitations) LoadByOrganizationID(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
scope Scoper,
orgID gid.GID, orgID gid.GID,
cursor *page.Cursor[InvitationOrderField], cursor *page.Cursor[InvitationOrderField],
) error { ) error {
query := ` query := `
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
FROM authz_invitations FROM authz_invitations
WHERE organization_id = @organization_id WHERE organization_id = @organization_id AND %s
AND %s AND %s
` `
query = fmt.Sprintf(query, cursor.SQLFragment()) query = fmt.Sprintf(query, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": orgID} args := pgx.StrictNamedArgs{
"organization_id": orgID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments()) maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, query, args) rows, err := conn.Query(ctx, query, args)
@@ -255,6 +275,7 @@ func (i *Invitations) LoadByOrganizationID(
func (i *Invitations) CountByOrganizationID( func (i *Invitations) CountByOrganizationID(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
scope Scoper,
orgID gid.GID, orgID gid.GID,
) (int, error) { ) (int, error) {
q := ` q := `
@@ -263,10 +284,51 @@ SELECT
FROM FROM
authz_invitations authz_invitations
WHERE WHERE
organization_id = @organization_id organization_id = @organization_id AND %s
` `
args := pgx.StrictNamedArgs{"organization_id": orgID} q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": orgID,
}
maps.Copy(args, scope.SQLArguments())
row := conn.QueryRow(ctx, q, args)
var count int
err := row.Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot count invitations: %w", err)
}
return count, nil
}
// Tenant scope is not applied because this is used to count invitations across all tenants
// for a user who doesn't have tenant access yet (before accepting an invitation).
func (i *Invitations) CountByEmail(
ctx context.Context,
conn pg.Conn,
email string,
filter *InvitationFilter,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
authz_invitations
WHERE
email = @email
AND %s
`
q = fmt.Sprintf(q, filter.SQLFragment())
args := pgx.StrictNamedArgs{
"email": email,
}
maps.Copy(args, filter.SQLArguments())
row := conn.QueryRow(ctx, q, args) row := conn.QueryRow(ctx, q, args)

View File

@@ -0,0 +1,49 @@
// 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 (
InvitationFilter struct {
onlyPending *bool
}
)
func NewInvitationFilter(onlyPending *bool) *InvitationFilter {
return &InvitationFilter{
onlyPending: onlyPending,
}
}
func (f *InvitationFilter) SQLArguments() pgx.NamedArgs {
return pgx.NamedArgs{
"only_pending": f.onlyPending,
}
}
func (f *InvitationFilter) SQLFragment() string {
return `
(
CASE
WHEN @only_pending::boolean IS NOT NULL AND @only_pending::boolean = true THEN
(accepted_at IS NULL AND expires_at > NOW())
ELSE TRUE
END
)`
}

View File

@@ -76,28 +76,20 @@ func (m Membership) CursorKey(orderBy MembershipOrderField) page.CursorKey {
panic(fmt.Sprintf("unsupported order by: %s", orderBy)) panic(fmt.Sprintf("unsupported order by: %s", orderBy))
} }
// Tenant id scope is not applied because memberships are managed at the organization level and don't require tenant isolation. func (m *Membership) Create(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (m *Membership) Create(ctx context.Context, conn pg.Conn) error {
query := ` query := `
INSERT INTO authz_memberships (id, user_id, organization_id, role, created_at, updated_at) INSERT INTO authz_memberships (tenant_id, id, user_id, organization_id, role, created_at, updated_at)
SELECT VALUES (@tenant_id, @id, @user_id, @organization_id, @role, @created_at, @updated_at)
generate_gid(decode_base64_unpadded(o.tenant_id), @entity_type),
@user_id,
@organization_id,
@role,
@created_at,
@updated_at
FROM organizations o
WHERE o.id = @organization_id
` `
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"id": m.ID,
"user_id": m.UserID, "user_id": m.UserID,
"organization_id": m.OrganizationID, "organization_id": m.OrganizationID,
"role": m.Role, "role": m.Role,
"created_at": m.CreatedAt, "created_at": m.CreatedAt,
"updated_at": m.UpdatedAt, "updated_at": m.UpdatedAt,
"entity_type": MembershipEntityType,
} }
result, err := conn.Exec(ctx, query, args) result, err := conn.Exec(ctx, query, args)
@@ -116,10 +108,10 @@ func (m *Membership) Create(ctx context.Context, conn pg.Conn) error {
return nil return nil
} }
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
func (m *Membership) LoadByID( func (m *Membership) LoadByID(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
scope Scoper,
membershipID gid.GID, membershipID gid.GID,
) error { ) error {
query := ` query := `
@@ -134,12 +126,15 @@ func (m *Membership) LoadByID(
m.updated_at m.updated_at
FROM authz_memberships m FROM authz_memberships m
JOIN users u ON m.user_id = u.id JOIN users u ON m.user_id = u.id
WHERE m.id = @membership_id WHERE m.id = @membership_id AND %s
` `
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"membership_id": membershipID, "membership_id": membershipID,
} }
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, query, args) rows, err := conn.Query(ctx, query, args)
if err != nil { if err != nil {
@@ -158,10 +153,10 @@ func (m *Membership) LoadByID(
return nil return nil
} }
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
func (m *Membership) LoadByUserAndOrg( func (m *Membership) LoadByUserAndOrg(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
scope Scoper,
userID gid.GID, userID gid.GID,
orgID gid.GID, orgID gid.GID,
) error { ) error {
@@ -177,13 +172,16 @@ func (m *Membership) LoadByUserAndOrg(
m.updated_at m.updated_at
FROM authz_memberships m FROM authz_memberships m
JOIN users u ON m.user_id = u.id JOIN users u ON m.user_id = u.id
WHERE m.user_id = @user_id AND m.organization_id = @organization_id WHERE m.user_id = @user_id AND m.organization_id = @organization_id AND %s
` `
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"user_id": userID, "user_id": userID,
"organization_id": orgID, "organization_id": orgID,
} }
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, query, args) rows, err := conn.Query(ctx, query, args)
if err != nil { if err != nil {
@@ -202,19 +200,21 @@ func (m *Membership) LoadByUserAndOrg(
return nil return nil
} }
// Tenant id scope is not applied because memberships are managed at the organization level and don't require tenant isolation. func (m *Membership) Update(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (m *Membership) Update(ctx context.Context, conn pg.Conn) error {
query := ` query := `
UPDATE authz_memberships UPDATE authz_memberships
SET role = @role, updated_at = @updated_at SET role = @role, updated_at = @updated_at
WHERE id = @id WHERE id = @id AND %s
` `
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"id": m.ID, "id": m.ID,
"role": m.Role, "role": m.Role,
"updated_at": m.UpdatedAt, "updated_at": m.UpdatedAt,
} }
maps.Copy(args, scope.SQLArguments())
result, err := conn.Exec(ctx, query, args) result, err := conn.Exec(ctx, query, args)
if err != nil { if err != nil {
@@ -228,16 +228,18 @@ func (m *Membership) Update(ctx context.Context, conn pg.Conn) error {
return nil return nil
} }
// Tenant id scope is not applied because memberships are managed at the organization level and don't require tenant isolation. func (m *Membership) Delete(ctx context.Context, conn pg.Conn, scope Scoper) error {
func (m *Membership) Delete(ctx context.Context, conn pg.Conn) error {
query := ` query := `
DELETE FROM authz_memberships DELETE FROM authz_memberships
WHERE id = @id WHERE id = @id AND %s
` `
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"id": m.ID, "id": m.ID,
} }
maps.Copy(args, scope.SQLArguments())
result, err := conn.Exec(ctx, query, args) result, err := conn.Exec(ctx, query, args)
if err != nil { if err != nil {
@@ -251,10 +253,10 @@ func (m *Membership) Delete(ctx context.Context, conn pg.Conn) error {
return nil return nil
} }
// Tenant id scope is not applied because we want to access all user's memberships across tenants for authentication purposes.
func (m *Memberships) LoadByUserID( func (m *Memberships) LoadByUserID(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
scope Scoper,
userID gid.GID, userID gid.GID,
) error { ) error {
query := ` query := `
@@ -272,11 +274,17 @@ FROM
JOIN users u ON m.user_id = u.id JOIN users u ON m.user_id = u.id
WHERE WHERE
m.user_id = @user_id m.user_id = @user_id
AND %s
ORDER BY ORDER BY
m.created_at DESC m.created_at DESC
` `
args := pgx.StrictNamedArgs{"user_id": userID} query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"user_id": userID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, query, args) rows, err := conn.Query(ctx, query, args)
if err != nil { if err != nil {
@@ -292,10 +300,10 @@ ORDER BY
return nil return nil
} }
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
func (m *Memberships) LoadByOrganizationID( func (m *Memberships) LoadByOrganizationID(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
scope Scoper,
organizationID gid.GID, organizationID gid.GID,
cursor *page.Cursor[MembershipOrderField], cursor *page.Cursor[MembershipOrderField],
) error { ) error {
@@ -315,11 +323,15 @@ JOIN users u ON m.user_id = u.id
WHERE WHERE
m.organization_id = @organization_id m.organization_id = @organization_id
AND %s AND %s
AND %s
` `
query = fmt.Sprintf(query, cursor.SQLFragment()) query = fmt.Sprintf(query, scope.SQLFragment(), cursor.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID} args := pgx.StrictNamedArgs{
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, cursor.SQLArguments()) maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, query, args) rows, err := conn.Query(ctx, query, args)
@@ -339,14 +351,19 @@ WHERE
func (m *Memberships) CountByOrganizationID( func (m *Memberships) CountByOrganizationID(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
scope Scoper,
organizationID gid.GID, organizationID gid.GID,
) (int, error) { ) (int, error) {
query := ` query := `
SELECT COUNT(*) SELECT COUNT(*)
FROM authz_memberships FROM authz_memberships
WHERE organization_id = @organization_id WHERE organization_id = @organization_id AND %s
` `
args := pgx.StrictNamedArgs{"organization_id": organizationID} query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
row := conn.QueryRow(ctx, query, args) row := conn.QueryRow(ctx, query, args)
var count int var count int
if err := row.Scan(&count); err != nil { if err := row.Scan(&count); err != nil {

View File

@@ -8,6 +8,7 @@ CREATE TYPE authz_role AS ENUM ('OWNER', 'ADMIN', 'MEMBER', 'VIEWER');
-- Create authz_memberships table with id as primary key -- Create authz_memberships table with id as primary key
CREATE TABLE authz_memberships ( CREATE TABLE authz_memberships (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
organization_id TEXT NOT NULL, organization_id TEXT NOT NULL,
role authz_role NOT NULL, role authz_role NOT NULL,
@@ -19,6 +20,7 @@ CREATE TABLE authz_memberships (
-- Create authz_invitations table -- Create authz_invitations table
CREATE TABLE authz_invitations ( CREATE TABLE authz_invitations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL, organization_id TEXT NOT NULL,
email TEXT NOT NULL, email TEXT NOT NULL,
full_name TEXT NOT NULL, full_name TEXT NOT NULL,
@@ -29,8 +31,9 @@ CREATE TABLE authz_invitations (
); );
-- Copy data from users_organizations to authz_memberships -- Copy data from users_organizations to authz_memberships
INSERT INTO authz_memberships (id, user_id, organization_id, role, created_at, updated_at) INSERT INTO authz_memberships (tenant_id, id, user_id, organization_id, role, created_at, updated_at)
SELECT SELECT
organizations.tenant_id,
generate_gid(decode_base64_unpadded(organizations.tenant_id), 38) as id, generate_gid(decode_base64_unpadded(organizations.tenant_id), 38) as id,
users_organizations.user_id, users_organizations.user_id,
users_organizations.organization_id, users_organizations.organization_id,

View File

@@ -32,7 +32,6 @@ type (
ID gid.GID `db:"id"` ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"` OrganizationID gid.GID `db:"organization_id"`
Kind PeopleKind `db:"kind"` Kind PeopleKind `db:"kind"`
UserID *gid.GID `db:"user_id"`
FullName string `db:"full_name"` FullName string `db:"full_name"`
PrimaryEmailAddress string `db:"primary_email_address"` PrimaryEmailAddress string `db:"primary_email_address"`
AdditionalEmailAddresses []string `db:"additional_email_addresses"` AdditionalEmailAddresses []string `db:"additional_email_addresses"`
@@ -78,7 +77,6 @@ SELECT
id, id,
organization_id, organization_id,
kind, kind,
user_id,
full_name, full_name,
primary_email_address, primary_email_address,
additional_email_addresses, additional_email_addresses,
@@ -126,7 +124,6 @@ func (p *People) LoadByEmail(
id, id,
organization_id, organization_id,
kind, kind,
user_id,
full_name, full_name,
primary_email_address, primary_email_address,
additional_email_addresses, additional_email_addresses,
@@ -167,58 +164,6 @@ func (p *People) LoadByEmail(
return nil return nil
} }
func (p *People) LoadByUserID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
userID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
kind,
user_id,
full_name,
primary_email_address,
additional_email_addresses,
position,
contract_start_date,
contract_end_date,
created_at,
updated_at
FROM
peoples
WHERE
%s
AND user_id = @user_id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"user_id": userID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query people: %w", err)
}
people, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[People])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrPeopleNotFound{Identifier: userID.String()}
}
return fmt.Errorf("cannot collect people: %w", err)
}
*p = people
return nil
}
func (p People) Insert( func (p People) Insert(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
@@ -230,7 +175,6 @@ INSERT INTO
tenant_id, tenant_id,
id, id,
organization_id, organization_id,
user_id,
kind, kind,
full_name, full_name,
primary_email_address, primary_email_address,
@@ -245,7 +189,6 @@ VALUES (
@tenant_id, @tenant_id,
@people_id, @people_id,
@organization_id, @organization_id,
@user_id,
@kind, @kind,
@full_name, @full_name,
@primary_email_address, @primary_email_address,
@@ -262,7 +205,6 @@ VALUES (
"tenant_id": scope.GetTenantID(), "tenant_id": scope.GetTenantID(),
"people_id": p.ID, "people_id": p.ID,
"organization_id": p.OrganizationID, "organization_id": p.OrganizationID,
"user_id": p.UserID,
"kind": p.Kind, "kind": p.Kind,
"full_name": p.FullName, "full_name": p.FullName,
"primary_email_address": p.PrimaryEmailAddress, "primary_email_address": p.PrimaryEmailAddress,
@@ -343,7 +285,6 @@ SELECT
id, id,
organization_id, organization_id,
kind, kind,
user_id,
full_name, full_name,
primary_email_address, primary_email_address,
additional_email_addresses, additional_email_addresses,
@@ -390,7 +331,6 @@ func (p *People) Update(
) error { ) error {
q := ` q := `
UPDATE peoples SET UPDATE peoples SET
user_id = @user_id,
full_name = @full_name, full_name = @full_name,
primary_email_address = @primary_email_address, primary_email_address = @primary_email_address,
additional_email_addresses = @additional_email_addresses, additional_email_addresses = @additional_email_addresses,
@@ -406,7 +346,6 @@ WHERE %s
args := pgx.StrictNamedArgs{ args := pgx.StrictNamedArgs{
"people_id": p.ID, "people_id": p.ID,
"user_id": p.UserID,
"full_name": p.FullName, "full_name": p.FullName,
"primary_email_address": p.PrimaryEmailAddress, "primary_email_address": p.PrimaryEmailAddress,
"additional_email_addresses": p.AdditionalEmailAddresses, "additional_email_addresses": p.AdditionalEmailAddresses,
@@ -447,7 +386,6 @@ SELECT
id, id,
organization_id, organization_id,
kind, kind,
user_id,
full_name, full_name,
primary_email_address, primary_email_address,
additional_email_addresses, additional_email_addresses,

View File

@@ -115,6 +115,7 @@ WHERE
func (u *Users) CountByOrganizationID( func (u *Users) CountByOrganizationID(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
scope Scoper,
organizationID gid.GID, organizationID gid.GID,
) (int, error) { ) (int, error) {
q := ` q := `
@@ -124,11 +125,14 @@ FROM
users users
WHERE WHERE
id IN ( id IN (
SELECT user_id FROM authz_memberships WHERE organization_id = @organization_id SELECT user_id FROM authz_memberships WHERE organization_id = @organization_id AND %s
) )
` `
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID} args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
row := conn.QueryRow(ctx, q, args) row := conn.QueryRow(ctx, q, args)

View File

@@ -1,81 +0,0 @@
// 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"
"time"
"github.com/getprobo/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
)
type (
UserOrganization struct {
UserID gid.GID `db:"user_id"`
OrganizationID gid.GID `db:"organization_id"`
CreatedAt time.Time `db:"created_at"`
}
UserOrganizations []*UserOrganization
)
func (uo UserOrganization) Insert(
ctx context.Context,
conn pg.Conn,
) error {
q := `
INSERT INTO users_organizations (user_id, organization_id, created_at)
VALUES (@user_id, @organization_id, @created_at)
`
_, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"user_id": uo.UserID, "organization_id": uo.OrganizationID, "created_at": uo.CreatedAt})
return err
}
// Tenant id scope is not applied because user organizations are managed at the organization level and don't require tenant isolation.
func (uo UserOrganization) Delete(ctx context.Context, conn pg.Conn) error {
q := `
DELETE FROM users_organizations WHERE user_id = @user_id AND organization_id = @organization_id
`
_, err := conn.Exec(ctx, q, pgx.StrictNamedArgs{"user_id": uo.UserID, "organization_id": uo.OrganizationID})
return err
}
func (uo *UserOrganizations) ForUserID(
ctx context.Context,
conn pg.Conn,
userID gid.GID,
) error {
q := `
SELECT user_id, organization_id, created_at FROM users_organizations WHERE user_id = @user_id
`
rows, err := conn.Query(ctx, q, pgx.StrictNamedArgs{"user_id": userID})
if err != nil {
return err
}
userOrganizations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[UserOrganization])
if err != nil {
return err
}
*uo = userOrganizations
return nil
}

View File

@@ -32,7 +32,6 @@ type (
UpdatePeopleRequest struct { UpdatePeopleRequest struct {
ID gid.GID ID gid.GID
UserID *gid.GID
Kind *coredata.PeopleKind Kind *coredata.PeopleKind
FullName *string FullName *string
PrimaryEmailAddress *string PrimaryEmailAddress *string
@@ -44,7 +43,6 @@ type (
CreatePeopleRequest struct { CreatePeopleRequest struct {
OrganizationID gid.GID OrganizationID gid.GID
UserID *gid.GID
FullName string FullName string
PrimaryEmailAddress string PrimaryEmailAddress string
AdditionalEmailAddresses []string AdditionalEmailAddresses []string
@@ -75,26 +73,6 @@ func (s PeopleService) Get(
return people, nil return people, nil
} }
func (s PeopleService) GetByUserID(
ctx context.Context,
userID gid.GID,
) (*coredata.People, error) {
people := &coredata.People{}
err := s.svc.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return people.LoadByUserID(ctx, conn, s.svc.scope, userID)
},
)
if err != nil {
return nil, err
}
return people, nil
}
func (s PeopleService) CountForOrganizationID( func (s PeopleService) CountForOrganizationID(
ctx context.Context, ctx context.Context,
organizationID gid.GID, organizationID gid.GID,
@@ -164,10 +142,6 @@ func (s PeopleService) Update(
return fmt.Errorf("cannot load people: %w", err) return fmt.Errorf("cannot load people: %w", err)
} }
if req.UserID != nil {
people.UserID = req.UserID
}
if req.Kind != nil { if req.Kind != nil {
people.Kind = *req.Kind people.Kind = *req.Kind
} }
@@ -234,7 +208,6 @@ func (s PeopleService) Create(
FullName: req.FullName, FullName: req.FullName,
PrimaryEmailAddress: req.PrimaryEmailAddress, PrimaryEmailAddress: req.PrimaryEmailAddress,
AdditionalEmailAddresses: req.AdditionalEmailAddresses, AdditionalEmailAddresses: req.AdditionalEmailAddresses,
UserID: req.UserID,
Position: req.Position, Position: req.Position,
ContractStartDate: req.ContractStartDate, ContractStartDate: req.ContractStartDate,
ContractEndDate: req.ContractEndDate, ContractEndDate: req.ContractEndDate,

View File

@@ -1,81 +0,0 @@
// 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 console_v1
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/statelesstoken"
"go.gearno.de/kit/httpserver"
)
type (
InvitationConfirmationRequest struct {
Token string `json:"token"`
Password string `json:"password"`
}
InvitationConfirmationResponse struct {
}
)
func InvitationConfirmationHandler(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req InvitationConfirmationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err))
return
}
payload, err := statelesstoken.ValidateToken[coredata.InvitationData](
authCfg.CookieSecret,
authz.TokenTypeOrganizationInvitation,
req.Token,
)
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("invalid invitation token: %w", err))
return
}
user, _, err := authSvc.SignUp(r.Context(), payload.Data.Email, req.Password, payload.Data.FullName)
if err != nil {
var errUserAlreadyExists *auth.ErrUserAlreadyExists
if errors.As(err, &errUserAlreadyExists) {
user, err = authSvc.GetUserByEmail(r.Context(), payload.Data.Email)
if err != nil {
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("failed to load existing user: %w", err))
return
}
} else {
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("failed to create user: %w", err))
return
}
}
err = authzSvc.AcceptInvitation(r.Context(), req.Token, user.ID)
if err != nil {
httpserver.RenderError(w, http.StatusInternalServerError, err)
return
}
httpserver.RenderJSON(w, http.StatusOK, InvitationConfirmationResponse{})
}
}

View File

@@ -21,6 +21,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"strings" "strings"
"time" "time"
@@ -157,7 +158,7 @@ func NewMux(
r.Post("/auth/register", SignUpHandler(authSvc, authCfg)) r.Post("/auth/register", SignUpHandler(authSvc, authCfg))
r.Post("/auth/login", SignInHandler(authSvc, authCfg)) r.Post("/auth/login", SignInHandler(authSvc, authCfg))
r.Delete("/auth/logout", SignOutHandler(authSvc, authCfg)) r.Delete("/auth/logout", SignOutHandler(authSvc, authCfg))
r.Post("/auth/invitation", InvitationConfirmationHandler(authSvc, authzSvc, authCfg)) r.Post("/auth/signup-from-invitation", SignupFromInvitationHandler(authSvc, authCfg))
r.Post("/auth/forget-password", ForgetPasswordHandler(authSvc, authCfg)) r.Post("/auth/forget-password", ForgetPasswordHandler(authSvc, authCfg))
r.Post("/auth/reset-password", ResetPasswordHandler(authSvc, authCfg)) r.Post("/auth/reset-password", ResetPasswordHandler(authSvc, authCfg))
@@ -316,18 +317,28 @@ func (r *Resolver) ProboService(ctx context.Context, tenantID gid.TenantID) *pro
return GetTenantService(ctx, r.proboSvc, tenantID) return GetTenantService(ctx, r.proboSvc, tenantID)
} }
func (r *Resolver) AuthzService(ctx context.Context, tenantID gid.TenantID) *authz.TenantAuthzService {
return GetTenantAuthzService(ctx, r.authzSvc, tenantID)
}
func GetTenantService(ctx context.Context, proboSvc *probo.Service, tenantID gid.TenantID) *probo.TenantService { func GetTenantService(ctx context.Context, proboSvc *probo.Service, tenantID gid.TenantID) *probo.TenantService {
validateTenantAccess(ctx, tenantID)
return proboSvc.WithTenant(tenantID)
}
func GetTenantAuthzService(ctx context.Context, authzSvc *authz.Service, tenantID gid.TenantID) *authz.TenantAuthzService {
validateTenantAccess(ctx, tenantID)
return authzSvc.WithTenant(tenantID)
}
func validateTenantAccess(ctx context.Context, tenantID gid.TenantID) {
tenantIDs, _ := ctx.Value(userTenantContextKey).(*[]gid.TenantID) tenantIDs, _ := ctx.Value(userTenantContextKey).(*[]gid.TenantID)
if tenantIDs == nil { if tenantIDs == nil {
panic(fmt.Errorf("tenant not found")) panic(fmt.Errorf("tenant not found"))
} }
for _, id := range *tenantIDs { if !slices.Contains(*tenantIDs, tenantID) {
if id == tenantID {
return proboSvc.WithTenant(tenantID)
}
}
panic(fmt.Errorf("tenant not found")) panic(fmt.Errorf("tenant not found"))
} }
}

View File

@@ -1465,6 +1465,10 @@ input InvitationOrder {
field: InvitationOrderField! field: InvitationOrderField!
} }
input InvitationFilter {
onlyPending: Boolean
}
input DocumentVersionFilter { input DocumentVersionFilter {
status: DocumentStatus status: DocumentStatus
} }
@@ -1572,6 +1576,7 @@ type Organization implements Node {
last: Int last: Int
before: CursorKey before: CursorKey
orderBy: InvitationOrder orderBy: InvitationOrder
filter: InvitationFilter
): InvitationConnection! @goField(forceResolver: true) ): InvitationConnection! @goField(forceResolver: true)
connectors( connectors(
@@ -1736,8 +1741,6 @@ type User implements Node {
email: String! email: String!
createdAt: Datetime! createdAt: Datetime!
updatedAt: Datetime! updatedAt: Datetime!
people(organizationId: ID!): People @goField(forceResolver: true)
} }
type Membership implements Node { type Membership implements Node {
@@ -1759,6 +1762,7 @@ type Invitation implements Node {
expiresAt: Datetime! expiresAt: Datetime!
acceptedAt: Datetime acceptedAt: Datetime
createdAt: Datetime! createdAt: Datetime!
organization: Organization! @goField(forceResolver: true)
} }
type Connector implements Node { type Connector implements Node {
@@ -2295,6 +2299,15 @@ type Viewer {
before: CursorKey before: CursorKey
orderBy: OrganizationOrder orderBy: OrganizationOrder
): OrganizationConnection! @goField(forceResolver: true) ): OrganizationConnection! @goField(forceResolver: true)
invitations(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: InvitationOrder
filter: InvitationFilter
): InvitationConnection! @goField(forceResolver: true)
} }
# Connection Types # Connection Types
@@ -2394,13 +2407,19 @@ type TrustCenterReferenceEdge {
node: TrustCenterReference! node: TrustCenterReference!
} }
type UserConnection { type UserConnection
@goModel(
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.UserConnection"
) {
totalCount: Int! @goField(forceResolver: true) totalCount: Int! @goField(forceResolver: true)
edges: [UserEdge!]! edges: [UserEdge!]!
pageInfo: PageInfo! pageInfo: PageInfo!
} }
type MembershipConnection { type MembershipConnection
@goModel(
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.MembershipConnection"
) {
totalCount: Int! @goField(forceResolver: true) totalCount: Int! @goField(forceResolver: true)
edges: [MembershipEdge!]! edges: [MembershipEdge!]!
pageInfo: PageInfo! pageInfo: PageInfo!
@@ -2709,7 +2728,10 @@ type File {
updatedAt: Datetime! updatedAt: Datetime!
} }
type InvitationConnection { type InvitationConnection
@goModel(
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.InvitationConnection"
) {
totalCount: Int! @goField(forceResolver: true) totalCount: Int! @goField(forceResolver: true)
edges: [InvitationEdge!]! edges: [InvitationEdge!]!
pageInfo: PageInfo! pageInfo: PageInfo!
@@ -2780,6 +2802,7 @@ type Mutation {
# User mutations # User mutations
confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload! confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload!
inviteUser(input: InviteUserInput!): InviteUserPayload! inviteUser(input: InviteUserInput!): InviteUserPayload!
acceptInvitation(input: AcceptInvitationInput!): AcceptInvitationPayload!
deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload! deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload!
removeMember(input: RemoveMemberInput!): RemoveMemberPayload! removeMember(input: RemoveMemberInput!): RemoveMemberPayload!
@@ -3540,6 +3563,10 @@ input InviteUserInput {
createPeople: Boolean! createPeople: Boolean!
} }
input AcceptInvitationInput {
invitationId: ID!
}
input DeleteInvitationInput { input DeleteInvitationInput {
invitationId: ID! invitationId: ID!
} }
@@ -4066,12 +4093,16 @@ type InviteUserPayload {
invitationEdge: InvitationEdge! invitationEdge: InvitationEdge!
} }
type AcceptInvitationPayload {
invitation: Invitation!
}
type DeleteInvitationPayload { type DeleteInvitationPayload {
deletedInvitationId: ID! deletedInvitationId: ID!
} }
type RemoveMemberPayload { type RemoveMemberPayload {
success: Boolean! deletedMemberId: ID!
} }
input VendorRiskAssessmentOrder { input VendorRiskAssessmentOrder {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
// 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 console_v1
import (
"encoding/json"
"fmt"
"net/http"
"github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/securecookie"
"go.gearno.de/kit/httpserver"
)
type (
SignupFromInvitationRequest struct {
Token string `json:"token"`
Password string `json:"password"`
FullName string `json:"fullName"`
}
SignupFromInvitationResponse struct {
}
)
func SignupFromInvitationHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req SignupFromInvitationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot decode body: %w", err))
return
}
user, session, err := authSvc.SignupFromInvitation(r.Context(), req.Token, req.Password, req.FullName)
if err != nil {
httpserver.RenderError(w, http.StatusBadRequest, err)
return
}
securecookie.Set(
w,
securecookie.DefaultConfig(
authCfg.CookieName,
authCfg.CookieSecret,
),
session.ID.String(),
)
httpserver.RenderJSON(
w,
http.StatusOK,
SignUpResponse{
User: UserResponse{
ID: user.ID,
Email: user.EmailAddress,
FullName: user.FullName,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
},
},
)
}
}

View File

@@ -16,10 +16,28 @@ package types
import ( import (
"github.com/getprobo/probo/pkg/coredata" "github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page" "github.com/getprobo/probo/pkg/page"
) )
func NewInvitationConnection(p *page.Page[*coredata.Invitation, coredata.InvitationOrderField]) *InvitationConnection { type (
InvitationConnection struct {
TotalCount int `json:"totalCount"`
Edges []*InvitationEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
Resolver any
ParentID gid.GID
Filter *InvitationFilter
}
)
func NewInvitationConnection(
p *page.Page[*coredata.Invitation, coredata.InvitationOrderField],
resolver any,
parentID gid.GID,
filter *InvitationFilter,
) *InvitationConnection {
var edges = make([]*InvitationEdge, len(p.Data)) var edges = make([]*InvitationEdge, len(p.Data))
for i := range edges { for i := range edges {
@@ -29,6 +47,9 @@ func NewInvitationConnection(p *page.Page[*coredata.Invitation, coredata.Invitat
return &InvitationConnection{ return &InvitationConnection{
Edges: edges, Edges: edges,
PageInfo: NewPageInfo(p), PageInfo: NewPageInfo(p),
Resolver: resolver,
ParentID: parentID,
Filter: filter,
} }
} }

View File

@@ -16,14 +16,28 @@ package types
import ( import (
"github.com/getprobo/probo/pkg/coredata" "github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page" "github.com/getprobo/probo/pkg/page"
) )
type ( type (
MembershipConnection struct {
TotalCount int `json:"totalCount"`
Edges []*MembershipEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
Resolver any
ParentID gid.GID
}
MembershipOrderBy OrderBy[coredata.MembershipOrderField] MembershipOrderBy OrderBy[coredata.MembershipOrderField]
) )
func NewMembershipConnection(p *page.Page[*coredata.Membership, coredata.MembershipOrderField]) *MembershipConnection { func NewMembershipConnection(
p *page.Page[*coredata.Membership, coredata.MembershipOrderField],
resolver any,
parentID gid.GID,
) *MembershipConnection {
var edges = make([]*MembershipEdge, len(p.Data)) var edges = make([]*MembershipEdge, len(p.Data))
for i := range edges { for i := range edges {
@@ -33,6 +47,8 @@ func NewMembershipConnection(p *page.Page[*coredata.Membership, coredata.Members
return &MembershipConnection{ return &MembershipConnection{
Edges: edges, Edges: edges,
PageInfo: NewPageInfo(p), PageInfo: NewPageInfo(p),
Resolver: resolver,
ParentID: parentID,
} }
} }

View File

@@ -16,6 +16,14 @@ type Node interface {
GetID() gid.GID GetID() gid.GID
} }
type AcceptInvitationInput struct {
InvitationID gid.GID `json:"invitationId"`
}
type AcceptInvitationPayload struct {
Invitation *Invitation `json:"invitation"`
}
type AssessVendorInput struct { type AssessVendorInput struct {
ID gid.GID `json:"id"` ID gid.GID `json:"id"`
WebsiteURL string `json:"websiteUrl"` WebsiteURL string `json:"websiteUrl"`
@@ -1207,22 +1215,21 @@ type Invitation struct {
ExpiresAt time.Time `json:"expiresAt"` ExpiresAt time.Time `json:"expiresAt"`
AcceptedAt *time.Time `json:"acceptedAt,omitempty"` AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
Organization *Organization `json:"organization"`
} }
func (Invitation) IsNode() {} func (Invitation) IsNode() {}
func (this Invitation) GetID() gid.GID { return this.ID } func (this Invitation) GetID() gid.GID { return this.ID }
type InvitationConnection struct {
TotalCount int `json:"totalCount"`
Edges []*InvitationEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type InvitationEdge struct { type InvitationEdge struct {
Cursor page.CursorKey `json:"cursor"` Cursor page.CursorKey `json:"cursor"`
Node *Invitation `json:"node"` Node *Invitation `json:"node"`
} }
type InvitationFilter struct {
OnlyPending *bool `json:"onlyPending,omitempty"`
}
type InvitationOrder struct { type InvitationOrder struct {
Direction page.OrderDirection `json:"direction"` Direction page.OrderDirection `json:"direction"`
Field coredata.InvitationOrderField `json:"field"` Field coredata.InvitationOrderField `json:"field"`
@@ -1280,12 +1287,6 @@ type Membership struct {
func (Membership) IsNode() {} func (Membership) IsNode() {}
func (this Membership) GetID() gid.GID { return this.ID } func (this Membership) GetID() gid.GID { return this.ID }
type MembershipConnection struct {
TotalCount int `json:"totalCount"`
Edges []*MembershipEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type MembershipEdge struct { type MembershipEdge struct {
Cursor page.CursorKey `json:"cursor"` Cursor page.CursorKey `json:"cursor"`
Node *Membership `json:"node"` Node *Membership `json:"node"`
@@ -1493,7 +1494,7 @@ type RemoveMemberInput struct {
} }
type RemoveMemberPayload struct { type RemoveMemberPayload struct {
Success bool `json:"success"` DeletedMemberID gid.GID `json:"deletedMemberId"`
} }
type Report struct { type Report struct {
@@ -2120,18 +2121,11 @@ type User struct {
Email string `json:"email"` Email string `json:"email"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
People *People `json:"people,omitempty"`
} }
func (User) IsNode() {} func (User) IsNode() {}
func (this User) GetID() gid.GID { return this.ID } func (this User) GetID() gid.GID { return this.ID }
type UserConnection struct {
TotalCount int `json:"totalCount"`
Edges []*UserEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type UserEdge struct { type UserEdge struct {
Cursor page.CursorKey `json:"cursor"` Cursor page.CursorKey `json:"cursor"`
Node *User `json:"node"` Node *User `json:"node"`
@@ -2316,4 +2310,5 @@ type Viewer struct {
ID gid.GID `json:"id"` ID gid.GID `json:"id"`
User *User `json:"user"` User *User `json:"user"`
Organizations *OrganizationConnection `json:"organizations"` Organizations *OrganizationConnection `json:"organizations"`
Invitations *InvitationConnection `json:"invitations"`
} }

View File

@@ -16,14 +16,28 @@ package types
import ( import (
"github.com/getprobo/probo/pkg/coredata" "github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page" "github.com/getprobo/probo/pkg/page"
) )
type ( type (
UserConnection struct {
TotalCount int `json:"totalCount"`
Edges []*UserEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
Resolver any
ParentID gid.GID
}
UserOrderBy OrderBy[coredata.UserOrderField] UserOrderBy OrderBy[coredata.UserOrderField]
) )
func NewUserConnection(p *page.Page[*coredata.User, coredata.UserOrderField]) *UserConnection { func NewUserConnection(
p *page.Page[*coredata.User, coredata.UserOrderField],
resolver any,
parentID gid.GID,
) *UserConnection {
var edges = make([]*UserEdge, len(p.Data)) var edges = make([]*UserEdge, len(p.Data))
for i := range edges { for i := range edges {
@@ -33,6 +47,8 @@ func NewUserConnection(p *page.Page[*coredata.User, coredata.UserOrderField]) *U
return &UserConnection{ return &UserConnection{
Edges: edges, Edges: edges,
PageInfo: NewPageInfo(p), PageInfo: NewPageInfo(p),
Resolver: resolver,
ParentID: parentID,
} }
} }

View File

@@ -891,27 +891,47 @@ func (r *frameworkConnectionResolver) TotalCount(ctx context.Context, obj *types
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver)) panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
} }
// Organization is the resolver for the organization field.
func (r *invitationResolver) Organization(ctx context.Context, obj *types.Invitation) (*types.Organization, error) {
organization, err := r.authzSvc.GetOrganizationByInvitationID(ctx, obj.ID)
if err != nil {
panic(fmt.Errorf("cannot load organization: %w", err))
}
return types.NewOrganization(organization), nil
}
// TotalCount is the resolver for the totalCount field. // TotalCount is the resolver for the totalCount field.
func (r *invitationConnectionResolver) TotalCount(ctx context.Context, obj *types.InvitationConnection) (int, error) { func (r *invitationConnectionResolver) TotalCount(ctx context.Context, obj *types.InvitationConnection) (int, error) {
currentUser := UserFromContext(ctx) switch obj.Resolver.(type) {
if currentUser == nil { case *organizationResolver:
return 0, fmt.Errorf("no authenticated user") authzSvc := r.AuthzService(ctx, obj.ParentID.TenantID())
} count, err := authzSvc.CountOrganizationInvitations(ctx, obj.ParentID)
memberships, err := r.authzSvc.GetAllUserOrganizations(ctx, currentUser.ID)
if err != nil || len(memberships) == 0 {
return 0, fmt.Errorf("user has no organization memberships")
}
orgID := memberships[0].ID
count, err := r.authzSvc.CountOrganizationInvitations(ctx, orgID)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to count invitations: %w", err) panic(fmt.Errorf("failed to count organization invitations: %w", err))
}
return count, nil
case *viewerResolver:
user := UserFromContext(ctx)
if user == nil {
panic(fmt.Errorf("no authenticated user"))
} }
invitationFilter := coredata.NewInvitationFilter(nil)
if obj.Filter != nil {
invitationFilter = coredata.NewInvitationFilter(obj.Filter.OnlyPending)
}
count, err := r.authzSvc.CountUserInvitations(ctx, user.EmailAddress, invitationFilter)
if err != nil {
panic(fmt.Errorf("failed to count user invitations: %w", err))
}
return count, nil return count, nil
} }
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
}
// Evidences is the resolver for the evidences field. // Evidences is the resolver for the evidences field.
func (r *measureResolver) Evidences(ctx context.Context, obj *types.Measure, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.EvidenceOrderBy) (*types.EvidenceConnection, error) { func (r *measureResolver) Evidences(ctx context.Context, obj *types.Measure, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.EvidenceOrderBy) (*types.EvidenceConnection, error) {
prb := r.ProboService(ctx, obj.ID.TenantID()) prb := r.ProboService(ctx, obj.ID.TenantID())
@@ -1052,23 +1072,17 @@ func (r *measureConnectionResolver) TotalCount(ctx context.Context, obj *types.M
// TotalCount is the resolver for the totalCount field. // TotalCount is the resolver for the totalCount field.
func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *types.MembershipConnection) (int, error) { func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *types.MembershipConnection) (int, error) {
currentUser := UserFromContext(ctx) switch obj.Resolver.(type) {
if currentUser == nil { case *organizationResolver:
return 0, fmt.Errorf("no authenticated user") authzSvc := r.AuthzService(ctx, obj.ParentID.TenantID())
} count, err := authzSvc.CountOrganizationMemberships(ctx, obj.ParentID)
memberships, err := r.authzSvc.GetAllUserOrganizations(ctx, currentUser.ID)
if err != nil || len(memberships) == 0 {
return 0, fmt.Errorf("user has no organization memberships")
}
orgID := memberships[0].ID
count, err := r.authzSvc.CountOrganizationMemberships(ctx, orgID)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to count memberships: %w", err) panic(fmt.Errorf("failed to count organization memberships: %w", err))
} }
return count, nil return count, nil
default:
panic(fmt.Errorf("unknown resolver type for membership connection"))
}
} }
// CreateOrganization is the resolver for the createOrganization field. // CreateOrganization is the resolver for the createOrganization field.
@@ -1104,7 +1118,6 @@ func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.C
ctx, ctx,
probo.CreatePeopleRequest{ probo.CreatePeopleRequest{
OrganizationID: organization.ID, OrganizationID: organization.ID,
UserID: &UserFromContext(ctx).ID,
FullName: UserFromContext(ctx).FullName, FullName: UserFromContext(ctx).FullName,
PrimaryEmailAddress: UserFromContext(ctx).EmailAddress, PrimaryEmailAddress: UserFromContext(ctx).EmailAddress,
AdditionalEmailAddresses: []string{}, AdditionalEmailAddresses: []string{},
@@ -1379,18 +1392,10 @@ func (r *mutationResolver) ConfirmEmail(ctx context.Context, input types.Confirm
// InviteUser is the resolver for the inviteUser field. // InviteUser is the resolver for the inviteUser field.
func (r *mutationResolver) InviteUser(ctx context.Context, input types.InviteUserInput) (*types.InviteUserPayload, error) { func (r *mutationResolver) InviteUser(ctx context.Context, input types.InviteUserInput) (*types.InviteUserPayload, error) {
user := UserFromContext(ctx) authzSvc := r.AuthzService(ctx, input.OrganizationID.TenantID())
invitation, err := authzSvc.InviteUserToOrganization(ctx, input.OrganizationID, input.Email, input.FullName, string(authz.RoleMember))
organizations, err := r.authzSvc.GetAllUserOrganizations(ctx, user.ID)
if err != nil { if err != nil {
panic(fmt.Errorf("failed to list organizations for user: %w", err)) panic(fmt.Errorf("failed to invite user to organization: %w", err))
}
for _, organization := range organizations {
if organization.ID == input.OrganizationID {
invitation, err := r.authzSvc.InviteUserToOrganization(ctx, input.OrganizationID, input.Email, input.FullName, string(authz.RoleMember))
if err != nil {
return nil, err
} }
if input.CreatePeople { if input.CreatePeople {
@@ -1411,16 +1416,25 @@ func (r *mutationResolver) InviteUser(ctx context.Context, input types.InviteUse
InvitationEdge: types.NewInvitationEdge(invitation, coredata.InvitationOrderFieldCreatedAt), InvitationEdge: types.NewInvitationEdge(invitation, coredata.InvitationOrderFieldCreatedAt),
}, nil }, nil
} }
// AcceptInvitation is the resolver for the acceptInvitation field.
func (r *mutationResolver) AcceptInvitation(ctx context.Context, input types.AcceptInvitationInput) (*types.AcceptInvitationPayload, error) {
user := UserFromContext(ctx)
invitation, err := r.authzSvc.AcceptInvitationByID(ctx, input.InvitationID, user.ID)
if err != nil {
panic(fmt.Errorf("failed to accept invitation: %w", err))
} }
return nil, fmt.Errorf("organization not found") return &types.AcceptInvitationPayload{Invitation: types.NewInvitation(invitation)}, nil
} }
// DeleteInvitation is the resolver for the deleteInvitation field. // DeleteInvitation is the resolver for the deleteInvitation field.
func (r *mutationResolver) DeleteInvitation(ctx context.Context, input types.DeleteInvitationInput) (*types.DeleteInvitationPayload, error) { func (r *mutationResolver) DeleteInvitation(ctx context.Context, input types.DeleteInvitationInput) (*types.DeleteInvitationPayload, error) {
err := r.authzSvc.DeleteInvitation(ctx, input.InvitationID) authzSvc := r.AuthzService(ctx, input.InvitationID.TenantID())
err := authzSvc.DeleteInvitation(ctx, input.InvitationID)
if err != nil { if err != nil {
return nil, err panic(fmt.Errorf("failed to delete invitation: %w", err))
} }
return &types.DeleteInvitationPayload{ return &types.DeleteInvitationPayload{
@@ -1430,25 +1444,13 @@ func (r *mutationResolver) DeleteInvitation(ctx context.Context, input types.Del
// RemoveMember is the resolver for the removeMember field. // RemoveMember is the resolver for the removeMember field.
func (r *mutationResolver) RemoveMember(ctx context.Context, input types.RemoveMemberInput) (*types.RemoveMemberPayload, error) { func (r *mutationResolver) RemoveMember(ctx context.Context, input types.RemoveMemberInput) (*types.RemoveMemberPayload, error) {
user := UserFromContext(ctx) authzSvc := r.AuthzService(ctx, input.OrganizationID.TenantID())
err := authzSvc.RemoveMemberFromOrganization(ctx, input.OrganizationID, input.MemberID)
organizations, err := r.authzSvc.GetAllUserOrganizations(ctx, user.ID)
if err != nil {
panic(fmt.Errorf("failed to list organizations for user: %w", err))
}
for _, organization := range organizations {
if organization.ID == input.OrganizationID {
err := r.authzSvc.RemoveMemberFromOrganization(ctx, input.OrganizationID, input.MemberID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &types.RemoveMemberPayload{Success: true}, nil return &types.RemoveMemberPayload{DeletedMemberID: input.MemberID}, nil
}
}
return nil, fmt.Errorf("organization not found")
} }
// CreatePeople is the resolver for the createPeople field. // CreatePeople is the resolver for the createPeople field.
@@ -3611,16 +3613,17 @@ func (r *organizationResolver) Memberships(ctx context.Context, obj *types.Organ
cursor := types.NewCursor(first, after, last, before, pageOrderBy) cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.authzSvc.GetAllOrganizationMemberships(ctx, obj.ID, cursor) authzSvc := r.AuthzService(ctx, obj.ID.TenantID())
page, err := authzSvc.GetMembershipsByOrganizationID(ctx, obj.ID, cursor)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot list memberships: %w", err)) panic(fmt.Errorf("cannot list memberships: %w", err))
} }
return types.NewMembershipConnection(page), nil return types.NewMembershipConnection(page, r, obj.ID), nil
} }
// Invitations is the resolver for the invitations field. // Invitations is the resolver for the invitations field.
func (r *organizationResolver) Invitations(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.InvitationOrder) (*types.InvitationConnection, error) { func (r *organizationResolver) Invitations(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.InvitationOrder, filter *types.InvitationFilter) (*types.InvitationConnection, error) {
pageOrderBy := page.OrderBy[coredata.InvitationOrderField]{ pageOrderBy := page.OrderBy[coredata.InvitationOrderField]{
Field: coredata.InvitationOrderFieldCreatedAt, Field: coredata.InvitationOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc, Direction: page.OrderDirectionDesc,
@@ -3634,12 +3637,13 @@ func (r *organizationResolver) Invitations(ctx context.Context, obj *types.Organ
cursor := types.NewCursor(first, after, last, before, pageOrderBy) cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.authzSvc.GetAllOrganizationInvitations(ctx, obj.ID, cursor) authzSvc := r.AuthzService(ctx, obj.ID.TenantID())
page, err := authzSvc.GetInvitationsByOrganizationID(ctx, obj.ID, cursor)
if err != nil { if err != nil {
panic(fmt.Errorf("cannot list invitations: %w", err)) panic(fmt.Errorf("cannot list invitations: %w", err))
} }
return types.NewInvitationConnection(page), nil return types.NewInvitationConnection(page, r, obj.ID, filter), nil
} }
// Connectors is the resolver for the connectors field. // Connectors is the resolver for the connectors field.
@@ -4943,41 +4947,19 @@ func (r *trustCenterReferenceConnectionResolver) TotalCount(ctx context.Context,
return count, nil return count, nil
} }
// People is the resolver for the people field.
func (r *userResolver) People(ctx context.Context, obj *types.User, organizationID gid.GID) (*types.People, error) {
prb := r.ProboService(ctx, organizationID.TenantID())
people, err := prb.Peoples.GetByUserID(ctx, obj.ID)
if err != nil {
var errPeopleNotFound *coredata.ErrPeopleNotFound
if errors.As(err, &errPeopleNotFound) {
return nil, nil
}
panic(fmt.Errorf("failed to get people: %w", err))
}
return types.NewPeople(people), nil
}
// TotalCount is the resolver for the totalCount field. // TotalCount is the resolver for the totalCount field.
func (r *userConnectionResolver) TotalCount(ctx context.Context, obj *types.UserConnection) (int, error) { func (r *userConnectionResolver) TotalCount(ctx context.Context, obj *types.UserConnection) (int, error) {
currentUser := UserFromContext(ctx) switch obj.Resolver.(type) {
if currentUser == nil { case *organizationResolver:
return 0, fmt.Errorf("no authenticated user") authzSvc := r.AuthzService(ctx, obj.ParentID.TenantID())
} count, err := authzSvc.CountOrganizationUsers(ctx, obj.ParentID)
memberships, err := r.authzSvc.GetAllUserOrganizations(ctx, currentUser.ID)
if err != nil || len(memberships) == 0 {
return 0, fmt.Errorf("user has no organization memberships")
}
orgID := memberships[0].ID
count, err := r.authzSvc.CountOrganizationMemberships(ctx, orgID)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to count memberships: %w", err) panic(fmt.Errorf("failed to count organization users: %w", err))
} }
return count, nil return count, nil
default:
panic(fmt.Errorf("unknown resolver type for user connection"))
}
} }
// Organization is the resolver for the organization field. // Organization is the resolver for the organization field.
@@ -5353,6 +5335,35 @@ func (r *viewerResolver) Organizations(ctx context.Context, obj *types.Viewer, f
return types.NewOrganizationConnection(page), nil return types.NewOrganizationConnection(page), nil
} }
// Invitations is the resolver for the invitations field.
func (r *viewerResolver) Invitations(ctx context.Context, obj *types.Viewer, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.InvitationOrder, filter *types.InvitationFilter) (*types.InvitationConnection, error) {
user := UserFromContext(ctx)
pageOrderBy := page.OrderBy[coredata.InvitationOrderField]{
Field: coredata.InvitationOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.InvitationOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
invitationFilter := coredata.NewInvitationFilter(nil)
if filter != nil {
invitationFilter = coredata.NewInvitationFilter(filter.OnlyPending)
}
invitations, err := r.authzSvc.GetUserInvitations(ctx, user.EmailAddress, cursor, invitationFilter)
if err != nil {
panic(fmt.Errorf("failed to list invitations for user: %w", err))
}
return types.NewInvitationConnection(invitations, r, gid.GID{}, filter), nil
}
// Asset returns schema.AssetResolver implementation. // Asset returns schema.AssetResolver implementation.
func (r *Resolver) Asset() schema.AssetResolver { return &assetResolver{r} } func (r *Resolver) Asset() schema.AssetResolver { return &assetResolver{r} }
@@ -5432,6 +5443,9 @@ func (r *Resolver) FrameworkConnection() schema.FrameworkConnectionResolver {
return &frameworkConnectionResolver{r} return &frameworkConnectionResolver{r}
} }
// Invitation returns schema.InvitationResolver implementation.
func (r *Resolver) Invitation() schema.InvitationResolver { return &invitationResolver{r} }
// InvitationConnection returns schema.InvitationConnectionResolver implementation. // InvitationConnection returns schema.InvitationConnectionResolver implementation.
func (r *Resolver) InvitationConnection() schema.InvitationConnectionResolver { func (r *Resolver) InvitationConnection() schema.InvitationConnectionResolver {
return &invitationConnectionResolver{r} return &invitationConnectionResolver{r}
@@ -5541,9 +5555,6 @@ func (r *Resolver) TrustCenterReferenceConnection() schema.TrustCenterReferenceC
return &trustCenterReferenceConnectionResolver{r} return &trustCenterReferenceConnectionResolver{r}
} }
// User returns schema.UserResolver implementation.
func (r *Resolver) User() schema.UserResolver { return &userResolver{r} }
// UserConnection returns schema.UserConnectionResolver implementation. // UserConnection returns schema.UserConnectionResolver implementation.
func (r *Resolver) UserConnection() schema.UserConnectionResolver { return &userConnectionResolver{r} } func (r *Resolver) UserConnection() schema.UserConnectionResolver { return &userConnectionResolver{r} }
@@ -5603,6 +5614,7 @@ type evidenceConnectionResolver struct{ *Resolver }
type fileResolver struct{ *Resolver } type fileResolver struct{ *Resolver }
type frameworkResolver struct{ *Resolver } type frameworkResolver struct{ *Resolver }
type frameworkConnectionResolver struct{ *Resolver } type frameworkConnectionResolver struct{ *Resolver }
type invitationResolver struct{ *Resolver }
type invitationConnectionResolver struct{ *Resolver } type invitationConnectionResolver struct{ *Resolver }
type measureResolver struct{ *Resolver } type measureResolver struct{ *Resolver }
type measureConnectionResolver struct{ *Resolver } type measureConnectionResolver struct{ *Resolver }
@@ -5630,7 +5642,6 @@ type trustCenterDocumentAccessResolver struct{ *Resolver }
type trustCenterDocumentAccessConnectionResolver struct{ *Resolver } type trustCenterDocumentAccessConnectionResolver struct{ *Resolver }
type trustCenterReferenceResolver struct{ *Resolver } type trustCenterReferenceResolver struct{ *Resolver }
type trustCenterReferenceConnectionResolver struct{ *Resolver } type trustCenterReferenceConnectionResolver struct{ *Resolver }
type userResolver struct{ *Resolver }
type userConnectionResolver struct{ *Resolver } type userConnectionResolver struct{ *Resolver }
type vendorResolver struct{ *Resolver } type vendorResolver struct{ *Resolver }
type vendorBusinessAssociateAgreementResolver struct{ *Resolver } type vendorBusinessAssociateAgreementResolver struct{ *Resolver }