Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
db5934d
feat: migrate SDK to use SetProjectMemberRole and RemoveProjectMember…
whoAbhishekSah Apr 3, 2026
26a8aa4
fix: use role UUIDs instead of string names for project member operat…
whoAbhishekSah Apr 3, 2026
3b244d5
fix: pass roles prop to AddMemberDropdown component
whoAbhishekSah Apr 3, 2026
2243fb1
feat: migrate views-new project files to use SetProjectMemberRole and…
whoAbhishekSah Apr 7, 2026
3d02d4e
fix: address review comments on SDK migration
whoAbhishekSah Apr 8, 2026
a75aad8
fix: pass memberType through remove member flow for group support
whoAbhishekSah Apr 8, 2026
cef814e
chore: remove accidentally added doc file
whoAbhishekSah Apr 8, 2026
d5f9939
refactor: change admin assign-role from multi-select to single-select
whoAbhishekSah Apr 9, 2026
8910abc
chore: remove unintended mock file
whoAbhishekSah Apr 9, 2026
4ebcd8e
fix: use Checkbox with single-select logic instead of Radio
whoAbhishekSah Apr 9, 2026
8cb9637
fix: migrate admin AssignRole dialog from raw policy CRUD to SetOrgan…
whoAbhishekSah Apr 10, 2026
6b6d945
fix: make memberType required in RemoveProjectMemberDialog
whoAbhishekSah Apr 10, 2026
05823c4
Revert "fix: skip redundant policy delete+create when role is unchanged"
whoAbhishekSah Apr 10, 2026
8583b0d
fix: address review feedback — use constants, isDirty, and early returns
whoAbhishekSah Apr 10, 2026
bb4c989
fix: use ~/admin path alias for constants imports
whoAbhishekSah Apr 10, 2026
f306b11
fix: restore core/ files to match main, remove stale revert
whoAbhishekSah Apr 10, 2026
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
90 changes: 17 additions & 73 deletions web/sdk/admin/components/AssignRole.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,19 @@ import {
Text,
toast,
} from "@raystack/apsara";
import { useCallback } from "react";
import type {
SearchOrganizationUsersResponse_OrganizationUser,
Role,
Policy,
} from "@raystack/proton/frontier";
import {
FrontierService,
FrontierServiceQueries,
ListPoliciesRequestSchema,
DeletePolicyRequestSchema,
CreatePolicyRequestSchema,
SetOrganizationMemberRoleRequestSchema,
} from "@raystack/proton/frontier";
import { create } from "@bufbuild/protobuf";
import { useMutation, useTransport } from "@connectrpc/connect-query";
import { createClient } from "@connectrpc/connect";
import { useMutation } from "@connectrpc/connect-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { SCOPES } from "../utils/constants";

