docs/superpowers/plans/2026-04-04-project-members-sa-wi-react-migration.md
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Migrate project-scoped Members, Service Accounts, and Workload Identities pages from Vue to React by making the existing workspace-level React pages accept an optional projectId prop.
Architecture: The existing React pages (MembersPage.tsx, ServiceAccountsPage.tsx, WorkloadIdentitiesPage.tsx) gain an optional projectId prop. When provided, they switch to project-scoped APIs (project IAM policy, project parent resource). New Vue mount wrappers pass projectId from Vue Router to React. Routes swap Vue components for mount wrappers.
Tech Stack: React, TypeScript, Pinia stores via useVueState, react-i18next, Tailwind CSS
frontend/src/react/ProjectMembersPageMount.vue — Vue mount wrapperfrontend/src/react/ProjectServiceAccountsPageMount.vue — Vue mount wrapperfrontend/src/react/ProjectWorkloadIdentitiesPageMount.vue — Vue mount wrapperfrontend/src/react/pages/settings/ServiceAccountsPage.tsx — add projectId prop, project-scoped parent/permissionsfrontend/src/react/pages/settings/WorkloadIdentitiesPage.tsx — add projectId prop, project-scoped parent/permissionsfrontend/src/react/pages/settings/MembersPage.tsx — add projectId prop, project IAM policy, project roles, Request Role featurefrontend/src/react/pages/settings/shared/RoleMultiSelect.tsx — add optional scope prop to filter role groups (project vs workspace)frontend/src/router/dashboard/projectV1.ts:334-365 — swap Vue components for React mount wrappersfrontend/src/react/mount.ts — no change needed (project page loaders already configured via import.meta.glob("./pages/settings/*.tsx"))Files:
frontend/src/react/pages/settings/ServiceAccountsPage.tsxThe ServiceAccountsPage currently uses actuatorStore.workspaceResourceName as parent and hasWorkspacePermissionV2 for permissions. Add optional projectId prop to support project scope.
At the top of ServiceAccountsPage, change the signature and add project resolution:
export function ServiceAccountsPage({ projectId }: { projectId?: string }) {
Add project store and resolution after existing store declarations:
import { useProjectV1Store } from "@/store";
import { projectNamePrefix } from "@/store/modules/v1/common";
import { hasProjectPermissionV2 } from "@/utils";
// Inside the component:
const projectStore = useProjectV1Store();
const projectName = projectId ? `${projectNamePrefix}${projectId}` : undefined;
const project = useVueState(() =>
projectName ? projectStore.getProjectByName(projectName) : undefined
);
Replace the existing parent line:
// Before:
const parent = useVueState(() => actuatorStore.workspaceResourceName);
// After:
const parent = useVueState(() =>
projectName ?? actuatorStore.workspaceResourceName
);
Replace the workspace permission check in the header button:
// Before:
disabled={!hasWorkspacePermissionV2("bb.serviceAccounts.create")}
// After:
disabled={
project
? !hasProjectPermissionV2(project, "bb.serviceAccounts.create")
: !hasWorkspacePermissionV2("bb.serviceAccounts.create")
}
// Before:
sessionKey: "bb.service-accounts.active.page-size",
// After:
sessionKey: `bb.service-accounts${projectName ? `.${projectName}` : ""}.active.page-size`,
Same for inactive session key.
Add project prop to the drawer:
<CreateServiceAccountDrawer
serviceAccount={editingSa}
project={projectName}
onClose={...}
onCreated={handleCreated}
onUpdated={handleUpdated}
/>
The CreateServiceAccountDrawer (defined inline in the same file) needs a project prop to scope creation and role assignment to the project. Model after CreateWorkloadIdentityDrawer in frontend/src/react/components/CreateWorkloadIdentityDrawer.tsx:76-88 which already supports this.
Add project?: string to the drawer props. When project is set:
project as parent for createServiceAccounthasProjectPermissionV2 for permission checksprojectIamPolicyStore.updateProjectIamPolicy for role assignment instead of workspaceStore.patchIamPolicyscope="project" to RoleMultiSelect when in project scope (so only project + custom roles are shown)function CreateServiceAccountDrawer({
serviceAccount,
project,
onClose,
onCreated,
onUpdated,
}: {
serviceAccount: ServiceAccount | undefined;
project?: string;
onClose: () => void;
onCreated: (sa: ServiceAccount) => void;
onUpdated: (sa: ServiceAccount) => void;
}) {
const projectStore = useProjectV1Store();
const projectIamPolicyStore = useProjectIamPolicyStore();
const projectEntity = useVueState(() =>
project ? projectStore.getProjectByName(project) : undefined
);
const parent = useMemo(
() => project ?? actuatorStore.workspaceResourceName,
[project, actuatorStore]
);
// Permission check:
const hasPermission = projectEntity
? hasProjectPermissionV2(projectEntity, isEditMode ? "bb.serviceAccounts.update" : "bb.serviceAccounts.create")
: hasWorkspacePermissionV2(isEditMode ? "bb.serviceAccounts.update" : "bb.serviceAccounts.create");
// In handleCreate, after creating the SA:
if (roles.length > 0) {
const member = getServiceAccountNameInBinding(sa.email);
if (projectEntity) {
// Use the same updateProjectIamPolicyForMember pattern from CreateWorkloadIdentityDrawer
await updateProjectIamPolicyForMember(projectEntity.name, member, roles);
} else {
await workspaceStore.patchIamPolicy([{ member, roles }]);
}
}
Add the updateProjectIamPolicyForMember helper (copy from CreateWorkloadIdentityDrawer.tsx:237-264).
Imports to add:
import { useProjectV1Store } from "@/store";
import { useProjectIamPolicyStore } from "@/store/modules/v1/projectIamPolicy";
import { hasProjectPermissionV2 } from "@/utils";
import { BindingSchema } from "@/types/proto-es/v1/iam_policy_pb";
The ServiceAccountTable component uses hasWorkspacePermissionV2 for inline operations (delete, edit, undelete). Pass a project prop through and switch to hasProjectPermissionV2 when in project scope.
Add to ServiceAccountTable props:
function ServiceAccountTable({
users,
project,
onUserUpdated,
onUserSelected,
}: {
users: User[];
project?: Project;
onUserUpdated: (user: User) => void;
onUserSelected?: (user: User) => void;
}) {
Replace permission checks:
// Before:
hasWorkspacePermissionV2("bb.serviceAccounts.delete")
// After:
(project ? hasProjectPermissionV2(project, "bb.serviceAccounts.delete") : hasWorkspacePermissionV2("bb.serviceAccounts.delete"))
Same for .get and .undelete permissions. Pass project={project} when rendering <ServiceAccountTable>.
Run: pnpm --dir frontend type-check
Run: pnpm --dir frontend fix
git add frontend/src/react/pages/settings/ServiceAccountsPage.tsx
git commit -m "feat(frontend): add project scope support to ServiceAccountsPage"
Files:
frontend/src/react/pages/settings/WorkloadIdentitiesPage.tsxSame pattern as Task 1 but simpler — the CreateWorkloadIdentityDrawer already supports a project prop.
export function WorkloadIdentitiesPage({ projectId }: { projectId?: string }) {
Add project store imports and resolution (same pattern as Task 1).
const parent = useVueState(() =>
projectName ?? actuatorStore.workspaceResourceName
);
Replace workspaceResourceName usage in fetchActive and fetchInactive with parent.
Update the create button:
disabled={
project
? !hasProjectPermissionV2(project, "bb.workloadIdentities.create")
: !hasWorkspacePermissionV2("bb.workloadIdentities.create")
}
Replace ComponentPermissionGuard with a project-aware check. The existing ComponentPermissionGuard only checks workspace permissions and will block project-only users. Replace it with a conditional:
// Before:
<ComponentPermissionGuard permissions={["bb.workloadIdentities.list"]}>
</ComponentPermissionGuard>
// After: remove the guard wrapper. The route already has requiredPermissionList for bb.workloadIdentities.list,
// so users who reach this page already have permission. Just render the content directly.
sessionKey: `bb.paged-workload-identity-table${projectName ? `.${projectName}` : ""}.active`,
Same for inactive/deleted key.
Already accepts project prop. Just pass it:
<CreateWorkloadIdentityDrawer
workloadIdentity={editingWI}
project={projectName}
onClose={...}
onCreated={...}
onUpdated={...}
/>
Same pattern as Task 1 Step 7 — pass project prop through and switch permission checks.
Run: pnpm --dir frontend type-check
Run: pnpm --dir frontend fix
git add frontend/src/react/pages/settings/WorkloadIdentitiesPage.tsx
git commit -m "feat(frontend): add project scope support to WorkloadIdentitiesPage"
Files:
frontend/src/react/pages/settings/MembersPage.tsxThis is the most complex task. The workspace MembersPage currently uses only workspace IAM policy. Project scope needs:
Both workspace + project policies for getMemberBindings
Project IAM mutations instead of workspace
Project role display (projectRoleBindings) instead of workspace roles (workspaceLevelRoles)
"Request Role" button
Description text with learn-more link
Step 1: Add projectId prop and project resolution
export function MembersPage({ projectId }: { projectId?: string }) {
Add imports and project resolution:
import {
useProjectV1Store,
usePermissionStore,
useRoleStore,
useSubscriptionV1Store,
} from "@/store";
import { useProjectIamPolicyStore } from "@/store/modules/v1/projectIamPolicy";
import { projectNamePrefix } from "@/store/modules/v1/common";
import {
PRESET_WORKSPACE_ROLES,
PresetRoleType,
type Permission,
} from "@/types";
import type { Project } from "@/types/proto-es/v1/project_service_pb";
import { PlanFeature } from "@/types/proto-es/v1/subscription_service_pb";
import { hasProjectPermissionV2, isBindingPolicyExpired } from "@/utils";
// Inside component:
const projectStore = useProjectV1Store();
const projectIamPolicyStore = useProjectIamPolicyStore();
const permissionStore = usePermissionStore();
const roleStore = useRoleStore();
const subscriptionStore = useSubscriptionV1Store();
const projectName = projectId ? `${projectNamePrefix}${projectId}` : undefined;
const project = useVueState(() =>
projectName ? projectStore.getProjectByName(projectName) : undefined
);
Add a useEffect to fetch the project IAM policy when in project scope:
useEffect(() => {
if (projectName) {
projectIamPolicyStore.getOrFetchProjectIamPolicy(projectName);
}
}, [projectName, projectIamPolicyStore]);
const projectIamPolicy = useVueState(() =>
projectName ? projectIamPolicyStore.getProjectIamPolicy(projectName) : undefined
);
The key difference: project scope includes both workspace + project policies and ignores workspace roles.
const workspaceRoles = useMemo(() => new Set(PRESET_WORKSPACE_ROLES), []);
const memberBindings = useVueState(() =>
getMemberBindings({
policies: projectName
? [
{ level: "WORKSPACE" as const, policy: workspaceStore.workspaceIamPolicy },
{ level: "PROJECT" as const, policy: projectIamPolicy! },
]
: [
{ level: "WORKSPACE" as const, policy: workspaceStore.workspaceIamPolicy },
],
searchText: memberSearchText,
ignoreRoles: projectName ? workspaceRoles : new Set([]),
})
);
const canSetIamPolicy = project
? hasProjectPermissionV2(project, "bb.projects.setIamPolicy")
: hasWorkspacePermissionV2("bb.workspaces.setIamPolicy");
Currently MemberTable renders mb.workspaceLevelRoles. In project scope, render mb.projectRoleBindings instead.
Add a scope prop to MemberTable:
function MemberTable({
bindings,
allowEdit,
scope,
selectedBindings,
onSelectionChange,
onUpdateBinding,
onRevokeBinding,
}: {
bindings: MemberBinding[];
allowEdit: boolean;
scope: "workspace" | "project";
selectedBindings: string[];
onSelectionChange: (selected: string[]) => void;
onUpdateBinding: (binding: MemberBinding) => void;
onRevokeBinding: (binding: MemberBinding) => void;
}) {
In the roles column, switch based on scope:
<td className="px-4 py-2">
<div className="flex flex-wrap gap-1">
{scope === "project"
? sortRoles(mb.projectRoleBindings.map((b) => b.role)).map((role) => (
<Badge key={role} className="text-xs">
{displayRoleTitle(role)}
</Badge>
))
: sortRoles([...mb.workspaceLevelRoles]).map((role) => (
<Badge key={role} className="text-xs gap-x-1">
<Building2 className="h-3 w-3" />
{displayRoleTitle(role)}
</Badge>
))}
</div>
</td>
Add selectDisabled logic for project scope — members with no project role bindings can't be selected:
// In checkbox column, disable if no project role bindings:
<input
type="checkbox"
checked={selectedBindings.includes(mb.binding)}
onChange={() => toggleOne(mb.binding)}
disabled={scope === "project" && mb.projectRoleBindings.length === 0}
/>
Add scope prop to MemberTableByRole. In project scope, group by projectRoleBindings instead of workspaceLevelRoles:
const roleToBindings = useMemo(() => {
const map = new Map<string, MemberBinding[]>();
for (const mb of bindings) {
const roles = scope === "project"
? mb.projectRoleBindings.map((b) => b.role)
: [...mb.workspaceLevelRoles];
for (const role of roles) {
if (!map.has(role)) map.set(role, []);
map.get(role)!.push(mb);
}
}
const sortedRoles = sortRoles([...map.keys()]);
return sortedRoles.map((role) => ({
role,
members: map.get(role) ?? [],
}));
}, [bindings, scope]);
Remove the <Building2> icon from role headers in project scope (it signifies workspace).
In MembersPage, the revoke/grant operations need to target project IAM policy when in project scope.
For handleRevokeSelected:
const handleRevokeSelected = async () => {
// ... existing self-revoke check ...
if (window.confirm(t("settings.members.revoke-access-alert"))) {
try {
if (projectName && projectIamPolicy) {
const policy = structuredClone(projectIamPolicy);
for (const binding of policy.bindings) {
binding.members = binding.members.filter(
(member) => !selectedMembers.includes(member)
);
}
await projectIamPolicyStore.updateProjectIamPolicy(projectName, policy);
} else {
await workspaceStore.patchIamPolicy(
selectedMembers.map((m) => ({ member: m, roles: [] }))
);
}
pushNotification({ module: "bytebase", style: "INFO", title: t("settings.members.revoked") });
setSelectedMembers([]);
} catch { /* error shown by store */ }
}
};
For handleMemberRevokeBinding:
const handleMemberRevokeBinding = async (binding: MemberBinding) => {
try {
if (projectName && projectIamPolicy) {
const policy = structuredClone(projectIamPolicy);
for (const b of policy.bindings) {
b.members = b.members.filter((m) => m !== binding.binding);
}
await projectIamPolicyStore.updateProjectIamPolicy(projectName, policy);
} else {
await workspaceStore.patchIamPolicy([{ member: binding.binding, roles: [] }]);
}
pushNotification({ module: "bytebase", style: "INFO", title: t("settings.members.revoked") });
} catch { /* error shown by store */ }
};
Pass projectName and project to the drawer. Add project IAM policy mutation support:
function EditMemberRoleDrawer({
member,
projectName,
project,
onClose,
}: {
member?: MemberBinding;
projectName?: string;
project?: Project;
onClose: () => void;
}) {
Add store to component body (NOT inside handlers — hooks must be at component top level):
const projectIamPolicyStore = useProjectIamPolicyStore();
Initialize selectedRoles from the correct scope:
const [selectedRoles, setSelectedRoles] = useState<string[]>(() => {
if (!member) return [];
if (projectName) {
// In project scope, initialize from project role bindings
return member.projectRoleBindings.map((b) => b.role);
}
return [...member.workspaceLevelRoles];
});
In handleSubmit, switch between workspace and project mutations:
if (projectName) {
const policy = structuredClone(projectIamPolicyStore.getProjectIamPolicy(projectName));
if (isEditMode) {
// Remove member from all bindings, then add to selected roles
for (const binding of policy.bindings) {
binding.members = binding.members.filter((m) => m !== member.binding);
}
for (const role of selectedRoles) {
const existing = policy.bindings.find((b) => b.role === role);
if (existing) {
if (!existing.members.includes(member.binding)) {
existing.members.push(member.binding);
}
} else {
policy.bindings.push(create(BindingSchema, { role, members: [member.binding] }));
}
}
} else {
for (const binding of selectedBindings) {
for (const role of selectedRoles) {
const existing = policy.bindings.find((b) => b.role === role);
if (existing) {
if (!existing.members.includes(binding)) {
existing.members.push(binding);
}
} else {
policy.bindings.push(create(BindingSchema, { role, members: [binding] }));
}
}
}
}
await projectIamPolicyStore.updateProjectIamPolicy(projectName, policy);
} else {
// Existing workspace logic
if (isEditMode) {
await workspaceStore.patchIamPolicy([{ member: member.binding, roles: selectedRoles }]);
} else {
const batchPatch = selectedBindings.map((binding) => {
const existedRoles = workspaceStore.findRolesByMember(binding);
return { member: binding, roles: [...new Set([...selectedRoles, ...existedRoles])] };
});
await workspaceStore.patchIamPolicy(batchPatch);
}
}
For handleRevoke in project scope:
if (projectName) {
const policy = structuredClone(projectIamPolicyStore.getProjectIamPolicy(projectName));
for (const binding of policy.bindings) {
binding.members = binding.members.filter((m) => m !== member.binding);
}
await projectIamPolicyStore.updateProjectIamPolicy(projectName, policy);
} else {
await workspaceStore.patchIamPolicy([{ member: member.binding, roles: [] }]);
}
Import BindingSchema from @/types/proto-es/v1/iam_policy_pb and create from @bufbuild/protobuf.
Pass scope to RoleMultiSelect in the drawer so it only shows relevant roles:
<RoleMultiSelect
value={selectedRoles}
onChange={setSelectedRoles}
scope={projectName ? "project" : "workspace"}
/>
Modify frontend/src/react/pages/settings/shared/RoleMultiSelect.tsx to accept an optional scope prop:
export function RoleMultiSelect({
value,
onChange,
disabled,
scope,
}: {
value: string[];
onChange: (roles: string[]) => void;
disabled?: boolean;
scope?: "workspace" | "project";
}) {
In the groups computation, filter by scope:
const groups = useMemo(() => {
const kw = search.toLowerCase();
const matchRole = (name: string) =>
!kw || displayRoleTitle(name).toLowerCase().includes(kw);
const workspace = scope !== "project" ? PRESET_WORKSPACE_ROLES.filter(matchRole) : [];
const project = scope !== "workspace" ? PRESET_PROJECT_ROLES.filter(matchRole) : [];
const custom = roleList
.filter((r) => !PRESET_ROLES.includes(r.name))
.map((r) => r.name)
.filter(matchRole);
// ... rest unchanged
When scope is omitted, all groups are shown (backward-compatible).
At the top of the MembersPage return, before the search bar, add a project-scope description:
{projectName && (
<div className="textinfolabel px-4 pt-4">
{t("project.members.description")}{" "}
<a
href="https://docs.bytebase.com/administration/roles/?source=console#project-roles"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
{t("common.learn-more")}
</a>
</div>
)}
The Vue version shows a "Request Role" button when:
project.allowRequestRole is trueFor now, implement the visibility logic and button. The RoleGrantPanel is a Vue component — use it via a simple state flag that triggers a Vue-rendered panel (or implement inline if simple enough).
Add state and computed values:
const hasRequestRoleFeature = useVueState(() =>
subscriptionStore.hasFeature(PlanFeature.FEATURE_REQUEST_ROLE_WORKFLOW)
);
const projectOwnerPermissions = useVueState(() =>
(roleStore.getRoleByName(PresetRoleType.PROJECT_OWNER)?.permissions ?? []) as Permission[]
);
// Must use useVueState since permissionStore is Vue-reactive
const hasMissingPermission = useVueState(() => {
if (!project) return false;
const currentPerms = permissionStore.currentPermissionsInProjectV1(project);
const workspacePerms = permissionStore.currentPermissions;
return projectOwnerPermissions.some(
(p: Permission) => !workspacePerms.has(p) && !currentPerms.has(p)
);
});
const shouldShowRequestRole = project?.allowRequestRole && hasMissingPermission;
Note: The actual "Request Role" panel creates an issue — this is a complex Vue component (RoleGrantPanel). For the initial migration, add the button but defer the panel implementation. The button can call router.push to the issue creation page with pre-filled params, or we can leave a TODO to integrate the RoleGrantPanel later. Check with the codebase if there's a simpler path.
Update all MemberTable and MemberTableByRole renders to pass the scope:
<MemberTable
bindings={memberBindings}
allowEdit={canSetIamPolicy}
scope={projectName ? "project" : "workspace"}
selectedBindings={selectedMembers}
onSelectionChange={setSelectedMembers}
onUpdateBinding={handleMemberUpdateBinding}
onRevokeBinding={handleMemberRevokeBinding}
/>
<MemberTableByRole
bindings={memberBindings}
allowEdit={canSetIamPolicy}
scope={projectName ? "project" : "workspace"}
onUpdateBinding={handleMemberUpdateBinding}
onRevokeBinding={handleMemberRevokeBinding}
/>
Pass project context to drawer:
<EditMemberRoleDrawer
member={editingMember}
projectName={projectName}
project={project}
onClose={() => {
setShowEditMemberDrawer(false);
setEditingMember(undefined);
}}
/>
Run: pnpm --dir frontend type-check
Run: pnpm --dir frontend fix
git add frontend/src/react/pages/settings/MembersPage.tsx
git commit -m "feat(frontend): add project scope support to MembersPage"
Files:
Create: frontend/src/react/ProjectMembersPageMount.vue
Create: frontend/src/react/ProjectServiceAccountsPageMount.vue
Create: frontend/src/react/ProjectWorkloadIdentitiesPageMount.vue
Modify: frontend/src/router/dashboard/projectV1.ts:334-365
Step 1: Create ProjectMembersPageMount.vue
Follow the exact pattern from frontend/src/react/ProjectGitOpsPageMount.vue:
<template>
<div ref="container" class="h-full" />
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
const props = defineProps<{
projectId: string;
}>();
const { locale } = useI18n();
const container = ref<HTMLElement>();
// biome-ignore lint/suspicious/noExplicitAny: React Root type from dynamic import
let root: any = null; // eslint-disable-line @typescript-eslint/no-explicit-any
async function render() {
if (!container.value) return;
const [{ mountReactPage, updateReactPage }, i18nModule] = await Promise.all([
import("./mount"),
import("./i18n"),
]);
if (i18nModule.default.language !== locale.value) {
await i18nModule.default.changeLanguage(locale.value);
}
const pageProps = { projectId: props.projectId };
if (!root) {
root = await mountReactPage(
container.value,
"MembersPage",
pageProps
);
} else {
await updateReactPage(root, "MembersPage", pageProps);
}
}
onMounted(() => render());
watch(locale, () => render());
watch(
() => props.projectId,
() => render()
);
onUnmounted(() => {
root?.unmount();
root = null;
});
</script>
Same template as Step 1, replacing "MembersPage" with "ServiceAccountsPage".
Same template as Step 1, replacing "MembersPage" with "WorkloadIdentitiesPage".
In frontend/src/router/dashboard/projectV1.ts, update lines 334-365:
// Members route (line 341):
// Before:
component: () => import("@/views/project/ProjectMemberDashboard.vue"),
// After:
component: () => import("@/react/ProjectMembersPageMount.vue"),
// Service Accounts route (lines 351-352):
// Before:
component: () =>
import("@/components/User/Settings/ServiceAccountPanel.vue"),
// After:
component: () => import("@/react/ProjectServiceAccountsPageMount.vue"),
// Workload Identities route (lines 362-363):
// Before:
component: () =>
import("@/components/User/Settings/WorkloadIdentityPanel.vue"),
// After:
component: () => import("@/react/ProjectWorkloadIdentitiesPageMount.vue"),
Run: pnpm --dir frontend type-check
Run: pnpm --dir frontend fix
git add frontend/src/react/ProjectMembersPageMount.vue \
frontend/src/react/ProjectServiceAccountsPageMount.vue \
frontend/src/react/ProjectWorkloadIdentitiesPageMount.vue \
frontend/src/router/dashboard/projectV1.ts
git commit -m "refactor(frontend): migrate project Members, Service Accounts, Workload Identities to React"
Run: pnpm --dir frontend type-check
Run: pnpm --dir frontend check
Run: pnpm --dir frontend test
Run: pnpm --dir frontend dev and manually verify:
http://localhost:3000/members — workspace members (unchanged behavior)
http://localhost:3000/service-accounts — workspace service accounts (unchanged)
http://localhost:3000/workload-identities — workspace workload identities (unchanged)
http://localhost:3000/projects/new-project/members — project members (migrated)
http://localhost:3000/projects/new-project/service-accounts — project service accounts (migrated)
http://localhost:3000/projects/new-project/workload-identities — project workload identities (migrated)
Step 5: Final commit if any fixes needed