diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx index 223213e9b..b827739e9 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx @@ -105,6 +105,11 @@ const ProcessBuilderHeaderContent = ({ const decisionProfileId = decisionProfile?.id; const validation = useProcessBuilderValidation(decisionProfileId); + const { data: userProfiles } = trpc.account.getUserProfiles.useQuery(); + const isProcessOwner = userProfiles?.some( + (p) => p.id === processInstance?.owner?.id, + ); + const storeData = useProcessBuilderStore((s) => decisionProfileId ? s.instances[decisionProfileId] : undefined, ); @@ -150,7 +155,9 @@ const ProcessBuilderHeaderContent = ({ instanceId, name: storeData?.name || undefined, description: storeData?.description || undefined, - stewardProfileId: storeData?.stewardProfileId || undefined, + stewardProfileId: isProcessOwner + ? storeData?.stewardProfileId || undefined + : undefined, phases: storeData?.phases, proposalTemplate: storeData?.proposalTemplate, config: storeData?.config, diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx index 32c4c0a9a..f963e8827 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx @@ -20,9 +20,7 @@ const AUTOSAVE_DEBOUNCE_MS = 1000; const createOverviewValidator = (t: TranslateFn) => z.object({ - stewardProfileId: z - .string({ message: t('Select a steward for this process') }) - .min(1, { message: t('Select a steward for this process') }), + stewardProfileId: z.string(), name: z .string({ message: t('Enter a process name') }) .min(1, { message: t('Enter a process name') }), @@ -119,6 +117,22 @@ export function OverviewSectionForm({ name: p.name, })); + // Ensure the current steward appears in the dropdown so the Select can + // render its name — even when the viewer isn't the owner and their own + // profile list doesn't include the steward. + if ( + instance.steward && + !profileItems.some((p) => p.id === instance.steward?.id) + ) { + profileItems.push({ + id: instance.steward.id, + name: instance.steward.name ?? '', + }); + } + + // Only the process owner can change the steward + const isProcessOwner = userProfiles?.some((p) => p.id === instance.owner?.id); + // Debounced save: draft persists to API; non-draft only buffers locally. const debouncedSave = useDebouncedCallback((values: OverviewFormData) => { setSaveStatus(decisionProfileId, 'saving'); @@ -141,7 +155,9 @@ export function OverviewSectionForm({ instanceId, name: values.name, description: values.description, - stewardProfileId: values.stewardProfileId || undefined, + stewardProfileId: isProcessOwner + ? values.stewardProfileId || undefined + : undefined, config: { organizeByCategories: values.organizeByCategories, requireCollaborativeProposals: values.requireCollaborativeProposals, @@ -219,13 +235,13 @@ export function OverviewSectionForm({ children={(field) => ( field.handleChange(key as string)} onBlur={field.handleBlur} description={t( - 'The organization, coalition, committee or individual responsible for running this process.', + 'The organization, coalition, committee or individual responsible for running this process. Only the process owner can change the steward.', )} errorMessage={getFieldErrorMessage(field)} > diff --git a/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts b/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts index 65660b59f..286532679 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts @@ -22,7 +22,6 @@ const nonEmptyString = z.string().trim().min(1); const overviewSchema = z.object({ name: nonEmptyString, - stewardProfileId: nonEmptyString, description: nonEmptyString, }); diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index a2e9e19fc..e0642d257 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -538,7 +538,7 @@ "Please try again in a moment": "অনুগ্রহ করে কিছুক্ষণ পরে আবার চেষ্টা করুন", "Process Stewardship": "প্রক্রিয়া পরিচালনা", "Who is stewarding this process?": "এই প্রক্রিয়াটি কে পরিচালনা করছে?", - "The organization, coalition, committee or individual responsible for running this process.": "এই প্রক্রিয়া পরিচালনার জন্য দায়ী সংস্থা, জোট, কমিটি বা ব্যক্তি।", + "The organization, coalition, committee or individual responsible for running this process. Only the process owner can change the steward.": "এই প্রক্রিয়া পরিচালনার জন্য দায়ী সংস্থা, জোট, কমিটি বা ব্যক্তি। শুধুমাত্র প্রক্রিয়ার মালিক তত্ত্বাবধায়ক পরিবর্তন করতে পারেন।", "Primary focus areas": "প্রাথমিক ফোকাস ক্ষেত্র", "Search focus areas": "ফোকাস ক্ষেত্র খুঁজুন", "Select the strategic areas this process advances": "এই প্রক্রিয়া যে কৌশলগত ক্ষেত্রগুলি অগ্রসর করে তা নির্বাচন করুন", diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 888431b00..5e80b1352 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -531,7 +531,7 @@ "Please try again in a moment": "Please try again in a moment", "Process Stewardship": "Process Stewardship", "Who is stewarding this process?": "Who is stewarding this process?", - "The organization, coalition, committee or individual responsible for running this process.": "The organization, coalition, committee or individual responsible for running this process.", + "The organization, coalition, committee or individual responsible for running this process. Only the process owner can change the steward.": "The organization, coalition, committee or individual responsible for running this process. Only the process owner can change the steward.", "Primary focus areas": "Primary focus areas", "Search focus areas": "Search focus areas", "Select the strategic areas this process advances": "Select the strategic areas this process advances", diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index b40d0ebd7..43aaf7c96 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -530,7 +530,7 @@ "Please try again in a moment": "Por favor, intenta de nuevo en un momento", "Process Stewardship": "Administración del proceso", "Who is stewarding this process?": "¿Quién está administrando este proceso?", - "The organization, coalition, committee or individual responsible for running this process.": "La organización, coalición, comité o individuo responsable de ejecutar este proceso.", + "The organization, coalition, committee or individual responsible for running this process. Only the process owner can change the steward.": "La organización, coalición, comité o individuo responsable de ejecutar este proceso. Solo el propietario del proceso puede cambiar el administrador.", "Primary focus areas": "Áreas de enfoque principales", "Search focus areas": "Buscar áreas de enfoque", "Select the strategic areas this process advances": "Seleccione las áreas estratégicas que este proceso avanza", diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index a3d5f3b7f..f0d896785 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -531,7 +531,7 @@ "Please try again in a moment": "Veuillez réessayer dans un instant", "Process Stewardship": "Gestion du processus", "Who is stewarding this process?": "Qui gère ce processus ?", - "The organization, coalition, committee or individual responsible for running this process.": "L'organisation, la coalition, le comité ou l'individu responsable de l'exécution de ce processus.", + "The organization, coalition, committee or individual responsible for running this process. Only the process owner can change the steward.": "L'organisation, la coalition, le comité ou l'individu responsable de l'exécution de ce processus. Seul le propriétaire du processus peut modifier le responsable.", "Primary focus areas": "Domaines d'intervention principaux", "Search focus areas": "Rechercher des domaines d'intervention", "Select the strategic areas this process advances": "Sélectionnez les domaines stratégiques que ce processus fait progresser", diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 04059e535..367093a5f 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -531,7 +531,7 @@ "Please try again in a moment": "Por favor, tente novamente em instantes", "Process Stewardship": "Administração do processo", "Who is stewarding this process?": "Quem está administrando este processo?", - "The organization, coalition, committee or individual responsible for running this process.": "A organização, coalizão, comitê ou indivíduo responsável por executar este processo.", + "The organization, coalition, committee or individual responsible for running this process. Only the process owner can change the steward.": "A organização, coalizão, comitê ou indivíduo responsável por executar este processo. Apenas o proprietário do processo pode alterar o administrador.", "Primary focus areas": "Áreas de foco principais", "Search focus areas": "Pesquisar áreas de foco", "Select the strategic areas this process advances": "Selecione as áreas estratégicas que este processo avança", diff --git a/packages/common/src/services/decision/createInstanceFromTemplate.ts b/packages/common/src/services/decision/createInstanceFromTemplate.ts index 09e727926..dee4cf017 100644 --- a/packages/common/src/services/decision/createInstanceFromTemplate.ts +++ b/packages/common/src/services/decision/createInstanceFromTemplate.ts @@ -27,9 +27,10 @@ export type CreateInstanceFromTemplateCoreOptions = { description?: string; phases?: PhaseOverride[]; ownerProfileId: string; + /** Defaults to ownerProfileId when not provided */ + stewardProfileId?: string; creatorAuthUserId: string; creatorEmail: string; - stewardProfileId?: string; /** Defaults to DRAFT */ status?: ProcessStatus; }; @@ -45,9 +46,9 @@ export const createInstanceFromTemplateCore = async ({ description, phases, ownerProfileId, + stewardProfileId = ownerProfileId, creatorAuthUserId, creatorEmail, - stewardProfileId, status = ProcessStatus.DRAFT, }: CreateInstanceFromTemplateCoreOptions) => { const template = await getTemplate(templateId); diff --git a/packages/common/src/services/decision/getDecisionBySlug.ts b/packages/common/src/services/decision/getDecisionBySlug.ts index bc5b4d0d2..7d3fb23c2 100644 --- a/packages/common/src/services/decision/getDecisionBySlug.ts +++ b/packages/common/src/services/decision/getDecisionBySlug.ts @@ -23,6 +23,7 @@ const decisionProfileQueryConfig = { organization: true, }, }, + steward: true, }, }, }, diff --git a/packages/common/src/services/decision/getInstance.ts b/packages/common/src/services/decision/getInstance.ts index 9967c9737..41ecbcedf 100644 --- a/packages/common/src/services/decision/getInstance.ts +++ b/packages/common/src/services/decision/getInstance.ts @@ -20,6 +20,7 @@ export const getInstance = async ({ instanceId, user }: GetInstanceInput) => { with: { process: true, owner: true, + steward: true, proposals: { columns: { id: true, diff --git a/packages/common/src/services/decision/updateDecisionInstance.ts b/packages/common/src/services/decision/updateDecisionInstance.ts index e0aba7f10..5d9a916ea 100644 --- a/packages/common/src/services/decision/updateDecisionInstance.ts +++ b/packages/common/src/services/decision/updateDecisionInstance.ts @@ -8,7 +8,7 @@ import { import type { User } from '@op/supabase/lib'; import { assertAccess, permission } from 'access-zones'; -import { CommonError, NotFoundError } from '../../utils'; +import { CommonError, NotFoundError, UnauthorizedError } from '../../utils'; import { getProfileAccessUser } from '../access'; import { schemaValidator } from './schemaValidator'; import type { @@ -97,7 +97,23 @@ export const updateDecisionInstance = async ({ updateData.status = status; } - if (stewardProfileId !== undefined) { + if ( + stewardProfileId !== undefined && + stewardProfileId !== existingInstance.stewardProfileId + ) { + const ownerProfileUser = existingInstance.ownerProfileId + ? await getProfileAccessUser({ + user, + profileId: existingInstance.ownerProfileId, + }) + : undefined; + + if (!ownerProfileUser) { + throw new UnauthorizedError( + 'Only the process owner can change the steward', + ); + } + updateData.stewardProfileId = stewardProfileId; } diff --git a/services/api/src/routers/decision/instances/updateDecisionInstance.test.ts b/services/api/src/routers/decision/instances/updateDecisionInstance.test.ts index ca3646196..2274227c7 100644 --- a/services/api/src/routers/decision/instances/updateDecisionInstance.test.ts +++ b/services/api/src/routers/decision/instances/updateDecisionInstance.test.ts @@ -673,4 +673,121 @@ describe.concurrent('updateDecisionInstance', () => { expect(phase.name).toBe(`Updated ${phase.phaseId}`); } }); + + it('should allow process owner to update steward', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + const caller = await createAuthenticatedCaller(setup.userEmail); + + // Use the org profile as the new steward (it's a valid profile the owner controls) + const newStewardId = setup.organization.profileId; + + const result = await caller.decision.updateDecisionInstance({ + instanceId: instance.instance.id, + stewardProfileId: newStewardId, + }); + + expect(result.processInstance.id).toBe(instance.instance.id); + + // Verify the steward was updated in the database + const dbInstance = await db._query.processInstances.findFirst({ + where: eq(processInstances.id, instance.instance.id), + }); + + expect(dbInstance!.stewardProfileId).toBe(newStewardId); + }); + + it('should not allow non-owner admin to change steward', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + // Create a member user and grant them admin access on the decision profile + // (skip instanceProfileIds to avoid duplicate profileUsers rows) + const memberUser = await testData.createMemberUser({ + organization: setup.organization, + }); + + // Grant admin access on the decision profile so they pass the general + // admin check but are still not the process owner + await testData.grantProfileAccess( + instance.profileId, + memberUser.authUserId, + memberUser.email, + ); + + const memberCaller = await createAuthenticatedCaller(memberUser.email); + + // Non-owner admin should NOT be able to change the steward + await expect( + memberCaller.decision.updateDecisionInstance({ + instanceId: instance.instance.id, + stewardProfileId: memberUser.profileId, + }), + ).rejects.toThrow(); + }); + + it('should allow non-owner admin to update other fields without changing steward', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + // Create a member user and grant them admin access on the decision profile + // (skip instanceProfileIds to avoid duplicate profileUsers rows) + const memberUser = await testData.createMemberUser({ + organization: setup.organization, + }); + + await testData.grantProfileAccess( + instance.profileId, + memberUser.authUserId, + memberUser.email, + ); + + const memberCaller = await createAuthenticatedCaller(memberUser.email); + + // Non-owner admin should still be able to update other fields + const newName = `Updated by member ${task.id}`; + const result = await memberCaller.decision.updateDecisionInstance({ + instanceId: instance.instance.id, + name: newName, + }); + + expect(result.processInstance.name).toBe(newName); + }); });