Release 2.8.0
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m49s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s

This commit is contained in:
2026-08-16 17:15:31 +01:00
parent 1bdc122995
commit 203d0bfc01
105 changed files with 4185 additions and 677 deletions
+42 -7
View File
@@ -13,9 +13,7 @@
</div>
<form id="account-name-form" method="post" action="/account/name" data-async-save>
<div class="card-body">
<div class="mb-3">
<input id="account-name" type="text" name="name" class="form-control" value="{{name}}" autocomplete="name" required />
</div>
<input id="account-name" type="text" name="name" class="form-control" value="{{name}}" autocomplete="name" required />
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Display name actions">
@@ -25,10 +23,47 @@
</div>
</form>
</div>
{{#if allowUserSessionRevocation}}
<div class="card card-outline card-secondary admin-form-card mt-3">
<div class="card-header">
<h3 class="card-title">Active sessions</h3>
</div>
<div class="card-body">
<div class="d-flex flex-column gap-2">
{{#if sessions.length}}
{{#each sessions}}
<div class="border rounded p-3 {{#if isCurrent}}border-primary bg-primary-subtle{{/if}}">
<div class="d-flex align-items-start justify-content-between gap-3">
<div>
<div class="fw-semibold">{{#if isCurrent}}Current session{{else}}Active session{{/if}}</div>
</div>
{{#unless isCurrent}}
<form method="post" action="/account/sessions/{{id}}/revoke">
<button type="submit" class="btn btn-sm btn-outline-danger">Sign out</button>
</form>
{{/unless}}
</div>
<div class="text-body-secondary small mt-2">{{ipAddress}} · {{userAgent}}</div>
<div class="text-body-secondary small">Created {{createdAtLabel}} · Last used {{lastUsedAtLabel}} · Expires {{expiresAtLabel}}</div>
</div>
{{/each}}
{{else}}
<p class="mb-0 text-body-secondary">No active sessions were found.</p>
{{/if}}
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<form method="post" action="/account/sessions/revoke">
<button type="submit" class="btn btn-danger">Sign out other sessions</button>
</form>
</div>
</div>
{{/if}}
</div>
<div class="col-12 col-xl-6">
<div class="card card-outline card-warning admin-form-card h-100">
<div class="card card-outline card-warning admin-form-card">
<div class="card-header">
<h3 class="card-title">Password</h3>
</div>
@@ -40,13 +75,13 @@
</div>
<div class="mb-3">
<label for="account-new-password" class="form-label">New password</label>
<input id="account-new-password" type="password" name="new_password" class="form-control" autocomplete="new-password" minlength="10" required />
<input id="account-new-password" type="password" name="new_password" class="form-control" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required />
</div>
<div class="mb-0">
<label for="account-confirm-password" class="form-label">Confirm new password</label>
<input id="account-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="10" required />
<input id="account-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required />
</div>
<div class="form-text mt-2">Use at least 10 characters and include 3 of: uppercase, lowercase, a number, and a symbol.</div>
<div class="form-text mt-2">{{passwordRequirementsText}}</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Password actions">
+5 -1
View File
@@ -13,12 +13,16 @@
{{/if}}
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" name="username" type="text" class="form-control" autocomplete="username" required />
<input id="username" name="username" type="text" class="form-control" autocomplete="username" value="{{username}}" required />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input id="password" name="password" type="password" class="form-control" autocomplete="current-password" required />
</div>
<div class="form-check mb-3">
<input id="remember-me" name="remember_me" type="checkbox" class="form-check-input" value="1" />
<label for="remember-me" class="form-check-label">Remember me</label>
</div>
<button type="submit" class="btn btn-primary w-100">Sign in</button>
</form>
</div>
@@ -0,0 +1,57 @@
<div class="page-header d-flex flex-wrap align-items-start gap-3">
<div>
<h2>Audit log</h2>
<p>Review recorded authentication, security, and session events.</p>
</div>
</div>
<div class="card card-outline card-secondary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<form method="get" action="/settings/audit-log" class="row g-2 align-items-end">
<div class="col-12 col-md-3">
<label class="form-label small" for="audit-category">Category</label>
<select id="audit-category" name="category" class="form-select form-select-sm">
<option value="">All categories</option>
{{#each categories}}<option value="{{this}}" {{#if (eq ../selectedCategory this)}}selected{{/if}}>{{this}}</option>{{/each}}
</select>
</div>
<div class="col-12 col-md-3">
<label class="form-label small" for="audit-event-type">Event type</label>
<input id="audit-event-type" name="event_type" class="form-control form-control-sm" value="{{selectedEventType}}" placeholder="e.g. login.success" list="audit-event-type-options" autocomplete="off">
<datalist id="audit-event-type-options">
{{#each eventTypes}}<option value="{{this}}"></option>{{/each}}
</datalist>
</div>
<div class="col-12 col-md-4">
<label class="form-label small" for="audit-search">Search</label>
<input id="audit-search" name="search" type="search" class="form-control form-control-sm" value="{{search}}" placeholder="Event, actor, or target" data-table-search>
</div>
<div class="col-12 col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm flex-fill"><i class="bi bi-search me-1" aria-hidden="true"></i>Filter</button>
{{#if (hasPermission currentUser 'audit-log.allow')}}<a class="btn btn-outline-primary btn-sm flex-fill" href="{{exportUrl}}"><i class="bi bi-download me-1" aria-hidden="true"></i>Export</a>{{/if}}
</div>
</form>
</div>
<div class="card-body p-0 table-responsive">
<table class="table table-striped align-middle mb-0" data-table-searchable>
<thead><tr><th data-table-sort-key="time">Time</th><th data-table-sort-key="event">Event</th><th data-table-sort-key="actor">Actor</th><th data-table-sort-key="target">Target</th><th>Source</th><th>Details</th></tr></thead>
<tbody>
{{#if events.length}}
{{#each events}}
<tr data-table-search-row>
<td class="text-nowrap"><time data-local-datetime data-local-datetime-format="24h" datetime="{{occurredAtValue}}">{{occurredAtValue}}</time></td>
<td><div class="fw-semibold text-capitalize">{{eventLabel}}</div><div class="text-muted small">{{category}}</div></td>
<td>{{actorLabel}}</td>
<td>{{targetLabelDisplay}}</td>
<td><div>{{ip_address}}</div><div class="text-muted small text-break">{{user_agent}}</div></td>
<td class="text-break"><small>{{detailsDisplay}}</small></td>
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default><td colspan="6" class="empty">No audit events match these filters.</td></tr>
{{/if}}
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/settings/audit-log" alwaysShow=true}}
</div>
+8 -9
View File
@@ -27,7 +27,7 @@
</div>
<div class="card-footer d-flex justify-content-end flex-wrap">
<div class="btn-group" role="group" aria-label="Role actions">
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showDelete=footerShowDelete}}}
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl cancelConfirmMessage=cancelConfirmMessage deleteUrl=footerDeleteUrl deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showDelete=footerShowDelete}}}
</div>
</div>
</div>
@@ -58,6 +58,7 @@
{{#each users}}
<tr data-table-search-row>
<td data-label="Select">
<input type="hidden" name="user_ids_present[]" value="{{id}}" />
<input class="form-check-input" type="checkbox" name="user_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
</td>
<td data-label="Username">
@@ -80,14 +81,13 @@
</div>
<div class="col-12 col-xl-8 d-flex flex-column gap-4">
<div class="card card-outline card-secondary admin-form-card">
<div class="card card-outline card-secondary admin-form-card overflow-hidden">
<div class="card-header">
<h3 class="card-title">Permissions</h3>
</div>
<div class="card-body">
<div class="card-body p-0">
{{#if permissionSections.length}}
<div class="card card-outline card-secondary overflow-hidden">
<div class="accordion accordion-flush" id="role-permissions-accordion">
<div class="accordion accordion-flush" id="role-permissions-accordion">
{{#each permissionSections}}
<div class="accordion-item overflow-hidden" data-permission-section data-permission-section-id="{{id}}">
<h2 class="accordion-header" id="permission-section-heading-{{id}}">
@@ -95,7 +95,7 @@
<span class="fw-semibold">{{title}}</span>
</button>
</h2>
<div id="permission-section-collapse-{{id}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="permission-section-heading-{{id}}">
<div id="permission-section-collapse-{{id}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" data-bs-parent="#role-permissions-accordion" aria-labelledby="permission-section-heading-{{id}}">
<div class="accordion-body p-0">
<div class="table-responsive">
<table class="table table-striped align-middle mb-0 rbac-permissions-table" style="table-layout: fixed; width: 100%;">
@@ -105,7 +105,7 @@
<col style="width: 5.5rem;" />
{{/each}}
</colgroup>
<thead class="table-light">
<thead class="rbac-permissions-table-head">
<tr>
<th scope="col">Type</th>
{{#each actions}}
@@ -117,7 +117,7 @@
{{#each groups}}
<tr data-permission-row data-permission-row-id="{{id}}">
<th scope="row" class="align-top">
<button type="button" class="text-reset border-0 bg-transparent p-0 fw-semibold text-start rbac-row-toggle" style="color: inherit; text-decoration-line: underline; text-decoration-style: dotted; text-underline-offset: 0.2em; cursor: pointer;" data-permission-row-toggle data-permission-row-target="{{id}}">{{title}}</button>
<button type="button" class="text-reset border-0 bg-transparent p-0 text-start rbac-row-toggle" style="color: inherit; text-decoration-line: underline; text-decoration-style: dotted; text-underline-offset: 0.2em; cursor: pointer;" data-permission-row-toggle data-permission-row-target="{{id}}">{{title}}</button>
</th>
{{#each cells}}
<td class="text-center">
@@ -140,7 +140,6 @@
</div>
</div>
{{/each}}
</div>
</div>
{{else}}
<div class="alert alert-warning mb-0">No permissions are available.</div>
+5 -5
View File
@@ -16,7 +16,7 @@
<input type="search" class="form-control" placeholder="Search roles" aria-label="Search roles" data-table-search />
</div>
{{#if (hasPermission currentUser 'rbac.create')}}
<a class="btn btn-primary btn-sm" href="/rbac/new">Add role</a>
<a class="btn btn-primary btn-sm" href="/settings/roles/new">Add role</a>
{{/if}}
</div>
</div>
@@ -55,13 +55,13 @@
{{#if (anyPermission ../currentUser 'rbac.update' 'rbac.delete')}}
<div class="actions users-row-actions">
{{#if (hasPermission ../currentUser 'rbac.update')}}
<a class="btn btn-sm btn-primary" href="/rbac/{{id}}/edit">Edit</a>
<a class="btn btn-sm btn-primary" href="/settings/roles/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'rbac.create')}}
<a class="btn btn-sm btn-secondary" href="/rbac/{{id}}/duplicate">Dupe</a>
<a class="btn btn-sm btn-secondary" href="/settings/roles/{{id}}/duplicate">Dupe</a>
{{/if}}
{{#if (hasPermission ../currentUser 'rbac.delete')}}
<form method="post" action="/rbac/{{id}}/delete" class="inline-form" data-confirm-message="Delete {{name}}?">
<form method="post" action="/settings/roles/{{id}}/delete" class="inline-form" data-confirm-message="Delete {{name}}?">
{{#if (gt user_count 0)}}
<button type="submit" class="btn btn-sm btn-outline-danger" disabled>Delete</button>
{{else}}
@@ -80,7 +80,7 @@
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/rbac" alwaysShow=true}}
{{> table-pagination pagination=pagination basePath="/settings/roles" alwaysShow=true}}
</div>
</div>
</div>
+451
View File
@@ -0,0 +1,451 @@
<div class="page-header">
<div>
<h2>System Settings</h2>
<p>Configure the defaults and content policies used across Pulse Signage.</p>
</div>
</div>
<div class="row g-3 align-items-start">
<div class="col-12 col-lg-3 col-xl-2">
<div class="card settings-nav-card">
<div class="card-body p-2">
<div class="text-uppercase text-muted small fw-semibold px-2 pt-1 pb-2">Settings</div>
<nav class="nav nav-pills flex-column gap-1" aria-label="Settings sections">
<a class="nav-link active" href="#application-defaults" data-settings-section-link>
<i class="bi bi-sliders me-2" aria-hidden="true"></i>Defaults
</a>
<a class="nav-link" href="#media-uploads" data-settings-section-link>
<i class="bi bi-cloud-arrow-up me-2" aria-hidden="true"></i>Media Uploads
</a>
<a class="nav-link" href="#icon-suggestions" data-settings-section-link>
<i class="bi bi-grid-3x3-gap me-2" aria-hidden="true"></i>Icon Suggestions
</a>
<a class="nav-link" href="#security-sessions" data-settings-section-link>
<i class="bi bi-shield-lock me-2" aria-hidden="true"></i>Security and Sessions
</a>
<a class="nav-link" href="#audit-logging" data-settings-section-link>
<i class="bi bi-journal-text me-2" aria-hidden="true"></i>Audit Logging
</a>
<a class="nav-link" href="#diagnostics" data-settings-section-link>
<i class="bi bi-activity me-2" aria-hidden="true"></i>Diagnostics
</a>
</nav>
</div>
</div>
</div>
<div class="col-12 col-lg-9 col-xl-10">
<form method="post" action="/settings/system" data-async-save data-settings-form="defaults">
<input type="hidden" name="settings_section" value="defaults">
<div id="application-defaults" class="card settings-section-card" data-settings-section>
<div class="card-header d-flex align-items-start justify-content-between gap-3">
<div class="min-w-0">
<h3 class="h5 mb-1 fw-semibold"><i class="bi bi-sliders me-2 text-primary" aria-hidden="true"></i>Defaults</h3>
<p class="text-muted small mb-0">Set starting values for players and announcements.</p>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12"><h4 class="h6 text-uppercase text-muted mb-0">Player defaults</h4></div>
<div class="col-12 col-md-4">
<label class="form-label" for="settings-default-slide-duration">Default slide duration</label>
<div class="input-group"><input id="settings-default-slide-duration" name="default_slide_duration_seconds" type="number" class="form-control" min="1" max="3600" required value="{{mediaSettings.defaultSlideDurationSeconds}}"><span class="input-group-text">seconds</span></div>
</div>
<div class="col-12 col-md-8">
<label class="form-label">Player behavior</label>
<div class="d-flex flex-wrap align-items-center gap-4 pt-2">
<div class="form-check form-switch">
<input id="settings-default-fade" name="default_fade_between_slides" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.defaultFadeBetweenSlides}}checked{{/if}}>
<label class="form-check-label" for="settings-default-fade">Fade between slides</label>
</div>
<div class="form-check form-switch">
<input id="settings-default-skip-rtmp" name="default_skip_unavailable_rtmp" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.defaultSkipUnavailableRtmp}}checked{{/if}}>
<label class="form-check-label" for="settings-default-skip-rtmp">Skip unavailable RTMP</label>
</div>
</div>
</div>
<div class="col-12"><hr class="my-2"><h4 class="h6 text-uppercase text-muted mb-0">Announcement defaults</h4></div>
<div class="col-12 col-md-6">
<label class="form-label" for="settings-default-announcement-icon">Default announcement icon</label>
<div class="announcement-icon-picker" data-icon-picker data-settings-default-icon-picker>
<select id="settings-default-announcement-icon" name="default_announcement_icon" class="d-none" tabindex="-1" aria-hidden="true">
{{#each suggestedAnnouncementIcons}}<option value="{{value}}" data-icon-label="{{label}}" {{#if (eq value ../mediaSettings.defaultAnnouncementIcon)}}selected{{/if}}>{{label}}</option>{{/each}}
</select>
<button type="button" class="announcement-icon-picker__toggle" data-icon-picker-toggle data-settings-default-icon-toggle aria-expanded="false" aria-controls="settings-default-announcement-icon-menu" title="Select an icon">
<span class="announcement-icon-picker__toggle-icon" data-icon-picker-preview data-settings-default-icon-preview aria-hidden="true"></span>
<span class="announcement-icon-picker__toggle-content"><span class="announcement-icon-picker__toggle-label" data-icon-picker-label data-settings-default-icon-label>Select an icon</span><span class="announcement-icon-picker__toggle-hint">Click to choose</span></span>
<i class="bi bi-chevron-down announcement-icon-picker__caret" aria-hidden="true"></i>
</button>
<div id="settings-default-announcement-icon-menu" class="announcement-icon-picker__menu" data-icon-picker-menu data-settings-default-icon-menu hidden>
<div class="announcement-icon-picker__header"><span>Choose default icon</span><button type="button" class="btn btn-sm btn-link text-decoration-none p-0" data-icon-picker-close data-settings-default-icon-close>Close</button></div>
<div class="input-group input-group-sm announcement-icon-picker__search"><span class="input-group-text"><i class="bi bi-search" aria-hidden="true"></i></span><input type="search" class="form-control" placeholder="Search icons" aria-label="Search icons" data-icon-picker-search data-settings-default-icon-search></div>
<div class="announcement-icon-picker__grid" data-icon-picker-grid data-settings-default-icon-grid>
{{#each defaultAnnouncementIconSuggestions}}<button type="button" class="announcement-icon-picker__option" data-icon-picker-option data-settings-default-icon-option data-icon-key="{{value}}" data-icon-label="{{label}}" aria-pressed="false" title="{{label}}"><i class="bi bi-{{value}}" aria-hidden="true"></i><span class="visually-hidden">{{label}}</span></button>{{/each}}
</div>
<div class="announcement-icon-picker__search-empty" data-icon-picker-empty data-settings-default-icon-empty hidden>No matching icons.</div>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="settings-default-announcement-duration">Default announcement duration</label>
<div class="row g-2"><div class="col-7"><input id="settings-default-announcement-duration" name="default_announcement_duration_value" type="number" class="form-control" min="1" max="3600" required value="{{mediaSettings.defaultAnnouncementDurationValue}}"></div><div class="col-5"><select name="default_announcement_duration_unit" class="form-select" required><option value="seconds" {{#if (eq mediaSettings.defaultAnnouncementDurationUnit 'seconds')}}selected{{/if}}>Seconds</option><option value="minutes" {{#if (eq mediaSettings.defaultAnnouncementDurationUnit 'minutes')}}selected{{/if}}>Minutes</option></select></div></div>
</div>
<div class="col-12"><hr class="my-2"><h4 class="h6 text-uppercase text-muted mb-0">Data source defaults</h4></div>
<div class="col-12 col-md-6">
<label class="form-label" for="settings-rss-interval-value">RSS feed refresh interval</label>
<div class="row g-2"><div class="col-7"><input id="settings-rss-interval-value" name="rss_default_interval_value" type="number" class="form-control" min="1" max="86400" required value="{{mediaSettings.rssDefaultIntervalValue}}"></div><div class="col-5"><select name="rss_default_interval_unit" class="form-select" required><option value="seconds" {{#if (eq mediaSettings.rssDefaultIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option><option value="minutes" {{#if (eq mediaSettings.rssDefaultIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option><option value="hours" {{#if (eq mediaSettings.rssDefaultIntervalUnit 'hours')}}selected{{/if}}>Hours</option></select></div></div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="settings-api-interval-value">API source refresh interval</label>
<div class="row g-2"><div class="col-7"><input id="settings-api-interval-value" name="api_default_interval_value" type="number" class="form-control" min="1" max="86400" required value="{{mediaSettings.apiDefaultIntervalValue}}"></div><div class="col-5"><select name="api_default_interval_unit" class="form-select" required><option value="seconds" {{#if (eq mediaSettings.apiDefaultIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option><option value="minutes" {{#if (eq mediaSettings.apiDefaultIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option><option value="hours" {{#if (eq mediaSettings.apiDefaultIntervalUnit 'hours')}}selected{{/if}}>Hours</option></select></div></div>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">{{#if (hasPermission currentUser 'system-settings.update')}}<button type="submit" class="btn btn-success">Save</button>{{else}}<button type="button" class="btn btn-outline-success" disabled>Save</button>{{/if}}</div>
</div>
</form>
<form method="post" action="/settings/system" data-async-save data-settings-form="icons">
<input type="hidden" name="settings_section" value="icons">
<div id="icon-suggestions" class="card settings-section-card" data-settings-section hidden>
<div class="card-header d-flex align-items-start justify-content-between gap-3">
<div class="min-w-0">
<h3 class="h5 mb-1 fw-semibold"><i class="bi bi-grid-3x3-gap me-2 text-primary" aria-hidden="true"></i>Icon Suggestions</h3>
<p class="text-muted small mb-0">Choose up to 48 icons shown first in icon pickers.</p>
</div>
</div>
<div class="card-body">
<div class="row g-3" data-selected-icon-list>
{{#each suggestedAnnouncementIcons}}
{{#if isSelected}}
<div class="col-12 col-sm-6 col-xl-3" draggable="true" data-selected-icon-item data-icon-key="{{value}}">
<div class="d-flex align-items-center gap-2 border rounded p-2 h-100">
<button type="button" class="btn btn-link text-body-secondary p-1" data-drag-icon title="Drag to reorder" aria-label="Drag {{label}} to reorder"><i class="bi bi-grip-vertical" aria-hidden="true"></i></button>
<i class="bi bi-{{value}} fs-5 text-primary" aria-hidden="true"></i>
<span class="flex-grow-1">{{label}}</span>
<input type="hidden" name="suggested_icons[]" value="{{value}}" data-ordered-icon-input>
<button type="button" class="btn btn-link text-danger p-1" data-remove-icon title="Remove {{label}}" aria-label="Remove {{label}}"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
</div>
</div>
{{/if}}
{{/each}}
<span class="col-12 text-muted small" data-no-selected-icons hidden>No icons selected.</span>
</div>
<div class="d-flex flex-wrap align-items-center gap-3 mt-3">
<button type="button" class="btn btn-outline-primary btn-sm" data-add-icon-button data-bs-toggle="modal" data-bs-target="#icon-suggestions-modal"><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add icon</button>
<span class="text-muted small"><strong data-icon-suggestions-count>0</strong> of 48 selected</span>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
{{#if (hasPermission currentUser 'system-settings.update')}}
<button type="submit" class="btn btn-success">Save</button>
{{else}}
<button type="button" class="btn btn-outline-success" disabled>Save</button>
{{/if}}
</div>
</div>
</form>
<form method="post" action="/settings/system" data-async-save data-settings-form="media">
<input type="hidden" name="settings_section" value="media">
<div id="media-uploads" class="card settings-section-card" data-settings-section hidden>
<div class="card-header d-flex align-items-start justify-content-between gap-3">
<div class="min-w-0">
<h3 class="h5 mb-1 fw-semibold"><i class="bi bi-cloud-arrow-up me-2 text-primary" aria-hidden="true"></i>Media Uploads</h3>
<p class="text-muted small mb-0">Set the maximum file sizes accepted by the slide and template upload flows.</p>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-4">
<label class="form-label" for="settings-image-max">Image max</label>
<div class="input-group">
<input id="settings-image-max" name="image_max_mb" type="number" class="form-control" min="1" max="1024" required value="{{mediaSettings.imageMaxMb}}">
<span class="input-group-text">MB</span>
</div>
<div class="form-text">Between 1 MB and 1 GB.</div>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="settings-video-max">Video max</label>
<div class="input-group">
<input id="settings-video-max" name="video_max_mb" type="number" class="form-control" min="1" max="4096" required value="{{mediaSettings.videoMaxMb}}">
<span class="input-group-text">MB</span>
</div>
<div class="form-text">Between 1 MB and 4 GB.</div>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="settings-wysiwyg-image-max">Rich text image max</label>
<div class="input-group">
<input id="settings-wysiwyg-image-max" name="wysiwyg_image_max_mb" type="number" class="form-control" min="0.1" max="1024" step="0.1" required value="{{mediaSettings.wysiwygImageMaxMb}}">
<span class="input-group-text">MB</span>
</div>
<div class="form-text">0.1 MB minimum; no larger than Image max.</div>
</div>
<div class="col-12">
<label class="form-label">Allowed upload types</label>
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="small text-muted fw-semibold mb-2">Images</div>
<div class="d-flex flex-wrap gap-2">
{{#each mediaSettings.imageMimeTypes}}
<input class="btn-check" type="checkbox" id="mime-{{value}}" name="allowed_mime_types[]" value="{{value}}" {{#if isSelected}}checked{{/if}}>
<label class="btn btn-outline-primary" for="mime-{{value}}">{{label}}</label>
{{/each}}
</div>
</div>
<div class="col-12 col-lg-6">
<div class="small text-muted fw-semibold mb-2">Video</div>
<div class="d-flex flex-wrap gap-2">
{{#each mediaSettings.videoMimeTypes}}
<input class="btn-check" type="checkbox" id="mime-{{value}}" name="allowed_mime_types[]" value="{{value}}" {{#if isSelected}}checked{{/if}}>
<label class="btn btn-outline-primary" for="mime-{{value}}">{{label}}</label>
{{/each}}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
{{#if (hasPermission currentUser 'system-settings.update')}}
<button type="submit" class="btn btn-success">Save</button>
{{else}}
<button type="button" class="btn btn-outline-success" disabled>Save</button>
{{/if}}
</div>
</div>
</form>
<form method="post" action="/settings/system" data-async-save data-settings-form="security">
<input type="hidden" name="settings_section" value="security">
<div id="security-sessions" class="card settings-section-card" data-settings-section hidden>
<div class="card-header d-flex align-items-start justify-content-between gap-3">
<div class="min-w-0">
<h3 class="h5 mb-1 fw-semibold"><i class="bi bi-shield-lock me-2 text-primary" aria-hidden="true"></i>Security and Sessions</h3>
<p class="text-muted small mb-0">Manage sessions, password restrictions, and account protection.</p>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12"><h4 class="h6 text-uppercase text-muted mb-0">Sessions</h4></div>
<div class="col-12 col-md-6">
<label class="form-label" for="settings-session-lifetime">Session lifetime</label>
<div class="input-group">
<input id="settings-session-lifetime" name="session_lifetime_days" type="number" class="form-control" min="1" max="365" required value="{{mediaSettings.sessionLifetimeDays}}">
<span class="input-group-text">days</span>
</div>
<div class="form-text">Applies to new logins. Existing sessions keep their current expiry.</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="settings-max-active-sessions">Maximum active sessions</label>
<div class="input-group">
<input id="settings-max-active-sessions" name="max_active_sessions" type="number" class="form-control" min="0" max="100" required value="{{mediaSettings.maxActiveSessions}}">
<span class="input-group-text">sessions</span>
</div>
<div class="form-text">Use 0 for unlimited. Oldest sessions are removed first.</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input id="settings-allow-session-revocation" name="allow_user_session_revocation" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.allowUserSessionRevocation}}checked{{/if}}>
<label class="form-check-label" for="settings-allow-session-revocation">Allow users to sign out their other active sessions</label>
</div>
</div>
<div class="col-12"><hr class="my-2"><h4 class="h6 text-uppercase text-muted mb-0">Login protection</h4></div>
<div class="col-12 col-md-4">
<label class="form-label" for="settings-login-attempts">Failed attempts before lockout</label>
<input id="settings-login-attempts" name="login_max_attempts" type="number" class="form-control" min="1" max="100" required value="{{mediaSettings.loginMaxAttempts}}">
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="settings-login-lockout">Lockout duration</label>
<div class="input-group">
<input id="settings-login-lockout" name="login_lockout_minutes" type="number" class="form-control" min="1" max="1440" required value="{{mediaSettings.loginLockoutMinutes}}">
<span class="input-group-text">minutes</span>
</div>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="settings-login-scope">Track failed logins by</label>
<select id="settings-login-scope" name="login_rate_limit_scope" class="form-select" required>
<option value="both" {{#if (eq mediaSettings.loginRateLimitScope 'both')}}selected{{/if}}>Username and IP address</option>
<option value="username" {{#if (eq mediaSettings.loginRateLimitScope 'username')}}selected{{/if}}>Username</option>
<option value="ip" {{#if (eq mediaSettings.loginRateLimitScope 'ip')}}selected{{/if}}>IP address</option>
</select>
<div class="form-text">Failed login responses remain generic.</div>
</div>
<div class="col-12"><hr class="my-2"><h4 class="h6 text-uppercase text-muted mb-0">Password and account protection</h4></div>
<div class="col-12 col-md-6 col-xl-3">
<label class="form-label" for="settings-password-min-length">Minimum length</label>
<input id="settings-password-min-length" name="password_min_length" type="number" class="form-control" min="8" max="128" required value="{{mediaSettings.passwordMinLength}}">
</div>
<div class="col-12 col-md-6 col-xl-3">
<label class="form-label" for="settings-password-min-categories">Minimum required categories</label>
<input id="settings-password-min-categories" name="password_min_categories" type="number" class="form-control" min="1" max="4" required value="{{mediaSettings.passwordMinCategories}}">
<div class="form-text">Lowercase, uppercase, number, or symbol.</div>
</div>
<div class="col-12 col-xl-6">
<label class="form-label">Always require</label>
<div class="d-flex flex-wrap gap-3">
<input id="settings-password-lowercase" name="password_require_lowercase" type="checkbox" class="btn-check" value="1" autocomplete="off" {{#if mediaSettings.passwordRequireLowercase}}checked{{/if}}>
<label class="btn btn-outline-primary" for="settings-password-lowercase">Lowercase</label>
<input id="settings-password-uppercase" name="password_require_uppercase" type="checkbox" class="btn-check" value="1" autocomplete="off" {{#if mediaSettings.passwordRequireUppercase}}checked{{/if}}>
<label class="btn btn-outline-primary" for="settings-password-uppercase">Uppercase</label>
<input id="settings-password-number" name="password_require_number" type="checkbox" class="btn-check" value="1" autocomplete="off" {{#if mediaSettings.passwordRequireNumber}}checked{{/if}}>
<label class="btn btn-outline-primary" for="settings-password-number">Number</label>
<input id="settings-password-symbol" name="password_require_symbol" type="checkbox" class="btn-check" value="1" autocomplete="off" {{#if mediaSettings.passwordRequireSymbol}}checked{{/if}}>
<label class="btn btn-outline-primary" for="settings-password-symbol">Symbol</label>
</div>
</div>
<div class="col-12">
<div class="form-check form-switch mb-2">
<input id="settings-require-password-new" name="require_password_change_for_new_users" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.requirePasswordChangeForNewUsers}}checked{{/if}}>
<label class="form-check-label" for="settings-require-password-new">Require password change for newly created users</label>
</div>
<div class="form-check form-switch">
<input id="settings-require-password-reset" name="require_password_change_after_admin_reset" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.requirePasswordChangeAfterAdminReset}}checked{{/if}}>
<label class="form-check-label" for="settings-require-password-reset">Require password change after an administrator reset</label>
</div>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
{{#if (hasPermission currentUser 'system-settings.update')}}
<button type="submit" class="btn btn-success">Save</button>
{{else}}
<button type="button" class="btn btn-outline-success" disabled>Save</button>
{{/if}}
</div>
</div>
</form>
<div id="diagnostics" class="card settings-section-card" data-settings-section hidden>
<div class="card-header d-flex align-items-start justify-content-between gap-3">
<div class="min-w-0">
<h3 class="h5 mb-1 fw-semibold"><i class="bi bi-activity me-2 text-primary" aria-hidden="true"></i>Diagnostics</h3>
<p class="text-muted small mb-0">Read-only application, database, data, session, and task health information.</p>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-4"><div class="text-body-secondary small">Application version</div><div class="fw-semibold">{{diagnostics.applicationVersion}}</div></div>
<div class="col-12 col-md-4"><div class="text-body-secondary small">Schema version</div><div class="fw-semibold">{{diagnostics.schemaVersion}}</div></div>
<div class="col-12 col-md-4"><div class="text-body-secondary small">Database</div><div class="fw-semibold">{{diagnostics.databaseStatus}}</div></div>
<div class="col-12 col-md-6"><div class="text-body-secondary small">Active sessions</div><div class="fw-semibold">{{diagnostics.activeSessionCount}}</div></div>
<div class="col-12 col-md-6"><div class="text-body-secondary small">Background tasks</div><div class="fw-semibold">{{diagnostics.backgroundTaskCount}}</div></div>
<div class="col-12"><hr class="my-2"><h4 class="h6 text-uppercase text-muted mb-0">Table counts</h4></div>
{{#each diagnostics.criticalTableCounts}}
<div class="col-6 col-md-4 col-xl-3"><div class="text-body-secondary small">{{label}}</div><div class="fw-semibold">{{count}}</div></div>
{{/each}}
</div>
</div>
</div>
<form method="post" action="/settings/system" data-async-save data-settings-form="audit">
<input type="hidden" name="settings_section" value="audit">
<div id="audit-logging" class="card settings-section-card" data-settings-section hidden>
<div class="card-header d-flex align-items-start justify-content-between gap-3">
<div class="min-w-0">
<h3 class="h5 mb-1 fw-semibold"><i class="bi bi-journal-text me-2 text-primary" aria-hidden="true"></i>Audit Logging</h3>
<p class="text-muted small mb-0">Retain security, administration, content, and data-source events for review.</p>
</div>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12">
<div class="form-check form-switch">
<input id="settings-audit-enabled" name="audit_enabled" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.auditEnabled}}checked{{/if}}>
<label class="form-check-label" for="settings-audit-enabled">Enable audit logging</label>
</div>
</div>
<div class="col-12"><hr class="my-2"><h4 class="h6 text-uppercase text-muted mb-0">Audit controls</h4></div>
<div class="col-12">
<div class="form-check form-switch">
<input id="settings-audit-metadata" name="include_request_metadata" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.auditIncludeRequestMetadata}}checked{{/if}}>
<label class="form-check-label" for="settings-audit-metadata">Store IP address and user agent</label>
</div>
<div class="form-text fst-italic">Disable this for reduced request metadata storage.</div>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="settings-audit-retention">Retain audit events for</label>
<div class="input-group settings-audit-retention-input">
<input id="settings-audit-retention" name="audit_retention_days" type="number" class="form-control" min="0" max="3650" required value="{{mediaSettings.auditRetentionDays}}">
<span class="input-group-text">days</span>
</div>
<div class="form-text fst-italic">Use 0 to retain events indefinitely. Cleanup runs daily.</div>
</div>
<div class="col-12"><hr class="my-2"><h4 class="h6 text-uppercase text-muted mb-0">Audited events</h4></div>
<div class="col-12">
<h4 class="settings-audit-category-label mb-1">General events</h4>
<p class="text-muted small fst-italic mb-2">Authentication, security, and session activity.</p>
<div class="d-flex flex-wrap gap-3">
{{#each mediaSettings.auditEventCategories}}
<div class="form-check"><input id="settings-audit-event-{{key}}" name="audit_categories[]" type="checkbox" class="form-check-input" value="{{key}}" {{#if isSelected}}checked{{/if}}><label class="form-check-label text-capitalize" for="settings-audit-event-{{key}}">{{label}}</label></div>
{{/each}}
</div>
</div>
<div class="col-12">
<h4 class="settings-audit-category-label mb-1">Administrative changes</h4>
<p class="text-muted small fst-italic mb-2">Changes to users, roles, and system settings.</p>
<div class="d-flex flex-wrap gap-3">
{{#each mediaSettings.auditAdministrationCategories}}
<div class="form-check"><input id="settings-audit-administration-{{key}}" name="audit_categories[]" type="checkbox" class="form-check-input" value="{{key}}" {{#if isSelected}}checked{{/if}}><label class="form-check-label text-capitalize" for="settings-audit-administration-{{key}}">{{label}}</label></div>
{{/each}}
</div>
</div>
<div class="col-12">
<h4 class="settings-audit-category-label mb-1">Content</h4>
<p class="text-muted small fst-italic mb-2">Changes to individual content and screen types.</p>
<div class="d-flex flex-wrap gap-3">
{{#each mediaSettings.auditContentCategories}}
<div class="form-check"><input id="settings-audit-content-{{key}}" name="audit_categories[]" type="checkbox" class="form-check-input" value="{{key}}" {{#if isSelected}}checked{{/if}}><label class="form-check-label text-capitalize" for="settings-audit-content-{{key}}">{{label}}</label></div>
{{/each}}
</div>
</div>
<div class="col-12">
<h4 class="settings-audit-category-label mb-1">Data sources</h4>
<p class="text-muted small fst-italic mb-2">Configuration changes only; automatic feed refreshes are not logged.</p>
<div class="d-flex flex-wrap gap-3">
{{#each mediaSettings.auditDataSourceCategories}}
<div class="form-check"><input id="settings-audit-data-source-{{key}}" name="audit_categories[]" type="checkbox" class="form-check-input" value="{{key}}" {{#if isSelected}}checked{{/if}}><label class="form-check-label text-capitalize" for="settings-audit-data-source-{{key}}">{{label}}</label></div>
{{/each}}
</div>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
{{#if (hasPermission currentUser 'system-settings.update')}}<button type="submit" class="btn btn-success">Save</button>{{else}}<button type="button" class="btn btn-outline-success" disabled>Save</button>{{/if}}
</div>
</div>
</form>
<div class="modal fade" id="icon-suggestions-modal" tabindex="-1" aria-labelledby="icon-suggestions-modal-title" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable modal-lg">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title fs-5" id="icon-suggestions-modal-title">Add suggested icons</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<label class="visually-hidden" for="icon-suggestions-search">Search icons</label>
<div class="input-group mb-3">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input id="icon-suggestions-search" class="form-control" type="search" placeholder="Search all Bootstrap icons" data-icon-suggestions-search>
</div>
<div class="row g-2">
{{#each suggestedAnnouncementIcons}}
<div class="col-12 col-sm-6 col-lg-4 icon-suggestion-option" data-icon-suggestion-option data-icon-label="{{label}}">
<label class="d-flex align-items-center gap-2 border rounded px-3 py-2 h-100" for="suggested-icon-{{value}}">
<input class="form-check-input mt-0" type="checkbox" id="suggested-icon-{{value}}" value="{{value}}" data-icon-picker-checkbox {{#if isSelected}}checked{{/if}}>
<i class="bi bi-{{value}} fs-5 text-primary" aria-hidden="true"></i>
<span>{{label}}</span>
</label>
</div>
{{/each}}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Done</button>
</div>
</div>
</div>
</div>
</div>
</div>
+39
View File
@@ -23,6 +23,12 @@
<input id="{{#if isEdit}}edit-user-name{{else}}user-name{{/if}}" type="text" name="name" class="form-control" autocomplete="name" value="{{#if isEdit}}{{user.name}}{{else}}{{formValues.name}}{{/if}}" maxlength="128" data-limit-text-length required />
</div>
</div>
{{#if isEdit}}
<div class="mt-3">
<input id="edit-user-account-locked" name="account_locked" type="checkbox" class="btn-check" value="1" autocomplete="off" {{#if user.account_locked}}checked{{/if}}>
<label class="btn btn-outline-danger" for="edit-user-account-locked"><span class="account-lock-label-unlocked">Account unlocked</span><span class="account-lock-label-locked">Account locked</span></label>
</div>
{{/if}}
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="{{#if isEdit}}Profile actions{{else}}User actions{{/if}}">
@@ -86,3 +92,36 @@
</div>
</form>
{{#if isEdit}}
<div class="card card-outline card-secondary admin-form-card mt-3">
<div class="card-header d-flex align-items-center justify-content-between">
<h3 class="card-title">Active sessions</h3>
{{#if sessions.length}}
<form method="post" action="/settings/users/{{user.id}}/sessions/revoke-all" class="ms-auto flex-shrink-0" data-confirm-message="Sign out all sessions for this user?">
<button type="submit" class="btn btn-sm btn-danger">Sign out all</button>
</form>
{{/if}}
</div>
<div class="card-body">
{{#if sessions.length}}
<div class="d-flex flex-column gap-2">
{{#each sessions}}
<div class="border rounded p-3">
<div class="d-flex align-items-start justify-content-between">
<div class="fw-semibold">Active session</div>
<form method="post" action="/settings/users/{{../user.id}}/sessions/{{id}}/revoke">
<button type="submit" class="btn btn-sm btn-outline-danger">Sign out</button>
</form>
</div>
<div class="text-body-secondary small mt-2">{{ipAddress}} · {{userAgent}}</div>
<div class="text-body-secondary small">Created {{createdAtLabel}} · Last used {{lastUsedAtLabel}} · Expires {{expiresAtLabel}}</div>
</div>
{{/each}}
</div>
{{else}}
<p class="mb-0 text-body-secondary">No active sessions were found.</p>
{{/if}}
</div>
</div>
{{/if}}
+14 -6
View File
@@ -14,7 +14,7 @@
<input type="search" class="form-control" placeholder="Search users" aria-label="Search users" data-table-search />
</div>
{{#if (hasPermission currentUser 'users.create')}}
<a class="btn btn-primary btn-sm" href="/users/new">Add user</a>
<a class="btn btn-primary btn-sm" href="/settings/users/new">Add user</a>
{{/if}}
</div>
</div>
@@ -25,6 +25,7 @@
<th data-table-sort-key="user">User</th>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="roles">Roles</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
@@ -66,18 +67,25 @@
</div>
</div>
</td>
<td data-label="Status">
{{#if account_locked}}
<span class="badge text-bg-danger">Locked</span>
{{else}}
<span class="badge text-bg-success">Active</span>
{{/if}}
</td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'users.create' 'users.update' 'users.delete')}}
<div class="actions users-row-actions">
{{#unless isCurrentUser}}
{{#if (hasPermission ../currentUser 'users.update')}}
<a class="btn btn-sm btn-primary" href="/users/{{id}}/edit">Edit</a>
<a class="btn btn-sm btn-primary" href="/settings/users/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'users.create')}}
<a class="btn btn-sm btn-secondary" href="/users/{{id}}/duplicate">Dupe</a>
<a class="btn btn-sm btn-secondary" href="/settings/users/{{id}}/duplicate">Dupe</a>
{{/if}}
{{#if (hasPermission ../currentUser 'users.delete')}}
<form method="post" action="/users/{{id}}/delete" class="inline-form" data-confirm-message="Delete {{username}}?">
<form method="post" action="/settings/users/{{id}}/delete" class="inline-form" data-confirm-message="Delete {{username}}?">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
{{/if}}
@@ -92,10 +100,10 @@
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default><td colspan="4" class="empty">No users found.</td></tr>
<tr data-table-search-empty-default><td colspan="5" class="empty">No users found.</td></tr>
{{/if}}
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/users" alwaysShow=true}}
{{> table-pagination pagination=pagination basePath="/settings/users" alwaysShow=true}}
</div>
+28 -12
View File
@@ -267,12 +267,20 @@
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'fonts.read')}}
{{#if (anyPermission currentUser 'system-settings.read' 'audit-log.read' 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'fonts.read')}}
<li class="nav-header">SETTINGS</li>
{{/if}}
{{#if (hasPermission currentUser 'fonts.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'fonts')}}active{{/if}}" href="/settings/fonts">
<i class="nav-icon bi bi-type"></i>
<p>Fonts</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'users.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'users')}}active{{/if}}" href="/users">
<a class="nav-link {{#if (eq active 'users')}}active{{/if}}" href="/settings/users">
<i class="nav-icon bi bi-people"></i>
<p>Users</p>
</a>
@@ -280,20 +288,12 @@
{{/if}}
{{#if (hasPermission currentUser 'rbac.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'rbac')}}active{{/if}}" href="/rbac">
<a class="nav-link {{#if (eq active 'rbac')}}active{{/if}}" href="/settings/roles">
<i class="nav-icon bi bi-shield-lock"></i>
<p>Roles and permissions</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'fonts.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'fonts')}}active{{/if}}" href="/settings/fonts">
<i class="nav-icon bi bi-type"></i>
<p>Fonts</p>
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read')}}
<li class="nav-item has-treeview{{#if backgroundTasksMenuOpen}} menu-open{{/if}}">
<a class="nav-link{{#if backgroundTasksMenuOpen}} active{{/if}}" href="#">
@@ -323,12 +323,28 @@
</ul>
</li>
{{/if}}
{{#if (hasPermission currentUser 'audit-log.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'audit-log')}}active{{/if}}" href="/settings/audit-log">
<i class="nav-icon bi bi-journal-text"></i>
<p>Audit log</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'system-settings.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'settings')}}active{{/if}}" href="/settings/system">
<i class="nav-icon bi bi-sliders"></i>
<p>System Settings</p>
</a>
</li>
{{/if}}
</ul>
</nav>
<div class="sidebar-version mt-auto pt-2 text-secondary small">
<div class="sidebar-version__inner border-top pt-2 d-flex align-items-center justify-content-between gap-3">
<span>v{{appVersion}}</span>
<a href="/settings/about" class="text-secondary text-decoration-none text-nowrap">Pulse Signage</a>
<a href="/settings/about" class="text-secondary text-decoration-none text-nowrap">About Pulse</a>
</div>
</div>
</div>
+19 -9
View File
@@ -51,33 +51,42 @@
</div>
<div class="col-12 col-lg-6">
<div class="form-label">Icon</div>
<div class="announcement-icon-picker" data-announcement-icon-picker-shell>
<div class="announcement-icon-picker" data-icon-picker data-announcement-icon-picker-shell>
<select id="announcement-icon" name="icon_key" class="d-none" tabindex="-1" aria-hidden="true">
{{#each announcementIcons}}
{{#each announcementIconCatalog}}
<option value="{{value}}" {{#if (eq ../announcement.icon_key value)}}selected{{/if}}>{{label}}</option>
{{/each}}
</select>
<button type="button" class="announcement-icon-picker__toggle" data-announcement-icon-picker-toggle aria-expanded="false" aria-controls="announcement-icon-picker-menu" title="Select an icon">
<span class="announcement-icon-picker__toggle-icon" data-announcement-icon-picker-icon aria-hidden="true"></span>
<button type="button" class="announcement-icon-picker__toggle" data-icon-picker-toggle data-announcement-icon-picker-toggle aria-expanded="false" aria-controls="announcement-icon-picker-menu" title="Select an icon">
<span class="announcement-icon-picker__toggle-icon" data-icon-picker-preview data-announcement-icon-picker-icon aria-hidden="true"></span>
<span class="announcement-icon-picker__toggle-content">
<span class="announcement-icon-picker__toggle-label" data-announcement-icon-picker-label>Select an icon</span>
<span class="announcement-icon-picker__toggle-label" data-icon-picker-label data-announcement-icon-picker-label>Select an icon</span>
<span class="announcement-icon-picker__toggle-hint">Click to choose</span>
</span>
<i class="bi bi-chevron-down announcement-icon-picker__caret" aria-hidden="true"></i>
</button>
<div id="announcement-icon-picker-menu" class="announcement-icon-picker__menu" data-announcement-icon-picker hidden>
<div id="announcement-icon-picker-menu" class="announcement-icon-picker__menu" data-icon-picker-menu data-announcement-icon-picker hidden>
<div class="announcement-icon-picker__header">
<span>Choose an icon</span>
<button type="button" class="btn btn-sm btn-link text-decoration-none p-0" data-announcement-icon-picker-close>Close</button>
<button type="button" class="btn btn-sm btn-link text-decoration-none p-0" data-icon-picker-close data-announcement-icon-picker-close>Close</button>
</div>
<div class="announcement-icon-picker__grid">
<div class="input-group input-group-sm announcement-icon-picker__search">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search all Bootstrap icons" aria-label="Search all Bootstrap icons" data-icon-picker-search data-announcement-icon-search />
</div>
<div class="announcement-icon-picker__section-header">
<span data-announcement-icon-grid-title>Suggested icons</span>
<span class="announcement-icon-picker__section-count" data-announcement-icon-search-count></span>
</div>
<div class="announcement-icon-picker__grid" data-icon-picker-grid data-announcement-icon-grid>
{{#each announcementIcons}}
<button type="button" class="announcement-icon-picker__option" data-announcement-icon-option data-icon-key="{{value}}" aria-pressed="false" title="{{label}}">
<button type="button" class="announcement-icon-picker__option" data-icon-picker-option data-announcement-icon-option data-icon-key="{{value}}" data-icon-label="{{label}}" data-icon-search-terms="{{value}} {{label}}" aria-pressed="false" title="{{label}}">
<i class="bi bi-{{value}}" aria-hidden="true"></i>
<span class="visually-hidden">{{label}}</span>
</button>
{{/each}}
</div>
<div class="announcement-icon-picker__search-empty" data-icon-picker-empty data-announcement-icon-search-empty hidden>No matching icons.</div>
</div>
</div>
</div>
@@ -165,6 +174,7 @@
</div>
</div>
</div>
<script src="/assets/js/icon-picker.js?v={{appVersion}}"></script>
<script type="module" src="/assets/js/announcements/announcement-form.js?v={{appVersion}}"></script>
</form>
+1 -1
View File
@@ -177,7 +177,7 @@
<span>Download for Windows</span>
</a>
<a class="btn btn-outline-secondary btn-lg flex-fill disabled dashboard-kiosk-launcher-download" data-kiosk-launcher-download data-kiosk-launcher-download-base="/downloads/kiosk/pulse-signage-kiosk.sh" aria-disabled="true" tabindex="-1">
<i class="bi bi-terminal me-2" aria-hidden="true"></i>
<i class="bi bi-tux me-2" aria-hidden="true"></i>
<span>Download for Linux</span>
</a>
</div>
+1 -1
View File
@@ -67,7 +67,7 @@
<div class="card-body p-0 playlist-items-table-responsive">
<table class="table table-striped w-100 mb-0">
<thead><tr><th>Order</th><th>Slide</th><th>Schedule</th><th>Playback</th><th>Actions</th></tr></thead>
<tbody id="playlist-items-body" data-playlist-id="{{playlist.id}}" data-playlist-canvas-id="{{playlistCanvasId}}">
<tbody id="playlist-items-body" data-playlist-id="{{playlist.id}}" data-playlist-canvas-id="{{playlistCanvasId}}" data-default-slide-duration="{{defaultSlideDurationSeconds}}">
{{#if playlistSlides.length}}
{{#each playlistSlides}}
{{> signage/playlists/slide-row slide=this playlistScheduleConfigUrl=../playlistScheduleConfigUrl}}