Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3225f02
DN-3759 feat: Added related users to the project and context
danielle-devv Jun 1, 2026
aa8d29c
DN-3759 feat: User array support
danielle-devv Jun 1, 2026
753c4aa
DN-3759 feat: Support for user arrays
danielle-devv Jun 1, 2026
097d3c1
DN-3759 feat: User pending logic from the new user ticket
danielle-devv Jun 2, 2026
5c22132
Merge branch 'main' into feature/DN-3759
danielle-devv Jun 11, 2026
b3e5f05
DN-3759 feat: Added titles and some merge fixes
danielle-devv Jun 11, 2026
4778afd
Merge remote-tracking branch 'origin/main' into feature/DN-3759
Jun 22, 2026
173eec8
DN-3759 Removed duplicate enum
Jun 22, 2026
d78a0b5
Merge remote-tracking branch 'origin/main' into feature/DN-3759
Jun 24, 2026
f9e3c09
DN-3759 Added api endpoint for editing email addresses of external users
Jun 25, 2026
b141771
DN-3759 Small fixes for InvitationState being null
Jun 25, 2026
7c88f70
DN-3759 Added RelatedUsers and RelatedUserGroups to inheritance flow.
Jun 25, 2026
928d805
DN-3759 Refactored 'isPending' to 'requiresInvitation'
Jun 26, 2026
fe7349b
DN-3759 fixed build issue
Jun 26, 2026
bdf2841
Merge remote-tracking branch 'origin/main' into feature/DN-3759
Jun 30, 2026
639a525
DN-3759 Added RelatedUsers preprocessing and moved the translations
Jun 30, 2026
6c13dde
DN-3759 Renamed Adviser to Advisor
Jul 1, 2026
733274a
Merge remote-tracking branch 'origin/main' into feature/DN-3759
Jul 2, 2026
c6e2d41
DN-3759 Added separate service for updating "external user" emails
Jul 2, 2026
2139138
DN-3759 Added update to instance data after external user invitations
Jul 2, 2026
c103e15
DN-3759 Unit test updates
Jul 2, 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
19 changes: 19 additions & 0 deletions Examples/Projects/Context/Entity.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,32 @@ properties:

- name: Coordinator
type: "[User]!"
text:
en: Coordinator
nl: Coördinator

- name: Impersonator
type: "[User]!"
text:
en: Impersonator
nl: Impersonator

- name: StudyManual
type: String!

- name: ConfidentialAdvisor
type: User!
text:
en: Confidential advisor
nl: Vertrouwenspersoon

- name: StudyAdvisor
type: User!
text:
en: Study advisor
nl: Studieadviseur


- name: GradingBasis
type: GradingBasis!
values:
Expand Down
24 changes: 24 additions & 0 deletions Examples/Projects/Project/Entity.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,27 @@ steps:
- Upload
- Assessment
- Publication

relatedUsers:
- property: Supervisor
group: default
- property: Examiner
group: default
- property: Reviewer
group: default
- property: SecondReader
group: default
- property: Course.Coordinator
group: support
- property: Course.ConfidentialAdvisor
group: support
- property: Course.StudyAdvisor
group: support

relatedUserGrouping:
groups:
- name: default
- name: support
title:
en: Other involved staff
nl: Andere betrokkenen
8 changes: 8 additions & 0 deletions Examples/Projects/Project/Properties.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ properties:
text:
en: Examiner
nl: Examinator

- name: Supervisor
type: User!
allowsExternalUsers: true
Expand All @@ -39,6 +40,13 @@ properties:
text:
en: Reviewer
nl: Beoordelaar

- name: SecondReader
type: User!
text:
en: Second reader
nl: Tweede beoordelaar

- name: Student
type: User!

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using UvA.Workflow.Api.Assessments.Dtos;
using UvA.Workflow.Api.Screens;
using UvA.Workflow.Api.Submissions.Dtos;
using UvA.Workflow.Api.Users;
using UvA.Workflow.Api.WorkflowInstances;
using UvA.Workflow.Api.WorkflowInstances.Dtos;

Expand All @@ -26,6 +27,7 @@ public static IServiceCollection AddWorkflowApiCore(this IServiceCollection serv
services.AddScoped<InstanceAuthorizationFilterService>();
services.AddScoped<RoleImpersonationService>();
services.AddScoped<IImpersonationContextService>(sp => sp.GetRequiredService<RoleImpersonationService>());
services.AddScoped<ExternalUserEmailUpdateService>();

return services;
}
Expand Down
7 changes: 7 additions & 0 deletions UvA.Workflow.Api/Users/Dtos/UpdateUserEmailDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using System.ComponentModel.DataAnnotations;

namespace UvA.Workflow.Api.Users.Dtos;

