Add role management

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-11-07 09:20:38 +01:00
parent 696adb7d79
commit 21c4b7cd9d
143 changed files with 5976 additions and 2094 deletions

View File

@@ -68,6 +68,20 @@ export function PageError({ resetErrorBoundary, error: propsError }: Props) {
);
}
if (error && error.toString().includes("FORBIDDEN")) {
return (
<div className={classNames.wrapper}>
<h1 className={classNames.title}>
<IconPageCross size={26} />
{__("Page not found")}
</h1>
<p className={classNames.description}>
{__("The page you are looking for does not exist")}
</p>
</div>
);
}
if (error && error.toString().includes("UNAUTHORIZED")) {
return (
<div className={classNames.wrapper}>

View File

@@ -6,6 +6,7 @@ import type { CustomDomainManagerDeleteMutation } from "./__generated__/CustomDo
import { CreateCustomDomainDialog } from "./CreateCustomDomainDialog";
import { DeleteCustomDomainDialog } from "./DeleteCustomDomainDialog";
import { DomainDetailsDialog } from "./DomainDetailsDialog";
import { Authorized } from "/permissions";
const deleteCustomDomainMutation = graphql`
mutation CustomDomainManagerDeleteMutation($input: DeleteCustomDomainInput!) {
@@ -106,9 +107,11 @@ export function CustomDomainManager({
)}
</p>
<div className="flex justify-center">
<CreateCustomDomainDialog organizationId={organizationId}>
<Button icon={IconPlusLarge}>{__("Add Domain")}</Button>
</CreateCustomDomainDialog>
<Authorized entity="Organization" action="createCustomDomain">
<CreateCustomDomainDialog organizationId={organizationId}>
<Button icon={IconPlusLarge}>{__("Add Domain")}</Button>
</CreateCustomDomainDialog>
</Authorized>
</div>
</div>
</Card>
@@ -136,12 +139,14 @@ export function CustomDomainManager({
<Button variant="secondary">{__("View Details")}</Button>
</DomainDetailsDialog>
<DeleteCustomDomainDialog
domainName={domain.domain}
onConfirm={handleDeleteDomain}
>
<Button variant="danger">{__("Delete")}</Button>
</DeleteCustomDomainDialog>
<Authorized entity="CustomDomain" action="deleteCustomDomain">
<DeleteCustomDomainDialog
domainName={domain.domain}
onConfirm={handleDeleteDomain}
>
<Button variant="danger">{__("Delete")}</Button>
</DeleteCustomDomainDialog>
</Authorized>
</div>
</div>
</div>

View File

@@ -5,6 +5,8 @@ import {
DialogFooter,
Field,
Checkbox,
Select,
Option,
useDialogRef,
} from "@probo/ui";
import type { PropsWithChildren } from "react";
@@ -15,6 +17,8 @@ import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { Controller } from "react-hook-form";
import { Suspense } from "react";
import { getAssignableRoles } from "/permissions";
const inviteMutation = graphql`
mutation InviteUserDialogMutation(
@@ -40,6 +44,7 @@ const inviteMutation = graphql`
const schema = z.object({
email: z.string().email(),
fullName: z.string(),
role: z.enum(["OWNER", "ADMIN", "FULL", "VIEWER"]).default("VIEWER"),
createPeople: z.boolean().default(false),
});
@@ -48,16 +53,17 @@ type Props = PropsWithChildren & {
onRefetch: () => void;
};
export function InviteUserDialog({ children, connectionId, onRefetch }: Props) {
function InviteUserDialogContent({ children, connectionId, onRefetch }: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const assignableRoles = getAssignableRoles(organizationId);
const [inviteUser, isInviting] = useMutationWithToasts(inviteMutation, {
successMessage: __("Invitation sent successfully"),
errorMessage: __("Failed to send invitation"),
});
const { register, handleSubmit, formState, reset, control } = useFormWithSchema(
schema,
{ defaultValues: { createPeople: false } },
{ defaultValues: { role: "VIEWER", createPeople: false } },
);
const dialogRef = useDialogRef();
@@ -69,6 +75,7 @@ export function InviteUserDialog({ children, connectionId, onRefetch }: Props) {
organizationId,
email: data.email,
fullName: data.fullName,
role: data.role,
createPeople: data.createPeople,
},
connections: connectionId ? [connectionId] : ["SettingsPageInvitations_invitations"],
@@ -107,6 +114,32 @@ export function InviteUserDialog({ children, connectionId, onRefetch }: Props) {
{...register("fullName")}
error={formState.errors.fullName?.message}
/>
<Field label={__("Role")} required>
<Controller
name="role"
control={control}
render={({ field }) => (
<>
<Select value={field.value} onValueChange={field.onChange}>
{assignableRoles.includes("OWNER") && <Option value="OWNER">{__("Owner")}</Option>}
{assignableRoles.includes("ADMIN") && <Option value="ADMIN">{__("Admin")}</Option>}
{assignableRoles.includes("VIEWER") && <Option value="VIEWER">{__("Viewer")}</Option>}
</Select>
<div className="mt-2 text-sm text-txt-tertiary">
{field.value === "OWNER" && (
<p>{__("Full access to everything")}</p>
)}
{field.value === "ADMIN" && (
<p>{__("Full access except organization setup and API keys")}</p>
)}
{field.value === "VIEWER" && (
<p>{__("Read-only access")}</p>
)}
</div>
</>
)}
/>
</Field>
<div className="space-y-2">
<div className="flex items-center space-x-3">
<Controller
@@ -142,3 +175,11 @@ export function InviteUserDialog({ children, connectionId, onRefetch }: Props) {
</Dialog>
);
}
export function InviteUserDialog(props: Props) {
return (
<Suspense fallback={props.children}>
<InviteUserDialogContent {...props} />
</Suspense>
);
}

View File

@@ -2,6 +2,7 @@ import { Badge, Button, Card } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { sprintf } from "@probo/helpers";
import type { TrustCenterGraphQuery$data } from "/hooks/graph/__generated__/TrustCenterGraphQuery.graphql";
import { Authorized } from "/permissions";
type Props = {
organizationId: string;
@@ -76,9 +77,11 @@ export function SlackConnections({ organizationId, slackConnections: connectedSl
</Badge>
</div>
) : (
<Button variant="secondary" asChild>
<a href={getUrl(slackConnection.id)}>{__("Connect")}</a>
</Button>
<Authorized entity="TrustCenter" action="updateTrustCenter">
<Button variant="secondary" asChild>
<a href={getUrl(slackConnection.id)}>{__("Connect")}</a>
</Button>
</Authorized>
)}
</Card>
))}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<617ea2708c8402c706671dc1a63b1316>>
* @generated SignedSource<<eda42f72473c65692ddd9cee68c0ce81>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,12 +9,13 @@
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type Role = "ADMIN" | "MEMBER" | "OWNER" | "VIEWER";
export type MembershipRole = "ADMIN" | "OWNER" | "VIEWER";
export type InviteUserInput = {
createPeople: boolean;
email: string;
fullName: string;
organizationId: string;
role: MembershipRole;
};
export type InviteUserDialogMutation$variables = {
connections: ReadonlyArray<string>;
@@ -30,7 +31,7 @@ export type InviteUserDialogMutation$data = {
readonly expiresAt: any;
readonly fullName: string;
readonly id: string;
readonly role: Role;
readonly role: MembershipRole;
};
};
};

View File

@@ -26,6 +26,8 @@ import TaskFormDialog, {
import { useOrganizationId } from "/hooks/useOrganizationId";
import { Link, useLocation, useParams } from "react-router";
import { promisifyMutation } from "@probo/helpers";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
import type { TaskFormDialogFragment$key } from "./__generated__/TaskFormDialogFragment.graphql";
import { updateStoreCounter } from "/hooks/useMutationWithIncrement";
@@ -49,6 +51,7 @@ type Props = {
export default function TasksCard({ tasks, connectionId }: Props) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const hash = useLocation().hash.replace("#", "");
const hashes = [
@@ -67,6 +70,9 @@ export default function TasksCard({ tasks, connectionId }: Props) {
usePageTitle(__("Tasks"));
const hasAnyAction = isAuthorized(organizationId, "Task", "updateTask") ||
isAuthorized(organizationId, "Task", "deleteTask");
return (
<div className="space-y-6">
{tasks?.length === 0 ? (
@@ -100,6 +106,7 @@ export default function TasksCard({ tasks, connectionId }: Props) {
key={task.id}
task={task}
connectionId={connectionId}
hasAnyAction={hasAnyAction}
/>
))}
</Fragment>
@@ -110,6 +117,7 @@ export default function TasksCard({ tasks, connectionId }: Props) {
key={task.id}
task={task}
connectionId={connectionId}
hasAnyAction={hasAnyAction}
/>
))}
</div>
@@ -122,6 +130,7 @@ export default function TasksCard({ tasks, connectionId }: Props) {
type TaskRowProps = {
task: ItemOf<Props["tasks"]> & TaskFormDialogFragment$key;
connectionId: string;
hasAnyAction: boolean;
};
const deleteMutation = graphql`
@@ -221,21 +230,27 @@ function TaskRow(props: TaskRowProps) {
<Avatar name={props.task.assignedTo?.fullName ?? ""} />
</Link>
)}
<ActionDropdown>
<DropdownItem
icon={IconPencil}
onClick={() => dialogRef.current?.open()}
>
{__("Edit")}
</DropdownItem>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
{props.hasAnyAction && (
<ActionDropdown>
<Authorized entity="Task" action="updateTask">
<DropdownItem
icon={IconPencil}
onClick={() => dialogRef.current?.open()}
>
{__("Edit")}
</DropdownItem>
</Authorized>
<Authorized entity="Task" action="deleteTask">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
)}
</div>
</div>
</>

View File

@@ -18,6 +18,7 @@ import { useMemo, useState, useCallback, useEffect } from "react";
import { sprintf, getAuditStateVariant, getAuditStateLabel, formatDate, getTrustCenterVisibilityOptions } from "@probo/helpers";
import { useOrganizationId } from "/hooks/useOrganizationId";
import type { TrustCenterAuditsCardFragment$key } from "./__generated__/TrustCenterAuditsCardFragment.graphql";
import { isAuthorized } from "/permissions";
const trustCenterAuditFragment = graphql`
fragment TrustCenterAuditsCardFragment on Audit {
@@ -124,6 +125,8 @@ function AuditRow(props: {
const { __ } = useTranslate();
const [optimisticValue, setOptimisticValue] = useState<string | null>(null);
const canUpdate = organizationId ? isAuthorized(organizationId, "TrustCenter", "updateTrustCenter") : false;
const handleValueChange = useCallback((value: string | {}) => {
const stringValue = typeof value === 'string' ? value : '';
const typedValue = stringValue as "NONE" | "PRIVATE" | "PUBLIC";
@@ -164,7 +167,7 @@ function AuditRow(props: {
type="select"
value={currentValue}
onValueChange={handleValueChange}
disabled={props.disabled}
disabled={props.disabled || !canUpdate}
className="w-[105px]"
>
{visibilityOptions.map((option) => (

View File

@@ -20,6 +20,7 @@ import { useFragment } from "react-relay";
import { useMemo, useState, useCallback, useEffect } from "react";
import { sprintf, getTrustCenterVisibilityOptions } from "@probo/helpers";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { isAuthorized } from "/permissions";
const trustCenterDocumentFragment = graphql`
fragment TrustCenterDocumentsCardFragment on Document {
@@ -129,6 +130,8 @@ function DocumentRow(props: {
const { __ } = useTranslate();
const [optimisticValue, setOptimisticValue] = useState<string | null>(null);
const canUpdate = organizationId ? isAuthorized(organizationId, "TrustCenter", "updateTrustCenter") : false;
const handleValueChange = useCallback((value: string | {}) => {
const stringValue = typeof value === 'string' ? value : '';
const typedValue = stringValue as "NONE" | "PRIVATE" | "PUBLIC";
@@ -164,7 +167,7 @@ function DocumentRow(props: {
type="select"
value={currentValue}
onValueChange={handleValueChange}
disabled={props.disabled}
disabled={props.disabled || !canUpdate}
className="w-[105px]"
>
{visibilityOptions.map((option) => (

View File

@@ -21,6 +21,9 @@ import { useFragment } from "react-relay";
import { useMemo, useState, useCallback, useEffect } from "react";
import { sprintf, getTrustCenterVisibilityOptions } from "@probo/helpers";
import { formatDate } from "@probo/helpers";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
import { useParams } from "react-router";
const trustCenterFileFragment = graphql`
fragment TrustCenterFilesCardFragment on TrustCenterFile {
@@ -147,6 +150,9 @@ function FileRow(props: {
const file = props.file;
const { __ } = useTranslate();
const [optimisticValue, setOptimisticValue] = useState<string | null>(null);
const { organizationId } = useParams();
const canUpdate = organizationId ? isAuthorized(organizationId, "TrustCenter", "updateTrustCenter") : false;
const handleValueChange = useCallback((value: string | {}) => {
const stringValue = typeof value === 'string' ? value : '';
@@ -179,7 +185,7 @@ function FileRow(props: {
type="select"
value={currentValue}
onValueChange={handleValueChange}
disabled={props.disabled}
disabled={props.disabled || !canUpdate}
className="w-[105px]"
>
{visibilityOptions.map((option) => (
@@ -201,20 +207,24 @@ function FileRow(props: {
onClick={() => window.open(file.fileUrl, '_blank', 'noopener,noreferrer')}
title={__("Download")}
/>
<Button
variant="secondary"
icon={IconPencil}
onClick={() => props.onEdit({ id: file.id, name: file.name, category: file.category })}
disabled={props.disabled}
title={__("Edit")}
/>
<Button
variant="danger"
icon={IconTrashCan}
onClick={() => props.onDelete(file.id)}
disabled={props.disabled}
title={__("Delete")}
/>
<Authorized entity="TrustCenterFile" action="updateTrustCenterFile">
<Button
variant="secondary"
icon={IconPencil}
onClick={() => props.onEdit({ id: file.id, name: file.name, category: file.category })}
disabled={props.disabled}
title={__("Edit")}
/>
</Authorized>
<Authorized entity="TrustCenterFile" action="deleteTrustCenterFile">
<Button
variant="danger"
icon={IconTrashCan}
onClick={() => props.onDelete(file.id)}
disabled={props.disabled}
title={__("Delete")}
/>
</Authorized>
</div>
</Td>
</Tr>

View File

@@ -21,6 +21,7 @@ import {
} from "/hooks/graph/TrustCenterReferenceGraph";
import { TrustCenterReferenceDialog, type TrustCenterReferenceDialogRef } from "./TrustCenterReferenceDialog";
import { DeleteTrustCenterReferenceDialog } from "./DeleteTrustCenterReferenceDialog";
import { Authorized } from "/permissions";
type Props = {
trustCenterId: string;
@@ -111,12 +112,14 @@ export function TrustCenterReferencesSection({ trustCenterId }: Props) {
{__("Showcase your customers and partners on your trust center")}
</p>
</div>
<Button
icon={IconPlusLarge}
onClick={handleCreate}
>
{__("Add Reference")}
</Button>
<Authorized entity="TrustCenter" action="createTrustCenterReference">
<Button
icon={IconPlusLarge}
onClick={handleCreate}
>
{__("Add Reference")}
</Button>
</Authorized>
</div>
<Table>
@@ -228,21 +231,25 @@ function ReferenceRow({
icon={IconArrowLink}
onClick={onVisitWebsite}
/>
<Button
variant="secondary"
icon={IconPencil}
onClick={onEdit}
/>
<DeleteTrustCenterReferenceDialog
referenceId={reference.id}
referenceName={reference.name}
connectionId={connectionId}
>
<Authorized entity="TrustCenterReference" action="updateTrustCenterReference">
<Button
variant="danger"
icon={IconTrashCan}
variant="secondary"
icon={IconPencil}
onClick={onEdit}
/>
</DeleteTrustCenterReferenceDialog>
</Authorized>
<Authorized entity="TrustCenterReference" action="deleteTrustCenterReference">
<DeleteTrustCenterReferenceDialog
referenceId={reference.id}
referenceName={reference.name}
connectionId={connectionId}
>
<Button
variant="danger"
icon={IconTrashCan}
/>
</DeleteTrustCenterReferenceDialog>
</Authorized>
</div>
</Td>
</Tr>

View File

@@ -14,9 +14,10 @@ import {
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { useFragment } from "react-relay";
import { useMemo, useState } from "react";
import { useMemo, useState, useEffect } from "react";
import { sprintf } from "@probo/helpers";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { isAuthorized } from "/permissions/permissions";
import type { TrustCenterVendorsCardFragment$key } from "./__generated__/TrustCenterVendorsCardFragment.graphql";
const trustCenterVendorFragment = graphql`
@@ -48,12 +49,44 @@ type Props<Params> = {
export function TrustCenterVendorsCard<Params>(props: Props<Params>) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const [limit, setLimit] = useState<number | null>(100);
const [canUpdate, setCanUpdate] = useState<boolean>(false);
const vendors = useMemo(() => {
return limit ? props.vendors.slice(0, limit) : props.vendors;
}, [props.vendors, limit]);
const showMoreButton = limit !== null && props.vendors.length > limit;
useEffect(() => {
if (!organizationId) {
setCanUpdate(false);
return;
}
try {
const authorized = isAuthorized(organizationId, "Vendor", "updateVendor");
setCanUpdate(authorized);
} catch (promise) {
if (promise instanceof Promise) {
promise
.then(() => {
try {
const authorized = isAuthorized(organizationId, "Vendor", "updateVendor");
setCanUpdate(authorized);
} catch {
setCanUpdate(false);
}
})
.catch(() => {
setCanUpdate(false);
});
} else {
setCanUpdate(false);
}
}
}, [organizationId]);
const onToggleVisibility = (vendorId: string, showOnTrustCenter: boolean) => {
props.onToggleVisibility({
variables: {
@@ -74,13 +107,13 @@ export function TrustCenterVendorsCard<Params>(props: Props<Params>) {
<Th>{__("Name")}</Th>
<Th>{__("Category")}</Th>
<Th>{__("Visibility")}</Th>
<Th></Th>
{canUpdate && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{vendors.length === 0 && (
<Tr>
<Td colSpan={4} className="text-center text-txt-secondary">
<Td colSpan={canUpdate ? 4 : 3} className="text-center text-txt-secondary">
{__("No vendors available")}
</Td>
</Tr>
@@ -91,6 +124,7 @@ export function TrustCenterVendorsCard<Params>(props: Props<Params>) {
vendor={vendor}
onToggleVisibility={onToggleVisibility}
disabled={props.disabled}
canUpdate={canUpdate}
/>
))}
</Tbody>
@@ -113,6 +147,7 @@ function VendorRow(props: {
vendor: TrustCenterVendorsCardFragment$key;
onToggleVisibility: (vendorId: string, showOnTrustCenter: boolean) => void;
disabled?: boolean;
canUpdate: boolean;
}) {
const vendor = useFragment(trustCenterVendorFragment, props.vendor);
const organizationId = useOrganizationId();
@@ -135,16 +170,18 @@ function VendorRow(props: {
{vendor.showOnTrustCenter ? __("Visible") : __("None")}
</Badge>
</Td>
<Td noLink width={100} className="text-end">
<Button
variant="secondary"
onClick={() => props.onToggleVisibility(vendor.id, !vendor.showOnTrustCenter)}
icon={vendor.showOnTrustCenter ? IconCrossLargeX : IconCheckmark1}
disabled={props.disabled}
>
{vendor.showOnTrustCenter ? __("Hide") : __("Show")}
</Button>
</Td>
{props.canUpdate && (
<Td noLink width={100} className="text-end">
<Button
variant="secondary"
onClick={() => props.onToggleVisibility(vendor.id, !vendor.showOnTrustCenter)}
icon={vendor.showOnTrustCenter ? IconCrossLargeX : IconCheckmark1}
disabled={props.disabled}
>
{vendor.showOnTrustCenter ? __("Hide") : __("Show")}
</Button>
</Td>
)}
</Tr>
);
}

View File

@@ -47,6 +47,7 @@ import { useToast } from "@probo/ui";
import { ErrorBoundary } from "react-error-boundary";
import { PageError } from "/components/PageError";
import { buildEndpoint } from "/providers/RelayProviders";
import { Authorized } from "/permissions";
const MainLayoutQuery = graphql`
query MainLayoutQuery($organizationId: ID!) {
@@ -72,7 +73,6 @@ const MainLayoutQuery = graphql`
*/
export function MainLayout() {
const { organizationId } = useParams();
const { __ } = useTranslate();
const prefix = `/organizations/${organizationId}`;
@@ -80,14 +80,31 @@ export function MainLayout() {
return <Navigate to="/" />;
}
return (
<Suspense fallback={<Skeleton className="w-full h-screen" />}>
<MainLayoutContent organizationId={organizationId} prefix={prefix} />
</Suspense>
);
}
function MainLayoutContent({
organizationId,
prefix,
}: {
organizationId: string;
prefix: string;
}) {
const { __ } = useTranslate();
const data = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, {
organizationId,
});
return (
<Layout
header={
<>
<div className="mr-auto">
<Suspense fallback={<Skeleton className="w-20 h-8" />}>
<OrganizationSelectorWrapper organizationId={organizationId} />
</Suspense>
<OrganizationSelector currentOrganization={data.organization} />
</div>
<Suspense fallback={<Skeleton className="w-32 h-8" />}>
<UserDropdown organizationId={organizationId} />
@@ -96,96 +113,132 @@ export function MainLayout() {
}
sidebar={
<ul className="space-y-[2px]">
<SidebarItem
label={__("Meetings")}
icon={IconCalendar1}
to={`${prefix}/meetings`}
/>
<SidebarItem
label={__("Tasks")}
icon={IconInboxEmpty}
to={`${prefix}/tasks`}
/>
<SidebarItem
label={__("Measures")}
icon={IconTodo}
to={`${prefix}/measures`}
/>
<SidebarItem
label={__("Risks")}
icon={IconFire3}
to={`${prefix}/risks`}
/>
<SidebarItem
label={__("Frameworks")}
icon={IconBank}
to={`${prefix}/frameworks`}
/>
<SidebarItem
label={__("People")}
icon={IconGroup1}
to={`${prefix}/people`}
/>
<SidebarItem
label={__("Vendors")}
icon={IconStore}
to={`${prefix}/vendors`}
/>
<SidebarItem
label={__("Documents")}
icon={IconPageTextLine}
to={`${prefix}/documents`}
/>
<SidebarItem
label={__("Assets")}
icon={IconBox}
to={`${prefix}/assets`}
/>
<SidebarItem
label={__("Data")}
icon={IconListStack}
to={`${prefix}/data`}
/>
<SidebarItem
label={__("Audits")}
icon={IconMedal}
to={`${prefix}/audits`}
/>
<SidebarItem
label={__("Nonconformities")}
icon={IconCrossLargeX}
to={`${prefix}/nonconformities`}
/>
<SidebarItem
label={__("Obligations")}
icon={IconBook}
to={`${prefix}/obligations`}
/>
<SidebarItem
label={__("Continual Improvements")}
icon={IconRotateCw}
to={`${prefix}/continual-improvements`}
/>
<SidebarItem
label={__("Processing Activities")}
icon={IconCircleProgress}
to={`${prefix}/processing-activities`}
/>
<SidebarItem
label={__("Snapshots")}
icon={IconClock}
to={`${prefix}/snapshots`}
/>
<SidebarItem
label={__("Trust Center")}
icon={IconShield}
to={`${prefix}/trust-center`}
/>
<SidebarItem
label={__("Settings")}
icon={IconSettingsGear2}
to={`${prefix}/settings`}
/>
<Authorized entity="Organization" action="listMeetings">
<SidebarItem
label={__("Meetings")}
icon={IconCalendar1}
to={`${prefix}/meetings`}
/>
</Authorized>
<Authorized entity="Organization" action="listTasks">
<SidebarItem
label={__("Tasks")}
icon={IconInboxEmpty}
to={`${prefix}/tasks`}
/>
</Authorized>
<Authorized entity="Organization" action="listMeasures">
<SidebarItem
label={__("Measures")}
icon={IconTodo}
to={`${prefix}/measures`}
/>
</Authorized>
<Authorized entity="Organization" action="listRisks">
<SidebarItem
label={__("Risks")}
icon={IconFire3}
to={`${prefix}/risks`}
/>
</Authorized>
<Authorized entity="Organization" action="listFrameworks">
<SidebarItem
label={__("Frameworks")}
icon={IconBank}
to={`${prefix}/frameworks`}
/>
</Authorized>
<Authorized entity="Organization" action="listPeople">
<SidebarItem
label={__("People")}
icon={IconGroup1}
to={`${prefix}/people`}
/>
</Authorized>
<Authorized entity="Organization" action="listVendors">
<SidebarItem
label={__("Vendors")}
icon={IconStore}
to={`${prefix}/vendors`}
/>
</Authorized>
<Authorized entity="Organization" action="listDocuments">
<SidebarItem
label={__("Documents")}
icon={IconPageTextLine}
to={`${prefix}/documents`}
/>
</Authorized>
<Authorized entity="Organization" action="listAssets">
<SidebarItem
label={__("Assets")}
icon={IconBox}
to={`${prefix}/assets`}
/>
</Authorized>
<Authorized entity="Organization" action="listData">
<SidebarItem
label={__("Data")}
icon={IconListStack}
to={`${prefix}/data`}
/>
</Authorized>
<Authorized entity="Organization" action="listAudits">
<SidebarItem
label={__("Audits")}
icon={IconMedal}
to={`${prefix}/audits`}
/>
</Authorized>
<Authorized entity="Organization" action="listNonconformities">
<SidebarItem
label={__("Nonconformities")}
icon={IconCrossLargeX}
to={`${prefix}/nonconformities`}
/>
</Authorized>
<Authorized entity="Organization" action="listObligations">
<SidebarItem
label={__("Obligations")}
icon={IconBook}
to={`${prefix}/obligations`}
/>
</Authorized>
<Authorized entity="Organization" action="listContinualImprovements">
<SidebarItem
label={__("Continual Improvements")}
icon={IconRotateCw}
to={`${prefix}/continual-improvements`}
/>
</Authorized>
<Authorized entity="Organization" action="listProcessingActivities">
<SidebarItem
label={__("Processing Activities")}
icon={IconCircleProgress}
to={`${prefix}/processing-activities`}
/>
</Authorized>
<Authorized entity="Organization" action="listSnapshots">
<SidebarItem
label={__("Snapshots")}
icon={IconClock}
to={`${prefix}/snapshots`}
/>
</Authorized>
<Authorized entity="Organization" action="getTrustCenter">
<SidebarItem
label={__("Trust Center")}
icon={IconShield}
to={`${prefix}/trust-center`}
/>
</Authorized>
<Authorized entity="Organization" action="listMembers">
<SidebarItem
label={__("Settings")}
icon={IconSettingsGear2}
to={`${prefix}/settings`}
/>
</Authorized>
</ul>
}
>
@@ -234,11 +287,13 @@ function UserDropdown({ organizationId }: { organizationId: string }) {
return (
<UserDropdownRoot fullName={user.fullName} email={user.email}>
<UserDropdownItem
to="/api-keys"
icon={IconKey}
label={__("API Keys")}
/>
<Authorized entity="Organization" action="deleteOrganization">
<UserDropdownItem
to="/api-keys"
icon={IconKey}
label={__("API Keys")}
/>
</Authorized>
<UserDropdownItem
to="mailto:support@getprobo.com"
icon={IconCircleQuestionmark}
@@ -287,16 +342,6 @@ interface InvitationsResponse {
invitations: Invitation[];
}
function OrganizationSelectorWrapper({
organizationId,
}: {
organizationId: string;
}) {
const data = useLazyLoadQuery<MainLayoutQueryType>(MainLayoutQuery, {
organizationId,
});
return <OrganizationSelector currentOrganization={data.organization} />;
}
function OrganizationSelector({
currentOrganization,

View File

@@ -120,7 +120,7 @@ export default function APIKeysPage() {
try {
const [apiKeysResponse, organizationsResponse] = await Promise.all([
fetch('/connect/api-keys', { credentials: 'include' }),
fetch('/connect/organizations', { credentials: 'include' }),
fetch('/connect/organizations?role=OWNER', { credentials: 'include' }),
]);
if (apiKeysResponse.status === 401 || organizationsResponse.status === 401) {

View File

@@ -28,6 +28,7 @@ import { useFormWithSchema } from "/hooks/useFormWithSchema";
import z from "zod";
import { getAssetTypeVariant, validateSnapshotConsistency } from "@probo/helpers";
import { SnapshotBanner } from "/components/SnapshotBanner";
import { Authorized } from "/permissions";
const updateAssetSchema = z.object({
name: z.string().min(1, "Name is required"),
@@ -114,15 +115,17 @@ export default function AssetDetailsPage(props: Props) {
</Badge>
</div>
{!isSnapshotMode && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteAsset}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
<Authorized entity="Asset" action="deleteAsset">
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteAsset}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Authorized>
)}
</div>
@@ -178,9 +181,11 @@ export default function AssetDetailsPage(props: Props) {
<div className="flex justify-end">
{formState.isDirty && !isSnapshotMode && (
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>
<Authorized entity="Asset" action="updateAsset">
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>
</Authorized>
)}
</div>
</form>

View File

@@ -35,6 +35,8 @@ import type {
} from "./__generated__/AssetsPageFragment.graphql";
import { SortableTable } from "/components/SortableTable";
import { SnapshotBanner } from "/components/SnapshotBanner";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
const paginatedAssetsFragment = graphql`
fragment AssetsPageFragment on Organization
@@ -106,6 +108,11 @@ export default function AssetsPage(props: Props) {
usePageTitle(__("Assets"));
const hasAnyAction = !isSnapshotMode && (
isAuthorized(organizationId, "Asset", "updateAsset") ||
isAuthorized(organizationId, "Asset", "deleteAsset")
);
return (
<div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
@@ -116,12 +123,14 @@ export default function AssetsPage(props: Props) {
)}
>
{!isSnapshotMode && (
<CreateAssetDialog
connection={connectionId}
organizationId={organizationId}
>
<Button icon={IconPlusLarge}>{__("Add asset")}</Button>
</CreateAssetDialog>
<Authorized entity="Organization" action="createAsset">
<CreateAssetDialog
connection={connectionId}
organizationId={organizationId}
>
<Button icon={IconPlusLarge}>{__("Add asset")}</Button>
</CreateAssetDialog>
</Authorized>
)}
</PageHeader>
<SortableTable {...pagination}>
@@ -132,7 +141,7 @@ export default function AssetsPage(props: Props) {
<Th>{__("Amount")}</Th>
<Th>{__("Owner")}</Th>
<Th>{__("Vendors")}</Th>
<Th></Th>
{hasAnyAction && <Th></Th>}
</Tr>
</Thead>
<Tbody>
@@ -141,6 +150,7 @@ export default function AssetsPage(props: Props) {
key={entry.id}
entry={entry}
connectionId={connectionId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
@@ -152,9 +162,11 @@ export default function AssetsPage(props: Props) {
function AssetRow({
entry,
connectionId,
hasAnyAction,
}: {
entry: AssetEntry;
connectionId: string;
hasAnyAction: boolean;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
@@ -204,19 +216,21 @@ function AssetRow({
<span className="text-txt-secondary text-sm">{__("None")}</span>
)}
</Td>
<Td noLink width={50} className="text-end">
{!isSnapshotMode && (
<ActionDropdown>
<DropdownItem
onClick={deleteAsset}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
)}
</Td>
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<Authorized entity="Asset" action="deleteAsset">
<ActionDropdown>
<DropdownItem
onClick={deleteAsset}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Authorized>
</Td>
)}
</Tr>
);
}

View File

@@ -34,6 +34,7 @@ import { useFormWithSchema } from "/hooks/useFormWithSchema";
import z from "zod";
import { getAuditStateLabel, getAuditStateVariant, auditStates, fileSize, sprintf, formatDatetime, formatError, formatDate, type GraphQLError } from "@probo/helpers";
import type { AuditGraphNodeQuery } from "/hooks/graph/__generated__/AuditGraphNodeQuery.graphql";
import { Authorized } from "/permissions";
const updateAuditSchema = z.object({
name: z.string().nullable().optional(),
@@ -145,13 +146,15 @@ export default function AuditDetailsPage(props: Props) {
</Badge>
</div>
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteAudit}
>
{__("Delete")}
</DropdownItem>
<Authorized entity="Audit" action="deleteAudit">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteAudit}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
</div>
@@ -184,9 +187,11 @@ export default function AuditDetailsPage(props: Props) {
<div className="flex justify-end">
{formState.isDirty && (
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>
<Authorized entity="Audit" action="updateAudit">
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>
</Authorized>
)}
</div>
</form>

View File

@@ -31,6 +31,8 @@ import type {
AuditsPageFragment$key,
} from "./__generated__/AuditsPageFragment.graphql";
import { SortableTable } from "/components/SortableTable";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
const paginatedAuditsFragment = graphql`
fragment AuditsPageFragment on Organization
@@ -92,6 +94,9 @@ export default function AuditsPage(props: Props) {
usePageTitle(__("Audits"));
const hasAnyAction = isAuthorized(organizationId, "Audit", "updateAudit") ||
isAuthorized(organizationId, "Audit", "deleteAudit");
return (
<div className="space-y-6">
<PageHeader
@@ -100,12 +105,14 @@ export default function AuditsPage(props: Props) {
"Manage your organization's compliance audits and their progress."
)}
>
<CreateAuditDialog
connection={connectionId}
organizationId={organizationId}
>
<Button icon={IconPlusLarge}>{__("Add audit")}</Button>
</CreateAuditDialog>
<Authorized entity="Organization" action="createAudit">
<CreateAuditDialog
connection={connectionId}
organizationId={organizationId}
>
<Button icon={IconPlusLarge}>{__("Add audit")}</Button>
</CreateAuditDialog>
</Authorized>
</PageHeader>
<SortableTable {...pagination}>
<Thead>
@@ -116,7 +123,7 @@ export default function AuditsPage(props: Props) {
<Th>{__("Valid From")}</Th>
<Th>{__("Valid Until")}</Th>
<Th>{__("Report")}</Th>
<Th></Th>
{hasAnyAction && <Th></Th>}
</Tr>
</Thead>
<Tbody>
@@ -125,6 +132,7 @@ export default function AuditsPage(props: Props) {
key={entry.id}
entry={entry}
connectionId={connectionId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
@@ -136,9 +144,11 @@ export default function AuditsPage(props: Props) {
function AuditRow({
entry,
connectionId,
hasAnyAction,
}: {
entry: AuditEntry;
connectionId: string;
hasAnyAction: boolean;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
@@ -164,17 +174,21 @@ function AuditRow({
<Badge variant="neutral">{__("Not uploaded")}</Badge>
)}
</Td>
<Td noLink width={50} className="text-end">
<ActionDropdown>
<DropdownItem
onClick={deleteAudit}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Td>
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
<Authorized entity="Audit" action="deleteAudit">
<DropdownItem
onClick={deleteAudit}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
</Td>
)}
</Tr>
);
}

View File

@@ -35,6 +35,7 @@ import z from "zod";
import { getStatusVariant, getStatusLabel, formatDatetime, validateSnapshotConsistency } from "@probo/helpers";
import { SnapshotBanner } from "/components/SnapshotBanner";
import type { ContinualImprovementGraphNodeQuery } from "/hooks/graph/__generated__/ContinualImprovementGraphNodeQuery.graphql";
import { Authorized } from "/permissions";
const updateImprovementSchema = z.object({
referenceId: z.string().min(1, "Reference ID is required"),
@@ -149,11 +150,13 @@ export default function ContinualImprovementDetailsPage(props: Props) {
]}
/>
{!isSnapshotMode && (
<ActionDropdown>
<DropdownItem onClick={deleteImprovement} variant="danger">
{__("Delete")}
</DropdownItem>
</ActionDropdown>
<Authorized entity="ContinualImprovement" action="deleteContinualImprovement">
<ActionDropdown>
<DropdownItem onClick={deleteImprovement} variant="danger">
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Authorized>
)}
</div>
@@ -286,13 +289,15 @@ export default function ContinualImprovementDetailsPage(props: Props) {
{!isSnapshotMode && (
<div className="flex justify-end pt-4">
<Button
type="submit"
variant="primary"
disabled={formState.isSubmitting}
>
{formState.isSubmitting ? __("Saving...") : __("Save Changes")}
</Button>
<Authorized entity="ContinualImprovement" action="updateContinualImprovement">
<Button
type="submit"
variant="primary"
disabled={formState.isSubmitting}
>
{formState.isSubmitting ? __("Saving...") : __("Save Changes")}
</Button>
</Authorized>
</div>
)}
</form>

View File

@@ -37,6 +37,8 @@ import type {
ContinualImprovementsPageFragment$key,
ContinualImprovementsPageFragment$data,
} from "./__generated__/ContinualImprovementsPageFragment.graphql";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
interface ContinualImprovementsPageProps {
queryRef: PreloadedQuery<ContinualImprovementsPageQuery>;
@@ -127,6 +129,11 @@ export default function ContinualImprovementsPage({ queryRef }: ContinualImprove
);
const improvements = data?.continualImprovements?.edges?.map((edge) => edge.node) ?? [];
const hasAnyAction = !isSnapshotMode && (
isAuthorized(organizationId, "ContinualImprovement", "updateContinualImprovement") ||
isAuthorized(organizationId, "ContinualImprovement", "deleteContinualImprovement")
);
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
@@ -134,14 +141,16 @@ export default function ContinualImprovementsPage({ queryRef }: ContinualImprove
)}
<PageHeader title={__("Continual Improvements")} description={__("Manage your continual improvements.")}>
{!isSnapshotMode && (
<CreateContinualImprovementDialog
organizationId={organizationId}
connectionId={connectionId}
>
<Button icon={IconPlusLarge}>
{__("Add continual improvement")}
</Button>
</CreateContinualImprovementDialog>
<Authorized entity="Organization" action="createContinualImprovement">
<CreateContinualImprovementDialog
organizationId={organizationId}
connectionId={connectionId}
>
<Button icon={IconPlusLarge}>
{__("Add continual improvement")}
</Button>
</CreateContinualImprovementDialog>
</Authorized>
)}
</PageHeader>
@@ -156,7 +165,7 @@ export default function ContinualImprovementsPage({ queryRef }: ContinualImprove
<Th>{__("Priority")}</Th>
<Th>{__("Owner")}</Th>
<Th>{__("Target Date")}</Th>
{!isSnapshotMode && <Th>{__("Actions")}</Th>}
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -166,6 +175,7 @@ export default function ContinualImprovementsPage({ queryRef }: ContinualImprove
improvement={improvement}
connectionId={connectionId}
snapshotId={snapshotId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
@@ -203,10 +213,12 @@ function ImprovementRow({
improvement,
connectionId,
snapshotId,
hasAnyAction,
}: {
improvement: NodeOf<NonNullable<ContinualImprovementsPageFragment$data['continualImprovements']>>;
connectionId: string;
snapshotId?: string;
hasAnyAction: boolean;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
@@ -267,16 +279,18 @@ function ImprovementRow({
<span className="text-txt-tertiary">{__("No target date")}</span>
)}
</Td>
{!isSnapshotMode && (
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
<DropdownItem
icon={IconTrashCan}
variant="danger"
onSelect={handleDelete}
>
{__("Delete")}
</DropdownItem>
<Authorized entity="ContinualImprovement" action="deleteContinualImprovement">
<DropdownItem
icon={IconTrashCan}
variant="danger"
onSelect={handleDelete}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
</Td>
)}

View File

@@ -35,6 +35,8 @@ import type {
import type { DataListQuery } from "./__generated__/DataListQuery.graphql";
import { SortableTable } from "/components/SortableTable";
import { SnapshotBanner } from "/components/SnapshotBanner";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
const paginatedDataFragment = graphql`
fragment DataPageFragment on Organization
@@ -119,6 +121,10 @@ export default function DataPage(props: Props) {
usePageTitle(__("Data"));
const hasAnyAction = !isSnapshotMode && ( isAuthorized(organizationId, "Datum", "updateDatum") ||
isAuthorized(organizationId, "Datum", "deleteDatum")
);
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
@@ -131,13 +137,15 @@ export default function DataPage(props: Props) {
)}
>
{!snapshotId && (
<CreateDatumDialog
connection={connectionId}
organizationId={organizationId}
onCreated={() => pagination.refetch({ snapshotId })}
>
<Button icon={IconPlusLarge}>{__("Add data")}</Button>
</CreateDatumDialog>
<Authorized entity="Organization" action="createDatum">
<CreateDatumDialog
connection={connectionId}
organizationId={organizationId}
onCreated={() => pagination.refetch({ snapshotId })}
>
<Button icon={IconPlusLarge}>{__("Add data")}</Button>
</CreateDatumDialog>
</Authorized>
)}
</PageHeader>
<SortableTable
@@ -150,12 +158,12 @@ export default function DataPage(props: Props) {
<Th>{__("Classification")}</Th>
<Th>{__("Owner")}</Th>
<Th>{__("Vendors")}</Th>
<Th></Th>
{hasAnyAction && <Th></Th>}
</Tr>
</Thead>
<Tbody>
{dataEntries.map((entry) => (
<DataRow key={entry.id} entry={entry} connectionId={connectionId} snapshotId={snapshotId} />
<DataRow key={entry.id} entry={entry} connectionId={connectionId} snapshotId={snapshotId} hasAnyAction={hasAnyAction} />
))}
</Tbody>
</SortableTable>
@@ -167,10 +175,12 @@ function DataRow({
entry,
connectionId,
snapshotId,
hasAnyAction,
}: {
entry: DataEntry;
connectionId: string;
snapshotId?: string;
hasAnyAction: boolean;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
@@ -215,18 +225,21 @@ function DataRow({
<span className="text-txt-secondary text-sm">{__("None")}</span>
)}
</Td>
<Td noLink width={50} className="text-end">
{!snapshotId && (<ActionDropdown>
<DropdownItem
onClick={deleteDatum}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
<Authorized entity="Datum" action="deleteDatum">
<DropdownItem
onClick={deleteDatum}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
)}
</Td>
</Td>
)}
</Tr>
);
}

View File

@@ -29,6 +29,7 @@ import z from "zod";
import { SnapshotBanner } from "/components/SnapshotBanner";
import { validateSnapshotConsistency } from "@probo/helpers";
import type { DatumGraphNodeQuery } from "/hooks/graph/__generated__/DatumGraphNodeQuery.graphql";
import { Authorized } from "/permissions";
const updateDatumSchema = z.object({
name: z.string().min(1, "Name is required"),
@@ -124,15 +125,17 @@ export default function DatumDetailsPage(props: Props) {
<Badge variant="info">{datumEntry?.dataClassification}</Badge>
</div>
{!isSnapshotMode && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteDatum}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
<Authorized entity="Datum" action="deleteDatum">
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteDatum}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Authorized>
)}
</div>
@@ -177,9 +180,11 @@ export default function DatumDetailsPage(props: Props) {
{!isSnapshotMode && (
<div className="flex justify-end">
{formState.isDirty && (
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>
<Authorized entity="Datum" action="updateDatum">
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>
</Authorized>
)}
</div>
)}

View File

@@ -76,6 +76,7 @@ import { DocumentTypeOptions } from "/components/form/DocumentTypeOptions";
import { DocumentClassificationOptions } from "/components/form/DocumentClassificationOptions";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { Authorized } from "/permissions";
type Props = {
queryRef: PreloadedQuery<DocumentGraphNodeQuery>;
@@ -521,20 +522,24 @@ export default function DocumentDetailPage(props: Props) {
</Dropdown>
<ActionDropdown variant="secondary">
<DropdownItem
onClick={() => updateDialogRef.current?.open()}
icon={IconPencil}
>
{isDraft ? __("Edit draft document") : __("Create new draft")}
</DropdownItem>
{isDraft && versions.length > 1 && (
<Authorized entity="Document" action="updateDocument">
<DropdownItem
onClick={handleDeleteDraft}
icon={IconTrashCan}
disabled={isDeletingDraft}
onClick={() => updateDialogRef.current?.open()}
icon={IconPencil}
>
{__("Delete draft document")}
{isDraft ? __("Edit draft document") : __("Create new draft")}
</DropdownItem>
</Authorized>
{isDraft && versions.length > 1 && (
<Authorized entity="Document" action="deleteDocument">
<DropdownItem
onClick={handleDeleteDraft}
icon={IconTrashCan}
disabled={isDeletingDraft}
>
{__("Delete draft document")}
</DropdownItem>
</Authorized>
)}
<DropdownItem
onClick={() => pdfDownloadDialogRef.current?.open()}
@@ -543,14 +548,16 @@ export default function DocumentDetailPage(props: Props) {
>
{__("Download PDF")}
</DropdownItem>
<DropdownItem
variant="danger"
icon={IconTrashCan}
disabled={isDeleting}
onClick={handleDelete}
>
{__("Delete document")}
</DropdownItem>
<Authorized entity="Document" action="deleteDocument">
<DropdownItem
variant="danger"
icon={IconTrashCan}
disabled={isDeleting}
onClick={handleDelete}
>
{__("Delete document")}
</DropdownItem>
</Authorized>
</ActionDropdown>
</div>
</div>

View File

@@ -57,6 +57,8 @@ import {
type BulkExportDialogRef,
} from "/components/documents/BulkExportDialog";
import type { DocumentsPageUserEmailQuery } from "./__generated__/DocumentsPageUserEmailQuery.graphql";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
const documentsFragment = graphql`
fragment DocumentsPageListFragment on Organization
@@ -135,6 +137,9 @@ export default function DocumentsPage(props: Props) {
usePageTitle(__("Documents"));
const hasAnyAction = isAuthorized(organization.id, "Document", "updateDocument") ||
isAuthorized(organization.id, "Document", "deleteDocument");
const handleSendSigningNotifications = () => {
sendSigningNotifications({
variables: {
@@ -193,6 +198,7 @@ export default function DocumentsPage(props: Props) {
description={__("Manage your organization's documents")}
>
<div className="flex gap-2">
<Authorized entity="Document" action="sendSigningNotifications">
<Button
icon={IconBell2}
variant="secondary"
@@ -200,10 +206,13 @@ export default function DocumentsPage(props: Props) {
>
{__("Send signing notifications")}
</Button>
<CreateDocumentDialog
connection={connectionId}
trigger={<Button icon={IconPlusLarge}>{__("New document")}</Button>}
/>
</Authorized>
<Authorized entity="Organization" action="createDocument">
<CreateDocumentDialog
connection={connectionId}
trigger={<Button icon={IconPlusLarge}>{__("New document")}</Button>}
/>
</Authorized>
</div>
</PageHeader>
{documents.length > 0 ? (
@@ -232,7 +241,7 @@ export default function DocumentsPage(props: Props) {
<Th className="w-60">{__("Owner")}</Th>
<Th className="w-60">{__("Last update")}</Th>
<Th className="w-20">{__("Signatures")}</Th>
<Th className="w-18"></Th>
{hasAnyAction && <Th className="w-18"></Th>}
</Tr>
) : (
<Tr>
@@ -249,29 +258,33 @@ export default function DocumentsPage(props: Props) {
</button>
</div>
<div className="flex gap-2 items-center">
<PublishDocumentsDialog
documentIds={selection}
onSave={clear}
>
<Button
icon={IconCheckmark1}
className="py-0.5 px-2 text-xs h-6 min-h-6"
<Authorized entity="Document" action="updateDocument">
<PublishDocumentsDialog
documentIds={selection}
onSave={clear}
>
{__("Publish")}
</Button>
</PublishDocumentsDialog>
<SignatureDocumentsDialog
documentIds={selection}
onSave={clear}
>
<Button
variant="secondary"
icon={IconSignature}
className="py-0.5 px-2 text-xs h-6 min-h-6"
<Button
icon={IconCheckmark1}
className="py-0.5 px-2 text-xs h-6 min-h-6"
>
{__("Publish")}
</Button>
</PublishDocumentsDialog>
</Authorized>
<Authorized entity="Document" action="bulkRequestSignatures">
<SignatureDocumentsDialog
documentIds={selection}
onSave={clear}
>
{__("Request signature")}
</Button>
</SignatureDocumentsDialog>
<Button
variant="secondary"
icon={IconSignature}
className="py-0.5 px-2 text-xs h-6 min-h-6"
>
{__("Request signature")}
</Button>
</SignatureDocumentsDialog>
</Authorized>
<BulkExportDialog
ref={bulkExportDialogRef}
onExport={handleBulkExport}
@@ -287,14 +300,16 @@ export default function DocumentsPage(props: Props) {
{__("Export")}
</Button>
</BulkExportDialog>
<Button
variant="danger"
icon={IconTrashCan}
onClick={handleBulkDelete}
className="py-0.5 px-2 text-xs h-6 min-h-6"
>
{__("Delete")}
</Button>
<Authorized entity="Document" action="deleteDocument">
<Button
variant="danger"
icon={IconTrashCan}
onClick={handleBulkDelete}
className="py-0.5 px-2 text-xs h-6 min-h-6"
>
{__("Delete")}
</Button>
</Authorized>
</div>
</div>
</Th>
@@ -310,6 +325,7 @@ export default function DocumentsPage(props: Props) {
document={document}
organizationId={organization.id}
connectionId={connectionId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
@@ -367,12 +383,14 @@ function DocumentRow({
organizationId,
checked,
onCheck,
hasAnyAction,
}: {
document: DocumentsPageRowFragment$key;
organizationId: string;
connectionId: string;
checked: boolean;
onCheck: () => void;
hasAnyAction: boolean;
}) {
const document = useFragment<DocumentsPageRowFragment$key>(
rowFragment,
@@ -444,17 +462,21 @@ function DocumentRow({
<Td className="w-20">
{signedCount}/{signatures.length}
</Td>
<Td noLink width={50} className="text-end w-18">
<ActionDropdown>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Td>
{hasAnyAction && (
<Td noLink width={50} className="text-end w-18">
<ActionDropdown>
<Authorized entity="Document" action="deleteDocument">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
</Td>
)}
</Tr>
);
}

View File

@@ -20,6 +20,7 @@ import { useOutletContext } from "react-router";
import type { DocumentSignaturesTab_signature$key } from "/pages/organizations/documents/tabs/__generated__/DocumentSignaturesTab_signature.graphql.ts";
import type { DocumentSignaturesTab_version$key } from "/pages/organizations/documents/tabs/__generated__/DocumentSignaturesTab_version.graphql.ts";
import type { DocumentSignaturesTabRefetchQuery } from "./__generated__/DocumentSignaturesTabRefetchQuery.graphql";
import { Authorized } from "/permissions";
type Version = NodeOf<DocumentDetailPageDocumentFragment$data["versions"]>;
@@ -274,6 +275,7 @@ function SignatureItem(props: {
</div>
</div>
{props.signable && (
<Authorized entity="Document" action="requestSignature">
<Button
variant="secondary"
className="ml-auto"
@@ -292,6 +294,7 @@ function SignatureItem(props: {
>
{__("Request signature")}
</Button>
</Authorized>
)}
</div>
);
@@ -328,6 +331,7 @@ function SignatureItem(props: {
{__("Signed")}
</Badge>
) : (
<Authorized entity="DocumentVersionSignature" action="cancelSignatureRequest">
<Button
variant="danger"
className="ml-auto"
@@ -345,6 +349,7 @@ function SignatureItem(props: {
>
{__("Cancel request")}
</Button>
</Authorized>
)}
</div>
);

View File

@@ -27,6 +27,7 @@ import { promisifyMutation } from "@probo/helpers";
import type { FrameworkGraphControlNodeQuery } from "/hooks/graph/__generated__/FrameworkGraphControlNodeQuery.graphql";
import { frameworkControlNodeQuery } from "/hooks/graph/FrameworkGraph";
import type { FrameworkDetailPageFragment$data } from "./__generated__/FrameworkDetailPageFragment.graphql";
import { Authorized } from "/permissions";
const attachMeasureMutation = graphql`
mutation FrameworkControlPageAttachMutation(
@@ -235,24 +236,28 @@ export default function FrameworkControlPage({ queryRef }: Props) {
</div>
</div>
<div className="flex gap-2">
<FrameworkControlDialog
frameworkId={framework.id}
connectionId={connectionId}
control={control}
>
<Button icon={IconPencil} variant="secondary">
{__("Edit control")}
</Button>
</FrameworkControlDialog>
<ActionDropdown variant="secondary">
<DropdownItem
icon={IconTrashCan}
variant="danger"
onClick={onDelete}
<Authorized entity="Control" action="updateControl">
<FrameworkControlDialog
frameworkId={framework.id}
connectionId={connectionId}
control={control}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
<Button icon={IconPencil} variant="secondary">
{__("Edit control")}
</Button>
</FrameworkControlDialog>
</Authorized>
<Authorized entity="Control" action="deleteControl">
<ActionDropdown variant="secondary">
<DropdownItem
icon={IconTrashCan}
variant="danger"
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Authorized>
</div>
</div>

View File

@@ -31,6 +31,7 @@ import type { FrameworkDetailPageExportFrameworkMutation } from "./__generated__
import { FrameworkFormDialog } from "./dialogs/FrameworkFormDialog";
import { FrameworkControlDialog } from "./dialogs/FrameworkControlDialog";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { Authorized } from "/permissions";
const frameworkDetailFragment = graphql`
fragment FrameworkDetailPageFragment on Framework {
@@ -149,14 +150,16 @@ export default function FrameworkDetailPage(props: Props) {
</>
}
>
<FrameworkFormDialog
organizationId={organizationId}
framework={framework}
>
<Button icon={IconPencil} variant="secondary">
{__("Edit")}
</Button>
</FrameworkFormDialog>
<Authorized entity="Framework" action="updateFramework">
<FrameworkFormDialog
organizationId={organizationId}
framework={framework}
>
<Button icon={IconPencil} variant="secondary">
{__("Edit")}
</Button>
</FrameworkFormDialog>
</Authorized>
<ActionDropdown variant="secondary">
<DropdownItem
variant="primary"
@@ -188,9 +191,11 @@ export default function FrameworkDetailPage(props: Props) {
>
{__("Export Framework")}
</DropdownItem>
<DropdownItem icon={IconTrashCan} variant="danger" onClick={onDelete}>
{__("Delete")}
</DropdownItem>
<Authorized entity="Framework" action="deleteFramework">
<DropdownItem icon={IconTrashCan} variant="danger" onClick={onDelete}>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
</PageHeader>
<div className="text-lg font-semibold">
@@ -211,15 +216,17 @@ export default function FrameworkDetailPage(props: Props) {
active={selectedControl?.id === control.id}
/>
))}
<FrameworkControlDialog
frameworkId={framework.id}
connectionId={connectionId}
>
<button className="flex gap-[6px] flex-col w-full p-4 space-y-[6px] rounded-xl cursor-pointer text-start text-sm text-txt-tertiary hover:bg-tertiary-hover">
<IconPlusLarge size={20} className="text-txt-primary" />
{__("Add new control")}
</button>
</FrameworkControlDialog>
<Authorized entity="Organization" action="createControl">
<FrameworkControlDialog
frameworkId={framework.id}
connectionId={connectionId}
>
<button className="flex gap-[6px] flex-col w-full p-4 space-y-[6px] rounded-xl cursor-pointer text-start text-sm text-txt-tertiary hover:bg-tertiary-hover">
<IconPlusLarge size={20} className="text-txt-primary" />
{__("Add new control")}
</button>
</FrameworkControlDialog>
</Authorized>
</div>
<Outlet context={{ framework }} />
</div>

View File

@@ -29,6 +29,8 @@ import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { useState, type ChangeEventHandler } from "react";
import { FrameworkLogo } from "/components/FrameworkLogo";
import { FrameworkFormDialog } from "./dialogs/FrameworkFormDialog";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
type Props = {
queryRef: PreloadedQuery<FrameworkGraphListQuery>;
@@ -118,6 +120,9 @@ export default function FrameworksPage(props: Props) {
const isLoading = isUploading || isImporting;
const hasAnyAction = isAuthorized(data.organization.id!, "Framework", "updateFramework") ||
isAuthorized(data.organization.id!, "Framework", "deleteFramework");
return (
<div className="space-y-6">
<FrameworkFormDialog
@@ -129,18 +134,20 @@ export default function FrameworksPage(props: Props) {
title={__("Frameworks")}
description={__("Manage your compliance frameworks")}
>
<FileButton
variant="secondary"
icon={IconFolderUpload}
onChange={handleUpload}
disabled={isLoading}
>
{__("Import")}
</FileButton>
<FrameworkSelector
onSelect={importNamedFramework}
disabled={isLoading}
/>
<Authorized entity="Organization" action="createFramework">
<FileButton
variant="secondary"
icon={IconFolderUpload}
onChange={handleUpload}
disabled={isLoading}
>
{__("Import")}
</FileButton>
<FrameworkSelector
onSelect={importNamedFramework}
disabled={isLoading}
/>
</Authorized>
</PageHeader>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -150,6 +157,7 @@ export default function FrameworksPage(props: Props) {
connectionId={connectionId}
key={framework.id}
framework={framework}
hasAnyAction={hasAnyAction}
/>
))}
</div>
@@ -169,6 +177,7 @@ type FrameworkCardProps = {
organizationId: string;
connectionId: string;
framework: FrameworksPageCardFragment$key;
hasAnyAction: boolean;
};
function FrameworkCard(props: FrameworkCardProps) {
@@ -189,23 +198,29 @@ function FrameworkCard(props: FrameworkCardProps) {
/>
<div className="flex justify-between mb-3">
<FrameworkLogo {...framework} />
<ActionDropdown className="z-10 relative">
<DropdownItem
icon={IconPencil}
onClick={() => {
dialogRef.current?.open();
}}
>
{__("Edit")}
</DropdownItem>
<DropdownItem
icon={IconTrashCan}
onClick={() => deleteFramework()}
variant="danger"
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
{props.hasAnyAction && (
<ActionDropdown className="z-10 relative">
<Authorized entity="Framework" action="updateFramework">
<DropdownItem
icon={IconPencil}
onClick={() => {
dialogRef.current?.open();
}}
>
{__("Edit")}
</DropdownItem>
</Authorized>
<Authorized entity="Framework" action="deleteFramework">
<DropdownItem
icon={IconTrashCan}
onClick={() => deleteFramework()}
variant="danger"
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
)}
</div>
<h2 className="text-xl font-medium">
<Link

View File

@@ -43,6 +43,7 @@ import {
sprintf,
} from "@probo/helpers";
import MeasureFormDialog from "./dialog/MeasureFormDialog";
import { Authorized } from "/permissions";
type Props = {
queryRef: PreloadedQuery<MeasureGraphNodeQuery>;
@@ -135,29 +136,33 @@ export default function MeasureDetailPage(props: Props) {
/>
<PageHeader title={measure.name} description={measure.description}>
<MeasureFormDialog measure={measure}>
<Button variant="secondary" icon={IconPencil}>
{__("Edit")}
</Button>
</MeasureFormDialog>
<Select
disabled={isUpdating}
onValueChange={onStateChange}
name="state"
placeholder={__("Select state")}
className="rounded-full"
value={measure.state}
>
{measureStates.map((state) => (
<Option key={state} value={state}>
{getMeasureStateLabel(__, state)}
</Option>
))}
</Select>
<Authorized entity="Measure" action="updateMeasure">
<MeasureFormDialog measure={measure}>
<Button variant="secondary" icon={IconPencil}>
{__("Edit")}
</Button>
</MeasureFormDialog>
<Select
disabled={isUpdating}
onValueChange={onStateChange}
name="state"
placeholder={__("Select state")}
className="rounded-full"
value={measure.state}
>
{measureStates.map((state) => (
<Option key={state} value={state}>
{getMeasureStateLabel(__, state)}
</Option>
))}
</Select>
</Authorized>
<ActionDropdown variant="secondary">
<DropdownItem variant="danger" icon={IconTrashCan} onClick={onDelete}>
{__("Delete")}
</DropdownItem>
<Authorized entity="Measure" action="deleteMeasure">
<DropdownItem variant="danger" icon={IconTrashCan} onClick={onDelete}>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
</PageHeader>

View File

@@ -48,6 +48,8 @@ import { useOrganizationId } from "/hooks/useOrganizationId";
import { Link, useParams } from "react-router";
import MeasureFormDialog from "./dialog/MeasureFormDialog";
import { usePageTitle } from "@probo/hooks";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
type Props = {
queryRef: PreloadedQuery<MeasureGraphListQuery>;
@@ -113,6 +115,9 @@ export default function MeasuresPage(props: Props) {
const importFileRef = useRef<HTMLInputElement>(null);
usePageTitle(__("Measures"));
const hasAnyAction = isAuthorized(organization.id, "Measure", "updateMeasure") ||
isAuthorized(organization.id, "Measure", "deleteMeasure");
const handleImport: ChangeEventHandler<HTMLInputElement> = (event) => {
const file = event.target.files?.[0];
if (!file) {
@@ -143,19 +148,21 @@ export default function MeasuresPage(props: Props) {
"Measures are actions taken to reduce the risk. Add them to track their implementation status."
)}
>
<FileButton
ref={importFileRef}
variant="secondary"
icon={IconFolderUpload}
onChange={handleImport}
>
{__("Import")}
</FileButton>
<MeasureFormDialog connection={connectionId}>
<Button variant="primary" icon={IconPlusLarge}>
{__("New measure")}
</Button>
</MeasureFormDialog>
<Authorized entity="Organization" action="createMeasure">
<FileButton
ref={importFileRef}
variant="secondary"
icon={IconFolderUpload}
onChange={handleImport}
>
{__("Import")}
</FileButton>
<MeasureFormDialog connection={connectionId}>
<Button variant="primary" icon={IconPlusLarge}>
{__("New measure")}
</Button>
</MeasureFormDialog>
</Authorized>
</PageHeader>
<MeasureImplementation measures={measures} className="my-10" />
{objectKeys(measuresPerCategory)
@@ -166,6 +173,7 @@ export default function MeasuresPage(props: Props) {
category={category}
measures={measuresPerCategory[category]}
connectionId={connectionId}
hasAnyAction={hasAnyAction}
/>
))}
</div>
@@ -176,6 +184,7 @@ type CategoryProps = {
category: string;
measures: NodeOf<MeasuresPageFragment$data["measures"]>[];
connectionId: string;
hasAnyAction: boolean;
};
function Category(props: CategoryProps) {
@@ -219,7 +228,7 @@ function Category(props: CategoryProps) {
<Tr>
<Th>{__("Measure")}</Th>
<Th>{__("State")}</Th>
<Th></Th>
{props.hasAnyAction && <Th></Th>}
</Tr>
</Thead>
<Tbody>
@@ -228,6 +237,7 @@ function Category(props: CategoryProps) {
key={measure.id}
measure={measure}
connectionId={props.connectionId}
hasAnyAction={props.hasAnyAction}
/>
))}
</Tbody>
@@ -251,6 +261,7 @@ function Category(props: CategoryProps) {
type MeasureRowProps = {
measure: NodeOf<MeasuresPageFragment$data["measures"]>;
connectionId: string;
hasAnyAction: boolean;
};
function MeasureRow(props: MeasureRowProps) {
@@ -292,24 +303,30 @@ function MeasureRow(props: MeasureRowProps) {
<Td width={120}>
<MeasureBadge state={props.measure.state} />
</Td>
<Td noLink width={50} className="text-end">
<ActionDropdown>
<DropdownItem
icon={IconPencil}
onClick={() => dialogRef.current?.open()}
>
{__("Edit")}
</DropdownItem>
<DropdownItem
onClick={onDelete}
disabled={isDeleting}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Td>
{props.hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
<Authorized entity="Measure" action="updateMeasure">
<DropdownItem
icon={IconPencil}
onClick={() => dialogRef.current?.open()}
>
{__("Edit")}
</DropdownItem>
</Authorized>
<Authorized entity="Measure" action="deleteMeasure">
<DropdownItem
onClick={onDelete}
disabled={isDeleting}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
</Td>
)}
</Tr>
</>
);

View File

@@ -21,11 +21,12 @@ import {
UpdateMeetingMinutesDialog,
type UpdateMeetingMinutesDialogRef,
} from "./dialogs/UpdateMeetingMinutesDialog";
import { useRef } from "react";
import { useRef, useState, useEffect } from "react";
import {
meetingNodeQuery,
useDeleteMeetingMutation,
} from "/hooks/graph/MeetingGraph";
import { isAuthorized } from "/permissions";
const meetingFragment = graphql`
fragment MeetingDetailPageMeetingFragment on Meeting {
@@ -62,6 +63,63 @@ export default function MeetingDetailPage(props: Props) {
const confirm = useConfirm();
const updateMinutesDialogRef = useRef<UpdateMeetingMinutesDialogRef>(null);
const [canUpdate, setCanUpdate] = useState<boolean>(false);
const [canDelete, setCanDelete] = useState<boolean>(false);
useEffect(() => {
if (!organizationId) {
setCanUpdate(false);
setCanDelete(false);
return;
}
try {
const updateAuth = isAuthorized(organizationId, "Meeting", "updateMeeting");
setCanUpdate(updateAuth);
} catch (promise) {
if (promise instanceof Promise) {
promise
.then(() => {
try {
const updateAuth = isAuthorized(organizationId, "Meeting", "updateMeeting");
setCanUpdate(updateAuth);
} catch {
setCanUpdate(false);
}
})
.catch(() => {
setCanUpdate(false);
});
} else {
setCanUpdate(false);
}
}
try {
const deleteAuth = isAuthorized(organizationId, "Meeting", "deleteMeeting");
setCanDelete(deleteAuth);
} catch (promise) {
if (promise instanceof Promise) {
promise
.then(() => {
try {
const deleteAuth = isAuthorized(organizationId, "Meeting", "deleteMeeting");
setCanDelete(deleteAuth);
} catch {
setCanDelete(false);
}
})
.catch(() => {
setCanDelete(false);
});
} else {
setCanDelete(false);
}
}
}, [organizationId]);
const hasAnyAction = canUpdate || canDelete;
usePageTitle(meeting.name);
const handleDelete = () => {
@@ -105,22 +163,28 @@ export default function MeetingDetailPage(props: Props) {
},
]}
/>
<ActionDropdown variant="secondary">
<DropdownItem
onClick={() => updateMinutesDialogRef.current?.open()}
icon={IconPencil}
>
{__("Edit minutes")}
</DropdownItem>
<DropdownItem
variant="danger"
icon={IconTrashCan}
disabled={isDeleting}
onClick={handleDelete}
>
{__("Delete meeting")}
</DropdownItem>
</ActionDropdown>
{hasAnyAction && (
<ActionDropdown variant="secondary">
{canUpdate && (
<DropdownItem
onClick={() => updateMinutesDialogRef.current?.open()}
icon={IconPencil}
>
{__("Edit minutes")}
</DropdownItem>
)}
{canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
disabled={isDeleting}
onClick={handleDelete}
>
{__("Delete meeting")}
</DropdownItem>
)}
</ActionDropdown>
)}
</div>
<PageHeader
title={meeting.name}

View File

@@ -42,6 +42,7 @@ import { Link } from "react-router";
import { useState, useEffect, useRef } from "react";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import type { MeetingsPage_UpdateSummaryMutation } from "./__generated__/MeetingsPage_UpdateSummaryMutation.graphql";
import { Authorized } from "/permissions";
const meetingsFragment = graphql`
fragment MeetingsPageListFragment on Organization
@@ -217,13 +218,15 @@ export default function MeetingsPage(props: Props) {
<h3 className="text-sm font-semibold text-txt-secondary">
{__("Summary")}
</h3>
<Button
variant="quaternary"
icon={IconPencil}
onClick={() => setIsEditing(true)}
>
{__("Edit")}
</Button>
<Authorized entity="Meeting" action="updateMeeting">
<Button
variant="quaternary"
icon={IconPencil}
onClick={() => setIsEditing(true)}
>
{__("Edit")}
</Button>
</Authorized>
</div>
<div className="w-full">
{displayedSummary ? (
@@ -245,9 +248,11 @@ export default function MeetingsPage(props: Props) {
"Track and manage your organization's meetings and their minutes."
)}
>
<CreateMeetingDialog connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add meeting")}</Button>
</CreateMeetingDialog>
<Authorized entity="Organization" action="createMeeting">
<CreateMeetingDialog connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add meeting")}</Button>
</CreateMeetingDialog>
</Authorized>
</PageHeader>
{meetingNodes.length > 0 ? (
<SortableTable {...pagination}>
@@ -365,17 +370,19 @@ function MeetingRow({
</span>
)}
</Td>
<Td noLink width={50} className="text-end w-18">
<ActionDropdown>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Td>
<Authorized entity="Meeting" action="deleteMeeting">
<Td noLink width={50} className="text-end w-18">
<ActionDropdown>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Td>
</Authorized>
</Tr>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<60e0c9d7301cff5c1df299e76debb633>>
* @generated SignedSource<<1fff8c5cca1610284185c485630e84de>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -19,6 +19,7 @@ export type MeetingsPage_UpdateSummaryMutation$variables = {
export type MeetingsPage_UpdateSummaryMutation$data = {
readonly updateOrganizationContext: {
readonly context: {
readonly organizationId: string;
readonly summary: string | null | undefined;
};
};
@@ -59,6 +60,13 @@ v1 = [
"name": "context",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "organizationId",
"storageKey": null
},
{
"alias": null,
"args": null,
@@ -91,16 +99,16 @@ return {
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "e6bead1dde5239f3cfd2fa1440191454",
"cacheID": "cb37cdde6dc5ac655a7cefc63d9e72e7",
"id": null,
"metadata": {},
"name": "MeetingsPage_UpdateSummaryMutation",
"operationKind": "mutation",
"text": "mutation MeetingsPage_UpdateSummaryMutation(\n $input: UpdateOrganizationContextInput!\n) {\n updateOrganizationContext(input: $input) {\n context {\n summary\n }\n }\n}\n"
"text": "mutation MeetingsPage_UpdateSummaryMutation(\n $input: UpdateOrganizationContextInput!\n) {\n updateOrganizationContext(input: $input) {\n context {\n organizationId\n summary\n }\n }\n}\n"
}
};
})();
(node as any).hash = "f354a34b18a449f02508c45d0e7d9dc5";
(node as any).hash = "8bfa5b636dbc3535dbed22e1869bc941";
export default node;

View File

@@ -31,6 +31,8 @@ import { deleteNonconformityMutation, NonconformitiesConnectionKey } from "../..
import { sprintf, promisifyMutation, getStatusVariant, getStatusLabel, formatDate } from "@probo/helpers";
import { SnapshotBanner } from "/components/SnapshotBanner";
import { useParams } from "react-router";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
import type { NonconformitiesPageQuery } from "./__generated__/NonconformitiesPageQuery.graphql";
import type {
NonconformitiesPageFragment$key,
@@ -129,6 +131,11 @@ export default function NonconformitiesPage({ queryRef }: NonconformitiesPagePro
);
const nonconformities: Nonconformity[] = nonconformitiesData?.nonconformities?.edges?.map((edge) => edge.node) ?? [];
const hasAnyAction = !isSnapshotMode && (
isAuthorized(organizationId, "Nonconformity", "updateNonconformity") ||
isAuthorized(organizationId, "Nonconformity", "deleteNonconformity")
);
return (
<div className="space-y-6">
{isSnapshotMode && (
@@ -141,9 +148,11 @@ export default function NonconformitiesPage({ queryRef }: NonconformitiesPagePro
)}
>
{!isSnapshotMode && (
<CreateNonconformityDialog organizationId={organizationId} connection={connectionId}>
<Button icon={IconPlusLarge}>{__("Add nonconformity")}</Button>
</CreateNonconformityDialog>
<Authorized entity="Organization" action="createNonconformity">
<CreateNonconformityDialog organizationId={organizationId} connection={connectionId}>
<Button icon={IconPlusLarge}>{__("Add nonconformity")}</Button>
</CreateNonconformityDialog>
</Authorized>
)}
</PageHeader>
@@ -169,7 +178,7 @@ export default function NonconformitiesPage({ queryRef }: NonconformitiesPagePro
<Th>{__("Audit")}</Th>
<Th>{__("Owner")}</Th>
<Th>{__("Due Date")}</Th>
{!isSnapshotMode && (<Th>{__("Actions")}</Th>)}
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -180,6 +189,7 @@ export default function NonconformitiesPage({ queryRef }: NonconformitiesPagePro
connectionId={connectionId}
isSnapshotMode={isSnapshotMode}
snapshotId={snapshotId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
@@ -209,11 +219,13 @@ function NonconformityRow({
connectionId,
isSnapshotMode,
snapshotId,
hasAnyAction,
}: {
nonconformity: Nonconformity;
connectionId: string;
isSnapshotMode: boolean;
snapshotId?: string;
hasAnyAction: boolean;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
@@ -283,15 +295,18 @@ function NonconformityRow({
<span className="text-txt-tertiary">{__("No due date")}</span>
)}
</Td>
{!isSnapshotMode && (<Td noLink width={50} className="text-end">
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
<DropdownItem
icon={IconTrashCan}
variant="danger"
onSelect={() => handleDeleteNonconformity(nonconformity)}
>
{__("Delete")}
</DropdownItem>
<Authorized entity="Nonconformity" action="deleteNonconformity">
<DropdownItem
icon={IconTrashCan}
variant="danger"
onSelect={() => handleDeleteNonconformity(nonconformity)}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
</Td>
)}

View File

@@ -34,6 +34,7 @@ import { useFormWithSchema } from "/hooks/useFormWithSchema";
import z from "zod";
import { getStatusVariant, getStatusLabel, formatDatetime, validateSnapshotConsistency, getStatusOptions, formatError, type GraphQLError } from "@probo/helpers";
import type { NonconformityGraphNodeQuery } from "/hooks/graph/__generated__/NonconformityGraphNodeQuery.graphql";
import { Authorized } from "/permissions";
const updateNonconformitySchema = z.object({
referenceId: z.string().min(1, "Reference ID is required"),
@@ -161,13 +162,15 @@ export default function NonconformityDetailsPage(props: Props) {
</div>
{!isSnapshotMode && (
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteNonconformity}
>
{__("Delete")}
</DropdownItem>
<Authorized entity="Nonconformity" action="deleteNonconformity">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteNonconformity}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
)}
</div>
@@ -276,9 +279,11 @@ export default function NonconformityDetailsPage(props: Props) {
<div className="flex justify-end">
{formState.isDirty && !isSnapshotMode && (
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>
<Authorized entity="Nonconformity" action="updateNonconformity">
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>
</Authorized>
)}
</div>
</form>

View File

@@ -35,6 +35,7 @@ import z from "zod";
import { getObligationStatusVariant, getObligationStatusLabel, formatDatetime, getObligationStatusOptions, validateSnapshotConsistency } from "@probo/helpers";
import { SnapshotBanner } from "/components/SnapshotBanner";
import type { ObligationGraphNodeQuery } from "/hooks/graph/__generated__/ObligationGraphNodeQuery.graphql";
import { Authorized } from "/permissions";
const updateObligationSchema = z.object({
area: z.string().optional(),
@@ -156,11 +157,13 @@ export default function ObligationDetailsPage(props: Props) {
</div>
{!isSnapshotMode && (
<ActionDropdown>
<DropdownItem icon={IconTrashCan} onClick={deleteObligation}>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
<Authorized entity="Obligation" action="deleteObligation">
<ActionDropdown>
<DropdownItem icon={IconTrashCan} onClick={deleteObligation}>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Authorized>
)}
</div>
@@ -298,12 +301,14 @@ export default function ObligationDetailsPage(props: Props) {
{!isSnapshotMode && (
<div className="flex justify-end">
<Button
type="submit"
disabled={formState.isSubmitting}
>
{formState.isSubmitting ? __("Saving...") : __("Save Changes")}
</Button>
<Authorized entity="Obligation" action="updateObligation">
<Button
type="submit"
disabled={formState.isSubmitting}
>
{formState.isSubmitting ? __("Saving...") : __("Save Changes")}
</Button>
</Authorized>
</div>
)}
</form>

View File

@@ -31,6 +31,8 @@ import { deleteObligationMutation } from "../../../hooks/graph/ObligationGraph";
import { promisifyMutation, getObligationStatusVariant, getObligationStatusLabel, formatDate } from "@probo/helpers";
import { SnapshotBanner } from "/components/SnapshotBanner";
import type { ObligationsPageQuery } from "./__generated__/ObligationsPageQuery.graphql";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
import type {
ObligationsPageFragment$key,
ObligationsPageFragment$data,
@@ -117,6 +119,11 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
const connectionId = obligationsData?.obligations?.__id || "";
const obligations: Obligation[] = obligationsData?.obligations?.edges?.map((edge) => edge.node) ?? [];
const hasAnyAction = !isSnapshotMode && (
isAuthorized(organizationId, "Obligation", "updateObligation") ||
isAuthorized(organizationId, "Obligation", "deleteObligation")
);
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
@@ -129,9 +136,11 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
)}
>
{!snapshotId && (
<CreateObligationDialog organizationId={organizationId} connection={connectionId}>
<Button icon={IconPlusLarge}>{__("Add obligation")}</Button>
</CreateObligationDialog>
<Authorized entity="Organization" action="createObligation">
<CreateObligationDialog organizationId={organizationId} connection={connectionId}>
<Button icon={IconPlusLarge}>{__("Add obligation")}</Button>
</CreateObligationDialog>
</Authorized>
)}
</PageHeader>
@@ -156,7 +165,7 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
<Th>{__("Status")}</Th>
<Th>{__("Owner")}</Th>
<Th>{__("Due Date")}</Th>
<Th>{__("Actions")}</Th>
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -166,6 +175,7 @@ export default function ObligationsPage({ queryRef }: ObligationsPageProps) {
obligation={obligation}
connectionId={connectionId}
snapshotId={snapshotId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
@@ -192,10 +202,12 @@ function ObligationRow({
obligation,
connectionId,
snapshotId,
hasAnyAction,
}: {
obligation: Obligation;
connectionId: string;
snapshotId?: string;
hasAnyAction: boolean;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
@@ -246,19 +258,21 @@ function ObligationRow({
<span className="text-txt-tertiary">{__("No due date")}</span>
)}
</Td>
<Td noLink width={50} className="text-end">
{!isSnapshotMode && (
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
<DropdownItem
icon={IconTrashCan}
variant="danger"
onSelect={handleDelete}
>
{__("Delete")}
</DropdownItem>
<Authorized entity="Obligation" action="deleteObligation">
<DropdownItem
icon={IconTrashCan}
variant="danger"
onSelect={handleDelete}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
)}
</Td>
</Td>
)}
</Tr>
);
}

View File

@@ -21,6 +21,7 @@ import {
import { useTranslate } from "@probo/i18n";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { Outlet } from "react-router";
import { Authorized } from "/permissions";
type Props = {
queryRef: PreloadedQuery<PeopleGraphNodeQuery>;
@@ -54,15 +55,17 @@ export default function PeopleDetailPage(props: Props) {
<Avatar name={people.fullName ?? ""} size="xl" />
<div className="text-2xl">{people.fullName}</div>
</div>
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deletePeople}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
<Authorized entity="People" action="deletePeople">
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deletePeople}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Authorized>
</div>
<Tabs>

View File

@@ -23,6 +23,8 @@ import { usePageTitle } from "@probo/hooks";
import { getRole } from "@probo/helpers";
import { CreatePeopleDialog } from "./dialogs/CreatePeopleDialog";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
type People = NodeOf<PeopleGraphPaginatedFragment$data["peoples"]>;
@@ -40,11 +42,15 @@ export default function PeopleListPage({
queryRef: PreloadedQuery<PeopleGraphPaginatedQuery>;
}) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { people, refetch, connectionId, hasNext, loadNext, isLoadingNext } =
usePeopleQuery(queryRef);
usePageTitle(__("Members"));
const hasAnyAction = isAuthorized(organizationId, "People", "updatePeople") ||
isAuthorized(organizationId, "People", "deletePeople");
return (
<div className="space-y-6">
<PageHeader
@@ -53,9 +59,11 @@ export default function PeopleListPage({
"Keep track of your company's workforce and their progress towards completing tasks assigned to them."
)}
>
<CreatePeopleDialog connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add member")}</Button>
</CreatePeopleDialog>
<Authorized entity="Organization" action="createPeople">
<CreatePeopleDialog connectionId={connectionId}>
<Button icon={IconPlusLarge}>{__("Add member")}</Button>
</CreatePeopleDialog>
</Authorized>
</PageHeader>
<SortableTable
refetch={refetch}
@@ -68,7 +76,7 @@ export default function PeopleListPage({
<SortableTh field="FULL_NAME">{__("Name")}</SortableTh>
<SortableTh field="KIND">{__("Role")}</SortableTh>
<Th>{__("Position")}</Th>
<Th>{__("Actions")}</Th>
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -77,6 +85,7 @@ export default function PeopleListPage({
key={person.id}
people={person}
connectionId={connectionId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
@@ -88,9 +97,11 @@ export default function PeopleListPage({
function PeopleRow({
people,
connectionId,
hasAnyAction,
}: {
people: People;
connectionId: string;
hasAnyAction: boolean;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
@@ -115,17 +126,21 @@ function PeopleRow({
</Td>
<Td className="text-sm">{getRole(__, people.kind)}</Td>
<Td className="text-sm">{people.position}</Td>
<Td noLink width={50} className="text-end">
<ActionDropdown>
<DropdownItem
icon={IconTrashCan}
variant="danger"
onClick={deletePeople}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Td>
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
<Authorized entity="People" action="deletePeople">
<DropdownItem
icon={IconTrashCan}
variant="danger"
onClick={deletePeople}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
</Td>
)}
</Tr>
);
}

View File

@@ -9,6 +9,7 @@ import type { PeopleGraphUpdateMutation } from "/hooks/graph/__generated__/Peopl
import { updatePeopleMutation } from "/hooks/graph/PeopleGraph";
import { Button, Card, Field, Input } from "@probo/ui";
import { EmailsField } from "/components/form/EmailsField";
import { Authorized } from "/permissions";
const schema = z.object({
fullName: z.string().min(1),
@@ -94,9 +95,11 @@ export default function PeopleProfileTab() {
</Card>
<div className="flex justify-end">
{formState.isDirty && (
<Button type="submit" disabled={isMutating}>
{__("Update")}
</Button>
<Authorized entity="People" action="updatePeople">
<Button type="submit" disabled={isMutating}>
{__("Update")}
</Button>
</Authorized>
)}
</div>
</form>

View File

@@ -32,6 +32,8 @@ import { CreateProcessingActivityDialog } from "./dialogs/CreateProcessingActivi
import { deleteProcessingActivityMutation, ProcessingActivitiesConnectionKey } from "../../../hooks/graph/ProcessingActivityGraph";
import { sprintf, promisifyMutation } from "@probo/helpers";
import { SnapshotBanner } from "/components/SnapshotBanner";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
import type { NodeOf } from "/types";
import type { ProcessingActivitiesPageQuery } from "./__generated__/ProcessingActivitiesPageQuery.graphql";
import type {
@@ -126,6 +128,11 @@ export default function ProcessingActivitiesPage({ queryRef }: ProcessingActivit
);
const activities = data?.processingActivities?.edges?.map((edge) => edge.node) ?? [];
const hasAnyAction = !isSnapshotMode && (
isAuthorized(organizationId, "ProcessingActivity", "updateProcessingActivity") ||
isAuthorized(organizationId, "ProcessingActivity", "deleteProcessingActivity")
);
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
@@ -133,14 +140,16 @@ export default function ProcessingActivitiesPage({ queryRef }: ProcessingActivit
)}
<PageHeader title={__("Processing Activities")} description={__("Manage your processing activities under GDPR")}>
{!isSnapshotMode && (
<CreateProcessingActivityDialog
organizationId={organizationId}
connectionId={connectionId}
>
<Button icon={IconPlusLarge}>
{__("Add processing activity")}
</Button>
</CreateProcessingActivityDialog>
<Authorized entity="Organization" action="createProcessingActivity">
<CreateProcessingActivityDialog
organizationId={organizationId}
connectionId={connectionId}
>
<Button icon={IconPlusLarge}>
{__("Add processing activity")}
</Button>
</CreateProcessingActivityDialog>
</Authorized>
)}
</PageHeader>
@@ -155,7 +164,7 @@ export default function ProcessingActivitiesPage({ queryRef }: ProcessingActivit
<Th>{__("Lawful Basis")}</Th>
<Th>{__("Location")}</Th>
<Th>{__("International Transfers")}</Th>
{!isSnapshotMode && <Th>{__("Actions")}</Th>}
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
@@ -164,6 +173,7 @@ export default function ProcessingActivitiesPage({ queryRef }: ProcessingActivit
key={activity.id}
activity={activity}
connectionId={connectionId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
@@ -200,9 +210,11 @@ export default function ProcessingActivitiesPage({ queryRef }: ProcessingActivit
function ActivityRow({
activity,
connectionId,
hasAnyAction,
}: {
activity: NodeOf<NonNullable<ProcessingActivitiesPageFragment$data['processingActivities']>>;
connectionId: string;
hasAnyAction: boolean;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
@@ -255,16 +267,18 @@ function ActivityRow({
{activity.internationalTransfers ? __("Yes") : __("No")}
</Badge>
</Td>
{!isSnapshotMode && (
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
<DropdownItem
icon={IconTrashCan}
variant="danger"
onSelect={handleDelete}
>
{__("Delete")}
</DropdownItem>
<Authorized entity="ProcessingActivity" action="deleteProcessingActivity">
<DropdownItem
icon={IconTrashCan}
variant="danger"
onSelect={handleDelete}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
</Td>
)}

View File

@@ -41,6 +41,7 @@ import {
} from "../../../components/form/ProcessingActivityEnumOptions";
import type { ProcessingActivityGraphNodeQuery } from "/hooks/graph/__generated__/ProcessingActivityGraphNodeQuery.graphql";
import { Authorized } from "/permissions";
const updateProcessingActivitySchema = z.object({
name: z.string().min(1, "Name is required"),
@@ -170,11 +171,13 @@ export default function ProcessingActivityDetailsPage(props: Props) {
]}
/>
{!isSnapshotMode && (
<ActionDropdown>
<DropdownItem onClick={deleteActivity} variant="danger">
{__("Delete")}
</DropdownItem>
</ActionDropdown>
<Authorized entity="ProcessingActivity" action="deleteProcessingActivity">
<ActionDropdown>
<DropdownItem onClick={deleteActivity} variant="danger">
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Authorized>
)}
</div>
@@ -407,13 +410,15 @@ export default function ProcessingActivityDetailsPage(props: Props) {
{!isSnapshotMode && (
<div className="flex justify-end pt-4">
<Button
type="submit"
variant="primary"
disabled={formState.isSubmitting}
>
{formState.isSubmitting ? __("Saving...") : __("Save Changes")}
</Button>
<Authorized entity="ProcessingActivity" action="updateProcessingActivity">
<Button
type="submit"
variant="primary"
disabled={formState.isSubmitting}
>
{formState.isSubmitting ? __("Saving...") : __("Save Changes")}
</Button>
</Authorized>
</div>
)}
</form>

View File

@@ -30,6 +30,7 @@ import {
} from "/hooks/graph/RiskGraph";
import type { RiskGraphNodeQuery } from "/hooks/graph/__generated__/RiskGraphNodeQuery.graphql";
import { SnapshotBanner } from "/components/SnapshotBanner";
import { Authorized } from "/permissions";
type Props = {
queryRef: PreloadedQuery<RiskGraphNodeQuery>;
@@ -120,23 +121,27 @@ export default function RiskDetailPage(props: Props) {
/>
{!isSnapshotMode && (
<div className="flex gap-2">
<FormRiskDialog
trigger={
<Button icon={IconPencil} variant="secondary">
{__("Edit")}
</Button>
}
risk={{ id: riskId, ...risk }}
/>
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
<Authorized entity="Risk" action="updateRisk">
<FormRiskDialog
trigger={
<Button icon={IconPencil} variant="secondary">
{__("Edit")}
</Button>
}
risk={{ id: riskId, ...risk }}
/>
</Authorized>
<Authorized entity="Risk" action="deleteRisk">
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Authorized>
</div>
)}
</div>

View File

@@ -29,6 +29,8 @@ import type { RiskGraphListQuery } from "/hooks/graph/__generated__/RiskGraphLis
import type { RiskGraphFragment$data } from "/hooks/graph/__generated__/RiskGraphFragment.graphql";
import { useParams } from "react-router";
import { SnapshotBanner } from "/components/SnapshotBanner";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
type Props = {
queryRef: PreloadedQuery<RiskGraphListQuery>;
@@ -54,6 +56,11 @@ export default function RisksPage(props: Props) {
usePageTitle(__("Risks"));
const hasAnyAction = !isSnapshotMode && (
isAuthorized(organizationId, "Risk", "updateRisk") ||
isAuthorized(organizationId, "Risk", "deleteRisk")
);
return (
<div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
@@ -64,13 +71,15 @@ export default function RisksPage(props: Props) {
)}
>
{!isSnapshotMode && (
<FormRiskDialog
connection={connectionId}
onSuccess={() => {
pagination.refetch({ snapshotId });
}}
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
/>
<Authorized entity="Organization" action="createRisk">
<FormRiskDialog
connection={connectionId}
onSuccess={() => {
pagination.refetch({ snapshotId });
}}
trigger={<Button icon={IconPlusLarge}>{__("New Risk")}</Button>}
/>
</Authorized>
)}
</PageHeader>
@@ -101,7 +110,7 @@ export default function RisksPage(props: Props) {
<SortableTh field="OWNER_FULL_NAME">
{__("Owner")}
</SortableTh>
<Th></Th>
{hasAnyAction && <Th></Th>}
</Tr>
</Thead>
<Tbody>
@@ -111,6 +120,7 @@ export default function RisksPage(props: Props) {
key={risk.id}
connectionId={connectionId}
organizationId={organizationId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
@@ -123,6 +133,7 @@ type RowProps = {
risk: NodeOf<RiskGraphFragment$data["risks"]>;
connectionId: string;
organizationId: string;
hasAnyAction: boolean;
};
function RiskRow(props: RowProps) {
@@ -180,26 +191,30 @@ function RiskRow(props: RowProps) {
<SeverityBadge score={risk.residualRiskScore} />
</Td>
<Td>{risk.owner?.fullName || __("Unassigned")}</Td>
<Td noLink className="text-end">
{!isSnapshotMode && (
{props.hasAnyAction && (
<Td noLink className="text-end">
<ActionDropdown>
<DropdownItem
icon={IconPencil}
onClick={() => formDialogRef.current?.open()}
>
{__("Edit")}
</DropdownItem>
<Authorized entity="Risk" action="updateRisk">
<DropdownItem
icon={IconPencil}
onClick={() => formDialogRef.current?.open()}
>
{__("Edit")}
</DropdownItem>
</Authorized>
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
<Authorized entity="Risk" action="deleteRisk">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={onDelete}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
)}
</Td>
</Td>
)}
</Tr>
</>
);

View File

@@ -23,6 +23,7 @@ import { z } from "zod";
import type { GeneralSettingsTabFragment$key } from "./__generated__/GeneralSettingsTabFragment.graphql";
import { DeleteOrganizationDialog } from "/components/organizations/DeleteOrganizationDialog";
import { useDeleteOrganizationMutation } from "/hooks/graph/OrganizationGraph";
import { isAuthorized } from "/permissions";
const generalSettingsTabFragment = graphql`
fragment GeneralSettingsTabFragment on Organization {
@@ -90,6 +91,9 @@ export default function GeneralSettingsTab() {
const organization = useFragment(generalSettingsTabFragment, organizationKey);
const deleteDialogRef = useDialogRef();
const canUpdate = isAuthorized(organization.id, "Organization", "updateOrganization");
const canDelete = isAuthorized(organization.id, "Organization", "deleteOrganization");
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [horizontalLogoPreview, setHorizontalLogoPreview] = useState<
string | null
@@ -267,17 +271,19 @@ export default function GeneralSettingsTab() {
name={organization.name}
size="xl"
/>
<FileButton
disabled={formState.isSubmitting || isUpdatingOrganization}
onChange={handleLogoChange}
variant="secondary"
className="ml-auto"
accept="image/png,image/jpeg,image/jpg"
>
{isUpdatingOrganization
? __("Uploading...")
: __("Change logo")}
</FileButton>
{canUpdate && (
<FileButton
disabled={formState.isSubmitting || isUpdatingOrganization}
onChange={handleLogoChange}
variant="secondary"
className="ml-auto"
accept="image/png,image/jpeg,image/jpg"
>
{isUpdatingOrganization
? __("Uploading...")
: __("Change logo")}
</FileButton>
)}
</div>
</div>
<div>
@@ -301,19 +307,21 @@ export default function GeneralSettingsTab() {
/>
</div>
)}
<FileButton
disabled={formState.isSubmitting || isUpdatingOrganization}
onChange={handleHorizontalLogoChange}
variant="secondary"
accept="image/png,image/jpeg,image/jpg"
>
{isUpdatingOrganization
? __("Uploading...")
: horizontalLogoPreview || organization.horizontalLogoUrl
? __("Change horizontal logo")
: __("Upload horizontal logo")}
</FileButton>
{organization.horizontalLogoUrl && (
{canUpdate && (
<FileButton
disabled={formState.isSubmitting || isUpdatingOrganization}
onChange={handleHorizontalLogoChange}
variant="secondary"
accept="image/png,image/jpeg,image/jpg"
>
{isUpdatingOrganization
? __("Uploading...")
: horizontalLogoPreview || organization.horizontalLogoUrl
? __("Change horizontal logo")
: __("Upload horizontal logo")}
</FileButton>
)}
{canUpdate && organization.horizontalLogoUrl && (
<Dialog
ref={deleteDialogRef}
trigger={
@@ -357,7 +365,7 @@ export default function GeneralSettingsTab() {
</div>
<Field
{...register("name")}
readOnly={formState.isSubmitting}
readOnly={formState.isSubmitting || !canUpdate}
name="name"
type="text"
label={__("Organization name")}
@@ -367,7 +375,7 @@ export default function GeneralSettingsTab() {
<Label>{__("Description")}</Label>
<Textarea
{...register("description")}
readOnly={formState.isSubmitting}
readOnly={formState.isSubmitting || !canUpdate}
name="description"
placeholder={__("Brief description of your organization")}
rows={3}
@@ -376,7 +384,7 @@ export default function GeneralSettingsTab() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field
{...register("websiteUrl")}
readOnly={formState.isSubmitting}
readOnly={formState.isSubmitting || !canUpdate}
name="websiteUrl"
type="url"
label={__("Website URL")}
@@ -384,7 +392,7 @@ export default function GeneralSettingsTab() {
/>
<Field
{...register("email")}
readOnly={formState.isSubmitting}
readOnly={formState.isSubmitting || !canUpdate}
name="email"
type="email"
label={__("Email")}
@@ -395,13 +403,13 @@ export default function GeneralSettingsTab() {
<Label>{__("Headquarter Address")}</Label>
<Textarea
{...register("headquarterAddress")}
readOnly={formState.isSubmitting}
readOnly={formState.isSubmitting || !canUpdate}
name="headquarterAddress"
placeholder={__("123 Main St, City, Country")}
/>
</div>
{formState.isDirty && (
{formState.isDirty && canUpdate && (
<div className="flex justify-end pt-6">
<Button
type="submit"
@@ -416,37 +424,39 @@ export default function GeneralSettingsTab() {
</Card>
</div>
<div className="space-y-4 mt-12">
<h2 className="text-base font-medium text-red-600">
{__("Danger Zone")}
</h2>
<Card padded className="border-red-200 flex items-center gap-3">
<div className="mr-auto">
<h3 className="text-base font-semibold text-red-700">
{__("Delete Organization")}
</h3>
<p className="text-sm text-txt-tertiary">
{__("Permanently delete this organization and all its data.")}{" "}
<span className="text-red-600 font-medium">
{__("This action cannot be undone.")}
</span>
</p>
</div>
<DeleteOrganizationDialog
organizationName={organization.name}
onConfirm={handleDeleteOrganization}
isDeleting={isDeletingOrganization}
>
<Button
variant="danger"
icon={IconTrashCan}
disabled={isDeletingOrganization}
{canDelete && (
<div className="space-y-4 mt-12">
<h2 className="text-base font-medium text-red-600">
{__("Danger Zone")}
</h2>
<Card padded className="border-red-200 flex items-center gap-3">
<div className="mr-auto">
<h3 className="text-base font-semibold text-red-700">
{__("Delete Organization")}
</h3>
<p className="text-sm text-txt-tertiary">
{__("Permanently delete this organization and all its data.")}{" "}
<span className="text-red-600 font-medium">
{__("This action cannot be undone.")}
</span>
</p>
</div>
<DeleteOrganizationDialog
organizationName={organization.name}
onConfirm={handleDeleteOrganization}
isDeleting={isDeletingOrganization}
>
{__("Delete Organization")}
</Button>
</DeleteOrganizationDialog>
</Card>
</div>
<Button
variant="danger"
icon={IconTrashCan}
disabled={isDeletingOrganization}
>
{__("Delete Organization")}
</Button>
</DeleteOrganizationDialog>
</Card>
</div>
)}
</form>
);
}

View File

@@ -1,11 +1,18 @@
import { useState } from "react";
import { useState, Suspense } from "react";
import { useOutletContext } from "react-router";
import { usePaginationFragment, graphql } from "react-relay";
import {
Badge,
Button,
Card,
Dialog,
DialogContent,
DialogFooter,
Field,
IconPencil,
IconTrashCan,
Option,
Select,
Spinner,
TabBadge,
TabItem,
@@ -16,6 +23,7 @@ import {
Thead,
Tr,
useConfirm,
useDialogRef,
} from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { SortableTable, SortableTh } from "/components/SortableTable";
@@ -24,6 +32,8 @@ import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { sprintf } from "@probo/helpers";
import clsx from "clsx";
import type { NodeOf } from "/types";
import { Authorized } from "/permissions";
import { getAssignableRoles, getUserRole } from "/permissions";
import type {
MembersSettingsTabMembershipsFragment$data,
MembersSettingsTabMembershipsFragment$key
@@ -112,6 +122,19 @@ const removeMemberMutation = graphql`
}
`;
const updateMembershipMutation = graphql`
mutation MembersSettingsTab_UpdateMembershipMutation(
$input: UpdateMembershipInput!
) {
updateMembership(input: $input) {
membership {
id
role
}
}
}
`;
const deleteInvitationMutation = graphql`
mutation MembersSettingsTab_DeleteInvitationMutation(
$input: DeleteInvitationInput!
@@ -157,12 +180,14 @@ export default function MembersSettingsTab() {
<div className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-base font-medium">{__("Workspace members")}</h2>
<InviteUserDialog
connectionId={invitationsPagination.data.invitations?.__id}
onRefetch={refetchInvitations}
>
<Button variant="secondary">{__("Invite member")}</Button>
</InviteUserDialog>
<Authorized entity="Organization" action="inviteUser">
<InviteUserDialog
connectionId={invitationsPagination.data.invitations?.__id}
onRefetch={refetchInvitations}
>
<Button variant="secondary">{__("Invite member")}</Button>
</InviteUserDialog>
</Authorized>
</div>
<Tabs>
@@ -351,13 +376,15 @@ function InvitationRow(props: {
{isDeleting ? (
<Spinner size={16} />
) : (
<Button
variant="danger"
onClick={onDelete}
disabled={isDeleting}
icon={IconTrashCan}
aria-label={__("Delete invitation")}
/>
<Authorized entity="Organization" action="deleteInvitation">
<Button
variant="danger"
onClick={onDelete}
disabled={isDeleting}
icon={IconTrashCan}
aria-label={__("Delete invitation")}
/>
</Authorized>
)}
</div>
</Td>
@@ -365,19 +392,33 @@ function InvitationRow(props: {
);
}
function MembershipRow(props: {
function MembershipRowContent(props: {
membership: NodeOf<MembersSettingsTabMembershipsFragment$data["memberships"]>;
connectionId?: string;
organizationId: string;
onRefetch: () => void;
}) {
const { __ } = useTranslate();
const availableRoles = getAssignableRoles(props.organizationId);
const currentUserRole = getUserRole(props.organizationId);
const [removeMember, isRemoving] = useMutationWithToasts(removeMemberMutation, {
successMessage: __("Member removed successfully"),
errorMessage: __("Failed to remove member"),
});
const [updateMembership, isUpdating] = useMutationWithToasts(updateMembershipMutation, {
successMessage: __("Role updated successfully"),
errorMessage: __("Failed to update role"),
});
const confirm = useConfirm();
const editDialogRef = useDialogRef();
const [isRemoved, setIsRemoved] = useState(false);
const [selectedRole, setSelectedRole] = useState<string>(props.membership.role);
// Only OWNER can edit OWNER members
const canEditThisRole = props.membership.role === "OWNER"
? currentUserRole === "OWNER"
: true;
if (isRemoved) {
return null;
@@ -409,41 +450,137 @@ function MembershipRow(props: {
);
};
const handleEditClick = () => {
setSelectedRole(props.membership.role);
editDialogRef.current?.open();
};
const handleUpdateRole = (e: React.FormEvent) => {
e.preventDefault();
updateMembership({
variables: {
input: {
memberId: props.membership.id,
organizationId: props.organizationId,
role: selectedRole,
},
},
onCompleted: () => {
editDialogRef.current?.close();
},
});
};
return (
<Tr className={clsx(isRemoving && "opacity-60 pointer-events-none")}>
<Td>
<div className="font-semibold">{props.membership.fullName}</div>
</Td>
<Td>
<div className="flex items-center gap-2">
{props.membership.emailAddress}
{props.membership.authMethod === "SAML" && (
<Badge variant="info">SAML</Badge>
)}
</div>
</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"
onClick={onRemove}
disabled={isRemoving}
icon={IconTrashCan}
aria-label={__("Remove member")}
/>
)}
</div>
</Td>
</Tr>
<>
<Tr className={clsx(isRemoving && "opacity-60 pointer-events-none")}>
<Td>
<div className="font-semibold">{props.membership.fullName}</div>
</Td>
<Td>
<div className="flex items-center gap-2">
{props.membership.emailAddress}
{props.membership.authMethod === "SAML" && (
<Badge variant="info">SAML</Badge>
)}
</div>
</Td>
<Td>
<Badge>{props.membership.role}</Badge>
</Td>
<Td>{new Date(props.membership.createdAt).toLocaleDateString()}</Td>
<Td noLink width={160} className="text-end">
<div
className="flex gap-2 justify-end"
onClick={(e) => e.stopPropagation()}
>
<Authorized entity="Organization" action="updateMembership">
{canEditThisRole && (
<Button
variant="secondary"
onClick={handleEditClick}
disabled={isUpdating}
icon={IconPencil}
aria-label={__("Edit role")}
/>
)}
</Authorized>
{isRemoving ? (
<Spinner size={16} />
) : (
<Authorized entity="Organization" action="removeMember">
{canEditThisRole && (
<Button
variant="danger"
onClick={onRemove}
disabled={isRemoving}
icon={IconTrashCan}
aria-label={__("Remove member")}
/>
)}
</Authorized>
)}
</div>
</Td>
</Tr>
<Dialog ref={editDialogRef} title={__("Edit Member Role")}>
<form onSubmit={handleUpdateRole}>
<DialogContent padded className="space-y-6">
<div>
<p className="text-txt-secondary text-sm mb-4">
{sprintf(__("Update the role for %s"), props.membership.fullName)}
</p>
<Field label={__("Role")} required>
<Select value={selectedRole} onValueChange={setSelectedRole}>
{availableRoles.includes("OWNER") && <Option value="OWNER">{__("Owner")}</Option>}
{availableRoles.includes("ADMIN") && <Option value="ADMIN">{__("Admin")}</Option>}
{availableRoles.includes("VIEWER") && <Option value="VIEWER">{__("Viewer")}</Option>}
</Select>
</Field>
<div className="mt-4 space-y-2 text-sm text-txt-tertiary">
{selectedRole === "OWNER" && (
<p>{__("Full access to everything")}</p>
)}
{selectedRole === "ADMIN" && (
<p>{__("Full access except organization setup and API keys")}</p>
)}
{selectedRole === "VIEWER" && (
<p>{__("Read-only access")}</p>
)}
</div>
</div>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={isUpdating || selectedRole === props.membership.role}>
{isUpdating && <Spinner />}
{__("Update Role")}
</Button>
</DialogFooter>
</form>
</Dialog>
</>
);
}
function MembershipRow(props: {
membership: NodeOf<MembersSettingsTabMembershipsFragment$data["memberships"]>;
connectionId?: string;
organizationId: string;
onRefetch: () => void;
}) {
return (
<Suspense fallback={
<Tr>
<Td><Spinner size={16} /></Td>
<Td></Td>
<Td></Td>
<Td></Td>
</Tr>
}>
<MembershipRowContent {...props} />
</Suspense>
);
}

View File

@@ -37,6 +37,7 @@ import {
useVerifyDomainMutation,
} from "/hooks/graph/SAMLConfigurationGraph";
import type { SAMLSettingsTabFragment$key } from "./__generated__/SAMLSettingsTabFragment.graphql";
import { Authorized } from "/permissions";
const samlSettingsTabFragment = graphql`
fragment SAMLSettingsTabFragment on Organization {
@@ -371,9 +372,11 @@ export default function SAMLSettingsTab() {
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-base font-medium">{__("SAML Single Sign-On")}</h2>
<Button onClick={() => handleOpenModal()}>
{__("Add Configuration")}
</Button>
<Authorized entity="Organization" action="createSAMLConfiguration">
<Button onClick={() => handleOpenModal()}>
{__("Add Configuration")}
</Button>
</Authorized>
</div>
{configs.length === 0 ? (
@@ -385,9 +388,11 @@ export default function SAMLSettingsTab() {
<p className="text-gray-600 mb-6">
{__("Set up SAML 2.0 single sign-on for your organization by adding a configuration for each email domain.")}
</p>
<Button onClick={() => handleOpenModal()}>
{__("Add Your First Configuration")}
</Button>
<Authorized entity="Organization" action="createSAMLConfiguration">
<Button onClick={() => handleOpenModal()}>
{__("Add Your First Configuration")}
</Button>
</Authorized>
</div>
</Card>
) : (
@@ -452,34 +457,42 @@ export default function SAMLSettingsTab() {
<div className="flex gap-2 justify-end">
{config.domainVerified ? (
<>
<Button
variant={config.enabled ? "danger" : "primary"}
onClick={() => handleToggleEnabled(config)}
disabled={isEnabling || isDisabling}
>
{config.enabled ? __("Disable") : __("Enable")}
</Button>
<Button
variant="secondary"
onClick={() => handleOpenModal(config)}
>
{__("Edit")}
</Button>
<Authorized entity="SAMLConfiguration" action="updateSAMLConfiguration">
<Button
variant={config.enabled ? "danger" : "primary"}
onClick={() => handleToggleEnabled(config)}
disabled={isEnabling || isDisabling}
>
{config.enabled ? __("Disable") : __("Enable")}
</Button>
</Authorized>
<Authorized entity="SAMLConfiguration" action="updateSAMLConfiguration">
<Button
variant="secondary"
onClick={() => handleOpenModal(config)}
>
{__("Edit")}
</Button>
</Authorized>
</>
) : (
<>
<Button
variant="primary"
onClick={() => handleOpenModal(config)}
>
{__("Verify Domain")}
</Button>
<Button
variant="danger"
onClick={() => handleDelete(config)}
>
{__("Delete")}
</Button>
<Authorized entity="Organization" action="verifyDomain">
<Button
variant="primary"
onClick={() => handleOpenModal(config)}
>
{__("Verify Domain")}
</Button>
</Authorized>
<Authorized entity="Organization" action="deleteOrganization">
<Button
variant="danger"
onClick={() => handleDelete(config)}
>
{__("Delete")}
</Button>
</Authorized>
</>
)}
</div>

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<8df0455e495843c9db156c36f38c97d6>>
* @generated SignedSource<<0a1a073bbc42108dd13e235594c27457>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -10,7 +10,7 @@
import { ReaderFragment } from 'relay-runtime';
export type InvitationStatus = "ACCEPTED" | "EXPIRED" | "PENDING";
export type Role = "ADMIN" | "MEMBER" | "OWNER" | "VIEWER";
export type MembershipRole = "ADMIN" | "OWNER" | "VIEWER";
import { FragmentRefs } from "relay-runtime";
export type MembersSettingsTabInvitationsFragment$data = {
readonly id: string;
@@ -24,7 +24,7 @@ export type MembersSettingsTabInvitationsFragment$data = {
readonly expiresAt: any;
readonly fullName: string;
readonly id: string;
readonly role: Role;
readonly role: MembershipRole;
readonly status: InvitationStatus;
};
}>;

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<ff12447635b4a42587b7d7de6ea1b4e1>>
* @generated SignedSource<<752dcbc2d152883fa2ac09b52039f01c>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -9,7 +9,7 @@
// @ts-nocheck
import { ReaderFragment } from 'relay-runtime';
export type Role = "ADMIN" | "MEMBER" | "OWNER" | "VIEWER";
export type MembershipRole = "ADMIN" | "OWNER" | "VIEWER";
export type UserAuthMethod = "PASSWORD" | "SAML";
import { FragmentRefs } from "relay-runtime";
export type MembersSettingsTabMembershipsFragment$data = {
@@ -23,7 +23,7 @@ export type MembersSettingsTabMembershipsFragment$data = {
readonly emailAddress: string;
readonly fullName: string;
readonly id: string;
readonly role: Role;
readonly role: MembershipRole;
};
}>;
readonly totalCount: number;

View File

@@ -0,0 +1,116 @@
/**
* @generated SignedSource<<562744e07bdb502c26aebe267b181b0e>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type MembershipRole = "ADMIN" | "OWNER" | "VIEWER";
export type UpdateMembershipInput = {
memberId: string;
organizationId: string;
role: MembershipRole;
};
export type MembersSettingsTab_UpdateMembershipMutation$variables = {
input: UpdateMembershipInput;
};
export type MembersSettingsTab_UpdateMembershipMutation$data = {
readonly updateMembership: {
readonly membership: {
readonly id: string;
readonly role: MembershipRole;
};
};
};
export type MembersSettingsTab_UpdateMembershipMutation = {
response: MembersSettingsTab_UpdateMembershipMutation$data;
variables: MembersSettingsTab_UpdateMembershipMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "UpdateMembershipPayload",
"kind": "LinkedField",
"name": "updateMembership",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Membership",
"kind": "LinkedField",
"name": "membership",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
},
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "role",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "MembersSettingsTab_UpdateMembershipMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "MembersSettingsTab_UpdateMembershipMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "675437becfdd9cc5ea20d2b40b9ace37",
"id": null,
"metadata": {},
"name": "MembersSettingsTab_UpdateMembershipMutation",
"operationKind": "mutation",
"text": "mutation MembersSettingsTab_UpdateMembershipMutation(\n $input: UpdateMembershipInput!\n) {\n updateMembership(input: $input) {\n membership {\n id\n role\n }\n }\n}\n"
}
};
})();
(node as any).hash = "29ead2b06842cc8ed98bbea1cf6c1bed";
export default node;

View File

@@ -34,6 +34,8 @@ import type { NodeOf } from "/types";
import SnapshotFormDialog from "./dialog/SnapshotFormDialog";
import { usePageTitle } from "@probo/hooks";
import { useOrganizationId } from "/hooks/useOrganizationId";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
type Props = {
queryRef: PreloadedQuery<SnapshotGraphListQuery>;
@@ -71,6 +73,8 @@ export default function SnapshotsPage(props: Props) {
const snapshots = data.snapshots.edges.map((edge) => edge.node);
usePageTitle(__("Snapshots"));
const hasAnyAction = isAuthorized(organizationId, "Snapshot", "deleteSnapshot");
return (
<div className="space-y-6">
<PageHeader
@@ -79,11 +83,13 @@ export default function SnapshotsPage(props: Props) {
"Snapshots capture point-in-time views of your organization's compliance state. Create snapshots to track progress over time."
)}
>
<SnapshotFormDialog connection={connectionId}>
<Button variant="primary" icon={IconPlusLarge}>
{__("New snapshot")}
</Button>
</SnapshotFormDialog>
<Authorized entity="Organization" action="createSnapshot">
<SnapshotFormDialog connection={connectionId}>
<Button variant="primary" icon={IconPlusLarge}>
{__("New snapshot")}
</Button>
</SnapshotFormDialog>
</Authorized>
</PageHeader>
{snapshots.length > 0 ? (
@@ -94,7 +100,7 @@ export default function SnapshotsPage(props: Props) {
<Th>{__("Type")}</Th>
<Th>{__("Description")}</Th>
<Th>{__("Created")}</Th>
<Th></Th>
{hasAnyAction && <Th></Th>}
</Tr>
</Thead>
<Tbody>
@@ -104,6 +110,7 @@ export default function SnapshotsPage(props: Props) {
snapshot={snapshot}
connectionId={connectionId}
organizationId={organizationId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
@@ -126,6 +133,7 @@ type SnapshotRowProps = {
snapshot: NodeOf<SnapshotsPageFragment$data["snapshots"]>;
connectionId: string;
organizationId: string;
hasAnyAction: boolean;
};
function SnapshotRow(props: SnapshotRowProps) {
@@ -148,17 +156,21 @@ function SnapshotRow(props: SnapshotRowProps) {
<Td className="text-txt-tertiary">
{formatDate(props.snapshot.createdAt)}
</Td>
<Td noLink width={50} className="text-end">
<ActionDropdown>
<DropdownItem
onClick={deleteSnapshot}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Td>
{props.hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
<Authorized entity="Snapshot" action="deleteSnapshot">
<DropdownItem
onClick={deleteSnapshot}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
</Td>
)}
</Tr>
);
}

View File

@@ -12,6 +12,7 @@ import { tasksQuery } from "/hooks/graph/TaskGraph";
import { usePageTitle } from "@probo/hooks";
import TasksCard from "/components/tasks/TasksCard";
import TaskFormDialog from "/components/tasks/TaskFormDialog";
import { Authorized } from "/permissions";
const tasksFragment = graphql`
fragment TasksPageFragment on Organization
@@ -76,9 +77,11 @@ export default function TasksPage({ queryRef }: Props) {
"Track your assigned compliance tasks and keep progress on track."
)}
>
<TaskFormDialog connection={connectionId}>
<Button icon={IconPlusLarge}>{__("New task")}</Button>
</TaskFormDialog>
<Authorized entity="Organization" action="createTask">
<TaskFormDialog connection={connectionId}>
<Button icon={IconPlusLarge}>{__("New task")}</Button>
</TaskFormDialog>
</Authorized>
</PageHeader>
<TasksCard connectionId={connectionId} tasks={tasks ?? []} />
</div>

View File

@@ -36,6 +36,7 @@ import {
} from "/hooks/graph/TrustCenterAccessGraph";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { useMutationWithToasts } from "/hooks/useMutationWithToasts";
import { Authorized } from "/permissions";
type ContextType = {
organization: {
@@ -422,12 +423,14 @@ export default function TrustCenterAccessTab() {
</p>
</div>
{organization.trustCenter?.id && (
<Button icon={IconPlusLarge} onClick={() => {
inviteForm.reset();
dialogRef.current?.open();
}}>
{__("Add Access")}
</Button>
<Authorized entity="TrustCenter" action="createTrustCenterAccess">
<Button icon={IconPlusLarge} onClick={() => {
inviteForm.reset();
dialogRef.current?.open();
}}>
{__("Add Access")}
</Button>
</Authorized>
)}
</div>
@@ -512,18 +515,22 @@ export default function TrustCenterAccessTab() {
className="flex gap-2 justify-end"
onClick={(e) => e.stopPropagation()}
>
<Button
variant="secondary"
onClick={() => handleEditAccess(access)}
disabled={isUpdating}
icon={IconPencil}
/>
<Button
variant="danger"
onClick={() => handleDelete(access.id)}
disabled={isDeleting}
icon={IconTrashCan}
/>
<Authorized entity="TrustCenterAccess" action="updateTrustCenterAccess">
<Button
variant="secondary"
onClick={() => handleEditAccess(access)}
disabled={isUpdating}
icon={IconPencil}
/>
</Authorized>
<Authorized entity="TrustCenterAccess" action="deleteTrustCenterAccess">
<Button
variant="danger"
onClick={() => handleDelete(access.id)}
disabled={isDeleting}
icon={IconTrashCan}
/>
</Authorized>
</div>
</Td>
</Tr>

View File

@@ -23,6 +23,7 @@ import {
} from "/hooks/graph/TrustCenterFileGraph";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { TrustCenterFilesCard } from "/components/trustCenter/TrustCenterFilesCard";
import { Authorized } from "/permissions";
import type { TrustCenterFilesCardFragment$key } from "/components/trustCenter/__generated__/TrustCenterFilesCardFragment.graphql";
type ContextType = {
@@ -200,9 +201,11 @@ export default function TrustCenterFilesTab() {
{__("Upload and manage files for your trust center")}
</p>
</div>
<Button icon={IconPlusLarge} onClick={() => createDialogRef.current?.open()}>
{__("Add File")}
</Button>
<Authorized entity="Organization" action="createTrustCenterFile">
<Button icon={IconPlusLarge} onClick={() => createDialogRef.current?.open()}>
{__("Add File")}
</Button>
</Authorized>
</div>
{(isUpdating || isDeleting) && (
<div className="flex items-center justify-center">

View File

@@ -13,6 +13,8 @@ import { useUpdateTrustCenterMutation, useUploadTrustCenterNDAMutation, useDelet
import type { TrustCenterGraphQuery$data } from "/hooks/graph/__generated__/TrustCenterGraphQuery.graphql";
import { useState } from "react";
import { SlackConnections } from "../../../components/organizations/SlackConnection";
import { isAuthorized } from "/permissions";
import { useParams } from "react-router";
type ContextType = {
organization: TrustCenterGraphQuery$data["organization"];
@@ -22,12 +24,15 @@ export default function TrustCenterOverviewTab() {
const { __ } = useTranslate();
const { toast } = useToast();
const { organization } = useOutletContext<ContextType>();
const { organizationId } = useParams();
const [updateTrustCenter, isUpdating] = useUpdateTrustCenterMutation();
const [uploadNDA, isUploadingNDA] = useUploadTrustCenterNDAMutation();
const [deleteNDA, isDeletingNDA] = useDeleteTrustCenterNDAMutation();
const [isActive, setIsActive] = useState(organization.trustCenter?.active || false);
const canUpdateTrustCenter = organizationId ? isAuthorized(organizationId, "TrustCenter", "updateTrustCenter") : false;
const handleToggleActive = async (active: boolean) => {
if (!organization.trustCenter?.id) {
toast({
@@ -128,6 +133,7 @@ export default function TrustCenterOverviewTab() {
<Checkbox
checked={isActive}
onChange={handleToggleActive}
disabled={!canUpdateTrustCenter}
/>
</div>
@@ -210,25 +216,35 @@ export default function TrustCenterOverviewTab() {
>
{__("Download PDF")}
</Button>
<Button
variant="quaternary"
icon={IconTrashCan}
onClick={handleNDADelete}
disabled={isDeletingNDA}
/>
{canUpdateTrustCenter && (
<Button
variant="quaternary"
icon={IconTrashCan}
onClick={handleNDADelete}
disabled={isDeletingNDA}
/>
)}
</div>
</div>
</div>
) : (
<Dropzone
description={__("Upload PDF files up to 10MB")}
isUploading={isUploadingNDA}
onDrop={handleNDAUpload}
accept={{
"application/pdf": [".pdf"],
}}
maxSize={10}
/>
<>
{canUpdateTrustCenter ? (
<Dropzone
description={__("Upload PDF files up to 10MB")}
isUploading={isUploadingNDA}
onDrop={handleNDAUpload}
accept={{
"application/pdf": [".pdf"],
}}
maxSize={10}
/>
) : (
<p className="text-sm text-txt-tertiary">
{__("No NDA file uploaded")}
</p>
)}
</>
)}
</div>
</Card>

View File

@@ -29,6 +29,7 @@ import { ImportAssessmentDialog } from "./dialogs/ImportAssessmentDialog";
import { complianceReportsFragment } from "./tabs/VendorComplianceTab";
import type { VendorComplianceTabFragment$key } from "./tabs/__generated__/VendorComplianceTabFragment.graphql";
import { SnapshotBanner } from "/components/SnapshotBanner";
import { Authorized } from "/permissions";
type Props = {
queryRef: PreloadedQuery<VendorGraphNodeQuery>;
@@ -97,15 +98,17 @@ export default function VendorDetailPage(props: Props) {
{__("Assessment From Website")}
</Button>
</ImportAssessmentDialog>
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteVendor}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
<Authorized entity="Vendor" action="deleteVendor">
<ActionDropdown variant="secondary">
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteVendor}
>
{__("Delete")}
</DropdownItem>
</ActionDropdown>
</Authorized>
</div>
)}
</div>

View File

@@ -37,6 +37,8 @@ import type {
} from "/hooks/graph/__generated__/VendorGraphPaginatedFragment.graphql";
import { SortableTable, SortableTh } from "/components/SortableTable";
import { SnapshotBanner } from "/components/SnapshotBanner";
import { Authorized } from "/permissions";
import { isAuthorized } from "/permissions";
type Vendor = NodeOf<VendorGraphPaginatedFragment$data["vendors"]>;
@@ -61,6 +63,11 @@ export default function VendorsPage(props: Props) {
usePageTitle(__("Vendors"));
const hasAnyAction = !isSnapshotMode && (
isAuthorized(organizationId, "Vendor", "updateVendor") ||
isAuthorized(organizationId, "Vendor", "deleteVendor")
);
return (
<div className="space-y-6">
{snapshotId && <SnapshotBanner snapshotId={snapshotId} />}
@@ -71,12 +78,14 @@ export default function VendorsPage(props: Props) {
)}
>
{!isSnapshotMode && (
<CreateVendorDialog
connection={connectionId}
organizationId={organizationId}
>
<Button icon={IconPlusLarge}>{__("Add vendor")}</Button>
</CreateVendorDialog>
<Authorized entity="Organization" action="createVendor">
<CreateVendorDialog
connection={connectionId}
organizationId={organizationId}
>
<Button icon={IconPlusLarge}>{__("Add vendor")}</Button>
</CreateVendorDialog>
</Authorized>
)}
</PageHeader>
<SortableTable {...pagination}>
@@ -86,7 +95,7 @@ export default function VendorsPage(props: Props) {
<Th>{__("Accessed At")}</Th>
<Th>{__("Data Risk")}</Th>
<Th>{__("Business Risk")}</Th>
<Th></Th>
{hasAnyAction && <Th></Th>}
</Tr>
</Thead>
<Tbody>
@@ -96,6 +105,7 @@ export default function VendorsPage(props: Props) {
vendor={vendor}
organizationId={organizationId}
connectionId={connectionId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
@@ -108,10 +118,12 @@ function VendorRow({
vendor,
organizationId,
connectionId,
hasAnyAction,
}: {
vendor: Vendor;
organizationId: string;
connectionId: string;
hasAnyAction: boolean;
}) {
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
@@ -144,19 +156,21 @@ function VendorRow({
<Td>
<RiskBadge level={latestAssessment?.businessImpact ?? "NONE"} />
</Td>
<Td noLink width={50} className="text-end">
{!isSnapshotMode && (
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
<DropdownItem
onClick={deleteVendor}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
<Authorized entity="Vendor" action="deleteVendor">
<DropdownItem
onClick={deleteVendor}
variant="danger"
icon={IconTrashCan}
>
{__("Delete")}
</DropdownItem>
</Authorized>
</ActionDropdown>
)}
</Td>
</Td>
)}
</Tr>
</>
);

View File

@@ -20,6 +20,7 @@ import type { useVendorFormFragment$key } from "/hooks/forms/__generated__/useVe
import type { VendorOverviewTabBusinessAssociateAgreementFragment$key } from "./__generated__/VendorOverviewTabBusinessAssociateAgreementFragment.graphql";
import type { VendorOverviewTabDataPrivacyAgreementFragment$key } from "./__generated__/VendorOverviewTabDataPrivacyAgreementFragment.graphql";
import type { VendorCategory } from "@probo/vendors";
import { Authorized } from "/permissions";
const vendorBusinessAssociateAgreementFragment = graphql`
fragment VendorOverviewTabBusinessAssociateAgreementFragment on Vendor {
@@ -395,9 +396,11 @@ export default function VendorOverviewTab() {
{/* Submit */}
{!isSnapshotMode && (
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{__("Update vendor")}
</Button>
<Authorized entity="Vendor" action="updateVendor">
<Button type="submit" disabled={isSubmitting}>
{__("Update vendor")}
</Button>
</Authorized>
</div>
)}
</form>

View File

@@ -0,0 +1,80 @@
import { type ReactNode, useState, useEffect } from "react";
import { useParams } from "react-router";
import { isAuthorized } from "./permissions";
type Props = {
entity: string;
action: string;
children: ReactNode;
fallback?: ReactNode;
};
/**
* Conditionally render children based on authorization check
* Automatically fetches permissions if not cached
*
* @param entity - The entity name (e.g., "Document", "Organization", "Vendor")
* @param action - The action/field name (e.g., "get", "createDocument", "updateVendor")
*
* @example
* <Authorized entity="Organization" action="createDocument">
* <CreateButton />
* </Authorized>
*
* @example
* <Authorized entity="Document" action="get">
* <DocumentViewer />
* </Authorized>
*
* @example
* <Authorized entity="Vendor" action="updateVendor">
* <EditButton />
* </Authorized>
*/
export function Authorized({
entity,
action,
children,
fallback = null,
}: Props) {
const { organizationId } = useParams();
const [hasAccess, setHasAccess] = useState<boolean | null>(null);
useEffect(() => {
if (!organizationId) {
setHasAccess(false);
return;
}
// Try to check authorization, catching promise throws
try {
const authorized = isAuthorized(organizationId, entity, action);
setHasAccess(authorized);
} catch (promise) {
// If a promise is thrown (Suspense pattern), wait for it
if (promise instanceof Promise) {
promise
.then(() => {
// Permissions loaded, try again
try {
const authorized = isAuthorized(organizationId, entity, action);
setHasAccess(authorized);
} catch {
setHasAccess(false);
}
})
.catch(() => {
setHasAccess(false);
});
} else {
setHasAccess(false);
}
}
}, [organizationId, entity, action]);
if (!organizationId || hasAccess === null || hasAccess === false) {
return fallback;
}
return children;
}

View File

@@ -0,0 +1,3 @@
export { isAuthorized, getUserRole, getAssignableRoles } from "./permissions";
export { Authorized } from "./Authorized";
export type { EntityPermissions } from "./permissions";

View File

@@ -0,0 +1,131 @@
// Authorization system that checks permissions from the backend
// Permissions are fetched per organization from /authz/:organizationId/permissions
// Format: { permissions: { "Document": { "node": true, "updateDocument": true }, "Organization": { "createDocument": true } }, role: "ADMIN" }
export type EntityPermissions = Record<string, Record<string, boolean>>;
type PermissionsResponse = {
permissions: EntityPermissions;
role: string;
};
let cachedPermissions: EntityPermissions | null = null;
let cachedRole: string | null = null;
let cachePromise: Promise<PermissionsResponse> | null = null;
let currentOrganizationId: string | null = null;
/**
* Fetch permissions for the current user's role in the organization
*/
function fetchPermissions(organizationId: string): Promise<PermissionsResponse> {
if (cachedPermissions && cachedRole && currentOrganizationId === organizationId) {
return Promise.resolve({ permissions: cachedPermissions, role: cachedRole });
}
if (cachePromise && currentOrganizationId === organizationId) {
return cachePromise;
}
if (currentOrganizationId !== organizationId) {
cachedPermissions = null;
cachedRole = null;
cachePromise = null;
currentOrganizationId = organizationId;
}
const requestedOrgId = organizationId;
cachePromise = fetch(`/authz/${encodeURIComponent(organizationId)}/permissions`, {
credentials: 'include',
})
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to fetch permissions: ${response.statusText}`);
}
return response.json();
})
.then((data: PermissionsResponse) => {
if (currentOrganizationId === requestedOrgId) {
cachedPermissions = data.permissions;
cachedRole = data.role;
}
cachePromise = null;
return data;
})
.catch((error) => {
cachePromise = null;
cachedPermissions = null;
cachedRole = null;
throw error;
});
return cachePromise;
}
/**
* Check if the user has permission for an entity and action
*
* @param organizationId - The organization ID
* @param entity - The entity name (e.g., "Document", "Organization", "Vendor")
* @param action - The action/field name (e.g., "node", "updateDocument", "createDocument")
* @returns true if the user has permission
*
* @example
* isAuthorized(orgId, "Document", "get") // Check if user can query Document nodes
* isAuthorized(orgId, "Document", "updateDocument") // Check if user can update documents
* isAuthorized(orgId, "Organization", "createDocument") // Check if user can create documents
*/
export function isAuthorized(
organizationId: string,
entity: string,
action: string
): boolean {
if (!cachedPermissions || currentOrganizationId !== organizationId) {
throw fetchPermissions(organizationId);
}
const entityPermissions = cachedPermissions[entity];
if (!entityPermissions) {
return false;
}
return entityPermissions[action] === true;
}
/**
* Get the current user's role in the organization
*
* @param organizationId - The organization ID
* @returns The user's role (e.g., "OWNER", "ADMIN", "VIEWER", "FULL")
*
* @example
* getUserRole(orgId) // Returns "ADMIN"
*/
export function getUserRole(organizationId: string): string {
if (!cachedRole || currentOrganizationId !== organizationId) {
throw fetchPermissions(organizationId);
}
return cachedRole;
}
/**
* Get available roles that the current user can assign
* Based on the rule: OWNER and FULL can assign any role, ADMIN can assign ADMIN and VIEWER but not OWNER
*
* @param organizationId - The organization ID
* @returns Array of roles that can be assigned
*/
export function getAssignableRoles(organizationId: string): string[] {
const currentRole = getUserRole(organizationId);
if (currentRole === "OWNER" || currentRole === "FULL") {
return ["OWNER", "ADMIN", "VIEWER"];
}
if (currentRole === "ADMIN") {
return ["ADMIN", "VIEWER"];
}
return [];
}

View File

@@ -10,8 +10,8 @@ import type { PropsWithChildren } from "react";
import { RelayEnvironmentProvider } from "react-relay";
export class UnAuthenticatedError extends Error {
constructor() {
super("UNAUTHENTICATED");
constructor(message?: string) {
super(message || "UNAUTHENTICATED");
this.name = "UnAuthenticatedError";
}
}
@@ -45,12 +45,19 @@ export class AuthenticationRequiredError extends Error {
}
export class UnauthorizedError extends Error {
constructor() {
super("UNAUTHORIZED");
constructor(message?: string) {
super(message || "UNAUTHORIZED");
this.name = "UnauthorizedError";
}
}
export class ForbiddenError extends Error {
constructor(message?: string) {
super(message || "FORBIDDEN");
this.name = "ForbiddenError";
}
}
export function buildEndpoint(path: string): string {
const host = import.meta.env.VITE_API_URL;
@@ -81,6 +88,9 @@ const hasAuthenticationRequiredError = (error: GraphQLError) =>
const hasUnauthorizedError = (error: GraphQLError) =>
error.extensions?.code == "UNAUTHORIZED";
const hasForbiddenError = (error: GraphQLError) =>
error.extensions?.code == "FORBIDDEN";
const fetchRelay: FetchFunction = async (
request,
variables,
@@ -147,8 +157,9 @@ const fetchRelay: FetchFunction = async (
if (json.errors) {
const errors = json.errors as GraphQLError[];
if (errors.find(hasUnauthenticatedError)) {
throw new UnAuthenticatedError();
const unauthenticatedError = errors.find(hasUnauthenticatedError);
if (unauthenticatedError) {
throw new UnAuthenticatedError(unauthenticatedError.message);
}
const authRequiredError = errors.find(hasAuthenticationRequiredError);
@@ -163,8 +174,14 @@ const fetchRelay: FetchFunction = async (
});
}
if (errors.find(hasUnauthorizedError)) {
throw new UnauthorizedError();
const unauthorizedError = errors.find(hasUnauthorizedError);
if (unauthorizedError) {
throw new UnauthorizedError(unauthorizedError.message);
}
const forbiddenError = errors.find(hasForbiddenError);
if (forbiddenError) {
throw new ForbiddenError(forbiddenError.message);
}
}

View File

@@ -13,6 +13,7 @@ import {
relayEnvironment,
UnAuthenticatedError,
UnauthorizedError,
ForbiddenError,
} from "./providers/RelayProviders";
import { PageSkeleton } from "./components/skeletons/PageSkeleton.tsx";
import { loadQuery, type PreloadedQuery } from "react-relay";
@@ -59,6 +60,10 @@ function ErrorBoundary({ error: propsError }: { error?: string }) {
return <PageError error="UNAUTHORIZED" />;
}
if (error instanceof ForbiddenError) {
return <PageError error="FORBIDDEN" />;
}
return <PageError error={error?.toString()} />;
}

View File

@@ -25,6 +25,7 @@ export default defineConfig({
"/pages": fileURLToPath(new URL("./src/pages", import.meta.url)),
"/routes": fileURLToPath(new URL("./src/routes", import.meta.url)),
"/providers": fileURLToPath(new URL("./src/providers", import.meta.url)),
"/permissions": fileURLToPath(new URL("./src/permissions", import.meta.url)),
},
},
});

View File

@@ -1,7 +1,10 @@
export interface GraphQLError {
message?: string;
extensions?: {
code?: string;
};
source?: {
errors?: Array<{ message: string }>;
errors?: Array<{ message: string; extensions?: { code?: string } }>;
};
}

View File

@@ -7,7 +7,7 @@ import {
Description,
Cancel,
} from "@radix-ui/react-alert-dialog";
import { useCallback, useState, type ComponentProps } from "react";
import { useCallback, useState, useMemo, type ComponentProps } from "react";
import { Button } from "../../Atoms/Button/Button";
import { Root as Portal } from "@radix-ui/react-portal";
import { dialog } from "./Dialog";
@@ -67,38 +67,69 @@ export function useConfirm() {
* Global component that displays a dialog when confirm() is called
*/
export function ConfirmDialog() {
const { message, title, variant, label, onConfirm, close } =
useConfirmStore();
const message = useConfirmStore((state) => state.message);
const isOpen = !!message;
if (!isOpen) {
return null;
}
return <ConfirmDialogContent />;
}
function ConfirmDialogContent() {
const message = useConfirmStore((state) => state.message);
const title = useConfirmStore((state) => state.title);
const variant = useConfirmStore((state) => state.variant);
const label = useConfirmStore((state) => state.label);
const onConfirm = useConfirmStore((state) => state.onConfirm);
const close = useConfirmStore((state) => state.close);
const { __ } = useTranslate();
const isOpen = !!message;
const {
overlay,
content,
header,
title: titleClassname,
footer,
} = dialog();
const [loading, setLoading] = useState(false);
const dialogStyles = useMemo(() => {
const styles = dialog();
return {
overlay: styles.overlay(),
content: styles.content({ className: "max-w-[500px]" }),
header: styles.header(),
title: styles.title(),
footer: styles.footer(),
};
}, []);
const handleConfirm = async () => {
setLoading(true);
try {
await onConfirm();
} catch (error) {
console.error('Confirm action failed:', error);
} finally {
close();
setLoading(false);
}
};
const handleOpenChange = (open: boolean) => {
if (!open) {
setLoading(false);
close();
}
};
return (
<Root open={isOpen} onOpenChange={close}>
<Root open={isOpen} onOpenChange={handleOpenChange}>
<Portal>
<Overlay className={overlay()} />
<Content className={content({ className: "max-w-[500px]" })}>
<header className={header()}>
<Title children={title} className={titleClassname()} />
<Overlay className={dialogStyles.overlay} />
<Content className={dialogStyles.content}>
<header className={dialogStyles.header}>
<Title children={title} className={dialogStyles.title} />
</header>
<Description className="p-6" children={message} />
<footer className={footer()}>
<footer className={dialogStyles.footer}>
<Cancel asChild>
<Button disabled={loading} variant="tertiary">
{__("Cancel")}

View File

@@ -77,9 +77,9 @@ func ExtractEmailDomain(email string) (string, error) {
return domain, nil
}
func MapSAMLRoleToSystemRole(samlRole string) *coredata.Role {
func MapSAMLRoleToSystemRole(samlRole string) *coredata.MembershipRole {
if samlRole != "" && isValidRole(samlRole) {
role := coredata.Role(samlRole)
role := coredata.MembershipRole(samlRole)
return &role
}
@@ -88,7 +88,7 @@ func MapSAMLRoleToSystemRole(samlRole string) *coredata.Role {
func isValidRole(role string) bool {
switch role {
case "OWNER", "ADMIN", "MEMBER", "VIEWER":
case "OWNER", "ADMIN", "VIEWER":
return true
default:
return false

View File

@@ -407,7 +407,7 @@ func (s *SAMLService) InitiateSAMLLogin(
type SAMLUserInfo struct {
Email string
FullName string
Role *coredata.Role
Role *coredata.MembershipRole
SAMLSubject string
OrganizationID gid.GID
SAMLConfigID gid.GID

View File

@@ -46,6 +46,8 @@ type (
invitationTokenValidity time.Duration
}
ErrCreateOrganizationDisabled struct{}
TenantAuthService struct {
pg *pg.Client
encryptionKey cipher.EncryptionKey
@@ -192,6 +194,10 @@ func (e ErrSAMLAutoSignupDisabled) Error() string {
return "SAML auto-signup is disabled for this organization"
}
func (e ErrCreateOrganizationDisabled) Error() string {
return "organization creation is disabled for users without existing admin or owner membership"
}
func (e ErrSAMLAuthRequired) Error() string {
return "SAML authentication required for this organization"
}
@@ -232,6 +238,28 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthService {
}
}
func (s Service) CanCreateOrganization(ctx context.Context, userID gid.GID) error {
if !s.disableSignup {
return nil
}
var memberships coredata.Memberships
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
return memberships.LoadByUserID(ctx, conn, coredata.NewNoScope(), userID)
})
if err != nil {
return fmt.Errorf("cannot load user memberships: %w", err)
}
for _, membership := range memberships {
if membership.Role == coredata.MembershipRoleOwner || membership.Role == coredata.MembershipRoleAdmin {
return nil
}
}
return &ErrCreateOrganizationDisabled{}
}
func (s Service) ForgetPassword(
ctx context.Context,
email string,
@@ -1351,13 +1379,20 @@ func (s *Service) CreateUserAPIKey(
for _, membership := range memberships {
scope := coredata.NewScope(membership.MembershipID.TenantID())
var m coredata.Membership
if err := m.LoadByID(ctx, tx, scope, membership.MembershipID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
userAPIKeyMembership := &coredata.UserAPIKeyMembership{
ID: gid.New(membership.MembershipID.TenantID(), coredata.UserAPIKeyMembershipEntityType),
UserAPIKeyID: userAPIKey.ID,
MembershipID: membership.MembershipID,
Role: membership.Role,
CreatedAt: now,
UpdatedAt: now,
ID: gid.New(membership.MembershipID.TenantID(), coredata.UserAPIKeyMembershipEntityType),
UserAPIKeyID: userAPIKey.ID,
MembershipID: membership.MembershipID,
Role: membership.Role,
OrganizationID: m.OrganizationID,
CreatedAt: now,
UpdatedAt: now,
}
if err := userAPIKeyMembership.Insert(ctx, tx, scope); err != nil {
@@ -1513,13 +1548,20 @@ func (s *Service) UpdateUserAPIKeyMemberships(
now := time.Now()
for _, membership := range memberships {
scope := coredata.NewScope(membership.MembershipID.TenantID())
var m coredata.Membership
if err := m.LoadByID(ctx, tx, scope, membership.MembershipID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
userAPIKeyMembership := &coredata.UserAPIKeyMembership{
ID: gid.New(membership.MembershipID.TenantID(), coredata.UserAPIKeyMembershipEntityType),
UserAPIKeyID: userAPIKey.ID,
MembershipID: membership.MembershipID,
Role: membership.Role,
CreatedAt: now,
UpdatedAt: now,
ID: gid.New(membership.MembershipID.TenantID(), coredata.UserAPIKeyMembershipEntityType),
UserAPIKeyID: userAPIKey.ID,
MembershipID: membership.MembershipID,
Role: membership.Role,
OrganizationID: m.OrganizationID,
CreatedAt: now,
UpdatedAt: now,
}
if err := userAPIKeyMembership.Insert(ctx, tx, scope); err != nil {

707
pkg/authz/permissions.go Normal file
View File

@@ -0,0 +1,707 @@
// 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 (
"slices"
"go.probo.inc/probo/pkg/coredata"
)
type (
Role string
Action string
)
const (
RoleOwner Role = "OWNER"
RoleAdmin Role = "ADMIN"
RoleViewer Role = "VIEWER"
RoleFull Role = "FULL"
)
const (
ActionGet Action = "get"
ActionGetAssetType Action = "getAssetType"
ActionGetAssignedTo Action = "getAssignedTo"
ActionGetAuthMethod Action = "getAuthMethod"
ActionGetBusinessAssociateAgreement Action = "getBusinessAssociateAgreement"
ActionGetBusinessOwner Action = "getBusinessOwner"
ActionGetCustomDomain Action = "getCustomDomain"
ActionGetDataPrivacyAgreement Action = "getDataPrivacyAgreement"
ActionGetFile Action = "getFile"
ActionGetFileUrl Action = "getFileUrl"
ActionGetFramework Action = "getFramework"
ActionGetHorizontalLogoUrl Action = "getHorizontalLogoUrl"
ActionGetLogoUrl Action = "getLogoUrl"
ActionGetMeasure Action = "getMeasure"
ActionGetNdaFileUrl Action = "getNdaFileUrl"
ActionGetOrganization Action = "getOrganization"
ActionGetOwner Action = "getOwner"
ActionGetSecurityOwner Action = "getSecurityOwner"
ActionGetSnapshot Action = "getSnapshot"
ActionGetTask Action = "getTask"
ActionGetTrustCenter Action = "getTrustCenter"
ActionGetTrustCenterFile Action = "getTrustCenterFile"
ActionGetVendor Action = "getVendor"
ActionActiveCount Action = "activeCount"
ActionAudit Action = "audit"
ActionAvailableDocumentAccesses Action = "availableDocumentAccesses"
ActionDocument Action = "document"
ActionDocumentVersion Action = "documentVersion"
ActionDownloadUrl Action = "downloadUrl"
ActionMemberships Action = "memberships"
ActionPendingRequestCount Action = "pendingRequestCount"
ActionPeoples Action = "peoples"
ActionReport Action = "report"
ActionReportUrl Action = "reportUrl"
ActionSignatures Action = "signatures"
ActionSignedBy Action = "signedBy"
ActionSpMetadataUrl Action = "spMetadataUrl"
ActionTestLoginUrl Action = "testLoginUrl"
ActionTotalCount Action = "totalCount"
ActionTrustCenterFile Action = "trustCenterFile"
ActionListAccesses Action = "listAccesses"
ActionListAssets Action = "listAssets"
ActionListAudits Action = "listAudits"
ActionListComplianceReports Action = "listComplianceReports"
ActionListContacts Action = "listContacts"
ActionListContinualImprovements Action = "listContinualImprovements"
ActionListControls Action = "listControls"
ActionListData Action = "listData"
ActionListDocuments Action = "listDocuments"
ActionListEvidences Action = "listEvidences"
ActionListFrameworks Action = "listFrameworks"
ActionListInvitations Action = "listInvitations"
ActionListMeasures Action = "listMeasures"
ActionListMeetings Action = "listMeetings"
ActionListMembers Action = "listMembers"
ActionListNonconformities Action = "listNonconformities"
ActionListObligations Action = "listObligations"
ActionListPeople Action = "listPeople"
ActionListProcessingActivities Action = "listProcessingActivities"
ActionListReferences Action = "listReferences"
ActionListRiskAssessments Action = "listRiskAssessments"
ActionListRisks Action = "listRisks"
ActionListSAMLConfigurations Action = "listSAMLConfigurations"
ActionListServices Action = "listServices"
ActionListSlackConnections Action = "listSlackConnections"
ActionListSnapshots Action = "listSnapshots"
ActionListTasks Action = "listTasks"
ActionListTrustCenterFiles Action = "listTrustCenterFiles"
ActionListVendors Action = "listVendors"
ActionListVersions Action = "listVersions"
ActionCreateAsset Action = "createAsset"
ActionCreateAudit Action = "createAudit"
ActionCreateContinualImprovement Action = "createContinualImprovement"
ActionCreateControl Action = "createControl"
ActionCreateControlAuditMapping Action = "createControlAuditMapping"
ActionCreateControlDocumentMapping Action = "createControlDocumentMapping"
ActionCreateControlMeasureMapping Action = "createControlMeasureMapping"
ActionCreateControlSnapshotMapping Action = "createControlSnapshotMapping"
ActionCreateCustomDomain Action = "createCustomDomain"
ActionCreateDatum Action = "createDatum"
ActionCreateDocument Action = "createDocument"
ActionCreateDraftDocumentVersion Action = "createDraftDocumentVersion"
ActionCreateFramework Action = "createFramework"
ActionCreateMeasure Action = "createMeasure"
ActionCreateMeeting Action = "createMeeting"
ActionCreateNonconformity Action = "createNonconformity"
ActionCreateObligation Action = "createObligation"
ActionCreatePeople Action = "createPeople"
ActionCreateProcessingActivity Action = "createProcessingActivity"
ActionCreateRisk Action = "createRisk"
ActionCreateRiskDocumentMapping Action = "createRiskDocumentMapping"
ActionCreateRiskMeasureMapping Action = "createRiskMeasureMapping"
ActionCreateRiskObligationMapping Action = "createRiskObligationMapping"
ActionCreateSAMLConfiguration Action = "createSAMLConfiguration"
ActionCreateSnapshot Action = "createSnapshot"
ActionCreateTask Action = "createTask"
ActionCreateTrustCenter Action = "createTrustCenter"
ActionCreateTrustCenterAccess Action = "createTrustCenterAccess"
ActionCreateTrustCenterFile Action = "createTrustCenterFile"
ActionCreateTrustCenterReference Action = "createTrustCenterReference"
ActionCreateVendor Action = "createVendor"
ActionCreateVendorContact Action = "createVendorContact"
ActionCreateVendorRiskAssessment Action = "createVendorRiskAssessment"
ActionCreateVendorService Action = "createVendorService"
ActionUpdateAsset Action = "updateAsset"
ActionUpdateAudit Action = "updateAudit"
ActionUpdateContinualImprovement Action = "updateContinualImprovement"
ActionUpdateControl Action = "updateControl"
ActionUpdateDatum Action = "updateDatum"
ActionUpdateDocument Action = "updateDocument"
ActionUpdateDocumentVersion Action = "updateDocumentVersion"
ActionUpdateFramework Action = "updateFramework"
ActionUpdateMeasure Action = "updateMeasure"
ActionUpdateMeeting Action = "updateMeeting"
ActionUpdateMembership Action = "updateMembership"
ActionUpdateNonconformity Action = "updateNonconformity"
ActionUpdateObligation Action = "updateObligation"
ActionUpdateOrganization Action = "updateOrganization"
ActionUpdatePeople Action = "updatePeople"
ActionUpdateProcessingActivity Action = "updateProcessingActivity"
ActionUpdateRisk Action = "updateRisk"
ActionUpdateSAMLConfiguration Action = "updateSAMLConfiguration"
ActionUpdateTask Action = "updateTask"
ActionUpdateTrustCenter Action = "updateTrustCenter"
ActionUpdateTrustCenterAccess Action = "updateTrustCenterAccess"
ActionUpdateTrustCenterFile Action = "updateTrustCenterFile"
ActionUpdateTrustCenterReference Action = "updateTrustCenterReference"
ActionUpdateVendor Action = "updateVendor"
ActionUpdateVendorBusinessAssociateAgreement Action = "updateVendorBusinessAssociateAgreement"
ActionUpdateVendorContact Action = "updateVendorContact"
ActionUpdateVendorDataPrivacyAgreement Action = "updateVendorDataPrivacyAgreement"
ActionUpdateVendorService Action = "updateVendorService"
ActionDeleteAsset Action = "deleteAsset"
ActionDeleteAudit Action = "deleteAudit"
ActionDeleteAuditReport Action = "deleteAuditReport"
ActionDeleteContinualImprovement Action = "deleteContinualImprovement"
ActionDeleteControl Action = "deleteControl"
ActionDeleteControlAuditMapping Action = "deleteControlAuditMapping"
ActionDeleteControlDocumentMapping Action = "deleteControlDocumentMapping"
ActionDeleteControlMeasureMapping Action = "deleteControlMeasureMapping"
ActionDeleteControlSnapshotMapping Action = "deleteControlSnapshotMapping"
ActionDeleteCustomDomain Action = "deleteCustomDomain"
ActionDeleteDatum Action = "deleteDatum"
ActionDeleteDocument Action = "deleteDocument"
ActionDeleteDraftDocumentVersion Action = "deleteDraftDocumentVersion"
ActionDeleteEvidence Action = "deleteEvidence"
ActionDeleteFramework Action = "deleteFramework"
ActionDeleteInvitation Action = "deleteInvitation"
ActionDeleteMeasure Action = "deleteMeasure"
ActionDeleteMeeting Action = "deleteMeeting"
ActionDeleteNonconformity Action = "deleteNonconformity"
ActionDeleteObligation Action = "deleteObligation"
ActionDeleteOrganization Action = "deleteOrganization"
ActionDeleteOrganizationHorizontalLogo Action = "deleteOrganizationHorizontalLogo"
ActionDeletePeople Action = "deletePeople"
ActionDeleteProcessingActivity Action = "deleteProcessingActivity"
ActionDeleteRisk Action = "deleteRisk"
ActionDeleteRiskDocumentMapping Action = "deleteRiskDocumentMapping"
ActionDeleteRiskMeasureMapping Action = "deleteRiskMeasureMapping"
ActionDeleteRiskObligationMapping Action = "deleteRiskObligationMapping"
ActionDeleteSAMLConfiguration Action = "deleteSAMLConfiguration"
ActionDeleteSnapshot Action = "deleteSnapshot"
ActionDeleteTask Action = "deleteTask"
ActionDeleteTrustCenterAccess Action = "deleteTrustCenterAccess"
ActionDeleteTrustCenterFile Action = "deleteTrustCenterFile"
ActionDeleteTrustCenterNDA Action = "deleteTrustCenterNDA"
ActionDeleteTrustCenterReference Action = "deleteTrustCenterReference"
ActionDeleteVendor Action = "deleteVendor"
ActionDeleteVendorBusinessAssociateAgreement Action = "deleteVendorBusinessAssociateAgreement"
ActionDeleteVendorComplianceReport Action = "deleteVendorComplianceReport"
ActionDeleteVendorContact Action = "deleteVendorContact"
ActionDeleteVendorDataPrivacyAgreement Action = "deleteVendorDataPrivacyAgreement"
ActionDeleteVendorService Action = "deleteVendorService"
ActionAcceptInvitation Action = "acceptInvitation"
ActionAssessVendor Action = "assessVendor"
ActionAssignTask Action = "assignTask"
ActionBulkDeleteDocuments Action = "bulkDeleteDocuments"
ActionBulkExportDocuments Action = "bulkExportDocuments"
ActionBulkPublishDocumentVersions Action = "bulkPublishDocumentVersions"
ActionBulkRequestSignatures Action = "bulkRequestSignatures"
ActionCancelSignatureRequest Action = "cancelSignatureRequest"
ActionConfirmEmail Action = "confirmEmail"
ActionDisableSAML Action = "disableSAML"
ActionEnableSAML Action = "enableSAML"
ActionExportDocumentVersionPDF Action = "exportDocumentVersionPDF"
ActionExportFramework Action = "exportFramework"
ActionGenerateDocumentChangelog Action = "generateDocumentChangelog"
ActionGenerateFrameworkStateOfApplicability Action = "generateFrameworkStateOfApplicability"
ActionImportFramework Action = "importFramework"
ActionImportMeasure Action = "importMeasure"
ActionInitiateDomainVerification Action = "initiateDomainVerification"
ActionInviteUser Action = "inviteUser"
ActionPublishDocumentVersion Action = "publishDocumentVersion"
ActionRemoveMember Action = "removeMember"
ActionRequestSignature Action = "requestSignature"
ActionSendSigningNotifications Action = "sendSigningNotifications"
ActionUnassignTask Action = "unassignTask"
ActionUploadAuditReport Action = "uploadAuditReport"
ActionUploadMeasureEvidence Action = "uploadMeasureEvidence"
ActionUploadTrustCenterNDA Action = "uploadTrustCenterNDA"
ActionUploadVendorBusinessAssociateAgreement Action = "uploadVendorBusinessAssociateAgreement"
ActionUploadVendorComplianceReport Action = "uploadVendorComplianceReport"
ActionUploadVendorDataPrivacyAgreement Action = "uploadVendorDataPrivacyAgreement"
ActionVerifyDomain Action = "verifyDomain"
)
var (
AllRoles = []Role{RoleOwner, RoleAdmin, RoleViewer, RoleFull}
EditRoles = []Role{RoleOwner, RoleAdmin, RoleFull}
)
var Permissions = map[uint16]map[Action][]Role{
coredata.OrganizationEntityType: {
ActionGet: AllRoles,
ActionGetLogoUrl: AllRoles,
ActionGetHorizontalLogoUrl: AllRoles,
ActionMemberships: AllRoles,
ActionPeoples: AllRoles,
ActionTotalCount: AllRoles,
ActionListMembers: AllRoles,
ActionListInvitations: AllRoles,
ActionListSlackConnections: AllRoles,
ActionListFrameworks: AllRoles,
ActionListControls: AllRoles,
ActionListVendors: AllRoles,
ActionListPeople: AllRoles,
ActionListDocuments: AllRoles,
ActionListMeetings: AllRoles,
ActionListMeasures: AllRoles,
ActionListRisks: AllRoles,
ActionListTasks: AllRoles,
ActionListAssets: AllRoles,
ActionListData: AllRoles,
ActionListAudits: AllRoles,
ActionListNonconformities: AllRoles,
ActionListObligations: AllRoles,
ActionListContinualImprovements: AllRoles,
ActionListProcessingActivities: AllRoles,
ActionListSnapshots: AllRoles,
ActionListTrustCenterFiles: AllRoles,
ActionGetTrustCenter: AllRoles,
ActionGetCustomDomain: AllRoles,
ActionListSAMLConfigurations: AllRoles,
ActionConfirmEmail: AllRoles,
ActionAcceptInvitation: AllRoles,
ActionUpdateOrganization: EditRoles,
ActionDeleteOrganizationHorizontalLogo: EditRoles,
ActionCreateTrustCenter: EditRoles,
ActionInviteUser: EditRoles,
ActionDeleteInvitation: EditRoles,
ActionUpdateMembership: EditRoles,
ActionCreatePeople: EditRoles,
ActionCreateVendor: EditRoles,
ActionCreateFramework: EditRoles,
ActionImportFramework: EditRoles,
ActionCreateControl: EditRoles,
ActionCreateMeasure: EditRoles,
ActionImportMeasure: EditRoles,
ActionCreateMeeting: EditRoles,
ActionCreateTask: EditRoles,
ActionCreateRisk: EditRoles,
ActionCreateDocument: EditRoles,
ActionCreateAsset: EditRoles,
ActionCreateDatum: EditRoles,
ActionCreateAudit: EditRoles,
ActionCreateNonconformity: EditRoles,
ActionCreateObligation: EditRoles,
ActionCreateContinualImprovement: EditRoles,
ActionCreateProcessingActivity: EditRoles,
ActionCreateSnapshot: EditRoles,
ActionCreateTrustCenterFile: EditRoles,
ActionSendSigningNotifications: EditRoles,
ActionRemoveMember: {RoleOwner, RoleFull},
ActionCreateCustomDomain: {RoleOwner},
ActionInitiateDomainVerification: {RoleOwner},
ActionVerifyDomain: {RoleOwner},
ActionCreateSAMLConfiguration: {RoleOwner},
ActionDeleteOrganization: {RoleOwner},
},
coredata.TrustCenterEntityType: {
ActionGet: AllRoles,
ActionGetNdaFileUrl: AllRoles,
ActionGetOrganization: AllRoles,
ActionListAccesses: AllRoles,
ActionListReferences: AllRoles,
ActionUpdateTrustCenter: EditRoles,
ActionUploadTrustCenterNDA: EditRoles,
ActionDeleteTrustCenterNDA: EditRoles,
ActionCreateTrustCenterAccess: EditRoles,
ActionCreateTrustCenterReference: EditRoles,
},
coredata.TrustCenterAccessEntityType: {
ActionGet: AllRoles,
ActionActiveCount: AllRoles,
ActionPendingRequestCount: AllRoles,
ActionAvailableDocumentAccesses: AllRoles,
ActionUpdateTrustCenterAccess: EditRoles,
ActionDeleteTrustCenterAccess: EditRoles,
},
coredata.TrustCenterReferenceEntityType: {
ActionGet: AllRoles,
ActionGetLogoUrl: AllRoles,
ActionUpdateTrustCenterReference: EditRoles,
ActionDeleteTrustCenterReference: EditRoles,
},
coredata.TrustCenterFileEntityType: {
ActionGet: AllRoles,
ActionGetFileUrl: AllRoles,
ActionUpdateTrustCenterFile: EditRoles,
ActionGetTrustCenterFile: EditRoles,
ActionDeleteTrustCenterFile: EditRoles,
},
coredata.UserEntityType: {
ActionGet: AllRoles,
},
coredata.MembershipEntityType: {
ActionGet: AllRoles,
ActionGetAuthMethod: AllRoles,
},
coredata.InvitationEntityType: {
ActionGet: AllRoles,
ActionGetOrganization: AllRoles,
},
coredata.PeopleEntityType: {
ActionGet: AllRoles,
ActionUpdatePeople: EditRoles,
ActionDeletePeople: EditRoles,
},
coredata.VendorEntityType: {
ActionGet: AllRoles,
ActionGetOrganization: AllRoles,
ActionListComplianceReports: AllRoles,
ActionGetBusinessAssociateAgreement: AllRoles,
ActionGetDataPrivacyAgreement: AllRoles,
ActionListContacts: AllRoles,
ActionListServices: AllRoles,
ActionListRiskAssessments: AllRoles,
ActionGetBusinessOwner: AllRoles,
ActionGetSecurityOwner: AllRoles,
ActionUpdateVendor: EditRoles,
ActionDeleteVendor: EditRoles,
ActionCreateVendorContact: EditRoles,
ActionCreateVendorService: EditRoles,
ActionUploadVendorComplianceReport: EditRoles,
ActionUploadVendorBusinessAssociateAgreement: EditRoles,
ActionDeleteVendorBusinessAssociateAgreement: EditRoles,
ActionUploadVendorDataPrivacyAgreement: EditRoles,
ActionCreateVendorRiskAssessment: EditRoles,
ActionAssessVendor: EditRoles,
},
coredata.VendorComplianceReportEntityType: {
ActionGet: AllRoles,
ActionGetVendor: AllRoles,
ActionGetFile: AllRoles,
ActionDeleteVendorComplianceReport: EditRoles,
},
coredata.VendorBusinessAssociateAgreementEntityType: {
ActionGet: AllRoles,
ActionGetVendor: AllRoles,
ActionGetFileUrl: AllRoles,
ActionUpdateVendorBusinessAssociateAgreement: EditRoles,
ActionDeleteVendorBusinessAssociateAgreement: EditRoles,
},
coredata.VendorContactEntityType: {
ActionGet: AllRoles,
ActionGetVendor: AllRoles,
ActionUpdateVendorContact: EditRoles,
ActionDeleteVendorContact: EditRoles,
},
coredata.VendorServiceEntityType: {
ActionGet: AllRoles,
ActionGetVendor: AllRoles,
ActionUpdateVendorService: EditRoles,
ActionDeleteVendorService: EditRoles,
},
coredata.VendorDataPrivacyAgreementEntityType: {
ActionGet: AllRoles,
ActionGetVendor: AllRoles,
ActionGetFileUrl: AllRoles,
ActionUpdateVendorDataPrivacyAgreement: EditRoles,
ActionDeleteVendorDataPrivacyAgreement: EditRoles,
},
coredata.VendorRiskAssessmentEntityType: {
ActionGet: AllRoles,
},
coredata.FrameworkEntityType: {
ActionGet: AllRoles,
ActionGetOrganization: AllRoles,
ActionListControls: AllRoles,
ActionCreateControl: EditRoles,
ActionUpdateFramework: EditRoles,
ActionDeleteFramework: EditRoles,
ActionGenerateFrameworkStateOfApplicability: EditRoles,
ActionExportFramework: EditRoles,
},
coredata.ControlEntityType: {
ActionGet: AllRoles,
ActionGetFramework: AllRoles,
ActionListMeasures: AllRoles,
ActionListDocuments: AllRoles,
ActionListAudits: AllRoles,
ActionListSnapshots: AllRoles,
ActionUpdateControl: EditRoles,
ActionDeleteControl: EditRoles,
ActionCreateControlMeasureMapping: EditRoles,
ActionCreateControlDocumentMapping: EditRoles,
ActionDeleteControlMeasureMapping: EditRoles,
ActionDeleteControlDocumentMapping: EditRoles,
ActionCreateControlAuditMapping: EditRoles,
ActionDeleteControlAuditMapping: EditRoles,
ActionCreateControlSnapshotMapping: EditRoles,
ActionDeleteControlSnapshotMapping: EditRoles,
},
coredata.MeasureEntityType: {
ActionGet: AllRoles,
ActionListEvidences: AllRoles,
ActionListTasks: AllRoles,
ActionListRisks: AllRoles,
ActionListControls: AllRoles,
ActionTotalCount: AllRoles,
ActionUpdateMeasure: EditRoles,
ActionDeleteMeasure: EditRoles,
ActionUploadMeasureEvidence: EditRoles,
},
coredata.TaskEntityType: {
ActionGet: AllRoles,
ActionGetAssignedTo: AllRoles,
ActionGetOrganization: AllRoles,
ActionGetMeasure: AllRoles,
ActionListEvidences: AllRoles,
ActionUpdateTask: EditRoles,
ActionDeleteTask: EditRoles,
ActionAssignTask: EditRoles,
ActionUnassignTask: EditRoles,
},
coredata.EvidenceEntityType: {
ActionGet: AllRoles,
ActionGetFile: AllRoles,
ActionGetTask: AllRoles,
ActionGetMeasure: AllRoles,
ActionDeleteEvidence: EditRoles,
},
coredata.DocumentEntityType: {
ActionGet: AllRoles,
ActionExportDocumentVersionPDF: AllRoles,
ActionGetOwner: AllRoles,
ActionGetOrganization: AllRoles,
ActionListVersions: AllRoles,
ActionListControls: AllRoles,
ActionTotalCount: AllRoles,
ActionUpdateDocument: EditRoles,
ActionDeleteDocument: EditRoles,
ActionPublishDocumentVersion: EditRoles,
ActionBulkPublishDocumentVersions: EditRoles,
ActionBulkDeleteDocuments: EditRoles,
ActionBulkExportDocuments: EditRoles,
ActionGenerateDocumentChangelog: EditRoles,
ActionCreateDraftDocumentVersion: EditRoles,
ActionDeleteDraftDocumentVersion: EditRoles,
ActionUpdateDocumentVersion: EditRoles,
ActionRequestSignature: EditRoles,
ActionBulkRequestSignatures: EditRoles,
ActionSendSigningNotifications: EditRoles,
ActionCancelSignatureRequest: EditRoles,
},
coredata.DocumentVersionEntityType: {
ActionGet: AllRoles,
ActionGetFile: AllRoles,
ActionGetOwner: AllRoles,
ActionDocument: AllRoles,
ActionSignatures: AllRoles,
ActionUpdateDocumentVersion: EditRoles,
},
coredata.DocumentVersionSignatureEntityType: {
ActionGet: AllRoles,
ActionDocumentVersion: AllRoles,
ActionSignedBy: AllRoles,
},
coredata.RiskEntityType: {
ActionGet: AllRoles,
ActionGetOwner: AllRoles,
ActionGetOrganization: AllRoles,
ActionTotalCount: AllRoles,
ActionListControls: AllRoles,
ActionListMeasures: AllRoles,
ActionListDocuments: AllRoles,
ActionListObligations: AllRoles,
ActionUpdateRisk: EditRoles,
ActionDeleteRisk: EditRoles,
ActionCreateRiskMeasureMapping: EditRoles,
ActionDeleteRiskMeasureMapping: EditRoles,
ActionCreateRiskDocumentMapping: EditRoles,
ActionDeleteRiskDocumentMapping: EditRoles,
ActionCreateRiskObligationMapping: EditRoles,
ActionDeleteRiskObligationMapping: EditRoles,
},
coredata.AssetEntityType: {
ActionGet: AllRoles,
ActionGetOwner: AllRoles,
ActionListVendors: AllRoles,
ActionGetAssetType: AllRoles,
ActionGetOrganization: AllRoles,
ActionUpdateAsset: EditRoles,
ActionDeleteAsset: EditRoles,
},
coredata.DatumEntityType: {
ActionGet: AllRoles,
ActionGetOwner: AllRoles,
ActionGetOrganization: AllRoles,
ActionListVendors: AllRoles,
ActionUpdateDatum: EditRoles,
ActionDeleteDatum: EditRoles,
},
coredata.AuditEntityType: {
ActionGet: AllRoles,
ActionGetFile: AllRoles,
ActionGetFramework: AllRoles,
ActionGetOrganization: AllRoles,
ActionReport: AllRoles,
ActionReportUrl: AllRoles,
ActionListControls: AllRoles,
ActionUpdateAudit: EditRoles,
ActionDeleteAudit: EditRoles,
ActionUploadAuditReport: EditRoles,
ActionDeleteAuditReport: EditRoles,
},
coredata.ReportEntityType: {
ActionGet: AllRoles,
ActionGetFile: AllRoles,
ActionGetOrganization: AllRoles,
ActionGetSnapshot: AllRoles,
ActionDownloadUrl: AllRoles,
},
coredata.NonconformityEntityType: {
ActionGet: AllRoles,
ActionGetOwner: AllRoles,
ActionGetOrganization: AllRoles,
ActionAudit: AllRoles,
ActionUpdateNonconformity: EditRoles,
ActionDeleteNonconformity: EditRoles,
},
coredata.ObligationEntityType: {
ActionGet: AllRoles,
ActionGetOrganization: AllRoles,
ActionGetOwner: AllRoles,
ActionListRisks: AllRoles,
ActionUpdateObligation: EditRoles,
ActionDeleteObligation: EditRoles,
},
coredata.ContinualImprovementEntityType: {
ActionGet: AllRoles,
ActionGetOwner: AllRoles,
ActionGetOrganization: AllRoles,
ActionUpdateContinualImprovement: EditRoles,
ActionDeleteContinualImprovement: EditRoles,
},
coredata.ProcessingActivityEntityType: {
ActionGet: AllRoles,
ActionGetOrganization: AllRoles,
ActionListVendors: AllRoles,
ActionUpdateProcessingActivity: EditRoles,
ActionDeleteProcessingActivity: EditRoles,
},
coredata.SnapshotEntityType: {
ActionGet: AllRoles,
ActionGetOrganization: AllRoles,
ActionListControls: AllRoles,
ActionDeleteSnapshot: EditRoles,
},
coredata.CustomDomainEntityType: {
ActionGet: {RoleOwner, RoleAdmin},
ActionDeleteCustomDomain: {RoleOwner},
},
coredata.SAMLConfigurationEntityType: {
ActionGet: {RoleOwner, RoleAdmin},
ActionSpMetadataUrl: {RoleOwner, RoleAdmin},
ActionTestLoginUrl: {RoleOwner, RoleAdmin},
ActionUpdateSAMLConfiguration: {RoleOwner, RoleAdmin},
ActionDeleteSAMLConfiguration: {RoleOwner, RoleAdmin},
ActionEnableSAML: {RoleOwner, RoleAdmin},
ActionDisableSAML: {RoleOwner, RoleAdmin},
},
coredata.FileEntityType: {
ActionGet: AllRoles,
ActionDownloadUrl: AllRoles,
},
coredata.TrustCenterDocumentAccessEntityType: {
ActionGet: AllRoles,
ActionReport: AllRoles,
ActionTrustCenterFile: AllRoles,
},
coredata.MeetingEntityType: {
ActionGet: AllRoles,
ActionGetOrganization: AllRoles,
ActionTotalCount: AllRoles,
ActionUpdateMeeting: EditRoles,
ActionDeleteMeeting: EditRoles,
},
}
func GetPermissionsForAction(entityType uint16, action Action) []Role {
if entityActions, ok := Permissions[entityType]; ok {
if roles, ok := entityActions[action]; ok {
return roles
}
}
return nil
}
func GetPermissionsByRole(userRole Role) map[string]map[Action]bool {
permissions := make(map[string]map[Action]bool)
for entityType, actions := range Permissions {
entityTypeName, ok := coredata.EntityModel(entityType)
if !ok {
continue
}
if permissions[entityTypeName] == nil {
permissions[entityTypeName] = make(map[Action]bool)
}
for action, allowedRoles := range actions {
if slices.Contains(allowedRoles, userRole) {
permissions[entityTypeName][action] = true
}
}
}
return permissions
}

View File

@@ -19,6 +19,7 @@ import (
"errors"
"fmt"
"net/url"
"slices"
"time"
"go.gearno.de/kit/pg"
@@ -37,6 +38,14 @@ func (e *TenantAccessError) Error() string {
return "not authorized"
}
type PermissionDeniedError struct {
Message string
}
func (e *PermissionDeniedError) Error() string {
return e.Message
}
type (
Service struct {
pg *pg.Client
@@ -103,6 +112,27 @@ func (s *Service) GetAllUserOrganizations(
return organizations, err
}
func (s *Service) GetUserOrganizationsWithRole(
ctx context.Context,
userID gid.GID,
role coredata.MembershipRole,
) (coredata.Organizations, error) {
organizations := coredata.Organizations{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := organizations.LoadAllByUserIDWithRole(ctx, conn, userID, role); err != nil {
return fmt.Errorf("cannot load user organizations with role: %w", err)
}
return nil
},
)
return organizations, err
}
func (s *Service) GetAllOrganizationsForUserAPIKeyId(
ctx context.Context,
userAPIKeyID gid.GID,
@@ -143,70 +173,6 @@ func (s *Service) GetUserOrganizations(
return organizations, err
}
func (s *Service) AcceptInvitation(
ctx context.Context,
token string,
userID gid.GID,
) error {
payload, err := statelesstoken.ValidateToken[coredata.InvitationData](
s.tokenSecret,
TokenTypeOrganizationInvitation,
token,
)
if err != nil {
return fmt.Errorf("invalid invitation token: %w", err)
}
invitationData := payload.Data
scope := coredata.NewScope(invitationData.InvitationID.TenantID())
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, tx, scope, invitationData.InvitationID); err != nil {
var errInvitationNotFound *coredata.ErrInvitationNotFound
if errors.As(err, &errInvitationNotFound) {
return fmt.Errorf("invitation was deleted or no longer exists")
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.AcceptedAt != nil {
return fmt.Errorf("invitation already accepted")
}
if time.Now().After(invitation.ExpiresAt) {
return fmt.Errorf("invitation expired")
}
now := time.Now()
membershipID := gid.New(scope.GetTenantID(), coredata.MembershipEntityType)
membership := &coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: invitation.OrganizationID,
Role: invitation.Role,
CreatedAt: now,
UpdatedAt: now,
}
if err := membership.Create(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot add user to organization: %w", err)
}
invitation.AcceptedAt = &now
if err := invitation.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot mark invitation as accepted: %w", err)
}
return nil
},
)
}
func (s *Service) AcceptInvitationByID(
ctx context.Context,
invitationID gid.GID,
@@ -274,36 +240,11 @@ func (s *Service) AcceptInvitationByID(
return acceptedInvitation, nil
}
func (s *Service) GetUserInvitations(
ctx context.Context,
email string,
cursor *page.Cursor[coredata.InvitationOrderField],
filter *coredata.InvitationFilter,
) (*page.Page[*coredata.Invitation, coredata.InvitationOrderField], error) {
var invitations coredata.Invitations
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := invitations.LoadByEmail(ctx, conn, coredata.NewNoScope(), email, cursor, filter); err != nil {
return fmt.Errorf("cannot load invitations: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(invitations, cursor), nil
}
type UserInvitation struct {
ID gid.GID
Email string
FullName string
Role coredata.Role
Role coredata.MembershipRole
ExpiresAt time.Time
AcceptedAt *time.Time
CreatedAt time.Time
@@ -417,7 +358,7 @@ func (s *TenantAuthzService) AddUserToOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
role coredata.Role,
role coredata.MembershipRole,
) error {
now := time.Now()
membershipID := gid.New(s.scope.GetTenantID(), coredata.MembershipEntityType)
@@ -538,6 +479,30 @@ func (s *TenantAuthzService) DeleteInvitation(
)
}
func (s *TenantAuthzService) GetMembershipByUserAndOrganizationID(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
) (*coredata.Membership, error) {
membership := &coredata.Membership{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.LoadByUserAndOrg(ctx, conn, s.scope, userID, orgID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return membership, nil
}
func (s *TenantAuthzService) GetMembershipsByOrganizationID(
ctx context.Context,
orgID gid.GID,
@@ -609,41 +574,11 @@ func (s *TenantAuthzService) CountOrganizationUsers(
return count, nil
}
func (s *TenantAuthzService) 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, s.scope, userID, orgID); err != nil {
if _, ok := err.(coredata.ErrMembershipNotFound); ok {
return nil // Not an error, just no access
}
return fmt.Errorf("cannot check organization access: %w", err)
}
haveAccess = true
return nil
},
)
if err != nil {
return false, err
}
return haveAccess, nil
}
func (s *TenantAuthzService) GetUserRoleInOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
) (coredata.Role, error) {
) (coredata.MembershipRole, error) {
membership := &coredata.Membership{}
err := s.pg.WithConn(
@@ -690,30 +625,55 @@ func (s *TenantAuthzService) RemoveMemberFromOrganization(
)
}
func (s *TenantAuthzService) UpdateUserRole(
func (s *TenantAuthzService) UpdateMembershipRole(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
newRole coredata.Role,
) error {
return s.pg.WithTx(
memberID gid.GID,
newRole coredata.MembershipRole,
) (*coredata.Membership, error) {
membership := &coredata.Membership{}
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
membership := &coredata.Membership{}
if err := membership.LoadByUserAndOrg(ctx, tx, s.scope, userID, orgID); err != nil {
return fmt.Errorf("cannot find membership: %w", err)
if err := membership.LoadByID(ctx, tx, s.scope, memberID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
if membership.OrganizationID != orgID {
return fmt.Errorf("membership does not belong to organization")
}
// If the new role cannot create API keys, delete all related API key memberships
if newRole != coredata.MembershipRoleOwner {
var apiKeyMemberships coredata.UserAPIKeyMemberships
if err := apiKeyMemberships.LoadByMembershipID(ctx, tx, s.scope, memberID); err != nil {
return fmt.Errorf("cannot load api key memberships: %w", err)
}
for _, apiKeyMembership := range apiKeyMemberships {
if err := apiKeyMembership.Delete(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot delete api key membership: %w", err)
}
}
}
membership.Role = newRole
membership.UpdatedAt = time.Now()
if err := membership.Update(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot update user role: %w", err)
return fmt.Errorf("cannot update membership role: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return membership, nil
}
func (s *TenantAuthzService) InviteUserToOrganization(
@@ -721,7 +681,7 @@ func (s *TenantAuthzService) InviteUserToOrganization(
organizationID gid.GID,
emailAddress string,
fullName string,
role coredata.Role,
role coredata.MembershipRole,
) (*coredata.Invitation, error) {
var invitation *coredata.Invitation
@@ -827,7 +787,7 @@ func (s *TenantAuthzService) EnsureSAMLMembership(
ctx context.Context,
userID gid.GID,
organizationID gid.GID,
role *coredata.Role,
role *coredata.MembershipRole,
) error {
now := time.Now()
@@ -842,7 +802,7 @@ func (s *TenantAuthzService) EnsureSAMLMembership(
return fmt.Errorf("cannot load membership: %w", err)
}
membershipRole := coredata.RoleMember
membershipRole := coredata.MembershipRoleViewer
if role != nil {
membershipRole = *role
}
@@ -878,15 +838,94 @@ func (s *TenantAuthzService) EnsureSAMLMembership(
)
}
// This is a placeholder for future permission system
func (s *TenantAuthzService) HasPermission(
func (s *TenantAuthzService) Authorize(
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)
user *coredata.User,
apiKey *coredata.UserAPIKey,
entityGID gid.GID,
action Action,
) error {
requiredRoles := GetPermissionsForAction(entityGID.EntityType(), action)
if requiredRoles == nil {
return &PermissionDeniedError{
Message: fmt.Sprintf("no permissions defined for action %s on entity type %d", action, entityGID.EntityType()),
}
}
role, err := s.GetUserOrAPIKeyRole(ctx, user, apiKey, entityGID)
if err != nil {
return fmt.Errorf("cannot get user or API key role: %w", err)
}
if !slices.Contains(requiredRoles, role) {
return &PermissionDeniedError{
Message: fmt.Sprintf("role %s not authorized for action %s, requires one of %v", role, action, requiredRoles),
}
}
return nil
}
func (s *TenantAuthzService) CanAssignRole(
ctx context.Context,
user *coredata.User,
apiKey *coredata.UserAPIKey,
entityGID gid.GID,
targetRole coredata.MembershipRole,
) error {
currentRole, err := s.GetUserOrAPIKeyRole(ctx, user, apiKey, entityGID)
if err != nil {
return fmt.Errorf("cannot get user or API key role: %w", err)
}
if currentRole == RoleOwner || currentRole == RoleFull {
return nil
}
if currentRole == RoleAdmin {
if targetRole == coredata.MembershipRoleOwner {
return &PermissionDeniedError{Message: "admin users cannot assign owner role"}
}
return nil
}
return &PermissionDeniedError{Message: fmt.Sprintf("role %s cannot assign roles", currentRole)}
}
func (s *TenantAuthzService) GetUserOrAPIKeyRole(
ctx context.Context,
user *coredata.User,
apiKey *coredata.UserAPIKey,
entityGID gid.GID,
) (Role, error) {
var role Role
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if user != nil {
membership := &coredata.Membership{}
if err := membership.LoadRoleByUserAndEntityID(ctx, conn, s.scope, user.ID, entityGID); err != nil {
return fmt.Errorf("cannot get user role: %w", err)
}
role = Role(membership.Role.String())
return nil
}
if apiKey != nil {
apiKeyMembership := &coredata.UserAPIKeyMembership{}
if err := apiKeyMembership.LoadRoleByAPIKeyAndEntityID(ctx, conn, s.scope, apiKey.ID, entityGID); err != nil {
return fmt.Errorf("cannot get API key role: %w", err)
}
role = Role(apiKeyMembership.Role.String())
return nil
}
return fmt.Errorf("no user or API key provided")
},
)
if err != nil {
return "", err
}
return role, nil
}

View File

@@ -47,13 +47,14 @@ func (a *UserAPIKeyMembership) Insert(
) error {
q := `
INSERT INTO
authz_api_keys_memberships (id, tenant_id, auth_user_api_key_id, membership_id, role, created_at, updated_at)
authz_api_keys_memberships (id, tenant_id, auth_user_api_key_id, membership_id, role, organization_id, created_at, updated_at)
VALUES (
@id,
@tenant_id,
@auth_user_api_key_id,
@membership_id,
@role,
@organization_id,
@created_at,
@updated_at
)
@@ -65,6 +66,7 @@ VALUES (
"auth_user_api_key_id": a.UserAPIKeyID,
"membership_id": a.MembershipID,
"role": a.Role,
"organization_id": a.OrganizationID,
"created_at": a.CreatedAt,
"updated_at": a.UpdatedAt,
}
@@ -127,6 +129,133 @@ ORDER BY akm.created_at DESC
return nil
}
// LoadRoleByAPIKeyAndEntityID loads an API key's role by querying any entity to extract its organization_id
func (a *UserAPIKeyMembership) LoadRoleByAPIKeyAndEntityID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
apiKeyID gid.GID,
entityID gid.GID,
) error {
entityType := entityID.EntityType()
// For organization, the entity ID is the organization ID
if entityType == OrganizationEntityType {
return a.LoadByAPIKeyIDAndOrganizationID(ctx, conn, scope, apiKeyID, entityID)
}
tableName, ok := EntityTable(entityType)
if !ok {
return fmt.Errorf("unsupported entity type for API key role lookup: %d", entityType)
}
query := fmt.Sprintf(`
SELECT
akm.id,
akm.auth_user_api_key_id,
akm.membership_id,
akm.role,
akm.created_at,
akm.updated_at
FROM
authz_api_keys_memberships akm
INNER JOIN authz_memberships m ON m.id = akm.membership_id
INNER JOIN %s e ON e.id = @entity_id
WHERE
%s
AND akm.auth_user_api_key_id = @api_key_id
AND m.organization_id = e.organization_id
LIMIT 1;
`, tableName, scope.SQLFragment())
args := pgx.NamedArgs{
"api_key_id": apiKeyID,
"entity_id": entityID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot query API key membership by entity: %w", err)
}
defer rows.Close()
if !rows.Next() {
return fmt.Errorf("API key membership not found for key %s and entity %s", apiKeyID, entityID)
}
var membership UserAPIKeyMembership
err = rows.Scan(
&membership.ID,
&membership.UserAPIKeyID,
&membership.MembershipID,
&membership.Role,
&membership.CreatedAt,
&membership.UpdatedAt,
)
if err != nil {
return fmt.Errorf("cannot scan API key membership: %w", err)
}
*a = membership
return nil
}
func (a *UserAPIKeyMembership) LoadByAPIKeyIDAndOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
apiKeyID gid.GID,
organizationID gid.GID,
) error {
q := `
SELECT
akm.id,
akm.auth_user_api_key_id,
akm.membership_id,
akm.role,
akm.created_at,
akm.updated_at,
m.organization_id,
o.name as organization_name
FROM
authz_api_keys_memberships akm
JOIN
authz_memberships m ON akm.membership_id = m.id
JOIN
organizations o ON m.organization_id = o.id
WHERE
akm.auth_user_api_key_id = @api_key_id
AND m.organization_id = @organization_id
AND m.%s
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"api_key_id": apiKeyID,
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query user api key membership: %w", err)
}
membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[UserAPIKeyMembership])
if err != nil {
if err == pgx.ErrNoRows {
return fmt.Errorf("API key does not have access to organization")
}
return fmt.Errorf("cannot collect user api key membership: %w", err)
}
*a = membership
return nil
}
func (a *UserAPIKeyMembership) Delete(
ctx context.Context,
conn pg.Conn,
@@ -155,6 +284,56 @@ WHERE
return nil
}
func (a *UserAPIKeyMemberships) LoadByMembershipID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
membershipID gid.GID,
) error {
q := `
SELECT
akm.id,
akm.auth_user_api_key_id,
akm.membership_id,
akm.role,
akm.created_at,
akm.updated_at,
m.organization_id,
o.name as organization_name
FROM
authz_api_keys_memberships akm
JOIN
authz_memberships m ON akm.membership_id = m.id
JOIN
organizations o ON m.organization_id = o.id
WHERE
akm.membership_id = @membership_id
AND m.%s
ORDER BY akm.created_at DESC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"membership_id": membershipID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query user api key memberships by membership id: %w", err)
}
memberships, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[UserAPIKeyMembership])
if err != nil {
return fmt.Errorf("cannot collect user api key memberships: %w", err)
}
*a = memberships
return nil
}
func DeleteAllUserAPIKeyMembershipsByUserAPIKeyID(
ctx context.Context,
conn pg.Conn,

View File

@@ -46,6 +46,7 @@ func (av AssetVendors) Merge(
conn pg.Conn,
scope Scoper,
assetID gid.GID,
organizationID gid.GID,
vendorIDs []gid.GID,
) error {
q := `
@@ -54,6 +55,7 @@ WITH vendor_ids AS (
unnest(@vendor_ids::text[]) AS vendor_id,
@tenant_id AS tenant_id,
@asset_id AS asset_id,
@organization_id AS organization_id,
@created_at::timestamptz AS created_at
)
MERGE INTO asset_vendors AS tgt
@@ -62,18 +64,19 @@ ON tgt.tenant_id = src.tenant_id
AND tgt.asset_id = src.asset_id
AND tgt.vendor_id = src.vendor_id
WHEN NOT MATCHED
THEN INSERT (tenant_id, asset_id, vendor_id, created_at)
VALUES (src.tenant_id, src.asset_id, src.vendor_id, src.created_at)
THEN INSERT (tenant_id, asset_id, vendor_id, organization_id, created_at)
VALUES (src.tenant_id, src.asset_id, src.vendor_id, src.organization_id, src.created_at)
WHEN NOT MATCHED BY SOURCE
AND tgt.tenant_id = @tenant_id AND tgt.asset_id = @asset_id
THEN DELETE
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"asset_id": assetID,
"created_at": time.Now(),
"vendor_ids": vendorIDs,
"tenant_id": scope.GetTenantID(),
"asset_id": assetID,
"organization_id": organizationID,
"created_at": time.Now(),
"vendor_ids": vendorIDs,
}
_, err := conn.Exec(ctx, q, args)
@@ -89,26 +92,29 @@ func (av AssetVendors) Insert(
conn pg.Conn,
scope Scoper,
assetID gid.GID,
organizationID gid.GID,
vendorIDs []gid.GID,
) error {
q := `
WITH vendor_ids AS (
SELECT unnest(@vendor_ids::text[]) AS vendor_id
)
INSERT INTO asset_vendors (tenant_id, asset_id, vendor_id, created_at)
INSERT INTO asset_vendors (tenant_id, asset_id, vendor_id, organization_id, created_at)
SELECT
@tenant_id AS tenant_id,
@asset_id AS asset_id,
vendor_id,
@organization_id AS organization_id,
@created_at AS created_at
FROM vendor_ids
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"asset_id": assetID,
"created_at": time.Now(),
"vendor_ids": vendorIDs,
"tenant_id": scope.GetTenantID(),
"asset_id": assetID,
"organization_id": organizationID,
"created_at": time.Now(),
"vendor_ids": vendorIDs,
}
_, err := conn.Exec(ctx, q, args)

View File

@@ -31,6 +31,7 @@ import (
type (
Control struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
SectionTitle string `db:"section_title"`
FrameworkID gid.GID `db:"framework_id"`
Name string `db:"name"`
@@ -127,6 +128,7 @@ WITH ctrl AS (
c.id,
c.section_title,
c.framework_id,
c.organization_id,
c.tenant_id,
c.name,
c.description,
@@ -146,6 +148,7 @@ SELECT
id,
section_title,
framework_id,
organization_id,
name,
description,
status,
@@ -236,6 +239,7 @@ WITH ctrl AS (
c.id,
c.section_title,
c.framework_id,
c.organization_id,
c.tenant_id,
c.name,
c.description,
@@ -255,6 +259,7 @@ SELECT
id,
section_title,
framework_id,
organization_id,
name,
description,
status,
@@ -351,6 +356,7 @@ WITH ctrl AS (
c.id,
c.section_title,
c.framework_id,
c.organization_id,
c.tenant_id,
c.name,
c.description,
@@ -376,6 +382,7 @@ SELECT
id,
section_title,
framework_id,
organization_id,
name,
description,
status,
@@ -455,6 +462,7 @@ SELECT
id,
section_title,
framework_id,
organization_id,
name,
description,
status,
@@ -548,6 +556,7 @@ WITH ctrl AS (
c.id,
c.section_title,
c.framework_id,
c.organization_id,
c.tenant_id,
c.name,
c.description,
@@ -567,6 +576,7 @@ SELECT
id,
section_title,
framework_id,
organization_id,
name,
description,
status,
@@ -613,6 +623,7 @@ SELECT
id,
section_title,
framework_id,
organization_id,
name,
description,
status,
@@ -661,6 +672,7 @@ SELECT
id,
section_title,
framework_id,
organization_id,
name,
description,
status,
@@ -707,6 +719,7 @@ INSERT INTO
controls (
tenant_id,
id,
organization_id,
framework_id,
section_title,
name,
@@ -719,6 +732,7 @@ INSERT INTO
VALUES (
@tenant_id,
@control_id,
@organization_id,
@framework_id,
@section_title,
@name,
@@ -733,6 +747,7 @@ VALUES (
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"control_id": c.ID,
"organization_id": c.OrganizationID,
"framework_id": c.FrameworkID,
"section_title": c.SectionTitle,
"name": c.Name,
@@ -884,6 +899,7 @@ WITH ctrl AS (
c.id,
c.section_title,
c.framework_id,
c.organization_id,
c.tenant_id,
c.name,
c.description,
@@ -903,6 +919,7 @@ SELECT
id,
section_title,
framework_id,
organization_id,
name,
description,
status,
@@ -994,6 +1011,7 @@ WITH ctrl AS (
c.id,
c.section_title,
c.framework_id,
c.organization_id,
c.tenant_id,
c.name,
c.description,
@@ -1013,6 +1031,7 @@ SELECT
id,
section_title,
framework_id,
organization_id,
name,
description,
status,

View File

@@ -27,9 +27,10 @@ import (
type (
ControlAudit struct {
ControlID gid.GID `db:"control_id"`
AuditID gid.GID `db:"audit_id"`
CreatedAt time.Time `db:"created_at"`
ControlID gid.GID `db:"control_id"`
AuditID gid.GID `db:"audit_id"`
OrganizationID gid.GID `db:"organization_id"`
CreatedAt time.Time `db:"created_at"`
}
ControlAudits []*ControlAudit
@@ -45,12 +46,14 @@ INSERT INTO
controls_audits (
control_id,
audit_id,
organization_id,
tenant_id,
created_at
)
VALUES (
@control_id,
@audit_id,
@organization_id,
@tenant_id,
@created_at
)
@@ -58,10 +61,11 @@ ON CONFLICT (control_id, audit_id) DO NOTHING;
`
args := pgx.StrictNamedArgs{
"control_id": ca.ControlID,
"audit_id": ca.AuditID,
"tenant_id": scope.GetTenantID(),
"created_at": ca.CreatedAt,
"control_id": ca.ControlID,
"audit_id": ca.AuditID,
"organization_id": ca.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": ca.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err

View File

@@ -29,10 +29,11 @@ import (
type (
ControlDocument struct {
ControlID gid.GID `db:"control_id"`
DocumentID gid.GID `db:"document_id"`
TenantID gid.TenantID `db:"tenant_id"`
CreatedAt time.Time `db:"created_at"`
ControlID gid.GID `db:"control_id"`
DocumentID gid.GID `db:"document_id"`
OrganizationID gid.GID `db:"organization_id"`
TenantID gid.TenantID `db:"tenant_id"`
CreatedAt time.Time `db:"created_at"`
}
ControlDocuments []*ControlDocument
@@ -57,22 +58,25 @@ INSERT INTO
controls_documents (
control_id,
document_id,
organization_id,
tenant_id,
created_at
)
VALUES (
@control_id,
@document_id,
@organization_id,
@tenant_id,
@created_at
);
`
args := pgx.StrictNamedArgs{
"control_id": cp.ControlID,
"document_id": cp.DocumentID,
"tenant_id": scope.GetTenantID(),
"created_at": cp.CreatedAt,
"control_id": cp.ControlID,
"document_id": cp.DocumentID,
"organization_id": cp.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": cp.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)

View File

@@ -20,17 +20,18 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
ControlMeasure struct {
ControlID gid.GID `db:"control_id"`
MeasureID gid.GID `db:"measure_id"`
TenantID gid.TenantID `db:"tenant_id"`
CreatedAt time.Time `db:"created_at"`
ControlID gid.GID `db:"control_id"`
MeasureID gid.GID `db:"measure_id"`
OrganizationID gid.GID `db:"organization_id"`
TenantID gid.TenantID `db:"tenant_id"`
CreatedAt time.Time `db:"created_at"`
}
ControlMeasures []*ControlMeasure
@@ -46,12 +47,14 @@ INSERT INTO
controls_measures (
control_id,
measure_id,
organization_id,
tenant_id,
created_at
)
VALUES (
@control_id,
@measure_id,
@organization_id,
@tenant_id,
@created_at
)
@@ -59,10 +62,11 @@ ON CONFLICT (control_id, measure_id) DO NOTHING;
`
args := pgx.StrictNamedArgs{
"control_id": cm.ControlID,
"measure_id": cm.MeasureID,
"tenant_id": scope.GetTenantID(),
"created_at": cm.CreatedAt,
"control_id": cm.ControlID,
"measure_id": cm.MeasureID,
"organization_id": cm.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": cm.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err

View File

@@ -27,9 +27,10 @@ import (
type (
ControlSnapshot struct {
ControlID gid.GID `db:"control_id"`
SnapshotID gid.GID `db:"snapshot_id"`
CreatedAt time.Time `db:"created_at"`
ControlID gid.GID `db:"control_id"`
SnapshotID gid.GID `db:"snapshot_id"`
OrganizationID gid.GID `db:"organization_id"`
CreatedAt time.Time `db:"created_at"`
}
ControlSnapshots []*ControlSnapshot
@@ -45,12 +46,14 @@ INSERT INTO
controls_snapshots (
control_id,
snapshot_id,
organization_id,
tenant_id,
created_at
)
VALUES (
@control_id,
@snapshot_id,
@organization_id,
@tenant_id,
@created_at
)
@@ -58,10 +61,11 @@ ON CONFLICT (control_id, snapshot_id) DO NOTHING;
`
args := pgx.StrictNamedArgs{
"control_id": cs.ControlID,
"snapshot_id": cs.SnapshotID,
"tenant_id": scope.GetTenantID(),
"created_at": cs.CreatedAt,
"control_id": cs.ControlID,
"snapshot_id": cs.SnapshotID,
"organization_id": cs.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": cs.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err

View File

@@ -22,17 +22,18 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/crypto/cipher"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
CustomDomain struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Domain string `db:"domain"`
HTTPChallengeToken *string `db:"http_challenge_token"`
HTTPChallengeKeyAuth *string `db:"http_challenge_key_auth"`
@@ -159,6 +160,7 @@ func (cd *CustomDomain) LoadByID(
q := `
SELECT
id,
organization_id,
domain,
http_challenge_token,
http_challenge_key_auth,
@@ -211,6 +213,7 @@ func (cd *CustomDomain) LoadByIDForUpdate(
q := `
SELECT
id,
organization_id,
domain,
http_challenge_token,
http_challenge_key_auth,
@@ -263,6 +266,7 @@ func (cd *CustomDomain) LoadByDomain(
q := `
SELECT
id,
organization_id,
domain,
http_challenge_token,
http_challenge_key_auth,
@@ -320,6 +324,7 @@ func (cd *CustomDomain) Insert(
INSERT INTO custom_domains (
id,
tenant_id,
organization_id,
domain,
http_challenge_token,
http_challenge_key_auth,
@@ -337,6 +342,7 @@ INSERT INTO custom_domains (
) VALUES (
@id,
@tenant_id,
@organization_id,
@domain,
@http_challenge_token,
@http_challenge_key_auth,
@@ -357,6 +363,7 @@ INSERT INTO custom_domains (
args := pgx.NamedArgs{
"id": cd.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": cd.OrganizationID,
"domain": cd.Domain,
"http_challenge_token": cd.HTTPChallengeToken,
"http_challenge_key_auth": cd.HTTPChallengeKeyAuth,
@@ -487,6 +494,7 @@ func (cd *CustomDomain) LoadByHTTPChallengeToken(
q := `
SELECT
id,
organization_id,
domain,
http_challenge_token,
http_challenge_key_auth,
@@ -537,6 +545,7 @@ func (domains *CustomDomains) ListDomainsForRenewal(
q := `
SELECT
id,
organization_id,
domain,
http_challenge_token,
http_challenge_key_auth,
@@ -589,6 +598,7 @@ func (domains *CustomDomains) ListDomainsWithPendingHTTPChallenges(
q := `
SELECT
id,
organization_id,
domain,
http_challenge_token,
http_challenge_key_auth,
@@ -644,6 +654,7 @@ func (domains *CustomDomains) LoadActiveCertificates(
q := `
SELECT
id,
organization_id,
domain,
http_challenge_token,
http_challenge_key_auth,
@@ -693,6 +704,7 @@ func (domains *CustomDomains) ListStaleProvisioningDomains(
q := `
SELECT
id,
organization_id,
domain,
http_challenge_token,
http_challenge_key_auth,

View File

@@ -41,6 +41,7 @@ func (dv DatumVendors) Merge(
conn pg.Conn,
scope Scoper,
datumID gid.GID,
organizationID gid.GID,
vendorIDs []gid.GID,
) error {
q := `
@@ -49,6 +50,7 @@ WITH vendor_ids AS (
unnest(@vendor_ids::text[]) AS vendor_id,
@tenant_id AS tenant_id,
@datum_id AS datum_id,
@organization_id AS organization_id,
@created_at::timestamptz AS created_at
)
MERGE INTO data_vendors AS tgt
@@ -57,18 +59,19 @@ ON tgt.tenant_id = src.tenant_id
AND tgt.datum_id = src.datum_id
AND tgt.vendor_id = src.vendor_id
WHEN NOT MATCHED THEN
INSERT (tenant_id, datum_id, vendor_id, created_at)
VALUES (src.tenant_id, src.datum_id, src.vendor_id, src.created_at)
INSERT (tenant_id, datum_id, vendor_id, organization_id, created_at)
VALUES (src.tenant_id, src.datum_id, src.vendor_id, src.organization_id, src.created_at)
WHEN NOT MATCHED BY SOURCE
AND tgt.tenant_id = @tenant_id AND tgt.datum_id = @datum_id
THEN DELETE
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"datum_id": datumID,
"created_at": time.Now(),
"vendor_ids": vendorIDs,
"tenant_id": scope.GetTenantID(),
"datum_id": datumID,
"organization_id": organizationID,
"created_at": time.Now(),
"vendor_ids": vendorIDs,
}
_, err := conn.Exec(ctx, q, args)
@@ -84,26 +87,29 @@ func (dv DatumVendors) Insert(
conn pg.Conn,
scope Scoper,
datumID gid.GID,
organizationID gid.GID,
vendorIDs []gid.GID,
) error {
q := `
WITH vendor_ids AS (
SELECT unnest(@vendor_ids::text[]) AS vendor_id
)
INSERT INTO data_vendors (tenant_id, datum_id, vendor_id, created_at)
INSERT INTO data_vendors (tenant_id, datum_id, vendor_id, organization_id, created_at)
SELECT
@tenant_id::text AS tenant_id,
@datum_id::text AS datum_id,
vendor_id,
@organization_id::text AS organization_id,
@created_at::timestamptz AS created_at
FROM vendor_ids
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"datum_id": datumID,
"created_at": time.Now(),
"vendor_ids": vendorIDs,
"tenant_id": scope.GetTenantID(),
"datum_id": datumID,
"organization_id": organizationID,
"created_at": time.Now(),
"vendor_ids": vendorIDs,
}
_, err := conn.Exec(ctx, q, args)

View File

@@ -21,16 +21,17 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
DocumentVersion struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
DocumentID gid.GID `db:"document_id"`
Title string `db:"title"`
OwnerID gid.GID `db:"owner_id"`
@@ -81,6 +82,7 @@ func (p *DocumentVersions) LoadByDocumentID(
q := `
SELECT
id,
organization_id,
document_id,
title,
owner_id,
@@ -140,6 +142,7 @@ func (p *DocumentVersion) LoadByID(
q := `
SELECT
id,
organization_id,
document_id,
title,
owner_id,
@@ -190,6 +193,7 @@ func (p DocumentVersion) Insert(
INSERT INTO document_versions (
tenant_id,
id,
organization_id,
document_id,
title,
owner_id,
@@ -204,6 +208,7 @@ INSERT INTO document_versions (
VALUES (
@tenant_id,
@id,
@organization_id,
@document_id,
@title,
@owner_id,
@@ -217,18 +222,19 @@ VALUES (
)
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"id": p.ID,
"document_id": p.DocumentID,
"title": p.Title,
"owner_id": p.OwnerID,
"version_number": p.VersionNumber,
"classification": p.Classification,
"content": p.Content,
"changelog": p.Changelog,
"status": p.Status,
"created_at": p.CreatedAt,
"updated_at": p.UpdatedAt,
"tenant_id": scope.GetTenantID(),
"id": p.ID,
"organization_id": p.OrganizationID,
"document_id": p.DocumentID,
"title": p.Title,
"owner_id": p.OwnerID,
"version_number": p.VersionNumber,
"classification": p.Classification,
"content": p.Content,
"changelog": p.Changelog,
"status": p.Status,
"created_at": p.CreatedAt,
"updated_at": p.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
@@ -264,6 +270,7 @@ func (p *DocumentVersion) LoadByDocumentIDAndVersionNumber(
q := `
SELECT
id,
organization_id,
document_id,
title,
owner_id,
@@ -316,6 +323,7 @@ func (p *DocumentVersion) LoadLatestVersion(
q := `
SELECT
id,
organization_id,
document_id,
title,
owner_id,
@@ -366,6 +374,7 @@ func (p *DocumentVersion) LoadLatestPublishedVersion(
q := `
SELECT
id,
organization_id,
document_id,
title,
owner_id,

View File

@@ -21,16 +21,17 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
DocumentVersionSignature struct {
ID gid.GID `json:"id"`
OrganizationID gid.GID `json:"-"`
DocumentVersionID gid.GID `json:"document_version_id"`
State DocumentVersionSignatureState `json:"state"`
SignedBy gid.GID `json:"signed_by"`
@@ -87,6 +88,7 @@ func (pvs *DocumentVersionSignature) LoadByDocumentVersionIDAndSignatory(
q := `
SELECT
id,
organization_id,
document_version_id,
state,
signed_by,
@@ -132,6 +134,7 @@ func (pvs *DocumentVersionSignature) LoadByID(
q := `
SELECT
id,
organization_id,
document_version_id,
state,
signed_by,
@@ -175,6 +178,7 @@ func (pvs DocumentVersionSignature) Insert(
INSERT INTO document_version_signatures (
id,
tenant_id,
organization_id,
document_version_id,
state,
signed_by,
@@ -185,6 +189,7 @@ INSERT INTO document_version_signatures (
) VALUES (
@id,
@tenant_id,
@organization_id,
@document_version_id,
@state,
@signed_by,
@@ -198,6 +203,7 @@ INSERT INTO document_version_signatures (
args := pgx.StrictNamedArgs{
"id": pvs.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": pvs.OrganizationID,
"document_version_id": pvs.DocumentVersionID,
"state": pvs.State,
"signed_by": pvs.SignedBy,
@@ -234,6 +240,7 @@ func (pvss *DocumentVersionSignatures) LoadByDocumentVersionID(
q := `
SELECT
id,
organization_id,
document_version_id,
state,
signed_by,
@@ -346,6 +353,7 @@ func (pvss *DocumentVersionSignaturesWithPeople) LoadByDocumentVersionIDWithPeop
WITH sigs AS (
SELECT
dvs.id,
dvs.organization_id,
dvs.tenant_id,
dvs.document_version_id,
dvs.state,
@@ -367,6 +375,7 @@ WITH sigs AS (
)
SELECT
id,
organization_id,
document_version_id,
state,
signed_by,

View File

@@ -20,9 +20,9 @@ import (
"fmt"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (

View File

@@ -68,3 +68,211 @@ const (
UserAPIKeyMembershipEntityType uint16 = 44
MeetingEntityType uint16 = 45
)
type EntityInfo struct {
Model string
Table string
}
var entityRegistry = map[uint16]EntityInfo{
OrganizationEntityType: {
Model: "Organization",
Table: "organizations",
},
FrameworkEntityType: {
Model: "Framework",
Table: "frameworks",
},
MeasureEntityType: {
Model: "Measure",
Table: "measures",
},
TaskEntityType: {
Model: "Task",
Table: "tasks",
},
EvidenceEntityType: {
Model: "Evidence",
Table: "evidences",
},
ConnectorEntityType: {
Model: "Connector",
Table: "connectors",
},
VendorRiskAssessmentEntityType: {
Model: "VendorRiskAssessment",
Table: "vendor_risk_assessments",
},
VendorEntityType: {
Model: "Vendor",
Table: "vendors",
},
PeopleEntityType: {
Model: "People",
Table: "peoples",
},
VendorComplianceReportEntityType: {
Model: "VendorComplianceReport",
Table: "vendor_compliance_reports",
},
DocumentEntityType: {
Model: "Document",
Table: "documents",
},
UserEntityType: {
Model: "User",
Table: "auth_users",
},
SessionEntityType: {
Model: "Session",
Table: "auth_sessions",
},
EmailEntityType: {
Model: "Email",
Table: "auth_emails",
},
ControlEntityType: {
Model: "Control",
Table: "controls",
},
RiskEntityType: {
Model: "Risk",
Table: "risks",
},
DocumentVersionEntityType: {
Model: "DocumentVersion",
Table: "document_versions",
},
DocumentVersionSignatureEntityType: {
Model: "DocumentVersionSignature",
Table: "document_version_signatures",
},
AssetEntityType: {
Model: "Asset",
Table: "assets",
},
DatumEntityType: {
Model: "Datum",
Table: "data",
},
AuditEntityType: {
Model: "Audit",
Table: "audits",
},
ReportEntityType: {
Model: "Report",
Table: "reports",
},
TrustCenterEntityType: {
Model: "TrustCenter",
Table: "trust_centers",
},
TrustCenterAccessEntityType: {
Model: "TrustCenterAccess",
Table: "trust_center_accesses",
},
VendorBusinessAssociateAgreementEntityType: {
Model: "VendorBusinessAssociateAgreement",
Table: "vendor_business_associate_agreements",
},
FileEntityType: {
Model: "File",
Table: "files",
},
VendorContactEntityType: {
Model: "VendorContact",
Table: "vendor_contacts",
},
VendorDataPrivacyAgreementEntityType: {
Model: "VendorDataPrivacyAgreement",
Table: "vendor_data_privacy_agreements",
},
NonconformityEntityType: {
Model: "Nonconformity",
Table: "nonconformities",
},
ObligationEntityType: {
Model: "Obligation",
Table: "obligations",
},
VendorServiceEntityType: {
Model: "VendorService",
Table: "vendor_services",
},
SnapshotEntityType: {
Model: "Snapshot",
Table: "snapshots",
},
ContinualImprovementEntityType: {
Model: "ContinualImprovement",
Table: "continual_improvements",
},
ProcessingActivityEntityType: {
Model: "ProcessingActivity",
Table: "processing_activities",
},
ExportJobEntityType: {
Model: "ExportJob",
Table: "export_jobs",
},
TrustCenterReferenceEntityType: {
Model: "TrustCenterReference",
Table: "trust_center_references",
},
TrustCenterDocumentAccessEntityType: {
Model: "",
Table: "trust_center_document_accesses",
},
CustomDomainEntityType: {
Model: "CustomDomain",
Table: "custom_domains",
},
InvitationEntityType: {
Model: "Invitation",
Table: "authz_invitations",
},
MembershipEntityType: {
Model: "Membership",
Table: "authz_memberships",
},
SlackMessageEntityType: {
Model: "SlackMessage",
Table: "slack_messages",
},
TrustCenterFileEntityType: {
Model: "TrustCenterFile",
Table: "trust_center_files",
},
SAMLConfigurationEntityType: {
Model: "SAMLConfiguration",
Table: "auth_saml_configurations",
},
UserAPIKeyEntityType: {
Model: "UserAPIKey",
Table: "auth_user_api_keys",
},
UserAPIKeyMembershipEntityType: {
Model: "UserAPIKeyMembership",
Table: "authz_api_keys_memberships",
},
MeetingEntityType: {
Model: "Meeting",
Table: "meetings",
},
}
func EntityTable(entityType uint16) (string, bool) {
info, ok := entityRegistry[entityType]
if !ok {
return "", false
}
return info.Table, true
}
func EntityModel(entityType uint16) (string, bool) {
info, ok := entityRegistry[entityType]
if !ok {
return "", false
}
return info.Model, true
}

View File

@@ -31,6 +31,7 @@ import (
type (
Evidence struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
MeasureID gid.GID `db:"measure_id"`
TaskID *gid.GID `db:"task_id"`
State EvidenceState `db:"state"`
@@ -141,6 +142,7 @@ INSERT INTO
evidences (
tenant_id,
id,
organization_id,
measure_id,
task_id,
reference_id,
@@ -155,6 +157,7 @@ INSERT INTO
VALUES (
@tenant_id,
@evidence_id,
@organization_id,
@measure_id,
@task_id,
@reference_id,
@@ -171,6 +174,7 @@ VALUES (
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"evidence_id": e.ID,
"organization_id": e.OrganizationID,
"measure_id": e.MeasureID,
"task_id": e.TaskID,
"reference_id": e.ReferenceID,
@@ -208,6 +212,7 @@ func (e *Evidence) LoadByID(
q := `
SELECT
id,
organization_id,
task_id,
measure_id,
reference_id,
@@ -288,6 +293,7 @@ func (e *Evidences) LoadByMeasureID(
q := `
SELECT
id,
organization_id,
measure_id,
task_id,
reference_id,
@@ -369,6 +375,7 @@ func (e *Evidences) LoadByTaskID(
q := `
SELECT
id,
organization_id,
measure_id,
task_id,
reference_id,

View File

@@ -8,14 +8,15 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
ExportJob struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
Type ExportJobType `db:"type"`
Arguments json.RawMessage `db:"arguments"`
Error *string `db:"error"`
@@ -54,6 +55,7 @@ func (ej *ExportJob) Insert(
q := `
INSERT INTO export_jobs (
id,
organization_id,
tenant_id,
type,
arguments,
@@ -63,6 +65,7 @@ INSERT INTO export_jobs (
created_at
) VALUES (
@id,
@organization_id,
@tenant_id,
@type,
@arguments,
@@ -73,6 +76,7 @@ INSERT INTO export_jobs (
)`
args := pgx.StrictNamedArgs{
"id": ej.ID,
"organization_id": ej.OrganizationID,
"tenant_id": scope.GetTenantID(),
"type": ej.Type,
"arguments": ej.Arguments,
@@ -126,6 +130,7 @@ func (ej *ExportJob) LoadByID(
q := `
SELECT
id,
organization_id,
type,
arguments,
error,
@@ -167,6 +172,7 @@ func (ej *ExportJob) LoadNextPendingForUpdateSkipLocked(
q := `
SELECT
id,
organization_id,
type,
arguments,
error,

View File

@@ -21,23 +21,24 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
File struct {
ID gid.GID `db:"id"`
BucketName string `db:"bucket_name"`
MimeType string `db:"mime_type"`
FileName string `db:"file_name"`
FileKey string `db:"file_key"`
FileSize int64 `db:"file_size"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
DeletedAt *time.Time `db:"deleted_at"`
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
BucketName string `db:"bucket_name"`
MimeType string `db:"mime_type"`
FileName string `db:"file_name"`
FileKey string `db:"file_key"`
FileSize int64 `db:"file_size"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
DeletedAt *time.Time `db:"deleted_at"`
}
Files []*File
@@ -68,6 +69,7 @@ func (f *File) LoadByID(
q := `
SELECT
id,
organization_id,
bucket_name,
mime_type,
file_name,
@@ -119,6 +121,7 @@ INSERT INTO
files (
id,
tenant_id,
organization_id,
bucket_name,
mime_type,
file_name,
@@ -131,6 +134,7 @@ INSERT INTO
VALUES (
@file_id,
@tenant_id,
@organization_id,
@bucket_name,
@mime_type,
@file_name,
@@ -143,16 +147,17 @@ VALUES (
`
args := pgx.StrictNamedArgs{
"file_id": f.ID,
"tenant_id": scope.GetTenantID(),
"bucket_name": f.BucketName,
"mime_type": f.MimeType,
"file_name": f.FileName,
"file_key": f.FileKey,
"file_size": f.FileSize,
"created_at": f.CreatedAt,
"updated_at": f.UpdatedAt,
"deleted_at": f.DeletedAt,
"file_id": f.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": f.OrganizationID,
"bucket_name": f.BucketName,
"mime_type": f.MimeType,
"file_name": f.FileName,
"file_key": f.FileKey,
"file_size": f.FileSize,
"created_at": f.CreatedAt,
"updated_at": f.UpdatedAt,
"deleted_at": f.DeletedAt,
}
_, err := conn.Exec(ctx, q, args)

View File

@@ -21,10 +21,10 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
@@ -33,7 +33,7 @@ type (
OrganizationID gid.GID `db:"organization_id"`
Email string `db:"email"`
FullName string `db:"full_name"`
Role Role `db:"role"`
Role MembershipRole `db:"role"`
Status InvitationStatus `db:"status"`
ExpiresAt time.Time `db:"expires_at"`
AcceptedAt *time.Time `db:"accepted_at"`
@@ -43,11 +43,11 @@ type (
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 Role `json:"role"`
InvitationID gid.GID `json:"invitation_id"`
OrganizationID gid.GID `json:"organization_id"`
Email string `json:"email"`
FullName string `json:"full_name"`
Role MembershipRole `json:"role"`
}
ErrInvitationNotFound struct {

View File

@@ -19,20 +19,19 @@ import (
"fmt"
)
type Role string
type MembershipRole string
const (
RoleOwner Role = "OWNER"
RoleAdmin Role = "ADMIN"
RoleMember Role = "MEMBER"
RoleViewer Role = "VIEWER"
MembershipRoleOwner MembershipRole = "OWNER"
MembershipRoleAdmin MembershipRole = "ADMIN"
MembershipRoleViewer MembershipRole = "VIEWER"
)
func (r Role) String() string {
func (r MembershipRole) String() string {
return string(r)
}
func (r *Role) Scan(value any) error {
func (r *MembershipRole) Scan(value any) error {
var s string
switch v := value.(type) {
case string:
@@ -40,24 +39,22 @@ func (r *Role) Scan(value any) error {
case []byte:
s = string(v)
default:
return fmt.Errorf("unsupported type for Role: %T", value)
return fmt.Errorf("unsupported type for MembershipRole: %T", value)
}
switch s {
case "OWNER":
*r = RoleOwner
*r = MembershipRoleOwner
case "ADMIN":
*r = RoleAdmin
case "MEMBER":
*r = RoleMember
*r = MembershipRoleAdmin
case "VIEWER":
*r = RoleViewer
*r = MembershipRoleViewer
default:
return fmt.Errorf("invalid Role value: %q", s)
return fmt.Errorf("invalid MembershipRole value: %q", s)
}
return nil
}
func (r Role) Value() (driver.Value, error) {
func (r MembershipRole) Value() (driver.Value, error) {
return r.String(), nil
}

View File

@@ -19,25 +19,26 @@ import (
"errors"
"fmt"
"maps"
"strings"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
Membership struct {
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
OrganizationID gid.GID `db:"organization_id"`
Role Role `db:"role"`
FullName string `db:"full_name"`
EmailAddress string `db:"email_address"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
UserID gid.GID `db:"user_id"`
OrganizationID gid.GID `db:"organization_id"`
Role MembershipRole `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
@@ -185,6 +186,83 @@ JOIN
return nil
}
// LoadRoleByUserAndEntityID loads a user's role by querying any entity to extract its organization_id
func (m *Membership) LoadRoleByUserAndEntityID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
userID gid.GID,
entityID gid.GID,
) error {
entityType := entityID.EntityType()
// For organization, the entity ID is the organization ID
if entityType == OrganizationEntityType {
return m.LoadByUserAndOrg(ctx, conn, scope, userID, entityID)
}
tableName, ok := EntityTable(entityType)
if !ok {
return fmt.Errorf("unsupported entity type for role lookup: %d", entityType)
}
// Build scope fragment with table alias to avoid ambiguity
scopeFragment := scope.SQLFragment()
// Replace column references with table-qualified versions
scopeFragment = strings.ReplaceAll(scopeFragment, "tenant_id =", "m.tenant_id =")
query := fmt.Sprintf(`
SELECT
m.id,
m.user_id,
m.organization_id,
m.role,
m.created_at,
m.updated_at
FROM
authz_memberships m
INNER JOIN %s e ON e.id = @entity_id
WHERE
%s
AND m.user_id = @user_id
AND m.organization_id = e.organization_id
LIMIT 1;
`, tableName, scopeFragment)
args := pgx.NamedArgs{
"user_id": userID,
"entity_id": entityID,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot query membership by entity: %w", err)
}
defer rows.Close()
if !rows.Next() {
return &ErrMembershipNotFound{UserID: userID, OrgID: entityID}
}
var membership Membership
err = rows.Scan(
&membership.ID,
&membership.UserID,
&membership.OrganizationID,
&membership.Role,
&membership.CreatedAt,
&membership.UpdatedAt,
)
if err != nil {
return fmt.Errorf("cannot scan membership: %w", err)
}
*m = membership
return nil
}
func (m *Membership) LoadByUserAndOrg(
ctx context.Context,
conn pg.Conn,
@@ -195,17 +273,17 @@ func (m *Membership) LoadByUserAndOrg(
query := `
WITH mbr AS (
SELECT
id,
user_id,
organization_id,
role,
created_at,
updated_at
am.id,
am.user_id,
am.organization_id,
am.role,
am.created_at,
am.updated_at
FROM
authz_memberships
authz_memberships am
WHERE
user_id = @user_id
AND organization_id = @organization_id
am.user_id = @user_id
AND am.organization_id = @organization_id
AND %s
)
SELECT
@@ -223,7 +301,12 @@ JOIN
users u ON mbr.user_id = u.id
`
query = fmt.Sprintf(query, scope.SQLFragment())
// Build scope fragment with table alias
scopeFragment := scope.SQLFragment()
// Replace column references with table-qualified versions
scopeFragment = strings.ReplaceAll(scopeFragment, "tenant_id =", "am.tenant_id =")
query = fmt.Sprintf(query, scopeFragment)
args := pgx.StrictNamedArgs{
"user_id": userID,
@@ -468,67 +551,3 @@ WHERE
}
return count, nil
}
func LoadUserIDsByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) ([]gid.GID, error) {
query := `
SELECT user_id
FROM authz_memberships
WHERE organization_id = @organization_id AND %s
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, query, args)
if err != nil {
return nil, fmt.Errorf("cannot query memberships: %w", err)
}
var userIDs []gid.GID
for rows.Next() {
var userID gid.GID
if err := rows.Scan(&userID); err != nil {
rows.Close()
return nil, fmt.Errorf("cannot scan user_id: %w", err)
}
userIDs = append(userIDs, userID)
}
rows.Close()
return userIDs, nil
}
func UpdateMembershipUserID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
oldUserID gid.GID,
newUserID gid.GID,
organizationID gid.GID,
) error {
query := `
UPDATE authz_memberships
SET user_id = @new_user_id, updated_at = @updated_at
WHERE user_id = @old_user_id AND organization_id = @organization_id AND %s
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"new_user_id": newUserID,
"old_user_id": oldUserID,
"organization_id": organizationID,
"updated_at": time.Now(),
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot update membership: %w", err)
}
return nil
}

View File

@@ -0,0 +1,210 @@
-- Set all existing memberships to OWNER role
UPDATE authz_memberships SET role = 'OWNER';
ALTER TABLE controls ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE controls
SET organization_id = organizations.id
FROM organizations
WHERE controls.tenant_id = organizations.tenant_id
AND controls.organization_id IS NULL;
ALTER TABLE controls ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE evidences ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE evidences
SET organization_id = organizations.id
FROM organizations
WHERE evidences.tenant_id = organizations.tenant_id
AND evidences.organization_id IS NULL;
ALTER TABLE evidences ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE files ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE files
SET organization_id = organizations.id
FROM organizations
WHERE files.tenant_id = organizations.tenant_id
AND files.organization_id IS NULL;
ALTER TABLE files ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE document_versions
SET organization_id = organizations.id
FROM organizations
WHERE document_versions.tenant_id = organizations.tenant_id
AND document_versions.organization_id IS NULL;
ALTER TABLE document_versions ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE document_version_signatures ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE document_version_signatures
SET organization_id = organizations.id
FROM organizations
WHERE document_version_signatures.tenant_id = organizations.tenant_id
AND document_version_signatures.organization_id IS NULL;
ALTER TABLE document_version_signatures ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE trust_center_references ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE trust_center_references
SET organization_id = organizations.id
FROM organizations
WHERE trust_center_references.tenant_id = organizations.tenant_id
AND trust_center_references.organization_id IS NULL;
ALTER TABLE trust_center_references ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE trust_center_accesses ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE trust_center_accesses
SET organization_id = organizations.id
FROM organizations
WHERE trust_center_accesses.tenant_id = organizations.tenant_id
AND trust_center_accesses.organization_id IS NULL;
ALTER TABLE trust_center_accesses ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE trust_center_document_accesses ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE trust_center_document_accesses
SET organization_id = organizations.id
FROM organizations
WHERE trust_center_document_accesses.tenant_id = organizations.tenant_id
AND trust_center_document_accesses.organization_id IS NULL;
ALTER TABLE trust_center_document_accesses ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE vendor_services ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE vendor_services
SET organization_id = organizations.id
FROM organizations
WHERE vendor_services.tenant_id = organizations.tenant_id
AND vendor_services.organization_id IS NULL;
ALTER TABLE vendor_services ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE vendor_contacts ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE vendor_contacts
SET organization_id = organizations.id
FROM organizations
WHERE vendor_contacts.tenant_id = organizations.tenant_id
AND vendor_contacts.organization_id IS NULL;
ALTER TABLE vendor_contacts ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE vendor_risk_assessments ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE vendor_risk_assessments
SET organization_id = organizations.id
FROM organizations
WHERE vendor_risk_assessments.tenant_id = organizations.tenant_id
AND vendor_risk_assessments.organization_id IS NULL;
ALTER TABLE vendor_risk_assessments ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE reports ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE reports
SET organization_id = organizations.id
FROM organizations
WHERE reports.tenant_id = organizations.tenant_id
AND reports.organization_id IS NULL;
ALTER TABLE reports ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE custom_domains ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE custom_domains
SET organization_id = organizations.id
FROM organizations
WHERE custom_domains.tenant_id = organizations.tenant_id
AND custom_domains.organization_id IS NULL;
ALTER TABLE custom_domains ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE asset_vendors ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE asset_vendors
SET organization_id = organizations.id
FROM organizations
WHERE asset_vendors.tenant_id = organizations.tenant_id
AND asset_vendors.organization_id IS NULL;
ALTER TABLE asset_vendors ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE authz_api_keys_memberships ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE authz_api_keys_memberships
SET organization_id = organizations.id
FROM organizations
WHERE authz_api_keys_memberships.tenant_id = organizations.tenant_id
AND authz_api_keys_memberships.organization_id IS NULL;
ALTER TABLE authz_api_keys_memberships ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE controls_audits ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE controls_audits
SET organization_id = organizations.id
FROM organizations
WHERE controls_audits.tenant_id = organizations.tenant_id
AND controls_audits.organization_id IS NULL;
ALTER TABLE controls_audits ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE controls_documents ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE controls_documents
SET organization_id = organizations.id
FROM organizations
WHERE controls_documents.tenant_id = organizations.tenant_id
AND controls_documents.organization_id IS NULL;
ALTER TABLE controls_documents ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE controls_measures ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE controls_measures
SET organization_id = organizations.id
FROM organizations
WHERE controls_measures.tenant_id = organizations.tenant_id
AND controls_measures.organization_id IS NULL;
ALTER TABLE controls_measures ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE controls_snapshots ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE controls_snapshots
SET organization_id = organizations.id
FROM organizations
WHERE controls_snapshots.tenant_id = organizations.tenant_id
AND controls_snapshots.organization_id IS NULL;
ALTER TABLE controls_snapshots ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE data_vendors ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE data_vendors
SET organization_id = organizations.id
FROM organizations
WHERE data_vendors.tenant_id = organizations.tenant_id
AND data_vendors.organization_id IS NULL;
ALTER TABLE data_vendors ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE export_jobs ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE export_jobs
SET organization_id = organizations.id
FROM organizations
WHERE export_jobs.tenant_id = organizations.tenant_id
AND export_jobs.organization_id IS NULL;
ALTER TABLE export_jobs ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE processing_activity_vendors ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE processing_activity_vendors
SET organization_id = organizations.id
FROM organizations
WHERE processing_activity_vendors.tenant_id = organizations.tenant_id
AND processing_activity_vendors.organization_id IS NULL;
ALTER TABLE processing_activity_vendors ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE risks_documents ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE risks_documents
SET organization_id = organizations.id
FROM organizations
WHERE risks_documents.tenant_id = organizations.tenant_id
AND risks_documents.organization_id IS NULL;
ALTER TABLE risks_documents ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE risks_measures ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE risks_measures
SET organization_id = organizations.id
FROM organizations
WHERE risks_measures.tenant_id = organizations.tenant_id
AND risks_measures.organization_id IS NULL;
ALTER TABLE risks_measures ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE risks_obligations ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE risks_obligations
SET organization_id = organizations.id
FROM organizations
WHERE risks_obligations.tenant_id = organizations.tenant_id
AND risks_obligations.organization_id IS NULL;
ALTER TABLE risks_obligations ALTER COLUMN organization_id SET NOT NULL;
ALTER TABLE vendor_compliance_reports ADD COLUMN IF NOT EXISTS organization_id TEXT;
UPDATE vendor_compliance_reports
SET organization_id = organizations.id
FROM organizations
WHERE vendor_compliance_reports.tenant_id = organizations.tenant_id
AND vendor_compliance_reports.organization_id IS NULL;
ALTER TABLE vendor_compliance_reports ALTER COLUMN organization_id SET NOT NULL;

View File

@@ -237,6 +237,63 @@ ORDER BY
return nil
}
func (o *Organizations) LoadAllByUserIDWithRole(
ctx context.Context,
conn pg.Conn,
userID gid.GID,
role MembershipRole,
) error {
q := `
WITH user_org AS (
SELECT
organization_id
FROM
authz_memberships
WHERE
user_id = @user_id
AND role = @role
)
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
name ASC
`
args := pgx.StrictNamedArgs{
"user_id": userID,
"role": role,
}
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 *Organizations) LoadAllByUserAPIKeyID(
ctx context.Context,
conn pg.Conn,

View File

@@ -20,21 +20,22 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
Report struct {
ID gid.GID `db:"id"`
ObjectKey string `db:"object_key"`
MimeType string `db:"mime_type"`
Filename string `db:"filename"`
Size int64 `db:"size"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
ObjectKey string `db:"object_key"`
MimeType string `db:"mime_type"`
Filename string `db:"filename"`
Size int64 `db:"size"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
Reports []*Report
@@ -49,6 +50,7 @@ func (r *Report) LoadByID(
q := `
SELECT
id,
organization_id,
object_key,
mime_type,
filename,
@@ -92,6 +94,7 @@ func (r *Report) Insert(
INSERT INTO reports (
id,
tenant_id,
organization_id,
object_key,
mime_type,
filename,
@@ -101,6 +104,7 @@ INSERT INTO reports (
) VALUES (
@id,
@tenant_id,
@organization_id,
@object_key,
@mime_type,
@filename,
@@ -111,14 +115,15 @@ INSERT INTO reports (
`
args := pgx.StrictNamedArgs{
"id": r.ID,
"tenant_id": scope.GetTenantID(),
"object_key": r.ObjectKey,
"mime_type": r.MimeType,
"filename": r.Filename,
"size": r.Size,
"created_at": r.CreatedAt,
"updated_at": r.UpdatedAt,
"id": r.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": r.OrganizationID,
"object_key": r.ObjectKey,
"mime_type": r.MimeType,
"filename": r.Filename,
"size": r.Size,
"created_at": r.CreatedAt,
"updated_at": r.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)

View File

@@ -27,10 +27,11 @@ import (
type (
RiskDocument struct {
RiskID gid.GID `db:"risk_id"`
DocumentID gid.GID `db:"document_id"`
TenantID gid.TenantID `db:"tenant_id"`
CreatedAt time.Time `db:"created_at"`
RiskID gid.GID `db:"risk_id"`
DocumentID gid.GID `db:"document_id"`
OrganizationID gid.GID `db:"organization_id"`
TenantID gid.TenantID `db:"tenant_id"`
CreatedAt time.Time `db:"created_at"`
}
RiskDocuments []*RiskDocument
@@ -46,22 +47,25 @@ INSERT INTO
risks_documents (
risk_id,
document_id,
organization_id,
tenant_id,
created_at
)
VALUES (
@risk_id,
@document_id,
@organization_id,
@tenant_id,
@created_at
);
`
args := pgx.StrictNamedArgs{
"risk_id": rp.RiskID,
"document_id": rp.DocumentID,
"tenant_id": scope.GetTenantID(),
"created_at": rp.CreatedAt,
"risk_id": rp.RiskID,
"document_id": rp.DocumentID,
"organization_id": rp.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": rp.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err

View File

@@ -20,17 +20,18 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (
RiskMeasure struct {
RiskID gid.GID `db:"risk_id"`
MeasureID gid.GID `db:"measure_id"`
TenantID gid.TenantID `db:"tenant_id"`
CreatedAt time.Time `db:"created_at"`
RiskID gid.GID `db:"risk_id"`
MeasureID gid.GID `db:"measure_id"`
OrganizationID gid.GID `db:"organization_id"`
TenantID gid.TenantID `db:"tenant_id"`
CreatedAt time.Time `db:"created_at"`
}
RiskMeasures []*RiskMeasure
@@ -46,22 +47,25 @@ INSERT INTO
risks_measures (
risk_id,
measure_id,
organization_id,
tenant_id,
created_at
)
VALUES (
@risk_id,
@measure_id,
@organization_id,
@tenant_id,
@created_at
);
`
args := pgx.StrictNamedArgs{
"risk_id": rm.RiskID,
"measure_id": rm.MeasureID,
"tenant_id": scope.GetTenantID(),
"created_at": rm.CreatedAt,
"risk_id": rm.RiskID,
"measure_id": rm.MeasureID,
"organization_id": rm.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": rm.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err

View File

@@ -27,9 +27,10 @@ import (
type (
RiskObligation struct {
RiskID gid.GID `db:"risk_id"`
ObligationID gid.GID `db:"obligation_id"`
CreatedAt time.Time `db:"created_at"`
RiskID gid.GID `db:"risk_id"`
ObligationID gid.GID `db:"obligation_id"`
OrganizationID gid.GID `db:"organization_id"`
CreatedAt time.Time `db:"created_at"`
}
RiskObligations []*RiskObligation
@@ -44,21 +45,24 @@ func (ro RiskObligation) Insert(
INSERT INTO risks_obligations (
risk_id,
obligation_id,
organization_id,
tenant_id,
created_at
) VALUES (
@risk_id,
@obligation_id,
@organization_id,
@tenant_id,
@created_at
)
`
args := pgx.StrictNamedArgs{
"risk_id": ro.RiskID,
"obligation_id": ro.ObligationID,
"tenant_id": scope.GetTenantID(),
"created_at": ro.CreatedAt,
"risk_id": ro.RiskID,
"obligation_id": ro.ObligationID,
"organization_id": ro.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": ro.CreatedAt,
}
_, err := conn.Exec(ctx, q, args)

View File

@@ -21,9 +21,9 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
)
type (

View File

@@ -22,16 +22,17 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
TrustCenterAccess struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
TenantID gid.TenantID `db:"tenant_id"`
TrustCenterID gid.GID `db:"trust_center_id"`
Email string `db:"email"`
@@ -82,6 +83,7 @@ func (tca *TrustCenterAccess) LoadByID(
q := `
SELECT
id,
organization_id,
tenant_id,
trust_center_id,
email,
@@ -135,6 +137,7 @@ func (tca *TrustCenterAccess) LoadByTrustCenterIDAndEmail(
q := `
SELECT
id,
organization_id,
tenant_id,
trust_center_id,
email,
@@ -191,6 +194,7 @@ func (tca *TrustCenterAccess) Insert(
INSERT INTO trust_center_accesses (
id,
tenant_id,
organization_id,
trust_center_id,
email,
name,
@@ -201,6 +205,7 @@ INSERT INTO trust_center_accesses (
) VALUES (
@id,
@tenant_id,
@organization_id,
@trust_center_id,
@email,
@name,
@@ -214,6 +219,7 @@ INSERT INTO trust_center_accesses (
args := pgx.StrictNamedArgs{
"id": tca.ID,
"tenant_id": tca.TenantID,
"organization_id": tca.OrganizationID,
"trust_center_id": tca.TrustCenterID,
"email": tca.Email,
"name": tca.Name,
@@ -317,6 +323,7 @@ func (tcas *TrustCenterAccesses) LoadByTrustCenterID(
q := `
SELECT
id,
organization_id,
tenant_id,
trust_center_id,
email,

View File

@@ -21,16 +21,17 @@ import (
"maps"
"time"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
TrustCenterDocumentAccess struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
TrustCenterAccessID gid.GID `db:"trust_center_access_id"`
DocumentID *gid.GID `db:"document_id"`
ReportID *gid.GID `db:"report_id"`
@@ -78,6 +79,7 @@ func (tcda *TrustCenterDocumentAccess) LoadByID(
q := `
SELECT
id,
organization_id,
trust_center_access_id,
document_id,
report_id,
@@ -127,6 +129,7 @@ func (tcda *TrustCenterDocumentAccess) LoadByTrustCenterAccessIDAndDocumentID(
q := `
SELECT
id,
organization_id,
trust_center_access_id,
document_id,
report_id,
@@ -177,6 +180,7 @@ func (tcda *TrustCenterDocumentAccess) LoadByTrustCenterAccessIDAndReportID(
q := `
SELECT
id,
organization_id,
trust_center_access_id,
document_id,
report_id,
@@ -226,6 +230,7 @@ func (tcda *TrustCenterDocumentAccess) Insert(
INSERT INTO trust_center_document_accesses (
id,
tenant_id,
organization_id,
trust_center_access_id,
document_id,
report_id,
@@ -237,6 +242,7 @@ INSERT INTO trust_center_document_accesses (
) VALUES (
@id,
@tenant_id,
@organization_id,
@trust_center_access_id,
@document_id,
@report_id,
@@ -251,6 +257,7 @@ INSERT INTO trust_center_document_accesses (
args := pgx.StrictNamedArgs{
"id": tcda.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": tcda.OrganizationID,
"trust_center_access_id": tcda.TrustCenterAccessID,
"document_id": tcda.DocumentID,
"report_id": tcda.ReportID,
@@ -512,6 +519,7 @@ final_items AS (
SELECT
COALESCE(tcda.id, ai.item_id) AS id,
tcda.tenant_id,
(SELECT organization_id FROM organization) AS organization_id,
@trust_center_access_id AS trust_center_access_id,
ai.document_id,
ai.report_id,
@@ -532,6 +540,7 @@ final_items AS (
)
SELECT
id,
organization_id,
trust_center_access_id,
document_id,
report_id,
@@ -576,6 +585,7 @@ func (tcdas *TrustCenterDocumentAccesses) LoadAllByTrustCenterAccessID(
q := `
SELECT
id,
organization_id,
trust_center_access_id,
document_id,
report_id,
@@ -717,6 +727,7 @@ func (tcdas TrustCenterDocumentAccesses) BulkInsertDocumentAccesses(
conn pg.Conn,
scope Scoper,
trustCenterAccessID gid.GID,
organizationID gid.GID,
documentIDs []gid.GID,
requested bool,
createdAt time.Time,
@@ -730,6 +741,7 @@ WITH document_access_data AS (
SELECT
generate_gid(decode_base64_unpadded(@tenant_id), @trust_center_document_access_entity_type) AS id,
@tenant_id AS tenant_id,
@organization_id AS organization_id,
@trust_center_access_id AS trust_center_access_id,
unnest(@document_ids::text[]) AS document_id,
null::text AS report_id,
@@ -740,7 +752,7 @@ WITH document_access_data AS (
@updated_at::timestamptz AS updated_at
)
INSERT INTO trust_center_document_accesses (
id, tenant_id, trust_center_access_id, document_id, report_id, trust_center_file_id, active, requested, created_at, updated_at
id, tenant_id, organization_id, trust_center_access_id, document_id, report_id, trust_center_file_id, active, requested, created_at, updated_at
)
SELECT * FROM document_access_data
ON CONFLICT DO NOTHING
@@ -748,6 +760,7 @@ ON CONFLICT DO NOTHING
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"organization_id": organizationID,
"trust_center_access_id": trustCenterAccessID,
"document_ids": documentIDs,
"trust_center_document_access_entity_type": TrustCenterDocumentAccessEntityType,
@@ -768,6 +781,7 @@ func (tcdas TrustCenterDocumentAccesses) BulkInsertReportAccesses(
conn pg.Conn,
scope Scoper,
trustCenterAccessID gid.GID,
organizationID gid.GID,
reportIDs []gid.GID,
requested bool,
createdAt time.Time,
@@ -781,6 +795,7 @@ WITH report_access_data AS (
SELECT
generate_gid(decode_base64_unpadded(@tenant_id), @trust_center_document_access_entity_type) AS id,
@tenant_id AS tenant_id,
@organization_id AS organization_id,
@trust_center_access_id AS trust_center_access_id,
null::text AS document_id,
unnest(@report_ids::text[]) AS report_id,
@@ -791,14 +806,15 @@ WITH report_access_data AS (
@updated_at::timestamptz AS updated_at
)
INSERT INTO trust_center_document_accesses (
id, tenant_id, trust_center_access_id, document_id, report_id, trust_center_file_id, active, requested, created_at, updated_at
id, tenant_id, organization_id, trust_center_access_id, document_id, report_id, trust_center_file_id, active, requested, created_at, updated_at
)
SELECT * FROM report_access_data
ON CONFLICT DO NOTHING
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"tenant_id": scope.GetTenantID(),
"organization_id": organizationID,
"trust_center_document_access_entity_type": TrustCenterDocumentAccessEntityType,
"trust_center_access_id": trustCenterAccessID,
"report_ids": reportIDs,
@@ -903,6 +919,7 @@ func (tcdas TrustCenterDocumentAccesses) BulkInsertTrustCenterFileAccesses(
conn pg.Conn,
scope Scoper,
trustCenterAccessID gid.GID,
organizationID gid.GID,
trustCenterFileIDs []gid.GID,
requested bool,
createdAt time.Time,
@@ -912,6 +929,7 @@ WITH trust_center_file_access_data AS (
SELECT
generate_gid(decode_base64_unpadded(@tenant_id), @trust_center_document_access_entity_type) AS id,
@tenant_id AS tenant_id,
@organization_id AS organization_id,
@trust_center_access_id AS trust_center_access_id,
null::text AS document_id,
null::text AS report_id,
@@ -922,14 +940,15 @@ WITH trust_center_file_access_data AS (
@updated_at::timestamptz AS updated_at
)
INSERT INTO trust_center_document_accesses (
id, tenant_id, trust_center_access_id, document_id, report_id, trust_center_file_id, active, requested, created_at, updated_at
id, tenant_id, organization_id, trust_center_access_id, document_id, report_id, trust_center_file_id, active, requested, created_at, updated_at
)
SELECT * FROM trust_center_file_access_data
ON CONFLICT DO NOTHING
`
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"tenant_id": scope.GetTenantID(),
"organization_id": organizationID,
"trust_center_document_access_entity_type": TrustCenterDocumentAccessEntityType,
"trust_center_access_id": trustCenterAccessID,
"trust_center_file_ids": trustCenterFileIDs,

View File

@@ -30,15 +30,16 @@ import (
type (
TrustCenterReference struct {
ID gid.GID `db:"id"`
TrustCenterID gid.GID `db:"trust_center_id"`
Name string `db:"name"`
Description *string `db:"description"`
WebsiteURL string `db:"website_url"`
LogoFileID gid.GID `db:"logo_file_id"`
Rank int `db:"rank"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
TrustCenterID gid.GID `db:"trust_center_id"`
Name string `db:"name"`
Description *string `db:"description"`
WebsiteURL string `db:"website_url"`
LogoFileID gid.GID `db:"logo_file_id"`
Rank int `db:"rank"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
TrustCenterReferences []*TrustCenterReference
@@ -83,6 +84,7 @@ func (t *TrustCenterReference) LoadByID(
q := `
SELECT
id,
organization_id,
trust_center_id,
name,
description,
@@ -128,6 +130,7 @@ INSERT INTO
trust_center_references (
tenant_id,
id,
organization_id,
trust_center_id,
name,
description,
@@ -140,6 +143,7 @@ INSERT INTO
VALUES (
@tenant_id,
@id,
@organization_id,
@trust_center_id,
@name,
@description,
@@ -155,6 +159,7 @@ RETURNING rank;
args := pgx.StrictNamedArgs{
"tenant_id": scope.GetTenantID(),
"id": t.ID,
"organization_id": t.OrganizationID,
"trust_center_id": t.TrustCenterID,
"name": t.Name,
"description": t.Description,
@@ -308,6 +313,7 @@ func (t *TrustCenterReferences) LoadByTrustCenterID(
q := `
SELECT
id,
organization_id,
trust_center_id,
name,
description,

View File

@@ -28,16 +28,17 @@ import (
type (
VendorComplianceReport struct {
ID gid.GID
VendorID gid.GID
ReportDate time.Time
ValidUntil *time.Time
ReportName string
ReportFileId *gid.GID
SnapshotID *gid.GID
SourceID *gid.GID
CreatedAt time.Time
UpdatedAt time.Time
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
VendorID gid.GID `db:"vendor_id"`
ReportDate time.Time `db:"report_date"`
ValidUntil *time.Time `db:"valid_until"`
ReportName string `db:"report_name"`
ReportFileId *gid.GID `db:"report_file_id"`
SnapshotID *gid.GID `db:"snapshot_id"`
SourceID *gid.GID `db:"source_id"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
VendorComplianceReports []*VendorComplianceReport
@@ -157,6 +158,7 @@ func (vcr *VendorComplianceReport) Insert(
INSERT INTO
vendor_compliance_reports (
id,
organization_id,
tenant_id,
vendor_id,
report_date,
@@ -168,6 +170,7 @@ INSERT INTO
)
VALUES (
@id,
@organization_id,
@tenant_id,
@vendor_id,
@report_date,
@@ -179,15 +182,16 @@ VALUES (
)
`
args := pgx.NamedArgs{
"id": vcr.ID,
"tenant_id": scope.GetTenantID(),
"vendor_id": vcr.VendorID,
"report_date": vcr.ReportDate,
"valid_until": vcr.ValidUntil,
"report_name": vcr.ReportName,
"report_file_id": vcr.ReportFileId,
"created_at": vcr.CreatedAt,
"updated_at": vcr.UpdatedAt,
"id": vcr.ID,
"organization_id": vcr.OrganizationID,
"tenant_id": scope.GetTenantID(),
"vendor_id": vcr.VendorID,
"report_date": vcr.ReportDate,
"valid_until": vcr.ValidUntil,
"report_name": vcr.ReportName,
"report_file_id": vcr.ReportFileId,
"created_at": vcr.CreatedAt,
"updated_at": vcr.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)

Some files were not shown because too many files have changed in this diff Show More