diff --git a/env.d.ts b/env.d.ts index c14d27e..d92e54c 100644 --- a/env.d.ts +++ b/env.d.ts @@ -4,8 +4,8 @@ /// declare module '@nextcloud/router' { - export function generateOcsUrl(path: string): string - export function generateUrl(path: string): string + export function generateOcsUrl(path: string, params?: object, options?: object): string + export function generateUrl(path: string, params?: object, options?: object): string } interface SettingsUserListRow { diff --git a/img/screenshots/admin-catalog-thumb.png b/img/screenshots/admin-catalog-thumb.png index 8dbf4db..56ef073 100644 Binary files a/img/screenshots/admin-catalog-thumb.png and b/img/screenshots/admin-catalog-thumb.png differ diff --git a/img/screenshots/admin-catalog.png b/img/screenshots/admin-catalog.png index 7d90d97..2eed1de 100644 Binary files a/img/screenshots/admin-catalog.png and b/img/screenshots/admin-catalog.png differ diff --git a/img/screenshots/personal-settings-thumb.png b/img/screenshots/personal-settings-thumb.png index 90f2589..8c913cd 100644 Binary files a/img/screenshots/personal-settings-thumb.png and b/img/screenshots/personal-settings-thumb.png differ diff --git a/img/screenshots/personal-settings.png b/img/screenshots/personal-settings.png index 1b7de62..b90fdb8 100644 Binary files a/img/screenshots/personal-settings.png and b/img/screenshots/personal-settings.png differ diff --git a/img/screenshots/user-management-dialog-thumb.png b/img/screenshots/user-management-dialog-thumb.png index 91e46b8..f4c0b7b 100644 Binary files a/img/screenshots/user-management-dialog-thumb.png and b/img/screenshots/user-management-dialog-thumb.png differ diff --git a/img/screenshots/workflow-notify-admins-thumb.png b/img/screenshots/workflow-notify-admins-thumb.png index 9bb15b7..05c7159 100644 Binary files a/img/screenshots/workflow-notify-admins-thumb.png and b/img/screenshots/workflow-notify-admins-thumb.png differ diff --git a/lib/Controller/FieldDefinitionApiController.php b/lib/Controller/FieldDefinitionApiController.php index 9810cee..f8585ae 100644 --- a/lib/Controller/FieldDefinitionApiController.php +++ b/lib/Controller/FieldDefinitionApiController.php @@ -55,10 +55,8 @@ public function index(): DataResponse { * @param string $fieldKey Immutable unique key of the field * @param string $label Human-readable label shown in the UI * @param string $type Value type accepted by the field - * @param bool $adminOnly Whether only admins can edit values for this field - * @param bool $userEditable Whether the owner can edit the field value - * @param bool $userVisible Whether the owner can see the field in personal settings - * @param string $initialVisibility Initial visibility applied to new values + * @param string $editPolicy Whether values are managed by admins only or by users too + * @param string $exposurePolicy Whether the field is hidden or which default visibility new values receive * @param int $sortOrder Display order used in admin and profile forms * @param bool $active Whether the definition is currently active * @param list $options Allowed values for select fields (ignored for other types) @@ -72,10 +70,8 @@ public function create( string $fieldKey, string $label, string $type, - bool $adminOnly = false, - bool $userEditable = false, - bool $userVisible = true, - string $initialVisibility = 'private', + string $editPolicy = 'users', + string $exposurePolicy = 'private', int $sortOrder = 0, bool $active = true, array $options = [], @@ -85,10 +81,8 @@ public function create( 'field_key' => $fieldKey, 'label' => $label, 'type' => $type, - 'admin_only' => $adminOnly, - 'user_editable' => $userEditable, - 'user_visible' => $userVisible, - 'initial_visibility' => $initialVisibility, + 'edit_policy' => $editPolicy, + 'exposure_policy' => $exposurePolicy, 'sort_order' => $sortOrder, 'active' => $active, ]; @@ -111,10 +105,8 @@ public function create( * @param int $id Identifier of the field definition * @param string $label Human-readable label shown in the UI * @param string $type Value type accepted by the field - * @param bool $adminOnly Whether only admins can edit values for this field - * @param bool $userEditable Whether the owner can edit the field value - * @param bool $userVisible Whether the owner can see the field in personal settings - * @param string $initialVisibility Initial visibility applied to new values + * @param string $editPolicy Whether values are managed by admins only or by users too + * @param string $exposurePolicy Whether the field is hidden or which default visibility new values receive * @param int $sortOrder Display order used in admin and profile forms * @param bool $active Whether the definition is currently active * @param list $options Allowed values for select fields (ignored for other types) @@ -129,10 +121,8 @@ public function update( int $id, string $label, string $type, - bool $adminOnly = false, - bool $userEditable = false, - bool $userVisible = true, - string $initialVisibility = 'private', + string $editPolicy = 'users', + string $exposurePolicy = 'private', int $sortOrder = 0, bool $active = true, array $options = [], @@ -146,10 +136,8 @@ public function update( $payload = [ 'label' => $label, 'type' => $type, - 'admin_only' => $adminOnly, - 'user_editable' => $userEditable, - 'user_visible' => $userVisible, - 'initial_visibility' => $initialVisibility, + 'edit_policy' => $editPolicy, + 'exposure_policy' => $exposurePolicy, 'sort_order' => $sortOrder, 'active' => $active, ]; diff --git a/lib/Controller/FieldValueApiController.php b/lib/Controller/FieldValueApiController.php index 6de3417..afd4efc 100644 --- a/lib/Controller/FieldValueApiController.php +++ b/lib/Controller/FieldValueApiController.php @@ -11,6 +11,7 @@ use InvalidArgumentException; use OCA\ProfileFields\AppInfo\Application; +use OCA\ProfileFields\Enum\FieldExposurePolicy; use OCA\ProfileFields\Service\FieldAccessService; use OCA\ProfileFields\Service\FieldDefinitionService; use OCA\ProfileFields\Service\FieldValueService; @@ -58,7 +59,7 @@ public function index(): DataResponse { $editableFields = []; foreach ($definitions as $definition) { - if (!$definition->getUserVisible()) { + if (!FieldExposurePolicy::from($definition->getExposurePolicy())->isUserVisible()) { continue; } diff --git a/lib/Db/FieldDefinition.php b/lib/Db/FieldDefinition.php index 69f57d4..ec24b93 100644 --- a/lib/Db/FieldDefinition.php +++ b/lib/Db/FieldDefinition.php @@ -20,14 +20,10 @@ * @method void setLabel(string $value) * @method string getType() * @method void setType(string $value) - * @method bool getAdminOnly() - * @method void setAdminOnly(bool $value) - * @method bool getUserEditable() - * @method void setUserEditable(bool $value) - * @method bool getUserVisible() - * @method void setUserVisible(bool $value) - * @method string getInitialVisibility() - * @method void setInitialVisibility(string $value) + * @method string getEditPolicy() + * @method void setEditPolicy(string $value) + * @method string getExposurePolicy() + * @method void setExposurePolicy(string $value) * @method int getSortOrder() * @method void setSortOrder(int $value) * @method bool getActive() @@ -43,10 +39,8 @@ class FieldDefinition extends Entity { protected $fieldKey; protected $label; protected $type; - protected $adminOnly; - protected $userEditable; - protected $userVisible; - protected $initialVisibility; + protected $editPolicy; + protected $exposurePolicy; protected $sortOrder; protected $active; protected $options; @@ -55,9 +49,6 @@ class FieldDefinition extends Entity { public function __construct() { $this->addType('id', 'integer'); - $this->addType('adminOnly', 'boolean'); - $this->addType('userEditable', 'boolean'); - $this->addType('userVisible', 'boolean'); $this->addType('sortOrder', 'integer'); $this->addType('active', 'boolean'); $this->addType('createdAt', 'datetime'); @@ -70,10 +61,8 @@ public function __construct() { * field_key: string, * label: string, * type: string, - * admin_only: bool, - * user_editable: bool, - * user_visible: bool, - * initial_visibility: string, + * edit_policy: string, + * exposure_policy: string, * sort_order: int, * active: bool, * options: list|null, @@ -89,10 +78,8 @@ public function jsonSerialize(): array { 'field_key' => $this->getFieldKey(), 'label' => $this->getLabel(), 'type' => $this->getType(), - 'admin_only' => $this->getAdminOnly(), - 'user_editable' => $this->getUserEditable(), - 'user_visible' => $this->getUserVisible(), - 'initial_visibility' => $this->getInitialVisibility(), + 'edit_policy' => $this->getEditPolicy(), + 'exposure_policy' => $this->getExposurePolicy(), 'sort_order' => $this->getSortOrder(), 'active' => $this->getActive(), 'options' => $rawOptions !== null ? (json_decode($rawOptions, true) ?? null) : null, diff --git a/lib/Db/FieldDefinitionMapper.php b/lib/Db/FieldDefinitionMapper.php index 99bad4f..54d4317 100644 --- a/lib/Db/FieldDefinitionMapper.php +++ b/lib/Db/FieldDefinitionMapper.php @@ -27,10 +27,8 @@ public function findByFieldKey(string $fieldKey): ?FieldDefinition { 'field_key', 'label', 'type', - 'admin_only', - 'user_editable', - 'user_visible', - 'initial_visibility', + 'edit_policy', + 'exposure_policy', 'sort_order', 'active', 'options', @@ -54,10 +52,8 @@ public function findById(int $id): ?FieldDefinition { 'field_key', 'label', 'type', - 'admin_only', - 'user_editable', - 'user_visible', - 'initial_visibility', + 'edit_policy', + 'exposure_policy', 'sort_order', 'active', 'options', @@ -84,10 +80,8 @@ public function findAllOrdered(): array { 'field_key', 'label', 'type', - 'admin_only', - 'user_editable', - 'user_visible', - 'initial_visibility', + 'edit_policy', + 'exposure_policy', 'sort_order', 'active', 'options', @@ -111,10 +105,8 @@ public function findActiveOrdered(): array { 'field_key', 'label', 'type', - 'admin_only', - 'user_editable', - 'user_visible', - 'initial_visibility', + 'edit_policy', + 'exposure_policy', 'sort_order', 'active', 'options', diff --git a/lib/Enum/FieldEditPolicy.php b/lib/Enum/FieldEditPolicy.php new file mode 100644 index 0000000..fd22432 --- /dev/null +++ b/lib/Enum/FieldEditPolicy.php @@ -0,0 +1,33 @@ + + */ + public static function values(): array { + return [ + self::ADMINS->value, + self::USERS->value, + ]; + } + + public static function isValid(string $value): bool { + return self::tryFrom($value) !== null; + } + + public function userCanEdit(): bool { + return $this === self::USERS; + } +} diff --git a/lib/Enum/FieldExposurePolicy.php b/lib/Enum/FieldExposurePolicy.php new file mode 100644 index 0000000..7167494 --- /dev/null +++ b/lib/Enum/FieldExposurePolicy.php @@ -0,0 +1,45 @@ + + */ + public static function values(): array { + return [ + self::HIDDEN->value, + self::PRIVATE->value, + self::USERS->value, + self::PUBLIC->value, + ]; + } + + public static function isValid(string $value): bool { + return self::tryFrom($value) !== null; + } + + public function isUserVisible(): bool { + return $this !== self::HIDDEN; + } + + public function initialVisibility(): FieldVisibility { + return match ($this) { + self::HIDDEN, self::PRIVATE => FieldVisibility::PRIVATE, + self::USERS => FieldVisibility::USERS, + self::PUBLIC => FieldVisibility::PUBLIC, + }; + } +} diff --git a/lib/Migration/Version1000Date20260309120000.php b/lib/Migration/Version1000Date20260309120000.php index 04c7882..c01ee42 100644 --- a/lib/Migration/Version1000Date20260309120000.php +++ b/lib/Migration/Version1000Date20260309120000.php @@ -41,16 +41,11 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $table->addColumn('type', Types::STRING, [ 'length' => 32, ]); - $table->addColumn('admin_only', Types::BOOLEAN, [ - 'default' => false, - ]); - $table->addColumn('user_editable', Types::BOOLEAN, [ - 'default' => false, - ]); - $table->addColumn('user_visible', Types::BOOLEAN, [ - 'default' => true, + $table->addColumn('edit_policy', Types::STRING, [ + 'length' => 32, + 'default' => 'users', ]); - $table->addColumn('initial_visibility', Types::STRING, [ + $table->addColumn('exposure_policy', Types::STRING, [ 'length' => 32, 'default' => 'private', ]); diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index e40b6e0..28fec42 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -12,14 +12,14 @@ /** * @psalm-type ProfileFieldsType = 'text'|'number'|'select' * @psalm-type ProfileFieldsVisibility = 'private'|'users'|'public' + * @psalm-type ProfileFieldsEditPolicy = 'admins'|'users' + * @psalm-type ProfileFieldsExposurePolicy = 'hidden'|'private'|'users'|'public' * @psalm-type ProfileFieldsDefinitionInput = array{ * field_key?: string, * label?: string, * type?: string, - * admin_only?: bool, - * user_editable?: bool, - * user_visible?: bool, - * initial_visibility?: string, + * edit_policy?: ProfileFieldsEditPolicy, + * exposure_policy?: ProfileFieldsExposurePolicy, * sort_order?: int, * active?: bool, * options?: list, @@ -29,10 +29,8 @@ * field_key: non-empty-string, * label: non-empty-string, * type: ProfileFieldsType, - * admin_only: bool, - * user_editable: bool, - * user_visible: bool, - * initial_visibility: ProfileFieldsVisibility, + * edit_policy: ProfileFieldsEditPolicy, + * exposure_policy: ProfileFieldsExposurePolicy, * sort_order: int, * active: bool, * options: list|null, diff --git a/lib/Search/ProfileFieldDirectorySearchService.php b/lib/Search/ProfileFieldDirectorySearchService.php index cc2fac1..cf63b5a 100644 --- a/lib/Search/ProfileFieldDirectorySearchService.php +++ b/lib/Search/ProfileFieldDirectorySearchService.php @@ -12,6 +12,7 @@ use InvalidArgumentException; use OCA\ProfileFields\Db\FieldValue; use OCA\ProfileFields\Db\FieldValueMapper; +use OCA\ProfileFields\Enum\FieldExposurePolicy; use OCA\ProfileFields\Enum\FieldVisibility; use OCA\ProfileFields\Service\FieldDefinitionService; use OCP\IGroupManager; @@ -72,7 +73,7 @@ public function search(?IUser $actor, string $term, int $limit, int $offset): ar continue; } - if (!$this->isSearchableForActor($definition->getUserVisible(), $value->getCurrentVisibility(), $actorIsAdmin, $actorUid !== null)) { + if (!$this->isSearchableForActor(FieldExposurePolicy::from($definition->getExposurePolicy()), $value->getCurrentVisibility(), $actorIsAdmin, $actorUid !== null)) { continue; } @@ -123,12 +124,12 @@ private function extractScalarValue(FieldValue $value): ?string { return trim((string)$scalar); } - private function isSearchableForActor(bool $fieldIsUserVisible, string $currentVisibility, bool $actorIsAdmin, bool $actorIsAuthenticated): bool { + private function isSearchableForActor(FieldExposurePolicy $exposurePolicy, string $currentVisibility, bool $actorIsAdmin, bool $actorIsAuthenticated): bool { if ($actorIsAdmin) { return true; } - if (!$fieldIsUserVisible) { + if (!$exposurePolicy->isUserVisible()) { return false; } diff --git a/lib/Service/DataImportService.php b/lib/Service/DataImportService.php index 9971469..1c3cd42 100644 --- a/lib/Service/DataImportService.php +++ b/lib/Service/DataImportService.php @@ -70,10 +70,8 @@ public function import(array $payload, bool $dryRun = false): array { * field_key: non-empty-string, * label: non-empty-string, * type: 'text'|'number', - * admin_only: bool, - * user_editable: bool, - * user_visible: bool, - * initial_visibility: 'private'|'users'|'public', + * edit_policy: 'admins'|'users', + * exposure_policy: 'hidden'|'private'|'users'|'public', * sort_order: int, * active: bool, * created_at?: non-empty-string, @@ -151,10 +149,8 @@ private function collectValueSummary(array $values, array &$summary): void { * field_key: non-empty-string, * label: non-empty-string, * type: 'text'|'number', - * admin_only: bool, - * user_editable: bool, - * user_visible: bool, - * initial_visibility: 'private'|'users'|'public', + * edit_policy: 'admins'|'users', + * exposure_policy: 'hidden'|'private'|'users'|'public', * sort_order: int, * active: bool, * created_at?: non-empty-string, @@ -253,10 +249,8 @@ private function persistValues(array $values, array $definitionsByFieldKey, arra * field_key: non-empty-string, * label: non-empty-string, * type: 'text'|'number', - * admin_only: bool, - * user_editable: bool, - * user_visible: bool, - * initial_visibility: 'private'|'users'|'public', + * edit_policy: 'admins'|'users', + * exposure_policy: 'hidden'|'private'|'users'|'public', * sort_order: int, * active: bool, * created_at?: non-empty-string, diff --git a/lib/Service/FieldAccessService.php b/lib/Service/FieldAccessService.php index 25e4579..a702061 100644 --- a/lib/Service/FieldAccessService.php +++ b/lib/Service/FieldAccessService.php @@ -10,6 +10,8 @@ namespace OCA\ProfileFields\Service; use OCA\ProfileFields\Db\FieldDefinition; +use OCA\ProfileFields\Enum\FieldEditPolicy; +use OCA\ProfileFields\Enum\FieldExposurePolicy; use OCA\ProfileFields\Enum\FieldVisibility; class FieldAccessService { @@ -38,15 +40,11 @@ public function canEditValue(?string $actorUid, string $ownerUid, FieldDefinitio return false; } - if (!$definition->getUserVisible()) { + if (!FieldExposurePolicy::from($definition->getExposurePolicy())->isUserVisible()) { return false; } - if ($definition->getAdminOnly()) { - return false; - } - - return $definition->getUserEditable(); + return FieldEditPolicy::from($definition->getEditPolicy())->userCanEdit(); } public function canChangeVisibility(?string $actorUid, string $ownerUid, bool $actorIsAdmin): bool { diff --git a/lib/Service/FieldDefinitionService.php b/lib/Service/FieldDefinitionService.php index 5a64b79..fcf758b 100644 --- a/lib/Service/FieldDefinitionService.php +++ b/lib/Service/FieldDefinitionService.php @@ -39,10 +39,8 @@ public function create(array $definition): FieldDefinition { $entity->setFieldKey($validated['field_key']); $entity->setLabel($validated['label']); $entity->setType($validated['type']); - $entity->setAdminOnly($validated['admin_only']); - $entity->setUserEditable($validated['user_editable']); - $entity->setUserVisible($validated['user_visible']); - $entity->setInitialVisibility($validated['initial_visibility']); + $entity->setEditPolicy($validated['edit_policy']); + $entity->setExposurePolicy($validated['exposure_policy']); $entity->setSortOrder($validated['sort_order']); $entity->setActive($validated['active']); try { @@ -71,10 +69,8 @@ public function update(FieldDefinition $existing, array $definition): FieldDefin $existing->setLabel($validated['label']); $existing->setType($validated['type']); - $existing->setAdminOnly($validated['admin_only']); - $existing->setUserEditable($validated['user_editable']); - $existing->setUserVisible($validated['user_visible']); - $existing->setInitialVisibility($validated['initial_visibility']); + $existing->setEditPolicy($validated['edit_policy']); + $existing->setExposurePolicy($validated['exposure_policy']); $existing->setSortOrder($validated['sort_order']); $existing->setActive($validated['active']); try { diff --git a/lib/Service/FieldDefinitionValidator.php b/lib/Service/FieldDefinitionValidator.php index eccf9fb..c0e81cf 100644 --- a/lib/Service/FieldDefinitionValidator.php +++ b/lib/Service/FieldDefinitionValidator.php @@ -10,8 +10,9 @@ namespace OCA\ProfileFields\Service; use InvalidArgumentException; +use OCA\ProfileFields\Enum\FieldEditPolicy; +use OCA\ProfileFields\Enum\FieldExposurePolicy; use OCA\ProfileFields\Enum\FieldType; -use OCA\ProfileFields\Enum\FieldVisibility; class FieldDefinitionValidator { /** @@ -19,10 +20,8 @@ class FieldDefinitionValidator { * field_key?: string, * label?: string, * type?: string, - * admin_only?: bool, - * user_editable?: bool, - * user_visible?: bool, - * initial_visibility?: string, + * edit_policy?: string, + * exposure_policy?: string, * sort_order?: int, * active?: bool, * options?: list, @@ -31,10 +30,8 @@ class FieldDefinitionValidator { * field_key: non-empty-string, * label: non-empty-string, * type: 'text'|'number'|'select', - * admin_only: bool, - * user_editable: bool, - * user_visible: bool, - * initial_visibility: 'private'|'users'|'public', + * edit_policy: 'admins'|'users', + * exposure_policy: 'hidden'|'private'|'users'|'public', * sort_order: int, * active: bool, * options: list|null, @@ -52,19 +49,14 @@ public function validate(array $definition): array { throw new InvalidArgumentException('type is not supported'); } - $visibility = (string)($definition['initial_visibility'] ?? FieldVisibility::PRIVATE->value); - if (!FieldVisibility::isValid($visibility)) { - throw new InvalidArgumentException('initial_visibility is not supported'); + $editPolicy = (string)($definition['edit_policy'] ?? FieldEditPolicy::USERS->value); + if (!FieldEditPolicy::isValid($editPolicy)) { + throw new InvalidArgumentException('edit_policy is not supported'); } - $adminOnly = (bool)($definition['admin_only'] ?? false); - $userEditable = (bool)($definition['user_editable'] ?? false); - $userVisible = (bool)($definition['user_visible'] ?? true); - if ($adminOnly && $userEditable) { - throw new InvalidArgumentException('admin_only and user_editable cannot both be enabled'); - } - if (!$userVisible && $userEditable) { - throw new InvalidArgumentException('user_editable cannot be enabled when the field is hidden from users'); + $exposurePolicy = (string)($definition['exposure_policy'] ?? FieldExposurePolicy::PRIVATE->value); + if (!FieldExposurePolicy::isValid($exposurePolicy)) { + throw new InvalidArgumentException('exposure_policy is not supported'); } $options = $this->validateOptions($type, $definition['options'] ?? null); @@ -73,10 +65,8 @@ public function validate(array $definition): array { 'field_key' => $fieldKey, 'label' => $label, 'type' => $type, - 'admin_only' => $adminOnly, - 'user_editable' => $userEditable, - 'user_visible' => $userVisible, - 'initial_visibility' => $visibility, + 'edit_policy' => $editPolicy, + 'exposure_policy' => $exposurePolicy, 'sort_order' => (int)($definition['sort_order'] ?? 0), 'active' => (bool)($definition['active'] ?? true), 'options' => $options, @@ -117,10 +107,8 @@ private function validateOptions(string $type, mixed $options): ?array { * field_key?: string, * label?: string, * type?: string, - * admin_only?: bool, - * user_editable?: bool, - * user_visible?: bool, - * initial_visibility?: string, + * edit_policy?: string, + * exposure_policy?: string, * sort_order?: int, * active?: bool, * options?: list, diff --git a/lib/Service/FieldValueService.php b/lib/Service/FieldValueService.php index c34b2d4..f527f4b 100644 --- a/lib/Service/FieldValueService.php +++ b/lib/Service/FieldValueService.php @@ -16,6 +16,7 @@ use OCA\ProfileFields\Db\FieldDefinition; use OCA\ProfileFields\Db\FieldValue; use OCA\ProfileFields\Db\FieldValueMapper; +use OCA\ProfileFields\Enum\FieldExposurePolicy; use OCA\ProfileFields\Enum\FieldType; use OCA\ProfileFields\Enum\FieldVisibility; use OCA\ProfileFields\Workflow\Event\ProfileFieldValueCreatedEvent; @@ -48,7 +49,7 @@ public function upsert( ): FieldValue { $normalizedValue = $this->normalizeValue($definition, $rawValue); $valueJson = $this->encodeValue($normalizedValue); - $visibility = $currentVisibility ?? $definition->getInitialVisibility(); + $visibility = $currentVisibility ?? FieldExposurePolicy::from($definition->getExposurePolicy())->initialVisibility()->value; if (!FieldVisibility::isValid($visibility)) { throw new InvalidArgumentException('current_visibility is not supported'); } diff --git a/lib/Service/ImportPayloadValidator.php b/lib/Service/ImportPayloadValidator.php index 323c6e6..b164cb8 100644 --- a/lib/Service/ImportPayloadValidator.php +++ b/lib/Service/ImportPayloadValidator.php @@ -33,10 +33,8 @@ public function __construct( * field_key: non-empty-string, * label: non-empty-string, * type: 'text'|'number', - * admin_only: bool, - * user_editable: bool, - * user_visible: bool, - * initial_visibility: 'private'|'users'|'public', + * edit_policy: 'admins'|'users', + * exposure_policy: 'hidden'|'private'|'users'|'public', * sort_order: int, * active: bool, * created_at?: non-empty-string, @@ -74,10 +72,8 @@ public function validate(array $payload): array { * field_key: non-empty-string, * label: non-empty-string, * type: 'text'|'number', - * admin_only: bool, - * user_editable: bool, - * user_visible: bool, - * initial_visibility: 'private'|'users'|'public', + * edit_policy: 'admins'|'users', + * exposure_policy: 'hidden'|'private'|'users'|'public', * sort_order: int, * active: bool, * created_at?: non-empty-string, @@ -124,10 +120,8 @@ private function validateDefinitions(array $definitions): array { * field_key: non-empty-string, * label: non-empty-string, * type: 'text'|'number', - * admin_only: bool, - * user_editable: bool, - * user_visible: bool, - * initial_visibility: 'private'|'users'|'public', + * edit_policy: 'admins'|'users', + * exposure_policy: 'hidden'|'private'|'users'|'public', * sort_order: int, * active: bool, * created_at?: non-empty-string, @@ -262,19 +256,15 @@ private function normalizeOptionalDate(array $payload, string $key, string $mess * field_key: non-empty-string, * label: non-empty-string, * type: 'text'|'number', - * admin_only: bool, - * user_editable: bool, - * user_visible: bool, - * initial_visibility: 'private'|'users'|'public', + * edit_policy: 'admins'|'users', + * exposure_policy: 'hidden'|'private'|'users'|'public', * sort_order: int, * active: bool, * } $definition */ private function isCompatibleDefinition(FieldDefinition $existingDefinition, array $definition): bool { return $existingDefinition->getType() === $definition['type'] - && $existingDefinition->getAdminOnly() === $definition['admin_only'] - && $existingDefinition->getUserEditable() === $definition['user_editable'] - && $existingDefinition->getUserVisible() === $definition['user_visible'] - && $existingDefinition->getInitialVisibility() === $definition['initial_visibility']; + && $existingDefinition->getEditPolicy() === $definition['edit_policy'] + && $existingDefinition->getExposurePolicy() === $definition['exposure_policy']; } } diff --git a/openapi-administration.json b/openapi-administration.json index ac4e54e..256cd6c 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -27,10 +27,8 @@ "field_key", "label", "type", - "admin_only", - "user_editable", - "user_visible", - "initial_visibility", + "edit_policy", + "exposure_policy", "sort_order", "active", "options", @@ -53,17 +51,11 @@ "type": { "$ref": "#/components/schemas/Type" }, - "admin_only": { - "type": "boolean" - }, - "user_editable": { - "type": "boolean" - }, - "user_visible": { - "type": "boolean" + "edit_policy": { + "$ref": "#/components/schemas/EditPolicy" }, - "initial_visibility": { - "$ref": "#/components/schemas/Visibility" + "exposure_policy": { + "$ref": "#/components/schemas/ExposurePolicy" }, "sort_order": { "type": "integer", @@ -87,6 +79,22 @@ } } }, + "EditPolicy": { + "type": "string", + "enum": [ + "admins", + "users" + ] + }, + "ExposurePolicy": { + "type": "string", + "enum": [ + "hidden", + "private", + "users", + "public" + ] + }, "LookupField": { "type": "object", "required": [ @@ -355,25 +363,15 @@ "type": "string", "description": "Value type accepted by the field" }, - "adminOnly": { - "type": "boolean", - "default": false, - "description": "Whether only admins can edit values for this field" - }, - "userEditable": { - "type": "boolean", - "default": false, - "description": "Whether the owner can edit the field value" - }, - "userVisible": { - "type": "boolean", - "default": true, - "description": "Whether the owner can see the field in personal settings" + "editPolicy": { + "type": "string", + "default": "users", + "description": "Whether values are managed by admins only or by users too" }, - "initialVisibility": { + "exposurePolicy": { "type": "string", "default": "private", - "description": "Initial visibility applied to new values" + "description": "Whether the field is hidden or which default visibility new values receive" }, "sortOrder": { "type": "integer", @@ -518,25 +516,15 @@ "type": "string", "description": "Value type accepted by the field" }, - "adminOnly": { - "type": "boolean", - "default": false, - "description": "Whether only admins can edit values for this field" - }, - "userEditable": { - "type": "boolean", - "default": false, - "description": "Whether the owner can edit the field value" - }, - "userVisible": { - "type": "boolean", - "default": true, - "description": "Whether the owner can see the field in personal settings" + "editPolicy": { + "type": "string", + "default": "users", + "description": "Whether values are managed by admins only or by users too" }, - "initialVisibility": { + "exposurePolicy": { "type": "string", "default": "private", - "description": "Initial visibility applied to new values" + "description": "Whether the field is hidden or which default visibility new values receive" }, "sortOrder": { "type": "integer", diff --git a/openapi-full.json b/openapi-full.json index 728d488..9160a38 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -27,10 +27,8 @@ "field_key", "label", "type", - "admin_only", - "user_editable", - "user_visible", - "initial_visibility", + "edit_policy", + "exposure_policy", "sort_order", "active", "options", @@ -53,17 +51,11 @@ "type": { "$ref": "#/components/schemas/Type" }, - "admin_only": { - "type": "boolean" - }, - "user_editable": { - "type": "boolean" - }, - "user_visible": { - "type": "boolean" + "edit_policy": { + "$ref": "#/components/schemas/EditPolicy" }, - "initial_visibility": { - "$ref": "#/components/schemas/Visibility" + "exposure_policy": { + "$ref": "#/components/schemas/ExposurePolicy" }, "sort_order": { "type": "integer", @@ -99,17 +91,11 @@ "type": { "type": "string" }, - "admin_only": { - "type": "boolean" + "edit_policy": { + "$ref": "#/components/schemas/EditPolicy" }, - "user_editable": { - "type": "boolean" - }, - "user_visible": { - "type": "boolean" - }, - "initial_visibility": { - "type": "string" + "exposure_policy": { + "$ref": "#/components/schemas/ExposurePolicy" }, "sort_order": { "type": "integer", @@ -126,6 +112,13 @@ } } }, + "EditPolicy": { + "type": "string", + "enum": [ + "admins", + "users" + ] + }, "EditableField": { "type": "object", "required": [ @@ -146,6 +139,15 @@ } } }, + "ExposurePolicy": { + "type": "string", + "enum": [ + "hidden", + "private", + "users", + "public" + ] + }, "LookupField": { "type": "object", "required": [ @@ -436,25 +438,15 @@ "type": "string", "description": "Value type accepted by the field" }, - "adminOnly": { - "type": "boolean", - "default": false, - "description": "Whether only admins can edit values for this field" - }, - "userEditable": { - "type": "boolean", - "default": false, - "description": "Whether the owner can edit the field value" - }, - "userVisible": { - "type": "boolean", - "default": true, - "description": "Whether the owner can see the field in personal settings" + "editPolicy": { + "type": "string", + "default": "users", + "description": "Whether values are managed by admins only or by users too" }, - "initialVisibility": { + "exposurePolicy": { "type": "string", "default": "private", - "description": "Initial visibility applied to new values" + "description": "Whether the field is hidden or which default visibility new values receive" }, "sortOrder": { "type": "integer", @@ -599,25 +591,15 @@ "type": "string", "description": "Value type accepted by the field" }, - "adminOnly": { - "type": "boolean", - "default": false, - "description": "Whether only admins can edit values for this field" - }, - "userEditable": { - "type": "boolean", - "default": false, - "description": "Whether the owner can edit the field value" - }, - "userVisible": { - "type": "boolean", - "default": true, - "description": "Whether the owner can see the field in personal settings" + "editPolicy": { + "type": "string", + "default": "users", + "description": "Whether values are managed by admins only or by users too" }, - "initialVisibility": { + "exposurePolicy": { "type": "string", "default": "private", - "description": "Initial visibility applied to new values" + "description": "Whether the field is hidden or which default visibility new values receive" }, "sortOrder": { "type": "integer", diff --git a/openapi.json b/openapi.json index c66314f..fa66e1d 100644 --- a/openapi.json +++ b/openapi.json @@ -27,10 +27,8 @@ "field_key", "label", "type", - "admin_only", - "user_editable", - "user_visible", - "initial_visibility", + "edit_policy", + "exposure_policy", "sort_order", "active", "options", @@ -53,17 +51,11 @@ "type": { "$ref": "#/components/schemas/Type" }, - "admin_only": { - "type": "boolean" + "edit_policy": { + "$ref": "#/components/schemas/EditPolicy" }, - "user_editable": { - "type": "boolean" - }, - "user_visible": { - "type": "boolean" - }, - "initial_visibility": { - "$ref": "#/components/schemas/Visibility" + "exposure_policy": { + "$ref": "#/components/schemas/ExposurePolicy" }, "sort_order": { "type": "integer", @@ -87,6 +79,13 @@ } } }, + "EditPolicy": { + "type": "string", + "enum": [ + "admins", + "users" + ] + }, "EditableField": { "type": "object", "required": [ @@ -107,6 +106,15 @@ } } }, + "ExposurePolicy": { + "type": "string", + "enum": [ + "hidden", + "private", + "users", + "public" + ] + }, "OCSMeta": { "type": "object", "required": [ diff --git a/package.json b/package.json index 4834997..ef7fbff 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "NODE_ENV=production vite --mode production build", "dev": "NODE_ENV=development vite --mode development build", "screenshots:refresh": "node playwright/generate-screenshots.mjs", - "typescript:generate": "mkdir -p src/types/openapi && npx openapi-typescript openapi.json -o src/types/openapi/openapi.ts && npx openapi-typescript openapi-administration.json -o src/types/openapi/openapi-administration.ts && npx openapi-typescript openapi-full.json -o src/types/openapi/openapi-full.ts", + "typescript:generate": "npx openapi-typescript -t", "watch": "NODE_ENV=development vite --mode development build --watch", "test": "vitest run", "test:watch": "vitest", diff --git a/playwright/e2e/profile-fields.spec.ts b/playwright/e2e/profile-fields.spec.ts index 4e77673..8a4a0ae 100644 --- a/playwright/e2e/profile-fields.spec.ts +++ b/playwright/e2e/profile-fields.spec.ts @@ -111,6 +111,159 @@ test('admin can create, update, and delete a field definition', async ({ page }) await deleteDefinitionByFieldKey(page.request, fieldKey) }) +test('admin uses a modal editor on compact layout', async ({ page }) => { + const suffix = Date.now() + const existingFieldKey = `playwright_mobile_existing_${suffix}` + const existingLabel = `Playwright mobile existing ${suffix}` + const createdFieldKey = `playwright_mobile_created_${suffix}` + const createdLabel = `Playwright mobile created ${suffix}` + + await deleteDefinitionByFieldKey(page.request, existingFieldKey) + await deleteDefinitionByFieldKey(page.request, createdFieldKey) + await createDefinition(page.request, { + fieldKey: existingFieldKey, + label: existingLabel, + }) + + try { + await page.setViewportSize({ width: 768, height: 1180 }) + await page.goto('./settings/admin/profile_fields') + await expect(page.getByTestId('profile-fields-admin')).toBeVisible() + await expect(page.getByText('No field selected')).toHaveCount(0) + + await page.getByTestId(`profile-fields-admin-definition-${existingFieldKey}`).click() + const editDialog = page.getByRole('dialog', { name: 'Edit field' }) + await expect(editDialog).toBeVisible() + await expect(editDialog.locator('#profile-fields-admin-label')).toHaveValue(existingLabel) + + const dialogBox = await editDialog.boundingBox() + const saveBox = await editDialog.getByTestId('profile-fields-admin-save').boundingBox() + expect(dialogBox).not.toBeNull() + expect(saveBox).not.toBeNull() + expect(dialogBox!.y + dialogBox!.height - (saveBox!.y + saveBox!.height)).toBeGreaterThan(16) + + await editDialog.getByRole('button', { name: /close/i }).click() + await expect(editDialog).toBeHidden() + + await page.getByTestId('profile-fields-admin-new-field').click() + const createDialog = page.getByRole('dialog', { name: 'Create field' }) + await expect(createDialog).toBeVisible() + await createDialog.locator('#profile-fields-admin-field-key').fill(createdFieldKey) + await createDialog.locator('#profile-fields-admin-label').fill(createdLabel) + await createDialog.getByTestId('profile-fields-admin-save').click() + + await expect(page.getByTestId('profile-fields-admin-success')).toContainText('Field definition created.') + await expect(page.getByTestId(`profile-fields-admin-definition-${createdFieldKey}`)).toBeVisible() + await expect(createDialog).toBeHidden() + } finally { + await deleteDefinitionByFieldKey(page.request, existingFieldKey) + await deleteDefinitionByFieldKey(page.request, createdFieldKey) + } +}) + +test('admin can reorder field definitions by dragging the list handles', async ({ page }) => { + const suffix = Date.now() + const firstFieldKey = `playwright_order_first_${suffix}` + const secondFieldKey = `playwright_order_second_${suffix}` + const firstLabel = `Playwright order first ${suffix}` + const secondLabel = `Playwright order second ${suffix}` + + await deleteDefinitionByFieldKey(page.request, firstFieldKey) + await deleteDefinitionByFieldKey(page.request, secondFieldKey) + await createDefinition(page.request, { + fieldKey: firstFieldKey, + label: firstLabel, + sortOrder: 0, + }) + await createDefinition(page.request, { + fieldKey: secondFieldKey, + label: secondLabel, + sortOrder: 1, + }) + + try { + await page.goto('./settings/admin/profile_fields') + await expect(page.getByTestId('profile-fields-admin')).toBeVisible() + + const firstHandle = page.getByTestId(`profile-fields-admin-definition-handle-${firstFieldKey}`) + const secondHandle = page.getByTestId(`profile-fields-admin-definition-handle-${secondFieldKey}`) + const verticalOrder = async() => { + const firstBox = await firstHandle.boundingBox() + const secondBox = await secondHandle.boundingBox() + expect(firstBox).not.toBeNull() + expect(secondBox).not.toBeNull() + return { + firstY: firstBox!.y, + secondY: secondBox!.y, + } + } + + let order = await verticalOrder() + expect(order.firstY).toBeLessThan(order.secondY) + + await secondHandle.dragTo(firstHandle) + + await expect.poll(verticalOrder).toEqual(expect.objectContaining({ + firstY: expect.any(Number), + secondY: expect.any(Number), + })) + order = await verticalOrder() + expect(order.secondY).toBeLessThan(order.firstY) + + await page.reload() + await expect(page.getByTestId('profile-fields-admin')).toBeVisible() + order = await verticalOrder() + expect(order.secondY).toBeLessThan(order.firstY) + } finally { + await deleteDefinitionByFieldKey(page.request, firstFieldKey) + await deleteDefinitionByFieldKey(page.request, secondFieldKey) + } +}) + +test('admin shows native status chip, actions menu, and drag handle in the expected order', async ({ page }) => { + const suffix = Date.now() + const fieldKey = `playwright_layout_${suffix}` + const label = `Playwright layout ${suffix}` + + await deleteDefinitionByFieldKey(page.request, fieldKey) + await createDefinition(page.request, { + fieldKey, + label, + active: true, + }) + + try { + await page.goto('./settings/admin/profile_fields') + await expect(page.getByTestId('profile-fields-admin')).toBeVisible() + + const row = page.getByTestId(`profile-fields-admin-definition-${fieldKey}`) + const fieldKeyText = row.getByText(fieldKey, { exact: true }) + const statusChip = row.getByText('Active', { exact: true }) + const actionsButton = row.getByRole('button', { name: `Actions for ${label}` }) + const dragHandle = page.getByTestId(`profile-fields-admin-definition-handle-${fieldKey}`) + + await expect(fieldKeyText).toBeVisible() + await expect(statusChip).toBeVisible() + await expect(actionsButton).toBeVisible() + await expect(dragHandle).toBeVisible() + + const fieldKeyBox = await fieldKeyText.boundingBox() + const statusBox = await statusChip.boundingBox() + const actionsBox = await actionsButton.boundingBox() + const dragHandleBox = await dragHandle.boundingBox() + + expect(fieldKeyBox).not.toBeNull() + expect(statusBox).not.toBeNull() + expect(actionsBox).not.toBeNull() + expect(dragHandleBox).not.toBeNull() + expect(fieldKeyBox!.x).toBeLessThan(statusBox!.x) + expect(statusBox!.x).toBeLessThan(actionsBox!.x) + expect(actionsBox!.x).toBeLessThan(dragHandleBox!.x) + } finally { + await deleteDefinitionByFieldKey(page.request, fieldKey) + } +}) + test('admin gets an initial select option row and can remove empty rows by keyboard', async ({ page }) => { const suffix = Date.now() const fieldKey = `playwright_select_create_${suffix}` @@ -322,9 +475,8 @@ test('embedded personal settings autosave a user-visible field', async ({ page } const definition = await createDefinition(page.request, { fieldKey, label, - userEditable: true, - userVisible: true, - initialVisibility: 'private', + editPolicy: 'users', + exposurePolicy: 'private', }) try { diff --git a/playwright/e2e/workflow.spec.ts b/playwright/e2e/workflow.spec.ts index e7fcbac..425b4f3 100644 --- a/playwright/e2e/workflow.spec.ts +++ b/playwright/e2e/workflow.spec.ts @@ -83,9 +83,8 @@ const createWorkflowFieldDefinition = async(page: Page, fieldKey: string, label: await createDefinition(page.request, { fieldKey, label, - userEditable: true, - userVisible: true, - initialVisibility: 'users', + editPolicy: 'users', + exposurePolicy: 'users', }) } diff --git a/playwright/generate-screenshots.mjs b/playwright/generate-screenshots.mjs index 20a94ab..ef4c29c 100644 --- a/playwright/generate-screenshots.mjs +++ b/playwright/generate-screenshots.mjs @@ -91,6 +91,14 @@ async function uploadCurrentUserAvatar(api, imagePath) { } } +const waitForAvatarImage = async(page) => { + await page.waitForFunction(() => { + const avatarImages = [...document.querySelectorAll('img')] + .filter((img) => img.src.includes('/avatar/')) + return avatarImages.some((img) => img.complete && img.naturalWidth > 0) + }, { timeout: 60_000 }) +} + async function appRequest(api, method, path, body) { const headers = { 'OCS-APIRequest': 'true', @@ -173,6 +181,9 @@ const hideNonShowcaseAdminDefinitions = async(page) => { const allowedKeys = new Set(keys) document.querySelectorAll('[data-testid^="profile-fields-admin-definition-"]').forEach((element) => { const testId = element.getAttribute('data-testid') ?? '' + if (testId.startsWith('profile-fields-admin-definition-handle-')) { + return + } const fieldKey = testId.replace('profile-fields-admin-definition-', '') const row = element.closest('li') if (row instanceof HTMLElement && !allowedKeys.has(fieldKey)) { @@ -304,6 +315,8 @@ const run = async() => { await createDemoUser(api) demoApi = await loginApi(demoUser.id, demoUser.password) await uploadCurrentUserAvatar(demoApi, demoAvatarPath) + await demoApi.dispose() + demoApi = await loginApi(demoUser.id, demoUser.password) for (const field of showcaseFields) { const definition = await appRequest(api, 'POST', './ocs/v2.php/apps/profile_fields/api/v1/definitions', { @@ -351,6 +364,7 @@ const run = async() => { const personalPage = await demoContext.newPage() await personalPage.goto('./settings/user/personal-info') + await waitForAvatarImage(personalPage) await personalPage.getByTestId('profile-fields-personal-field-showcase_support_region').waitFor({ state: 'visible', timeout: 60_000 }) await seedPedroPotiAccountProfile(personalPage) await hideNonShowcasePersonalFields(personalPage) diff --git a/playwright/support/profile-fields.ts b/playwright/support/profile-fields.ts index 82562ed..c899572 100644 --- a/playwright/support/profile-fields.ts +++ b/playwright/support/profile-fields.ts @@ -5,6 +5,8 @@ import type { APIRequestContext } from '@playwright/test' type FieldType = 'text' | 'number' | 'select' type FieldVisibility = 'private' | 'users' | 'public' +type FieldEditPolicy = 'admins' | 'users' +type FieldExposurePolicy = 'hidden' | FieldVisibility type FieldDefinition = { id: number @@ -12,10 +14,8 @@ type FieldDefinition = { label: string type: FieldType options: string[] | null - admin_only: boolean - user_editable: boolean - user_visible: boolean - initial_visibility: FieldVisibility + edit_policy: FieldEditPolicy + exposure_policy: FieldExposurePolicy sort_order: number active: boolean created_at: string @@ -38,10 +38,8 @@ type DefinitionPayload = { label: string type?: FieldType options?: string[] - adminOnly?: boolean - userEditable?: boolean - userVisible?: boolean - initialVisibility?: FieldVisibility + editPolicy?: FieldEditPolicy + exposurePolicy?: FieldExposurePolicy sortOrder?: number active?: boolean } @@ -105,10 +103,8 @@ export async function createDefinition( label: payload.label, type: payload.type ?? 'text', ...(payload.type === 'select' ? { options: payload.options ?? [] } : {}), - adminOnly: payload.adminOnly ?? false, - userEditable: payload.userEditable ?? true, - userVisible: payload.userVisible ?? true, - initialVisibility: payload.initialVisibility ?? 'private', + editPolicy: payload.editPolicy ?? 'users', + exposurePolicy: payload.exposurePolicy ?? 'private', sortOrder: payload.sortOrder ?? 0, active: payload.active ?? true, }) diff --git a/redocly.yaml b/redocly.yaml new file mode 100644 index 0000000..c43685d --- /dev/null +++ b/redocly.yaml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later +apis: + openapi@v1: + root: ./openapi.json + x-openapi-ts: + output: ./src/types/openapi/openapi.ts + openapi-administration@v1: + root: ./openapi-administration.json + x-openapi-ts: + output: ./src/types/openapi/openapi-administration.ts + openapi-full@v1: + root: ./openapi-full.json + x-openapi-ts: + output: ./src/types/openapi/openapi-full.ts diff --git a/src/components/AdminUserFieldsDialog.vue b/src/components/AdminUserFieldsDialog.vue index 671d179..def5b99 100644 --- a/src/components/AdminUserFieldsDialog.vue +++ b/src/components/AdminUserFieldsDialog.vue @@ -108,6 +108,7 @@ import { computed, defineComponent, reactive, ref, watch } from 'vue' import NcAvatar from '@nextcloud/vue/components/NcAvatar' import { NcButton, NcDialog, NcEmptyContent, NcInputField, NcLoadingIcon, NcNoteCard, NcSelect } from '@nextcloud/vue' import { listAdminUserValues, listDefinitions, upsertAdminUserValue } from '../api' +import { definitionDefaultVisibility } from '../types' import type { AdminEditableField, FieldDefinition, FieldType, FieldValueRecord, FieldVisibility } from '../types' import { buildAdminEditableFields } from '../utils/adminFieldValues.js' import { visibilityOptions } from '../utils/visibilityOptions.js' @@ -248,7 +249,7 @@ export default defineComponent({ const currentValue = field.value?.value userDraftValues[field.definition.id] = currentValue?.value?.toString() ?? '' - userDraftVisibilities[field.definition.id] = field.value?.current_visibility ?? field.definition.initial_visibility + userDraftVisibilities[field.definition.id] = field.value?.current_visibility ?? definitionDefaultVisibility(field.definition) delete userValueErrors[field.definition.id] } @@ -364,7 +365,7 @@ export default defineComponent({ const currentPayload = (field: AdminEditableField) => { return { value: field.value?.value?.value ?? null, - visibility: field.value?.current_visibility ?? field.definition.initial_visibility, + visibility: field.value?.current_visibility ?? definitionDefaultVisibility(field.definition), } } diff --git a/src/tests/utils/adminFieldValues.spec.ts b/src/tests/utils/adminFieldValues.spec.ts index 459beea..9dc64db 100644 --- a/src/tests/utils/adminFieldValues.spec.ts +++ b/src/tests/utils/adminFieldValues.spec.ts @@ -11,12 +11,11 @@ const definition = (id: number, sortOrder: number, active = true): FieldDefiniti field_key: `field_${id}`, label: `Field ${id}`, type: 'text', - admin_only: false, - user_editable: true, - user_visible: true, - initial_visibility: 'private', + edit_policy: 'users', + exposure_policy: 'private', sort_order: sortOrder, active, + options: null, created_at: '2026-03-10T00:00:00+00:00', updated_at: '2026-03-10T00:00:00+00:00', }) diff --git a/src/tests/utils/fieldOrder.spec.ts b/src/tests/utils/fieldOrder.spec.ts index 8b5b3ae..b1338ee 100644 --- a/src/tests/utils/fieldOrder.spec.ts +++ b/src/tests/utils/fieldOrder.spec.ts @@ -11,12 +11,11 @@ const definition = (id: number, sortOrder: number): FieldDefinition => ({ field_key: `field_${id}`, label: `Field ${id}`, type: 'text', - admin_only: false, - user_editable: true, - user_visible: true, - initial_visibility: 'private', + edit_policy: 'users', + exposure_policy: 'private', sort_order: sortOrder, active: true, + options: null, created_at: '2026-03-10T00:00:00+00:00', updated_at: '2026-03-10T00:00:00+00:00', }) diff --git a/src/types/index.ts b/src/types/index.ts index 7b2a4ba..7ee701e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -21,7 +21,15 @@ type ApiRequestJsonBody = ApiJsonBody & { + edit_policy: FieldEditPolicy + exposure_policy: FieldExposurePolicy +} // openapi-typescript collapses the loose `value: mixed` schema to Record. // Keep the surrounding contract generated and widen only this payload leaf for frontend use. @@ -43,8 +51,25 @@ export type LookupResult = Omit } -export type CreateDefinitionPayload = ApiRequestJsonBody -export type UpdateDefinitionPayload = ApiRequestJsonBody +export type CreateDefinitionPayload = { + fieldKey: string + label: string + type: FieldType + editPolicy?: FieldEditPolicy + exposurePolicy?: FieldExposurePolicy + sortOrder?: number + active?: boolean + options?: string[] +} +export type UpdateDefinitionPayload = { + label: string + type: FieldType + editPolicy?: FieldEditPolicy + exposurePolicy?: FieldExposurePolicy + sortOrder?: number + active?: boolean + options?: string[] +} export type UpsertOwnValuePayload = ApiRequestJsonBody export type UpdateOwnVisibilityPayload = ApiRequestJsonBody export type UpsertAdminUserValuePayload = ApiRequestJsonBody @@ -57,3 +82,14 @@ export type AdminEditableField = { export type ApiError = { message: string } + +export const definitionDefaultVisibility = (definition: Pick): FieldVisibility => { + switch (definition.exposure_policy) { + case 'public': + return 'public' + case 'users': + return 'users' + default: + return 'private' + } +} diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index 53aeb9a..d050176 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -3,7 +3,7 @@ * Do not make direct changes to the file. */ -export interface paths { +export type paths = { "/ocs/v2.php/apps/profile_fields/api/v1/definitions": { parameters: { query?: never; @@ -140,9 +140,9 @@ export interface paths { patch?: never; trace?: never; }; -} +}; export type webhooks = Record; -export interface components { +export type components = { schemas: { Definition: { /** Format: int64 */ @@ -150,10 +150,8 @@ export interface components { field_key: string; label: string; type: components["schemas"]["Type"]; - admin_only: boolean; - user_editable: boolean; - user_visible: boolean; - initial_visibility: components["schemas"]["Visibility"]; + edit_policy: components["schemas"]["EditPolicy"]; + exposure_policy: components["schemas"]["ExposurePolicy"]; /** Format: int64 */ sort_order: number; active: boolean; @@ -161,6 +159,10 @@ export interface components { created_at: string; updated_at: string; }; + /** @enum {string} */ + EditPolicy: "admins" | "users"; + /** @enum {string} */ + ExposurePolicy: "hidden" | "private" | "users" | "public"; LookupField: { definition: components["schemas"]["Definition"]; value: components["schemas"]["ValueRecord"]; @@ -214,7 +216,7 @@ export interface components { requestBodies: never; headers: never; pathItems: never; -} +}; export type $defs = Record; export interface operations { "field_definition_api-index": { @@ -265,25 +267,15 @@ export interface operations { /** @description Value type accepted by the field */ type: string; /** - * @description Whether only admins can edit values for this field - * @default false - */ - adminOnly?: boolean; - /** - * @description Whether the owner can edit the field value - * @default false - */ - userEditable?: boolean; - /** - * @description Whether the owner can see the field in personal settings - * @default true + * @description Whether values are managed by admins only or by users too + * @default users */ - userVisible?: boolean; + editPolicy?: string; /** - * @description Initial visibility applied to new values + * @description Whether the field is hidden or which default visibility new values receive * @default private */ - initialVisibility?: string; + exposurePolicy?: string; /** * Format: int64 * @description Display order used in admin and profile forms @@ -357,25 +349,15 @@ export interface operations { /** @description Value type accepted by the field */ type: string; /** - * @description Whether only admins can edit values for this field - * @default false - */ - adminOnly?: boolean; - /** - * @description Whether the owner can edit the field value - * @default false - */ - userEditable?: boolean; - /** - * @description Whether the owner can see the field in personal settings - * @default true + * @description Whether values are managed by admins only or by users too + * @default users */ - userVisible?: boolean; + editPolicy?: string; /** - * @description Initial visibility applied to new values + * @description Whether the field is hidden or which default visibility new values receive * @default private */ - initialVisibility?: string; + exposurePolicy?: string; /** * Format: int64 * @description Display order used in admin and profile forms diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index a11fa2e..1603a5e 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -3,7 +3,7 @@ * Do not make direct changes to the file. */ -export interface paths { +export type paths = { "/ocs/v2.php/apps/profile_fields/api/v1/definitions": { parameters: { query?: never; @@ -200,9 +200,9 @@ export interface paths { patch?: never; trace?: never; }; -} +}; export type webhooks = Record; -export interface components { +export type components = { schemas: { Definition: { /** Format: int64 */ @@ -210,10 +210,8 @@ export interface components { field_key: string; label: string; type: components["schemas"]["Type"]; - admin_only: boolean; - user_editable: boolean; - user_visible: boolean; - initial_visibility: components["schemas"]["Visibility"]; + edit_policy: components["schemas"]["EditPolicy"]; + exposure_policy: components["schemas"]["ExposurePolicy"]; /** Format: int64 */ sort_order: number; active: boolean; @@ -225,20 +223,22 @@ export interface components { field_key?: string; label?: string; type?: string; - admin_only?: boolean; - user_editable?: boolean; - user_visible?: boolean; - initial_visibility?: string; + edit_policy?: components["schemas"]["EditPolicy"]; + exposure_policy?: components["schemas"]["ExposurePolicy"]; /** Format: int64 */ sort_order?: number; active?: boolean; options?: string[]; }; + /** @enum {string} */ + EditPolicy: "admins" | "users"; EditableField: { definition: components["schemas"]["Definition"]; value: components["schemas"]["ValueRecord"]; can_edit: boolean; }; + /** @enum {string} */ + ExposurePolicy: "hidden" | "private" | "users" | "public"; LookupField: { definition: components["schemas"]["Definition"]; value: components["schemas"]["ValueRecord"]; @@ -299,7 +299,7 @@ export interface components { requestBodies: never; headers: never; pathItems: never; -} +}; export type $defs = Record; export interface operations { "field_definition_api-index": { @@ -350,25 +350,15 @@ export interface operations { /** @description Value type accepted by the field */ type: string; /** - * @description Whether only admins can edit values for this field - * @default false - */ - adminOnly?: boolean; - /** - * @description Whether the owner can edit the field value - * @default false - */ - userEditable?: boolean; - /** - * @description Whether the owner can see the field in personal settings - * @default true + * @description Whether values are managed by admins only or by users too + * @default users */ - userVisible?: boolean; + editPolicy?: string; /** - * @description Initial visibility applied to new values + * @description Whether the field is hidden or which default visibility new values receive * @default private */ - initialVisibility?: string; + exposurePolicy?: string; /** * Format: int64 * @description Display order used in admin and profile forms @@ -442,25 +432,15 @@ export interface operations { /** @description Value type accepted by the field */ type: string; /** - * @description Whether only admins can edit values for this field - * @default false - */ - adminOnly?: boolean; - /** - * @description Whether the owner can edit the field value - * @default false - */ - userEditable?: boolean; - /** - * @description Whether the owner can see the field in personal settings - * @default true + * @description Whether values are managed by admins only or by users too + * @default users */ - userVisible?: boolean; + editPolicy?: string; /** - * @description Initial visibility applied to new values + * @description Whether the field is hidden or which default visibility new values receive * @default private */ - initialVisibility?: string; + exposurePolicy?: string; /** * Format: int64 * @description Display order used in admin and profile forms diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 1ab2e3d..aa0db2a 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -3,7 +3,7 @@ * Do not make direct changes to the file. */ -export interface paths { +export type paths = { "/ocs/v2.php/apps/profile_fields/api/v1/me/values": { parameters: { query?: never; @@ -64,9 +64,9 @@ export interface paths { patch?: never; trace?: never; }; -} +}; export type webhooks = Record; -export interface components { +export type components = { schemas: { Definition: { /** Format: int64 */ @@ -74,10 +74,8 @@ export interface components { field_key: string; label: string; type: components["schemas"]["Type"]; - admin_only: boolean; - user_editable: boolean; - user_visible: boolean; - initial_visibility: components["schemas"]["Visibility"]; + edit_policy: components["schemas"]["EditPolicy"]; + exposure_policy: components["schemas"]["ExposurePolicy"]; /** Format: int64 */ sort_order: number; active: boolean; @@ -85,11 +83,15 @@ export interface components { created_at: string; updated_at: string; }; + /** @enum {string} */ + EditPolicy: "admins" | "users"; EditableField: { definition: components["schemas"]["Definition"]; value: components["schemas"]["ValueRecord"]; can_edit: boolean; }; + /** @enum {string} */ + ExposurePolicy: "hidden" | "private" | "users" | "public"; OCSMeta: { status: string; statuscode: number; @@ -121,7 +123,7 @@ export interface components { requestBodies: never; headers: never; pathItems: never; -} +}; export type $defs = Record; export interface operations { "field_value_api-index": { diff --git a/src/views/AdminSettings.vue b/src/views/AdminSettings.vue index 6a965d7..c5dce41 100644 --- a/src/views/AdminSettings.vue +++ b/src/views/AdminSettings.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later

Field catalog administration

- Create the global field catalog, control who can edit each field and tune the default visibility used when a value is first stored. + Create the global field catalog, choose who can edit each field, and define how exposed new values are by default.

@@ -22,9 +22,11 @@ SPDX-License-Identifier: AGPL-3.0-or-later {{ errorMessage }} - - {{ successMessage }} - + {{ successMessage }}
@@ -35,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later

Defined fields

-

Pick an existing definition to edit its rules or start a fresh field.

+

Pick a field to edit it or create a new one.

New field @@ -44,48 +46,104 @@ SPDX-License-Identifier: AGPL-3.0-or-later -
    -
  • - -
  • -
+ + + -
+ +
-
- - - New field - +
+
+

{{ editorEmptyState.title }}

+

{{ editorEmptyState.description }}

+
+ + New field + +
+
+ +