Add dark mode with toggle and localStorage persistence

Uses Tailwind v4 class-based dark mode with a Stimulus controller for
toggling. Respects prefers-color-scheme as default, prevents FOUC with
an inline script, and persists the user's choice in localStorage. All
views updated with dark: variants for backgrounds, text, borders,
badges, buttons, and form inputs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dan Milne
2026-03-22 00:37:58 +11:00
parent 43958f50ce
commit 3d98261a51
38 changed files with 744 additions and 636 deletions

View File

@@ -1,44 +1,44 @@
<div class="max-w-4xl mx-auto">
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold text-gray-900">API Keys</h1>
<p class="mt-2 text-sm text-gray-600">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">API Keys</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Bearer tokens for server-to-server access to forward auth applications.
</p>
</div>
<%= link_to "New API Key", new_api_key_path,
class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
class: "inline-flex items-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
</div>
<% if @api_keys.any? %>
<div class="bg-white shadow overflow-hidden sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<div class="bg-white dark:bg-gray-800 shadow overflow-hidden sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Application</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Used</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Expires</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"></th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Application</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Used</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Expires</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"></th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
<% @api_keys.each do |key| %>
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"><%= key.name %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= key.application.name %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= key.created_at.strftime("%b %d, %Y") %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= key.last_used_at ? time_ago_in_words(key.last_used_at) + " ago" : "Never" %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"><%= key.expires_at ? key.expires_at.strftime("%b %d, %Y") : "Never" %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100"><%= key.name %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"><%= key.application.name %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"><%= key.created_at.strftime("%b %d, %Y") %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"><%= key.last_used_at ? time_ago_in_words(key.last_used_at) + " ago" : "Never" %></td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"><%= key.expires_at ? key.expires_at.strftime("%b %d, %Y") : "Never" %></td>
<td class="px-6 py-4 whitespace-nowrap">
<% if key.revoked? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">Revoked</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200">Revoked</span>
<% elsif key.expired? %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">Expired</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-900/50 text-yellow-800 dark:text-yellow-200">Expired</span>
<% else %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200">Active</span>
<% end %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
@@ -54,12 +54,12 @@
</table>
</div>
<% else %>
<div class="bg-gray-50 rounded-lg border border-gray-200 p-8 text-center">
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No API keys</h3>
<p class="mt-2 text-sm text-gray-500">
<h3 class="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">No API keys</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
Create an API key to authenticate server-to-server requests.
</p>
<div class="mt-6">

View File

@@ -1,17 +1,17 @@
<div class="max-w-lg mx-auto">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">New API Key</h1>
<p class="mt-2 text-sm text-gray-600">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">New API Key</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Create a bearer token for server-to-server access to a forward auth application.
</p>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<%= form_with(model: @api_key, class: "space-y-6") do |f| %>
<% if @api_key.errors.any? %>
<div class="rounded-md bg-red-50 p-4">
<div class="text-sm text-red-700">
<div class="rounded-md bg-red-50 dark:bg-red-900/30 p-4">
<div class="text-sm text-red-700 dark:text-red-300">
<ul class="list-disc pl-5 space-y-1">
<% @api_key.errors.full_messages.each do |msg| %>
<li><%= msg %></li>
@@ -22,32 +22,32 @@
<% end %>
<div>
<%= f.label :name, class: "block text-sm font-medium text-gray-700" %>
<%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
<%= f.label :name, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
placeholder: "e.g., Video Player WebDAV" %>
</div>
<div>
<%= f.label :application_id, "Application", class: "block text-sm font-medium text-gray-700" %>
<%= f.label :application_id, "Application", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<% if @applications.any? %>
<%= f.collection_select :application_id, @applications, :id, :name,
{ prompt: "Select an application" },
{ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
{ class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" } %>
<% else %>
<p class="mt-1 text-sm text-gray-500">No forward auth applications available.</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No forward auth applications available.</p>
<% end %>
</div>
<div>
<%= f.label :expires_at, "Expiration (optional)", class: "block text-sm font-medium text-gray-700" %>
<%= f.datetime_local_field :expires_at, 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">Leave blank for no expiration.</p>
<%= f.label :expires_at, "Expiration (optional)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= f.datetime_local_field :expires_at, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Leave blank for no expiration.</p>
</div>
<div class="flex items-center justify-end gap-3">
<%= link_to "Cancel", api_keys_path, class: "text-sm font-medium text-gray-700 hover:text-gray-500" %>
<%= link_to "Cancel", api_keys_path, class: "text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-500 dark:hover:text-gray-400" %>
<%= f.submit "Create API Key",
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
</div>
<% end %>
</div>

View File

@@ -1,19 +1,19 @@
<div class="max-w-2xl mx-auto" data-controller="clipboard">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">API Key Created</h1>
<p class="mt-2 text-sm text-gray-600">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">API Key Created</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Copy your API key now. You won't be able to see it again.
</p>
</div>
<div class="bg-white shadow sm:rounded-lg">
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="rounded-md bg-yellow-50 p-4 mb-6">
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900/30 p-4 mb-6">
<div class="flex">
<svg class="h-5 w-5 text-yellow-400 mr-3 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
</svg>
<div class="text-sm text-yellow-800">
<div class="text-sm text-yellow-800 dark:text-yellow-200">
<p class="font-medium">Save this key now!</p>
<p class="mt-1">This is the only time you'll see the full API key. Store it securely.</p>
</div>
@@ -21,14 +21,14 @@
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">API Key</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">API Key</label>
<div class="flex items-center gap-2">
<input type="text" readonly value="<%= @plaintext_token %>"
data-clipboard-target="source"
class="flex-1 rounded-md border-gray-300 bg-gray-50 font-mono text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500" />
class="flex-1 rounded-md border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 dark:text-gray-100 font-mono text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500" />
<button data-action="click->clipboard#copy"
data-clipboard-target="button"
class="inline-flex items-center rounded-md border border-gray-300 bg-white py-2 px-3 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
class="inline-flex items-center rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 py-2 px-3 text-sm font-medium text-gray-700 dark:text-gray-200 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
@@ -37,22 +37,22 @@
</div>
</div>
<div class="mt-6 space-y-2 text-sm text-gray-600">
<div class="mt-6 space-y-2 text-sm text-gray-600 dark:text-gray-400">
<p><strong>Name:</strong> <%= @api_key.name %></p>
<p><strong>Application:</strong> <%= @api_key.application.name %></p>
<p><strong>Expires:</strong> <%= @api_key.expires_at ? @api_key.expires_at.strftime("%b %d, %Y %H:%M") : "Never" %></p>
</div>
<div class="mt-6 rounded-md bg-gray-50 p-4">
<p class="text-sm font-medium text-gray-700 mb-2">Usage example:</p>
<pre class="text-xs text-gray-600 overflow-x-auto">curl -H "Authorization: Bearer <%= @plaintext_token %>" \
<div class="mt-6 rounded-md bg-gray-50 dark:bg-gray-700 p-4">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Usage example:</p>
<pre class="text-xs text-gray-600 dark:text-gray-200 overflow-x-auto">curl -H "Authorization: Bearer <%= @plaintext_token %>" \
-H "X-Forwarded-Host: your-app.example.com" \
<%= request.base_url %>/api/verify</pre>
</div>
<div class="mt-8">
<%= link_to "Done", api_keys_path,
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" %>
class: "inline-flex justify-center rounded-md border border-transparent bg-blue-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900" %>
</div>
</div>
</div>