Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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') }),
Expand Down Expand Up @@ -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');
Expand All @@ -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,
Expand Down Expand Up @@ -219,13 +235,13 @@ export function OverviewSectionForm({
children={(field) => (
<field.Select
label={t('Who is stewarding this process?')}
isRequired
placeholder={t('Select')}
isDisabled={!isProcessOwner}
selectedKey={field.state.value || null}
onSelectionChange={(key) => 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)}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ const nonEmptyString = z.string().trim().min(1);

const overviewSchema = z.object({
name: nonEmptyString,
stewardProfileId: nonEmptyString,
description: nonEmptyString,
});

Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/lib/i18n/dictionaries/bn.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "এই প্রক্রিয়া যে কৌশলগত ক্ষেত্রগুলি অগ্রসর করে তা নির্বাচন করুন",
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/lib/i18n/dictionaries/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/lib/i18n/dictionaries/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/lib/i18n/dictionaries/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/app/src/lib/i18n/dictionaries/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/services/decision/getDecisionBySlug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const decisionProfileQueryConfig = {
organization: true,
},
},
steward: true,
},
},
},
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/services/decision/getInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const getInstance = async ({ instanceId, user }: GetInstanceInput) => {
with: {
process: true,
owner: true,
steward: true,
proposals: {
columns: {
id: true,
Expand Down
20 changes: 18 additions & 2 deletions packages/common/src/services/decision/updateDecisionInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});