public record UpdateUserEmailDto(
[Required] [EmailAddress] string Email,
[Required] string InstanceId);
13 changes: 11 additions & 2 deletions UvA.Workflow.Api/Users/Dtos/UserDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public record UserDto(
string? PreferredLanguage,
Organization? Organization,
bool IsExternal,
bool IsSuperAdmin
bool IsSuperAdmin,
bool RequiresInvitation
)
{
/// <summary>
Expand All @@ -29,7 +30,15 @@ public static UserDto Create(User user, bool isSuperAdmin = false)
user.PreferredLanguage,
user.Organization,
UserProviderKeys.IsExternal(user.ProviderKey),
isSuperAdmin
isSuperAdmin,
user.InvitationState == UserInvitationState.Required
);
}

/// <summary>
/// Creates a UserDto from an Instance User entity
/// </summary>
public static UserDto CreateFromInstanceUser(InstanceUser u) =>
new(u.Id, u.UserName, u.DisplayName, u.Email, u.PreferredLanguage, u.Organization, u.IsExternal,
false, u.InvitationState == UserInvitationState.Required);
}
135 changes: 135 additions & 0 deletions UvA.Workflow.Api/Users/ExternalUserEmailUpdateService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
using System.Text.Json;
using UvA.Workflow.Submissions;

namespace UvA.Workflow.Api.Users;

public enum ExternalUserEmailAnswerUpdateResult
{
Updated,
UserNotInAnswer,
Forbidden
}

public record ExternalUserEmailAnswerUpdatePlan(
ExternalUserEmailAnswerUpdateResult Result,
IReadOnlyCollection<QuestionContext> EditableContexts);

