Refactoring of authentification

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-09-08 10:02:52 +02:00
committed by Sacha Al Himdani
parent afa7e4fdd5
commit 0f96b8518f
53 changed files with 7997 additions and 1631 deletions

View File

@@ -17,9 +17,22 @@ import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { Controller } from "react-hook-form";
const inviteMutation = graphql`
mutation InviteUserDialogMutation($input: InviteUserInput!) {
mutation InviteUserDialogMutation(
$input: InviteUserInput!
$connections: [ID!]!
) {
inviteUser(input: $input) {
success
invitationEdge @appendEdge(connections: $connections) {
node {
id
email
fullName
role
expiresAt
acceptedAt
createdAt
}
}
}
}
`;
@@ -30,7 +43,11 @@ const schema = z.object({
createPeople: z.boolean().default(false),
});
export function InviteUserDialog({ children }: PropsWithChildren) {
type Props = PropsWithChildren & {
connectionId?: string;
};
export function InviteUserDialog({ children, connectionId }: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const [inviteUser, isInviting] = useMutationWithToasts(inviteMutation, {
@@ -53,6 +70,7 @@ export function InviteUserDialog({ children }: PropsWithChildren) {
fullName: data.fullName,
createPeople: data.createPeople,
},
connections: connectionId ? [connectionId] : ["SettingsPageInvitations_invitations"],
},
onSuccess: () => {
reset();

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<9ed3f33dde5f6a00c742b8db245ba03b>>
* @generated SignedSource<<8efbb638045cb24913b04b4de2033357>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -16,11 +16,22 @@ export type InviteUserInput = {
organizationId: string;
};
export type InviteUserDialogMutation$variables = {
connections: ReadonlyArray<string>;
input: InviteUserInput;
};
export type InviteUserDialogMutation$data = {
readonly inviteUser: {
readonly success: boolean;
readonly invitationEdge: {
readonly node: {
readonly acceptedAt: any | null | undefined;
readonly createdAt: any;
readonly email: string;
readonly expiresAt: any;
readonly fullName: string;
readonly id: string;
readonly role: string;
};
};
};
};
export type InviteUserDialogMutation = {
@@ -29,67 +40,170 @@ export type InviteUserDialogMutation = {
};
const node: ConcreteRequest = (function(){
var v0 = [
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "InviteUserPayload",
"kind": "LinkedField",
"name": "inviteUser",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "success",
"storageKey": null
}
],
"storageKey": null
}
];
v3 = {
"alias": null,
"args": null,
"concreteType": "InvitationEdge",
"kind": "LinkedField",
"name": "invitationEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Invitation",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"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
}
],
"storageKey": null
}
],
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "InviteUserDialogMutation",
"selections": (v1/*: any*/),
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "InviteUserPayload",
"kind": "LinkedField",
"name": "inviteUser",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "InviteUserDialogMutation",
"selections": (v1/*: any*/)
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "InviteUserPayload",
"kind": "LinkedField",
"name": "inviteUser",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "appendEdge",
"key": "",
"kind": "LinkedHandle",
"name": "invitationEdge",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "aa667927b5e2cd0019a8457edc286181",
"cacheID": "2f6a83e238f7749e18757ec74e86b64b",
"id": null,
"metadata": {},
"name": "InviteUserDialogMutation",
"operationKind": "mutation",
"text": "mutation InviteUserDialogMutation(\n $input: InviteUserInput!\n) {\n inviteUser(input: $input) {\n success\n }\n}\n"
"text": "mutation InviteUserDialogMutation(\n $input: InviteUserInput!\n) {\n inviteUser(input: $input) {\n invitationEdge {\n node {\n id\n email\n fullName\n role\n expiresAt\n acceptedAt\n createdAt\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "de4c5b5208a2ff0953e9d15b844df842";
(node as any).hash = "3981061f31a11e83ad32bed9fabddf64";
export default node;

View File

@@ -10,6 +10,8 @@ export const organizationViewQuery = graphql`
id
name
...SettingsPageFragment
...SettingsPageMembershipsFragment
...SettingsPageInvitationsFragment
}
}
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<5a41cd3709282172e4d5a3ca65d07b96>>
* @generated SignedSource<<df47e943d9509dbbe4d7cf9498941171>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -17,7 +17,7 @@ export type OrganizationGraph_ViewQuery$data = {
readonly node: {
readonly id?: string;
readonly name?: string;
readonly " $fragmentSpreads": FragmentRefs<"SettingsPageFragment">;
readonly " $fragmentSpreads": FragmentRefs<"SettingsPageFragment" | "SettingsPageInvitationsFragment" | "SettingsPageMembershipsFragment">;
};
};
export type OrganizationGraph_ViewQuery = {
@@ -58,29 +58,133 @@ v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"name": "__typename",
"storageKey": null
},
v5 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "type",
"name": "email",
"storageKey": null
},
v6 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "type",
"storageKey": null
},
v7 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
v7 = [
v8 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
v9 = [
{
"kind": "Literal",
"name": "first",
"value": 100
"value": 20
},
{
"kind": "Literal",
"name": "orderBy",
"value": {
"direction": "ASC",
"field": "CREATED_AT"
}
}
],
v10 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "totalCount",
"storageKey": null
},
v11 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
v12 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "role",
"storageKey": null
},
v13 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
},
v14 = {
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
},
v15 = {
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
},
v16 = [
"orderBy"
];
return {
"fragment": {
@@ -106,6 +210,16 @@ return {
"args": null,
"kind": "FragmentSpread",
"name": "SettingsPageFragment"
},
{
"args": null,
"kind": "FragmentSpread",
"name": "SettingsPageMembershipsFragment"
},
{
"args": null,
"kind": "FragmentSpread",
"name": "SettingsPageInvitationsFragment"
}
],
"type": "Organization",
@@ -132,13 +246,7 @@ return {
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
(v4/*: any*/),
(v2/*: any*/),
{
"kind": "InlineFragment",
@@ -172,7 +280,7 @@ return {
"name": "websiteUrl",
"storageKey": null
},
(v4/*: any*/),
(v5/*: any*/),
{
"alias": null,
"args": null,
@@ -211,7 +319,7 @@ return {
"name": "dnsRecords",
"plural": true,
"selections": [
(v5/*: any*/),
(v6/*: any*/),
(v3/*: any*/),
{
"alias": null,
@@ -237,14 +345,8 @@ return {
],
"storageKey": null
},
(v6/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
(v7/*: any*/),
(v8/*: any*/),
{
"alias": null,
"args": null,
@@ -255,52 +357,17 @@ return {
],
"storageKey": null
},
(v7/*: any*/),
(v8/*: any*/),
{
"alias": null,
"args": (v7/*: any*/),
"concreteType": "UserConnection",
"kind": "LinkedField",
"name": "users",
"plural": false,
"selections": [
"args": [
{
"alias": null,
"args": null,
"concreteType": "UserEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "User",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
(v4/*: any*/),
(v6/*: any*/)
],
"storageKey": null
}
],
"storageKey": null
"kind": "Literal",
"name": "first",
"value": 100
}
],
"storageKey": "users(first:100)"
},
{
"alias": null,
"args": (v7/*: any*/),
"concreteType": "ConnectorConnection",
"kind": "LinkedField",
"name": "connectors",
@@ -324,8 +391,8 @@ return {
"selections": [
(v2/*: any*/),
(v3/*: any*/),
(v5/*: any*/),
(v6/*: any*/)
(v6/*: any*/),
(v7/*: any*/)
],
"storageKey": null
}
@@ -334,6 +401,130 @@ return {
}
],
"storageKey": "connectors(first:100)"
},
{
"alias": null,
"args": (v9/*: any*/),
"concreteType": "MembershipConnection",
"kind": "LinkedField",
"name": "memberships",
"plural": false,
"selections": [
(v10/*: any*/),
{
"alias": null,
"args": null,
"concreteType": "MembershipEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Membership",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v2/*: any*/),
(v11/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "emailAddress",
"storageKey": null
},
(v12/*: any*/),
(v7/*: any*/),
(v4/*: any*/)
],
"storageKey": null
},
(v13/*: any*/)
],
"storageKey": null
},
(v14/*: any*/),
(v15/*: any*/)
],
"storageKey": "memberships(first:20,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
},
{
"alias": null,
"args": (v9/*: any*/),
"filters": (v16/*: any*/),
"handle": "connection",
"key": "SettingsPageMemberships_memberships",
"kind": "LinkedHandle",
"name": "memberships"
},
{
"alias": null,
"args": (v9/*: any*/),
"concreteType": "InvitationConnection",
"kind": "LinkedField",
"name": "invitations",
"plural": false,
"selections": [
(v10/*: any*/),
{
"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": [
(v2/*: any*/),
(v5/*: any*/),
(v11/*: any*/),
(v12/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "expiresAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "acceptedAt",
"storageKey": null
},
(v7/*: any*/),
(v4/*: any*/)
],
"storageKey": null
},
(v13/*: any*/)
],
"storageKey": null
},
(v14/*: any*/),
(v15/*: any*/)
],
"storageKey": "invitations(first:20,orderBy:{\"direction\":\"ASC\",\"field\":\"CREATED_AT\"})"
},
{
"alias": null,
"args": (v9/*: any*/),
"filters": (v16/*: any*/),
"handle": "connection",
"key": "SettingsPageInvitations_invitations",
"kind": "LinkedHandle",
"name": "invitations"
}
],
"type": "Organization",
@@ -345,16 +536,16 @@ return {
]
},
"params": {
"cacheID": "8b9e1f3b1e93e1823354e77f10764e54",
"cacheID": "eb300e10b8aec548831c1610ea7bc0ec",
"id": null,
"metadata": {},
"name": "OrganizationGraph_ViewQuery",
"operationKind": "query",
"text": "query OrganizationGraph_ViewQuery(\n $organizationId: ID!\n) {\n node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n name\n ...SettingsPageFragment\n }\n id\n }\n}\n\nfragment SettingsPageFragment on Organization {\n id\n name\n logoUrl\n horizontalLogoUrl\n description\n websiteUrl\n email\n headquarterAddress\n customDomain {\n id\n domain\n sslStatus\n dnsRecords {\n type\n name\n value\n ttl\n purpose\n }\n createdAt\n updatedAt\n sslExpiresAt\n }\n users(first: 100) {\n edges {\n node {\n id\n fullName\n email\n createdAt\n }\n }\n }\n connectors(first: 100) {\n edges {\n node {\n id\n name\n type\n createdAt\n }\n }\n }\n}\n"
"text": "query OrganizationGraph_ViewQuery(\n $organizationId: ID!\n) {\n node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n name\n ...SettingsPageFragment\n ...SettingsPageMembershipsFragment\n ...SettingsPageInvitationsFragment\n }\n id\n }\n}\n\nfragment SettingsPageFragment on Organization {\n id\n name\n logoUrl\n horizontalLogoUrl\n description\n websiteUrl\n email\n headquarterAddress\n customDomain {\n id\n domain\n sslStatus\n dnsRecords {\n type\n name\n value\n ttl\n purpose\n }\n createdAt\n updatedAt\n sslExpiresAt\n }\n createdAt\n updatedAt\n connectors(first: 100) {\n edges {\n node {\n id\n name\n type\n createdAt\n }\n }\n }\n}\n\nfragment SettingsPageInvitationsFragment on Organization {\n invitations(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n email\n fullName\n role\n expiresAt\n acceptedAt\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment SettingsPageMembershipsFragment on Organization {\n memberships(first: 20, orderBy: {direction: ASC, field: CREATED_AT}) {\n totalCount\n edges {\n node {\n id\n fullName\n emailAddress\n role\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "196e8c1fc9c2e0b3c76c8b338ed5c7f7";
(node as any).hash = "fda1489f2b80fd3d0b3962574bd7dfe3";
export default node;

View File

@@ -1,5 +1,4 @@
import {
ActionDropdown,
Avatar,
Badge,
Button,
@@ -7,14 +6,21 @@ import {
Dialog,
DialogContent,
DialogFooter,
DropdownItem,
Field,
FileButton,
IconTrashCan,
Label,
PageHeader,
Spinner,
TabBadge,
TabItem,
Tabs,
Tbody,
Td,
Textarea,
Th,
Thead,
Tr,
useConfirm,
useDialogRef,
useToast,
@@ -22,19 +28,28 @@ import {
import { useTranslate } from "@probo/i18n";
import type { PreloadedQuery } from "react-relay";
import type { OrganizationGraph_ViewQuery } from "/hooks/graph/__generated__/OrganizationGraph_ViewQuery.graphql";
import { useFragment, useMutation, usePreloadedQuery } from "react-relay";
import { useFragment, useMutation, usePreloadedQuery, usePaginationFragment } from "react-relay";
import { organizationViewQuery } from "/hooks/graph/OrganizationGraph";
import { graphql } from "relay-runtime";
import { SortableTable, SortableTh } from "/components/SortableTable";
import clsx from "clsx";
import type {
SettingsPageFragment$data,
SettingsPageFragment$key,
} from "./__generated__/SettingsPageFragment.graphql";
import type {
SettingsPageMembershipsFragment$data,
SettingsPageMembershipsFragment$key
} from "./__generated__/SettingsPageMembershipsFragment.graphql";
import type {
SettingsPageInvitationsFragment$data,
SettingsPageInvitationsFragment$key
} from "./__generated__/SettingsPageInvitationsFragment.graphql";
import { useState, type ChangeEventHandler, useEffect } from "react";
import { sprintf } from "@probo/helpers";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { z } from "zod";
import type { NodeOf } from "/types";
import clsx from "clsx";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { InviteUserDialog } from "/components/organizations/InviteUserDialog";
@@ -82,16 +97,8 @@ const organizationFragment = graphql`
updatedAt
sslExpiresAt
}
users(first: 100) {
edges {
node {
id
fullName
email
createdAt
}
}
}
createdAt
updatedAt
connectors(first: 100) {
edges {
node {
@@ -105,6 +112,83 @@ const organizationFragment = graphql`
}
`;
const paginatedMembershipsFragment = graphql`
fragment SettingsPageMembershipsFragment on Organization
@refetchable(queryName: "SettingsMembershipsRefetchQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 20 }
order: { type: "MembershipOrder", defaultValue: { direction: ASC, field: CREATED_AT } }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
memberships(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "SettingsPageMemberships_memberships") {
__id
totalCount
edges {
node {
id
fullName
emailAddress
role
createdAt
}
}
}
}
`;
const paginatedInvitationsFragment = graphql`
fragment SettingsPageInvitationsFragment on Organization
@refetchable(queryName: "SettingsInvitationsRefetchQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 20 }
order: { type: "InvitationOrder", defaultValue: { direction: ASC, field: CREATED_AT } }
after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null }
) {
invitations(
first: $first
after: $after
last: $last
before: $before
orderBy: $order
) @connection(key: "SettingsPageInvitations_invitations") {
__id
totalCount
edges {
node {
id
email
fullName
role
expiresAt
acceptedAt
createdAt
}
}
}
}
`;
const deleteInvitationMutation = graphql`
mutation SettingsPage_DeleteInvitationMutation(
$input: DeleteInvitationInput!
$connections: [ID!]!
) {
deleteInvitation(input: $input) {
deletedInvitationId @deleteEdge(connections: $connections)
}
}
`;
const updateOrganizationMutation = graphql`
mutation SettingsPage_UpdateMutation($input: UpdateOrganizationInput!) {
updateOrganization(input: $input) {
@@ -145,6 +229,17 @@ export default function SettingsPage({ queryRef }: Props) {
organizationFragment,
organizationKey
);
const membershipsPagination = usePaginationFragment(
paginatedMembershipsFragment,
organizationKey as SettingsPageMembershipsFragment$key
);
const invitationsPagination = usePaginationFragment(
paginatedInvitationsFragment,
organizationKey as SettingsPageInvitationsFragment$key
);
const [updateOrganization] = useMutation(updateOrganizationMutation);
const [deleteHorizontalLogo, isDeletingHorizontalLogo] = useMutationWithToasts(
deleteHorizontalLogoMutation,
@@ -154,7 +249,27 @@ export default function SettingsPage({ queryRef }: Props) {
}
);
const [deleteOrganization, isDeleting] = useDeleteOrganizationMutation();
const users = organization.users.edges.map((edge) => edge.node);
const memberships = membershipsPagination.data.memberships?.edges.map((edge) => edge.node) || [];
const invitations = invitationsPagination.data.invitations?.edges.map((edge) => edge.node) || [];
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(
organizationSchema,
@@ -454,19 +569,103 @@ export default function SettingsPage({ queryRef }: Props) {
</Card>
</div>
</form>
{/* Integrations */}
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-base font-medium">{__("Workspace members")}</h2>
<InviteUserDialog>
<InviteUserDialog connectionId={invitationsPagination.data.invitations?.__id}>
<Button variant="secondary">{__("Invite member")}</Button>
</InviteUserDialog>
</div>
<Card className="divide-y divide-border-solid">
{users.map((user) => (
<UserRow key={user.id} user={user} />
))}
<Tabs>
<TabItem
active={activeTab === "memberships"}
onClick={() => setActiveTab("memberships")}
>
{__("Members")}
{(membershipsPagination.data.memberships?.totalCount || 0) > 0 && (
<TabBadge>{membershipsPagination.data.memberships?.totalCount}</TabBadge>
)}
</TabItem>
<TabItem
active={activeTab === "invitations"}
onClick={() => setActiveTab("invitations")}
>
{__("Invitations")}
{(invitationsPagination.data.invitations?.totalCount || 0) > 0 && (
<TabBadge>{invitationsPagination.data.invitations?.totalCount}</TabBadge>
)}
</TabItem>
</Tabs>
<Card>
<div className="px-6 pb-6 pt-6">
{activeTab === "memberships" && (
<SortableTable
{...membershipsPagination}
refetch={refetchMemberships}
>
<Thead>
<Tr>
<SortableTh field="FULL_NAME">{__("Name")}</SortableTh>
<SortableTh field="EMAIL_ADDRESS">{__("Email")}</SortableTh>
<SortableTh field="ROLE">{__("Role")}</SortableTh>
<SortableTh field="CREATED_AT">{__("Joined")}</SortableTh>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{memberships.length === 0 ? (
<Tr>
<Td colSpan={5} className="text-center text-txt-secondary">
{__("No members")}
</Td>
</Tr>
) : (
memberships.map((membership) => (
<MembershipRow key={membership.id} membership={membership} />
))
)}
</Tbody>
</SortableTable>
)}
{activeTab === "invitations" && (
<SortableTable
{...invitationsPagination}
refetch={refetchInvitations}
>
<Thead>
<Tr>
<SortableTh field="FULL_NAME">{__("Name")}</SortableTh>
<SortableTh field="EMAIL">{__("Email")}</SortableTh>
<SortableTh field="ROLE">{__("Role")}</SortableTh>
<SortableTh field="CREATED_AT">{__("Invited")}</SortableTh>
<Th>{__("Status")}</Th>
<SortableTh field="ACCEPTED_AT">{__("Accepted at")}</SortableTh>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{invitations.length === 0 ? (
<Tr>
<Td colSpan={7} className="text-center text-txt-secondary">
{__("No invitations")}
</Td>
</Tr>
) : (
invitations.map((invitation) => (
<InvitationRow
key={invitation.id}
invitation={invitation}
connectionId={invitationsPagination.data.invitations?.__id}
/>
))
)}
</Tbody>
</SortableTable>
)}
</div>
</Card>
</div>
@@ -594,23 +793,107 @@ function Connectors(props: {
);
}
const removeUserMutation = graphql`
mutation SettingsPage_RemoveUserMutation($input: RemoveUserInput!) {
removeUser(input: $input) {
const removeMemberMutation = graphql`
mutation SettingsPage_RemoveMemberMutation($input: RemoveMemberInput!) {
removeMember(input: $input) {
success
}
}
`;
function UserRow(props: { user: NodeOf<SettingsPageFragment$data["users"]> }) {
function InvitationRow(props: {
invitation: NodeOf<SettingsPageInvitationsFragment$data["invitations"]>;
connectionId?: string;
}) {
const { __ } = useTranslate();
const confirm = useConfirm();
const [deleteInvitation, isDeleting] = useMutationWithToasts(
deleteInvitationMutation,
{
successMessage: sprintf(
__("Invitation for %s deleted successfully"),
props.invitation.fullName
),
}
);
const isExpired = new Date() > new Date(props.invitation.expiresAt);
const isAccepted = !!props.invitation.acceptedAt;
const onDelete = () => {
confirm(
() => {
return deleteInvitation({
variables: {
input: {
invitationId: props.invitation.id,
},
connections: props.connectionId ? [props.connectionId] : [],
},
});
},
{
message: sprintf(
__("Are you sure you want to delete the invitation for %s?"),
props.invitation.fullName
),
}
);
};
return (
<Tr className={clsx(isDeleting && "opacity-60 pointer-events-none")}>
<Td>
<div className="font-semibold">{props.invitation.fullName}</div>
</Td>
<Td>{props.invitation.email}</Td>
<Td>
<Badge>{props.invitation.role}</Badge>
</Td>
<Td>{new Date(props.invitation.createdAt).toLocaleDateString()}</Td>
<Td>
{isAccepted ? (
<Badge variant="success">{__("Accepted")}</Badge>
) : isExpired ? (
<Badge variant="danger">{__("Expired")}</Badge>
) : (
<Badge variant="warning">{__("Pending")}</Badge>
)}
</Td>
<Td>
{props.invitation.acceptedAt ? new Date(props.invitation.acceptedAt).toLocaleDateString() : "-"}
</Td>
<Td noLink width={80} className="text-end">
<div
className="flex gap-2 justify-end"
onClick={(e) => e.stopPropagation()}
>
{isDeleting ? (
<Spinner size={16} />
) : (
<Button
variant="danger"
onClick={onDelete}
disabled={isDeleting}
icon={IconTrashCan}
aria-label={__("Delete invitation")}
/>
)}
</div>
</Td>
</Tr>
);
}
function MembershipRow(props: { membership: NodeOf<SettingsPageMembershipsFragment$data["memberships"]> }) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const [removeUser, isRemoving] = useMutationWithToasts(removeUserMutation, {
const [removeMember, isRemoving] = useMutationWithToasts(removeMemberMutation, {
successMessage: sprintf(
__("User %s removed successfully"),
props.user.fullName
__("Member %s removed successfully"),
props.membership.fullName
),
errorMessage: sprintf(__("Failed to remove user %s"), props.user.fullName),
errorMessage: sprintf(__("Failed to remove member %s"), props.membership.fullName),
});
const confirm = useConfirm();
const [isRemoved, setIsRemoved] = useState(false);
@@ -622,10 +905,10 @@ function UserRow(props: { user: NodeOf<SettingsPageFragment$data["users"]> }) {
const onRemove = async () => {
confirm(
() => {
return removeUser({
return removeMember({
variables: {
input: {
userId: props.user.id,
memberId: props.membership.id,
organizationId: organizationId,
},
},
@@ -637,42 +920,40 @@ function UserRow(props: { user: NodeOf<SettingsPageFragment$data["users"]> }) {
{
message: sprintf(
__("Are you sure you want to remove %s?"),
props.user.fullName
props.membership.fullName
),
}
);
};
return (
<div
className={clsx(
"flex justify-between items-center py-4 px-4",
isRemoving && "opacity-60 pointer-events-none"
)}
>
<div className="flex items-center gap-4">
<Avatar name={props.user.fullName} size="l" />
<div>
<h3 className="text-base font-semibold">{props.user.fullName}</h3>
<p className="text-sm text-txt-tertiary">{props.user.email}</p>
</div>
</div>
<div className="flex gap-2 items-center">
<Badge>{__("Owner")}</Badge>
{isRemoving ? (
<Spinner size={16} />
) : (
<ActionDropdown>
<DropdownItem
<Tr className={clsx(isRemoving && "opacity-60 pointer-events-none")}>
<Td>
<div className="font-semibold">{props.membership.fullName}</div>
</Td>
<Td>{props.membership.emailAddress}</Td>
<Td>
<Badge>{props.membership.role}</Badge>
</Td>
<Td>{new Date(props.membership.createdAt).toLocaleDateString()}</Td>
<Td noLink width={80} className="text-end">
<div
className="flex gap-2 justify-end"
onClick={(e) => e.stopPropagation()}
>
{isRemoving ? (
<Spinner size={16} />
) : (
<Button
variant="danger"
icon={IconTrashCan}
onClick={onRemove}
>
{__("Remove")}
</DropdownItem>
</ActionDropdown>
)}
</div>
</div>
disabled={isRemoving}
icon={IconTrashCan}
aria-label={__("Remove member")}
/>
)}
</div>
</Td>
</Tr>
);
}

View File

@@ -0,0 +1,368 @@
/**
* @generated SignedSource<<9b6812ed468b85a1d3a8684ed1c3ad6f>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type InvitationOrderField = "ACCEPTED_AT" | "CREATED_AT" | "EMAIL" | "EXPIRES_AT" | "FULL_NAME" | "ROLE";
export type OrderDirection = "ASC" | "DESC";
export type InvitationOrder = {
direction: OrderDirection;
field: InvitationOrderField;
};
export type SettingsInvitationsRefetchQuery$variables = {
after?: any | null | undefined;
before?: any | null | undefined;
first?: number | null | undefined;
id: string;
last?: number | null | undefined;
order?: InvitationOrder | null | undefined;
};
export type SettingsInvitationsRefetchQuery$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"SettingsPageInvitationsFragment">;
};
};
export type SettingsInvitationsRefetchQuery = {
response: SettingsInvitationsRefetchQuery$data;
variables: SettingsInvitationsRefetchQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
v2 = {
"defaultValue": 20,
"kind": "LocalArgument",
"name": "first"
},
v3 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "id"
},
v4 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
v5 = {
"defaultValue": {
"direction": "ASC",
"field": "CREATED_AT"
},
"kind": "LocalArgument",
"name": "order"
},
v6 = [
{
"kind": "Variable",
"name": "id",
"variableName": "id"
}
],
v7 = {
"kind": "Variable",
"name": "after",
"variableName": "after"
},
v8 = {
"kind": "Variable",
"name": "before",
"variableName": "before"
},
v9 = {
"kind": "Variable",
"name": "first",
"variableName": "first"
},
v10 = {
"kind": "Variable",
"name": "last",
"variableName": "last"
},
v11 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v12 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v13 = [
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
];
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "SettingsInvitationsRefetchQuery",
"selections": [
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": [
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
{
"kind": "Variable",
"name": "order",
"variableName": "order"
}
],
"kind": "FragmentSpread",
"name": "SettingsPageInvitationsFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v3/*: any*/)
],
"kind": "Operation",
"name": "SettingsInvitationsRefetchQuery",
"selections": [
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v11/*: any*/),
(v12/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v13/*: any*/),
"concreteType": "InvitationConnection",
"kind": "LinkedField",
"name": "invitations",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "totalCount",
"storageKey": null
},
{
"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": [
(v12/*: 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
},
(v11/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
},
{
"alias": null,
"args": (v13/*: any*/),
"filters": [
"orderBy"
],
"handle": "connection",
"key": "SettingsPageInvitations_invitations",
"kind": "LinkedHandle",
"name": "invitations"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "d1ad09b7e16875a4fc09c5e8dc7e3b7e",
"id": null,
"metadata": {},
"name": "SettingsInvitationsRefetchQuery",
"operationKind": "query",
"text": "query SettingsInvitationsRefetchQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: InvitationOrder = {direction: ASC, field: CREATED_AT}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...SettingsPageInvitationsFragment_16fISc\n id\n }\n}\n\nfragment SettingsPageInvitationsFragment_16fISc on Organization {\n invitations(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n totalCount\n edges {\n node {\n id\n email\n fullName\n role\n expiresAt\n acceptedAt\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "d971d93653991efde284ed2b87068698";
export default node;

View File

@@ -0,0 +1,354 @@
/**
* @generated SignedSource<<49986e7498d757f2074e93e6cf4747a5>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type MembershipOrderField = "CREATED_AT" | "EMAIL_ADDRESS" | "FULL_NAME" | "ROLE";
export type OrderDirection = "ASC" | "DESC";
export type MembershipOrder = {
direction: OrderDirection;
field: MembershipOrderField;
};
export type SettingsMembershipsRefetchQuery$variables = {
after?: any | null | undefined;
before?: any | null | undefined;
first?: number | null | undefined;
id: string;
last?: number | null | undefined;
order?: MembershipOrder | null | undefined;
};
export type SettingsMembershipsRefetchQuery$data = {
readonly node: {
readonly " $fragmentSpreads": FragmentRefs<"SettingsPageMembershipsFragment">;
};
};
export type SettingsMembershipsRefetchQuery = {
response: SettingsMembershipsRefetchQuery$data;
variables: SettingsMembershipsRefetchQuery$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
v2 = {
"defaultValue": 20,
"kind": "LocalArgument",
"name": "first"
},
v3 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "id"
},
v4 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
v5 = {
"defaultValue": {
"direction": "ASC",
"field": "CREATED_AT"
},
"kind": "LocalArgument",
"name": "order"
},
v6 = [
{
"kind": "Variable",
"name": "id",
"variableName": "id"
}
],
v7 = {
"kind": "Variable",
"name": "after",
"variableName": "after"
},
v8 = {
"kind": "Variable",
"name": "before",
"variableName": "before"
},
v9 = {
"kind": "Variable",
"name": "first",
"variableName": "first"
},
v10 = {
"kind": "Variable",
"name": "last",
"variableName": "last"
},
v11 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
},
v12 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
v13 = [
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
];
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v3/*: any*/),
(v4/*: any*/),
(v5/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "SettingsMembershipsRefetchQuery",
"selections": [
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"args": [
(v7/*: any*/),
(v8/*: any*/),
(v9/*: any*/),
(v10/*: any*/),
{
"kind": "Variable",
"name": "order",
"variableName": "order"
}
],
"kind": "FragmentSpread",
"name": "SettingsPageMembershipsFragment"
}
],
"storageKey": null
}
],
"type": "Query",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/),
(v2/*: any*/),
(v4/*: any*/),
(v5/*: any*/),
(v3/*: any*/)
],
"kind": "Operation",
"name": "SettingsMembershipsRefetchQuery",
"selections": [
{
"alias": null,
"args": (v6/*: any*/),
"concreteType": null,
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v11/*: any*/),
(v12/*: any*/),
{
"kind": "InlineFragment",
"selections": [
{
"alias": null,
"args": (v13/*: any*/),
"concreteType": "MembershipConnection",
"kind": "LinkedField",
"name": "memberships",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "totalCount",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "MembershipEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Membership",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v12/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "emailAddress",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "role",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
(v11/*: any*/)
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
},
{
"alias": null,
"args": (v13/*: any*/),
"filters": [
"orderBy"
],
"handle": "connection",
"key": "SettingsPageMemberships_memberships",
"kind": "LinkedHandle",
"name": "memberships"
}
],
"type": "Organization",
"abstractKey": null
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "57c4ca08006166b58c1fb2407f091704",
"id": null,
"metadata": {},
"name": "SettingsMembershipsRefetchQuery",
"operationKind": "query",
"text": "query SettingsMembershipsRefetchQuery(\n $after: CursorKey = null\n $before: CursorKey = null\n $first: Int = 20\n $last: Int = null\n $order: MembershipOrder = {direction: ASC, field: CREATED_AT}\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...SettingsPageMembershipsFragment_16fISc\n id\n }\n}\n\nfragment SettingsPageMembershipsFragment_16fISc on Organization {\n memberships(first: $first, after: $after, last: $last, before: $before, orderBy: $order) {\n totalCount\n edges {\n node {\n id\n fullName\n emailAddress\n role\n createdAt\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n"
}
};
})();
(node as any).hash = "d28514cdb5181fc1023dc4ea5bddb4f2";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<122f3da12674107565d07159872492db>>
* @generated SignedSource<<8477f42a02a102a4ce8540b794ac4289>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -22,6 +22,7 @@ export type SettingsPageFragment$data = {
};
}>;
};
readonly createdAt: any;
readonly customDomain: {
readonly createdAt: any;
readonly dnsRecords: ReadonlyArray<{
@@ -44,16 +45,7 @@ export type SettingsPageFragment$data = {
readonly id: string;
readonly logoUrl: string | null | undefined;
readonly name: string;
readonly users: {
readonly edges: ReadonlyArray<{
readonly node: {
readonly createdAt: any;
readonly email: string;
readonly fullName: string;
readonly id: string;
};
}>;
};
readonly updatedAt: any;
readonly websiteUrl: string | null | undefined;
readonly " $fragmentType": "SettingsPageFragment";
};
@@ -81,30 +73,23 @@ v2 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"name": "type",
"storageKey": null
},
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "type",
"name": "createdAt",
"storageKey": null
},
v4 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"name": "updatedAt",
"storageKey": null
},
v5 = [
{
"kind": "Literal",
"name": "first",
"value": 100
}
];
};
return {
"argumentDefinitions": [],
"kind": "Fragment",
@@ -141,7 +126,13 @@ return {
"name": "websiteUrl",
"storageKey": null
},
(v2/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "email",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -180,7 +171,7 @@ return {
"name": "dnsRecords",
"plural": true,
"selections": [
(v3/*: any*/),
(v2/*: any*/),
(v1/*: any*/),
{
"alias": null,
@@ -206,14 +197,8 @@ return {
],
"storageKey": null
},
(v3/*: any*/),
(v4/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "updatedAt",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -224,52 +209,17 @@ return {
],
"storageKey": null
},
(v3/*: any*/),
(v4/*: any*/),
{
"alias": null,
"args": (v5/*: any*/),
"concreteType": "UserConnection",
"kind": "LinkedField",
"name": "users",
"plural": false,
"selections": [
"args": [
{
"alias": null,
"args": null,
"concreteType": "UserEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "User",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v0/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
(v2/*: any*/),
(v4/*: any*/)
],
"storageKey": null
}
],
"storageKey": null
"kind": "Literal",
"name": "first",
"value": 100
}
],
"storageKey": "users(first:100)"
},
{
"alias": null,
"args": (v5/*: any*/),
"concreteType": "ConnectorConnection",
"kind": "LinkedField",
"name": "connectors",
@@ -293,8 +243,8 @@ return {
"selections": [
(v0/*: any*/),
(v1/*: any*/),
(v3/*: any*/),
(v4/*: any*/)
(v2/*: any*/),
(v3/*: any*/)
],
"storageKey": null
}
@@ -310,6 +260,6 @@ return {
};
})();
(node as any).hash = "6cea6f88fb0d7b2ae7ef9053b9979898";
(node as any).hash = "b3b152b507befd6b6918a12972e05f14";
export default node;

View File

@@ -0,0 +1,278 @@
/**
* @generated SignedSource<<4f099d1ee6b4635ca8129eca77d3f7e8>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type SettingsPageInvitationsFragment$data = {
readonly id: string;
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 role: string;
};
}>;
readonly totalCount: number;
};
readonly " $fragmentType": "SettingsPageInvitationsFragment";
};
export type SettingsPageInvitationsFragment$key = {
readonly " $data"?: SettingsPageInvitationsFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"SettingsPageInvitationsFragment">;
};
import SettingsInvitationsRefetchQuery_graphql from './SettingsInvitationsRefetchQuery.graphql';
const node: ReaderFragment = (function(){
var v0 = [
"invitations"
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
{
"defaultValue": 20,
"kind": "LocalArgument",
"name": "first"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
{
"defaultValue": {
"direction": "ASC",
"field": "CREATED_AT"
},
"kind": "LocalArgument",
"name": "order"
}
],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "bidirectional",
"path": (v0/*: any*/)
}
],
"refetch": {
"connection": {
"forward": {
"count": "first",
"cursor": "after"
},
"backward": {
"count": "last",
"cursor": "before"
},
"path": (v0/*: any*/)
},
"fragmentPathInResult": [
"node"
],
"operation": SettingsInvitationsRefetchQuery_graphql,
"identifierInfo": {
"identifierField": "id",
"identifierQueryVariableName": "id"
}
}
},
"name": "SettingsPageInvitationsFragment",
"selections": [
{
"alias": "invitations",
"args": [
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
],
"concreteType": "InvitationConnection",
"kind": "LinkedField",
"name": "__SettingsPageInvitations_invitations_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "totalCount",
"storageKey": null
},
{
"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,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
},
(v1/*: any*/)
],
"type": "Organization",
"abstractKey": null
};
})();
(node as any).hash = "d971d93653991efde284ed2b87068698";
export default node;

