Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
ed8cfe6
Add SitePermissionManager service and wire issue #189 triggers
Copilot Aug 10, 2026
38b8fa5
Remove docs/service-layer-pattern.md (rationale kept in chat only)
Copilot Aug 10, 2026
105169b
Apply remaining changes
Copilot Aug 10, 2026
2c0ed29
fix: sync site permissions when team member roles are changed or remo…
Copilot Aug 10, 2026
26bde25
fix: map can_add_site_pages to ROLE_MANAGER (not ROLE_ADMIN) and ROLE…
Copilot Aug 10, 2026
2ec5bbc
feat: site admin UI warning and team attribution; fix removal logic t…
Copilot Aug 10, 2026
ee7d715
Fix upgrade: require_once SitePermissionManager before direct instant…
Copilot Aug 10, 2026
f90a766
Remove syncAllSitePermissions from upgrade; expose as config form button
Copilot Aug 10, 2026
c322631
Fix ROLE_ADMIN constant, remove exclusion logic, update sync language
Copilot Aug 10, 2026
93ec134
Fix ROLE_ADMIN constant, remove exclusion logic, update language to r…
Copilot Aug 10, 2026
565999c
Refresh TeamUser entity in syncSitePermissionsForUser to fix stale ro…
Copilot Aug 10, 2026
cb06e0a
Use syncAllSitePermissions at end of team edit, matching sync button …
Copilot Aug 10, 2026
b699f7c
fix: only use the sync method
alexdryden Aug 10, 2026
90793c0
fix: return the devolop version and just add a single sync all
alexdryden Aug 10, 2026
a5b0765
fix: single sync at the end
alexdryden Aug 10, 2026
eb4c007
fix: restore to pre feat state
alexdryden Aug 10, 2026
2c4bea1
fix: single sync at the end
alexdryden Aug 10, 2026
ebe86eb
fix: add logging
alexdryden Aug 11, 2026
29682df
Add detailed debug logging and fix $omekaRole typo in SitePermissionM…
Copilot Aug 11, 2026
a060432
Fix Logger type hint: use Laminas\Log\Logger instead of controller pl…
Copilot Aug 11, 2026
759efcc
chore: detailed logging
alexdryden Aug 11, 2026
9a0a4e8
fix: typo
alexdryden Aug 11, 2026
ff455a5
fix: typo
alexdryden Aug 11, 2026
5fcd49a
Fix partial DQL polluting TeamRole identity map with incomplete entities
Copilot Aug 11, 2026
0980830
refactor: old, weird code with api calls mostly
alexdryden Aug 11, 2026
75e6cd9
Refactor controllers to use API; clean up dead imports and dead code
Copilot Aug 11, 2026
84dfaa5
Fix review issues: logging level, unused var, XSS protection in inlin…
Copilot Aug 11, 2026
b6f5f83
Fix validateRequest to handle UPDATE operation in AbstractTeamEntityA…
Copilot Aug 11, 2026
7e551a7
Remove userAction/route/TeamCompactForm; use API for team-site; refac…
Copilot Aug 11, 2026
c7b7a24
fix: role edits, team-site domain error, null sites crash, and users …
Copilot Aug 11, 2026
36d2d89
fix: show warning banner only when teams actually manage users on the…
Copilot Aug 11, 2026
c535a9b
fix: normalise team/user keys before validation in AbstractTeamEntity…
Copilot Aug 11, 2026
f7979e6
Clean up change-referencing comments; keep only code-explaining comments
Copilot Aug 11, 2026
ad77ff2
Fix PHP parse errors: remove duplicate code in TeamForm.php and Updat…
Copilot Aug 11, 2026
b4eb6f4
Fix addCsrf() call, Containerinterface typo, remove unused import; up…
Copilot Aug 11, 2026
86247e4
chore: planning full team add/edit refactor
Copilot Aug 11, 2026
c2b7310
Apply remaining changes
Copilot Aug 11, 2026
3657935
fix: restore TeamSitesAddRemoveForm to extend Form so prepare() works
Copilot Aug 11, 2026
880b045
fix: remove unused setData() call on SecondaryResourcesForm (Fieldset)
Copilot Aug 12, 2026
d13bb63
refactor: follow Omeka S form pattern for team add/edit pages
Copilot Aug 12, 2026
bb2889c
Fix role assignment bug, teams column, deprecation warning, and conso…
Copilot Aug 12, 2026
a2b1567
fix: add allow empty for select fields
alexdryden Aug 12, 2026
b0d0f46
fix: normalize team_sites field name and fix o:site allow_empty valid…
Copilot Aug 12, 2026
4bb1ec8
fix: replace TeamSitesFieldset with direct AllSiteSelect on TeamForm;…
Copilot Aug 12, 2026
4ce1a5b
fix: restore chosen-select class to sites
alexdryden Aug 12, 2026
4d33a40
Apply remaining changes
Copilot Aug 12, 2026
398854b
fix: replace nested API calls in hydrate with direct EM ops; fix unde…
Copilot Aug 12, 2026
227749c
Fix DQL reserved keyword 'is' alias and item-set-only filtering in Te…
Copilot Aug 12, 2026
0c8ce25
Fix deselection of item sets and resource templates in team form JS
Copilot Aug 12, 2026
7a17b86
Replace syncAllSitePermissions with targeted sync calls in UpdateCont…
Copilot Aug 12, 2026
96ea4d2
refactor: move site-permission sync into TeamUserAdapter and TeamSite…
Copilot Aug 12, 2026
c357dfa
Fix three site permission bugs: remove uses stale TeamSite row, sync …
Copilot Aug 12, 2026
059a2b1
Refactor SitePermissionManager to use API; build template rows server…
Copilot Aug 12, 2026
4af3fc7
Use real PHP loop indices in rendered permission rows
Copilot Aug 12, 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
34 changes: 29 additions & 5 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ Omeka S Module (PHP-based)
### PHP Standards
- Follow PSR-12 coding standards where applicable
- Use proper type hints and return types
- Document complex logic with clear comments
- Document complex logic with inline comments only where strictly needed; comments must describe the current code, never reference previous versions or prior fixes
- Class and method docblocks follow Python/Google style: a single summary sentence, followed by an optional blank line and longer description, `Args:`/`Returns:`/`Throws:` sections where appropriate, and `@param`/`@return` PHPDoc tags for type information
- Prefer dependency injection over global state
- Use Omeka S service manager patterns

