fix: tests

This commit is contained in:
Matthieu B
2025-11-03 17:51:45 +01:00
parent d8bdc832c9
commit 704aaddd97
5 changed files with 294 additions and 398 deletions

View File

@@ -1,384 +1,270 @@
<div
class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all w-full max-w-2xl dark:bg-gray-800"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
x-data="{ activeTab: 'tab-{{ related_users[0].id }}' }"
>
<div class="bg-white dark:bg-gray-800">
<!-- Header -->
<div class="flex justify-between items-center px-4 pt-5 sm:px-6 sm:pt-6 pb-3">
<h2
id="modal-title"
class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white"
>
{{ _("Edit User") }} {{ user.username or user.email }}
</h2>
<button
type="button"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
onclick="document.getElementById('modal').classList.add('hidden')"
aria-label="{{ _('Close modal') }}"
>
<svg
class="w-3 h-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"
/>
</svg>
<span class="sr-only">{{ _("Close modal") }}</span>
</button>
</div>
<!-- Tab Navigation -->
<div class="border-b border-gray-200 dark:border-gray-700 px-4 sm:px-6">
<nav class="flex -mb-px space-x-2 overflow-x-auto" aria-label="{{ _('Tabs') }}" role="tablist">
{% for related_user in related_users %}
<button
type="button"
role="tab"
:aria-selected="activeTab === 'tab-{{ related_user.id }}'"
@click="activeTab = 'tab-{{ related_user.id }}'"
:class="activeTab === 'tab-{{ related_user.id }}'
? 'border-primary text-primary dark:border-primary dark:text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
class="whitespace-nowrap py-3 px-4 border-b-2 font-medium text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition-colors"
>
{% if related_user.server %}
{{ related_user.server.name }}
{% else %}
{{ _("Local") }}
{% endif %}
</button>
{% endfor %}
<button
type="button"
role="tab"
:aria-selected="activeTab === 'tab-notes'"
@click="activeTab = 'tab-notes'"
:class="activeTab === 'tab-notes'
? 'border-primary text-primary dark:border-primary dark:text-primary'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
class="whitespace-nowrap py-3 px-4 border-b-2 font-medium text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition-colors"
>
{{ _("Notes") }}
</button>
</nav>
</div>
<!-- Tab Content -->
<div class="px-4 pb-4 pt-4 sm:p-6 sm:pt-4">
{% for related_user in related_users %}
<div
x-show="activeTab === 'tab-{{ related_user.id }}'"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
role="tabpanel"
:aria-hidden="activeTab !== 'tab-{{ related_user.id }}'"
>
<!-- Server Badge (for multi-server setups) -->
{% if related_users|length > 1 %}
<div class="flex items-center justify-between mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center">
{% if related_user.server %}
{{ related_user.server.server_type|server_name_tag(related_user.server.name) }}
{% else %}
{{ 'local'|server_type_tag }}
{% endif %}
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">
ID: {{ related_user.id }}
</span>
</div>
{% endif %}
<!-- Permission Management Section (only for supported servers) -->
{% if related_user.server and related_user.server.server_type in ['plex', 'jellyfin', 'emby', 'audiobookshelf'] %}
<div class="mb-6">
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
{{ _("Permission Management") }}
</h3>
<!-- Permission Toggles -->
<div class="space-y-3 bg-gray-50 dark:bg-gray-900 rounded-lg p-4"
x-data="{
downloading: {{ (related_user.allow_downloads if related_user.allow_downloads is not none else false)|tojson }},
liveTv: {{ (related_user.allow_live_tv if related_user.allow_live_tv is not none else false)|tojson }},
cameraUpload: {{ (related_user.allow_camera_upload if related_user.allow_camera_upload is not none else false)|tojson }},
updating: {
downloads: false,
liveTv: false,
camera: false
},
togglePermission(type, formRef) {
// Store the current value in case we need to roll back
const originalValue = this[type];
// Set updating state
this.updating[type === 'downloading' ? 'downloads' : (type === 'liveTv' ? 'liveTv' : 'camera')] = true;
// Optimistically update UI
this[type] = !this[type];
// Trigger the form submission
this.$nextTick(() => htmx.trigger(formRef, 'submit'));
},
showError(message) {
alert('Error: ' + message);
}
}">
<!-- Downloads Permission (All servers support this) -->
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ _("Allow Downloads") }}
</label>
<button
type="button"
@click="togglePermission('downloading', $refs.downloadForm)"
:disabled="updating.downloads"
:class="[
downloading ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700',
updating.downloads ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
]"
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="{{ _('Toggle downloads permission') }}"
>
<span
:class="downloading ? 'translate-x-5' : 'translate-x-0'"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
></span>
</button>
<form x-ref="downloadForm"
method="post"
action="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
hx-post="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
hx-swap="none"
@htmx:after-request="updating.downloads = false"
@htmx:response-error="downloading = !downloading; updating.downloads = false; showError($event.detail.xhr.responseText || 'Failed to update permission')"
class="hidden">
<input type="hidden" name="permission_type" value="allow_downloads">
<input type="hidden" name="enabled" :value="downloading ? 'true' : 'false'">
</form>
</div>
<!-- Live TV Permission (Plex, Jellyfin, Emby only) -->
{% if related_user.server and related_user.server.server_type in ['plex', 'jellyfin', 'emby'] %}
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ _("Allow Live TV") }}
</label>
<button
type="button"
@click="togglePermission('liveTv', $refs.liveTvForm)"
:disabled="updating.liveTv"
:class="[
liveTv ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700',
updating.liveTv ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
]"
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="{{ _('Toggle live TV permission') }}"
>
<span
:class="liveTv ? 'translate-x-5' : 'translate-x-0'"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
></span>
</button>
<form x-ref="liveTvForm"
method="post"
action="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
hx-post="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
hx-swap="none"
@htmx:after-request="updating.liveTv = false"
@htmx:response-error="liveTv = !liveTv; updating.liveTv = false; showError($event.detail.xhr.responseText || 'Failed to update permission')"
class="hidden">
<input type="hidden" name="permission_type" value="allow_live_tv">
<input type="hidden" name="enabled" :value="liveTv ? 'true' : 'false'">
</form>
</div>
{% endif %}
<!-- Camera Upload Permission (Plex only) -->
{% if related_user.server and related_user.server.server_type == 'plex' %}
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ _("Allow Camera Upload") }}
</label>
<button
type="button"
@click="togglePermission('cameraUpload', $refs.cameraForm)"
:disabled="updating.camera"
:class="[
cameraUpload ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700',
updating.camera ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'
]"
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="{{ _('Toggle camera upload permission') }}"
>
<span
:class="cameraUpload ? 'translate-x-5' : 'translate-x-0'"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
></span>
</button>
<form x-ref="cameraForm"
method="post"
action="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
hx-post="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
hx-swap="none"
@htmx:after-request="updating.camera = false"
@htmx:response-error="cameraUpload = !cameraUpload; updating.camera = false; showError($event.detail.xhr.responseText || 'Failed to update permission')"
class="hidden">
<input type="hidden" name="permission_type" value="allow_camera_upload">
<input type="hidden" name="enabled" :value="cameraUpload ? 'true' : 'false'">
</form>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Library Access Section -->
{% set lib_data = user_libraries_map.get(related_user.id, {}) %}
{% set libraries = lib_data.get('libraries', []) %}
{% set accessible = lib_data.get('accessible_libraries', []) %}
<div class="mb-6">
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
{{ _("Library Access") }}
</h3>
{% if libraries|length == 0 %}
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-500 dark:text-gray-400">
<p>{{ _("No libraries available") }}</p>
<p class="text-xs mt-1">{{ _("Scan libraries from the invitation page to enable library restrictions") }}</p>
</div>
{% else %}
<div class="relative" x-data="{ updating: false }">
<form id="library-form-{{ related_user.id }}"
@htmx:before-request="updating = true"
@htmx:after-request="updating = false"
:class="updating ? 'opacity-60 pointer-events-none' : ''"
class="transition-opacity duration-200">
<ul class="h-48 px-3 py-2 overflow-y-auto text-sm text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-900">
{% for library in libraries %}
<li>
<label class="flex items-center ps-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer py-2"
:class="updating ? 'cursor-not-allowed' : 'cursor-pointer'">
<input
type="checkbox"
name="library_ids[]"
value="{{ library.id }}"
{% if accessible|length == 0 or library.name in accessible %}checked{% endif %}
hx-post="{{ url_for('admin.update_user_libraries', db_id=related_user.id) }}"
hx-trigger="change"
hx-include="#library-form-{{ related_user.id }}"
hx-swap="none"
:disabled="updating"
class="w-4 h-4 text-primary bg-gray-100 border-gray-300 rounded focus:ring-primary dark:focus:ring-primary dark:ring-offset-gray-900 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<span class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">
{{ library.name }}
</span>
</label>
</li>
{% endfor %}
</ul>
</form>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ _("Click to toggle library access. Updates save automatically.") }}
</p>
{% endif %}
</div>
<!-- Expiry Management Section -->
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
{{ _("Expiry Management") }}
</h3>
<form
hx-post="{{ url_for('admin.user_detail', db_id=related_user.id) }}"
class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4"
>
<div class="flex items-end gap-3">
<div class="flex-1">
<label
for="expires_{{ related_user.id }}"
class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300"
>
{{ _("Expiry date") }}
</label>
<input
id="expires_{{ related_user.id }}"
name="expires"
type="datetime-local"
value="{{ related_user.expires|default('', true) and related_user.expires.strftime('%Y-%m-%dT%H:%M') or '' }}"
class="bg-white border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-800 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
/>
</div>
<button
type="submit"
class="inline-flex items-center px-4 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary_hover focus:ring-4 focus:outline-none focus:ring-primary_focus dark:bg-primary dark:hover:bg-primary_hover dark:focus:ring-primary_focus"
>
{{ _("Update") }}
</button>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{% if related_user.expires %}
{{ _("Current expiry") }}: {{ related_user.expires|human_date }}
{% else %}
{{ _("No expiry set") }}
{% endif %}
</p>
</form>
</div>
</div>
{% endfor %}
<!-- Notes Tab Content -->
<div
x-show="activeTab === 'tab-notes'"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
role="tabpanel"
:aria-hidden="activeTab !== 'tab-notes'"
>
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
{{ _("Notes") }}
</h3>
<form
hx-post="{{ url_for('admin.user_detail', db_id=user.id) }}"
class="space-y-4"
>
<div>
<label for="notes" class="sr-only">{{ _("User notes") }}</label>
<textarea
id="notes"
name="notes"
rows="6"
placeholder="{{ _('Add notes about this user...') }}"
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-3 dark:bg-gray-900 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
>{{ user.notes|default('', true) }}</textarea>
</div>
<div class="flex justify-end">
<button
type="submit"
class="inline-flex items-center px-4 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary_hover focus:ring-4 focus:outline-none focus:ring-primary_focus dark:bg-primary dark:hover:bg-primary_hover dark:focus:ring-primary_focus"
>
{{ _("Update Notes") }}
</button>
</div>
</form>
</div>
</div>
<div class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all w-full max-w-2xl dark:bg-gray-800"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
x-data="{ activeTab: 'tab-{{ related_users[0].id }}' }">
<div class="bg-white dark:bg-gray-800">
<!-- Header -->
<div class="flex justify-between items-center px-4 pt-5 sm:px-6 sm:pt-6 pb-3">
<h2 id="modal-title"
class="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
{{ _("Edit User") }} {{ user.username or user.email }}
</h2>
<button type="button"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ms-auto inline-flex justify-center items-center dark:hover:bg-gray-600 dark:hover:text-white"
onclick="document.getElementById('modal').classList.add('hidden')"
aria-label="{{ _('Close modal') }}">
<svg class="w-3 h-3"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6" />
</svg>
<span class="sr-only">{{ _('Close modal') }}</span>
</button>
</div>
<!-- Tab Navigation -->
<div class="border-b border-gray-200 dark:border-gray-700 px-4 sm:px-6">
<nav class="flex -mb-px space-x-2 overflow-x-auto"
aria-label="{{ _('Tabs') }}"
role="tablist">
{% for related_user in related_users %}
<button type="button"
role="tab"
:aria-selected="activeTab === 'tab-{{ related_user.id }}'"
@click="activeTab = 'tab-{{ related_user.id }}'"
:class="activeTab === 'tab-{{ related_user.id }}' ? 'border-primary text-primary dark:border-primary dark:text-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
class="whitespace-nowrap py-3 px-4 border-b-2 font-medium text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition-colors">
{% if related_user.server %}
{{ related_user.server.name }}
{% else %}
{{ _("Local") }}
{% endif %}
</button>
{% endfor %}
<button type="button"
role="tab"
:aria-selected="activeTab === 'tab-notes'"
@click="activeTab = 'tab-notes'"
:class="activeTab === 'tab-notes' ? 'border-primary text-primary dark:border-primary dark:text-primary' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
class="whitespace-nowrap py-3 px-4 border-b-2 font-medium text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-800 transition-colors">
{{ _("Notes") }}
</button>
</nav>
</div>
<!-- Tab Content -->
<div class="px-4 pb-4 pt-4 sm:p-6 sm:pt-4">
{% for related_user in related_users %}
<div x-show="activeTab === 'tab-{{ related_user.id }}'"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
role="tabpanel"
:aria-hidden="activeTab !== 'tab-{{ related_user.id }}'">
<!-- Server Badge (for multi-server setups) -->
{% if related_users|length > 1 %}
<div class="flex items-center justify-between mb-4 pb-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center">
{% if related_user.server %}
{{ related_user.server.server_type|server_name_tag(related_user.server.name) }}
{% else %}
{{ 'local'|server_type_tag }}
{% endif %}
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">ID: {{ related_user.id }}</span>
</div>
{% endif %}
<!-- Permission Management Section (only for supported servers) -->
{% if related_user.server and related_user.server.server_type in ['plex', 'jellyfin', 'emby', 'audiobookshelf'] %}
<div class="mb-6">
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">{{ _("Permission Management") }}</h3>
<!-- Permission Toggles -->
<div class="space-y-3 bg-gray-50 dark:bg-gray-900 rounded-lg p-4"
x-data="{ downloading: {{ (related_user.allow_downloads if related_user.allow_downloads is not none else false)|tojson }}, liveTv: {{ (related_user.allow_live_tv if related_user.allow_live_tv is not none else false)|tojson }}, cameraUpload: {{ (related_user.allow_camera_upload if related_user.allow_camera_upload is not none else false)|tojson }}, updating: { downloads: false, liveTv: false, camera: false }, togglePermission(type, formRef) { // Store the current value in case we need to roll back const originalValue = this[type]; // Set updating state this.updating[type === 'downloading' ? 'downloads' : (type === 'liveTv' ? 'liveTv' : 'camera')] = true; // Optimistically update UI this[type] = !this[type]; // Trigger the form submission this.$nextTick(() => htmx.trigger(formRef, 'submit')); }, showError(message) { alert('Error: ' + message); } }">
<!-- Downloads Permission (All servers support this) -->
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("Allow Downloads") }}</label>
<button type="button"
@click="togglePermission('downloading', $refs.downloadForm)"
:disabled="updating.downloads"
:class="[ downloading ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700', updating.downloads ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer' ]"
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="{{ _('Toggle downloads permission') }}">
<span :class="downloading ? 'translate-x-5' : 'translate-x-0'"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
</button>
<form x-ref="downloadForm"
method="post"
action="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
hx-post="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
hx-swap="none"
@htmx:after-request="updating.downloads = false"
@htmx:response-error="downloading = !downloading; updating.downloads = false; showError($event.detail.xhr.responseText || 'Failed to update permission')"
class="hidden">
<input type="hidden" name="permission_type" value="allow_downloads">
<input type="hidden" name="enabled" :value="downloading ? 'true' : 'false'">
</form>
</div>
<!-- Live TV Permission (Plex, Jellyfin, Emby only) -->
{% if related_user.server and related_user.server.server_type in ['plex', 'jellyfin', 'emby'] %}
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("Allow Live TV") }}</label>
<button type="button"
@click="togglePermission('liveTv', $refs.liveTvForm)"
:disabled="updating.liveTv"
:class="[ liveTv ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700', updating.liveTv ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer' ]"
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="{{ _('Toggle live TV permission') }}">
<span :class="liveTv ? 'translate-x-5' : 'translate-x-0'"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
</button>
<form x-ref="liveTvForm"
method="post"
action="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
hx-post="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
hx-swap="none"
@htmx:after-request="updating.liveTv = false"
@htmx:response-error="liveTv = !liveTv; updating.liveTv = false; showError($event.detail.xhr.responseText || 'Failed to update permission')"
class="hidden">
<input type="hidden" name="permission_type" value="allow_live_tv">
<input type="hidden" name="enabled" :value="liveTv ? 'true' : 'false'">
</form>
</div>
{% endif %}
<!-- Camera Upload Permission (Plex only) -->
{% if related_user.server and related_user.server.server_type == 'plex' %}
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("Allow Camera Upload") }}</label>
<button type="button"
@click="togglePermission('cameraUpload', $refs.cameraForm)"
:disabled="updating.camera"
:class="[ cameraUpload ? 'bg-primary' : 'bg-gray-200 dark:bg-gray-700', updating.camera ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer' ]"
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-all duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label="{{ _('Toggle camera upload permission') }}">
<span :class="cameraUpload ? 'translate-x-5' : 'translate-x-0'"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
</button>
<form x-ref="cameraForm"
method="post"
action="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
hx-post="{{ url_for('admin.update_user_permissions', db_id=related_user.id) }}"
hx-swap="none"
@htmx:after-request="updating.camera = false"
@htmx:response-error="cameraUpload = !cameraUpload; updating.camera = false; showError($event.detail.xhr.responseText || 'Failed to update permission')"
class="hidden">
<input type="hidden" name="permission_type" value="allow_camera_upload">
<input type="hidden" name="enabled" :value="cameraUpload ? 'true' : 'false'">
</form>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Library Access Section -->
{% set lib_data = user_libraries_map.get(related_user.id, {}) %}
{% set libraries = lib_data.get('libraries', []) %}
{% set accessible = lib_data.get('accessible_libraries', []) %}
<div class="mb-6">
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">{{ _("Library Access") }}</h3>
{% if libraries|length == 0 %}
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-500 dark:text-gray-400">
<p>{{ _("No libraries available") }}</p>
<p class="text-xs mt-1">{{ _("Scan libraries from the invitation page to enable library restrictions") }}</p>
</div>
{% else %}
<div class="relative" x-data="{ updating: false }">
<form id="library-form-{{ related_user.id }}"
@htmx:before-request="updating = true"
@htmx:after-request="updating = false"
:class="updating ? 'opacity-60 pointer-events-none' : ''"
class="transition-opacity duration-200">
<ul class="h-48 px-3 py-2 overflow-y-auto text-sm text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-900">
{% for library in libraries %}
<li>
<label class="flex items-center ps-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer py-2"
:class="updating ? 'cursor-not-allowed' : 'cursor-pointer'">
<input type="checkbox"
name="library_ids[]"
value="{{ library.id }}"
{% if accessible|length == 0 or library.name in accessible %}checked{% endif %}
hx-post="{{ url_for('admin.update_user_libraries', db_id=related_user.id) }}"
hx-trigger="change"
hx-include="#library-form-{{ related_user.id }}"
hx-swap="none"
:disabled="updating"
class="w-4 h-4 text-primary bg-gray-100 border-gray-300 rounded focus:ring-primary dark:focus:ring-primary dark:ring-offset-gray-900 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<span class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">{{ library.name }}</span>
</label>
</li>
{% endfor %}
</ul>
</form>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{{ _("Click to toggle library access. Updates save automatically.") }}
</p>
{% endif %}
</div>
<!-- Expiry Management Section -->
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">{{ _("Expiry Management") }}</h3>
<form hx-post="{{ url_for('admin.user_detail', db_id=related_user.id) }}"
class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<div class="flex items-end gap-3">
<div class="flex-1">
<label for="expires_{{ related_user.id }}"
class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ _("Expiry date") }}
</label>
<input id="expires_{{ related_user.id }}"
name="expires"
type="datetime-local"
value="{{ related_user.expires|default('', true) and related_user.expires.strftime('%Y-%m-%dT%H:%M') or '' }}"
class="bg-white border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-2.5 dark:bg-gray-800 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white" />
</div>
<button type="submit"
class="inline-flex items-center px-4 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary_hover focus:ring-4 focus:outline-none focus:ring-primary_focus dark:bg-primary dark:hover:bg-primary_hover dark:focus:ring-primary_focus">
{{ _("Update") }}
</button>
</div>
<p class="mt-3 text-xs text-gray-500 dark:text-gray-400">
{% if related_user.expires %}
{{ _("Current expiry") }}: {{ related_user.expires|human_date }}
{% else %}
{{ _("No expiry set") }}
{% endif %}
</p>
</form>
</div>
</div>
{% endfor %}
<!-- Notes Tab Content -->
<div x-show="activeTab === 'tab-notes'"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 transform translate-y-2"
x-transition:enter-end="opacity-100 transform translate-y-0"
role="tabpanel"
:aria-hidden="activeTab !== 'tab-notes'">
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">{{ _("Notes") }}</h3>
<form hx-post="{{ url_for('admin.user_detail', db_id=user.id) }}"
class="space-y-4">
<div>
<label for="notes" class="sr-only">{{ _("User notes") }}</label>
<textarea id="notes"
name="notes"
rows="6"
placeholder="{{ _('Add notes about this user...') }}"
class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary focus:border-primary block w-full p-3 dark:bg-gray-900 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">{{ user.notes|default('', true) }}</textarea>
</div>
<div class="flex justify-end">
<button type="submit"
class="inline-flex items-center px-4 py-2.5 text-sm font-medium text-white bg-primary rounded-lg hover:bg-primary_hover focus:ring-4 focus:outline-none focus:ring-primary_focus dark:bg-primary dark:hover:bg-primary_hover dark:focus:ring-primary_focus">
{{ _("Update Notes") }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>

View File

@@ -302,8 +302,11 @@
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<div class="fixed inset-0 modal-backdrop transition-opacity" style="z-index: 0 !important;"></div>
<div class="flex min-h-full items-center justify-center p-4" style="position: relative; z-index: 10 !important;">
<div class="fixed inset-0 modal-backdrop transition-opacity"
style="z-index: 0 !important"></div>
<div class="flex min-h-full items-center justify-center p-4"
style="position: relative;
z-index: 10 !important">
<div id="modal-user"></div>
</div>
</div>

View File

@@ -127,9 +127,7 @@
</div>
</button>
{% else %}
<div class="px-3 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">
{{ _("Add Step to Bundle") }}
</div>
<div class="px-3 py-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("Add Step to Bundle") }}</div>
{% if bundles %}
{% for bundle in bundles %}
<button hx-get="{{ url_for('wizard_admin.create_step', simple=1, bundle_id=bundle.id) }}"
@@ -151,9 +149,7 @@
</button>
{% endfor %}
{% else %}
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
{{ _("No bundles available yet") }}
</div>
<div class="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">{{ _("No bundles available yet") }}</div>
{% endif %}
<div class="border-t border-gray-200 dark:border-gray-700 my-1"></div>
{% endif %}

View File

@@ -13,9 +13,9 @@
{% set pre_steps = real_steps | selectattr('step.category', 'equalto', 'pre_invite') | list %}
{% set post_steps = real_steps | selectattr('step.category', 'equalto', 'post_invite') | list %}
{% set other_steps = real_steps
| rejectattr('step.category', 'equalto', 'pre_invite')
| rejectattr('step.category', 'equalto', 'post_invite')
| list %}
| rejectattr('step.category', 'equalto', 'pre_invite')
| rejectattr('step.category', 'equalto', 'post_invite')
| list %}
{% set orphaned_steps = b.steps | rejectattr('step') | list %}
<div class="border rounded-md p-4 dark:border-gray-700">
<div class="flex items-center justify-between mb-4">
@@ -65,14 +65,14 @@
</span>
</div>
{% if b.description %}<p class="text-sm text-gray-600 dark:text-gray-300 mb-6">{{ b.description }}</p>{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="wizard-category-section border rounded-lg p-4 bg-gray-50 dark:bg-gray-900 dark:border-gray-600">
<h3 class="text-base font-semibold text-gray-800 dark:text-gray-100 mb-2">{{ _("Before Invite Acceptance") }}</h3>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ _("Steps shown to users before they accept the invitation") }}
</p>
<ol class="bundle-steps bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 {% if not pre_steps %}min-h-[120px] flex items-center justify-center{% endif %}"
<ol class="bundle-steps bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700
{% if not pre_steps %}min-h-[120px] flex items-center justify-center{% endif %}"
data-bundle="{{ b.id }}"
data-category="pre_invite"
data-bundle-column="0"
@@ -153,7 +153,8 @@
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
{{ _("Steps shown to users after they accept the invitation") }}
</p>
<ol class="bundle-steps bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 {% if not post_steps %}min-h-[120px] flex items-center justify-center{% endif %}"
<ol class="bundle-steps bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700
{% if not post_steps %}min-h-[120px] flex items-center justify-center{% endif %}"
data-bundle="{{ b.id }}"
data-category="post_invite"
data-bundle-column="1"
@@ -299,11 +300,12 @@
</div>
{% endif %}
</div>
{% if orphaned_steps %}
<div class="mt-4 border border-dashed border-red-500/50 dark:border-red-400/40 rounded-lg p-4 bg-red-50/40 dark:bg-red-900/10">
<h4 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-2">{{ _("Orphaned Steps") }}</h4>
<p class="text-xs text-red-500 dark:text-red-300 mb-3">{{ _("These entries no longer have an associated step. Remove them to keep the bundle tidy.") }}</p>
<p class="text-xs text-red-500 dark:text-red-300 mb-3">
{{ _("These entries no longer have an associated step. Remove them to keep the bundle tidy.") }}
</p>
<ol class="space-y-2">
{% for bs in orphaned_steps %}
<li class="flex items-center justify-between bg-white/80 dark:bg-gray-800/70 rounded-md px-3 py-2">

View File

@@ -5,6 +5,8 @@ These tests simulate the complete user journey from receiving an invitation
link to successfully creating accounts on media servers.
"""
import contextlib
import multiprocessing
import os
import tempfile
from unittest.mock import patch
@@ -12,6 +14,13 @@ from unittest.mock import patch
import pytest
from playwright.sync_api import Page, expect
# Fix for Python 3.14+ multiprocessing compatibility with pytest-flask live_server
# GitHub Actions uses spawn/forkserver by default which can't pickle local functions
# Force 'fork' method before any fixtures initialize
with contextlib.suppress(RuntimeError):
# RuntimeError raised if method already set, which is fine
multiprocessing.set_start_method("fork", force=True)
from app import create_app
from app.config import BaseConfig
from app.extensions import db