View File

@@ -0,0 +1,262 @@
/**
* @generated SignedSource<<778a6585d3f3d9b346273c20319ce96d>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
import { FragmentRefs } from "relay-runtime";
export type SettingsPageMembershipsFragment$data = {
readonly id: string;
readonly memberships: {
readonly __id: string;
readonly edges: ReadonlyArray<{
readonly node: {
readonly createdAt: any;
readonly emailAddress: string;
readonly fullName: string;
readonly id: string;
readonly role: string;
};
}>;
readonly totalCount: number;
};
readonly " $fragmentType": "SettingsPageMembershipsFragment";
};
export type SettingsPageMembershipsFragment$key = {
readonly " $data"?: SettingsPageMembershipsFragment$data;
readonly " $fragmentSpreads": FragmentRefs<"SettingsPageMembershipsFragment">;
};
import SettingsMembershipsRefetchQuery_graphql from './SettingsMembershipsRefetchQuery.graphql';
const node: ReaderFragment = (function(){
var v0 = [
"memberships"
],
v1 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
};
return {
"argumentDefinitions": [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "after"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "before"
},
{
"defaultValue": 20,
"kind": "LocalArgument",
"name": "first"
},
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "last"
},
{
"defaultValue": {
"direction": "ASC",
"field": "CREATED_AT"
},
"kind": "LocalArgument",
"name": "order"
}
],
"kind": "Fragment",
"metadata": {
"connection": [
{
"count": null,
"cursor": null,
"direction": "bidirectional",
"path": (v0/*: any*/)
}
],
"refetch": {
"connection": {
"forward": {
"count": "first",
"cursor": "after"
},
"backward": {
"count": "last",
"cursor": "before"
},
"path": (v0/*: any*/)
},
"fragmentPathInResult": [
"node"
],
"operation": SettingsMembershipsRefetchQuery_graphql,
"identifierInfo": {
"identifierField": "id",
"identifierQueryVariableName": "id"
}
}
},
"name": "SettingsPageMembershipsFragment",
"selections": [
{
"alias": "memberships",
"args": [
{
"kind": "Variable",
"name": "orderBy",
"variableName": "order"
}
],
"concreteType": "MembershipConnection",
"kind": "LinkedField",
"name": "__SettingsPageMemberships_memberships_connection",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "totalCount",
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "MembershipEdge",
"kind": "LinkedField",
"name": "edges",
"plural": true,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Membership",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
(v1/*: any*/),
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "fullName",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "emailAddress",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "role",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "createdAt",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__typename",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "cursor",
"storageKey": null
}
],
"storageKey": null
},
{
"alias": null,
"args": null,
"concreteType": "PageInfo",
"kind": "LinkedField",
"name": "pageInfo",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "endCursor",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasNextPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "hasPreviousPage",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "startCursor",
"storageKey": null
}
],
"storageKey": null
},
{
"kind": "ClientExtension",
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "__id",
"storageKey": null
}
]
}
],
"storageKey": null
},
(v1/*: any*/)
],
"type": "Organization",
"abstractKey": null
};
})();
(node as any).hash = "d28514cdb5181fc1023dc4ea5bddb4f2";
export default node;