public class ExternalUserEmailUpdateService(
RightsService rightsService,
AnswerService answerService,
ModelService modelService)
{
public static bool CanUpdateExternalUserEmail(User user)
=> UserProviderKeys.IsExternal(user.ProviderKey) &&
user.InvitationState == UserInvitationState.Required;

public async Task<ExternalUserEmailAnswerUpdatePlan> PrepareAnswerReferenceUpdate(
WorkflowInstance instance,
User user,
CancellationToken ct)
{
var contexts = GetMatchingUserQuestionContexts(instance, user.Id).ToArray();
if (contexts.Length == 0)
return new ExternalUserEmailAnswerUpdatePlan(
ExternalUserEmailAnswerUpdateResult.UserNotInAnswer,
[]);

var editableContexts = new List<QuestionContext>();
foreach (var context in contexts)
{
if (await CanEdit(context))
editableContexts.Add(context);
}

if (editableContexts.Count == 0)
return new ExternalUserEmailAnswerUpdatePlan(
ExternalUserEmailAnswerUpdateResult.Forbidden,
[]);

return new ExternalUserEmailAnswerUpdatePlan(
ExternalUserEmailAnswerUpdateResult.Updated,
editableContexts);
}

public async Task UpdateAnswerReferences(
ExternalUserEmailAnswerUpdatePlan plan,
User user,
CancellationToken ct)
{
foreach (var context in plan.EditableContexts)
{
if (!TryCreateUpdatedUserAnswerValue(context, user, out var updatedAnswerValue))
continue;

await answerService.SaveAnswer(context, updatedAnswerValue, ct);
}
}

private IEnumerable<QuestionContext> GetMatchingUserQuestionContexts(WorkflowInstance instance, string userId)
{
var workflowDefinition = modelService.WorkflowDefinitions[instance.WorkflowDefinition];
foreach (var form in workflowDefinition.Forms)
{
var submissionState = FormSubmissionState.Resolve(instance, form, workflowDefinition);
foreach (var question in form.PropertyDefinitions.Where(q => q.DataType == DataType.User))
{
var context = new QuestionContext(instance, submissionState, form, question);
if (ContainsUserAnswerValue(context, userId))
yield return context;
}
}
}

private static bool ContainsUserAnswerValue(QuestionContext context, string userId)
{
var currentAnswer = context.Instance.GetProperty(context.Form.PropertyName, context.PropertyDefinition.Name);
if (currentAnswer == null || currentAnswer.IsBsonNull)
return false;

if (context.PropertyDefinition.IsArray)
{
var users = ObjectContext.GetValue(currentAnswer, context.PropertyDefinition) as InstanceUser[];
return users?.Any(u => u.Id == userId) == true;
}

var answerUser = ObjectContext.GetValue(currentAnswer, context.PropertyDefinition) as InstanceUser;
return answerUser?.Id == userId;
}

private static bool TryCreateUpdatedUserAnswerValue(
QuestionContext context,
User user,
out JsonElement? value)
{
var currentAnswer = context.Instance.GetProperty(context.Form.PropertyName, context.PropertyDefinition.Name);
value = null;
if (currentAnswer == null || currentAnswer.IsBsonNull)
return false;

var updatedUser = InstanceUser.FromUser(user);
if (context.PropertyDefinition.IsArray)
{
var users = ObjectContext.GetValue(currentAnswer, context.PropertyDefinition) as InstanceUser[];
if (users == null || users.All(u => u.Id != user.Id))
return false;

value = JsonSerializer.SerializeToElement(
users.Select(u => u.Id == user.Id ? updatedUser : u).ToArray(),
AnswerConversionService.Options);
return true;
}

var answerUser = ObjectContext.GetValue(currentAnswer, context.PropertyDefinition) as InstanceUser;
if (answerUser?.Id != user.Id)
return false;

value = JsonSerializer.SerializeToElement(updatedUser, AnswerConversionService.Options);
return true;
}

private async Task<bool> CanEdit(QuestionContext context) =>
await rightsService.Can(context.Instance,
[context.SubmissionState.IsSubmitted ? RoleAction.Edit : RoleAction.Submit],
RightsEvaluationMode.RequestContext,
context.Form.Name);
}
56 changes: 56 additions & 0 deletions UvA.Workflow.Api/Users/UsersController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@ namespace UvA.Workflow.Api.Users;
public class UsersController(
IUserService userService,
IUserRepository userRepository,
IWorkflowInstanceRepository workflowInstanceRepository,
RightsService rightsService,
IEduIdUserService eduIdUserService,
HttpContextCurrentUserAccessor realUserAccessor,
UserImpersonationTokenService userImpersonationTokenService,
ExternalUserEmailUpdateService externalUserEmailUpdateService,
ILogger<UsersController> logger) : ApiControllerBase
{
private const string ValidEmailStatus = "Valid";
private const string ManualUserInternalEmailCode = "ManualUserInternalEmail";
private const string ManualUserEmailAlreadyExistsCode = "ManualUserEmailAlreadyExists";
private const string InvalidEmailAddressCode = "InvalidEmailAddress";
private const string ImpersonationTargetNotFoundCode = "ImpersonationTargetNotFound";
private const string UserNotInAnswerCode = "UserNotInAnswer";

private const string InternalEmailMessage = "Internal email address";
private const string DuplicateEmailMessage = "Email already exists";
Expand Down Expand Up @@ -94,6 +97,59 @@ public async Task<ActionResult<UserDto>> GetById(string id, CancellationToken ct
return Ok(UserDto.Create(user));
}

[HttpPut("{id}/email")]
public async Task<ActionResult<UserDto>> UpdateEmail(
string id,
[FromBody] UpdateUserEmailDto dto,
CancellationToken ct)
{
var instance = await workflowInstanceRepository.GetById(dto.InstanceId, ct);
if (instance == null)
return WorkflowInstanceNotFound;

var user = await userRepository.GetById(id, ct);
if (user == null)
return UserNotFound;

if (!ExternalUserEmailUpdateService.CanUpdateExternalUserEmail(user))
{
return Unprocessable(
"UserEmailUpdateNotAllowed",
"Email address can only be updated for external users that have not started an invitation");
}

var updatePlan = await externalUserEmailUpdateService.PrepareAnswerReferenceUpdate(instance, user, ct);
switch (updatePlan.Result)
{
case ExternalUserEmailAnswerUpdateResult.UserNotInAnswer:
return Unprocessable(UserNotInAnswerCode, UserNotInAnswerCode);
case ExternalUserEmailAnswerUpdateResult.Forbidden:
return Forbidden();
}

if (user.Email != dto.Email)
{
var emailValidationResult = await ValidateEmail(dto.Email, ct);
if (emailValidationResult != null)
return emailValidationResult;
}

var previousEmail = user.Email;
var email = dto.Email.Trim();
var userChanged = !string.Equals(user.Email, email, StringComparison.Ordinal);
if (!userChanged)
return Ok(UserDto.Create(user));

user.Email = email;
if (string.Equals(user.UserName, previousEmail, StringComparison.OrdinalIgnoreCase))
user.UserName = email;

await userRepository.Update(user, ct);
await externalUserEmailUpdateService.UpdateAnswerReferences(updatePlan, user, ct);

return Ok(UserDto.Create(user));
}

[HttpGet("find")]
public async Task<ActionResult<IEnumerable<UserSearchResultDto>>> Find(string query,
[FromQuery] bool includeExternalUsers = true, CancellationToken ct = default)
Expand Down
21 changes: 19 additions & 2 deletions UvA.Workflow.Api/WorkflowInstances/Dtos/WorkflowInstanceDto.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using UvA.Workflow.Api.Submissions.Dtos;
using UvA.Workflow.Api.Users.Dtos;
using UvA.Workflow.Api.WorkflowDefinitions.Dtos;
using UvA.Workflow.Events;
using UvA.Workflow.Notifications;
Expand All @@ -24,7 +25,8 @@ public record WorkflowInstanceDto(
RoleAction[] Permissions,
bool CanUseAdminTools,
bool CanImpersonate,
string[] ViewerRoles
string[] ViewerRoles,
RelatedUserGroupsDto RelatedUserGroups
);

public record FieldDto(string? Key, BilingualString Title, object? Value);
Expand Down Expand Up @@ -120,4 +122,19 @@ public static InstanceEventDto Create(InstanceEvent instanceEvent)
{
return new InstanceEventDto(instanceEvent.Id, instanceEvent.Date);
}
}
}

public record RelatedUserDto(
BilingualString Title,
UserDto? User
);

public record RelatedUserGroupDto(
string Name,
BilingualString Title,
RelatedUserDto[] Users
);

public record RelatedUserGroupsDto(
RelatedUserGroupDto[] Groups
);
Loading
Loading