js_admin 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e58cee8e95f86b47a5487366c9bb11691c14ad57da5a8fbb5d9f3522b49c7372
4
- data.tar.gz: e47f66d3d176de880706e7fd52deb86d03ba058bf63bc75b9d702ba4e20f0814
3
+ metadata.gz: bacbc059bd6ee6d36d9ad35c5fa86b8cb258b4d1fda939bf3bd98b269f74c7ea
4
+ data.tar.gz: ef27365d7d4d61abfb48a114bcd9a1eb3a5f569b943a2369951f12e4e78aef22
5
5
  SHA512:
6
- metadata.gz: cd4ee8893dea3b7a19998cf43923919491a6b5fbbda3865b4efb2b127b7d3e851da0d790a9e3be30dceaa84e9b97990b1672c13db3a9c7621f484091f3f07ad4
7
- data.tar.gz: 1c149b1cca178d1850fc1a78051d1b59af102d3323a4c38f57c93fd3952652cd37c1e0657bd65da3973b81ab3aaaf0f0da94ff6517e14d8e5846202c47df0d10
6
+ metadata.gz: bccfe7a4bc553ff4581f6a31a4beb244fe47a39cec17a3d851b527ff74f8d2b3d8d12aa0e3f71a85ac74b2bbcd4bccddb919327cb8235c4688fa37483cc91973
7
+ data.tar.gz: 79370d4f7c372a9527924c0768f641109424b24c55fb42ef2f16011702f518b04560201c5b3c43c2200c860a60a447f1edf72ce542f33da2d53a3ab2af5a74aa
data/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.0 - 2026-10-18
6
+
7
+ - Add host application authentication and authorization callbacks as an alternative to Basic Auth.
8
+ - Reject conflicting or incomplete authentication configuration with localized errors.
9
+ - Add English and Korean I18n translations for the JSAdmin interface.
10
+ - Add `select_options` configuration to render a column as a select with static or callable choices.
11
+
5
12
  ## 0.1.0 - 2026-07-18
6
13
 
7
14
  - Change the default mount path to `/admin`.
data/README.md CHANGED
@@ -74,9 +74,12 @@ Available configuration options:
74
74
  | `records_per_page` | `10` | Number of records shown per resource index page. |
75
75
  | `association_limit` | `100` | Maximum number of associated records loaded into a `belongs_to` select. |
76
76
  | `display_name_methods` | `%i[name title email username id]` | Methods tried in order when displaying records in tables and select labels. |
77
+ | `field_select_options` | `{}` | Select choices by model and column. Register them with `select_options`. |
77
78
  | `basic_auth_username` | `nil` | HTTP Basic Auth username. Auth is disabled unless username and password are both present. |
78
79
  | `basic_auth_password` | `nil` | HTTP Basic Auth password. |
79
80
  | `basic_auth_realm` | `"JSAdmin"` | HTTP Basic Auth realm. |
81
+ | `authenticate_with` | `nil` | Controller-context callback delegated to the host application's authentication. |
82
+ | `authorize_with` | `nil` | Optional controller-context callback that returns whether the authenticated user may access JSAdmin. |
80
83
 
81
84
  Example:
82
85
 
@@ -86,14 +89,21 @@ JSAdmin.configure do |config|
86
89
  config.include_models User, Post
87
90
  config.exclude_models AuditLog
88
91
  config.records_per_page = 10
92
+ config.select_options "Post", category: -> { Category.pluck(:name) }
89
93
  end
90
94
  ```
91
95
 
96
+ `select_options` renders the column as a select. Pass an array or a callable;
97
+ callables are evaluated each time the form renders. Choices can be values or
98
+ `[label, value]` pairs.
99
+
92
100
  ## Authentication
93
101
 
94
- JSAdmin works without authentication unless Basic Auth credentials are
95
- configured. When both username and password are set, every JSAdmin request
96
- requires HTTP Basic Auth.
102
+ JSAdmin works without authentication unless Basic Auth credentials or an
103
+ authentication callback are configured. Choose exactly one of the following
104
+ authentication strategies.
105
+
106
+ For HTTP Basic Auth, configure both username and password:
97
107
 
98
108
  ```ruby
99
109
  # config/initializers/js_admin.rb
@@ -104,8 +114,45 @@ JSAdmin.configure do |config|
104
114
  end
