Release v2.11.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m47s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 31s

This commit is contained in:
2026-09-04 15:43:55 +01:00
parent 2c150b5b2e
commit 98f969ca0f
104 changed files with 3559 additions and 447 deletions
+113
View File
@@ -0,0 +1,113 @@
<div class="page-header">
<div>
<h2>My account</h2>
<p>Update your username, email, display name, and password.</p>
</div>
</div>
<div class="row g-4">
<div class="col-12 col-xl-6">
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">Account details</h3>
</div>
<form id="account-profile-form" method="post" action="/account/profile" autocomplete="off" data-async-save data-async-save-refresh-page="true">
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<label for="account-username" class="form-label">Username</label>
<input id="account-username" type="text" name="username" class="form-control" value="{{username}}" autocomplete="username" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" maxlength="64" required />
</div>
<div class="col-12 col-md-6">
<label for="account-name" class="form-label">Display name</label>
<input id="account-name" type="text" name="name" class="form-control" value="{{name}}" autocomplete="name" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" required />
</div>
<div class="col-12">
<label for="account-email" class="form-label">Email address</label>
<input id="account-email" type="email" name="email" class="form-control" value="{{email}}" autocomplete="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" />
<div class="form-text">{{#if emailPending}}Pending verification for {{pendingEmail}}. Enter a different address here to replace it, or resend the verification email below.{{else}}{{#if emailVerified}}Verified{{else}}Not verified{{/if}}. Email is optional; changing the address requires confirmation by email.{{/if}}</div>
</div>
</div>
<input type="hidden" name="current_password" data-account-profile-current-password />
</div>
</form>
{{#if emailPending}}<form method="post" action="/account/email" class="px-3 pb-3"><input type="hidden" name="email" value="{{pendingEmail}}"><button type="submit" class="btn btn-sm btn-outline-primary">Send verification email</button></form>{{/if}}
</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">
<div class="card-header">
<h3 class="card-title">Password</h3>
</div>
<form id="account-password-form" method="post" action="/account/password" autocomplete="off" data-async-save data-async-save-refresh-page="true">
<div class="card-body">
<input type="hidden" name="current_password" data-account-password-current-password />
<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="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" 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="off" data-bwignore="true" data-lpignore="true" data-1p-ignore="true" minlength="{{passwordMinimumLength}}" required />
</div>
<div class="form-text mt-2">{{passwordRequirementsText}}</div>
</div>
</form>
</div>
<div class="card card-outline card-secondary admin-form-card mt-3">
<div class="card-header">
<h3 class="card-title">Confirmation</h3>
</div>
<div class="card-body">
<label for="account-current-password" class="form-label">Current password</label>
<input id="account-current-password" type="password" class="form-control" autocomplete="current-password" data-account-current-password required />
<div class="form-text">Required to save account details or change your password.</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Account change actions">
<button type="submit" class="btn btn-success" form="account-profile-form" name="save_action" value="save">Save account details</button>
<button type="submit" class="btn btn-warning" form="account-password-form" name="save_action" value="save">Change password</button>
<a class="btn btn-secondary" href="{{returnUrl}}" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
</div>
</div>
</div>
</div>
</div>
-95
View File
@@ -1,95 +0,0 @@
<div class="page-header">
<div>
<h2>My account</h2>
<p>Update your display name and password.</p>
</div>
</div>
<div class="row g-4">
<div class="col-12 col-xl-6">
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">Display name</h3>
</div>
<form id="account-name-form" method="post" action="/account/name" data-async-save>
<div class="card-body">
<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">
<button type="submit" class="btn btn-success" form="account-name-form" name="save_action" value="save">Save</button>
<a class="btn btn-warning" href="/dashboard" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
</div>
</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">
<div class="card-header">
<h3 class="card-title">Password</h3>
</div>
<form id="account-password-form" method="post" action="/account/password" data-async-save data-async-save-refresh-page="true">
<div class="card-body">
<div class="mb-3">
<label for="account-current-password" class="form-label">Current password</label>
<input id="account-current-password" type="password" name="current_password" class="form-control" autocomplete="current-password" required />
</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="{{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="{{passwordMinimumLength}}" required />
</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">
<button type="submit" class="btn btn-success" form="account-password-form" name="save_action" value="save">Save</button>
<a class="btn btn-warning" href="{{returnUrl}}" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
</div>
</div>
</form>
</div>
</div>
</div>
+27
View File
@@ -0,0 +1,27 @@
<div class="login-logo text-center mb-3"><strong>Pulse</strong> Signage</div>
{{#if isValidInvitation}}
<div class="card mx-auto" style="max-width: 30rem;"><div class="card-body p-4">
{{#if message}}<div class="alert alert-warning">{{message}}</div>{{/if}}
<form method="post" action="/accept-invite">
<input type="hidden" name="token" value="{{token}}">
<div class="mb-3"><label for="invite-email" class="form-label">Email address</label><input id="invite-email" type="email" class="form-control" value="{{email}}" disabled></div>
<div class="mb-3"><label for="invite-username" class="form-label">Username</label><input id="invite-username" name="username" type="text" class="form-control" autocomplete="username" maxlength="255" required></div>
<div class="mb-3"><label for="invite-name" class="form-label">Display name</label><input id="invite-name" name="name" type="text" class="form-control" value="{{name}}" autocomplete="name" maxlength="255" required></div>
<div class="mb-3"><label for="invite-password" class="form-label">Password</label><input id="invite-password" name="password" type="password" class="form-control" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required></div>
<div class="mb-3"><label for="invite-confirm-password" class="form-label">Confirm password</label><input id="invite-confirm-password" name="confirm_password" type="password" class="form-control" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required></div>
<div class="form-text mb-3">{{passwordRequirementsText}}</div>
<button type="submit" class="btn btn-primary w-100">Create account</button>
</form>
</div></div>
{{else}}
<div class="card login-card email-verification-error-card mx-auto">
<div class="email-verification-error-card__header">
<div class="email-verification-error-card__icon"><i class="bi bi-exclamation-lg" aria-hidden="true"></i></div>
<h1 class="h4 mb-0">Invitation link unavailable</h1>
</div>
<div class="card-body login-card-body text-center">
<p class="mb-4">{{message}}</p>
<a href="/login" class="btn btn-primary w-100">Return to sign in</a>
</div>
</div>
{{/if}}
@@ -0,0 +1,11 @@
<div class="login-logo text-center mb-3"><strong>Pulse</strong> Signage</div>
<div class="card login-card email-verification-error-card mx-auto">
<div class="email-verification-error-card__header">
<div class="email-verification-error-card__icon"><i class="bi bi-exclamation-lg" aria-hidden="true"></i></div>
<h1 class="h4 mb-0">Verification link unavailable</h1>
</div>
<div class="card-body login-card-body text-center">
<p class="mb-4">{{message}}</p>
<a href="/login" class="btn btn-primary w-100">Return to sign in</a>
</div>
</div>
+11
View File
@@ -0,0 +1,11 @@
<div class="login-logo text-center mb-3"><strong>Pulse</strong> Signage</div>
<div class="card login-card email-verified-card mx-auto">
<div class="email-verified-card__header">
<div class="email-verified-card__icon"><i class="bi bi-check-lg" aria-hidden="true"></i></div>
<h1 class="h4 mb-0">Email verified</h1>
</div>
<div class="card-body login-card-body text-center">
<p class="mb-4">Your email address has been verified successfully.</p>
<a href="/login" class="btn btn-primary w-100">Continue to sign in</a>
</div>
</div>
+11
View File
@@ -0,0 +1,11 @@
<div class="login-logo text-center mb-3"><strong>Pulse</strong> Signage</div>
<div class="card mx-auto" style="max-width: 26rem;"><div class="card-body p-4">
<h1 class="h4 mb-3">Reset password</h1>
{{#if message}}<div class="alert alert-warning">{{message}}</div>{{/if}}
<form method="post" action="/forgot-password">
<label for="reset-identity" class="form-label">Username or email</label>
<input id="reset-identity" name="identity" type="text" class="form-control mb-3" autocomplete="username" required>
<button type="submit" class="btn btn-primary w-100">Email reset link</button>
</form>
<a class="d-block text-center mt-3" href="/login">Back to sign in</a>
</div></div>
+27 -12
View File
@@ -1,29 +1,44 @@
<div class="login-logo text-center mb-3">
<strong>Pulse</strong> Signage
</div>
<div class="card mx-auto" style="max-width: 26rem;">
<div class="card-body p-4">
<h1 class="h4 mb-3">Sign in</h1>
<div class="card login-card mx-auto">
<div class="card-body login-card-body">
<p class="login-box-msg">Sign in to start your session</p>
{{#if message}}
<div class="alert alert-warning">{{message}}</div>
<div class="alert alert-warning py-2">{{message}}</div>
{{/if}}
<form method="post" action="/login">
{{#if returnTo}}
<input type="hidden" name="returnTo" value="{{returnTo}}" />
{{/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" value="{{username}}" required />
<label for="username" class="visually-hidden">Username or email</label>
<div class="input-group">
<input id="username" name="username" type="text" class="form-control" placeholder="Username or email" autocomplete="username" value="{{username}}" required />
<span class="input-group-text" aria-hidden="true"><i class="bi bi-person"></i></span>
</div>
</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 />
<label for="password" class="visually-hidden">Password</label>
<div class="input-group">
<input id="password" name="password" type="password" class="form-control" placeholder="Password" autocomplete="current-password" required />
<span class="input-group-text" aria-hidden="true"><i class="bi bi-lock"></i></span>
</div>
</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 class="row align-items-center g-3 mb-3">
<div class="col-7">
<div class="form-check">
<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>
</div>
<div class="col-5">
<button type="submit" class="btn btn-primary w-100">Sign in</button>
</div>
</div>
<button type="submit" class="btn btn-primary w-100">Sign in</button>
</form>
{{#if showForgotPassword}}
<p class="mb-0 text-center"><a href="/forgot-password">I forgot my password</a></p>
{{/if}}
</div>
</div>
+14
View File
@@ -0,0 +1,14 @@
<div class="login-logo text-center mb-3"><strong>Pulse</strong> Signage</div>
<div class="card mx-auto" style="max-width: 26rem;"><div class="card-body p-4">
<h1 class="h4 mb-3">Choose a new password</h1>
{{#if message}}<div class="alert alert-warning">{{message}}</div>{{/if}}
<form method="post" action="/reset-password">
<input type="hidden" name="token" value="{{token}}">
<label for="reset-password" class="form-label">New password</label>
<input id="reset-password" name="password" type="password" class="form-control mb-3" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required>
<label for="reset-confirm-password" class="form-label">Confirm password</label>
<input id="reset-confirm-password" name="confirm_password" type="password" class="form-control mb-3" autocomplete="new-password" minlength="{{passwordMinimumLength}}" required>
<div class="form-text mb-3">{{passwordRequirementsText}}</div>
<button type="submit" class="btn btn-primary w-100">Update password</button>
</form>
</div></div>
+195 -163
View File
@@ -5,190 +5,222 @@
</div>
</div>
<form id="api-source-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">API source details</h3>
</div>
<div class="card-body">
<div class="row g-3 mb-4">
<div class="col-12 col-lg-5">
<label for="api-source-name" class="form-label">Name</label>
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" maxlength="128" data-limit-text-length required />
</div>
<div class="col-12 col-lg-5">
<label for="api-source-url" class="form-label">API URL</label>
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" maxlength="1024" data-limit-text-length placeholder="https://example.com/api.json" required />
</div>
<div class="col-12 col-lg-2">
<label for="api-source-request-method" class="form-label">Request method</label>
<select id="api-source-request-method" name="request_method" class="form-select" data-api-source-request-method>
<option value="GET" {{#if (eq apiSource.requestMethod 'GET')}}selected{{/if}}>GET</option>
<option value="POST" {{#if (eq apiSource.requestMethod 'POST')}}selected{{/if}}>POST</option>
</select>
</div>
{{#if isEdit}}<form id="api-source-toggle-form" method="post" action="/data-sources/api-sources/{{apiSource.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
<div class="row g-3 align-items-start api-source-layout">
<div class="col-12">
<form id="api-source-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">Connection</h3>
</div>
<div class="mb-4" data-api-source-request-section>
<h4 class="api-source-section-heading">Request</h4>
<div class="row g-3 align-items-end">
<div class="col-12">
<div class="form-text mb-2">POST requests use JSON with the existing authentication settings.</div>
<textarea id="api-source-request-body" name="request_body_json" class="form-control font-monospace" rows="5" data-api-source-request-body placeholder="{&#10; &quot;ids&quot;: [1, 2, 3]&#10;}" spellcheck="false">{{apiSource.requestBodyJson}}</textarea>
<div class="card-body">
<div class="row g-3 g-md-3">
<div class="col-12 col-lg-5">
<label for="api-source-name" class="form-label">Name</label>
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" maxlength="128" data-limit-text-length required />
</div>
</div>
</div>
<div class="mb-4">
<h4 class="api-source-section-heading">Access and parsing</h4>
<div class="row g-3 align-items-end">
<div class="col-12 col-md-4">
<label for="api-source-auth-method" class="form-label">Auth method</label>
<select id="api-source-auth-method" name="auth_method" class="form-select" data-api-source-auth-method>
<option value="none" {{#if (eq apiSource.authMethod 'none')}}selected{{/if}}>None</option>
<option value="basic" {{#if (eq apiSource.authMethod 'basic')}}selected{{/if}}>Basic</option>
<option value="bearer" {{#if (eq apiSource.authMethod 'bearer')}}selected{{/if}}>Bearer token</option>
<option value="api_key_header" {{#if (eq apiSource.authMethod 'api_key_header')}}selected{{/if}}>API key header</option>
<option value="token_login" {{#if (eq apiSource.authMethod 'token_login')}}selected{{/if}}>Login then token</option>
<div class="col-12 col-lg-5">
<label for="api-source-url" class="form-label">API URL</label>
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" maxlength="1024" data-limit-text-length placeholder="https://example.com/api.json" required />
</div>
<div class="col-12 col-lg-2">
<label for="api-source-request-method" class="form-label">Request method</label>
<select id="api-source-request-method" name="request_method" class="form-select" data-api-source-request-method>
<option value="GET" {{#if (eq apiSource.requestMethod 'GET')}}selected{{/if}}>GET</option>
<option value="POST" {{#if (eq apiSource.requestMethod 'POST')}}selected{{/if}}>POST</option>
</select>
</div>
<div class="col-12 col-md-8">
<label for="api-source-items-path" class="form-label">Items path</label>
<input id="api-source-items-path" name="items_path" class="form-control" value="{{apiSource.itemsPath}}" maxlength="255" data-limit-text-length placeholder="items, results.data, or another array path" />
</div>
</div>
<div class="row mt-2">
<div class="col-12 col-md-8 offset-md-4">
<div class="form-text">Optional. Use dot notation to change the default base for placeholders.</div>
</div>
</div>
</div>
<div class="mb-4" data-api-source-auth-details-section>
<h4 class="api-source-section-heading">Authentication details</h4>
<div class="row g-3 mb-3" data-api-source-auth-panel="basic" hidden>
<div class="col-12 col-md-6">
<label for="api-source-auth-username" class="form-label">Username</label>
<input id="api-source-auth-username" name="auth_username" class="form-control" value="{{apiSource.authUsername}}" maxlength="255" data-limit-text-length autocomplete="username" />
</div>
<div class="col-12 col-md-6">
<label for="api-source-auth-password" class="form-label">Password</label>
<input id="api-source-auth-password" name="auth_password" type="password" class="form-control" value="{{apiSource.authPassword}}" maxlength="255" data-limit-text-length autocomplete="current-password" />
</div>
</div>
<div class="row g-3 mb-3" data-api-source-auth-panel="bearer" hidden>
<div class="col-12">
<label for="api-source-auth-bearer-token" class="form-label">Bearer token</label>
<div class="input-group">
<input id="api-source-auth-bearer-token" name="auth_bearer_token" type="password" class="form-control" value="{{apiSource.authBearerToken}}" maxlength="255" data-limit-text-length autocomplete="off" spellcheck="false" autocapitalize="off" autocorrect="off" data-api-source-bearer-token-input />
<button type="button" class="btn btn-outline-secondary" aria-label="Show bearer token" aria-pressed="false" data-api-source-bearer-token-toggle>
<i class="bi bi-eye" aria-hidden="true"></i>
</button>
<div class="mb-2" data-api-source-request-section>
<h4 class="api-source-section-heading">Request</h4>
<div class="row g-3 align-items-end">
<div class="col-12">
<div class="form-text mb-2">POST requests use JSON with the existing authentication settings.</div>
<textarea id="api-source-request-body" name="request_body_json" class="form-control font-monospace" rows="5" data-api-source-request-body placeholder="{&#10; &quot;ids&quot;: [1, 2, 3]&#10;}" spellcheck="false">{{apiSource.requestBodyJson}}</textarea>
</div>
</div>
</div>
<div class="row g-3 mb-3" data-api-source-auth-panel="api_key_header" hidden>
<div class="col-12 col-md-5">
<label for="api-source-auth-header-name" class="form-label">Header name</label>
<input id="api-source-auth-header-name" name="auth_header_name" class="form-control" value="{{apiSource.authHeaderName}}" maxlength="255" data-limit-text-length placeholder="X-API-Key" />
<div class="mt-3 mb-2">
<h4 class="api-source-section-heading">Access and parsing</h4>
<div class="row g-3 align-items-end">
<div class="col-12 col-md-4">
<label for="api-source-auth-method" class="form-label">Auth method</label>
<select id="api-source-auth-method" name="auth_method" class="form-select" data-api-source-auth-method>
<option value="none" {{#if (eq apiSource.authMethod 'none')}}selected{{/if}}>None</option>
<option value="basic" {{#if (eq apiSource.authMethod 'basic')}}selected{{/if}}>Basic</option>
<option value="bearer" {{#if (eq apiSource.authMethod 'bearer')}}selected{{/if}}>Bearer token</option>
<option value="api_key_header" {{#if (eq apiSource.authMethod 'api_key_header')}}selected{{/if}}>API key header</option>
<option value="token_login" {{#if (eq apiSource.authMethod 'token_login')}}selected{{/if}}>Login then token</option>
</select>
</div>
<div class="col-12 col-md-8">
<label for="api-source-items-path" class="form-label">Items path</label>
<input id="api-source-items-path" name="items_path" class="form-control" value="{{apiSource.itemsPath}}" maxlength="255" data-limit-text-length placeholder="items, results.data, or another array path" />
</div>
</div>
<div class="col-12 col-md-7">
<label for="api-source-auth-header-value" class="form-label">Header value</label>
<input id="api-source-auth-header-value" name="auth_header_value" class="form-control" value="{{apiSource.authHeaderValue}}" maxlength="255" data-limit-text-length />
<div class="row">
<div class="col-12 col-md-8 offset-md-4">
<div class="form-text">Optional. Use dot notation to change the default base for placeholders.</div>
</div>
</div>
</div>
<div class="row g-3 mb-3" data-api-source-auth-panel="token_login" hidden>
<div class="col-12 col-md-8">
<label for="api-source-token-url" class="form-label">Login / token URL</label>
<input id="api-source-token-url" name="token_url" type="url" class="form-control" value="{{apiSource.tokenUrl}}" maxlength="1024" placeholder="https://example.com/login" />
<div class="form-text">The login request is sent as POST with the JSON body below.</div>
</div>
<div class="col-12 col-md-4">
<label for="api-source-token-response-path" class="form-label">Token response path</label>
<input id="api-source-token-response-path" name="token_response_path" class="form-control" value="{{apiSource.tokenResponsePath}}" placeholder="access_token" />
<div class="form-text">Example: <code>data.token</code></div>
</div>
<div class="col-12">
<label for="api-source-token-body" class="form-label">Login request body</label>
<textarea id="api-source-token-body" name="token_request_body_json" class="form-control font-monospace" rows="4" placeholder="{&#10; &quot;username&quot;: &quot;...&quot;,&#10; &quot;password&quot;: &quot;...&quot;&#10;}" spellcheck="false">{{apiSource.tokenRequestBodyJson}}</textarea>
</div>
<div class="col-12 col-md-6">
<label for="api-source-token-header-name" class="form-label">API token header</label>
<input id="api-source-token-header-name" name="token_header_name" class="form-control" value="{{apiSource.tokenHeaderName}}" placeholder="Authorization" />
</div>
<div class="col-12 col-md-6">
<label for="api-source-token-header-prefix" class="form-label">Token prefix</label>
<input id="api-source-token-header-prefix" name="token_header_prefix" class="form-control" value="{{apiSource.tokenHeaderPrefix}}" placeholder="Bearer" />
<div>
<h4 class="api-source-section-heading">Refresh</h4>
<div class="row g-3">
<div class="col-12 col-md-4">
<label for="api-source-interval" class="form-label">Update interval</label>
<input id="api-source-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{apiSource.updateIntervalValue}}" required />
</div>
<div class="col-12 col-md-4">
<label for="api-source-interval-unit" class="form-label">Unit</label>
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
<option value="hours" {{#if (eq apiSource.updateIntervalUnit 'hours')}}selected{{/if}}>Hours</option>
</select>
</div>
</div>
</div>
</div>
<div>
<h4 class="api-source-section-heading">Refresh</h4>
<div class="row g-3">
<div class="col-12 col-md-4">
<label for="api-source-interval" class="form-label">Update interval</label>
<input id="api-source-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{apiSource.updateIntervalValue}}" required />
<div class="card-footer d-flex justify-content-end align-items-center">
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "api-sources.update")}}<button type="submit" form="api-source-toggle-form" data-async-data-source-toggle data-enabled="{{#if apiSource.enabled}}true{{else}}false{{/if}}" class="btn {{#if apiSource.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if apiSource.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if apiSource.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "api-sources.allow")}}<button type="button" data-manual-refresh-url="/data-sources/api-sources/{{apiSource.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
</div>
</div>
<div class="card card-outline card-info admin-form-card mt-3 overflow-hidden api-source-auth-card{{#if isEdit}} collapsed-card{{/if}}" data-api-source-auth-card hidden>
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Authentication</h3>
<div class="card-tools d-flex align-items-center ms-auto me-0 flex-shrink-0">
<button type="button" class="btn btn-tool p-0 text-body-secondary" data-lte-toggle="card-collapse" aria-label="Toggle authentication settings" title="Toggle authentication settings">
<i class="bi bi-plus-lg" aria-hidden="true" data-lte-icon="expand"></i>
<i class="bi bi-dash-lg" aria-hidden="true" data-lte-icon="collapse"></i>
</button>
</div>
<div class="col-12 col-md-4">
<label for="api-source-interval-unit" class="form-label">Unit</label>
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
<option value="hours" {{#if (eq apiSource.updateIntervalUnit 'hours')}}selected{{/if}}>Hours</option>
</select>
</div>
<div class="card-body">
<div class="mb-4" data-api-source-auth-details-section>
<h4 class="api-source-section-heading">Authentication details</h4>
<div class="row g-3 mb-3" data-api-source-auth-panel="basic" hidden>
<div class="col-12 col-md-6">
<label for="api-source-auth-username" class="form-label">Username</label>
<input id="api-source-auth-username" name="auth_username" class="form-control" value="{{apiSource.authUsername}}" maxlength="255" data-limit-text-length autocomplete="username" />
</div>
<div class="col-12 col-md-6">
<label for="api-source-auth-password" class="form-label">Password</label>
<input id="api-source-auth-password" name="auth_password" type="password" class="form-control" value="{{apiSource.authPassword}}" maxlength="255" data-limit-text-length autocomplete="current-password" />
</div>
</div>
<div class="row g-3 mb-3" data-api-source-auth-panel="bearer" hidden>
<div class="col-12">
<label for="api-source-auth-bearer-token" class="form-label">Bearer token</label>
<div class="input-group">
<input id="api-source-auth-bearer-token" name="auth_bearer_token" type="password" class="form-control" value="{{apiSource.authBearerToken}}" maxlength="255" data-limit-text-length autocomplete="off" spellcheck="false" autocapitalize="off" autocorrect="off" data-api-source-bearer-token-input />
<button type="button" class="btn btn-outline-secondary" aria-label="Show bearer token" aria-pressed="false" data-api-source-bearer-token-toggle>
<i class="bi bi-eye" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div class="row g-3 mb-3" data-api-source-auth-panel="api_key_header" hidden>
<div class="col-12 col-md-5">
<label for="api-source-auth-header-name" class="form-label">Header name</label>
<input id="api-source-auth-header-name" name="auth_header_name" class="form-control" value="{{apiSource.authHeaderName}}" maxlength="255" data-limit-text-length placeholder="X-API-Key" />
</div>
<div class="col-12 col-md-7">
<label for="api-source-auth-header-value" class="form-label">Header value</label>
<input id="api-source-auth-header-value" name="auth_header_value" class="form-control" value="{{apiSource.authHeaderValue}}" maxlength="255" data-limit-text-length />
</div>
</div>
<div class="row g-3 mb-3" data-api-source-auth-panel="token_login" hidden>
<div class="col-12 col-md-8">
<label for="api-source-token-url" class="form-label">Login / token URL</label>
<input id="api-source-token-url" name="token_url" type="url" class="form-control" value="{{apiSource.tokenUrl}}" maxlength="1024" placeholder="https://example.com/login" />
<div class="form-text">The login request is sent as POST with the JSON body below.</div>
</div>
<div class="col-12 col-md-4">
<label for="api-source-token-response-path" class="form-label">Token response path</label>
<input id="api-source-token-response-path" name="token_response_path" class="form-control" value="{{apiSource.tokenResponsePath}}" placeholder="access_token" />
<div class="form-text">Example: <code>data.token</code></div>
</div>
<div class="col-12">
<label for="api-source-token-body" class="form-label">Login request body</label>
<textarea id="api-source-token-body" name="token_request_body_json" class="form-control font-monospace" rows="4" placeholder="{&#10; &quot;username&quot;: &quot;...&quot;,&#10; &quot;password&quot;: &quot;...&quot;&#10;}" spellcheck="false">{{apiSource.tokenRequestBodyJson}}</textarea>
</div>
<div class="col-12 col-md-8">
<label for="api-source-token-refresh-url" class="form-label">Refresh URL</label>
<input id="api-source-token-refresh-url" name="token_refresh_url" type="url" class="form-control" value="{{apiSource.tokenRefreshUrl}}" maxlength="1024" placeholder="Optional; defaults to the login URL" />
</div>
<div class="col-12 col-md-4">
<label for="api-source-token-refresh-response-path" class="form-label">Refresh token response path</label>
<input id="api-source-token-refresh-response-path" name="token_refresh_response_path" class="form-control" value="{{apiSource.tokenRefreshResponsePath}}" maxlength="255" placeholder="refresh_token" />
</div>
<div class="col-12">
<label for="api-source-token-refresh-body" class="form-label">Refresh request body</label>
<textarea id="api-source-token-refresh-body" name="token_refresh_request_body_json" class="form-control font-monospace" rows="3" placeholder="{&#10; &quot;grant_type&quot;: &quot;refresh_token&quot;,&#10; &quot;refresh_token&quot;: &quot;&#123;&#123;refresh_token&#125;&#125;&quot;&#10;}" spellcheck="false">{{apiSource.tokenRefreshRequestBodyJson}}</textarea>
<div class="form-text">Optional. Leave blank for the standard JSON refresh request. Use <code>&#123;&#123;refresh_token&#125;&#125;</code> where the current refresh token belongs.</div>
</div>
<div class="col-12 col-md-6">
<label for="api-source-token-header-name" class="form-label">API token header</label>
<input id="api-source-token-header-name" name="token_header_name" class="form-control" value="{{apiSource.tokenHeaderName}}" placeholder="Authorization" />
</div>
<div class="col-12 col-md-6">
<label for="api-source-token-header-prefix" class="form-label">Token prefix</label>
<input id="api-source-token-header-prefix" name="token_header_prefix" class="form-control" value="{{apiSource.tokenHeaderPrefix}}" placeholder="Bearer" />
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end align-items-center">
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "api-sources.update")}}<button type="submit" form="api-source-toggle-form" data-async-data-source-toggle data-enabled="{{#if apiSource.enabled}}true{{else}}false{{/if}}" class="btn {{#if apiSource.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if apiSource.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if apiSource.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "api-sources.allow")}}<button type="button" data-manual-refresh-url="/data-sources/api-sources/{{apiSource.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
</div>
</form>
</div>
</form>
{{#if isEdit}}<form id="api-source-toggle-form" method="post" action="/data-sources/api-sources/{{apiSource.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
<div class="card card-outline card-secondary mt-3" id="api-source-response-panel">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Latest response</h3>
{{#if lastResponseJson}}
<div class="btn-group btn-group-sm ms-auto" role="group" aria-label="JSON tree actions">
<button type="button" class="btn btn-outline-secondary" data-json-toggle-collapse-all>
<i class="bi bi-arrows-collapse me-1"></i>
Collapse all
</button>
<button type="button" class="btn btn-outline-secondary" data-json-toggle-expand-all>
<i class="bi bi-arrows-expand me-1"></i>
Expand all
</button>
</div>
{{/if}}
</div>
<div class="card-body">
{{#if lastPullError}}
<div class="alert alert-warning">{{lastPullError}}</div>
{{/if}}
<dl class="row">
<dt class="col-12 col-md-3">Last pulled</dt>
<dd class="col-12 col-md-9">
{{#if apiSource.lastPulledAtValue}}
<time data-local-datetime datetime="{{apiSource.lastPulledAtValue}}">{{#if apiSource.lastPulledAtLabel}}{{apiSource.lastPulledAtLabel}}{{else}}{{apiSource.lastPulledAtValue}}{{/if}}</time>
{{else}}
-
<div class="col-12">
<div class="card card-outline card-secondary api-source-response-card" id="api-source-response-panel">
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Latest response</h3>
{{#if lastResponseJson}}
<div class="btn-group btn-group-sm ms-auto" role="group" aria-label="JSON tree actions">
<button type="button" class="btn btn-outline-secondary" data-json-toggle-collapse-all>
<i class="bi bi-arrows-collapse me-1"></i>
Collapse all
</button>
<button type="button" class="btn btn-outline-secondary" data-json-toggle-expand-all>
<i class="bi bi-arrows-expand me-1"></i>
Expand all
</button>
</div>
{{/if}}
</dd>
<dt class="col-12 col-md-3">Status</dt>
<dd class="col-12 col-md-9">{{#if apiSource.lastResponseStatus}}{{apiSource.lastResponseStatus}}{{else}}-{{/if}}</dd>
<dt class="col-12 col-md-3">Content type</dt>
<dd class="col-12 col-md-9">{{#if apiSource.lastResponseContentType}}{{apiSource.lastResponseContentType}}{{else}}-{{/if}}</dd>
</dl>
{{#if lastResponseJson}}
<div data-json-toggle-panel>
<script type="application/json" data-json-toggle-source>{{json lastResponseJson}}</script>
<div class="mb-0 small bg-body-tertiary border rounded p-3 font-monospace" style="white-space: pre-wrap; word-break: break-word;" data-json-toggle-output>{{lastResponseJson}}</div>
</div>
{{else}}
<div class="alert alert-secondary mb-0">No JSON response is stored yet.</div>
{{/if}}
<div class="card-body">
{{#if lastPullError}}
<div class="alert alert-warning">{{lastPullError}}</div>
{{/if}}
<dl class="row">
<dt class="col-12 col-md-3">Last pulled</dt>
<dd class="col-12 col-md-9">
{{#if apiSource.lastPulledAtValue}}
<time data-local-datetime datetime="{{apiSource.lastPulledAtValue}}">{{#if apiSource.lastPulledAtLabel}}{{apiSource.lastPulledAtLabel}}{{else}}{{apiSource.lastPulledAtValue}}{{/if}}</time>
{{else}}
-
{{/if}}
</dd>
<dt class="col-12 col-md-3">Status</dt>
<dd class="col-12 col-md-9">{{#if apiSource.lastResponseStatus}}{{apiSource.lastResponseStatus}}{{else}}-{{/if}}</dd>
<dt class="col-12 col-md-3">Content type</dt>
<dd class="col-12 col-md-9">{{#if apiSource.lastResponseContentType}}{{apiSource.lastResponseContentType}}{{else}}-{{/if}}</dd>
</dl>
{{#if lastResponseJson}}
<div data-json-toggle-panel>
<script type="application/json" data-json-toggle-source>{{json lastResponseJson}}</script>
<div class="mb-0 small bg-body-tertiary border rounded p-3 font-monospace" style="white-space: pre-wrap; word-break: break-word;" data-json-toggle-output>{{lastResponseJson}}</div>
</div>
{{else}}
<div class="alert alert-secondary mb-0">No JSON response is stored yet.</div>
{{/if}}
</div>
</div>
</div>
</div>
@@ -9,12 +9,12 @@
<div class="card-header">
<h3 class="card-title">Saved sources</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search API sources" aria-label="Search API sources" data-table-search />
</div>
{{#if (hasPermission currentUser 'api-sources.create')}}
<a class="btn btn-primary btn-sm" href="/data-sources/api-sources/new">Add API source</a>
<a class="btn btn-primary btn-sm" href="/data-sources/api-sources/new">Add<span class="table-action-context"> API source</span></a>
{{/if}}
</div>
</div>
@@ -9,12 +9,12 @@
<div class="card-header">
<h3 class="card-title">Saved feeds</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search RSS feeds" aria-label="Search RSS feeds" data-table-search />
</div>
{{#if (hasPermission currentUser 'rss-feeds.create')}}
<a class="btn btn-primary btn-sm" href="/data-sources/rss-feeds/new">Add RSS feed</a>
<a class="btn btn-primary btn-sm" href="/data-sources/rss-feeds/new">Add<span class="table-action-context"> RSS feed</span></a>
{{/if}}
</div>
</div>
@@ -9,12 +9,12 @@
<div class="card-header">
<h3 class="card-title">Saved groups</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search timetables" aria-label="Search timetables" data-table-search />
</div>
{{#if (hasPermission currentUser 'timetables.create')}}
<a class="btn btn-primary btn-sm" href="/data-sources/timetables/new">Add timetable group</a>
<a class="btn btn-primary btn-sm" href="/data-sources/timetables/new">Add<span class="table-action-context"> timetable group</span></a>
{{/if}}
</div>
</div>
+3 -3
View File
@@ -9,11 +9,11 @@
<div class="card-header">
<h3 class="card-title">Saved locations</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search weather locations" aria-label="Search weather locations" data-table-search />
</div>
{{#if (hasPermission currentUser 'weather.create')}}<a class="btn btn-primary btn-sm" href="/data-sources/weather/new">Add location</a>{{/if}}
{{#if (hasPermission currentUser 'weather.create')}}<a class="btn btn-primary btn-sm" href="/data-sources/weather/new">Add<span class="table-action-context"> location</span></a>{{/if}}
</div>
</div>
<div class="card-body table-responsive p-0">
+4 -1
View File
@@ -24,7 +24,10 @@
</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 class="input-group input-group-sm table-search-group">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input id="audit-search" name="search" type="search" class="form-control" value="{{search}}" placeholder="Event, actor, or target" data-table-search>
</div>
</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>
+2 -2
View File
@@ -36,8 +36,8 @@
<div class="card-header">
<h3 class="card-title">Managed fonts</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search fonts" aria-label="Search fonts" data-table-search />
</div>
</div>
@@ -0,0 +1,67 @@
<div class="page-header">
<div>
<h2>Pending invitations</h2>
<p>Invitations that have not yet been accepted.</p>
</div>
</div>
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header d-flex justify-content-between align-items-center">
<h3 class="card-title">Active invitations</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search invitations" aria-label="Search invitations" data-table-search />
</div>
{{#if (hasPermission currentUser 'invitations.create')}}
<a href="/settings/users/invite" class="btn btn-info btn-sm">Invite user</a>
{{/if}}
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-striped w-100 mb-0">
<thead>
<tr>
<th data-table-sort-key="email">Email</th>
<th data-table-sort-key="name">Name</th>
<th>Roles</th>
<th data-table-sort-key="created">Sent</th>
<th data-table-sort-key="expires">Expires</th>
<th data-table-sort-key="createdBy">Sent by</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{#if invitations.length}}
{{#each invitations}}
<tr data-table-search-row>
<td data-label="Email">{{email}}</td>
<td data-label="Name">{{#if name}}{{name}}{{else}}-{{/if}}</td>
<td data-label="Roles">{{roleNames}}</td>
<td data-label="Sent">{{createdAtLabel}}</td>
<td data-label="Expires">{{expiresAtLabel}}</td>
<td data-label="Sent by">{{createdByLabel}}</td>
<td data-label="Actions">
<div class="actions users-row-actions">
{{#if (hasPermission ../currentUser 'invitations.allow')}}
<form method="post" action="/settings/invitations/{{id}}/resend" class="inline-form" data-confirm-message="Resend invitation to {{email}}?">
<button type="submit" class="btn btn-sm btn-primary">Resend</button>
</form>
{{/if}}
{{#if (hasPermission ../currentUser 'invitations.delete')}}
<form method="post" action="/settings/invitations/{{id}}/delete" class="inline-form" data-confirm-message="Delete invitation for {{email}}?">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
{{/if}}
</div>
</td>
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default><td colspan="7" class="empty">No pending invitations.</td></tr>
{{/if}}
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/settings/invitations" alwaysShow=true}}
</div>
+2 -2
View File
@@ -35,8 +35,8 @@
<div class="card card-outline card-secondary admin-form-card" data-table-search-container data-table-pagination-card>
<div class="card-header d-flex flex-wrap align-items-center justify-content-between">
<h3 class="card-title">Users</h3>
<div class="input-group input-group-sm flex-shrink-1 ms-auto" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 ms-auto table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search users" aria-label="Search users" data-table-search />
</div>
</div>
+3 -3
View File
@@ -11,12 +11,12 @@
<div class="card-header">
<h3 class="card-title">Existing roles</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<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="/settings/roles/new">Add role</a>
<a class="btn btn-primary btn-sm" href="/settings/roles/new">Add<span class="table-action-context"> role</span></a>
{{/if}}
</div>
</div>
+120 -1
View File
@@ -26,6 +26,12 @@
<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="#email-delivery" data-settings-section-link>
<i class="bi bi-envelope me-2" aria-hidden="true"></i>Email Delivery
</a>
<a class="nav-link" href="#email-templates" data-settings-section-link>
<i class="bi bi-envelope-paper me-2" aria-hidden="true"></i>Email Templates
</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>
@@ -104,7 +110,13 @@
</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 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>
@@ -257,6 +269,104 @@
</div>
</form>
<form method="post" action="/settings/system" data-async-save data-settings-form="email">
<input type="hidden" name="settings_section" value="email">
<div id="email-delivery" class="card settings-section-card" data-settings-section hidden>
<div class="card-header"><h3 class="h5 mb-1 fw-semibold"><i class="bi bi-envelope me-2 text-primary" aria-hidden="true"></i>Email delivery</h3><p class="text-muted small mb-0">Configure SMTP for password recovery and account verification.</p></div>
<div class="card-body"><div class="row g-3">
<div class="col-12"><div class="form-check form-switch"><input id="settings-smtp-enabled" name="smtp_enabled" type="checkbox" class="form-check-input" value="1" {{#if mediaSettings.smtpEnabled}}checked{{/if}}><label class="form-check-label" for="settings-smtp-enabled">Enable SMTP email delivery</label></div></div>
<div class="col-12 col-md-8"><label class="form-label" for="settings-smtp-host">SMTP host</label><input id="settings-smtp-host" name="smtp_host" type="text" class="form-control" value="{{mediaSettings.smtpHost}}"></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-smtp-port">Port</label><input id="settings-smtp-port" name="smtp_port" type="number" class="form-control" min="1" max="65535" value="{{mediaSettings.smtpPort}}"></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-smtp-security">Security</label><select id="settings-smtp-security" name="smtp_security" class="form-select"><option value="none" {{#if (eq mediaSettings.smtpSecurity 'none')}}selected{{/if}}>None</option><option value="starttls" {{#if (eq mediaSettings.smtpSecurity 'starttls')}}selected{{/if}}>STARTTLS</option><option value="tls" {{#if (eq mediaSettings.smtpSecurity 'tls')}}selected{{/if}}>TLS</option></select></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-smtp-username">SMTP username</label><input id="settings-smtp-username" name="smtp_username" type="text" class="form-control" value="{{mediaSettings.smtpUsername}}"></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-smtp-password">SMTP password</label><input id="settings-smtp-password" name="smtp_password" type="password" class="form-control" placeholder="{{#if mediaSettings.smtpConfigured}}Leave unchanged{{/if}}"></div>
<div class="col-12 col-md-6"><label class="form-label" for="settings-from-address">From address</label><input id="settings-from-address" name="from_address" type="email" class="form-control" value="{{mediaSettings.emailFromAddress}}"></div>
<div class="col-12 col-md-6"><label class="form-label" for="settings-from-name">From name</label><input id="settings-from-name" name="from_name" type="text" class="form-control" value="{{mediaSettings.emailFromName}}"></div>
</div></div>
<div class="card-footer d-flex align-items-center">
{{#if (hasPermission currentUser 'system-settings.update')}}
<button type="submit" name="settings_action" value="test_email" class="btn btn-outline-primary">Send test email</button>
<button type="submit" class="btn btn-success ms-auto">Save</button>
{{else}}
<button type="button" class="btn btn-outline-primary" disabled>Send test email</button>
<button type="button" class="btn btn-outline-success ms-auto" disabled>Save</button>
{{/if}}
</div>
</div>
</form>
<form method="post" action="/settings/system" data-async-save data-settings-form="email-templates">
<input type="hidden" name="settings_section" value="email-templates">
<div id="email-templates" class="card settings-section-card" data-settings-section hidden>
<div class="card-header"><h3 class="h5 mb-1 fw-semibold"><i class="bi bi-envelope-paper me-2 text-primary" aria-hidden="true"></i>Email templates</h3><p class="text-muted small mb-0">Customize the messages sent for email verification and password resets.</p></div>
<div class="card-body">
<div data-email-preview-user data-username="{{currentUser.username}}" data-display-name="{{currentUser.name}}" data-email="{{currentUser.email}}" hidden></div>
<div class="email-template-editor" data-email-template-editor>
<div class="row g-4 align-items-start">
<div class="col-12 col-lg-6">
<h4 class="h6">Email verification</h4>
<label class="form-label" for="settings-verification-subject">Subject</label>
<input id="settings-verification-subject" name="verification_subject" type="text" class="form-control mb-2" value="{{mediaSettings.verificationEmailSubject}}" maxlength="255" data-email-template-subject>
<label class="form-label" for="settings-verification-body">Message</label>
<div class="btn-toolbar mb-2" role="toolbar" aria-label="Verification message formatting"><div class="btn-group btn-group-sm" role="group"><button type="button" class="btn btn-outline-secondary" data-email-format="b"><strong>B</strong></button><button type="button" class="btn btn-outline-secondary" data-email-format="i"><em>I</em></button><button type="button" class="btn btn-outline-secondary" data-email-format="u"><u>U</u></button></div></div>
<textarea id="settings-verification-body" name="verification_body" class="form-control" rows="5" data-email-template-body>{{mediaSettings.verificationEmailBody}}</textarea>
<div class="row g-2 mt-1">
<div class="col-12 col-md-8"><label class="form-label" for="settings-verification-button-text">Button text</label><input id="settings-verification-button-text" name="verification_button_text" type="text" class="form-control" value="{{mediaSettings.verificationButtonText}}" maxlength="120" data-email-template-button-text></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-verification-alignment">Alignment</label><select id="settings-verification-alignment" name="verification_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.verificationButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.verificationButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.verificationButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
</div>
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the verification link. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>.</div>
</div>
<div class="col-12 col-lg-6">
<div class="email-template-preview" data-email-template-preview aria-label="Email verification preview"></div>
</div>
</div>
</div>
<hr class="my-4">
<div class="email-template-editor" data-email-template-editor>
<div class="row g-4 align-items-start">
<div class="col-12 col-lg-6">
<h4 class="h6">Password reset</h4>
<label class="form-label" for="settings-reset-subject">Subject</label>
<input id="settings-reset-subject" name="reset_subject" type="text" class="form-control mb-2" value="{{mediaSettings.resetEmailSubject}}" maxlength="255" data-email-template-subject>
<label class="form-label" for="settings-reset-body">Message</label>
<div class="btn-toolbar mb-2" role="toolbar" aria-label="Reset message formatting"><div class="btn-group btn-group-sm" role="group"><button type="button" class="btn btn-outline-secondary" data-email-format="b"><strong>B</strong></button><button type="button" class="btn btn-outline-secondary" data-email-format="i"><em>I</em></button><button type="button" class="btn btn-outline-secondary" data-email-format="u"><u>U</u></button></div></div>
<textarea id="settings-reset-body" name="reset_body" class="form-control" rows="5" data-email-template-body>{{mediaSettings.resetEmailBody}}</textarea>
<div class="row g-2 mt-1">
<div class="col-12 col-md-8"><label class="form-label" for="settings-reset-button-text">Button text</label><input id="settings-reset-button-text" name="reset_button_text" type="text" class="form-control" value="{{mediaSettings.resetButtonText}}" maxlength="120" data-email-template-button-text></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-reset-alignment">Alignment</label><select id="settings-reset-alignment" name="reset_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.resetButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.resetButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.resetButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
</div>
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the reset link. Available values: <code>[[username]]</code>, <code>[[display_name]]</code>, <code>[[email]]</code>.</div>
</div>
<div class="col-12 col-lg-6">
<div class="email-template-preview" data-email-template-preview aria-label="Password reset preview"></div>
</div>
</div>
</div>
<hr class="my-4">
<div class="email-template-editor" data-email-template-editor>
<div class="row g-4 align-items-start">
<div class="col-12 col-lg-6">
<h4 class="h6">User invitation</h4>
<label class="form-label" for="settings-invitation-subject">Subject</label>
<input id="settings-invitation-subject" name="invitation_subject" type="text" class="form-control mb-2" value="{{mediaSettings.invitationEmailSubject}}" maxlength="255" data-email-template-subject>
<label class="form-label" for="settings-invitation-body">Message</label>
<textarea id="settings-invitation-body" name="invitation_body" class="form-control" rows="5" data-email-template-body>{{mediaSettings.invitationEmailBody}}</textarea>
<div class="row g-2 mt-1">
<div class="col-12 col-md-8"><label class="form-label" for="settings-invitation-button-text">Button text</label><input id="settings-invitation-button-text" name="invitation_button_text" type="text" class="form-control" value="{{mediaSettings.invitationButtonText}}" maxlength="120" data-email-template-button-text></div>
<div class="col-12 col-md-4"><label class="form-label" for="settings-invitation-alignment">Alignment</label><select id="settings-invitation-alignment" name="invitation_button_alignment" class="form-select" data-email-template-alignment><option value="left" {{#if (eq mediaSettings.invitationButtonAlignment 'left')}}selected{{/if}}>Left</option><option value="center" {{#if (eq mediaSettings.invitationButtonAlignment 'center')}}selected{{/if}}>Center</option><option value="right" {{#if (eq mediaSettings.invitationButtonAlignment 'right')}}selected{{/if}}>Right</option></select></div>
</div>
<div class="form-text">Use <code>[[action_button]]</code> for a button or <code>[[url]]</code> for the invitation link. Available values: <code>[[display_name]]</code>, <code>[[email]]</code>.</div>
</div>
<div class="col-12 col-lg-6"><div class="email-template-preview" data-email-template-preview aria-label="User invitation preview"></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>
@@ -449,6 +559,15 @@
{{/each}}
</div>
</div>
<div class="col-12">
<h4 class="settings-audit-category-label mb-1">Screen controls</h4>
<p class="text-muted small fst-italic mb-2">Commands sent to connected screens and players.</p>
<div class="d-flex flex-wrap gap-3">
{{#each mediaSettings.auditScreenControlCommands}}
<div class="form-check"><input id="settings-audit-command-{{key}}" name="audit_screen_control_commands[]" type="checkbox" class="form-check-input" value="{{key}}" {{#if isSelected}}checked{{/if}}><label class="form-check-label" for="settings-audit-command-{{key}}">{{label}}</label></div>
{{/each}}
</div>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
@@ -33,8 +33,8 @@
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Task Queue</h3>
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-task-tools">
<div class="input-group input-group-sm background-tasks-task-search" style="width: min(12rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm background-tasks-task-search table-search-group" style="width: min(12rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search tasks" aria-label="Search tasks" data-table-search />
</div>
{{#if canManage}}
@@ -9,8 +9,8 @@
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Scheduled Tasks</h3>
<div class="ms-auto d-flex flex-nowrap align-items-center background-tasks-recurring-tools">
<div class="input-group input-group-sm background-tasks-recurring-search">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm background-tasks-recurring-search table-search-group">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search scheduled" aria-label="Search scheduled refreshes" data-table-search />
</div>
</div>
+20 -3
View File
@@ -22,11 +22,28 @@
<label for="{{#if isEdit}}edit-user-name{{else}}user-name{{/if}}" class="form-label">Name</label>
<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 class="col-12">
<label for="{{#if isEdit}}edit-user-email{{else}}user-email{{/if}}" class="form-label">Email address</label>
<input id="{{#if isEdit}}edit-user-email{{else}}user-email{{/if}}" type="email" name="email" class="form-control" autocomplete="email" value="{{#if isEdit}}{{user.email}}{{else}}{{formValues.email}}{{/if}}" maxlength="320" />
{{#if isEdit}}<div class="form-text">{{#if user.email_verified_at}}Verified{{else}}Not verified{{/if}}. Email is used for password recovery.</div>{{/if}}
</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 class="border-top mt-3 pt-3">
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="form-check form-switch">
<input id="edit-user-account-locked" name="account_locked" type="checkbox" class="form-check-input" value="1" autocomplete="off" {{#if user.account_locked}}checked{{/if}}>
<label class="form-check-label" for="edit-user-account-locked">Account locked</label>
</div>
</div>
<div class="col-12 col-md-6">
<div class="form-check form-switch">
<input id="edit-user-email-verified" name="email_verified" type="checkbox" class="form-check-input" value="1" autocomplete="off" {{#if user.email_verified_at}}checked{{/if}}>
<label class="form-check-label" for="edit-user-email-verified">Email verified</label>
</div>
</div>
</div>
</div>
{{/if}}
</div>
+67
View File
@@ -0,0 +1,67 @@
<div class="page-header">
<div>
<h2>Invite user</h2>
<p>Send a secure invitation. The account is created when the recipient accepts it.</p>
</div>
</div>
<form method="post" action="/settings/users/invite">
<div class="row g-3">
<div class="col-12 col-xl-4">
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">Invitation details</h3>
</div>
<div class="card-body">
{{#if message}}<div class="alert alert-{{messageVariant}}">{{message}}</div>{{/if}}
<div class="row g-3">
<div class="col-12">
<label for="invite-email" class="form-label">Email address</label>
<input id="invite-email" name="email" type="email" class="form-control" value="{{formValues.email}}" autocomplete="email" maxlength="320" required>
</div>
<div class="col-12">
<label for="invite-name" class="form-label">Display name</label>
<input id="invite-name" name="name" type="text" class="form-control" value="{{formValues.name}}" autocomplete="name" maxlength="255" required>
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Invitation actions">
<button type="submit" class="btn btn-primary">Send invitation</button>
<a class="btn btn-secondary" href="/settings/users">Cancel</a>
</div>
</div>
</div>
</div>
<div class="col-12 col-xl-8">
<div class="card card-outline card-secondary admin-form-card">
<div class="card-header">
<h3 class="card-title">Roles</h3>
</div>
<div class="card-body">
<div class="row g-3">
{{#if roles.length}}
{{#each roles}}
<div class="col-12 col-md-6 col-xl-4">
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}}>
<span class="form-check-label pe-4">
<strong>{{name}}</strong>
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
</span>
</label>
</div>
{{/each}}
{{else}}
<div class="col-12">
<div class="alert alert-warning mb-0">Create a role before sending invitations.</div>
</div>
{{/if}}
</div>
<div class="form-text mt-2">The recipient will receive these roles when the invitation is accepted.</div>
</div>
</div>
</div>
</div>
</form>
+7 -5
View File
@@ -9,13 +9,15 @@
<div class="card-header">
<h3 class="card-title">Existing users</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<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="/settings/users/new">Add user</a>
{{/if}}
<div class="btn-group">
{{#if (hasPermission currentUser 'invitations.create')}}
<a href="/settings/users/invite" class="btn btn-info btn-sm">Invite user</a><a class="btn btn-primary btn-sm" href="/settings/users/new">Add<span class="table-action-context"> user</span></a>
{{/if}}
</div>
</div>
</div>
<div class="card-body table-responsive p-0">
+34 -15
View File
@@ -6,8 +6,6 @@
<title>{{title}} - Pulse</title>
<link rel="icon" type="image/png" href="/assets/favicon.png" />
<script src="/assets/js/theme-init.js"></script>
<link rel="preload" href="/assets/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="preload" href="/assets/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff" as="font" type="font/woff" crossorigin="anonymous" />
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte-colors.min.css" />
@@ -276,7 +274,7 @@
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'system-settings.read' 'audit-log.read' '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' 'invitations.read' 'fonts.read')}}
<li class="nav-header">SETTINGS</li>
{{/if}}
{{#if (hasPermission currentUser 'fonts.read')}}
@@ -287,20 +285,41 @@
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'users.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'users')}}active{{/if}}" href="/settings/users">
{{#if (anyPermission currentUser 'users.read' 'rbac.read' 'invitations.read')}}
<li class="nav-item has-treeview{{#if userManagementMenuOpen}} menu-open{{/if}}">
<a class="nav-link{{#if userManagementMenuOpen}} active{{/if}}" href="#">
<i class="nav-icon bi bi-people"></i>
<p>Users</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'rbac.read')}}
<li class="nav-item">
<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>
<p>
User management
<i class="nav-arrow bi bi-chevron-right"></i>
</p>
</a>
{{#if (hasPermission currentUser 'users.read')}}
<ul class="nav nav-treeview">
<li class="nav-item">
<a class="nav-link {{#if (eq active 'users')}}active{{/if}}" href="/settings/users">
<i class="nav-icon bi bi-person"></i>
<p>Users</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'rbac.read')}}
<li class="nav-item">
<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 'invitations.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'users-invitations')}}active{{/if}}" href="/settings/invitations">
<i class="nav-icon bi bi-envelope-open"></i>
<p>Pending invitations</p>
</a>
</li>
{{/if}}
</ul>
</li>
{{/if}}
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read')}}
+3 -3
View File
@@ -9,12 +9,12 @@
<div class="card-header">
<h3 class="card-title">Existing announcements</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(16rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(16rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search announcements" aria-label="Search announcements" data-table-search />
</div>
{{#if (hasPermission currentUser 'announcements.create')}}
<a class="btn btn-primary btn-sm" href="/announcements/new">Add announcement</a>
<a class="btn btn-primary btn-sm" href="/announcements/new">Add<span class="table-action-context"> announcement</span></a>
{{/if}}
</div>
</div>
+3 -3
View File
@@ -9,12 +9,12 @@
<div class="card-header">
<h3 class="card-title">Saved sizes</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search canvas sizes" aria-label="Search canvas sizes" data-table-search />
</div>
{{#if (hasPermission currentUser 'canvas-sizes.create')}}
<a class="btn btn-primary btn-sm" href="/canvas-sizes/new">Add canvas size</a>
<a class="btn btn-primary btn-sm" href="/canvas-sizes/new">Add<span class="table-action-context"> canvas size</span></a>
{{/if}}
</div>
</div>
+34 -6
View File
@@ -1,4 +1,4 @@
<div class="page-header">
<div class="page-header clients-page-header">
<div>
<h2>Connected clients</h2>
<p>Inspect live player connections and send commands to individual clients.</p>
@@ -6,7 +6,7 @@
</div>
{{#if (anyPermission currentUser 'clients.allow' 'pairing.allow')}}
<div class="card card-outline card-secondary mb-4 screen-command-card">
<div class="card card-outline card-secondary mb-4 screen-command-card clients-screen-command-card">
<div class="card-header">
<div class="dashboard-card-heading">
<h3 class="card-title">Screen Group controls</h3>
@@ -36,7 +36,7 @@
data-playlist-name="{{playlist_name}}"
>{{name}}</option>
{{/each}}
<option value="__all__" data-screen-name="All screens" data-screen-target-all="true">All Screens</option>
<option value="__all__" data-screen-name="All screen groups" data-screen-target-all="true">All screen groups</option>
</select>
</div>
</div>
@@ -82,12 +82,12 @@
</div>
{{/if}}
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card card-outline card-primary clients-client-table-card" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Active connections</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search clients" aria-label="Search clients" data-table-search />
</div>
</div>
@@ -186,6 +186,34 @@
{{> table-pagination pagination=pagination basePath="/clients" alwaysShow=true}}
</div>
<section class="clients-mobile-clients" aria-label="Active connections">
<div class="clients-mobile-client-tools mb-4 mt-0">
<div><h3>Active connections</h3></div>
<div class="clients-mobile-client-search input-group input-group-sm"><label for="clients-mobile-search">Search clients</label><span class="input-group-text" role="button" tabindex="0" aria-label="Search clients" data-mobile-client-search-toggle><i class="bi bi-search" aria-hidden="true"></i></span><input id="clients-mobile-search" class="form-control form-control-sm" type="search" placeholder="Search clients" /></div>
</div>
<div id="clients-mobile-client-list">
{{#if clients.length}}
{{#each clients}}
<article class="card card-outline {{#if paused}}card-warning{{else}}card-primary{{/if}} clients-mobile-client" data-mobile-client-id="{{id}}" data-mobile-client-client-id="{{clientId}}" data-mobile-client-key="{{id}}" data-client-id="{{id}}" data-client-client-id="{{clientId}}" data-client-screen-slug="{{screen_slug}}" data-client-player-base-url="{{player_url}}" data-mobile-client-search="{{client_name}} {{clientId}} {{screen_name}} {{screen_slug}}">
<div class="card-body">
<div class="clients-mobile-client-header"><div><h4 data-mobile-client-name>{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}</h4></div><span class="badge {{#if blackout}}text-bg-secondary{{else if paused}}text-bg-warning{{else}}text-bg-success{{/if}}">{{#if blackout}}Blackout{{else if paused}}Paused{{else}}Live{{/if}}</span></div>
<div class="clients-mobile-client-details"><div><small>Screen group</small><strong>{{#if screen_name}}{{screen_name}}{{else}}{{screen_slug}}{{/if}}</strong></div><div><small>Now showing</small><strong>{{#if currentSlideTitle}}{{currentSlideTitle}}{{else}}No slide currently showing{{/if}}</strong></div></div>
{{#if (hasPermission ../currentUser 'clients.allow')}}
<div class="clients-mobile-client-actions"><form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="{{id}}" /><input type="hidden" name="playerBaseUrl" value="{{player_url}}" /><button type="submit" class="btn btn-sm btn-danger" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button></form><form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="{{id}}" /><input type="hidden" name="playerBaseUrl" value="{{player_url}}" /><button type="submit" class="btn btn-sm btn-warning" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="{{id}}" /><input type="hidden" name="playerBaseUrl" value="{{player_url}}" /><button type="submit" class="btn btn-sm btn-warning" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form wide" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="{{id}}" /><input type="hidden" name="playerBaseUrl" value="{{player_url}}" /><button type="submit" class="btn btn-sm btn-info w-100" aria-label="Pause client" title="Pause client"><i class="bi {{#if paused}}bi-play-fill{{else}}bi-pause-fill{{/if}} me-1" aria-hidden="true"></i>{{#if paused}}Resume{{else}}Pause{{/if}}</button></form><form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form wide" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="{{#if blackout}}false{{else}}true{{/if}}" /><input type="hidden" name="connectionId" value="{{id}}" /><input type="hidden" name="playerBaseUrl" value="{{player_url}}" /><button type="submit" class="btn btn-sm {{#if blackout}}btn-success{{else}}btn-secondary{{/if}} w-100" aria-label="{{#if blackout}}Restore client{{else}}Blackout client{{/if}}" title="{{#if blackout}}Restore client{{else}}Blackout client{{/if}}"><i class="bi {{#if blackout}}bi-eye{{else}}bi-eye-slash{{/if}} me-1" aria-hidden="true"></i>{{#if blackout}}Restore{{else}}Blackout{{/if}}</button></form></div>
{{/if}}
</div>
</article>
{{/each}}
{{else}}<div class="alert alert-secondary">No connected clients yet.</div>{{/if}}
</div>
<div class="alert alert-secondary" id="clients-mobile-empty" hidden>No clients match that search.</div>
</section>
<script>
(function () { var input = document.getElementById('clients-mobile-search'); var toggle = document.querySelector('[data-mobile-client-search-toggle]'); if (input && toggle) { var focusInput = function (event) { if (event && event.preventDefault) { event.preventDefault(); } input.focus(); }; toggle.addEventListener('mousedown', focusInput); toggle.addEventListener('click', focusInput); toggle.addEventListener('keydown', function (event) { if (event.key === 'Enter' || event.key === ' ') { focusInput(event); } }); } }());
</script>
<script>
(function () { var input = document.getElementById('clients-mobile-search'); var empty = document.getElementById('clients-mobile-empty'); if (input) { input.addEventListener('input', function () { var query = input.value.trim().toLowerCase(); var visible = 0; document.querySelectorAll('[data-mobile-client-search]').forEach(function (card) { var matches = !query || card.getAttribute('data-mobile-client-search').toLowerCase().indexOf(query) !== -1; card.hidden = !matches; if (matches) visible += 1; }); if (empty) empty.hidden = visible > 0; }); } }());
</script>
{{#> modal-shell modalId="client-move-screen-modal" modalLabelId="client-move-screen-modal-label" modalDialogClass="modal-dialog-centered modal-dialog-scrollable modal-lg" modalBackdropStatic=true modalKeyboardDisabled=true}}
<form id="client-move-screen-form" method="post" action="#" class="d-flex flex-column">
<div class="modal-header">
+3 -3
View File
@@ -9,12 +9,12 @@
<div class="card-header">
<h3 class="card-title">Existing playlists</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search playlists" aria-label="Search playlists" data-table-search />
</div>
{{#if (hasPermission currentUser 'playlists.create')}}
<a class="btn btn-primary btn-sm" href="/playlists/new">Create playlist</a>
<a class="btn btn-primary btn-sm" href="/playlists/new">Create<span class="table-action-context"> playlist</span></a>
{{/if}}
</div>
</div>
+3 -3
View File
@@ -9,12 +9,12 @@
<div class="card-header">
<h3 class="card-title">Existing screen groups</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search screen groups" aria-label="Search screen groups" data-table-search />
</div>
{{#if (hasPermission currentUser 'screens.create')}}
<a class="btn btn-primary btn-sm" href="/screens/new">Add screen group</a>
<a class="btn btn-primary btn-sm" href="/screens/new">Add<span class="table-action-context"> screen group</span></a>
{{/if}}
</div>
</div>
+3 -3
View File
@@ -9,12 +9,12 @@
<div class="card-header">
<h3 class="card-title">Existing slides</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search slides" aria-label="Search slides" data-table-search />
</div>
{{#if (hasPermission currentUser 'slides.create')}}
<a class="btn btn-primary btn-sm" href="/slides/new">Create slide</a>
<a class="btn btn-primary btn-sm" href="/slides/new">Create<span class="table-action-context"> slide</span></a>
{{/if}}
</div>
</div>
+3 -3
View File
@@ -9,12 +9,12 @@
<div class="card-header">
<h3 class="card-title">Existing templates</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<div class="input-group input-group-sm flex-shrink-1 table-search-group" style="width: min(14rem, 100%);">
<span class="input-group-text" role="button" tabindex="0" aria-label="Search" data-table-search-toggle><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search templates" aria-label="Search templates" data-table-search />
</div>
{{#if (hasPermission currentUser 'templates.create')}}
<a class="btn btn-primary btn-sm" href="/templates/new">Create template</a>
<a class="btn btn-primary btn-sm" href="/templates/new">Create<span class="table-action-context"> template</span></a>
{{/if}}
</div>
</div>