Expand All @@ -93,13 +94,36 @@ Omeka S Module (PHP-based)

## Build, Validation, and Testing

### Linting — Always Run

**Always run `php -l` on every PHP file you create or modify before committing.** This catches parse errors immediately:

```bash
php -l path/to/File.php
# or check all files at once:
find . -name "*.php" -print0 | xargs -0 php -l | grep -v "No syntax errors"
```

### Omeka S Coding Standards

Omeka S ships a PHP_CodeSniffer ruleset. When the Omeka S codebase is present, run it against changed files:

```bash
vendor/bin/phpcs --standard=vendor/omeka/omeka-s/coding-standards/Omeka/ruleset.xml src/Path/To/ChangedFile.php
```

Even without the full Omeka environment, apply these standards manually:
- No `addCsrf()` calls — Omeka S adds CSRF to **all** forms automatically via its `Omeka\Form\Initializer\Csrf` initializer. Never call `addCsrf()` manually.
- Forms extend `Laminas\Form\Form` (or an Omeka subclass); the initializer handles CSRF.
- Verify every `use` import is spelled correctly (case-sensitive on Linux) and actually resolves.
- Remove all unused `use` statements.

### Current State
**No explicit build steps, CI/CD pipelines, or automated validation are currently present** in this repository.
**No CI/CD pipelines or automated test suites are currently present** in this repository beyond `php -l`.

### What This Means for Agents
- **Skip automatic build/validate steps** unless explicitly directed by task requirements
- **No linting, testing, or compilation** commands to run by default
- **Manual validation** may be performed by viewing files and inspecting code
- **Always run `php -l`** on every changed PHP file — this is mandatory, not optional
- **Manual validation** should also check method existence against Omeka S source (search GitHub omeka/omeka-s if needed)
- **Future improvements**: Agents may recommend or prototype build/test infrastructure as part of code quality improvements