interface AssignRoleProps {
organizationId: string;
Expand All @@ -37,9 +30,7 @@ interface AssignRoleProps {
}

const formSchema = z.object({
roleIds: z.instanceof(Set<string>).refine((set) => set.size > 0, {
message: "At least one role must be selected",
}),
roleId: z.string().min(1, "A role must be selected"),
});

type FormData = z.infer<typeof formSchema>;
Expand All @@ -51,90 +42,42 @@ export const AssignRole = ({
onRoleUpdate,
onClose,
}: AssignRoleProps) => {
const transport = useTransport();
const currentRoleId = user?.roleIds?.[0] || "";

const {
handleSubmit,
watch,
setValue,
formState: { isSubmitting, errors },
formState: { isSubmitting, errors, isDirty },
} = useForm<FormData>({
defaultValues: {
roleIds: new Set(user?.roleIds || []),
roleId: currentRoleId,
},
resolver: zodResolver(formSchema),
});

const { mutateAsync: deletePolicy } = useMutation(
FrontierServiceQueries.deletePolicy,
const { mutateAsync: setMemberRole } = useMutation(
FrontierServiceQueries.setOrganizationMemberRole,
);

const { mutateAsync: createPolicy } = useMutation(
FrontierServiceQueries.createPolicy,
);

const roleIds = watch("roleIds");
const selectedRoleId = watch("roleId");

function onCheckedChange(value: boolean | string, roleId?: string) {
if (!roleId) return;
const currentRoles = new Set(roleIds);

if (value) {
currentRoles.add(roleId);
} else {
currentRoles.delete(roleId);
setValue("roleId", roleId, { shouldDirty: true });
}

setValue("roleIds", currentRoles);
}

const checkRole = useCallback(
(roleId?: string) => {
if (!roleId) return false;
return roleIds?.has(roleId) || false;
},
[roleIds],
);

const onSubmit = async (data: FormData) => {
try {
const client = createClient(FrontierService, transport);
const policiesResp = await client.listPolicies(
create(ListPoliciesRequestSchema, {
await setMemberRole(
create(SetOrganizationMemberRoleRequestSchema, {
orgId: organizationId,
userId: user?.id,
roleId: data.roleId,
}),
);
const policies = policiesResp.policies || [];

const removedRolesPolicies = policies.filter(
(policy: Policy) => !(policy.roleId && data.roleIds.has(policy.roleId)),
);
await Promise.all(
removedRolesPolicies.map((policy: Policy) =>
deletePolicy(
create(DeletePolicyRequestSchema, { id: policy.id || "" }),
),
),
);

const resource = `${SCOPES.ORG}:${organizationId}`;
const principal = `${SCOPES.USER}:${user?.id}`;

const assignedRolesArr = Array.from(data.roleIds);
await Promise.all(
assignedRolesArr.map((roleId) =>
createPolicy(
create(CreatePolicyRequestSchema, {
body: {
roleId,
resource,
principal,
},
}),
),
),
);

if (onRoleUpdate) {
onRoleUpdate();
Expand Down Expand Up @@ -165,7 +108,7 @@ export const AssignRole = ({
<Flex direction="column" gap={4}>
{roles.map((role) => {
const htmlId = `role-${role.id}`;
const checked = checkRole(role.id);
const checked = selectedRoleId === role.id;
return (
<Flex gap={3} key={role.id}>
<Checkbox
Expand All @@ -180,8 +123,8 @@ export const AssignRole = ({
</Flex>
);
})}
{errors.roleIds && (
<Text variant="danger">{errors.roleIds.message}</Text>
{errors.roleId && (
<Text variant="danger">{errors.roleId.message}</Text>
)}
</Flex>
</div>
Expand All @@ -200,6 +143,7 @@ export const AssignRole = ({
</Dialog.Close>
<Button
type="submit"
disabled={!isDirty}
data-test-id="assign-role-update-button"
loading={isSubmitting}
loaderText="Updating..."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,20 @@ import {
toast,
} from "@raystack/apsara";
import styles from "./members.module.css";
import { useCallback } from "react";
import type {
SearchProjectUsersResponse_ProjectUser,
Role,
} from "@raystack/proton/frontier";
import {
FrontierServiceQueries,
FrontierService,
ListPoliciesRequestSchema,
DeletePolicyRequestSchema,
CreatePolicyRequestSchema,
SetProjectMemberRoleRequestSchema,
} from "@raystack/proton/frontier";
import { create } from "@bufbuild/protobuf";
import { useMutation, useTransport } from "@connectrpc/connect-query";
import { createClient } from "@connectrpc/connect";
import { useMutation } from "@connectrpc/connect-query";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { SCOPES } from "~/admin/utils/constants";

interface AssignRoleProps {
projectId: string;
Expand All @@ -36,9 +32,7 @@ interface AssignRoleProps {
}

const formSchema = z.object({
roleIds: z.instanceof(Set<string>).refine((set) => set.size > 0, {
message: "At least one role must be selected",
}),
roleId: z.string().min(1, "A role must be selected"),
});

type FormData = z.infer<typeof formSchema>;
Expand All @@ -50,95 +44,41 @@ export const AssignRole = ({
onRoleUpdate,
onClose,
}: AssignRoleProps) => {
const transport = useTransport();
const currentRoleId = user?.roleIds?.[0] || "";

const {
handleSubmit,
watch,
setValue,
formState: { isSubmitting, errors },
formState: { isSubmitting, errors, isDirty },
} = useForm<FormData>({
defaultValues: {
roleIds: new Set(user?.roleIds || []),
roleId: currentRoleId,
},
resolver: zodResolver(formSchema),
});

const { mutateAsync: deletePolicy } = useMutation(
FrontierServiceQueries.deletePolicy,
const { mutateAsync: setProjectMemberRole } = useMutation(
FrontierServiceQueries.setProjectMemberRole,
);

const { mutateAsync: createPolicy } = useMutation(
FrontierServiceQueries.createPolicy,
);

const roleIds = watch("roleIds");

function onCheckedChange(value: boolean | string, roleId?: string) {
if (!roleId) return;
const currentRoles = new Set(roleIds);

if (value) {
currentRoles.add(roleId);
} else {
currentRoles.delete(roleId);
}

setValue("roleIds", currentRoles);
}

const checkRole = useCallback(
(roleId?: string) => {
if (!roleId) return false;
return roleIds?.has(roleId) || false;
},
[roleIds],
);
const selectedRoleId = watch("roleId");

const onSubmit = async (data: FormData) => {
try {
const client = createClient(FrontierService, transport);
const policiesResp = await client.listPolicies(
create(ListPoliciesRequestSchema, {
projectId: projectId,
userId: user?.id,
await setProjectMemberRole(
create(SetProjectMemberRoleRequestSchema, {
projectId,
principalId: user?.id || "",
principalType: SCOPES.USER,
roleId: data.roleId,
}),
);
const policies = policiesResp.policies || [];

const removedRolesPolicies = policies.filter(
(policy) => !(policy.roleId && data.roleIds.has(policy.roleId)),
);
await Promise.all(
removedRolesPolicies.map((policy) =>
deletePolicy(
create(DeletePolicyRequestSchema, { id: policy.id || "" }),
),
),
);

const resource = `app/project:${projectId}`;
const principal = `app/user:${user?.id}`;

const assignedRolesArr = Array.from(data.roleIds);
await Promise.all(
assignedRolesArr.map((roleId) =>
createPolicy(
create(CreatePolicyRequestSchema, {
body: {
roleId,
resource,
principal,
},
}),
),
),
);

if (onRoleUpdate) {
onRoleUpdate({
...user,
roleIds: assignedRolesArr,
roleIds: [data.roleId],
} as SearchProjectUsersResponse_ProjectUser);
}

Expand Down Expand Up @@ -171,23 +111,25 @@ export const AssignRole = ({
<Flex direction="column" gap={4}>
{roles.map((role) => {
const htmlId = `role-${role.id}`;
const checked = checkRole(role.id);
const checked = selectedRoleId === role.id;
return (
<Flex gap={3} key={role.id}>
<Checkbox
id={htmlId}
data-test-id={`role-checkbox-${role.id}`}
checked={checked}
onCheckedChange={(value) =>
onCheckedChange(value, role.id)
onCheckedChange={() =>
setValue("roleId", role.id || "", {
shouldDirty: true,
})
}
/>
<Label htmlFor={htmlId}>{role.title}</Label>
</Flex>
);
})}
{errors.roleIds && (
<Text variant="danger">{errors.roleIds.message}</Text>
{errors.roleId && (
<Text variant="danger">{errors.roleId.message}</Text>
)}
</Flex>
</div>
Expand All @@ -206,6 +148,7 @@ export const AssignRole = ({
</Dialog.Close>
<Button
type="submit"
disabled={!isDirty}
data-test-id="assign-role-update-button"
loading={isSubmitting}
loaderText="Updating..."
Expand Down
Loading
Loading