View File

@@ -0,0 +1,132 @@
/**
* @generated SignedSource<<f058193b755f9fed3efe4910aae9a7e3>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type DeleteInvitationInput = {
invitationId: string;
};
export type SettingsPage_DeleteInvitationMutation$variables = {
connections: ReadonlyArray<string>;
input: DeleteInvitationInput;
};
export type SettingsPage_DeleteInvitationMutation$data = {
readonly deleteInvitation: {
readonly deletedInvitationId: string;
};
};
export type SettingsPage_DeleteInvitationMutation = {
response: SettingsPage_DeleteInvitationMutation$data;
variables: SettingsPage_DeleteInvitationMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "connections"
},
v1 = {
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
},
v2 = [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
v3 = {
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "deletedInvitationId",
"storageKey": null
};
return {
"fragment": {
"argumentDefinitions": [
(v0/*: any*/),
(v1/*: any*/)
],
"kind": "Fragment",
"metadata": null,
"name": "SettingsPage_DeleteInvitationMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteInvitationPayload",
"kind": "LinkedField",
"name": "deleteInvitation",
"plural": false,
"selections": [
(v3/*: any*/)
],
"storageKey": null
}
],
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": [
(v1/*: any*/),
(v0/*: any*/)
],
"kind": "Operation",
"name": "SettingsPage_DeleteInvitationMutation",
"selections": [
{
"alias": null,
"args": (v2/*: any*/),
"concreteType": "DeleteInvitationPayload",
"kind": "LinkedField",
"name": "deleteInvitation",
"plural": false,
"selections": [
(v3/*: any*/),
{
"alias": null,
"args": null,
"filters": null,
"handle": "deleteEdge",
"key": "",
"kind": "ScalarHandle",
"name": "deletedInvitationId",
"handleArgs": [
{
"kind": "Variable",
"name": "connections",
"variableName": "connections"
}
]
}
],
"storageKey": null
}
]
},
"params": {
"cacheID": "1c362c5db7a985d7548166b2b1eb42c9",
"id": null,
"metadata": {},
"name": "SettingsPage_DeleteInvitationMutation",
"operationKind": "mutation",
"text": "mutation SettingsPage_DeleteInvitationMutation(\n $input: DeleteInvitationInput!\n) {\n deleteInvitation(input: $input) {\n deletedInvitationId\n }\n}\n"
}
};
})();
(node as any).hash = "3c484508ba04b5a75eca62fa6afeb16d";
export default node;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<e5fd0bf99e25c4f410c6c53f4025ed15>>
* @generated SignedSource<<994eb713978ecef3329fd9873e4c744c>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,21 +9,21 @@
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type RemoveUserInput = {
export type RemoveMemberInput = {
memberId: string;
organizationId: string;
userId: string;
};
export type SettingsPage_RemoveUserMutation$variables = {
input: RemoveUserInput;
export type SettingsPage_RemoveMemberMutation$variables = {
input: RemoveMemberInput;
};
export type SettingsPage_RemoveUserMutation$data = {
readonly removeUser: {
export type SettingsPage_RemoveMemberMutation$data = {
readonly removeMember: {
readonly success: boolean;
};
};
export type SettingsPage_RemoveUserMutation = {
response: SettingsPage_RemoveUserMutation$data;
variables: SettingsPage_RemoveUserMutation$variables;
export type SettingsPage_RemoveMemberMutation = {
response: SettingsPage_RemoveMemberMutation$data;
variables: SettingsPage_RemoveMemberMutation$variables;
};
const node: ConcreteRequest = (function(){
@@ -44,9 +44,9 @@ v1 = [
"variableName": "input"
}
],
"concreteType": "RemoveUserPayload",
"concreteType": "RemoveMemberPayload",
"kind": "LinkedField",
"name": "removeUser",
"name": "removeMember",
"plural": false,
"selections": [
{
@@ -65,7 +65,7 @@ return {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "SettingsPage_RemoveUserMutation",
"name": "SettingsPage_RemoveMemberMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
@@ -74,20 +74,20 @@ return {
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "SettingsPage_RemoveUserMutation",
"name": "SettingsPage_RemoveMemberMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "a397335d917a93baf77ceba83c735326",
"cacheID": "97e29046871ce8aab01abf98a62236fc",
"id": null,
"metadata": {},
"name": "SettingsPage_RemoveUserMutation",
"name": "SettingsPage_RemoveMemberMutation",
"operationKind": "mutation",
"text": "mutation SettingsPage_RemoveUserMutation(\n $input: RemoveUserInput!\n) {\n removeUser(input: $input) {\n success\n }\n}\n"
"text": "mutation SettingsPage_RemoveMemberMutation(\n $input: RemoveMemberInput!\n) {\n removeMember(input: $input) {\n success\n }\n}\n"
}
};
})();
(node as any).hash = "ba5bf0e182d15f4805080f63d1604d91";
(node as any).hash = "f61071a0fb6f6554e56e79b6b04bc135";
export default node;

View File

@@ -8,7 +8,7 @@ const cls = tv({
slots: {
wrapper:
"border-b border-border-low flex gap-6 text-sm font-medium text-txt-secondary",
item: "py-4 hover:text-txt-primary border-b-2 active:border-border-active -mb-[1px] active:text-txt-primary flex items-center gap-2",
item: "py-4 hover:text-txt-primary border-b-2 active:border-border-active -mb-[1px] active:text-txt-primary flex items-center gap-2 cursor-pointer",
badge: "py-1 px-2 text-txt-secondary text-xs font-semibold rounded-lg bg-highlight",
},
variants: {

View File

@@ -0,0 +1,12 @@
Hi {{.FullName}},
You have requested a password reset for your Probo account.
Please click the link below to reset your password:
{{.ResetURL}}
If you did not request this password reset, please ignore this email.
Thanks,
Probo Team

View File

@@ -0,0 +1,12 @@
Hi {{.FullName}},
Thanks for joining Probo!
Please confirm your email address by clicking the link below:
{{.ConfirmationURL}}
If you did not sign up for Probo, please ignore this email.
Thanks,
Probo Team

602
pkg/auth/service.go Normal file
View File

@@ -0,0 +1,602 @@
// 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 auth
import (
"bytes"
"context"
_ "embed"
"errors"
"fmt"
"html/template"
"net/mail"
"net/url"
"time"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/crypto/passwdhash"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/statelesstoken"
"go.gearno.de/kit/pg"
)
type (
// Service handles ONLY user authentication and management
// No organization-related logic - that belongs to authz service
Service struct {
pg *pg.Client
hp *passwdhash.Profile
hostname string
tokenSecret string
disableSignup bool
invitationTokenValidity time.Duration
}
ErrInvalidCredentials struct {
message string
}
ErrInvalidEmail struct {
email string
}
ErrInvalidPassword struct {
minLength int
maxLength int
}
ErrInvalidFullName struct {
fullName string
}
ErrUserAlreadyExists struct {
message string
}
ErrSessionNotFound struct {
message string
}
ErrSessionExpired struct {
message string
}
ErrInvalidTokenType struct {
message string
}
ErrSignupDisabled struct{}
EmailConfirmationData struct {
UserID gid.GID `json:"uid"`
Email string `json:"email"`
}
InvitationData struct {
OrganizationID gid.GID `json:"organization_id"`
Email string `json:"email"`
FullName string `json:"full_name"`
CreatePeople bool `json:"create_people"`
}
PasswordResetData struct {
Email string `json:"email"`
}
)
const (
TokenTypeEmailConfirmation = "email_confirmation"
TokenTypePasswordReset = "password_reset"
)
var (
//go:embed emails/signup_confirmation.txt.tmpl
signupEmailTemplateData string
signupEmailTemplate = template.Must(template.New("signup").Parse(signupEmailTemplateData))
signupEmailSubject = "Confirm your email address"
//go:embed emails/password_reset.txt.tmpl
passwordResetEmailTemplateData string
passwordResetEmailTemplate = template.Must(template.New("password_reset").Parse(passwordResetEmailTemplateData))
passwordResetEmailSubject = "Reset your password"
)
func (e ErrInvalidCredentials) Error() string {
return e.message
}
func (e ErrUserAlreadyExists) Error() string {
return e.message
}
func (e ErrSessionNotFound) Error() string {
return e.message
}
func (e ErrSessionExpired) Error() string {
return e.message
}
func (e ErrInvalidEmail) Error() string {
return fmt.Sprintf("invalid email: %s", e.email)
}
func (e ErrInvalidPassword) Error() string {
return fmt.Sprintf("invalid password: the length must be between %d and %d characters", e.minLength, e.maxLength)
}
func (e ErrInvalidFullName) Error() string {
return fmt.Sprintf("invalid full name: %s", e.fullName)
}
func (e ErrInvalidTokenType) Error() string {
return e.message
}
func (e ErrSignupDisabled) Error() string {
return "signup is disabled, contact the owner of the Probo instance"
}
func NewService(
ctx context.Context,
pgClient *pg.Client,
hp *passwdhash.Profile,
tokenSecret string,
hostname string,
disableSignup bool,
invitationTokenValidity time.Duration,
) (*Service, error) {
return &Service{
pg: pgClient,
hp: hp,
hostname: hostname,
tokenSecret: tokenSecret,
disableSignup: disableSignup,
invitationTokenValidity: invitationTokenValidity,
}, nil
}
func (s Service) ForgetPassword(
ctx context.Context,
email string,
) error {
// Always generate a new token to avoid timing attacks and leaking information
// about existing emails
passwordResetToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypePasswordReset,
1*time.Hour,
PasswordResetData{Email: email},
)
if err != nil {
return fmt.Errorf("cannot generate password reset token: %w", err)
}
resetPasswordUrl := url.URL{
Scheme: "https",
Host: s.hostname,
Path: "/auth/reset-password",
RawQuery: url.Values{
"token": []string{passwordResetToken},
}.Encode(),
}
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
user := &coredata.User{}
if err := user.LoadByEmail(ctx, conn, email); err != nil {
var errUserNotFound *coredata.ErrUserNotFound
if errors.As(err, &errUserNotFound) {
return nil // Don't leak information about non-existent users
}
return fmt.Errorf("cannot load user: %w", err)
}
body := bytes.NewBuffer(nil)
err = passwordResetEmailTemplate.Execute(
body,
map[string]string{
"FullName": user.FullName,
"ResetURL": resetPasswordUrl.String(),
},
)
if err != nil {
return fmt.Errorf("cannot execute password reset template: %w", err)
}
passwordResetEmail := coredata.NewEmail(
user.FullName,
email,
passwordResetEmailSubject,
body.String(),
)
if err := passwordResetEmail.Insert(ctx, conn); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
return nil
},
)
}
func (s Service) SignUp(
ctx context.Context,
emailAddress string,
password string,
fullName string,
) (*coredata.User, *coredata.Session, error) {
if s.disableSignup {
return nil, nil, &ErrSignupDisabled{}
}
if _, err := mail.ParseAddress(emailAddress); err != nil {
return nil, nil, &ErrInvalidEmail{emailAddress}
}
if len(password) < 8 || len(password) > 128 {
return nil, nil, &ErrInvalidPassword{minLength: 8, maxLength: 128}
}
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)
}
now := time.Now()
user := &coredata.User{
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
EmailAddress: emailAddress,
HashedPassword: hashedPassword,
EmailAddressVerified: false,
FullName: fullName,
CreatedAt: now,
UpdatedAt: now,
}
session := &coredata.Session{
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
UserID: user.ID,
Data: coredata.SessionData{},
ExpiredAt: now.Add(24 * time.Hour * 7), // 7 days,
CreatedAt: now,
UpdatedAt: now,
}
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
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)
}
confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeEmailConfirmation,
24*time.Hour,
EmailConfirmationData{UserID: user.ID, Email: user.EmailAddress},
)
if err != nil {
return fmt.Errorf("cannot generate confirmation token: %w", err)
}
confirmationUrl := url.URL{
Scheme: "https",
Host: s.hostname,
Path: "/auth/confirm-email",
RawQuery: url.Values{
"token": []string{confirmationToken},
}.Encode(),
}
body := bytes.NewBuffer(nil)
err = signupEmailTemplate.Execute(
body,
map[string]string{
"FullName": user.FullName,
"ConfirmationURL": confirmationUrl.String(),
},
)
if err != nil {
return fmt.Errorf("cannot execute signup template: %w", err)
}
confirmationEmail := coredata.NewEmail(
user.FullName,
user.EmailAddress,
signupEmailSubject,
body.String(),
)
if err := confirmationEmail.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
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
}
func (s Service) SignIn(
ctx context.Context,
emailAddress string,
password string,
) (*coredata.Session, *coredata.User, error) {
if _, err := mail.ParseAddress(emailAddress); err != nil {
return nil, nil, &ErrInvalidCredentials{"invalid email or password"}
}
user := &coredata.User{}
session := &coredata.Session{}
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := user.LoadByEmail(ctx, tx, emailAddress); err != nil {
var errUserNotFound *coredata.ErrUserNotFound
if errors.As(err, &errUserNotFound) {
return &ErrInvalidCredentials{"invalid email or password"}
}
return fmt.Errorf("cannot load user by email: %w", err)
}
match, err := s.hp.ComparePasswordAndHash([]byte(password), user.HashedPassword)
if err != nil {
return fmt.Errorf("cannot verify password: %w", err)
}
if !match {
return &ErrInvalidCredentials{"invalid email or password"}
}
now := time.Now()
session = &coredata.Session{
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
UserID: user.ID,
Data: coredata.SessionData{},
ExpiredAt: now.Add(24 * time.Hour * 7), // 7 days
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 session, user, nil
}
func (s Service) SignOut(ctx context.Context, sessionID gid.GID) error {
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
session := &coredata.Session{}
if err := session.LoadByID(ctx, conn, sessionID); err != nil {
return &ErrSessionNotFound{"session not found"}
}
if err := coredata.DeleteSession(ctx, conn, sessionID); err != nil {
return fmt.Errorf("cannot delete session: %w", err)
}
return nil
},
)
}
func (s Service) GetSession(ctx context.Context, sessionID gid.GID) (*coredata.Session, error) {
session := &coredata.Session{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := session.LoadByID(ctx, conn, sessionID); err != nil {
return &ErrSessionNotFound{"session not found"}
}
if time.Now().After(session.ExpiredAt) {
// Clean up expired session
_ = coredata.DeleteSession(ctx, conn, sessionID)
return &ErrSessionExpired{"session expired"}
}
return nil
},
)
if err != nil {
return nil, err
}
return session, nil
}
func (s Service) GetUserByID(ctx context.Context, userID gid.GID) (*coredata.User, error) {
user := &coredata.User{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := user.LoadByID(ctx, conn, userID); err != nil {
return fmt.Errorf("cannot load user by ID: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return user, nil
}
func (s Service) GetUserByEmail(ctx context.Context, email string) (*coredata.User, error) {
user := &coredata.User{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := user.LoadByEmail(ctx, conn, email); err != nil {
return fmt.Errorf("cannot load user by email: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return user, nil
}
func (s Service) GetUserBySession(ctx context.Context, sessionID gid.GID) (*coredata.User, error) {
session, err := s.GetSession(ctx, sessionID)
if err != nil {
return nil, err
}
return s.GetUserByID(ctx, session.UserID)
}
func (s Service) UpdateSession(ctx context.Context, sessionID gid.GID) (*coredata.Session, error) {
session := &coredata.Session{}
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := session.LoadByID(ctx, tx, sessionID); err != nil {
return &ErrSessionNotFound{"session not found"}
}
if time.Now().After(session.ExpiredAt) {
return &ErrSessionExpired{"session expired"}
}
now := time.Now()
session.ExpiredAt = now.Add(24 * time.Hour * 7) // Extend by 7 days
session.UpdatedAt = now
if err := session.Update(ctx, tx); err != nil {
return fmt.Errorf("cannot update session: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return session, nil
}
func (s Service) ConfirmEmail(ctx context.Context, tokenString string) error {
payload, err := statelesstoken.ValidateToken[EmailConfirmationData](
s.tokenSecret,
TokenTypeEmailConfirmation,
tokenString,
)
if err != nil {
return &ErrInvalidTokenType{"invalid confirmation token"}
}
emailConfirmationData := payload.Data
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
if err := user.LoadByID(ctx, tx, emailConfirmationData.UserID); err != nil {
return fmt.Errorf("cannot load user: %w", err)
}
if user.EmailAddressVerified {
return nil
}
if err := user.UpdateEmailVerification(ctx, tx, true); err != nil {
return fmt.Errorf("cannot update user email verification: %w", err)
}
return nil
},
)
}
func (s Service) ResetPassword(ctx context.Context, tokenString string, newPassword string) error {
payload, err := statelesstoken.ValidateToken[PasswordResetData](
s.tokenSecret,
TokenTypePasswordReset,
tokenString,
)
if err != nil {
return &ErrInvalidTokenType{"invalid reset token"}
}
passwordResetData := payload.Data
if len(newPassword) < 8 || len(newPassword) > 128 {
return &ErrInvalidPassword{minLength: 8, maxLength: 128}
}
hashedPassword, err := s.hp.HashPassword([]byte(newPassword))
if err != nil {
return fmt.Errorf("cannot hash password: %w", err)
}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
if err := user.LoadByEmail(ctx, tx, passwordResetData.Email); err != nil {
var errUserNotFound *coredata.ErrUserNotFound
if errors.As(err, &errUserNotFound) {
return nil // Don't leak information about non-existent users
}
return fmt.Errorf("cannot load user: %w", err)
}
if err := user.UpdatePassword(ctx, tx, hashedPassword); err != nil {
return fmt.Errorf("cannot update password: %w", err)
}
return nil
},
)
}

View File

@@ -0,0 +1,10 @@
Hi {{.FullName}},
You have been invited to join organization {{.OrganizationName}}. Please click the link below to accept the invitation:
{{.InvitationURL}}
If you don't want to accept the invitation, you can ignore this email.
Thanks,
Probo Team

559
pkg/authz/service.go Normal file
View File

@@ -0,0 +1,559 @@
// 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 authz
import (
"bytes"
"context"
_ "embed"
"errors"
"fmt"
"html/template"
"time"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/getprobo/probo/pkg/statelesstoken"
"go.gearno.de/kit/pg"
)
type (
// Service handles all authorization logic including organization
// membership and permissions. This service is completely independent
// of authentication methods.
Service struct {
pg *pg.Client
hostname string
tokenSecret string
invitationTokenValidity time.Duration
}
Role string
)
const (
RoleOwner Role = "OWNER"
RoleAdmin Role = "ADMIN"
RoleMember Role = "MEMBER"
RoleViewer Role = "VIEWER"
)
const (
TokenTypeOrganizationInvitation = "organization_invitation"
)
var (
//go:embed emails/invitation.txt.tmpl
invitationEmailBodyData string
invitationEmailBodyTemplate = template.Must(template.New("invitation").Parse(invitationEmailBodyData))
invitationEmailSubject = "Invitation to join organization"
)
func NewService(
ctx context.Context,
pgClient *pg.Client,
hostname string,
tokenSecret string,
invitationTokenValidity time.Duration,
) (*Service, error) {
return &Service{
pg: pgClient,
hostname: hostname,
tokenSecret: tokenSecret,
invitationTokenValidity: invitationTokenValidity,
}, nil
}
func (s *Service) GetAllUserOrganizations(
ctx context.Context,
userID gid.GID,
) ([]*coredata.Organization, error) {
var organizations []*coredata.Organization
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
var organizationList coredata.Organizations
if err := organizationList.LoadAllByUserID(ctx, conn, userID); err != nil {
return fmt.Errorf("failed to load user organizations: %w", err)
}
organizations = organizationList
return nil
})
return organizations, err
}
func (s *Service) GetUserOrganizations(
ctx context.Context,
userID gid.GID,
cursor *page.Cursor[coredata.OrganizationOrderField],
) ([]*coredata.Organization, error) {
var organizations coredata.Organizations
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
if err := organizations.LoadByUserID(ctx, conn, userID, cursor); err != nil {
return fmt.Errorf("failed to load user organizations: %w", err)
}
return nil
})
return organizations, err
}
func (s *Service) GetAllOrganizationInvitations(
ctx context.Context,
orgID gid.GID,
cursor *page.Cursor[coredata.InvitationOrderField],
) (*page.Page[*coredata.Invitation, coredata.InvitationOrderField], error) {
var invitations coredata.Invitations
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := invitations.LoadByOrganizationID(ctx, conn, orgID, cursor); err != nil {
return fmt.Errorf("failed to load organization invitations: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(invitations, cursor), nil
}
func (s *Service) CountOrganizationInvitations(
ctx context.Context,
orgID gid.GID,
) (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.CountByOrganizationID(ctx, conn, orgID)
return err
},
)
if err != nil {
return 0, fmt.Errorf("failed to count invitations: %w", err)
}
return count, nil
}
func (s *Service) DeleteInvitation(
ctx context.Context,
invitationID gid.GID,
) error {
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, conn, invitationID); err != nil {
return fmt.Errorf("failed to load invitation: %w", err)
}
if err := invitation.Delete(ctx, conn); err != nil {
return fmt.Errorf("failed to delete invitation: %w", err)
}
return nil
},
)
}
func (s *Service) GetAllOrganizationMemberships(
ctx context.Context,
orgID gid.GID,
cursor *page.Cursor[coredata.MembershipOrderField],
) (*page.Page[*coredata.Membership, coredata.MembershipOrderField], error) {
var memberships coredata.Memberships
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := memberships.LoadByOrganizationID(ctx, conn, orgID, cursor); err != nil {
return fmt.Errorf("failed to load organization memberships: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(memberships, cursor), nil
}
func (s *Service) CountOrganizationMemberships(
ctx context.Context,
orgID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var memberships coredata.Memberships
var err error
count, err = memberships.CountByOrganizationID(ctx, conn, orgID)
return err
},
)
if err != nil {
return 0, fmt.Errorf("failed to count memberships: %w", err)
}
return count, nil
}
func (s *Service) CanUserAccessOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
) (bool, error) {
membership := &coredata.Membership{}
haveAccess := false
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.LoadByUserAndOrg(ctx, conn, userID, orgID); err != nil {
if _, ok := err.(coredata.ErrMembershipNotFound); ok {
return nil // Not an error, just no access
}
return fmt.Errorf("failed to check organization access: %w", err)
}
haveAccess = true
return nil
},
)
if err != nil {
return false, err
}
return haveAccess, nil
}
func (s *Service) GetUserRoleInOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
) (string, error) {
membership := &coredata.Membership{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.LoadByUserAndOrg(ctx, conn, userID, orgID); err != nil {
return fmt.Errorf("failed to get user role: %w", err)
}
return nil
},
)
if err != nil {
return "", err
}
return membership.Role, nil
}
func (s *Service) RemoveMemberFromOrganization(
ctx context.Context,
orgID gid.GID,
memberID gid.GID,
) error {
membership := &coredata.Membership{}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := membership.LoadByID(ctx, tx, memberID); err != nil {
return fmt.Errorf("failed to load membership: %w", err)
}
if membership.OrganizationID != orgID {
return fmt.Errorf("membership does not belong to organization")
}
if err := membership.Delete(ctx, tx); err != nil {
return fmt.Errorf("failed to delete membership: %w", err)
}
return nil
},
)
}
func (s *Service) AddUserToOrganization(
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,
userID gid.GID,
orgID gid.GID,
newRole string,
) error {
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
membership := &coredata.Membership{}
if err := membership.LoadByUserAndOrg(ctx, tx, userID, orgID); err != nil {
return fmt.Errorf("failed to find membership: %w", err)
}
membership.Role = newRole
membership.UpdatedAt = time.Now()
if err := membership.Update(ctx, tx); err != nil {
return fmt.Errorf("failed to update user role: %w", err)
}
return nil
},
)
}
func (s *Service) InviteUserToOrganization(
ctx context.Context,
organizationID gid.GID,
emailAddress string,
fullName string,
role string,
) (*coredata.Invitation, error) {
var invitation *coredata.Invitation
err := s.pg.WithTx(ctx, func(tx pg.Conn) error {
user := &coredata.User{}
userExists := true
if err := user.LoadByEmail(ctx, tx, emailAddress); err != nil {
var userNotFound *coredata.ErrUserNotFound
if errors.As(err, &userNotFound) {
userExists = false
} else {
return fmt.Errorf("failed to check if user exists: %w", err)
}
}
organization := &coredata.Organization{}
scope := coredata.NewScope(organizationID.TenantID())
if err := organization.LoadByID(ctx, tx, scope, organizationID); err != nil {
return fmt.Errorf("failed to load organization: %w", err)
}
invitationID := gid.New(organizationID.TenantID(), coredata.InvitationEntityType)
now := time.Now()
invitation = &coredata.Invitation{
ID: invitationID,
OrganizationID: organizationID,
Email: emailAddress,
FullName: fullName,
Role: role,
ExpiresAt: now.Add(s.invitationTokenValidity),
CreatedAt: now,
}
if userExists {
membership := &coredata.Membership{
UserID: user.ID,
OrganizationID: organizationID,
Role: role,
CreatedAt: now,
UpdatedAt: now,
}
if err := membership.Create(ctx, tx); err != nil {
return fmt.Errorf("failed to add user to organization: %w", err)
}
invitation.AcceptedAt = &now
} else {
invitationData := coredata.InvitationData{
InvitationID: invitationID,
OrganizationID: organizationID,
Email: emailAddress,
FullName: fullName,
Role: role,
}
invitationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeOrganizationInvitation,
s.invitationTokenValidity,
invitationData,
)
if err != nil {
return fmt.Errorf("failed to generate invitation token: %w", err)
}
body := bytes.NewBuffer(nil)
err = invitationEmailBodyTemplate.Execute(
body,
map[string]string{
"FullName": fullName,
"OrganizationName": organization.Name,
"InvitationURL": fmt.Sprintf("https://%s/auth/confirm-invitation?token=%s", s.hostname, invitationToken),
},
)
if err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
email := coredata.NewEmail(
fullName,
emailAddress,
invitationEmailSubject,
body.String(),
)
if err := email.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
}
if err := invitation.Create(ctx, tx); err != nil {
return fmt.Errorf("cannot create invitation: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
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
func (s *Service) HasPermission(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
resource string,
action string,
) (bool, error) {
// For now, just check if user is a member
// In the future, this will check specific permissions based on role
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

@@ -59,4 +59,6 @@ const (
TrustCenterReferenceEntityType
TrustCenterDocumentAccessEntityType
CustomDomainEntityType
InvitationEntityType
MembershipEntityType
)

280
pkg/coredata/invitation.go Normal file
View File

@@ -0,0 +1,280 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"errors"
"fmt"
"maps"
"time"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
)
type (
Invitation struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Email string `db:"email"`
FullName string `db:"full_name"`
Role string `db:"role"`
ExpiresAt time.Time `db:"expires_at"`
AcceptedAt *time.Time `db:"accepted_at"`
CreatedAt time.Time `db:"created_at"`
}
Invitations []*Invitation
InvitationData struct {
InvitationID gid.GID `json:"invitation_id"`
OrganizationID gid.GID `json:"organization_id"`
Email string `json:"email"`
FullName string `json:"full_name"`
Role string `json:"role"`
}
ErrInvitationNotFound struct {
Token string
}
)
func (e ErrInvitationNotFound) Error() string {
return fmt.Sprintf("invitation not found: %s", e.Token)
}
func (i Invitation) CursorKey(orderBy InvitationOrderField) page.CursorKey {
switch orderBy {
case InvitationOrderFieldFullName:
return page.NewCursorKey(i.ID, i.FullName)
case InvitationOrderFieldEmail:
return page.NewCursorKey(i.ID, i.Email)
case InvitationOrderFieldRole:
return page.NewCursorKey(i.ID, i.Role)
case InvitationOrderFieldCreatedAt:
return page.NewCursorKey(i.ID, i.CreatedAt)
case InvitationOrderFieldExpiresAt:
return page.NewCursorKey(i.ID, i.ExpiresAt)
case InvitationOrderFieldAcceptedAt:
acceptedAt := time.Time{}
if i.AcceptedAt != nil {
acceptedAt = *i.AcceptedAt
}
return page.NewCursorKey(i.ID, acceptedAt)
}
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) error {
query := `
INSERT INTO authz_invitations (
id, organization_id, email, full_name, role, expires_at, created_at
) VALUES (
@id, @organization_id, @email, @full_name, @role, @expires_at, @created_at
)
`
args := pgx.StrictNamedArgs{
"id": i.ID,
"organization_id": i.OrganizationID,
"email": i.Email,
"full_name": i.FullName,
"role": i.Role,
"expires_at": i.ExpiresAt,
"created_at": i.CreatedAt,
}
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("failed to create invitation: %w", err)
}
return nil
}
// Tenant id scope is not applied because we want to access invitations across all tenants for authentication purposes.
func (i *Invitation) LoadByID(
ctx context.Context,
conn pg.Conn,
id gid.GID,
) error {
query := `
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
FROM authz_invitations
WHERE id = @id
`
args := pgx.StrictNamedArgs{
"id": id,
}
rows, err := conn.Query(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot query invitation: %w", err)
}
invitation, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Invitation])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrInvitationNotFound{Token: id.String()}
}
return fmt.Errorf("cannot collect invitation: %w", err)
}
*i = invitation
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) error {
query := `
UPDATE authz_invitations
SET accepted_at = @accepted_at
WHERE id = @id
`
args := pgx.StrictNamedArgs{
"id": i.ID,
"accepted_at": i.AcceptedAt,
}
result, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("failed to update invitation: %w", err)
}
if result.RowsAffected() == 0 {
return ErrInvitationNotFound{Token: i.ID.String()}
}
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) error {
query := `
DELETE FROM authz_invitations
WHERE id = @id
`
args := pgx.StrictNamedArgs{
"id": i.ID,
}
result, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("failed to delete invitation: %w", err)
}
if result.RowsAffected() == 0 {
return ErrInvitationNotFound{Token: i.ID.String()}
}
return nil
}
func (i *Invitations) LoadByEmail(
ctx context.Context,
conn pg.Conn,
email string,
) error {
query := `
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
FROM authz_invitations
WHERE email = @email AND accepted_at IS NULL
ORDER BY created_at DESC
`
args := pgx.StrictNamedArgs{
"email": email,
}
rows, err := conn.Query(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot query invitations: %w", err)
}
invitations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Invitation])
if err != nil {
return fmt.Errorf("cannot collect invitations: %w", err)
}
*i = invitations
return nil
}
func (i *Invitations) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
orgID gid.GID,
cursor *page.Cursor[InvitationOrderField],
) error {
query := `
SELECT id, organization_id, email, full_name, role, expires_at, accepted_at, created_at
FROM authz_invitations
WHERE organization_id = @organization_id
AND %s
`
query = fmt.Sprintf(query, cursor.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": orgID}
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot query invitations: %w", err)
}
invitations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Invitation])
if err != nil {
return fmt.Errorf("cannot collect invitations: %w", err)
}
*i = invitations
return nil
}
func (i *Invitations) CountByOrganizationID(
ctx context.Context,
conn pg.Conn,
orgID gid.GID,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
authz_invitations
WHERE
organization_id = @organization_id
`
args := pgx.StrictNamedArgs{"organization_id": orgID}
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
}

View File

@@ -0,0 +1,73 @@
// 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 "fmt"
// InvitationOrderField defines the fields that can be used to order invitations
type InvitationOrderField string
// InvitationOrderField constants
const (
InvitationOrderFieldFullName InvitationOrderField = "FULL_NAME"
InvitationOrderFieldEmail InvitationOrderField = "EMAIL"
InvitationOrderFieldRole InvitationOrderField = "ROLE"
InvitationOrderFieldCreatedAt InvitationOrderField = "CREATED_AT"
InvitationOrderFieldExpiresAt InvitationOrderField = "EXPIRES_AT"
InvitationOrderFieldAcceptedAt InvitationOrderField = "ACCEPTED_AT"
)
func (p InvitationOrderField) Column() string {
switch p {
case InvitationOrderFieldFullName:
return "full_name"
case InvitationOrderFieldEmail:
return "email"
case InvitationOrderFieldRole:
return "role"
case InvitationOrderFieldCreatedAt:
return "created_at"
case InvitationOrderFieldExpiresAt:
return "expires_at"
case InvitationOrderFieldAcceptedAt:
return "accepted_at"
}
panic(fmt.Sprintf("unsupported order by: %s", p))
}
func (e InvitationOrderField) IsValid() bool {
switch e {
case InvitationOrderFieldFullName, InvitationOrderFieldEmail, InvitationOrderFieldRole, InvitationOrderFieldCreatedAt, InvitationOrderFieldExpiresAt, InvitationOrderFieldAcceptedAt:
return true
}
return false
}
func (e InvitationOrderField) String() string {
return string(e)
}
func (e *InvitationOrderField) UnmarshalText(text []byte) error {
*e = InvitationOrderField(text)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid InvitationOrderField", string(text))
}
return nil
}
func (e InvitationOrderField) MarshalText() ([]byte, error) {
return []byte(e.String()), nil
}

356
pkg/coredata/membership.go Normal file
View File

@@ -0,0 +1,356 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"errors"
"fmt"
"maps"
"time"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
)
type (
Membership struct {
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
OrganizationID gid.GID `db:"organization_id"`
Role string `db:"role"`
FullName string `db:"full_name"`
EmailAddress string `db:"email_address"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Memberships []*Membership
ErrMembershipNotFound struct {
UserID gid.GID
OrgID gid.GID
}
ErrMembershipAlreadyExists struct {
UserID gid.GID
OrgID gid.GID
}
)
func (e ErrMembershipNotFound) Error() string {
return fmt.Sprintf("membership not found for user %s in organization %s", e.UserID, e.OrgID)
}
func (e ErrMembershipAlreadyExists) Error() string {
return fmt.Sprintf("membership already exists for user %s in organization %s", e.UserID, e.OrgID)
}
func (m Membership) CursorKey(orderBy MembershipOrderField) page.CursorKey {
switch orderBy {
case MembershipOrderFieldFullName:
return page.NewCursorKey(m.ID, m.FullName)
case MembershipOrderFieldEmailAddress:
return page.NewCursorKey(m.ID, m.EmailAddress)
case MembershipOrderFieldRole:
return page.NewCursorKey(m.ID, m.Role)
case MembershipOrderFieldCreatedAt:
return page.NewCursorKey(m.ID, m.CreatedAt)
}
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) error {
query := `
INSERT INTO authz_memberships (id, user_id, organization_id, role, created_at, updated_at)
SELECT
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{
"user_id": m.UserID,
"organization_id": m.OrganizationID,
"role": m.Role,
"created_at": m.CreatedAt,
"updated_at": m.UpdatedAt,
"entity_type": MembershipEntityType,
}
result, err := conn.Exec(ctx, query, args)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrMembershipAlreadyExists{UserID: m.UserID, OrgID: m.OrganizationID}
}
return fmt.Errorf("failed to create membership: %w", err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("failed to create membership: organization %s not found", m.OrganizationID)
}
return nil
}
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
func (m *Membership) LoadByID(
ctx context.Context,
conn pg.Conn,
membershipID gid.GID,
) error {
query := `
SELECT
m.id,
m.user_id,
m.organization_id,
m.role,
u.fullname as full_name,
u.email_address,
m.created_at,
m.updated_at
FROM authz_memberships m
JOIN users u ON m.user_id = u.id
WHERE m.id = @membership_id
`
args := pgx.StrictNamedArgs{
"membership_id": membershipID,
}
rows, err := conn.Query(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot query membership: %w", err)
}
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrMembershipNotFound{UserID: gid.GID{}, OrgID: gid.GID{}}
}
return fmt.Errorf("cannot collect membership: %w", err)
}
*m = membership
return nil
}
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
func (m *Membership) LoadByUserAndOrg(
ctx context.Context,
conn pg.Conn,
userID gid.GID,
orgID gid.GID,
) error {
query := `
SELECT
m.id,
m.user_id,
m.organization_id,
m.role,
u.fullname as full_name,
u.email_address,
m.created_at,
m.updated_at
FROM authz_memberships m
JOIN users u ON m.user_id = u.id
WHERE m.user_id = @user_id AND m.organization_id = @organization_id
`
args := pgx.StrictNamedArgs{
"user_id": userID,
"organization_id": orgID,
}
rows, err := conn.Query(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot query membership: %w", err)
}
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrMembershipNotFound{UserID: userID, OrgID: orgID}
}
return fmt.Errorf("cannot collect membership: %w", err)
}
*m = membership
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) error {
query := `
UPDATE authz_memberships
SET role = @role, updated_at = @updated_at
WHERE id = @id
`
args := pgx.StrictNamedArgs{
"id": m.ID,
"role": m.Role,
"updated_at": m.UpdatedAt,
}
result, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("failed to update membership: %w", err)
}
if result.RowsAffected() == 0 {
return ErrMembershipNotFound{UserID: m.UserID, OrgID: m.OrganizationID}
}
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) error {
query := `
DELETE FROM authz_memberships
WHERE id = @id
`
args := pgx.StrictNamedArgs{
"id": m.ID,
}
result, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("failed to delete membership: %w", err)
}
if result.RowsAffected() == 0 {
return ErrMembershipNotFound{UserID: m.UserID, OrgID: m.OrganizationID}
}
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(
ctx context.Context,
conn pg.Conn,
userID gid.GID,
) error {
query := `
SELECT
m.id,
m.user_id,
m.organization_id,
m.role,
u.fullname as full_name,
u.email_address,
m.created_at,
m.updated_at
FROM
authz_memberships m
JOIN users u ON m.user_id = u.id
WHERE
m.user_id = @user_id
ORDER BY
m.created_at DESC
`
args := pgx.StrictNamedArgs{"user_id": userID}
rows, err := conn.Query(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot query memberships: %w", err)
}
memberships, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Membership])
if err != nil {
return fmt.Errorf("cannot collect memberships: %w", err)
}
*m = memberships
return nil
}
// Tenant id scope is not applied because we want to access memberships across all tenants for authentication purposes.
func (m *Memberships) LoadByOrganizationID(
ctx context.Context,
conn pg.Conn,
organizationID gid.GID,
cursor *page.Cursor[MembershipOrderField],
) error {
query := `
SELECT
m.id,
m.user_id,
m.organization_id,
m.role,
u.fullname as full_name,
u.email_address,
m.created_at,
m.updated_at
FROM
authz_memberships m
JOIN users u ON m.user_id = u.id
WHERE
m.organization_id = @organization_id
AND %s
`
query = fmt.Sprintf(query, cursor.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, cursor.SQLArguments())
rows, err := conn.Query(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot query memberships: %w", err)
}
memberships, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Membership])
if err != nil {
return fmt.Errorf("cannot collect memberships: %w", err)
}
*m = memberships
return nil
}
func (m *Memberships) CountByOrganizationID(
ctx context.Context,
conn pg.Conn,
organizationID gid.GID,
) (int, error) {
query := `
SELECT COUNT(*)
FROM authz_memberships
WHERE organization_id = @organization_id
`
args := pgx.StrictNamedArgs{"organization_id": organizationID}
row := conn.QueryRow(ctx, query, args)
var count int
if err := row.Scan(&count); err != nil {
return 0, fmt.Errorf("cannot count memberships: %w", err)
}
return count, nil
}

View File

@@ -0,0 +1,53 @@
// 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
type (
MembershipOrderField string
)
const (
MembershipOrderFieldFullName MembershipOrderField = "FULL_NAME"
MembershipOrderFieldEmailAddress MembershipOrderField = "EMAIL_ADDRESS"
MembershipOrderFieldRole MembershipOrderField = "ROLE"
MembershipOrderFieldCreatedAt MembershipOrderField = "CREATED_AT"
)
func (p MembershipOrderField) Column() string {
switch p {
case MembershipOrderFieldFullName:
return "u.fullname"
case MembershipOrderFieldEmailAddress:
return "u.email_address"
case MembershipOrderFieldRole:
return "m.role"
case MembershipOrderFieldCreatedAt:
return "m.created_at"
}
return string(p)
}
func (p MembershipOrderField) String() string {
return string(p)
}
func (p MembershipOrderField) MarshalText() ([]byte, error) {
return []byte(p.String()), nil
}
func (p *MembershipOrderField) UnmarshalText(text []byte) error {
*p = MembershipOrderField(text)
return nil
}

View File

@@ -0,0 +1,41 @@
-- Create authorization tables for the new authz service
-- This migration creates the new authz tables while keeping the existing users_organizations table
-- for backward compatibility during the transition
-- Create role enum
CREATE TYPE authz_role AS ENUM ('OWNER', 'ADMIN', 'MEMBER', 'VIEWER');
-- Create authz_memberships table with id as primary key
CREATE TABLE authz_memberships (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
organization_id TEXT NOT NULL,
role authz_role NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (user_id, organization_id)
);
-- Create authz_invitations table
CREATE TABLE authz_invitations (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
email TEXT NOT NULL,
full_name TEXT NOT NULL,
role authz_role NOT NULL,
expires_at TIMESTAMP NOT NULL,
accepted_at TIMESTAMP,
created_at TIMESTAMP NOT NULL
);
-- Copy data from users_organizations to authz_memberships
INSERT INTO authz_memberships (id, user_id, organization_id, role, created_at, updated_at)
SELECT
generate_gid(decode_base64_unpadded(organizations.tenant_id), 38) as id,
users_organizations.user_id,
users_organizations.organization_id,
'MEMBER'::authz_role as role, -- Default role for existing memberships
users_organizations.created_at,
users_organizations.created_at as updated_at
FROM users_organizations
JOIN organizations ON users_organizations.organization_id = organizations.id;

View File

@@ -107,7 +107,7 @@ LIMIT 1;
}
// Tenant id scope is not applied in this functions because we want to access all user's organizations.
func (o *Organizations) ListForUserID(
func (o *Organizations) LoadByUserID(
ctx context.Context,
conn pg.Conn,
userID gid.GID,
@@ -118,7 +118,7 @@ WITH user_org AS (
SELECT
organization_id
FROM
users_organizations
authz_memberships
WHERE
user_id = @user_id
)
@@ -163,6 +163,59 @@ WHERE
return nil
}
// Tenant id scope is not applied in this function because we want to access all user's organizations.
func (o *Organizations) LoadAllByUserID(
ctx context.Context,
conn pg.Conn,
userID gid.GID,
) error {
q := `
WITH user_org AS (
SELECT
organization_id
FROM
authz_memberships
WHERE
user_id = @user_id
)
SELECT
tenant_id,
id,
name,
description,
website_url,
email,
headquarter_address,
custom_domain_id,
logo_file_id,
horizontal_logo_file_id,
created_at,
updated_at
FROM
organizations
INNER JOIN
user_org ON organizations.id = user_org.organization_id
ORDER BY
created_at DESC
`
args := pgx.StrictNamedArgs{"user_id": userID}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query organizations: %w", err)
}
organizations, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Organization])
if err != nil {
return fmt.Errorf("cannot collect organizations: %w", err)
}
*o = organizations
return nil
}
func (o *Organization) Insert(
ctx context.Context,
conn pg.Conn,

View File

@@ -47,6 +47,7 @@ func (s Session) CursorKey(orderBy SessionOrderField) page.CursorKey {
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
// Tenant id scope is not applied because we want to access sessions across all tenants for authentication purposes.
func (s *Session) LoadByID(
ctx context.Context,
conn pg.Conn,

View File

@@ -138,6 +138,7 @@ LIMIT 1;
return nil
}
// Tenant id scope is not applied because we want to access trust centers by slug across all tenants for public access.
func (tc *TrustCenter) LoadBySlug(
ctx context.Context,
conn pg.Conn,

View File

@@ -87,7 +87,7 @@ FROM
users
WHERE
id IN (
SELECT user_id FROM users_organizations WHERE organization_id = @organization_id
SELECT user_id FROM authz_memberships WHERE organization_id = @organization_id
)
AND %s
`
@@ -112,6 +112,36 @@ WHERE
return nil
}
func (u *Users) CountByOrganizationID(
ctx context.Context,
conn pg.Conn,
organizationID gid.GID,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
users
WHERE
id IN (
SELECT user_id FROM authz_memberships WHERE organization_id = @organization_id
)
`
args := pgx.StrictNamedArgs{"organization_id": organizationID}
row := conn.QueryRow(ctx, q, args)
var count int
err := row.Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot count users: %w", err)
}
return count, nil
}
// Tenant id scope is not applied because we want to access users across all tenants for authentication purposes.
func (u *User) LoadByEmail(
ctx context.Context,
conn pg.Conn,
@@ -154,6 +184,7 @@ LIMIT 1;
return nil
}
// Tenant id scope is not applied because we want to access users across all tenants for authentication purposes.
func (u *User) LoadByID(
ctx context.Context,
conn pg.Conn,

View File

@@ -46,6 +46,7 @@ VALUES (@user_id, @organization_id, @created_at)
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

View File

@@ -21,6 +21,8 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/getprobo/probo/pkg/agents"
"github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/certmanager"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/crypto/cipher"
@@ -28,7 +30,6 @@ import (
"github.com/getprobo/probo/pkg/filevalidation"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/html2pdf"
"github.com/getprobo/probo/pkg/usrmgr"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.gearno.de/x/ref"
@@ -56,9 +57,10 @@ type (
trustConfig TrustConfig
agentConfig agents.Config
html2pdfConverter *html2pdf.Converter
usrmgr *usrmgr.Service
acmeService *certmanager.ACMEService
fileManager *filemanager.Service
auth *auth.Service
authz *authz.Service
logger *log.Logger
}
@@ -117,9 +119,10 @@ func NewService(
trustConfig TrustConfig,
agentConfig agents.Config,
html2pdfConverter *html2pdf.Converter,
usrmgrService *usrmgr.Service,
acmeService *certmanager.ACMEService,
fileManagerService *filemanager.Service,
authService *auth.Service,
authzService *authz.Service,
logger *log.Logger,
) (*Service, error) {
if bucket == "" {
@@ -136,9 +139,10 @@ func NewService(
trustConfig: trustConfig,
agentConfig: agentConfig,
html2pdfConverter: html2pdfConverter,
usrmgr: usrmgrService,
acmeService: acmeService,
fileManager: fileManagerService,
auth: authService,
authz: authzService,
logger: logger,
}
@@ -202,7 +206,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
tenantService.Audits = &AuditService{svc: tenantService}
tenantService.Reports = &ReportService{svc: tenantService}
tenantService.TrustCenters = &TrustCenterService{svc: tenantService}
tenantService.TrustCenterAccesses = &TrustCenterAccessService{svc: tenantService, usrmgr: s.usrmgr}
tenantService.TrustCenterAccesses = &TrustCenterAccessService{svc: tenantService}
tenantService.TrustCenterReferences = &TrustCenterReferenceService{svc: tenantService}
tenantService.Nonconformities = &NonconformityService{svc: tenantService}
tenantService.Obligations = &ObligationService{svc: tenantService}

View File

@@ -25,14 +25,12 @@ import (
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/getprobo/probo/pkg/statelesstoken"
"github.com/getprobo/probo/pkg/usrmgr"
"go.gearno.de/kit/pg"
)
type (
TrustCenterAccessService struct {
svc *TenantService
usrmgr *usrmgr.Service
svc *TenantService
}
CreateTrustCenterAccessRequest struct {

View File

@@ -28,6 +28,8 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/getprobo/probo/pkg/agents"
"github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/awsconfig"
"github.com/getprobo/probo/pkg/certmanager"
"github.com/getprobo/probo/pkg/connector"
@@ -44,7 +46,6 @@ import (
"github.com/getprobo/probo/pkg/server"
"github.com/getprobo/probo/pkg/server/api"
"github.com/getprobo/probo/pkg/trust"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/prometheus/client_golang/prometheus"
"go.gearno.de/kit/httpclient"
"go.gearno.de/kit/httpserver"
@@ -257,7 +258,7 @@ func (impl *Implm) Run(
agent := agents.NewAgent(l.Named("agent"), agentConfig)
usrmgrService, err := usrmgr.NewService(
authService, err := auth.NewService(
ctx,
pgClient,
hp,
@@ -267,7 +268,18 @@ func (impl *Implm) Run(
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
)
if err != nil {
return fmt.Errorf("cannot create usrmgr service: %w", err)
return fmt.Errorf("cannot create auth service: %w", err)
}
authzService, err := authz.NewService(
ctx,
pgClient,
impl.cfg.Hostname,
impl.cfg.Auth.Cookie.Secret,
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
)
if err != nil {
return fmt.Errorf("cannot create authz service: %w", err)
}
fileManagerService := filemanager.NewService(s3Client)
@@ -312,9 +324,10 @@ func (impl *Implm) Run(
trustConfig,
agentConfig,
html2pdfConverter,
usrmgrService,
acmeService,
fileManagerService,
authService,
authzService,
l.Named("probo"),
)
if err != nil {
@@ -327,7 +340,7 @@ func (impl *Implm) Run(
impl.cfg.AWS.Bucket,
impl.cfg.EncryptionKey,
impl.cfg.TrustAuth.TokenSecret,
usrmgrService,
authService,
html2pdfConverter,
fileManagerService,
)
@@ -337,14 +350,15 @@ func (impl *Implm) Run(
AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins,
ExtraHeaderFields: impl.cfg.Api.ExtraHeaderFields,
Probo: proboService,
Usrmgr: usrmgrService,
Auth: authService,
Authz: authzService,
Trust: trustService,
ConnectorRegistry: defaultConnectorRegistry,
Agent: agent,
SafeRedirect: &saferedirect.SafeRedirect{AllowedHost: impl.cfg.Hostname},
CustomDomainCname: impl.cfg.CustomDomains.CnameTarget,
Logger: l.Named("http.server"),
Auth: api.ConsoleAuthConfig{
ConsoleAuth: api.ConsoleAuthConfig{
CookieName: impl.cfg.Auth.Cookie.Name,
CookieDomain: impl.cfg.Auth.Cookie.Domain,
SessionDuration: time.Duration(impl.cfg.Auth.Cookie.Duration) * time.Hour,

View File

@@ -20,13 +20,14 @@ import (
"time"
"github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/connector"
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/saferedirect"
console_v1 "github.com/getprobo/probo/pkg/server/api/console/v1"
trust_v1 "github.com/getprobo/probo/pkg/server/api/trust/v1"
"github.com/getprobo/probo/pkg/trust"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"go.gearno.de/kit/httpserver"
@@ -55,9 +56,10 @@ type (
Config struct {
AllowedOrigins []string
Probo *probo.Service
Usrmgr *usrmgr.Service
Auth *auth.Service
Authz *authz.Service
Trust *trust.Service
Auth ConsoleAuthConfig
ConsoleAuth ConsoleAuthConfig
TrustAuth TrustAuthConfig
ConnectorRegistry *connector.ConnectorRegistry
SafeRedirect *saferedirect.SafeRedirect
@@ -72,8 +74,9 @@ type (
)
var (
ErrMissingProboService = errors.New("server configuration requires a valid probo.Service instance")
ErrMissingUsrmgrService = errors.New("server configuration requires a valid usrmgr.Service instance")
ErrMissingProboService = errors.New("server configuration requires a valid probo.Service instance")
ErrMissingAuthService = errors.New("server configuration requires a valid auth.Service instance")
ErrMissingAuthzService = errors.New("server configuration requires a valid authz.Service instance")
)
func methodNotAllowed(w http.ResponseWriter, r *http.Request) {
@@ -105,20 +108,25 @@ func NewServer(cfg Config) (*Server, error) {
return nil, ErrMissingProboService
}
if cfg.Usrmgr == nil {
return nil, ErrMissingUsrmgrService
if cfg.Auth == nil {
return nil, ErrMissingAuthService
}
if cfg.Authz == nil {
return nil, ErrMissingAuthzService
}
// Create trust API handler once
trustAPIHandler := trust_v1.NewMux(
cfg.Logger.Named("trust.v1"),
cfg.Usrmgr,
cfg.Auth,
cfg.Authz,
cfg.Trust,
console_v1.AuthConfig{
CookieName: cfg.Auth.CookieName,
CookieDomain: cfg.Auth.CookieDomain,
SessionDuration: cfg.Auth.SessionDuration,
CookieSecret: cfg.Auth.CookieSecret,
CookieName: cfg.ConsoleAuth.CookieName,
CookieDomain: cfg.ConsoleAuth.CookieDomain,
SessionDuration: cfg.ConsoleAuth.SessionDuration,
CookieSecret: cfg.ConsoleAuth.CookieSecret,
},
trust_v1.TrustAuthConfig{
CookieName: cfg.TrustAuth.CookieName,
@@ -175,12 +183,13 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
console_v1.NewMux(
s.cfg.Logger.Named("console.v1"),
s.cfg.Probo,
s.cfg.Usrmgr,
s.cfg.Auth,
s.cfg.Authz,
console_v1.AuthConfig{
CookieName: s.cfg.Auth.CookieName,
CookieDomain: s.cfg.Auth.CookieDomain,
SessionDuration: s.cfg.Auth.SessionDuration,
CookieSecret: s.cfg.Auth.CookieSecret,
CookieName: s.cfg.ConsoleAuth.CookieName,
CookieDomain: s.cfg.ConsoleAuth.CookieDomain,
SessionDuration: s.cfg.ConsoleAuth.SessionDuration,
CookieSecret: s.cfg.ConsoleAuth.CookieSecret,
},
s.cfg.ConnectorRegistry,
s.cfg.SafeRedirect,

View File

@@ -19,7 +19,7 @@ import (
"fmt"
"net/http"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/getprobo/probo/pkg/auth"
"go.gearno.de/kit/httpserver"
)
@@ -33,7 +33,7 @@ type (
}
)
func ForgetPasswordHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc {
func ForgetPasswordHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req ForgetPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -41,7 +41,7 @@ func ForgetPasswordHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.H
return
}
err := usrmgrSvc.ForgetPassword(r.Context(), req.Email)
err := authSvc.ForgetPassword(r.Context(), req.Email)
if err != nil {
// For security reasons, we don't expose whether an email exists or not
httpserver.RenderError(w, http.StatusInternalServerError, fmt.Errorf("cannot process request: %w", err))

View File

@@ -16,11 +16,14 @@ package console_v1
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/usrmgr"
"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"
)
@@ -34,7 +37,7 @@ type (
}
)
func InvitationConfirmationHandler(usrmgrSvc *usrmgr.Service, proboSvc *probo.Service, authCfg AuthConfig) http.HandlerFunc {
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 {
@@ -42,7 +45,32 @@ func InvitationConfirmationHandler(usrmgrSvc *usrmgr.Service, proboSvc *probo.Se
return
}
_, err := usrmgrSvc.ConfirmInvitation(r.Context(), req.Token, req.Password)
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

View File

@@ -21,7 +21,7 @@ import (
"errors"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/getprobo/probo/pkg/auth"
"go.gearno.de/kit/httpserver"
)
@@ -36,7 +36,7 @@ type (
}
)
func ResetPasswordHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc {
func ResetPasswordHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req ResetPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -44,10 +44,10 @@ func ResetPasswordHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.Ha
return
}
err := usrmgrSvc.ResetPassword(r.Context(), req.Token, req.Password)
err := authSvc.ResetPassword(r.Context(), req.Token, req.Password)
if err != nil {
var invalidPasswordErr *usrmgr.ErrInvalidPassword
var invalidTokenErr *usrmgr.ErrInvalidTokenType
var invalidPasswordErr *auth.ErrInvalidPassword
var invalidTokenErr *auth.ErrInvalidTokenType
if errors.As(err, &invalidPasswordErr) {
httpserver.RenderError(w, http.StatusBadRequest, err)

View File

@@ -29,6 +29,8 @@ import (
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/connector"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
@@ -38,7 +40,6 @@ import (
gqlutils "github.com/getprobo/probo/pkg/server/graphql"
"github.com/getprobo/probo/pkg/server/session"
"github.com/getprobo/probo/pkg/statelesstoken"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/go-chi/chi/v5"
"github.com/vektah/gqlparser/v2/gqlerror"
"go.gearno.de/kit/log"
@@ -54,7 +55,8 @@ type (
Resolver struct {
proboSvc *probo.Service
usrmgrSvc *usrmgr.Service
authSvc *auth.Service
authzSvc *authz.Service
authCfg AuthConfig
customDomainCname string
}
@@ -81,7 +83,8 @@ func UserFromContext(ctx context.Context) *coredata.User {
func NewMux(
logger *log.Logger,
proboSvc *probo.Service,
usrmgrSvc *usrmgr.Service,
authSvc *auth.Service,
authzSvc *authz.Service,
authCfg AuthConfig,
connectorRegistry *connector.ConnectorRegistry,
safeRedirect *saferedirect.SafeRedirect,
@@ -151,14 +154,14 @@ func NewMux(
},
)
r.Post("/auth/register", SignUpHandler(usrmgrSvc, authCfg))
r.Post("/auth/login", SignInHandler(usrmgrSvc, authCfg))
r.Delete("/auth/logout", SignOutHandler(usrmgrSvc, authCfg))
r.Post("/auth/invitation", InvitationConfirmationHandler(usrmgrSvc, proboSvc, authCfg))
r.Post("/auth/forget-password", ForgetPasswordHandler(usrmgrSvc, authCfg))
r.Post("/auth/reset-password", ResetPasswordHandler(usrmgrSvc, authCfg))
r.Post("/auth/register", SignUpHandler(authSvc, authCfg))
r.Post("/auth/login", SignInHandler(authSvc, authCfg))
r.Delete("/auth/logout", SignOutHandler(authSvc, authCfg))
r.Post("/auth/invitation", InvitationConfirmationHandler(authSvc, authzSvc, authCfg))
r.Post("/auth/forget-password", ForgetPasswordHandler(authSvc, authCfg))
r.Post("/auth/reset-password", ResetPasswordHandler(authSvc, authCfg))
r.Get("/connectors/initiate", WithSession(usrmgrSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
r.Get("/connectors/initiate", WithSession(authSvc, authzSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
connectorID := r.URL.Query().Get("connector_id")
organizationID, err := gid.ParseGID(r.URL.Query().Get("organization_id"))
if err != nil {
@@ -175,7 +178,7 @@ func NewMux(
http.Redirect(w, r, redirectURL, http.StatusSeeOther)
}))
r.Get("/connectors/complete", WithSession(usrmgrSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
r.Get("/connectors/complete", WithSession(authSvc, authzSvc, authCfg, func(w http.ResponseWriter, r *http.Request) {
connectorID := r.URL.Query().Get("connector_id")
organizationID, err := gid.ParseGID(r.URL.Query().Get("organization_id"))
if err != nil {
@@ -206,19 +209,20 @@ func NewMux(
}))
r.Get("/", playground.Handler("GraphQL", "/api/console/v1/query"))
r.Post("/query", graphqlHandler(logger, proboSvc, usrmgrSvc, authCfg, customDomainCname))
r.Post("/query", graphqlHandler(logger, proboSvc, authSvc, authzSvc, authCfg, customDomainCname))
return r
}
func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, usrmgrSvc *usrmgr.Service, authCfg AuthConfig, customDomainCname string) http.HandlerFunc {
func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthConfig, customDomainCname string) http.HandlerFunc {
var mb int64 = 1 << 20
es := schema.NewExecutableSchema(
schema.Config{
Resolvers: &Resolver{
proboSvc: proboSvc,
usrmgrSvc: usrmgrSvc,
authSvc: authSvc,
authzSvc: authzSvc,
authCfg: authCfg,
customDomainCname: customDomainCname,
},
@@ -259,10 +263,10 @@ func graphqlHandler(logger *log.Logger, proboSvc *probo.Service, usrmgrSvc *usrm
},
)
return WithSession(usrmgrSvc, authCfg, srv.ServeHTTP)
return WithSession(authSvc, authzSvc, authCfg, srv.ServeHTTP)
}
func WithSession(usrmgrSvc *usrmgr.Service, authCfg AuthConfig, next http.HandlerFunc) http.HandlerFunc {
func WithSession(authSvc *auth.Service, authzSvc *authz.Service, authCfg AuthConfig, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -289,7 +293,7 @@ func WithSession(usrmgrSvc *usrmgr.Service, authCfg AuthConfig, next http.Handle
},
}
authResult := session.TryAuth(ctx, w, r, usrmgrSvc, sessionAuthCfg, errorHandler)
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
if authResult == nil {
next(w, r)
return
@@ -302,7 +306,7 @@ func WithSession(usrmgrSvc *usrmgr.Service, authCfg AuthConfig, next http.Handle
next(w, r.WithContext(ctx))
// Update session after the handler completes
if err := usrmgrSvc.UpdateSession(ctx, authResult.Session); err != nil {
if _, err := authSvc.UpdateSession(ctx, authResult.Session.ID); err != nil {
panic(fmt.Errorf("failed to update session: %w", err))
}
}

View File

@@ -1209,6 +1209,54 @@ enum SnapshotOrderField
)
}
enum MembershipOrderField
@goModel(model: "github.com/getprobo/probo/pkg/coredata.MembershipOrderField") {
FULL_NAME
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.MembershipOrderFieldFullName"
)
EMAIL_ADDRESS
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.MembershipOrderFieldEmailAddress"
)
ROLE
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.MembershipOrderFieldRole"
)
CREATED_AT
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.MembershipOrderFieldCreatedAt"
)
}
enum InvitationOrderField
@goModel(model: "github.com/getprobo/probo/pkg/coredata.InvitationOrderField") {
FULL_NAME
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.InvitationOrderFieldFullName"
)
EMAIL
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.InvitationOrderFieldEmail"
)
ROLE
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.InvitationOrderFieldRole"
)
CREATED_AT
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.InvitationOrderFieldCreatedAt"
)
EXPIRES_AT
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.InvitationOrderFieldExpiresAt"
)
ACCEPTED_AT
@goEnum(
value: "github.com/getprobo/probo/pkg/coredata.InvitationOrderFieldAcceptedAt"
)
}
# Input Types
input UserOrder
@goModel(
@@ -1404,6 +1452,19 @@ input SnapshotOrder
field: SnapshotOrderField!
}
input MembershipOrder
@goModel(
model: "github.com/getprobo/probo/pkg/server/api/console/v1/types.MembershipOrderBy"
) {
direction: OrderDirection!
field: MembershipOrderField!
}
input InvitationOrder {
direction: OrderDirection!
field: InvitationOrderField!
}
input DocumentVersionFilter {
status: DocumentStatus
}
@@ -1497,13 +1558,21 @@ type Organization implements Node {
email: String
headquarterAddress: String
users(
memberships(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: UserOrder
): UserConnection! @goField(forceResolver: true)
orderBy: MembershipOrder
): MembershipConnection! @goField(forceResolver: true)
invitations(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: InvitationOrder
): InvitationConnection! @goField(forceResolver: true)
connectors(
first: Int
@@ -1671,6 +1740,27 @@ type User implements Node {
people(organizationId: ID!): People @goField(forceResolver: true)
}
type Membership implements Node {
id: ID!
userID: ID!
organizationID: ID!
role: String!
fullName: String!
emailAddress: String!
createdAt: Datetime!
updatedAt: Datetime!
}
type Invitation implements Node {
id: ID!
email: String!
fullName: String!
role: String!
expiresAt: Datetime!
acceptedAt: Datetime
createdAt: Datetime!
}
type Connector implements Node {
id: ID!
name: String!
@@ -2305,10 +2395,22 @@ type TrustCenterReferenceEdge {
}
type UserConnection {
totalCount: Int! @goField(forceResolver: true)
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type MembershipConnection {
totalCount: Int! @goField(forceResolver: true)
edges: [MembershipEdge!]!
pageInfo: PageInfo!
}
type MembershipEdge {
cursor: CursorKey!
node: Membership!
}
type UserEdge {
cursor: CursorKey!
node: User!
@@ -2607,6 +2709,17 @@ type File {
updatedAt: Datetime!
}
type InvitationConnection {
totalCount: Int! @goField(forceResolver: true)
edges: [InvitationEdge!]!
pageInfo: PageInfo!
}
type InvitationEdge {
cursor: CursorKey!
node: Invitation!
}
# Root Types
type Query {
node(id: ID!): Node!
@@ -2667,7 +2780,8 @@ type Mutation {
# User mutations
confirmEmail(input: ConfirmEmailInput!): ConfirmEmailPayload!
inviteUser(input: InviteUserInput!): InviteUserPayload!
removeUser(input: RemoveUserInput!): RemoveUserPayload!
deleteInvitation(input: DeleteInvitationInput!): DeleteInvitationPayload!
removeMember(input: RemoveMemberInput!): RemoveMemberPayload!
# People mutations
createPeople(input: CreatePeopleInput!): CreatePeoplePayload!
@@ -3426,9 +3540,13 @@ input InviteUserInput {
createPeople: Boolean!
}
input RemoveUserInput {
input DeleteInvitationInput {
invitationId: ID!
}
input RemoveMemberInput {
organizationId: ID!
userId: ID!
memberId: ID!
}
input CreateControlInput {
@@ -3945,10 +4063,14 @@ type ConfirmEmailPayload {
}
type InviteUserPayload {
success: Boolean!
invitationEdge: InvitationEdge!
}
type RemoveUserPayload {
type DeleteInvitationPayload {
deletedInvitationId: ID!
}
type RemoveMemberPayload {
success: Boolean!
}

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@ import (
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/securecookie"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/getprobo/probo/pkg/auth"
"go.gearno.de/kit/httpserver"
)
@@ -46,7 +46,7 @@ type (
}
)
func SignInHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc {
func SignInHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req SignInRequest
@@ -55,9 +55,9 @@ func SignInHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFu
return
}
user, session, err := usrmgrSvc.SignIn(r.Context(), req.Email, req.Password)
session, user, err := authSvc.SignIn(r.Context(), req.Email, req.Password)
if err != nil {
var ErrInvalidCredentials *usrmgr.ErrInvalidCredentials
var ErrInvalidCredentials *auth.ErrInvalidCredentials
if errors.As(err, &ErrInvalidCredentials) {
httpserver.RenderError(w, http.StatusUnauthorized, err)
return

View File

@@ -20,11 +20,11 @@ import (
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/securecookie"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/getprobo/probo/pkg/auth"
"go.gearno.de/kit/httpserver"
)
func SignOutHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc {
func SignOutHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sessionID, err := securecookie.Get(r, securecookie.DefaultConfig(
@@ -42,7 +42,7 @@ func SignOutHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerF
return
}
err = usrmgrSvc.SignOut(r.Context(), gid)
err = authSvc.SignOut(r.Context(), gid)
if err != nil {
panic(fmt.Errorf("cannot sign out: %w", err))
}

View File

@@ -20,9 +20,8 @@ import (
"fmt"
"net/http"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/securecookie"
"github.com/getprobo/probo/pkg/usrmgr"
"go.gearno.de/kit/httpserver"
)
@@ -38,7 +37,7 @@ type (
}
)
func SignUpHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc {
func SignUpHandler(authSvc *auth.Service, authCfg AuthConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req SignUpRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -46,20 +45,20 @@ func SignUpHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFu
return
}
user, session, err := usrmgrSvc.SignUp(
user, session, err := authSvc.SignUp(
r.Context(),
req.Email,
req.Password,
req.FullName,
)
if err != nil {
var errUserAlreadyExists *coredata.ErrUserAlreadyExists
var errUserAlreadyExists *auth.ErrUserAlreadyExists
if errors.As(err, &errUserAlreadyExists) {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot register user: %w", err))
return
}
var errSignupDisabled *usrmgr.ErrSignupDisabled
var errSignupDisabled *auth.ErrSignupDisabled
if errors.As(err, &errSignupDisabled) {
httpserver.RenderError(w, http.StatusBadRequest, fmt.Errorf("cannot register user: %w", err))
return

View File

@@ -0,0 +1,52 @@
// 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 types
import (
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/page"
)
func NewInvitationConnection(p *page.Page[*coredata.Invitation, coredata.InvitationOrderField]) *InvitationConnection {
var edges = make([]*InvitationEdge, len(p.Data))
for i := range edges {
edges[i] = NewInvitationEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &InvitationConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}
func NewInvitationEdge(invitation *coredata.Invitation, orderBy coredata.InvitationOrderField) *InvitationEdge {
return &InvitationEdge{
Cursor: invitation.CursorKey(orderBy),
Node: NewInvitation(invitation),
}
}
func NewInvitation(i *coredata.Invitation) *Invitation {
return &Invitation{
ID: i.ID,
Email: i.Email,
FullName: i.FullName,
Role: i.Role,
ExpiresAt: i.ExpiresAt,
AcceptedAt: i.AcceptedAt,
CreatedAt: i.CreatedAt,
}
}

View File

@@ -0,0 +1,57 @@
// 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 types
import (
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/page"
)
type (
MembershipOrderBy OrderBy[coredata.MembershipOrderField]
)
func NewMembershipConnection(p *page.Page[*coredata.Membership, coredata.MembershipOrderField]) *MembershipConnection {
var edges = make([]*MembershipEdge, len(p.Data))
for i := range edges {
edges[i] = NewMembershipEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &MembershipConnection{
Edges: edges,
PageInfo: NewPageInfo(p),
}
}
func NewMembershipEdge(membership *coredata.Membership, orderBy coredata.MembershipOrderField) *MembershipEdge {
return &MembershipEdge{
Cursor: membership.CursorKey(orderBy),
Node: NewMembership(membership),
}
}
func NewMembership(m *coredata.Membership) *Membership {
return &Membership{
ID: m.ID,
UserID: m.UserID,
OrganizationID: m.OrganizationID,
Role: m.Role,
FullName: m.FullName,
EmailAddress: m.EmailAddress,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
}
}

View File

@@ -807,6 +807,14 @@ type DeleteFrameworkPayload struct {
DeletedFrameworkID gid.GID `json:"deletedFrameworkId"`
}
type DeleteInvitationInput struct {
InvitationID gid.GID `json:"invitationId"`
}
type DeleteInvitationPayload struct {
DeletedInvitationID gid.GID `json:"deletedInvitationId"`
}
type DeleteMeasureInput struct {
MeasureID gid.GID `json:"measureId"`
}
@@ -1191,6 +1199,35 @@ type ImportMeasurePayload struct {
MeasureEdges []*MeasureEdge `json:"measureEdges"`
}
type Invitation struct {
ID gid.GID `json:"id"`
Email string `json:"email"`
FullName string `json:"fullName"`
Role string `json:"role"`
ExpiresAt time.Time `json:"expiresAt"`
AcceptedAt *time.Time `json:"acceptedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
func (Invitation) IsNode() {}
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 {
Cursor page.CursorKey `json:"cursor"`
Node *Invitation `json:"node"`
}
type InvitationOrder struct {
Direction page.OrderDirection `json:"direction"`
Field coredata.InvitationOrderField `json:"field"`
}
type InviteUserInput struct {
OrganizationID gid.GID `json:"organizationId"`
Email string `json:"email"`
@@ -1199,7 +1236,7 @@ type InviteUserInput struct {
}
type InviteUserPayload struct {
Success bool `json:"success"`
InvitationEdge *InvitationEdge `json:"invitationEdge"`
}
type Measure struct {
@@ -1229,6 +1266,31 @@ type MeasureFilter struct {
State *coredata.MeasureState `json:"state,omitempty"`
}
type Membership struct {
ID gid.GID `json:"id"`
UserID gid.GID `json:"userID"`
OrganizationID gid.GID `json:"organizationID"`
Role string `json:"role"`
FullName string `json:"fullName"`
EmailAddress string `json:"emailAddress"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (Membership) IsNode() {}
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 {
Cursor page.CursorKey `json:"cursor"`
Node *Membership `json:"node"`
}
type Mutation struct {
}
@@ -1301,7 +1363,8 @@ type Organization struct {
WebsiteURL *string `json:"websiteUrl,omitempty"`
Email *string `json:"email,omitempty"`
HeadquarterAddress *string `json:"headquarterAddress,omitempty"`
Users *UserConnection `json:"users"`
Memberships *MembershipConnection `json:"memberships"`
Invitations *InvitationConnection `json:"invitations"`
Connectors *ConnectorConnection `json:"connectors"`
Frameworks *FrameworkConnection `json:"frameworks"`
Controls *ControlConnection `json:"controls"`
@@ -1424,12 +1487,12 @@ type PublishDocumentVersionPayload struct {
type Query struct {
}
type RemoveUserInput struct {
type RemoveMemberInput struct {
OrganizationID gid.GID `json:"organizationId"`
UserID gid.GID `json:"userId"`
MemberID gid.GID `json:"memberId"`
}
type RemoveUserPayload struct {
type RemoveMemberPayload struct {
Success bool `json:"success"`
}
@@ -2064,8 +2127,9 @@ func (User) IsNode() {}
func (this User) GetID() gid.GID { return this.ID }
type UserConnection struct {
Edges []*UserEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
TotalCount int `json:"totalCount"`
Edges []*UserEdge `json:"edges"`
PageInfo *PageInfo `json:"pageInfo"`
}
type UserEdge struct {

View File

@@ -12,6 +12,7 @@ import (
"fmt"
"time"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
@@ -890,6 +891,27 @@ func (r *frameworkConnectionResolver) TotalCount(ctx context.Context, obj *types
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
}
// TotalCount is the resolver for the totalCount field.
func (r *invitationConnectionResolver) TotalCount(ctx context.Context, obj *types.InvitationConnection) (int, error) {
currentUser := UserFromContext(ctx)
if currentUser == nil {
return 0, fmt.Errorf("no authenticated user")
}
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 {
return 0, fmt.Errorf("failed to count invitations: %w", err)
}
return count, nil
}
// 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) {
prb := r.ProboService(ctx, obj.ID.TenantID())
@@ -1028,6 +1050,27 @@ func (r *measureConnectionResolver) TotalCount(ctx context.Context, obj *types.M
panic(fmt.Errorf("unsupported resolver: %T", obj.Resolver))
}
// TotalCount is the resolver for the totalCount field.
func (r *membershipConnectionResolver) TotalCount(ctx context.Context, obj *types.MembershipConnection) (int, error) {
currentUser := UserFromContext(ctx)
if currentUser == nil {
return 0, fmt.Errorf("no authenticated user")
}
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 {
return 0, fmt.Errorf("failed to count memberships: %w", err)
}
return count, nil
}
// CreateOrganization is the resolver for the createOrganization field.
func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.CreateOrganizationInput) (*types.CreateOrganizationPayload, error) {
prb := r.proboSvc.WithTenant(gid.NewTenantID())
@@ -1042,10 +1085,11 @@ func (r *mutationResolver) CreateOrganization(ctx context.Context, input types.C
return nil, fmt.Errorf("cannot create organization: %w", err)
}
err = r.usrmgrSvc.EnrollUserInOrganization(
err = r.authzSvc.AddUserToOrganization(
ctx,
UserFromContext(ctx).ID,
organization.ID,
string(authz.RoleMember),
)
if err != nil {
return nil, fmt.Errorf("cannot add user to organization: %w", err)
@@ -1324,7 +1368,7 @@ func (r *mutationResolver) DeleteTrustCenterReference(ctx context.Context, input
// ConfirmEmail is the resolver for the confirmEmail field.
func (r *mutationResolver) ConfirmEmail(ctx context.Context, input types.ConfirmEmailInput) (*types.ConfirmEmailPayload, error) {
err := r.usrmgrSvc.ConfirmEmail(ctx, input.Token)
err := r.authSvc.ConfirmEmail(ctx, input.Token)
if err != nil {
return nil, err
@@ -1337,44 +1381,70 @@ func (r *mutationResolver) ConfirmEmail(ctx context.Context, input types.Confirm
func (r *mutationResolver) InviteUser(ctx context.Context, input types.InviteUserInput) (*types.InviteUserPayload, error) {
user := UserFromContext(ctx)
organizations, err := r.usrmgrSvc.ListOrganizationsForUserID(ctx, user.ID)
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 {
createPeople := input.CreatePeople
err := r.usrmgrSvc.InviteUser(ctx, input.OrganizationID, input.FullName, input.Email, createPeople)
invitation, err := r.authzSvc.InviteUserToOrganization(ctx, input.OrganizationID, input.Email, input.FullName, string(authz.RoleMember))
if err != nil {
return nil, err
}
return &types.InviteUserPayload{Success: true}, nil
if input.CreatePeople {
prb := r.ProboService(ctx, input.OrganizationID.TenantID())
_, err := prb.Peoples.Create(ctx, probo.CreatePeopleRequest{
OrganizationID: input.OrganizationID,
FullName: input.FullName,
PrimaryEmailAddress: input.Email,
AdditionalEmailAddresses: []string{},
Kind: coredata.PeopleKindEmployee,
})
if err != nil {
return nil, fmt.Errorf("failed to create people record: %w", err)
}
}
return &types.InviteUserPayload{
InvitationEdge: types.NewInvitationEdge(invitation, coredata.InvitationOrderFieldCreatedAt),
}, nil
}
}
return nil, fmt.Errorf("organization not found")
}
// RemoveUser is the resolver for the removeUser field.
func (r *mutationResolver) RemoveUser(ctx context.Context, input types.RemoveUserInput) (*types.RemoveUserPayload, error) {
// DeleteInvitation is the resolver for the deleteInvitation field.
func (r *mutationResolver) DeleteInvitation(ctx context.Context, input types.DeleteInvitationInput) (*types.DeleteInvitationPayload, error) {
err := r.authzSvc.DeleteInvitation(ctx, input.InvitationID)
if err != nil {
return nil, err
}
return &types.DeleteInvitationPayload{
DeletedInvitationID: input.InvitationID,
}, nil
}
// RemoveMember is the resolver for the removeMember field.
func (r *mutationResolver) RemoveMember(ctx context.Context, input types.RemoveMemberInput) (*types.RemoveMemberPayload, error) {
user := UserFromContext(ctx)
organizations, err := r.usrmgrSvc.ListOrganizationsForUserID(ctx, user.ID)
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.usrmgrSvc.RemoveUser(ctx, input.OrganizationID, input.UserID)
err := r.authzSvc.RemoveMemberFromOrganization(ctx, input.OrganizationID, input.MemberID)
if err != nil {
return nil, err
}
return &types.RemoveUserPayload{Success: true}, nil
return &types.RemoveMemberPayload{Success: true}, nil
}
}
@@ -3526,14 +3596,14 @@ func (r *organizationResolver) HorizontalLogoURL(ctx context.Context, obj *types
return prb.Organizations.GenerateHorizontalLogoURL(ctx, obj.ID, 1*time.Hour)
}
// Users is the resolver for the users field.
func (r *organizationResolver) Users(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.UserOrderBy) (*types.UserConnection, error) {
pageOrderBy := page.OrderBy[coredata.UserOrderField]{
Field: coredata.UserOrderFieldCreatedAt,
// Memberships is the resolver for the memberships field.
func (r *organizationResolver) Memberships(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MembershipOrderBy) (*types.MembershipConnection, error) {
pageOrderBy := page.OrderBy[coredata.MembershipOrderField]{
Field: coredata.MembershipOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.UserOrderField]{
pageOrderBy = page.OrderBy[coredata.MembershipOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
@@ -3541,12 +3611,35 @@ func (r *organizationResolver) Users(ctx context.Context, obj *types.Organizatio
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
page, err := r.usrmgrSvc.ListUsersForTenant(ctx, obj.ID, cursor)
page, err := r.authzSvc.GetAllOrganizationMemberships(ctx, obj.ID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list users: %w", err))
panic(fmt.Errorf("cannot list memberships: %w", err))
}
return types.NewUserConnection(page), nil
return types.NewMembershipConnection(page), nil
}
// 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) {
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)
page, err := r.authzSvc.GetAllOrganizationInvitations(ctx, obj.ID, cursor)
if err != nil {
panic(fmt.Errorf("cannot list invitations: %w", err))
}
return types.NewInvitationConnection(page), nil
}
// Connectors is the resolver for the connectors field.
@@ -4866,6 +4959,27 @@ func (r *userResolver) People(ctx context.Context, obj *types.User, organization
return types.NewPeople(people), nil
}
// TotalCount is the resolver for the totalCount field.
func (r *userConnectionResolver) TotalCount(ctx context.Context, obj *types.UserConnection) (int, error) {
currentUser := UserFromContext(ctx)
if currentUser == nil {
return 0, fmt.Errorf("no authenticated user")
}
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 {
return 0, fmt.Errorf("failed to count memberships: %w", err)
}
return count, nil
}
// Organization is the resolver for the organization field.
func (r *vendorResolver) Organization(ctx context.Context, obj *types.Vendor) (*types.Organization, error) {
prb := r.ProboService(ctx, obj.ID.TenantID())
@@ -5229,7 +5343,7 @@ func (r *viewerResolver) Organizations(ctx context.Context, obj *types.Viewer, f
}
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
organizations, err := r.usrmgrSvc.ListOrganizationsForUserIDPaginated(ctx, user.ID, cursor)
organizations, err := r.authzSvc.GetUserOrganizations(ctx, user.ID, cursor)
if err != nil {
panic(fmt.Errorf("failed to list organizations for user: %w", err))
}
@@ -5318,6 +5432,11 @@ func (r *Resolver) FrameworkConnection() schema.FrameworkConnectionResolver {
return &frameworkConnectionResolver{r}
}
// InvitationConnection returns schema.InvitationConnectionResolver implementation.
func (r *Resolver) InvitationConnection() schema.InvitationConnectionResolver {
return &invitationConnectionResolver{r}
}
// Measure returns schema.MeasureResolver implementation.
func (r *Resolver) Measure() schema.MeasureResolver { return &measureResolver{r} }
@@ -5326,6 +5445,11 @@ func (r *Resolver) MeasureConnection() schema.MeasureConnectionResolver {
return &measureConnectionResolver{r}
}
// MembershipConnection returns schema.MembershipConnectionResolver implementation.
func (r *Resolver) MembershipConnection() schema.MembershipConnectionResolver {
return &membershipConnectionResolver{r}
}
// Mutation returns schema.MutationResolver implementation.
func (r *Resolver) Mutation() schema.MutationResolver { return &mutationResolver{r} }
@@ -5420,6 +5544,9 @@ func (r *Resolver) TrustCenterReferenceConnection() schema.TrustCenterReferenceC
// User returns schema.UserResolver implementation.
func (r *Resolver) User() schema.UserResolver { return &userResolver{r} }
// UserConnection returns schema.UserConnectionResolver implementation.
func (r *Resolver) UserConnection() schema.UserConnectionResolver { return &userConnectionResolver{r} }
// Vendor returns schema.VendorResolver implementation.
func (r *Resolver) Vendor() schema.VendorResolver { return &vendorResolver{r} }
@@ -5476,8 +5603,10 @@ type evidenceConnectionResolver struct{ *Resolver }
type fileResolver struct{ *Resolver }
type frameworkResolver struct{ *Resolver }
type frameworkConnectionResolver struct{ *Resolver }
type invitationConnectionResolver struct{ *Resolver }
type measureResolver struct{ *Resolver }
type measureConnectionResolver struct{ *Resolver }
type membershipConnectionResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }
type nonconformityResolver struct{ *Resolver }
type nonconformityConnectionResolver struct{ *Resolver }
@@ -5502,6 +5631,7 @@ type trustCenterDocumentAccessConnectionResolver struct{ *Resolver }
type trustCenterReferenceResolver struct{ *Resolver }
type trustCenterReferenceConnectionResolver struct{ *Resolver }
type userResolver struct{ *Resolver }
type userConnectionResolver struct{ *Resolver }
type vendorResolver struct{ *Resolver }
type vendorBusinessAssociateAgreementResolver struct{ *Resolver }
type vendorComplianceReportResolver struct{ *Resolver }

View File

@@ -26,17 +26,18 @@ import (
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/probo"
console_v1 "github.com/getprobo/probo/pkg/server/api/console/v1"
"github.com/getprobo/probo/pkg/server/api/trust/v1/auth"
"github.com/getprobo/probo/pkg/server/api/trust/v1/schema"
"github.com/getprobo/probo/pkg/server/api/trust/v1/trustauth"
gqlutils "github.com/getprobo/probo/pkg/server/graphql"
"github.com/getprobo/probo/pkg/server/session"
"github.com/getprobo/probo/pkg/statelesstoken"
"github.com/getprobo/probo/pkg/trust"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
)
@@ -79,31 +80,32 @@ func UserFromContext(ctx context.Context) *coredata.User {
return user
}
func TokenAccessFromContext(ctx context.Context) *auth.TokenAccessData {
tokenAccess, _ := ctx.Value(tokenAccessContextKey).(*auth.TokenAccessData)
func TokenAccessFromContext(ctx context.Context) *trustauth.TokenAccessData {
tokenAccess, _ := ctx.Value(tokenAccessContextKey).(*trustauth.TokenAccessData)
return tokenAccess
}
// UserFromContext implements auth.ContextAccessor interface
// UserFromContext implements trustauth.ContextAccessor interface
func (r *Resolver) UserFromContext(ctx context.Context) *coredata.User {
return UserFromContext(ctx)
}
// TokenAccessFromContext implements auth.ContextAccessor interface
func (r *Resolver) TokenAccessFromContext(ctx context.Context) *auth.TokenAccessData {
// TokenAccessFromContext implements trustauth.ContextAccessor interface
func (r *Resolver) TokenAccessFromContext(ctx context.Context) *trustauth.TokenAccessData {
return TokenAccessFromContext(ctx)
}
func NewMux(
logger *log.Logger,
usrmgrSvc *usrmgr.Service,
authSvc *auth.Service,
authzSvc *authz.Service,
trustSvc *trust.Service,
authCfg console_v1.AuthConfig,
trustAuthCfg TrustAuthConfig,
) *chi.Mux {
r := chi.NewMux()
r.Handle("/graphql", graphqlHandler(logger, usrmgrSvc, trustSvc, authCfg, trustAuthCfg))
r.Handle("/graphql", graphqlHandler(logger, authSvc, authzSvc, trustSvc, authCfg, trustAuthCfg))
r.Post("/auth/authenticate", authTokenHandler(trustSvc, trustAuthCfg))
r.Delete("/auth/logout", trustCenterLogoutHandler(authCfg, trustAuthCfg))
@@ -111,7 +113,7 @@ func NewMux(
return r
}
func graphqlHandler(logger *log.Logger, usrmgrSvc *usrmgr.Service, trustSvc *trust.Service, authCfg console_v1.AuthConfig, trustAuthCfg TrustAuthConfig) http.HandlerFunc {
func graphqlHandler(logger *log.Logger, authSvc *auth.Service, authzSvc *authz.Service, trustSvc *trust.Service, authCfg console_v1.AuthConfig, trustAuthCfg TrustAuthConfig) http.HandlerFunc {
resolver := &Resolver{
trustCenterSvc: trustSvc,
authCfg: authCfg,
@@ -122,7 +124,7 @@ func graphqlHandler(logger *log.Logger, usrmgrSvc *usrmgr.Service, trustSvc *tru
Resolvers: resolver,
}
c.Directives.MustBeAuthenticated = auth.MustBeAuthenticatedDirective(resolver)
c.Directives.MustBeAuthenticated = trustauth.MustBeAuthenticatedDirective(resolver)
es := schema.NewExecutableSchema(c)
@@ -137,7 +139,7 @@ func graphqlHandler(logger *log.Logger, usrmgrSvc *usrmgr.Service, trustSvc *tru
srv.SetRecoverFunc(gqlutils.RecoverFunc)
return WithSession(usrmgrSvc, trustSvc, authCfg, trustAuthCfg, srv.ServeHTTP)
return WithSession(authSvc, authzSvc, trustSvc, authCfg, trustAuthCfg, srv.ServeHTTP)
}
func (r *Resolver) RootTrustService(ctx context.Context) *trust.TenantService {
@@ -149,14 +151,14 @@ func (r *Resolver) PublicTrustService(ctx context.Context, tenantID gid.TenantID
}
func (r *Resolver) PrivateTrustService(ctx context.Context, tenantID gid.TenantID) (*trust.TenantService, error) {
if err := auth.ValidateTenantAccess(ctx, r, userTenantContextKey, tenantID); err != nil {
if err := trustauth.ValidateTenantAccess(ctx, r, userTenantContextKey, tenantID); err != nil {
return nil, fmt.Errorf("cannot access trust center: %w", err)
}
return r.trustCenterSvc.WithTenant(tenantID), nil
}
func WithSession(usrmgrSvc *usrmgr.Service, trustSvc *trust.Service, authCfg console_v1.AuthConfig, trustAuthCfg TrustAuthConfig, next http.HandlerFunc) http.HandlerFunc {
func WithSession(authSvc *auth.Service, authzSvc *authz.Service, trustSvc *trust.Service, authCfg console_v1.AuthConfig, trustAuthCfg TrustAuthConfig, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
@@ -168,9 +170,9 @@ func WithSession(usrmgrSvc *usrmgr.Service, trustSvc *trust.Service, authCfg con
return
}
if authCtx := trySessionAuth(ctx, w, r, usrmgrSvc, authCfg); authCtx != nil {
if authCtx := trySessionAuth(ctx, w, r, authSvc, authzSvc, authCfg); authCtx != nil {
next(w, r.WithContext(authCtx))
updateSessionIfNeeded(authCtx, usrmgrSvc)
updateSessionIfNeeded(authCtx, authSvc)
return
}
@@ -178,7 +180,7 @@ func WithSession(usrmgrSvc *usrmgr.Service, trustSvc *trust.Service, authCfg con
}
}
func trySessionAuth(ctx context.Context, w http.ResponseWriter, r *http.Request, usrmgrSvc *usrmgr.Service, authCfg console_v1.AuthConfig) context.Context {
func trySessionAuth(ctx context.Context, w http.ResponseWriter, r *http.Request, authSvc *auth.Service, authzSvc *authz.Service, authCfg console_v1.AuthConfig) context.Context {
sessionAuthCfg := session.AuthConfig{
CookieName: authCfg.CookieName,
CookieSecret: authCfg.CookieSecret,
@@ -199,7 +201,7 @@ func trySessionAuth(ctx context.Context, w http.ResponseWriter, r *http.Request,
},
}
authResult := session.TryAuth(ctx, w, r, usrmgrSvc, sessionAuthCfg, errorHandler)
authResult := session.TryAuth(ctx, w, r, authSvc, authzSvc, sessionAuthCfg, errorHandler)
if authResult == nil {
return nil
}
@@ -235,7 +237,7 @@ func tryTokenAuth(ctx context.Context, w http.ResponseWriter, r *http.Request, t
return nil
}
tokenAccess := &auth.TokenAccessData{
tokenAccess := &trustauth.TokenAccessData{
TrustCenterID: basicPayload.Data.TrustCenterID,
Email: basicPayload.Data.Email,
TenantID: tenantID,
@@ -258,10 +260,10 @@ func clearTokenCookie(w http.ResponseWriter, trustAuthCfg TrustAuthConfig) {
})
}
func updateSessionIfNeeded(ctx context.Context, usrmgrSvc *usrmgr.Service) {
func updateSessionIfNeeded(ctx context.Context, authSvc *auth.Service) {
session := SessionFromContext(ctx)
if session != nil {
if err := usrmgrSvc.UpdateSession(ctx, session); err != nil {
if _, err := authSvc.UpdateSession(ctx, session.ID); err != nil {
panic(fmt.Errorf("failed to update session: %w", err))
}
}

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package auth
package trustauth
import (
"context"

View File

@@ -21,6 +21,8 @@ import (
"strings"
"github.com/getprobo/probo/pkg/agents"
"github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/connector"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/probo"
@@ -30,7 +32,6 @@ import (
"github.com/getprobo/probo/pkg/server/trust"
"github.com/getprobo/probo/pkg/server/web"
trust_pkg "github.com/getprobo/probo/pkg/trust"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/go-chi/chi/v5"
"go.gearno.de/kit/log"
)
@@ -40,9 +41,10 @@ type Config struct {
AllowedOrigins []string
ExtraHeaderFields map[string]string
Probo *probo.Service
Usrmgr *usrmgr.Service
Auth *auth.Service
Authz *authz.Service
Trust *trust_pkg.Service
Auth api.ConsoleAuthConfig
ConsoleAuth api.ConsoleAuthConfig
TrustAuth api.TrustAuthConfig
ConnectorRegistry *connector.ConnectorRegistry
Agent *agents.Agent
@@ -68,9 +70,10 @@ func NewServer(cfg Config) (*Server, error) {
apiCfg := api.Config{
AllowedOrigins: cfg.AllowedOrigins,
Probo: cfg.Probo,
Usrmgr: cfg.Usrmgr,
Trust: cfg.Trust,
Auth: cfg.Auth,
Authz: cfg.Authz,
Trust: cfg.Trust,
ConsoleAuth: cfg.ConsoleAuth,
TrustAuth: cfg.TrustAuth,
ConnectorRegistry: cfg.ConnectorRegistry,
SafeRedirect: cfg.SafeRedirect,

View File

@@ -19,10 +19,11 @@ import (
"errors"
"net/http"
"github.com/getprobo/probo/pkg/authz"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/securecookie"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/getprobo/probo/pkg/auth"
)
type AuthConfig struct {
@@ -48,7 +49,8 @@ func TryAuth(
ctx context.Context,
w http.ResponseWriter,
r *http.Request,
usrmgrSvc *usrmgr.Service,
authSvc *auth.Service,
authzSvc *authz.Service,
authCfg AuthConfig,
errorHandler ErrorHandler,
) *AuthResult {
@@ -71,7 +73,7 @@ func TryAuth(
return nil
}
session, err := usrmgrSvc.GetSession(ctx, sessionID)
session, err := authSvc.GetSession(ctx, sessionID)
if err != nil {
if errorHandler.OnSessionError != nil {
errorHandler.OnSessionError(w, authCfg)
@@ -79,7 +81,7 @@ func TryAuth(
return nil
}
user, err := usrmgrSvc.GetUserBySession(ctx, sessionID)
user, err := authSvc.GetUserBySession(ctx, sessionID)
if err != nil {
if errorHandler.OnUserError != nil {
errorHandler.OnUserError(w, authCfg)
@@ -87,7 +89,7 @@ func TryAuth(
return nil
}
tenantIDs, err := usrmgrSvc.ListTenantsForUserID(ctx, user.ID)
organizations, err := authzSvc.GetAllUserOrganizations(ctx, user.ID)
if err != nil {
if errorHandler.OnTenantError != nil {
errorHandler.OnTenantError(err)
@@ -95,6 +97,11 @@ func TryAuth(
return nil
}
tenantIDs := make([]gid.TenantID, len(organizations))
for i, org := range organizations {
tenantIDs[i] = org.ID.TenantID()
}
return &AuthResult{
Session: session,
User: user,

View File

@@ -16,13 +16,13 @@ package trust
import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/getprobo/probo/pkg/auth"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/crypto/cipher"
"github.com/getprobo/probo/pkg/filemanager"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/html2pdf"
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/usrmgr"
"go.gearno.de/kit/pg"
)
@@ -34,7 +34,7 @@ type (
proboSvc *probo.Service
encryptionKey cipher.EncryptionKey
tokenSecret string
usrmgr *usrmgr.Service
auth *auth.Service
html2pdfConverter *html2pdf.Converter
fileManager *filemanager.Service
}
@@ -47,7 +47,7 @@ type (
proboSvc *probo.Service
encryptionKey cipher.EncryptionKey
tokenSecret string
usrmgr *usrmgr.Service
auth *auth.Service
html2pdfConverter *html2pdf.Converter
fileManager *filemanager.Service
TrustCenters *TrustCenterService
@@ -68,7 +68,7 @@ func NewService(
bucket string,
encryptionKey cipher.EncryptionKey,
tokenSecret string,
usrmgr *usrmgr.Service,
auth *auth.Service,
html2pdfConverter *html2pdf.Converter,
fileManagerService *filemanager.Service,
) *Service {
@@ -78,7 +78,7 @@ func NewService(
bucket: bucket,
encryptionKey: encryptionKey,
tokenSecret: tokenSecret,
usrmgr: usrmgr,
auth: auth,
html2pdfConverter: html2pdfConverter,
fileManager: fileManagerService,
}
@@ -93,7 +93,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
proboSvc: s.proboSvc,
encryptionKey: s.encryptionKey,
tokenSecret: s.tokenSecret,
usrmgr: s.usrmgr,
auth: s.auth,
html2pdfConverter: s.html2pdfConverter,
fileManager: s.fileManager,
}
@@ -103,7 +103,7 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
tenantService.Audits = &AuditService{svc: tenantService}
tenantService.Vendors = &VendorService{svc: tenantService}
tenantService.Frameworks = &FrameworkService{svc: tenantService}
tenantService.TrustCenterAccesses = &TrustCenterAccessService{svc: tenantService, usrmgr: s.usrmgr}
tenantService.TrustCenterAccesses = &TrustCenterAccessService{svc: tenantService, auth: s.auth}
tenantService.TrustCenterReferences = &TrustCenterReferenceService{svc: tenantService}
tenantService.Reports = &ReportService{svc: tenantService}
tenantService.Organizations = &OrganizationService{svc: tenantService}

View File

@@ -24,14 +24,14 @@ import (
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/getprobo/probo/pkg/auth"
"go.gearno.de/kit/pg"
)
type (
TrustCenterAccessService struct {
svc *TenantService
usrmgr *usrmgr.Service
auth *auth.Service
}
RequestTrustCenterAccessRequest struct {

View File

@@ -1,953 +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 usrmgr
import (
"context"
"errors"
"fmt"
"net/mail"
"net/url"
"time"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/crypto/passwdhash"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/page"
"github.com/getprobo/probo/pkg/statelesstoken"
"go.gearno.de/kit/pg"
)
type (
Service struct {
pg *pg.Client
hp *passwdhash.Profile
hostname string
tokenSecret string
disableSignup bool
invitationTokenValidity time.Duration
}
ErrInvalidCredentials struct {
message string
}
ErrInvalidEmail struct {
email string
}
ErrInvalidPassword struct {
minLength int
maxLength int
}
ErrInvalidFullName struct {
fullName string
}
ErrUserAlreadyExists struct {
message string
}
ErrSessionNotFound struct {
message string
}
ErrSessionExpired struct {
message string
}
ErrInvalidTokenType struct {
message string
}
ErrSignupDisabled struct{}
EmailConfirmationData struct {
UserID gid.GID `json:"uid"`
Email string `json:"email"`
}
InvitationData struct {
OrganizationID gid.GID `json:"organization_id"`
Email string `json:"email"`
FullName string `json:"full_name"`
CreatePeople bool `json:"create_people"`
}
PasswordResetData struct {
Email string `json:"email"`
}
)
// Token types
const (
TokenTypeEmailConfirmation = "email_confirmation"
TokenTypeOrganizationInvitation = "organization_invitation"
TokenTypePasswordReset = "password_reset"
)
var (
signupEmailSubject = "Confirm your email address"
signupEmailTemplate = `
Thanks joining Probo!
Please confirm your email address by clicking the link below[1]
[1] %s
`
invitationEmailSubject = "Join Probo"
invitationEmailTemplate = `
You have been invited to join Probo!
Please click the link below to sign up[1]
[1] %s
`
passwordResetEmailSubject = "Reset your password"
passwordResetEmailTemplate = `
You have requested a password reset for your Probo account.
Please click the link below to reset your password[1]
If you did not request this password reset, please ignore this email.
[1] %s
`
)
func (e ErrInvalidCredentials) Error() string {
return e.message
}
func (e ErrUserAlreadyExists) Error() string {
return e.message
}
func (e ErrSessionNotFound) Error() string {
return e.message
}
func (e ErrSessionExpired) Error() string {
return e.message
}
func (e ErrInvalidEmail) Error() string {
return fmt.Sprintf("invalid email: %s", e.email)
}
func (e ErrInvalidPassword) Error() string {
return fmt.Sprintf("invalid password: the length must be between %d and %d characters", e.minLength, e.maxLength)
}
func (e ErrInvalidFullName) Error() string {
return fmt.Sprintf("invalid full name: %s", e.fullName)
}
func (e ErrInvalidTokenType) Error() string {
return e.message
}
func (e ErrSignupDisabled) Error() string {
return "signup is disabled, contact the owner of the Probo instance"
}
func NewService(
ctx context.Context,
pgClient *pg.Client,
hp *passwdhash.Profile,
tokenSecret string,
hostname string,
disableSignup bool,
invitationTokenValidity time.Duration,
) (*Service, error) {
return &Service{
pg: pgClient,
hp: hp,
hostname: hostname,
tokenSecret: tokenSecret,
disableSignup: disableSignup,
invitationTokenValidity: invitationTokenValidity,
}, nil
}
func (s Service) ForgetPassword(
ctx context.Context,
email string,
) error {
// Always generate a new token to avoid timing attacks and leaking information
// about existing emails
passwordResetToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypePasswordReset,
1*time.Hour,
PasswordResetData{Email: email},
)
if err != nil {
return fmt.Errorf("cannot generate password reset token: %w", err)
}
resetPasswordUrl := url.URL{
Scheme: "https",
Host: s.hostname,
Path: "/auth/reset-password",
RawQuery: url.Values{
"token": []string{passwordResetToken},
}.Encode(),
}
user := &coredata.User{}
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := user.LoadByEmail(ctx, tx, email); err != nil {
var errUserNotFound *coredata.ErrUserNotFound
if errors.As(err, &errUserNotFound) {
// We don't want to leak information about existing emails
// Return success even if the email doesn't exist
return nil
}
return fmt.Errorf("cannot load user by %q email: %w", email, err)
}
resetPasswordEmail := coredata.NewEmail(
user.FullName,
user.EmailAddress,
passwordResetEmailSubject,
fmt.Sprintf(passwordResetEmailTemplate, resetPasswordUrl.String()),
)
if err := resetPasswordEmail.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
return nil
},
)
if err != nil {
return err
}
return nil
}
func (s Service) SignUp(
ctx context.Context,
email, password, fullName string,
) (*coredata.User, *coredata.Session, error) {
if s.disableSignup {
return nil, nil, &ErrSignupDisabled{}
}
if _, err := mail.ParseAddress(email); err != nil {
return nil, nil, &ErrInvalidEmail{email}
}
if len(password) < 8 || len(password) > 128 {
return nil, nil, &ErrInvalidPassword{minLength: 8, maxLength: 128}
}
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)
}
now := time.Now()
user := &coredata.User{
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
EmailAddress: email,
HashedPassword: hashedPassword,
FullName: fullName,
CreatedAt: now,
UpdatedAt: now,
}
session := &coredata.Session{
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
UserID: user.ID,
ExpiredAt: now.Add(24 * time.Hour),
CreatedAt: now,
UpdatedAt: now,
}
confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeEmailConfirmation,
1*time.Hour,
EmailConfirmationData{UserID: user.ID, Email: user.EmailAddress},
)
if err != nil {
return nil, nil, fmt.Errorf("cannot generate confirmation token: %w", err)
}
confirmationEmailUrl := url.URL{
Scheme: "https",
Host: s.hostname,
Path: "/auth/confirm-email",
RawQuery: url.Values{
"token": []string{confirmationToken},
}.Encode(),
}
confirmationEmail := coredata.NewEmail(
user.FullName,
user.EmailAddress,
signupEmailSubject,
fmt.Sprintf(signupEmailTemplate, confirmationEmailUrl.String()),
)
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := user.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert user: %w", err)
}
if err := session.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert session: %w", err)
}
if err := confirmationEmail.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, err
}
return user, session, nil
}
func (s Service) SignIn(
ctx context.Context,
email, password string,
) (*coredata.User, *coredata.Session, error) {
now := time.Now()
user := &coredata.User{}
session := &coredata.Session{
ID: gid.New(gid.NilTenant, coredata.SessionEntityType),
UserID: gid.Nil,
ExpiredAt: now.Add(24 * time.Hour),
CreatedAt: now,
UpdatedAt: now,
}
if len(password) < 8 || len(password) > 128 {
return nil, nil, &ErrInvalidPassword{minLength: 8, maxLength: 128}
}
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := user.LoadByEmail(ctx, tx, email); err != nil {
_, _ = s.hp.ComparePasswordAndHash([]byte("this-compare-should-never-succeed"), []byte("it-just-to-prevent-timing-attack"))
var errUserNotFound *coredata.ErrUserNotFound
if errors.As(err, &errUserNotFound) {
return &ErrInvalidCredentials{message: "invalid email or password"}
}
return fmt.Errorf("cannot load user by email: %w", err)
}
ok, err := s.hp.ComparePasswordAndHash([]byte(password), user.HashedPassword)
if err != nil {
return fmt.Errorf("cannot compare password: %w", err)
}
if !ok {
return &ErrInvalidCredentials{message: "invalid email or password"}
}
session.UserID = user.ID
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
}
func (s Service) SignOut(
ctx context.Context,
sessionID gid.GID,
) error {
return s.pg.WithConn(
ctx,
func(tx pg.Conn) error {
err := coredata.DeleteSession(ctx, tx, sessionID)
if err != nil {
return fmt.Errorf("cannot delete session: %w", err)
}
return nil
},
)
}
func (s Service) GetSession(
ctx context.Context,
sessionID gid.GID,
) (*coredata.Session, error) {
session := &coredata.Session{}
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := session.LoadByID(ctx, tx, sessionID); err != nil {
return &ErrSessionNotFound{message: "session not found"}
}
if time.Now().After(session.ExpiredAt) {
if err := coredata.DeleteSession(ctx, tx, sessionID); err != nil {
return fmt.Errorf("cannot delete expired session: %w", err)
}
return &ErrSessionExpired{message: "session expired"}
}
return nil
},
)
if err != nil {
return nil, err
}
return session, nil
}
func (s Service) GetUserByID(
ctx context.Context,
userID gid.GID,
) (*coredata.User, error) {
user := &coredata.User{}
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := user.LoadByID(ctx, tx, userID); err != nil {
return fmt.Errorf("user not found: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return user, nil
}
func (s Service) GetUserBySession(
ctx context.Context,
sessionID gid.GID,
) (*coredata.User, error) {
session, err := s.GetSession(ctx, sessionID)
if err != nil {
return nil, err
}
return s.GetUserByID(ctx, session.UserID)
}
func (s Service) ListOrganizationsForUserID(
ctx context.Context,
userID gid.GID,
) (coredata.Organizations, error) {
uos := coredata.UserOrganizations{}
organizations := []*coredata.Organization{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := uos.ForUserID(ctx, conn, userID); err != nil {
return fmt.Errorf("cannot list user organizations: %w", err)
}
for _, uo := range uos {
scope := coredata.NewScope(uo.OrganizationID.TenantID())
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, conn, scope, uo.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization by id: %w", err)
}
organizations = append(organizations, organization)
}
return nil
},
)
if err != nil {
return nil, err
}
return organizations, nil
}
// Tenant id scope is not applied in this functions because we want to access all user's organizations.
func (s Service) ListOrganizationsForUserIDPaginated(
ctx context.Context,
userID gid.GID,
cursor *page.Cursor[coredata.OrganizationOrderField],
) (coredata.Organizations, error) {
organizations := coredata.Organizations{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
err := organizations.ListForUserID(ctx, conn, userID, cursor)
if err != nil {
return fmt.Errorf("cannot list user organizations: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return organizations, nil
}
func (s Service) ListTenantsForUserID(
ctx context.Context,
userID gid.GID,
) ([]gid.TenantID, error) {
uos := coredata.UserOrganizations{}
err := s.pg.WithConn(
ctx,
func(tx pg.Conn) error {
return uos.ForUserID(ctx, tx, userID)
},
)
if err != nil {
return nil, err
}
tenantIDs := make([]gid.TenantID, len(uos))
for _, uo := range uos {
tenantIDs = append(tenantIDs, uo.OrganizationID.TenantID())
}
return tenantIDs, nil
}
func (s Service) EnrollUserInOrganization(
ctx context.Context,
userID gid.GID,
organizationID gid.GID,
) error {
uo := coredata.UserOrganization{
UserID: userID,
OrganizationID: organizationID,
CreatedAt: time.Now(),
}
return s.pg.WithConn(
ctx,
func(tx pg.Conn) error {
return uo.Insert(ctx, tx)
},
)
}
func (s Service) UpdateSession(
ctx context.Context,
session *coredata.Session,
) error {
session.UpdatedAt = time.Now()
session.ExpiredAt = time.Now().Add(24 * time.Hour)
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
return session.Update(ctx, tx)
},
)
}
func (s Service) ConfirmEmail(ctx context.Context, tokenString string) error {
token, err := statelesstoken.ValidateToken[EmailConfirmationData](
s.tokenSecret,
TokenTypeEmailConfirmation,
tokenString,
)
if err != nil {
return fmt.Errorf("cannot validate email confirmation token: %w", err)
}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
if err := user.LoadByID(ctx, tx, token.Data.UserID); err != nil {
return fmt.Errorf("user not found: %w", err)
}
if user.EmailAddress != token.Data.Email {
return fmt.Errorf("token email does not match user email")
}
if err := user.UpdateEmailVerification(ctx, tx, true); err != nil {
return fmt.Errorf("cannot update user email verification: %w", err)
}
return nil
},
)
}
func (s Service) ListUsersForTenant(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.UserOrderField],
) (*page.Page[*coredata.User, coredata.UserOrderField], error) {
users := coredata.Users{}
err := s.pg.WithConn(
ctx,
func(tx pg.Conn) error {
return users.LoadByOrganizationID(ctx, tx, organizationID, cursor)
},
)
if err != nil {
return nil, err
}
return page.NewPage(users, cursor), nil
}
func (s Service) InviteUser(
ctx context.Context,
organizationID gid.GID,
fullName string,
emailAddress string,
createPeople bool,
) error {
if _, err := mail.ParseAddress(emailAddress); err != nil {
return &ErrInvalidEmail{emailAddress}
}
if fullName == "" {
return &ErrInvalidFullName{fullName}
}
var userExists bool
err := s.pg.WithConn(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
if err := user.LoadByEmail(ctx, tx, emailAddress); err != nil {
var errUserNotFound *coredata.ErrUserNotFound
if errors.As(err, &errUserNotFound) {
userExists = false
return nil
}
return fmt.Errorf("cannot load user by email: %w", err)
}
userExists = true
uo := coredata.UserOrganization{
UserID: user.ID,
OrganizationID: organizationID,
CreatedAt: time.Now(),
}
if err := uo.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert user organization: %w", err)
}
if createPeople {
people := &coredata.People{}
scope := coredata.NewScope(organizationID.TenantID())
if err := people.LoadByEmail(ctx, tx, scope, emailAddress); err != nil {
var errPeopleNotFound *coredata.ErrPeopleNotFound
if errors.As(err, &errPeopleNotFound) {
people = &coredata.People{
ID: gid.New(organizationID.TenantID(), coredata.PeopleEntityType),
OrganizationID: organizationID,
UserID: &user.ID,
FullName: fullName,
PrimaryEmailAddress: emailAddress,
Kind: coredata.PeopleKindContractor,
AdditionalEmailAddresses: []string{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := people.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert people: %w", err)
}
} else {
return fmt.Errorf("cannot load people by email: %w", err)
}
} else {
people.UserID = &user.ID
people.FullName = fullName
people.UpdatedAt = time.Now()
if err := people.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update people: %w", err)
}
}
}
return nil
},
)
if err != nil {
return err
}
if userExists {
return nil
}
confirmationToken, err := statelesstoken.NewToken(
s.tokenSecret,
TokenTypeOrganizationInvitation,
s.invitationTokenValidity,
InvitationData{OrganizationID: organizationID, Email: emailAddress, FullName: fullName, CreatePeople: createPeople},
)
if err != nil {
return fmt.Errorf("cannot generate confirmation token: %w", err)
}
confirmationInvitationUrl := url.URL{
Scheme: "https",
Host: s.hostname,
Path: "/auth/confirm-invitation",
RawQuery: url.Values{
"token": []string{confirmationToken},
}.Encode(),
}
confirmationEmail := coredata.NewEmail(
fullName,
emailAddress,
invitationEmailSubject,
fmt.Sprintf(invitationEmailTemplate, confirmationInvitationUrl.String()),
)
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := confirmationEmail.Insert(ctx, conn); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
return nil
},
)
}
func (s Service) ConfirmInvitation(ctx context.Context, tokenString string, password string) (*coredata.User, error) {
token, err := statelesstoken.ValidateToken[InvitationData](
s.tokenSecret,
TokenTypeOrganizationInvitation,
tokenString,
)
if err != nil {
return nil, fmt.Errorf("cannot validate organization invitation token: %w", err)
}
if len(password) < 8 || len(password) > 128 {
return nil, &ErrInvalidPassword{minLength: 8, maxLength: 128}
}
now := time.Now()
hashedPassword, err := s.hp.HashPassword([]byte(password))
if err != nil {
return nil, fmt.Errorf("cannot hash password: %w", err)
}
user := &coredata.User{}
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := user.LoadByEmail(ctx, tx, token.Data.Email); err != nil {
var errUserNotFound *coredata.ErrUserNotFound
if errors.As(err, &errUserNotFound) {
user = &coredata.User{
ID: gid.New(gid.NilTenant, coredata.UserEntityType),
EmailAddress: token.Data.Email,
HashedPassword: hashedPassword,
EmailAddressVerified: true,
FullName: token.Data.FullName,
CreatedAt: now,
UpdatedAt: now,
}
if err := user.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert user: %w", err)
}
}
}
uo := coredata.UserOrganization{
UserID: user.ID,
OrganizationID: token.Data.OrganizationID,
CreatedAt: now,
}
if err := uo.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert user organization: %w", err)
}
if token.Data.CreatePeople {
people := &coredata.People{}
scope := coredata.NewScope(token.Data.OrganizationID.TenantID())
if err := people.LoadByEmail(ctx, tx, scope, token.Data.Email); err != nil {
var errPeopleNotFound *coredata.ErrPeopleNotFound
if errors.As(err, &errPeopleNotFound) {
peopleID := gid.New(token.Data.OrganizationID.TenantID(), coredata.PeopleEntityType)
people = &coredata.People{
ID: peopleID,
OrganizationID: token.Data.OrganizationID,
UserID: &user.ID,
FullName: token.Data.FullName,
PrimaryEmailAddress: token.Data.Email,
Kind: coredata.PeopleKindEmployee,
AdditionalEmailAddresses: []string{},
CreatedAt: now,
UpdatedAt: now,
}
if err := people.Insert(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot insert people: %w", err)
}
} else {
return fmt.Errorf("cannot load people by email: %w", err)
}
} else {
people.UserID = &user.ID
people.FullName = token.Data.FullName
people.UpdatedAt = now
if err := people.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update people: %w", err)
}
}
}
return nil
},
)
if err != nil {
return nil, err
}
return user, nil
}
func (s Service) RemoveUser(ctx context.Context, organizationID gid.GID, userID gid.GID) error {
return s.pg.WithConn(
ctx,
func(tx pg.Conn) error {
uo := coredata.UserOrganization{
UserID: userID,
OrganizationID: organizationID,
}
if err := uo.Delete(ctx, tx); err != nil {
return fmt.Errorf("cannot delete user organization: %w", err)
}
return nil
},
)
}
func (s Service) ResetPassword(ctx context.Context, tokenString string, newPassword string) error {
token, err := statelesstoken.ValidateToken[PasswordResetData](
s.tokenSecret,
TokenTypePasswordReset,
tokenString,
)
if err != nil {
return fmt.Errorf("cannot validate password reset token: %w", err)
}
if len(newPassword) < 8 || len(newPassword) > 128 {
return &ErrInvalidPassword{minLength: 8, maxLength: 128}
}
hashedPassword, err := s.hp.HashPassword([]byte(newPassword))
if err != nil {
return fmt.Errorf("cannot hash password: %w", err)
}
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
if err := user.LoadByEmail(ctx, tx, token.Data.Email); err != nil {
var errUserNotFound *coredata.ErrUserNotFound
if errors.As(err, &errUserNotFound) {
return fmt.Errorf("user not found: %w", err)
}
return fmt.Errorf("cannot load user by email: %w", err)
}
if err := user.UpdatePassword(ctx, tx, hashedPassword); err != nil {
return fmt.Errorf("cannot update user password: %w", err)
}
return nil
},
)
}