### Testing in Omeka Context
Expand Down
173 changes: 81 additions & 92 deletions Module.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use Teams\Form\Element\BlankTeamSelect;
use Teams\Form\Element\RoleSelect;
use Teams\Form\Element\TeamSelect;
use Teams\Service\SitePermissionManager;
use Omeka\Api\Adapter\ItemAdapter;
use Omeka\Api\Adapter\ItemSetAdapter;
use Omeka\Api\Adapter\MediaAdapter;
Expand Down Expand Up @@ -190,16 +191,16 @@ public function upgrade($oldVersion, $newVersion, ServiceLocatorInterface $servi
$globalSettings = $serviceLocator->get('Omeka\Settings');
$globalSettings->set('teams_filter_bypass_roles', ["global_admin"]);
}
if (version_compare($oldVersion, '4.2.0', '<')) {
// Site permission sync cannot run here: module entities are not
// registered with Doctrine during the upgrade step. Use the
// "Sync Site Permissions" button on the module config page instead.
}
}

public function updateAllUserSites()
{
$em = $this->getServiceLocator()->get('Omeka\EntityManager');
$active_users = $em->getRepository('Teams\Entity\TeamUser')->findAllBy(['is_active' => true]);

foreach ($active_users as $user) {
$this->updateUserSites($user->getUser()->getId());
}
$this->getServiceLocator()->get(SitePermissionManager::class)->updateAllUserDefaultSites();
}

public function handleConfigForm(AbstractController $controller)
Expand All @@ -213,6 +214,9 @@ public function handleConfigForm(AbstractController $controller)
$globalSettings->set('teams_site_admin_make_user', $params['teams_site_admin_make_user']);
$globalSettings->set('teams_filter_bypass_roles', $params['teams_filter_bypass_roles']);

if (!empty($params['teams_sync_site_permissions'])) {
$this->getServiceLocator()->get(SitePermissionManager::class)->syncAllSitePermissions();
}
}

