usernames 0.1.3 → 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 +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +154 -7
- data/app/assets/javascripts/usernames/index.js +16 -0
- data/app/assets/javascripts/usernames/username_check_controller.js +191 -0
- data/app/controllers/usernames/availability_controller.rb +177 -0
- data/config/importmap.rb +8 -0
- data/config/locales/usernames.en.yml +6 -2
- data/config/locales/usernames.es.yml +2 -0
- data/config/locales/usernames.pt.yml +2 -0
- data/lib/generators/usernames/availability_generator.rb +85 -24
- data/lib/generators/usernames/install_generator.rb +24 -0
- data/lib/generators/usernames/install_messages.rb +41 -2
- data/lib/generators/usernames/javascript_generator.rb +38 -0
- data/lib/generators/usernames/javascript_setup.rb +124 -0
- data/lib/generators/usernames/templates/availability/_field.html.erb.tt +5 -15
- data/lib/generators/usernames/templates/usernames.rb +8 -2
- data/lib/usernames/configuration.rb +93 -3
- data/lib/usernames/engine.rb +74 -13
- data/lib/usernames/form_helpers.rb +215 -0
- data/lib/usernames/locale.rb +39 -0
- data/lib/usernames/model.rb +137 -26
- data/lib/usernames/reserved/listing.rb +1 -0
- data/lib/usernames/reserved.rb +18 -8
- data/lib/usernames/routes.rb +26 -0
- data/lib/usernames/rules/options.rb +9 -1
- data/lib/usernames/rules.rb +9 -1
- data/lib/usernames/targets.rb +65 -0
- data/lib/usernames/unique_violation.rb +0 -1
- data/lib/usernames/version.rb +1 -1
- data/lib/usernames.rb +3 -0
- data/sig/usernames.rbs +33 -6
- metadata +11 -3
- data/lib/generators/usernames/templates/availability/availability_controller.rb.tt +0 -107
- data/lib/generators/usernames/templates/availability/username_check_controller.js.tt +0 -115
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ec6362d06fa7d1d326142514846d8bff7919e3fd6a2d7987819c7d6161b29f2
|
|
4
|
+
data.tar.gz: ce53042c9f1116fa5e98bdf46291c0f212d2b9aa27ad4455c7ba055169cf8764
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74ed5ba9b1994027c8fbabc46dd2fe40871482dfce974df4082ca8029c15a33af1a83850f7bd6470443a0e33d0e2dd8081b7e94f8d817628906955e7b74f478c
|
|
7
|
+
data.tar.gz: 0ed17f0406d8eff7d2155865010bec6d60dba7ff3b7ef0e97526e8e7d27317eae47a6fe36d64518f2e856dff57bdb7f43ee49c7f8726468e8ae2a1e7104fd4d7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-10-27
|
|
4
|
+
|
|
5
|
+
The live availability check moves into the gem. In 0.1 it was a generator that copied a controller, a route, a Stimulus controller and a partial into every app, each yours to keep in step with the gem; now the engine ships all of it, and in an app on the conventional importmap + Stimulus setup a username field that checks names as people type is four lines: `gem "usernames"`, `has_username :handle`, `<%= f.username_field :handle %> <%= f.username_status :handle %>` and `import "usernames"` — with no copied username implementation left in the app. `has_username` also learns three options that apps were writing by hand around it.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **The availability endpoint**, `Usernames::AvailabilityController`, at `GET /usernames/availability?username=…&target=…`, answering `{ state: "free" | "taken" | "invalid", message: }` in the model's own words. The guard rails of the 0.1 template come with it: Rails' `rate_limit` (`config.availability.rate_limit`, on a counter of its own, apart from any limiter the parent controller declares), taken, reserved and disallowed names answered alike as "taken", the server's own normaliser first, and `Cache-Control: no-store` on every answer — 404s, 429s and 503s included.
|
|
10
|
+
- **Targets: an explicit allowlist** of what the endpoint answers for, `config.availability.targets = { "user" => "User#handle" }`, which the installer writes. A request names an alias; only a configured alias is looked up, and the class it names is resolved from your configuration on every request — never from what a client sent — so declaring `has_username` on a model never publishes it. An unknown alias is a 404 (rate limited like any request); a configured one that no longer resolves raises `Usernames::ConfigurationError` instead of answering.
|
|
11
|
+
- **A route drawn by the engine**, appended after yours as `usernames_availability` at `config.availability.path` (`nil` draws none), without a format, and skipped when you draw one of that name yourself. `usernames_availability` is also a routing method, for putting it above a catch-all such as `get "*path"`, which would otherwise swallow it. Its first segment is reserved like any of your routes.
|
|
12
|
+
- **Form helpers.** `f.username_field :handle` renders the input with the `username-check` controller on it — the rules (messages in the model's words), the case mode, the saved name and the endpoint — and `f.username_status :handle` the `<output aria-live="polite">` it writes to. `availability: false` keeps the format hints without a request; `static: true` is a plain field. `class`, `data-controller`, `data-action` and `aria-describedby` join the field's own; ids follow `field_id`, namespaces and indexes included. A field for a column no target names raises in development and test, and renders plain (logged once) in production.
|
|
13
|
+
- **JavaScript shipped by the gem**, in `app/assets/javascripts/usernames/`: the Stimulus controller, and `index.js`, which registers it on stimulus-rails' exported `application` — deterministically, once. With importmap-rails the engine pins `usernames` and `usernames/username-check` (before importmap draws its map); Propshaft and Sprockets serve both files, and Sprockets is told to precompile them. Without importmap-rails, or without an asset pipeline, the engine adds nothing. The controller shows `checking`, `free`, `taken`, `invalid`, `unchanged` or `error` in the status's `data-state`; a request generation counter and aborts make sure only the latest answer is shown (A, then B, then A again included), a 429, 503, network failure, unreadable answer or no answer within the `timeout` value (8 seconds, headers and body together) ends in `error` with a localised message (`usernames.availability.error`, in English, Spanish and Portuguese), and the status resets on disconnect, on form reset and before Turbo caches the page. It still never blocks submission.
|
|
14
|
+
- **`config.reserved_if`**: a callable asked last, after every word list, about the name as normalised — `->(name) { Page.exists?(slug: name) }` — so names your data reserves (page slugs, organisations) are refused as `is reserved`, everywhere a reserved name is. `nil` by default, checked at boot to answer `call`, also accepted per model; `config.allowed` still lifts it, and `rails usernames:reserved` mentions it.
|
|
15
|
+
- **`required: { on: :profile }`** asks for a name whenever the record is validated in that context (or one of several), on every save there — including an account whose name was blank before the gem arrived, since that form is where they type one. Outside it, nothing changes.
|
|
16
|
+
- **`seed: :first_name`** (or a proc): a generated name starts from that value — `ana`, then `ana_42`, then the default shape when it is blank or every form of it is taken. It is read once per save and reused by a redraw after a lost race; an error raised in it is the app's.
|
|
17
|
+
- **`clearable: false`** refuses saving a saved name blank again (`:blank`), required or not. It is not immutability, leaves stored blanks alone, and — being a validation — is skipped by `update_column` and `update_all`.
|
|
18
|
+
- **`config.availability.parent_controller`** (default: your `ApplicationController` when there is one, so its locale and authentication callbacks apply; `"ActionController::API"` works) and **`config.availability.rate_limit_store`** (default `Rails.cache`, read once when the endpoint's class loads).
|
|
19
|
+
- **`rails generate usernames:javascript`** copies the Stimulus controller into `app/javascript/controllers` for apps that bundle their JavaScript, where `import "usernames"` has nothing to resolve it.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **`rails generate usernames:install`** writes `config.availability.targets` for its `--model`/`--column`, and in an importmap app whose `controllers/application.js` exports the Stimulus application it appends `import "usernames"` to `app/javascript/application.js` — once, never removing anything — saying why not otherwise. It ends with the form line, the production cache store and — when Devise is loaded — the two `devise_parameter_sanitizer.permit` lines that keep Devise from dropping the field.
|
|
24
|
+
- **`rails generate usernames:availability`** no longer copies anything by default: it explains that the check is built in, and lists the files a 0.1 install copied, with the two ways to upgrade. `--copy` writes a set of your own — the endpoint (replacing the gem's, same class), the Stimulus controller, a partial built on the helpers, and the route at the top of `config/routes.rb` — all speaking the same protocol.
|
|
25
|
+
- **A rate limit store that can't count is a 503 everywhere but development and test.** 0.1 answered 503 only in production and only for a `NullStore`; the endpoint now notices any store whose `increment` returns nothing or fails (a cache whose backend is down, a wrapper around a `NullStore`), and refuses in staging too. In development and test it proceeds unlimited and logs once.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **A new record saved in a validation context of its own got no generated name.** Generation ran `on: :create`, which an explicit context (`save(context: :import)`) replaces, so such records were saved without one. It now runs for every new record whose name is blank, whatever the context.
|
|
30
|
+
|
|
31
|
+
### Upgrading from 0.1
|
|
32
|
+
|
|
33
|
+
Nothing changes for a model with `has_username` and no availability check. If you ran `rails generate usernames:availability` under 0.1:
|
|
34
|
+
|
|
35
|
+
- **Your copied files speak the 0.1 protocol, and they win over the gem's.** Your controller reads `params[:<column>]` and asks `current_user`; the gem's helpers send `username` and `target`. Pick one mode — `rails generate usernames:availability` lists what it finds. *Managed* (recommended): delete the controller, the `usernames/availability` route, `app/javascript/controllers/username_check_controller.js` and `app/views/usernames/_field.html.erb`; add `config.availability.targets = { "user" => "User#username" }`, replace the partial with `f.username_field` / `f.username_status`, and add `import "usernames"`. *Customized*: keep your pair, set `config.availability.path = nil`, and don't `import "usernames"` (both would register `username-check`).
|
|
36
|
+
- **"Already your name" is the browser's call now.** The endpoint no longer asks who is signed in: the field carries the record's saved name and shows `unchanged` without asking. A server-side `current_user` check in a copied controller is yours to keep or drop.
|
|
37
|
+
- **Staging needs a cache store that counts.** See *Changed*: a `NullStore` there now turns the endpoint off (503) rather than letting it run unlimited.
|
|
38
|
+
- **Devise drops fields it doesn't know.** If people choose their name at sign-up, permit it: `devise_parameter_sanitizer.permit(:sign_up, keys: [:handle])` and `:account_update`.
|
|
39
|
+
- **New locale key** `usernames.availability.error` ("couldn't be checked just now"; Spanish and Portuguese ship). In a language the gem doesn't ship, it and `usernames.available` read in English — your app's own English wording, if it has one, else the gem's, even in an app whose available locales leave English out — until you add them to that language's locale file. They never read "Translation missing", with or without I18n fallbacks.
|
|
40
|
+
- **A new record saved with a custom validation context now gets a generated name** (see *Fixed*). If you relied on it staying blank, pass `generate: false` or a condition.
|
|
41
|
+
- `clearable:` defaults to `true`: names can still be cleared, as in 0.1. Pass `clearable: false` to stop that.
|
|
42
|
+
|
|
3
43
|
## [0.1.3] - 2026-10-27
|
|
4
44
|
|
|
5
45
|
### Changed
|
data/README.md
CHANGED
|
@@ -60,7 +60,7 @@ rails generate usernames:install
|
|
|
60
60
|
rails db:migrate
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
The generator writes `config/initializers/usernames.rb` (ten options, all commented), adds a `username` column to `users` if there isn't one, and adds **the one unique index the gem needs** — with the right collation, so the database agrees with Ruby about what "the same name" means (see [Uniqueness, case and collation](#uniqueness-case-and-collation)).
|
|
63
|
+
The generator writes `config/initializers/usernames.rb` (ten options, all commented, and one live line that publishes the column to the [live availability check](#live-availability-check)), adds a `username` column to `users` if there isn't one, and adds **the one unique index the gem needs** — with the right collation, so the database agrees with Ruby about what "the same name" means (see [Uniqueness, case and collation](#uniqueness-case-and-collation)).
|
|
64
64
|
|
|
65
65
|
Then, in your model:
|
|
66
66
|
|
|
@@ -81,6 +81,24 @@ end
|
|
|
81
81
|
> [!IMPORTANT]
|
|
82
82
|
> **Already have users?** `has_username` only checks a username **when it changes**, so adding the gem never stops an existing account from saving its other attributes. But Rails' `normalizes` doesn't rewrite rows already in the database, and a stored `JAVI` next to a new `javi` would be two identities. Run `rails usernames:audit` first and read [Adopting an existing column](#adopting-an-existing-column) — it's a five-minute, read-only check.
|
|
83
83
|
|
|
84
|
+
## Quickstart: a username field in four lines
|
|
85
|
+
|
|
86
|
+
Once the column is installed and migrated (and audited, if it already held names), this is the whole integration for a `User` with a `handle`, in an app on the conventional importmap + Stimulus setup:
|
|
87
|
+
|
|
88
|
+
```text
|
|
89
|
+
gem "usernames" # Gemfile
|
|
90
|
+
has_username :handle, case: :preserve # app/models/user.rb
|
|
91
|
+
<%= f.label :handle %> <%= f.username_field :handle %> <%= f.username_status :handle %> # any form for a User
|
|
92
|
+
import "usernames" // app/javascript/application.js
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
(`rails generate usernames:install --column=handle --case=preserve` writes the migration, adds the `import` line, and publishes `User#handle` to the live check in the initializer.)
|
|
96
|
+
|
|
97
|
+
As someone types, the field shows the first format rule the name breaks — the same rules and the same words as the server, with no round trip — then, once typing pauses, whether the name is free. Their own saved name is simply *unchanged*. Nothing in the browser blocks submission: the model and its unique index decide.
|
|
98
|
+
|
|
99
|
+
No copied username implementation lives in your app: the endpoint, its route, the Stimulus controller and the form helpers are the gem's, and upgrade with it. What stays yours is what is genuinely about your app — your forms, your labels and styling, and, with Devise, the one line that permits the field (see [With Devise](#with-devise)). Not on importmap, or no Stimulus? See [How it is wired](#how-it-is-wired-and-how-to-turn-each-piece-off).
|
|
100
|
+
|
|
101
|
+
|
|
84
102
|
## What it checks, in order
|
|
85
103
|
|
|
86
104
|
The first failing rule names the error. Messages never contain what the user typed, so they can never echo abuse back or trip I18n on a `%{` someone pasted.
|
|
@@ -129,6 +147,18 @@ end
|
|
|
129
147
|
| 2 | `config.reserved`, `config.forbidden` | reserved |
|
|
130
148
|
| 3 | your routes | reserved |
|
|
131
149
|
| 4 | the bundled lists | reserved |
|
|
150
|
+
| 5 | `config.reserved_if` | reserved |
|
|
151
|
+
|
|
152
|
+
**Names your data reserves.** A slug your pages already use, a team or an organisation that shares the URL space — anything that isn't a fixed list — is one callable, asked last, only about names every list has let through:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
Usernames.configure do |config|
|
|
156
|
+
config.reserved_if = ->(name) { Page.exists?(slug: name) || Organization.exists?(handle: name) }
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
It receives the name as normalised (lowercased in the default mode, case kept with `case: :preserve`), and a truthy answer refuses it as `is reserved` — in validation, in `Usernames.check`, and as "taken" in the availability check. `config.allowed` still lifts it. What it raises is your app's error, not the gem's; it runs on every check that reaches it, so keep it to one indexed query.
|
|
161
|
+
|
|
132
162
|
|
|
133
163
|
Your own official account, under a name the gem reserves? Lift exactly that name — `config.allowed += %w[yourapp]` — and register it yourself before launch (a seed, or the console). It frees that exact name only: `yourapp2` and `your.app` stay reserved. `has_username` deliberately has no per-record skip: whether a name may be held is a fact about the name, and a skip that hangs off the record is one `update` away from letting someone else keep `admin`. `rails usernames:reserved` prints the effective list with the source of every word, for when you wonder why `x` is reserved.
|
|
134
164
|
|
|
@@ -190,7 +220,7 @@ Alternatively, `case: :preserve` leaves stored case alone; the audit will tell y
|
|
|
190
220
|
|
|
191
221
|
## Generated names and suggestions
|
|
192
222
|
|
|
193
|
-
Every new account has a name from day one. A profile with no name is a row that renders as plain text everywhere and has no page — so `has_username` assigns one
|
|
223
|
+
Every new account has a name from day one. A profile with no name is a row that renders as plain text everywhere and has no page — so `has_username` assigns one to a new record whose field is blank, whatever validation context it is saved in. (Only to new records: an existing account may still clear its username, unless you pass `required: true` or `clearable: false`.)
|
|
194
224
|
|
|
195
225
|
```ruby
|
|
196
226
|
User.create!(email: "ana@example.com").username # => "quiet_otter_42"
|
|
@@ -207,6 +237,32 @@ has_username required: -> { invited? }, generate: false
|
|
|
207
237
|
|
|
208
238
|
By default a name is generated exactly where none is required, record by record. An explicit `generate:` is honoured as given: `required: true, generate: true` fills a blank on create and refuses clearing it later.
|
|
209
239
|
|
|
240
|
+
A profile form that asks for the name, while sign-up hands one out? Require it in that validation context only:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
has_username required: { on: :profile } # or { on: %i[profile onboarding] }
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
.save(context: :profile) # in the profile controller: the name can't be blank here
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Presence is then checked on every save in that context — even for an account whose name was blank before the gem arrived, since that form is where they type one. Every other save works as without it. The context has to come from your controller: a form can't set it.
|
|
251
|
+
|
|
252
|
+
**Keeping a name.** `clearable: false` refuses saving a saved name blank again (`can't be blank`), required or not — a username other people link to stays one. It is not immutability: changing it to another name is fine. A stored blank was never a name, so it saves as before, and `update_column` / `update_all` skip it like any validation.
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
has_username clearable: false
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
**Starting from the person's name.** `seed:` names a method (or a proc) whose value a generated name starts from:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
has_username seed: :first_name # Ana → "ana", or "ana_42" if taken, or "quiet_otter_42" when first_name is blank
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
It is read once per save, cleaned and cut at word boundaries the way `generate_username(from:)` does it below; a redraw after a lost race reuses it, and an error raised in it is your app's. Whatever it returns becomes part of a **public** name: `seed: :email` publishes the local part of people's addresses (`ana+news.com` → `ana`), which is exactly what the gem never does on its own.
|
|
265
|
+
|
|
210
266
|
From a real name:
|
|
211
267
|
|
|
212
268
|
```ruby
|
|
@@ -240,13 +296,104 @@ Whatever you return still goes through the validator and the redraw logic.
|
|
|
240
296
|
|
|
241
297
|
## Live availability check
|
|
242
298
|
|
|
299
|
+
```erb
|
|
300
|
+
<%= form_with model: @user do |f| %>
|
|
301
|
+
<%= f.label :handle %>
|
|
302
|
+
<%= f.username_field :handle %>
|
|
303
|
+
<%= f.username_status :handle %>
|
|
304
|
+
<% end %>
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
`f.username_field` is a text field (`autocomplete="username"`, no autocapitalise or autocorrect — pass any option to change them) that the gem's `username-check` Stimulus controller watches. `f.username_status` is the `<output for="user_handle" aria-live="polite">` it writes to, so screen readers hear the answer too. The status gets a `data-state` to style: `checking`, `free`, `taken`, `invalid` (a format rule, said at once, in the model's own words), `unchanged` (the record's saved name, typed again in any case — not asked about), or `error` (the answer didn't come: offline, rate limited, switched off, or not within eight seconds). Nothing blocks submission: no `pattern`, no `setCustomValidity`, no `maxlength` (it would cut a pasted `@name` before normalisation; pass `maxlength:` if you want one).
|
|
308
|
+
|
|
309
|
+
```erb
|
|
310
|
+
<%= f.username_field :handle, availability: false %> <%# format hints only, no request %>
|
|
311
|
+
<%= f.username_field :handle, static: true %> <%# a plain text field %>
|
|
312
|
+
<%= f.username_field :handle, class: "input", data: { controller: "autosize" } %> <%# joined, not replaced %>
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`class`, `data-controller`, `data-action` and `aria-describedby` are joined with the field's own; ids come from `field_id` (`user_handle`, `user_handle_status`), so `namespace:` and `index:` work. Two forms for the same record on one page need different ids: give `form_with` a `namespace:`, or pass the same `id:` to both helpers.
|
|
316
|
+
|
|
317
|
+
The browser asks `GET /usernames/availability?username=javi&target=user` and gets `{ "state": "free" | "taken" | "invalid", "message": "…" }`. `target` is an alias from `config.availability.targets`, which the installer writes:
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
config.availability.targets = { "user" => "User#handle" } # alias => "Model#column"
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Only these are ever answered: anything else is a 404, and declaring `has_username` on a model never publishes it. A field for a column that isn't a target raises in development and test (and renders as a plain field, with one log line, in production). A single-table subclass's form uses its parent's target.
|
|
324
|
+
|
|
325
|
+
An availability endpoint tells anyone which names exist — that is its job — so it comes with guard rails:
|
|
326
|
+
|
|
327
|
+
- **Rate limited** with Rails' `rate_limit` (30 a minute per IP), counted in `Rails.cache`, on a counter of its own: a limiter your `ApplicationController` declares counts separately. If that store can't count — the `NullStore`, or a backend that's down — Rails' limiter silently lets everything through, so outside development and test the endpoint answers **503**, with a log line, until it can. In production that means a shared store: Solid Cache, Redis or Memcached. (A `MemoryStore` works, per process, with a warning.)
|
|
328
|
+
- **Uniform answers**: taken, reserved and disallowed names all answer "taken". No word list, matched word or reason is sent to the browser — though, like any availability check, it can't stop someone inferring a list by asking.
|
|
329
|
+
- **Never cached** (`Cache-Control: no-store`), 404s, 429s and 503s included, and advisory only: the model's validations and the unique index decide at save time.
|
|
330
|
+
- **Public by default.** It inherits your `ApplicationController` (when there is one), so its locale, host and authentication callbacks apply — the messages come in the language your app picked. It knows nothing about who is signed in; "unchanged" is the browser comparing against the saved name the field carries.
|
|
331
|
+
|
|
332
|
+
### How it is wired (and how to turn each piece off)
|
|
333
|
+
|
|
334
|
+
| Piece | What the gem does | Off switch |
|
|
335
|
+
|---|---|---|
|
|
336
|
+
| Endpoint | `Usernames::AvailabilityController`, inheriting `config.availability.parent_controller` (default: `ApplicationController` if defined, else `ActionController::Base`; `"ActionController::API"` works) | `config.availability.targets = {}` answers nothing |
|
|
337
|
+
| Route | appended after your routes, named `usernames_availability`, at `config.availability.path` (`/usernames/availability`), no format. Its first segment (`usernames`) is reserved like any of your routes | `config.availability.path = nil`, or draw a route of that name yourself |
|
|
338
|
+
| Rate limit | `config.availability.rate_limit = { to: 30, within: 1.minute }`, counted in `config.availability.rate_limit_store` (default `Rails.cache`, read once at boot) | — (point it at a store that counts) |
|
|
339
|
+
| Form helpers | `f.username_field` / `f.username_status` on every form builder | `availability: false`, `static: true`, or don't use them |
|
|
340
|
+
| JavaScript | with importmap-rails, the pins `usernames` and `usernames/username-check`; Propshaft and Sprockets serve them | leave out `import "usernames"` |
|
|
341
|
+
|
|
342
|
+
`import "usernames"` imports your `controllers/application` (stimulus-rails' generated file, which exports `application`) and registers `username-check` on it, once. Starting Stimulus some other way? Import the controller alone and register it yourself:
|
|
343
|
+
|
|
344
|
+
```js
|
|
345
|
+
import UsernameCheckController from "usernames/username-check"
|
|
346
|
+
application.register("username-check", UsernameCheckController)
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
**Catch-all routes.** The appended route comes after yours, so a catch-all such as `get "*path"` swallows it. Put it above the catch-all yourself, and the gem draws nothing more:
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
Rails.application.routes.draw do
|
|
353
|
+
usernames_availability # at the top level, before any catch-all
|
|
354
|
+
get "*path", to: "pages#show"
|
|
355
|
+
end
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
**No Stimulus** (or JavaScript off): the field is an ordinary text field, and the model still says why a name won't do on submit.
|
|
359
|
+
|
|
360
|
+
**jsbundling, esbuild, Vite…** `import "usernames"` needs importmap. Copy the controller instead, and register it as `username-check`:
|
|
361
|
+
|
|
243
362
|
```bash
|
|
244
|
-
rails generate usernames:
|
|
363
|
+
rails generate usernames:javascript # app/javascript/controllers/username_check_controller.js
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
The copy is then your app's: run the generator again after upgrading the gem.
|
|
367
|
+
|
|
368
|
+
**Customising the endpoint or the controller**: `rails generate usernames:availability --copy` writes the endpoint (it replaces the gem's — same class), the Stimulus controller, a form partial and the route into your app, all speaking the same protocol. Drop `import "usernames"` then: your Stimulus setup registers your copy.
|
|
369
|
+
|
|
370
|
+
### With Devise
|
|
371
|
+
|
|
372
|
+
Devise permits only its own attributes, so a `handle` field it doesn't know about is dropped — the form says the name is free, the save ignores it, and the account gets a generated one. Permit it where people choose or change it:
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
class ApplicationController < ActionController::Base
|
|
376
|
+
before_action :configure_permitted_parameters, if: :devise_controller?
|
|
377
|
+
|
|
378
|
+
private
|
|
379
|
+
|
|
380
|
+
def configure_permitted_parameters
|
|
381
|
+
devise_parameter_sanitizer.permit(:sign_up, keys: [:handle])
|
|
382
|
+
devise_parameter_sanitizer.permit(:account_update, keys: [:handle])
|
|
383
|
+
end
|
|
384
|
+
end
|
|
245
385
|
```
|
|
246
386
|
|
|
247
|
-
|
|
387
|
+
That's strong parameters, which are your app's decision: the gem never mass-assigns anything. Logging in with a username or an email is a recipe [below](#recipes).
|
|
388
|
+
|
|
389
|
+
### Upgrading from 0.1
|
|
390
|
+
|
|
391
|
+
0.1's `rails generate usernames:availability` copied a controller, a `usernames/availability` route, a Stimulus controller and a `_field` partial into your app. They speak the 0.1 protocol, and your app's copies win over the gem's, so pick one mode (`rails generate usernames:availability` lists what it finds):
|
|
392
|
+
|
|
393
|
+
- **Managed** (recommended): delete all four, add `config.availability.targets = { "user" => "User#username" }` to the initializer, replace the partial with `f.username_field` / `f.username_status`, and add `import "usernames"`. From then on the check upgrades with the gem.
|
|
394
|
+
- **Customized**: keep your pair, set `config.availability.path = nil` so the gem draws no route of its own (yours is named `usernames_availability`, so it would skip anyway), and don't `import "usernames"`.
|
|
248
395
|
|
|
249
|
-
|
|
396
|
+
The rest of 0.2.0 is listed in the [CHANGELOG](CHANGELOG.md#upgrading-from-01).
|
|
250
397
|
|
|
251
398
|
## Recipes
|
|
252
399
|
|
|
@@ -313,7 +460,7 @@ es:
|
|
|
313
460
|
username_reserved: "está reservado"
|
|
314
461
|
```
|
|
315
462
|
|
|
316
|
-
A per-model override reaches every place the gem speaks for that model: `User.check_username(value)` (`Usernames.check` with the model's rules and words) and `User.username_rules_for_browser` (the hints) look messages up the way the model's own errors do, and the
|
|
463
|
+
A per-model override reaches every place the gem speaks for that model: `User.check_username(value)` (`Usernames.check` with the model's rules and words) and `User.username_rules_for_browser` (the hints) look messages up the way the model's own errors do, and the availability check uses both, so the form, the hints and the endpoint say the same thing. Its own two messages are `usernames.available` ("is available") and `usernames.availability.error` ("couldn't be checked just now").
|
|
317
464
|
|
|
318
465
|
`rails generate usernames:locale --locale=fr` copies the English file for you to translate.
|
|
319
466
|
|
|
@@ -338,7 +485,7 @@ Usernames.configure do |config|
|
|
|
338
485
|
end
|
|
339
486
|
```
|
|
340
487
|
|
|
341
|
-
Less common, documented here rather than in the initializer: `first_character` (`:alphanumeric`, or `:letter` for the Snapchat/Telegram rule), `reserve_routes` (`true`), `app_name` (auto-detected; `false` to opt out), `offensive.adapters` (`[:moderate]`), `on_adapter_failure` (`:allow` logs and skips a raising adapter; `:reject` fails the save), `generator.adjectives` / `generator.nouns` / `generator` (a callable), `unique_index` (only if the generator didn't create yours). Every option is also accepted per model — `has_username :handle, length: 2..24` — and per validator call. `has_username` itself takes `required:` (`false`; `true`, a method name such as `required: :handle_required`,
|
|
488
|
+
Less common, documented here rather than in the initializer: `first_character` (`:alphanumeric`, or `:letter` for the Snapchat/Telegram rule), `reserve_routes` (`true`), `app_name` (auto-detected; `false` to opt out), `offensive.adapters` (`[:moderate]`), `on_adapter_failure` (`:allow` logs and skips a raising adapter; `:reject` fails the save), `generator.adjectives` / `generator.nouns` / `generator` (a callable), `unique_index` (only if the generator didn't create yours), `reserved_if` (`nil`; a callable, see [Reserved names](#reserved-names)). Every option is also accepted per model — `has_username :handle, length: 2..24` — and per validator call. `has_username` itself takes `required:` (`false`; `true`, a method name such as `required: :handle_required`, a proc, or `{ on: :profile }`), `generate:` (by default: wherever a name isn't required), `seed:` (a method name or a proc) and `clearable:` (`true`). The availability check reads `config.availability.targets` (`{}` until the installer writes yours), `.path` (`"/usernames/availability"`, `nil` for no route), `.parent_controller` (`nil`: your `ApplicationController`, else `ActionController::Base`), `.rate_limit` (`{ to: 30, within: 1.minute }`) and `.rate_limit_store` (`nil`: `Rails.cache`). An invalid value raises `Usernames::ConfigurationError` at boot, naming it.
|
|
342
489
|
|
|
343
490
|
## Data and licences
|
|
344
491
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// import "usernames" — in app/javascript/application.js, for the conventional
|
|
2
|
+
// importmap + Stimulus setup (stimulus-rails' controllers/application.js,
|
|
3
|
+
// which exports `application`). Registers the username-check controller on
|
|
4
|
+
// that application, once, after it exists: the import below orders it.
|
|
5
|
+
//
|
|
6
|
+
// An app that starts Stimulus some other way skips this file and registers
|
|
7
|
+
// the controller itself:
|
|
8
|
+
//
|
|
9
|
+
// import UsernameCheckController from "usernames/username-check"
|
|
10
|
+
// application.register("username-check", UsernameCheckController)
|
|
11
|
+
import { application } from "controllers/application"
|
|
12
|
+
import UsernameCheckController from "usernames/username-check"
|
|
13
|
+
|
|
14
|
+
application.register("username-check", UsernameCheckController)
|
|
15
|
+
|
|
16
|
+
export { UsernameCheckController }
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Live hints and an "is this name free?" check on a username field, from the
|
|
4
|
+
// usernames gem. `f.username_field :handle` puts it on the input itself;
|
|
5
|
+
// `f.username_status :handle` renders the <output> it writes to:
|
|
6
|
+
//
|
|
7
|
+
// <input data-controller="username-check" data-action="input->username-check#check"
|
|
8
|
+
// data-username-check-url-value="/usernames/availability"
|
|
9
|
+
// data-username-check-target-value="user"
|
|
10
|
+
// data-username-check-rules-value="[…User.handle_rules_for_browser…]"
|
|
11
|
+
// data-username-check-status-id-value="user_handle_status"
|
|
12
|
+
// aria-describedby="user_handle_status">
|
|
13
|
+
// <output id="user_handle_status" for="user_handle" aria-live="polite"></output>
|
|
14
|
+
//
|
|
15
|
+
// The status gets a data-state for styling: checking, free, taken, invalid,
|
|
16
|
+
// unchanged (the record's saved name, so nothing to ask), error (the answer
|
|
17
|
+
// didn't come: offline, rate limited, switched off) or "" (nothing to say).
|
|
18
|
+
//
|
|
19
|
+
// Everything here is a HINT. The browser only approximates the server's
|
|
20
|
+
// normalisation (Ruby's strip and JavaScript's trim() disagree on NUL, U+FEFF
|
|
21
|
+
// and U+1680), so nothing here blocks submission: no setCustomValidity, no
|
|
22
|
+
// pattern attribute. The model's validations and the unique index decide.
|
|
23
|
+
export default class extends Controller {
|
|
24
|
+
static values = {
|
|
25
|
+
// GET <url>?username=<typed>&target=<target>; no url, no request (format hints only).
|
|
26
|
+
url: String,
|
|
27
|
+
target: String,
|
|
28
|
+
rules: Array,
|
|
29
|
+
lowercase: { type: Boolean, default: true },
|
|
30
|
+
persisted: String,
|
|
31
|
+
statusId: String,
|
|
32
|
+
errorMessage: String,
|
|
33
|
+
delay: { type: Number, default: 300 },
|
|
34
|
+
// How long an answer may take, headers and body together, before it is an error.
|
|
35
|
+
timeout: { type: Number, default: 8000 }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
connect() {
|
|
39
|
+
this.patterns = this.rulesValue.map((rule) => ({ ...rule, regexp: this.compile(rule.pattern) }))
|
|
40
|
+
this.generation = 0
|
|
41
|
+
this.reset = this.reset.bind(this)
|
|
42
|
+
this.form = this.element.form
|
|
43
|
+
if (this.form) this.form.addEventListener("reset", this.reset)
|
|
44
|
+
document.addEventListener("turbo:before-cache", this.reset)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
disconnect() {
|
|
48
|
+
this.reset()
|
|
49
|
+
if (this.form) this.form.removeEventListener("reset", this.reset)
|
|
50
|
+
document.removeEventListener("turbo:before-cache", this.reset)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// On every keystroke: the saved name is simply unchanged; otherwise the
|
|
54
|
+
// first rule the name breaks, at once, and whether it is free once
|
|
55
|
+
// typing pauses. Every call starts a new generation: an answer for an
|
|
56
|
+
// older one is never shown, even for the same name typed again (A, B, A).
|
|
57
|
+
check() {
|
|
58
|
+
const typed = this.element.value
|
|
59
|
+
const generation = ++this.generation
|
|
60
|
+
this.cancel()
|
|
61
|
+
|
|
62
|
+
if (this.unchanged(typed)) return this.render({ state: "unchanged", message: "" })
|
|
63
|
+
|
|
64
|
+
const name = this.normalize(typed)
|
|
65
|
+
const broken = this.firstBrokenRule(name)
|
|
66
|
+
if (broken) return this.render({ state: "invalid", message: broken.message })
|
|
67
|
+
// Only names that could be registered are worth a request.
|
|
68
|
+
if (name === "" || !this.urlValue) return this.render(null)
|
|
69
|
+
|
|
70
|
+
this.render({ state: "checking", message: "" })
|
|
71
|
+
this.timer = setTimeout(() => this.ask(name, generation), this.delayValue)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async ask(name, generation) {
|
|
75
|
+
const request = new AbortController()
|
|
76
|
+
this.request = request
|
|
77
|
+
const url = new URL(this.urlValue, window.location.href)
|
|
78
|
+
url.searchParams.set("username", name)
|
|
79
|
+
if (this.targetValue) url.searchParams.set("target", this.targetValue)
|
|
80
|
+
|
|
81
|
+
// The deadline covers the headers and the body alike: a stalled
|
|
82
|
+
// connection, or a body that never finishes, ends in "error" too.
|
|
83
|
+
// Aborting rejects `abandoned`, so neither await can outlive it.
|
|
84
|
+
let timedOut = false
|
|
85
|
+
const abandoned = new Promise((_resolve, reject) => {
|
|
86
|
+
request.signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true })
|
|
87
|
+
})
|
|
88
|
+
abandoned.catch(() => {})
|
|
89
|
+
const deadline = setTimeout(() => {
|
|
90
|
+
timedOut = true
|
|
91
|
+
request.abort()
|
|
92
|
+
}, this.timeoutValue)
|
|
93
|
+
this.deadline = deadline
|
|
94
|
+
|
|
95
|
+
let answer = null
|
|
96
|
+
try {
|
|
97
|
+
const response = await Promise.race([fetch(url, {
|
|
98
|
+
headers: { Accept: "application/json" },
|
|
99
|
+
signal: request.signal,
|
|
100
|
+
credentials: "same-origin"
|
|
101
|
+
}), abandoned])
|
|
102
|
+
if (response.ok) answer = await Promise.race([response.json(), abandoned])
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// Cancelled because a newer check took over, or by a reset: say
|
|
105
|
+
// nothing. Out of time is an answer of its own, below.
|
|
106
|
+
if (error.name === "AbortError" && !timedOut) return
|
|
107
|
+
} finally {
|
|
108
|
+
clearTimeout(deadline)
|
|
109
|
+
}
|
|
110
|
+
// A slow answer for something typed earlier is not an answer for this.
|
|
111
|
+
if (generation !== this.generation) return
|
|
112
|
+
|
|
113
|
+
this.request = null
|
|
114
|
+
// 429, 503, a network failure, no answer in time or something unreadable:
|
|
115
|
+
// say so, never "checking" forever.
|
|
116
|
+
this.render(this.understood(answer) ? answer : { state: "error", message: this.errorMessageValue })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Forgets whatever is in flight and clears the status: on disconnect, when
|
|
120
|
+
// the form is reset, and before Turbo caches the page.
|
|
121
|
+
reset() {
|
|
122
|
+
this.cancel()
|
|
123
|
+
this.generation += 1
|
|
124
|
+
this.render(null)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The record's saved name, typed again (in any case, with or without a
|
|
128
|
+
// leading @): the server would only say it is taken — by its owner.
|
|
129
|
+
unchanged(typed) {
|
|
130
|
+
return this.persistedValue !== "" && this.fold(typed) === this.fold(this.persistedValue)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// How the server compares two names, approximately: NFKC, trimmed, one
|
|
134
|
+
// leading @ dropped, case ignored (in both case modes).
|
|
135
|
+
fold(value) {
|
|
136
|
+
return value.normalize("NFKC").trim().replace(/^@/, "").toLowerCase()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The server's normalisation, approximately: NFKC, trim,
|
|
140
|
+
// lowercase (unless case is kept), one leading @ before a letter or digit.
|
|
141
|
+
normalize(value) {
|
|
142
|
+
let name = value.normalize("NFKC").trim()
|
|
143
|
+
if (this.lowercaseValue) name = name.toLowerCase().normalize("NFKC")
|
|
144
|
+
return name.replace(this.lowercaseValue ? /^@(?=[a-z0-9])/ : /^@(?=[A-Za-z0-9])/, "")
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Each pattern matches a name that BREAKS its rule; the first one wins.
|
|
148
|
+
firstBrokenRule(name) {
|
|
149
|
+
if (name === "") return null
|
|
150
|
+
return this.patterns.find(({ regexp }) => regexp && regexp.test(name)) || null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// With the u flag a length counts code points, as Ruby does.
|
|
154
|
+
compile(pattern) {
|
|
155
|
+
try {
|
|
156
|
+
return new RegExp(pattern, "u")
|
|
157
|
+
} catch {
|
|
158
|
+
try { return new RegExp(pattern) } catch { return null }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
understood(answer) {
|
|
163
|
+
return answer !== null && typeof answer === "object" && ["free", "taken", "invalid"].includes(answer.state) &&
|
|
164
|
+
typeof answer.message === "string"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The <output> named by the status-id value, or by the input's aria-describedby.
|
|
168
|
+
get status() {
|
|
169
|
+
const ids = [this.statusIdValue, ...(this.element.getAttribute("aria-describedby") || "").split(/\s+/)]
|
|
170
|
+
for (const id of ids) {
|
|
171
|
+
const element = id && document.getElementById(id)
|
|
172
|
+
if (element && element.tagName === "OUTPUT") return element
|
|
173
|
+
}
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
render(answer) {
|
|
178
|
+
const status = this.status
|
|
179
|
+
if (!status) return
|
|
180
|
+
|
|
181
|
+
status.dataset.state = answer ? answer.state : ""
|
|
182
|
+
status.textContent = answer && answer.message ? answer.message : ""
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
cancel() {
|
|
186
|
+
clearTimeout(this.timer)
|
|
187
|
+
clearTimeout(this.deadline)
|
|
188
|
+
if (this.request) this.request.abort()
|
|
189
|
+
this.request = null
|
|
190
|
+
}
|
|
191
|
+
}
|