105
115
  ```
106
116
 
107
- For production apps, you can also mount JSAdmin inside the host application's
108
- admin authentication constraint when you need app-level authorization.
117
+ To delegate authentication to the host application, provide a callback instead.
118
+ JSAdmin controllers inherit from the host application's `ApplicationController`,
119
+ and the callback runs in the controller context:
120
+
121
+ ```ruby
122
+ # config/initializers/js_admin.rb
123
+ JSAdmin.configure do |config|
124
+ config.authenticate_with do
125
+ authenticate_user!
126
+ end
127
+
128
+ config.authorize_with do
129
+ current_user&.admin?
130
+ end
131
+ end
132
+ ```
133
+
134
+ `authorize_with` is optional. When configured, returning `false` responds with
135
+ `403 Forbidden`. Basic Auth and `authenticate_with` cannot be configured
136
+ together. Partial Basic Auth credentials and `authorize_with` without
137
+ `authenticate_with` also raise `JSAdmin::ConfigurationError`.
138
+
139
+ For production apps, you can alternatively mount JSAdmin inside the host
140
+ application's admin authentication constraint.
141
+
142
+ ## Internationalization
143
+
144
+ JSAdmin follows the host application's `I18n.locale`.
145
+
146
+ Supported locales:
147
+
148
+ | Locale | Language |
149
+ | --- | --- |
150
+ | `en` | English |
151
+ | `ko` | 한국어 |
152
+
153
+ The translations cover the interface, CRUD notices, configuration errors, and
154
+ generator messages. Host applications can override any key under the
155
+ `js_admin` namespace.
109
156
 
110
157
  ## Field Mapping
111
158
 
@@ -117,6 +164,7 @@ JSAdmin maps database columns to inputs automatically:
117
164
  | `boolean` | checkbox |
118
165
  | `integer`, `bigint`, `float`, `decimal` | number input |
119
166
  | `date`, `datetime`, `time` | matching date/time input |
167
+ | `select_options` configured column | select |
120
168
  | Rails enum | select |
121
169
  | `belongs_to` foreign key | select |
122
170
  | other columns | text input |
@@ -1,20 +1,36 @@
1
1
  require "digest"
2
2
 
3
3
  module JSAdmin
4
- class ApplicationController < ActionController::Base
5
- before_action :authenticate_basic_auth
4
+ class ApplicationController < ::ApplicationController
5
+ before_action :authenticate_js_admin
6
+ before_action :authorize_js_admin
6
7
 
7
8
  private
8
9
 
9
- def authenticate_basic_auth
10
- return unless JSAdmin.configuration.basic_auth_enabled?
10
+ def authenticate_js_admin
11
+ case JSAdmin.configuration.authentication_strategy
12
+ when :basic_auth
13
+ authenticate_basic_auth
14
+ when :callback
15
+ instance_exec(&JSAdmin.configuration.authentication_callback)
16
+ end
17
+ end
11
18
 
19
+ def authenticate_basic_auth
12
20
  authenticate_or_request_with_http_basic(basic_auth_realm) do |username, password|
13
21
  secure_compare(username, JSAdmin.configuration.basic_auth_username) &&
14
22
  secure_compare(password, JSAdmin.configuration.basic_auth_password)
15
23
  end
16
24
  end
17
25
 
26
+ def authorize_js_admin
27
+ callback = JSAdmin.configuration.authorization_callback
28
+ return unless callback
29
+ return if instance_exec(&callback)
30
+
31
+ head :forbidden
32
+ end
33
+
18
34
  def basic_auth_realm
19
35
  realm = JSAdmin.configuration.basic_auth_realm.to_s
20
36
  return "JSAdmin" if realm.empty?
@@ -18,7 +18,8 @@ module JSAdmin
18
18
  @record = @resource.new_record(resource_params)
19
19
 
20
20
  if @record.save
21
- redirect_to resource_path(@resource.model_id, @record), notice: "#{@resource.singular_label} was created."
21
+ redirect_to resource_path(@resource.model_id, @record),
22
+ notice: t("js_admin.resources.notices.created", resource: @resource.singular_label)
22
23
  else
23
24
  render :new, status: :unprocessable_entity
24
25
  end
@@ -29,7 +30,8 @@ module JSAdmin
29
30
 
30
31
  def update
31
32
  if @record.update(resource_params)
32
- redirect_to resource_path(@resource.model_id, @record), notice: "#{@resource.singular_label} was updated."
33
+ redirect_to resource_path(@resource.model_id, @record),
34
+ notice: t("js_admin.resources.notices.updated", resource: @resource.singular_label)
33
35
  else
34
36
  render :edit, status: :unprocessable_entity
35
37
  end
@@ -37,7 +39,7 @@ module JSAdmin
37
39
 
38
40
  def destroy
39
41
  @record.destroy
40
- message = "#{@resource.singular_label} was deleted."
42
+ message = t("js_admin.resources.notices.deleted", resource: @resource.singular_label)
41
43
 
42
44
  respond_to do |format|
43
45
  format.turbo_stream do
@@ -101,6 +101,8 @@ module JSAdmin
101
101
  tag.input(**js_admin_input_options(form, field, js_admin_text_input_classes(form, field)).merge(type: "datetime-local", value: js_admin_formatted_field_value(form, field, "%Y-%m-%dT%H:%M:%S")))
102
102
  when :time_field
103
103
  tag.input(**js_admin_input_options(form, field, js_admin_text_input_classes(form, field)).merge(type: "time", value: js_admin_formatted_field_value(form, field, "%H:%M:%S")))
104
+ when :configured_select
105
+ select_tag(js_admin_field_name(form, field), options_for_select(field.configured_select_values, js_admin_field_value(form, field)), js_admin_select_options(form, field))
104
106
  when :enum_select
105
107
  select_tag(js_admin_field_name(form, field), options_for_select(field.enum_values, js_admin_field_value(form, field)), js_admin_select_options(form, field))
106
108
  when :association_select
@@ -171,7 +173,11 @@ module JSAdmin
171
173
 
172
174
  def js_admin_table_value(field, record)
173
175
  value = field.formatted_value(record)
174
- classes = value == "NULL" ? "text-slate-400 dark:text-slate-500" : "text-slate-700 dark:text-slate-200"
176
+ classes = if field.value_for(record).nil?
177
+ "text-slate-400 dark:text-slate-500"
178
+ else
179
+ "text-slate-700 dark:text-slate-200"
180
+ end
175
181
 
176
182
  tag.span(truncate(value, length: 80), class: classes)
177
183
  end
@@ -189,7 +195,7 @@ module JSAdmin
189
195
  end
190
196
 
191
197
  def js_admin_sidebar_resource_label(resource)
192
- resource.model_class.name.demodulize.underscore.humanize
198
+ resource.singular_label
193
199
  end
194
200
 
195
201
  def js_admin_sidebar_namespace_label(namespace)
@@ -1,6 +1,6 @@
1
1
  <div class="mb-8">
2
- <h1 class="text-2xl font-semibold text-slate-950 dark:text-slate-100">Models</h1>
3
- <p class="mt-2 text-sm text-slate-600 dark:text-slate-400"><%= pluralize(@resources.size, "model") %> available</p>
2
+ <h1 class="text-2xl font-semibold text-slate-950 dark:text-slate-100"><%= t("js_admin.models.heading") %></h1>
3
+ <p class="mt-2 text-sm text-slate-600 dark:text-slate-400"><%= t("js_admin.models.available", count: @resources.size, formatted_count: number_with_delimiter(@resources.size)) %></p>
4
4
  </div>
5
5
 
6
6
  <div class="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
@@ -1,4 +1,4 @@
1
- <% content_for :title, "JSAdmin Models" %>
1
+ <% content_for :title, t("js_admin.titles.models") %>
2
2
 
3
3
  <%= render layout: "layouts/js_admin/content_frame" do %>
4
4
  <%= render "index_content" %>
@@ -1,11 +1,11 @@
1
1
  <div class="mb-8">
2
2
  <div class="mb-2 text-sm text-slate-500 dark:text-slate-400">
3
- <%= link_to "Home", root_path, class: "hover:text-slate-950 dark:hover:text-slate-100" %> /
3
+ <%= link_to t("js_admin.navigation.home"), root_path, class: "hover:text-slate-950 dark:hover:text-slate-100" %> /
4
4
  <%= link_to @resource.label, resources_path(@resource.model_id), class: "hover:text-slate-950 dark:hover:text-slate-100" %> /
5
5
  <%= link_to @resource.display_name(@record), resource_path(@resource.model_id, @record), class: "hover:text-slate-950 dark:hover:text-slate-100" %> /
6
- Edit
6
+ <%= t("js_admin.resources.actions.edit") %>
7
7
  </div>
8
- <h1 class="text-2xl font-semibold text-slate-950 dark:text-slate-100">Edit <%= @resource.display_name(@record) %></h1>
8
+ <h1 class="text-2xl font-semibold text-slate-950 dark:text-slate-100"><%= t("js_admin.resources.headings.edit", record: @resource.display_name(@record)) %></h1>
9
9
  </div>
10
10
 
11
11
  <%= render "form", record: @record, resource: @resource, url: resource_path(@resource.model_id, @record) %>
@@ -31,7 +31,8 @@
31
31
  </div>
32
32
 
33
33
  <div class="flex items-center gap-2">
34
- <%= form.submit class: "inline-flex items-center justify-center rounded-md bg-sky-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-sky-400" %>
35
- <%= link_to "Cancel", record.persisted? ? resource_path(resource.model_id, record) : resources_path(resource.model_id), class: "inline-flex items-center justify-center rounded-md border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800" %>
34
+ <% submit_label = record.persisted? ? t("js_admin.resources.actions.update", resource: resource.singular_label) : t("js_admin.resources.actions.create", resource: resource.singular_label) %>
35
+ <%= form.submit submit_label, class: "inline-flex items-center justify-center rounded-md bg-sky-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-sky-400" %>
36
+ <%= link_to t("js_admin.resources.actions.cancel"), record.persisted? ? resource_path(resource.model_id, record) : resources_path(resource.model_id), class: "inline-flex items-center justify-center rounded-md border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800" %>
36
37
  </div>
37
38
  <% end %>
@@ -1,11 +1,11 @@
1
1
  <div class="mb-8 flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
2
2
  <div>
3
- <div class="mb-2 text-sm text-slate-500 dark:text-slate-400"><%= link_to "Home", root_path, class: "hover:text-slate-950 dark:hover:text-slate-100" %> / <%= @resource.label %></div>
3
+ <div class="mb-2 text-sm text-slate-500 dark:text-slate-400"><%= link_to t("js_admin.navigation.home"), root_path, class: "hover:text-slate-950 dark:hover:text-slate-100" %> / <%= @resource.label %></div>
4
4
  <h1 class="text-2xl font-semibold text-slate-950 dark:text-slate-100"><%= @resource.label %></h1>
5
- <p class="mt-2 text-sm text-slate-600 dark:text-slate-400"><%= number_with_delimiter(@total_count) %> records</p>
5
+ <p class="mt-2 text-sm text-slate-600 dark:text-slate-400"><%= t("js_admin.resources.records", count: @total_count, formatted_count: number_with_delimiter(@total_count)) %></p>
6
6
  </div>
7
7
 
8
- <%= link_to "New #{@resource.singular_label}", new_resource_path(@resource.model_id), class: "inline-flex items-center justify-center rounded-md bg-sky-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-sky-400" %>
8
+ <%= link_to t("js_admin.resources.actions.new", resource: @resource.singular_label), new_resource_path(@resource.model_id), class: "inline-flex items-center justify-center rounded-md bg-sky-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-sky-400" %>
9
9
  </div>
10
10
 
11
11
  <div class="overflow-x-auto rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
@@ -15,7 +15,7 @@
15
15
  <% @resource.list_fields.each do |field| %>
16
16
  <th scope="col" class="whitespace-nowrap px-5 py-4 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"><%= field.human_name %></th>
17
17
  <% end %>
18
- <th scope="col" class="w-44 px-5 py-4 text-right text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Actions</th>
18
+ <th scope="col" class="w-44 px-5 py-4 text-right text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"><%= t("js_admin.resources.table.actions") %></th>
19
19
  </tr>
20
20
  </thead>
21
21
  <tbody class="divide-y divide-slate-200 bg-white dark:divide-slate-800 dark:bg-slate-900">
@@ -26,9 +26,9 @@
26
26
  <% end %>
27
27
  <td class="whitespace-nowrap px-5 py-4 text-right text-sm">
28
28
  <div class="flex items-center justify-end gap-3">
29
- <%= link_to "View", resource_path(@resource.model_id, record), class: "font-medium text-sky-700 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-200" %>
30
- <%= link_to "Edit", edit_resource_path(@resource.model_id, record), class: "font-medium text-slate-700 hover:text-slate-950 dark:text-slate-300 dark:hover:text-slate-100" %>
31
- <%= button_to "Delete", resource_path(@resource.model_id, record), method: :delete, form: { class: "inline", data: { turbo_confirm: "Delete this record?" } }, class: "font-medium text-rose-700 hover:text-rose-900 dark:text-rose-300 dark:hover:text-rose-200" %>
29
+ <%= link_to t("js_admin.resources.actions.view"), resource_path(@resource.model_id, record), class: "font-medium text-sky-700 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-200" %>
30
+ <%= link_to t("js_admin.resources.actions.edit"), edit_resource_path(@resource.model_id, record), class: "font-medium text-slate-700 hover:text-slate-950 dark:text-slate-300 dark:hover:text-slate-100" %>
31
+ <%= button_to t("js_admin.resources.actions.delete"), resource_path(@resource.model_id, record), method: :delete, form: { class: "inline", data: { turbo_confirm: t("js_admin.resources.delete_confirmation") } }, class: "font-medium text-rose-700 hover:text-rose-900 dark:text-rose-300 dark:hover:text-rose-200" %>
32
32
  </div>
33
33
  </td>
34
34
  </tr>
@@ -36,7 +36,7 @@
36
36
 
37
37
  <% if @records.empty? %>
38
38
  <tr>
39
- <td colspan="<%= @resource.list_fields.size + 1 %>" class="px-5 py-12 text-center text-sm text-slate-500 dark:text-slate-400">No records</td>
39
+ <td colspan="<%= @resource.list_fields.size + 1 %>" class="px-5 py-12 text-center text-sm text-slate-500 dark:text-slate-400"><%= t("js_admin.resources.table.no_records") %></td>
40
40
  </tr>
41
41
  <% end %>
42
42
  </tbody>
@@ -44,31 +44,31 @@
44
44
  </div>
45
45
 
46
46
  <div class="mt-6 flex justify-end">
47
- <nav class="flex flex-wrap items-center justify-end gap-2" aria-label="Pagination">
47
+ <nav class="flex flex-wrap items-center justify-end gap-2" aria-label="<%= t("js_admin.resources.pagination.label") %>">
48
48
  <% if @page > 1 %>
49
- <%= link_to "First", resources_path(@resource.model_id, page: 1), class: js_admin_pagination_link_classes %>
49
+ <%= link_to t("js_admin.resources.pagination.first"), resources_path(@resource.model_id, page: 1), class: js_admin_pagination_link_classes %>
50
50
  <% else %>
51
- <span class="<%= js_admin_pagination_disabled_classes %>" aria-disabled="true">First</span>
51
+ <span class="<%= js_admin_pagination_disabled_classes %>" aria-disabled="true"><%= t("js_admin.resources.pagination.first") %></span>
52
52
  <% end %>
53
53
 
54
54
  <% if @resource.previous_page?(@page) %>
55
- <%= link_to "Previous", resources_path(@resource.model_id, page: @page - 1), class: js_admin_pagination_link_classes %>
55
+ <%= link_to t("js_admin.resources.pagination.previous"), resources_path(@resource.model_id, page: @page - 1), class: js_admin_pagination_link_classes %>
56
56
  <% else %>
57
- <span class="<%= js_admin_pagination_disabled_classes %>" aria-disabled="true">Previous</span>
57
+ <span class="<%= js_admin_pagination_disabled_classes %>" aria-disabled="true"><%= t("js_admin.resources.pagination.previous") %></span>
58
58
  <% end %>
59
59
 
60
- <span class="<%= js_admin_pagination_status_classes %>" aria-current="page" aria-label="Page <%= @page %> of <%= @total_pages %>"><%= @page %> / <%= @total_pages %></span>
60
+ <span class="<%= js_admin_pagination_status_classes %>" aria-current="page" aria-label="<%= t("js_admin.resources.pagination.status", page: @page, total_pages: @total_pages) %>"><%= @page %> / <%= @total_pages %></span>
61
61
 
62
62
  <% if @resource.next_page?(@page) %>
63
- <%= link_to "Next", resources_path(@resource.model_id, page: @page + 1), class: js_admin_pagination_link_classes %>
63
+ <%= link_to t("js_admin.resources.pagination.next"), resources_path(@resource.model_id, page: @page + 1), class: js_admin_pagination_link_classes %>
64
64
  <% else %>
65
- <span class="<%= js_admin_pagination_disabled_classes %>" aria-disabled="true">Next</span>
65
+ <span class="<%= js_admin_pagination_disabled_classes %>" aria-disabled="true"><%= t("js_admin.resources.pagination.next") %></span>
66
66
  <% end %>
67
67
 
68
68
  <% if @page < @total_pages %>
69
- <%= link_to "Last", resources_path(@resource.model_id, page: @total_pages), class: js_admin_pagination_link_classes %>
69
+ <%= link_to t("js_admin.resources.pagination.last"), resources_path(@resource.model_id, page: @total_pages), class: js_admin_pagination_link_classes %>
70
70
  <% else %>
71
- <span class="<%= js_admin_pagination_disabled_classes %>" aria-disabled="true">Last</span>
71
+ <span class="<%= js_admin_pagination_disabled_classes %>" aria-disabled="true"><%= t("js_admin.resources.pagination.last") %></span>
72
72
  <% end %>
73
73
  </nav>
74
74
  </div>
@@ -1,10 +1,10 @@
1
1
  <div class="mb-8">
2
2
  <div class="mb-2 text-sm text-slate-500 dark:text-slate-400">
3
- <%= link_to "Home", root_path, class: "hover:text-slate-950 dark:hover:text-slate-100" %> /
3
+ <%= link_to t("js_admin.navigation.home"), root_path, class: "hover:text-slate-950 dark:hover:text-slate-100" %> /
4
4
  <%= link_to @resource.label, resources_path(@resource.model_id), class: "hover:text-slate-950 dark:hover:text-slate-100" %> /
5
- New
5
+ <%= t("js_admin.resources.actions.new", resource: @resource.singular_label) %>
6
6
  </div>
7
- <h1 class="text-2xl font-semibold text-slate-950 dark:text-slate-100">New <%= @resource.singular_label %></h1>
7
+ <h1 class="text-2xl font-semibold text-slate-950 dark:text-slate-100"><%= t("js_admin.resources.headings.new", resource: @resource.singular_label) %></h1>
8
8
  </div>
9
9
 
10
10
  <%= render "form", record: @record, resource: @resource, url: resources_path(@resource.model_id) %>
@@ -1,7 +1,7 @@
1
1
  <div class="mb-8 flex flex-col gap-5 sm:flex-row sm:items-center sm:justify-between">
2
2
  <div>
3
3
  <div class="mb-2 text-sm text-slate-500 dark:text-slate-400">
4
- <%= link_to "Home", root_path, class: "hover:text-slate-950 dark:hover:text-slate-100" %> /
4
+ <%= link_to t("js_admin.navigation.home"), root_path, class: "hover:text-slate-950 dark:hover:text-slate-100" %> /
5
5
  <%= link_to @resource.label, resources_path(@resource.model_id), class: "hover:text-slate-950 dark:hover:text-slate-100" %> /
6
6
  <%= @resource.display_name(@record) %>
7
7
  </div>
@@ -9,8 +9,8 @@
9
9
  </div>
10
10
 
11
11
  <div class="flex gap-2">
12
- <%= link_to "Edit", edit_resource_path(@resource.model_id, @record), class: "inline-flex items-center justify-center rounded-md border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800" %>
13
- <%= button_to "Delete", resource_path(@resource.model_id, @record), method: :delete, form: { data: { turbo_confirm: "Delete this record?" } }, class: "inline-flex items-center justify-center rounded-md bg-rose-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-rose-700 dark:bg-rose-500 dark:hover:bg-rose-400" %>
12
+ <%= link_to t("js_admin.resources.actions.edit"), edit_resource_path(@resource.model_id, @record), class: "inline-flex items-center justify-center rounded-md border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800" %>
13
+ <%= button_to t("js_admin.resources.actions.delete"), resource_path(@resource.model_id, @record), method: :delete, form: { data: { turbo_confirm: t("js_admin.resources.delete_confirmation") } }, class: "inline-flex items-center justify-center rounded-md bg-rose-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-rose-700 dark:bg-rose-500 dark:hover:bg-rose-400" %>
14
14
  </div>
15
15
  </div>
16
16
 
@@ -1,4 +1,4 @@
1
- <% content_for :title, "Edit #{@resource.display_name(@record)}" %>
1
+ <% content_for :title, t("js_admin.titles.edit", record: @resource.display_name(@record)) %>
2
2
 
3
3
  <%= render layout: "layouts/js_admin/content_frame" do %>
4
4
  <%= render "edit_content" %>
@@ -1,4 +1,4 @@
1
- <% content_for :title, "New #{@resource.singular_label}" %>
1
+ <% content_for :title, t("js_admin.titles.new", resource: @resource.singular_label) %>
2
2
 
3
3
  <%= render layout: "layouts/js_admin/content_frame" do %>
4
4
  <%= render "new_content" %>
@@ -25,12 +25,12 @@
25
25
  </nav>
26
26
 
27
27
  <div class="shrink-0 border-t border-slate-200 px-5 py-4 dark:border-slate-800 sm:px-8 lg:px-5">
28
- <button type="button" data-js-admin-theme-target="toggle" data-action="js-admin-theme#toggle" class="inline-flex h-9 w-full items-center justify-between rounded-md border border-slate-300 bg-white px-3 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800" aria-label="Toggle dark mode" aria-pressed="false">
28
+ <button type="button" data-js-admin-theme-target="toggle" data-action="js-admin-theme#toggle" class="inline-flex h-9 w-full items-center justify-between rounded-md border border-slate-300 bg-white px-3 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800" aria-label="<%= t("js_admin.theme.toggle_aria_label") %>" aria-pressed="false">
29
29
  <span class="flex items-center gap-2">
30
30
  <span class="h-2.5 w-2.5 rounded-full bg-slate-500 dark:bg-amber-300"></span>
31
- <span>Theme</span>
31
+ <span><%= t("js_admin.theme.label") %></span>
32
32
  </span>
33
- <span class="text-xs text-slate-400 dark:text-slate-500">Toggle</span>
33
+ <span class="text-xs text-slate-400 dark:text-slate-500"><%= t("js_admin.theme.toggle") %></span>
34
34
  </button>
35
35
  </div>
36
36
  </aside>
@@ -0,0 +1,65 @@
1
+ en:
2
+ js_admin:
3
+ configuration:
4
+ errors:
5
+ incomplete_basic_auth: "Basic Auth username and password must both be configured."
6
+ multiple_authentication_strategies: "Basic Auth and authenticate_with cannot be configured at the same time."
7
+ authorization_without_authentication: "authorize_with requires authenticate_with."
8
+ generator:
9
+ options:
10
+ path: "Mount path for JSAdmin"
11
+ warnings:
12
+ importmap_missing: "config/importmap.rb was not found. Run bin/rails importmap:install, then rerun js_admin:install."
13
+ titles:
14
+ models: "JSAdmin Models"
15
+ new: "New %{resource}"
16
+ edit: "Edit %{record}"
17
+ navigation:
18
+ home: "Home"
19
+ models:
20
+ heading: "Models"
21
+ available:
22
+ one: "%{formatted_count} model available"
23
+ other: "%{formatted_count} models available"
24
+ resources:
25
+ notices:
26
+ created: "%{resource} was created."
27
+ updated: "%{resource} was updated."
28
+ deleted: "%{resource} was deleted."
29
+ records:
30
+ one: "%{formatted_count} record"
31
+ other: "%{formatted_count} records"
32
+ headings:
33
+ new: "New %{resource}"
34
+ edit: "Edit %{record}"
35
+ actions:
36
+ create: "Create %{resource}"
37
+ update: "Update %{resource}"
38
+ new: "New %{resource}"
39
+ view: "View"
40
+ edit: "Edit"
41
+ delete: "Delete"
42
+ cancel: "Cancel"
43
+ table:
44
+ actions: "Actions"
45
+ no_records: "No records"
46
+ delete_confirmation: "Delete this record?"
47
+ pagination:
48
+ label: "Pagination"
49
+ first: "First"
50
+ previous: "Previous"
51
+ next: "Next"
52
+ last: "Last"
53
+ status: "Page %{page} of %{total_pages}"
54
+ theme:
55
+ label: "Theme"
56
+ toggle: "Toggle"
57
+ toggle_aria_label: "Toggle dark mode"
58
+ values:
59
+ null: "NULL"
60
+ "true": "true"
61
+ "false": "false"
62
+ resource:
63
+ fallback_display_name: "%{resource} #%{id}"
64
+ errors:
65
+ unknown_resource: "Unknown JSAdmin resource: %{model_id}"
@@ -0,0 +1,65 @@
1
+ ko:
2
+ js_admin:
3
+ configuration:
4
+ errors:
5
+ incomplete_basic_auth: "Basic Auth username과 password를 모두 설정해야 합니다."
6
+ multiple_authentication_strategies: "Basic Auth와 authenticate_with는 동시에 설정할 수 없습니다."
7
+ authorization_without_authentication: "authorize_with를 사용하려면 authenticate_with가 필요합니다."
8
+ generator:
9
+ options:
10
+ path: "JSAdmin mount 경로"
11
+ warnings:
12
+ importmap_missing: "config/importmap.rb를 찾을 수 없습니다. bin/rails importmap:install 실행 후 js_admin:install을 다시 실행하세요."
13
+ titles:
14
+ models: "JSAdmin 모델"
15
+ new: "새 %{resource}"
16
+ edit: "%{record} 편집"
17
+ navigation:
18
+ home: "홈"
19
+ models:
20
+ heading: "모델"
21
+ available:
22
+ one: "사용 가능한 모델 %{formatted_count}개"
23
+ other: "사용 가능한 모델 %{formatted_count}개"
24
+ resources:
25
+ notices:
26
+ created: "%{resource} 레코드를 생성했습니다."
27
+ updated: "%{resource} 레코드를 수정했습니다."
28
+ deleted: "%{resource} 레코드를 삭제했습니다."
29
+ records:
30
+ one: "레코드 %{formatted_count}개"
31
+ other: "레코드 %{formatted_count}개"
32
+ headings:
33
+ new: "새 %{resource}"
34
+ edit: "%{record} 편집"
35
+ actions:
36
+ create: "%{resource} 생성"
37
+ update: "%{resource} 저장"
38
+ new: "새 %{resource}"
39
+ view: "보기"
40
+ edit: "편집"
41
+ delete: "삭제"
42
+ cancel: "취소"
43
+ table:
44
+ actions: "작업"
45
+ no_records: "레코드가 없습니다"
46
+ delete_confirmation: "이 레코드를 삭제하시겠습니까?"
47
+ pagination:
48
+ label: "페이지 탐색"
49
+ first: "처음"
50
+ previous: "이전"
51
+ next: "다음"
52
+ last: "마지막"
53
+ status: "전체 %{total_pages}페이지 중 %{page}페이지"
54
+ theme:
55
+ label: "테마"
56
+ toggle: "전환"
57
+ toggle_aria_label: "다크 모드 전환"
58
+ values:
59
+ null: "NULL"
60
+ "true": "참"
61
+ "false": "거짓"
62
+ resource:
63
+ fallback_display_name: "%{resource} #%{id}"
64
+ errors:
65
+ unknown_resource: "알 수 없는 JSAdmin 리소스입니다: %{model_id}"
@@ -16,7 +16,7 @@ module JSAdmin
16
16
  class_option :path,
17
17
  type: :string,
18
18
  default: "/admin",
19
- desc: "Mount path for JSAdmin"
19
+ desc: I18n.t("js_admin.generator.options.path")
20
20
 
21
21
  def mount_engine
22
22
  route %(mount JSAdmin::Engine => "#{options[:path]}")
@@ -36,10 +36,17 @@ module JSAdmin
36
36
  # Methods tried in order when displaying records in labels and associations.
37
37
  # config.display_name_methods = %i[name title email username id]
38
38
 
39
+ # Render a column as a select. Callables are evaluated on every form render.
40
+ # config.select_options "Post", category: -> { Category.pluck(:name) }
41
+
39
42
  # Basic Auth is disabled unless both username and password are present.
40
43
  # config.basic_auth_username = ENV["JS_ADMIN_BASIC_AUTH_USERNAME"]
41
44
  # config.basic_auth_password = ENV["JS_ADMIN_BASIC_AUTH_PASSWORD"]
42
45
  # config.basic_auth_realm = "JSAdmin"
46
+
47
+ # Or delegate authentication and authorization to the host application.
48
+ # config.authenticate_with { authenticate_user! }
49
+ # config.authorize_with { current_user&.admin? }
43
50
  end
44
51
  RUBY
45
52
  end
@@ -48,7 +55,7 @@ module JSAdmin
48
55
  importmap_path = application_path("config/importmap.rb")
49
56
 
50
57
  unless File.exist?(importmap_path)
51
- say_status :warning, "config/importmap.rb was not found. Run bin/rails importmap:install, then rerun js_admin:install.", :yellow
58
+ say_status :warning, I18n.t("js_admin.generator.warnings.importmap_missing"), :yellow
52
59
  return
53
60
  end
54
61
 
@@ -5,19 +5,25 @@ module JSAdmin
5
5
  :records_per_page,
6
6
  :association_limit,
7
7
  :display_name_methods,
8
+ :field_select_options,
8
9
  :basic_auth_username,
9
10
  :basic_auth_password,
10
11
  :basic_auth_realm
11
12
 
13
+ attr_reader :authentication_callback, :authorization_callback
14
+
12
15
  def initialize
13
16
  @included_models = []
14
17
  @excluded_models = []
15
18
  @records_per_page = 10
16
19
  @association_limit = 100
17
20
  @display_name_methods = %i[name title email username id]
21
+ @field_select_options = {}
18
22
  @basic_auth_username = nil
19
23
  @basic_auth_password = nil
20
24
  @basic_auth_realm = "JSAdmin"
25
+ @authentication_callback = nil
26
+ @authorization_callback = nil
21
27
  end
22
28
 
23
29
  def include_models(*models)
@@ -28,12 +34,72 @@ module JSAdmin
28
34
  excluded_models.concat(normalize_model_names(models))
29
35
  end
30
36
 
37
+ def select_options(model, **options_by_field)
38
+ model_options = field_select_options[normalize_model_names([ model ]).first] ||= {}
39
+ model_options.merge!(options_by_field.transform_keys(&:to_s))
40
+ end
41
+
42
+ def select_options_for(model, field_name)
43
+ field_select_options.dig(normalize_model_names([ model ]).first, field_name.to_s)
44
+ end
45
+
31
46
  def basic_auth_enabled?
32
- basic_auth_username.to_s != "" && basic_auth_password.to_s != ""
47
+ basic_auth_username_configured? && basic_auth_password_configured?
48
+ end
49
+
50
+ def authenticate_with(&callback)
51
+ @authentication_callback = callback
52
+ end
53
+
54
+ def authorize_with(&callback)
55
+ @authorization_callback = callback
56
+ end
57
+
58
+ def authentication_strategy
59
+ validate!
60
+
61
+ return :callback if authentication_callback
62
+ return :basic_auth if basic_auth_enabled?
63
+
64
+ :none
65
+ end
66
+
67
+ def validate!
68
+ validate_basic_auth_credentials!
69
+ validate_authentication_strategy!
70
+ validate_authorization_callback!
71
+
72
+ self
33
73
  end
34
74
 
35
75
  private
36
76
 
77
+ def validate_basic_auth_credentials!
78
+ return if basic_auth_username_configured? == basic_auth_password_configured?
79
+
80
+ raise ConfigurationError, I18n.t("js_admin.configuration.errors.incomplete_basic_auth")
81
+ end
82
+
83
+ def validate_authentication_strategy!
84
+ return unless basic_auth_enabled? && authentication_callback
85
+
86
+ raise ConfigurationError, I18n.t("js_admin.configuration.errors.multiple_authentication_strategies")
87
+ end
88
+
89
+ def validate_authorization_callback!
90
+ return unless authorization_callback && !authentication_callback
91
+
92
+ raise ConfigurationError, I18n.t("js_admin.configuration.errors.authorization_without_authentication")
93
+ end
94
+
95
+ def basic_auth_username_configured?
96
+ basic_auth_username.to_s != ""
97
+ end
98
+
99
+ def basic_auth_password_configured?
100
+ basic_auth_password.to_s != ""
101
+ end
102
+
37
103
  def normalize_model_names(models)
38
104
  models.flatten.map { |model| model.is_a?(Class) ? model.name : model.to_s }
39
105
  end
@@ -0,0 +1,4 @@
1
+ module JSAdmin
2
+ class ConfigurationError < StandardError
3
+ end
4
+ end
@@ -36,6 +36,7 @@ module JSAdmin
36
36
  end
37
37
 
38
38
  def input_kind
39
+ return :configured_select if configured_select?
39
40
  return :association_select if belongs_to_association
40
41
  return :enum_select if enum?
41
42
 
@@ -65,6 +66,15 @@ module JSAdmin
65
66
  resource.model_class.defined_enums.fetch(name, {}).keys
66
67
  end
67
68
 
69
+ def configured_select?
70
+ !configured_select_source.nil?
71
+ end
72
+
73
+ def configured_select_values
74
+ source = configured_select_source
75
+ Array(source.respond_to?(:call) ? source.call : source)
76
+ end
77
+
68
78
  def belongs_to_association
69
79
  @belongs_to_association ||= resource.model_class
70
80
  .reflect_on_all_associations(:belongs_to)
@@ -98,11 +108,11 @@ module JSAdmin
98
108
 
99
109
  case value
100
110
  when nil
101
- "NULL"
111
+ I18n.t("js_admin.values.null")
102
112
  when true
103
- "true"
113
+ I18n.t("js_admin.values.true")
104
114
  when false
105
- "false"
115
+ I18n.t("js_admin.values.false")
106
116
  else
107
117
  value.to_s
108
118
  end
@@ -110,6 +120,10 @@ module JSAdmin
110
120
 
111
121
  private
112
122
 
123
+ def configured_select_source
124
+ JSAdmin.configuration.select_options_for(resource.model_class, name)
125
+ end
126
+
113
127
  def inheritance_column?
114
128
  name == resource.model_class.inheritance_column
115
129
  end
@@ -7,7 +7,10 @@ module JSAdmin
7
7
 
8
8
  def resource_for!(model_id)
9
9
  resources.find { |resource| resource.model_id == model_id } ||
10
- raise(ActiveRecord::RecordNotFound, "Unknown JSAdmin resource: #{model_id}")
10
+ raise(
11
+ ActiveRecord::RecordNotFound,
12
+ I18n.t("js_admin.errors.unknown_resource", model_id: model_id)
13
+ )
11
14
  end
12
15
 
13
16
  def reset!
@@ -69,7 +69,11 @@ module JSAdmin
69
69
 
70
70
  return record.public_send(display_method).to_s if display_method
71
71
 
72
- "#{singular_label} ##{record.id}"
72
+ I18n.t(
73
+ "js_admin.resource.fallback_display_name",
74
+ resource: singular_label,
75
+ id: record.id
76
+ )
73
77
  end
74
78
 
75
79
  def next_page?(page)
@@ -1,3 +1,3 @@
1
1
  module JSAdmin
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/js_admin.rb CHANGED
@@ -5,6 +5,7 @@ require "stimulus-rails"
5
5
  require "tailwindcss-rails"
6
6
  require "turbo-rails"
7
7
 
8
+ require "js_admin/configuration_error"
8
9
  require "js_admin/configuration"
9
10
  require "js_admin/field"
10
11
  require "js_admin/registry"
@@ -19,6 +20,7 @@ module JSAdmin
19
20
 
20
21
  def configure
21
22
  yield configuration
23
+ configuration.validate!
22
24
  Registry.reset!
23
25
  end
24
26
 
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: js_admin
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - JoungSik
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-07-17 00:00:00.000000000 Z
10
+ date: 2026-10-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rails
@@ -150,10 +150,13 @@ files:
150
150
  - app/views/layouts/js_admin/_flash_messages.html.erb
151
151
  - app/views/layouts/js_admin/_sidebar.html.erb
152
152
  - app/views/layouts/js_admin/application.html.erb
153
+ - config/locales/js_admin.en.yml
154
+ - config/locales/js_admin.ko.yml
153
155
  - config/routes.rb
154
156
  - lib/generators/js_admin/install/install_generator.rb
155
157
  - lib/js_admin.rb
156
158
  - lib/js_admin/configuration.rb
159
+ - lib/js_admin/configuration_error.rb
157
160
  - lib/js_admin/engine.rb
158
161
  - lib/js_admin/field.rb
159
162
  - lib/js_admin/registry.rb