public function getConfigForm(PhpRenderer $renderer)
Expand Down Expand Up @@ -955,16 +959,18 @@ public function userCreate(Event $event)
}
}
$role_id = $team_role_ids[$team_id];
$role = $em->getRepository('Teams\Entity\TeamRole')
->findOneBy(['id' => $role_id]);
$team_user_exists = $em->getRepository('Teams\Entity\TeamUser')
->findOneBy(['team' => $team->getId(), 'user' => $user_id]);
if (!$team_user_exists) {
$team_user = new TeamUser($team, $user, $role);
$em->persist($team_user);
// Create through the adapter so site-permission syncing is
// handled automatically.
$this->getServiceLocator()->get('Omeka\ApiManager')->create('team-user', [
'team' => $team->getId(),
'user' => $user_id,
'role' => (int) $role_id,
]);
}
}
$em->flush();
if ($default_team) {
$em->getRepository('Teams\Entity\TeamUser')
->findOneBy(['team' => $default_team, 'user' => $user_id])
Expand Down Expand Up @@ -1039,27 +1045,7 @@ public function updateItemSites($item_id)

public function updateUserSites($user_id)
{
$em = $this->getServiceLocator()->get('Omeka\EntityManager');

$userSettings = $this->getServiceLocator()->get('Omeka\Settings\User');

$site_ids = [];
$settingId = 'default_item_sites';

$active_team = $em->getRepository('Teams\Entity\TeamUser')
->findOneBy(['user' => $user_id, 'is_current' => true]);
if ($active_team) {
$active_team = $active_team->getTeam();

$team_sites = $active_team->getTeamSites();

foreach ($team_sites as $team_site):
$site_ids[] = $team_site->getSite()->getId();
endforeach;

//update default sites
$userSettings->set($settingId, $site_ids, $user_id);
}
$this->getServiceLocator()->get(SitePermissionManager::class)->updateUserDefaultSites($user_id);
}

/**
Expand Down Expand Up @@ -1154,33 +1140,17 @@ public function siteCreate(Event $event)

$team_ids = $request->getContent()['team'];

$all_teams_users = [];
$all_team_resources = [];

//add team sites
// Create team-site associations through the adapter so that
// site-permission syncing is handled automatically.
$api = $this->getServiceLocator()->get('Omeka\ApiManager');
foreach ($team_ids as $team_id):
$team = $teams->findOneBy(['id' => $team_id]);
$team_site = new TeamSite($team, $site);
$em->persist($team_site);

//get team users
$all_teams_users[] = $team->getTeamUsers();
$api->create('team-site', ['team' => $team_id, 'site' => $site_id]);

//get team items
$all_team_resources[] = $team->getTeamResources();

endforeach;
$em->flush();

//update current team users to include new site in their default sites
foreach ($all_teams_users as $team_users):
foreach ($team_users as $team_user):
if ($team_user->getCurrent()) {
$user_id = $team_user->getUser()->getId();
$this->updateUserSites($user_id);
}

endforeach;
endforeach;

//update all item-site to include all items from the site's teams
Expand Down Expand Up @@ -1265,30 +1235,28 @@ public function userUpdate(Event $event)

//get it this way because the roles are added dynamically as js and not part of pre-baked form
$role_id = $request->getContent()['o-module-teams:TeamRole'][$team_id];
$role = $em->getRepository('Teams\Entity\TeamRole')
->findOneBy(['id' => $role_id]);

$team_user_exists = $em->getRepository('Teams\Entity\TeamUser')
->findOneBy(['team' => $team->getId(), 'user' => $user_id]);
//TODO: review this section
if ($team_user_exists) {
echo $team_user_exists->getId();
} else {
$team_user = new TeamUser($team, $user, $role);
$em->persist($team_user);
// Create through the adapter so site-permission syncing is
// handled automatically.
$teamUserResponse = $this->getServiceLocator()->get('Omeka\ApiManager')->create('team-user', [
'team' => $team->getId(),
'user' => $user_id,
'role' => (int) $role_id,
]);
$teamUserEntity = $teamUserResponse->getContent();
if ($team_id == $current_team_id) {
$team_user->setCurrent(true);
$teamUserEntity->setCurrent(true);
$em->flush();
}
$em->persist($team_user);

//this is not ideal to flush each iteration, but it is how to check to make sure they didn't
//TODO: catch this in chosen-trigger.js instead
$em->flush();
}

endforeach;

$em->flush();
}
if (array_key_exists('o-module-teams:DefaultTeam', $request->getContent())) {
if ($current_user->getRole() == 'global_admin' or $current_user->getId() == $target_user) {
Expand Down Expand Up @@ -1403,43 +1371,27 @@ public function siteUpdate(Event $event)
$added_teams = array_diff($form_teams, $existing_teams);
$removed_teams = array_diff($existing_teams, $form_teams);

foreach ($team_sites as $team_site):
if (in_array($team_site->getTeam()->getId(), $removed_teams)) {
$em->remove($team_site);
}
endforeach;
$em->flush();
// Delete removed team-site associations through the adapter so that
// site-permission cleanup is handled automatically.
$api = $this->getServiceLocator()->get('Omeka\ApiManager');
foreach ($removed_teams as $team_id) {
$api->delete('team-site', ['team' => $team_id, 'site' => $site_id]);
}

//add teams to the site for each new team listed in the form
foreach ($added_teams as $team):
$team_site = new TeamSite(
$em->getRepository('Teams\Entity\Team')->findOneBy(['id' => $team]),
$em->getRepository('Omeka\Entity\Site')->findOneBy(['id' => $site_id])
);
$em->persist($team_site);
endforeach;
$em->flush();
// Add new team-site associations through the adapter so that
// site-permission syncing is handled automatically.
foreach ($added_teams as $team_id) {
$api->create('team-site', ['team' => $team_id, 'site' => $site_id]);
}

//get any items or users that need to be updated
//by either removing or adding item-sits or user default site
//get any items that need their site membership updated
$delta_item_site = [];
$delta_user_site = [];
foreach (array_merge($added_teams, $removed_teams) as $team_id) {
$delta_item_site[] = $em->getRepository('Teams\Entity\Team')
->findOneBy(['id' => $team_id])
->getTeamResources();
$delta_user_site[] = $em->getRepository('Teams\Entity\Team')
->findOneBy(['id' => $team_id])
->getTeamUsers();
}

//update current team users to include new site in their default sites
foreach ($delta_user_site as $team_users) {
foreach ($team_users as $team_user) {
$user_id = $team_user->getUser()->getId();
$this->updateUserSites($user_id);
}
}
foreach ($delta_item_site as $team_item_collection) {
foreach ($team_item_collection as $team_item) {
$this->updateItemSites($team_item->getResource()->getId());
Expand Down Expand Up @@ -1952,6 +1904,43 @@ public function siteEdit(Event $event)
echo $view->partial('teams/partial/site-admin/edit', ['site_teams' => $site_teams, 'team_ids' => $team_ids]);
}

/**
* Warns that site user roles are managed by Teams, and annotates each user
* row in the Omeka site-admin permissions table with the team(s) responsible.
*
* @param Event $event
*/
public function siteUsersTeamsInfo(Event $event)
{
$view = $event->getTarget();
$site = $view->vars()->site;
if (!$site) {
return;
}

$messenger = new Messenger();
$messenger->addWarning(
'User roles on this site are managed by the Teams module. '
. 'Manual changes made here may be overwritten the next time team memberships or roles are updated.'
);

$em = $this->getServiceLocator()->get('Omeka\EntityManager');
$teamSites = $em->getRepository('Teams\Entity\TeamSite')->findBy(['site' => $site->id()]);

// Build userId => [teamName, ...] for every user who has a team-managed
// permission on this site.
$teamManagedUsers = [];
foreach ($teamSites as $teamSite) {
$team = $teamSite->getTeam();
$teamUsers = $em->getRepository('Teams\Entity\TeamUser')->findBy(['team' => $team->getId()]);
foreach ($teamUsers as $teamUser) {
$userId = $teamUser->getUser()->getId();
$teamManagedUsers[$userId][] = $team->getName();
}
}

echo $view->partial('teams/partial/site-admin/users-teams-info', ['teamManagedUsers' => $teamManagedUsers]);
}

public function getModules()
{
Expand Down
32 changes: 4 additions & 28 deletions asset/js/inject-removal-util-element.js
Original file line number Diff line number Diff line change
@@ -1,43 +1,19 @@
$(window).on('load', function() {
$("#o-modules-team-remove-item-sets").parent().parent().css('visibility', 'hidden');
$("#o-modules-team-remove-resource-templates").parent().parent().css('visibility', 'hidden');

//not ideal, but for some reason the chosen option from chosen-options.js are getting unset, so settin them here
// Initialize chosen on the item-sets and resource-templates multiselects.
// Chosen handles deselection natively: deselected values are removed from
// the select element, so they are omitted from the submitted form data and
// the adapter treats them as removed.
$("#o-modules-team-item-sets").chosen({
allow_single_deselect: true,
disable_search_threshold: 10,
width: '100%',
include_group_label_in_selected: true,
}).change( function(event, params) {
let $values = $("#o-modules-team-remove-item-sets").val();
if (params.deselected){
let $label = $("#o-modules-team-item-sets option[value='"+params.deselected+"']").text();
$("#o-modules-team-remove-item-sets").append('<option value='+params.deselected+'>'+$label+'</option>').trigger("chosen:updated");
$values.push(params.deselected);
console.log($values);
}
if (params.selected) {
$values.splice($.inArray(params.selected, $values), 1);
}
$("#o-modules-team-remove-item-sets").val($values).trigger("chosen:updated");
});

$("#o-modules-team-resource-templates").chosen({
allow_single_deselect: true,
disable_search_threshold: 10,
width: '100%',
include_group_label_in_selected: true,
}).change( function(event, params) {
let $values = $("#o-modules-team-remove-resource-templates").val();
if (params.deselected){
let $label = $("#o-modules-team-resource-templates option[value='"+params.deselected+"']").text();
$("#o-modules-team-remove-resource-templates").append('<option value='+params.deselected+'>'+$label+'</option>').trigger("chosen:updated");
$values.push(params.deselected);
console.log($values);
}
if (params.selected) {
$values.splice($.inArray(params.selected, $values), 1);
}
$("#o-modules-team-remove-resource-templates").val($values).trigger("chosen:updated");
});
});
Loading
Loading