@@ -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 = "tru e"
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-titl e"
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 >