Files
clinch/app/views/admin/applications/_form.html.erb
Dan Milne 6be23c2c37
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Add backchannel logout, per application logout.
2025-11-27 16:38:27 +11:00

230 lines
16 KiB
Plaintext

<%= form_with(model: [:admin, application], class: "space-y-6", data: { controller: "application-form form-errors" }) do |form| %>
<%= render "shared/form_errors", form: form %>
<div>
<%= form.label :name, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :name, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "My Application" %>
</div>
<div>
<%= form.label :slug, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :slug, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "my-app" %>
<p class="mt-1 text-sm text-gray-500">Lowercase letters, numbers, and hyphens only. Used in URLs and API calls.</p>
</div>
<div>
<%= form.label :description, class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
</div>
<div>
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700" %>
<% if application.icon.attached? %>
<div class="mt-2 mb-3 flex items-center gap-4">
<%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200", alt: "Current icon" %>
<div class="text-sm text-gray-600">
<p class="font-medium">Current icon</p>
<p class="text-xs"><%= number_to_human_size(application.icon.blob.byte_size) %></p>
</div>
</div>
<% end %>
<div class="mt-2" data-controller="file-drop">
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-blue-400 transition-colors"
data-file-drop-target="dropzone"
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop">
<div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="flex text-sm text-gray-600">
<label for="<%= form.field_id(:icon) %>" class="relative cursor-pointer bg-white rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500">
<span>Upload a file</span>
<%= form.file_field :icon,
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
class: "sr-only",
data: { file_drop_target: "input", action: "change->file-drop#handleFiles" } %>
</label>
<p class="pl-1">or drag and drop</p>
</div>
<p class="text-xs text-gray-500">PNG, JPG, GIF, or SVG up to 2MB</p>
</div>
</div>
<div data-file-drop-target="preview" class="mt-3 hidden">
<div class="flex items-center gap-3 p-3 bg-blue-50 rounded-md border border-blue-200">
<img data-file-drop-target="previewImage" class="h-12 w-12 rounded object-cover" alt="Preview">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900" data-file-drop-target="filename"></p>
<p class="text-xs text-gray-500" data-file-drop-target="filesize"></p>
</div>
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 hover:text-gray-600">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>
<div>
<%= form.label :landing_url, "Landing URL", class: "block text-sm font-medium text-gray-700" %>
<%= form.url_field :landing_url, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "https://app.example.com" %>
<p class="mt-1 text-sm text-gray-500">The main URL users will visit to access this application. This will be shown as a link on their dashboard.</p>
</div>
<div>
<%= form.label :app_type, "Application Type", class: "block text-sm font-medium text-gray-700" %>
<%= form.select :app_type, [["OpenID Connect (OIDC)", "oidc"], ["Forward Auth (Reverse Proxy)", "forward_auth"]], {}, {
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
disabled: application.persisted?,
data: { action: "change->application-form#updateFieldVisibility", application_form_target: "appTypeSelect" }
} %>
<% if application.persisted? %>
<p class="mt-1 text-sm text-gray-500">Application type cannot be changed after creation.</p>
<% end %>
</div>
<!-- OIDC-specific fields -->
<div id="oidc-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.oidc? || !application.persisted? %>" data-application-form-target="oidcFields">
<h3 class="text-base font-semibold text-gray-900">OIDC Configuration</h3>
<div>
<%= form.label :redirect_uris, "Redirect URIs", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :redirect_uris, rows: 4, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://example.com/callback\nhttps://app.example.com/auth/callback" %>
<p class="mt-1 text-sm text-gray-500">One URI per line. These are the allowed callback URLs for your application.</p>
</div>
<div>
<%= form.label :backchannel_logout_uri, "Backchannel Logout URI (Optional)", class: "block text-sm font-medium text-gray-700" %>
<%= form.url_field :backchannel_logout_uri, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "https://app.example.com/oidc/backchannel-logout" %>
<p class="mt-1 text-sm text-gray-500">
If the application supports OpenID Connect Backchannel Logout, enter the logout endpoint URL.
When users log out, Clinch will send logout notifications to this endpoint for immediate session termination.
Leave blank if the application doesn't support backchannel logout.
</p>
</div>
<div class="border-t border-gray-200 pt-4 mt-4">
<h4 class="text-sm font-semibold text-gray-900 mb-3">Token Expiration Settings</h4>
<p class="text-sm text-gray-500 mb-4">Configure how long tokens remain valid. Shorter times are more secure but require more frequent refreshes.</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<%= form.label :access_token_ttl, "Access Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
<%= form.number_field :access_token_ttl, value: application.access_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500">
Range: 5 min - 24 hours
<br>Default: 1 hour (3600s)
<br>Current: <span class="font-medium"><%= application.access_token_ttl_human || "1 hour" %></span>
</p>
</div>
<div>
<%= form.label :refresh_token_ttl, "Refresh Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
<%= form.number_field :refresh_token_ttl, value: application.refresh_token_ttl || 2592000, min: 86400, max: 7776000, step: 86400, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500">
Range: 1 day - 90 days
<br>Default: 30 days (2592000s)
<br>Current: <span class="font-medium"><%= application.refresh_token_ttl_human || "30 days" %></span>
</p>
</div>
<div>
<%= form.label :id_token_ttl, "ID Token TTL (seconds)", class: "block text-sm font-medium text-gray-700" %>
<%= form.number_field :id_token_ttl, value: application.id_token_ttl || 3600, min: 300, max: 86400, step: 60, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500">
Range: 5 min - 24 hours
<br>Default: 1 hour (3600s)
<br>Current: <span class="font-medium"><%= application.id_token_ttl_human || "1 hour" %></span>
</p>
</div>
</div>
<details class="mt-3">
<summary class="cursor-pointer text-sm text-blue-600 hover:text-blue-800">Understanding Token Types</summary>
<div class="mt-2 ml-4 space-y-2 text-sm text-gray-600">
<p><strong>Access Token:</strong> Used to access protected resources (APIs). Shorter lifetime = more secure. Users won't notice automatic refreshes.</p>
<p><strong>Refresh Token:</strong> Used to get new access tokens without re-authentication. Longer lifetime = better UX (less re-logins).</p>
<p><strong>ID Token:</strong> Contains user identity information (JWT). Should match access token lifetime in most cases.</p>
<p class="text-xs italic mt-2">💡 Tip: Banking apps use 5-15 min access tokens. Internal tools use 1-4 hours.</p>
</div>
</details>
</div>
</div>
<!-- Forward Auth-specific fields -->
<div id="forward-auth-fields" class="space-y-6 border-t border-gray-200 pt-6 <%= 'hidden' unless application.forward_auth? %>" data-application-form-target="forwardAuthFields">
<h3 class="text-base font-semibold text-gray-900">Forward Auth Configuration</h3>
<div>
<%= form.label :domain_pattern, "Domain Pattern", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_field :domain_pattern, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono", placeholder: "*.example.com or app.example.com" %>
<p class="mt-1 text-sm text-gray-500">Domain pattern to match. Use * for wildcard subdomains (e.g., *.example.com matches app.example.com, api.example.com, etc.)</p>
</div>
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :headers_config, "Custom Headers Configuration (JSON)", class: "block text-sm font-medium text-gray-700" %>
<%= form.text_area :headers_config, value: (application.headers_config.present? && application.headers_config.any? ? JSON.pretty_generate(application.headers_config) : ""), rows: 10,
class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono",
placeholder: '{"user": "Remote-User", "groups": "Remote-Groups"}',
data: {
action: "input->json-validator#validate blur->json-validator#format",
json_validator_target: "textarea"
} %>
<div class="mt-2 text-sm text-gray-600 space-y-1">
<div class="flex items-center justify-between">
<p class="font-medium">Optional: Customize header names sent to your application.</p>
<div class="flex items-center gap-2">
<button type="button" data-action="json-validator#format" class="text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">Format JSON</button>
<button type="button" data-action="json-validator#insertSample" data-json-sample='{"user": "Remote-User", "groups": "Remote-Groups", "email": "Remote-Email", "name": "Remote-Name", "admin": "Remote-Admin"}' class="text-xs bg-blue-100 hover:bg-blue-200 text-blue-700 px-2 py-1 rounded">Insert Example</button>
</div>
</div>
<p><strong>Default headers:</strong> X-Remote-User, X-Remote-Email, X-Remote-Name, X-Remote-Groups, X-Remote-Admin</p>
<div data-json-validator-target="status" class="text-xs font-medium"></div>
<details class="mt-2">
<summary class="cursor-pointer text-blue-600 hover:text-blue-800">Show available header keys and what data they send</summary>
<div class="mt-2 ml-4 space-y-1 text-xs">
<p><code class="bg-gray-100 px-1 rounded">user</code> - User's email address</p>
<p><code class="bg-gray-100 px-1 rounded">email</code> - User's email address</p>
<p><code class="bg-gray-100 px-1 rounded">name</code> - User's display name (falls back to email if not set)</p>
<p><code class="bg-gray-100 px-1 rounded">groups</code> - Comma-separated list of group names (e.g., "admin,developers")</p>
<p><code class="bg-gray-100 px-1 rounded">admin</code> - "true" or "false" indicating admin status</p>
<p class="mt-2 italic">Example: <code class="bg-gray-100 px-1 rounded">{"user": "Remote-User", "groups": "Remote-Groups"}</code></p>
<p class="italic">Need custom user fields? Add them to user's custom_claims for OIDC tokens</p>
</div>
</details>
</div>
</div>
</div>
<div>
<%= form.label :group_ids, "Allowed Groups (Optional)", class: "block text-sm font-medium text-gray-700" %>
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 rounded-md p-3">
<% if @available_groups.any? %>
<% @available_groups.each do |group| %>
<div class="flex items-center">
<%= check_box_tag "application[group_ids][]", group.id, application.allowed_groups.include?(group), class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= label_tag "application_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900" %>
<span class="ml-2 text-xs text-gray-500">(<%= pluralize(group.users.count, "member") %>)</span>
</div>
<% end %>
<% else %>
<p class="text-sm text-gray-500">No groups available. Create groups first to restrict access.</p>
<% end %>
</div>
<p class="mt-1 text-sm text-gray-500">If no groups are selected, all active users can access this application.</p>
</div>
<div class="flex items-center">
<%= form.check_box :active, class: "h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500" %>
<%= form.label :active, "Active", class: "ml-2 block text-sm text-gray-900" %>
</div>
<div class="flex gap-3">
<%= form.submit application.persisted? ? "Update Application" : "Create Application", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
<%= link_to "Cancel", admin_applications_path, class: "rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
</div>
<% end %>