Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/api/services/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,10 @@ export function updatePermission(permission: PermissionTable) {
export function deletePermission(id: string) {
return IncidentCommander.delete(`/permissions?id=eq.${id}`);
}

export async function fetchPermissionById(id: string) {
const response = await IncidentCommander.get<PermissionTable[]>(
`/permissions?id=eq.${id}`
);
Copy link
Preview

Copilot AI Oct 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the API returns an empty array or an error scenario masked as a 200 with no rows, this returns undefined silently, causing the edit flow to proceed as if creating a new permission. Consider validating the response and throwing (or returning a rejected promise) when no record is found: e.g. if (!response.data.length) throw new Error('Permission not found'); so the UI can show an error state instead of falling back to 'Add'.

Suggested change
);
);
if (!response.data || response.data.length === 0) {
throw new Error('Permission not found');
}

Copilot uses AI. Check for mistakes.

return response.data[0];
}
36 changes: 32 additions & 4 deletions src/api/types/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ import { Topology } from "./topology";
import { Team, User } from "./users";
import { NotificationRules } from "./notifications";

export type PermissionGlobalObject =
| "catalog"
| "component"
| "canaries"
| "connection"
| "playbook"
| "topology";

export type PermissionTable = {
id: string;
description: string;
action: string;
deny?: boolean;
object?: string;
subject?: string;
subject_type?:
| "group"
Expand All @@ -29,11 +36,13 @@ export type PermissionTable = {
agents?: string[];

// Resources
object?: PermissionGlobalObject;
object_selector?: Record<string, any>[];
component_id?: string;
config_id?: string;
canary_id?: string;
config_id?: string;
connection_id?: string;
playbook_id?: string;

// Deprecated fields
// These are subject fields that we do not use anymore.
Expand All @@ -42,7 +51,6 @@ export type PermissionTable = {
person_id?: string;
notification_id?: string;
team_id?: string;
playbook_id?: string;
};

export type PermissionsSummary = PermissionTable & {
Expand All @@ -56,12 +64,32 @@ export type PermissionsSummary = PermissionTable & {
};
playbook: Pick<PlaybookSpec, "id" | "name" | "namespace" | "icon" | "title">;
team: Pick<Team, "id" | "name" | "icon">;
connection: Pick<Connection, "id" | "name" | "type">;
notification: Pick<NotificationRules, "id" | "name" | "namespace">;
group: any;
subject: string;
person: User;
createdBy: User;
tags: Record<string, string> | null;
agents: string[] | null;

// These represent global objects
object: PermissionGlobalObject;

// These represent object selectors per type
object_selector?: PermissionObjectSelector;

// These are objects that are specifically chosen
config_object: Pick<ConfigItem, "id" | "name" | "type" | "config_class">;
playbook_object: Pick<PlaybookSpec, "id" | "name" | "icon">;
connection_object: Pick<Connection, "id" | "name" | "type">;
component_object: Pick<Topology, "id" | "name" | "icon">;
};

type PermissionObjectSelector = {
playbooks: Selectors[];
connections: Selectors[];
configs: Selectors[];
components: Selectors[];
};

interface Selectors {}
9 changes: 8 additions & 1 deletion src/components/Forms/Formik/FormikPeopleDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,14 @@ export default function FormikPeopleDropdown({
const options = useMemo(
() =>
people?.map((person) => ({
label: person.name,
label: (
<div className="flex items-center justify-between gap-2">
<span>{person.name}</span>
{person.email && (
<span className="text-xs text-gray-500">{person.email}</span>
)}
</div>
),
value: person.id
})),
[people]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import { useState } from "react";
export const permissionObjectList = [
{ label: "Canaries", value: "canaries" },
{ label: "Catalog", value: "catalog" },
{ label: "Incident", value: "incident" },
{ label: "People", value: "people" },
{ label: "Topology", value: "topology" },
{ label: "Playbooks", value: "playbooks" },
{ label: "Connection", value: "connection" },
Expand All @@ -24,11 +22,27 @@ export const permissionObjectList = [
];

export default function FormikPermissionSelectResourceFields() {
const { setFieldValue } = useFormikContext<Record<string, any>>();
const { setFieldValue, values } = useFormikContext<Record<string, any>>();

const getInitialTab = ():
| "Component"
| "Catalog"
| "Canary"
| "Playbook"
| "Connection"
| "Global" => {
if (values.playbook_id) return "Playbook";
if (values.config_id) return "Catalog";
if (values.component_id) return "Component";
if (values.connection_id) return "Connection";
if (values.canary_id) return "Canary";
if (values.object) return "Global";
return "Catalog";
};

const [switchOption, setSwitchOption] = useState<
"Component" | "Catalog" | "Canary" | "Playbook" | "Connection" | "Global"
>("Catalog");
>(getInitialTab());

return (
<div className="flex flex-col gap-2">
Expand Down
124 changes: 81 additions & 43 deletions src/components/Permissions/ManagePermissions/Forms/PermissionForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
addPermission,
updatePermission
updatePermission,
fetchPermissionById
} from "@flanksource-ui/api/services/permissions";
import { PermissionTable } from "@flanksource-ui/api/types/permissions";
import FormikCheckbox from "@flanksource-ui/components/Forms/Formik/FormikCheckbox";
Expand All @@ -16,7 +17,7 @@ import { useUser } from "@flanksource-ui/context";
import { tables } from "@flanksource-ui/context/UserAccessContext/permissions";
import { Button } from "@flanksource-ui/ui/Buttons/Button";
import { Modal } from "@flanksource-ui/ui/Modal";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import clsx from "clsx";
import { Form, Formik, useFormikContext } from "formik";
Expand Down Expand Up @@ -82,7 +83,7 @@ function PermissionFormContent({
return (
<div className="flex flex-col gap-3 p-4">
<PermissionsSubjectControls />
{isResourceIdProvided ? (
{isResourceIdProvided && isReadOnly ? (
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold">Resource</label>
<PermissionResource />
Expand Down Expand Up @@ -123,17 +124,32 @@ export default function PermissionForm({
isOpen = false,
data
}: PermissionFormProps) {
const permissionId = data?.id;

// Fetch full permission data when editing
const { data: fetchedPermission, isLoading: isFetchingPermission } = useQuery(
{
queryKey: ["permission", permissionId],
queryFn: () => fetchPermissionById(permissionId!),
enabled: !!permissionId && isOpen
}
);

// Use fetched data if editing, otherwise use provided data
const permissionData = permissionId ? fetchedPermission : data;
Copy link
Preview

Copilot AI Oct 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the fetch completes with an error or returns no record, fetchedPermission will be undefined and permissionData becomes undefined, causing the form to treat an edit as a creation (title, submit path) and potentially insert a duplicate. Use the stable permissionId or original data as a fallback (e.g. const permissionData = permissionId ? fetchedPermission ?? data : data) and gate add vs update logic on permissionId, not permissionData?.id.

Suggested change
const permissionData = permissionId ? fetchedPermission : data;
const permissionData = permissionId ? (fetchedPermission ?? data) : data;

Copilot uses AI. Check for mistakes.


// Only show loading when we're actually fetching an existing permission
const shouldShowLoading = !!permissionId && isFetchingPermission;

const isResourceIdProvided = useMemo(() => {
return !!(
data?.component_id ||
data?.config_id ||
data?.canary_id ||
data?.canary_id ||
data?.playbook_id ||
data?.connection_id ||
data?.object
permissionData?.component_id ||
permissionData?.config_id ||
permissionData?.canary_id ||
permissionData?.playbook_id ||
permissionData?.connection_id
);
}, [data]);
}, [permissionData]);

const { user } = useUser();
const queryClient = useQueryClient();
Expand Down Expand Up @@ -191,9 +207,25 @@ export default function PermissionForm({

const isLoading = adding || updating;

if (shouldShowLoading) {
return (
<Modal
title="Edit Permission"
onClose={onClose}
open={isOpen}
bodyClass="flex flex-col w-full flex-1 h-full overflow-y-auto"
helpLink="/reference/permissions"
>
<div className="flex flex-1 items-center justify-center">
<FaSpinner className="animate-spin text-2xl" />
</div>
</Modal>
);
}

return (
<Modal
title={data?.id ? "Edit Permission" : "Add Permission"}
title={permissionData?.id ? "Edit Permission" : "Add Permission"}
onClose={onClose}
open={isOpen}
bodyClass="flex flex-col w-full flex-1 h-full overflow-y-auto"
Expand All @@ -202,32 +234,32 @@ export default function PermissionForm({
<div className="flex flex-1 flex-col gap-2">
<Formik<Partial<PermissionTable>>
initialValues={{
action: data?.action,
component_id: data?.component_id,
config_id: data?.config_id,
canary_id: data?.canary_id,
playbook_id: data?.playbook_id,
deny: data?.deny ?? false,
object: data?.object,
description: data?.description,
connection_id: data?.connection_id,
created_at: data?.created_at,
created_by: data?.created_by,
updated_at: data?.updated_at,
updated_by: data?.updated_by,
id: data?.id,
notification_id: data?.notification_id,
person_id: data?.person_id,
team_id: data?.team_id,
subject: data?.subject,
subject_type: data?.subject_type,
until: data?.until,
source: data?.source || "UI",
tags: data?.tags || {},
agents: data?.agents || []
action: permissionData?.action,
component_id: permissionData?.component_id,
config_id: permissionData?.config_id,
canary_id: permissionData?.canary_id,
playbook_id: permissionData?.playbook_id,
deny: permissionData?.deny ?? false,
object: permissionData?.object,
description: permissionData?.description,
connection_id: permissionData?.connection_id,
created_at: permissionData?.created_at,
created_by: permissionData?.created_by,
updated_at: permissionData?.updated_at,
updated_by: permissionData?.updated_by,
id: permissionData?.id,
notification_id: permissionData?.notification_id,
person_id: permissionData?.person_id,
team_id: permissionData?.team_id,
subject: permissionData?.subject,
subject_type: permissionData?.subject_type,
until: permissionData?.until,
source: permissionData?.source || "UI",
tags: permissionData?.tags || {},
agents: permissionData?.agents || []
}}
onSubmit={(v) => {
if (!data?.id) {
if (!permissionData?.id) {
return add({
...v
} as PermissionTable);
Comment on lines +262 to 265
Copy link
Preview

Copilot AI Oct 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relying on permissionData?.id to choose add vs update can misclassify an edit when permissionData failed to load; this will attempt an add instead of update. Use the original permissionId prop (or a separate flag like !!permissionId) to decide the branch, ensuring failed fetches do not create duplicates.

Copilot uses AI. Check for mistakes.

Expand All @@ -243,13 +275,13 @@ export default function PermissionForm({
<PermissionFormContent
isResourceIdProvided={isResourceIdProvided}
agentOptions={agentOptions}
source={data?.source}
source={permissionData?.source}
/>
</div>
<CanEditResource
id={data?.id}
id={permissionData?.id}
resourceType={"permissions"}
source={data?.source}
source={permissionData?.source}
className="flex items-center bg-gray-100 px-5 py-4"
>
<AuthorizationAccessCheck
Expand All @@ -259,12 +291,12 @@ export default function PermissionForm({
<div
className={clsx(
"flex items-center bg-gray-100 px-5 py-4",
data?.id ? "justify-between" : "justify-end"
permissionData?.id ? "justify-between" : "justify-end"
)}
>
{data?.id && (
{permissionData?.id && (
<DeletePermission
permissionId={data.id}
permissionId={permissionData.id}
onDeleted={onClose}
/>
)}
Expand All @@ -275,7 +307,13 @@ export default function PermissionForm({
) : undefined
}
type="submit"
text={data?.id ? "Save" : isLoading ? "Saving ..." : "Save"}
text={
permissionData?.id
? "Save"
: isLoading
? "Saving ..."
: "Save"
}
className="btn-primary"
/>
</div>
Expand Down
Loading
Loading