Sign-in and account¶
| Rails | |
|---|---|
| Status | ✅ |
| Code | app/controllers/{magic_links,passkey_sessions,passkeys,accounts,privacy_consents,application}_controller.rb, app/models/{magic_token,passkey,user,privacy_policy}.rb, config/privacy.yml, config/terms.yml, app/models/terms_of_service.rb, config/initializers/session_store.rb, app/services/{passkey_ceremony,account_erasure}.rb, app/jobs/account_erasure_job.rb, app/lib/passkey_params.rb, app/javascript/controllers/passkey_controller.js, app/views/{magic_links,accounts,privacy_consents,logbooks/_passkey_banner}, config/initializers/webauthn.rb |
Verified against code: 2026-10-19
Purpose¶
Users sign in without a password: with a passkey where the browser supports it, or with a magic link sent by email. The magic link always works, so it is also the way back in after losing a device. Admins are users with admin and sign in the same way (admin.md).
Accounts¶
- An account is one
usersrow, identified by its email address (stored lower-case, unique). - During the beta (
SAILEVER_BETA=true) only existing accounts get a magic link. An unknown address goes to the beta form instead (Requesting one), so who can sign in, and therefore pair a watch, is decided by the admin's beta invitation, or by a crew link: a working crew link counts as an approved beta invite (sharing.md § Crew link). - Outside the beta (or with a crew link), requesting a magic link for an unknown address creates the account, once the privacy and terms checkboxes are ticked (Requesting one). There is no separate sign-up.
- A beta invitation from the admin also creates the account (admin.md), without a privacy consent or terms: the user agrees and accepts on the first signed-in page (Privacy consent).
- Each account stores which privacy policy version it agreed to and when (
privacy_consent_version,privacy_consented_at), and which terms version it accepted and when (terms_version,terms_accepted_at). - Profile fields: first name, last name (max 100 each), preferred language (
locale, one of the 7 site locales, may be blank).
Sign-in page (/auth/magic_link)¶
Auth layout, centred card.
| Element | Content |
|---|---|
| Eyebrow | "Sign in" |
| Title / text | "Your logbook" / "Enter your email and we'll send you a link to sign in." After a link was sent (?sent=1 and a notice): "Check your email" / "We sent a sign-in link to your inbox. The link is valid for 30 minutes and works once." |
| Passkey block | Button "Sign in with passkey" (lock icon), an error line, and "Or get a sign-in link by email:". Shown only when the browser supports passkeys (see Passkey support). |
| Email field | Label "Email", required, autocomplete="username webauthn" (lets the browser offer a passkey in autofill), autofocus |
| Privacy checkbox | Only after an unknown address was sent outside the beta or with a crew link (see below). Required. Lock icon, bold label "I agree to the privacy policy", hint "I have read the privacy policy and agree that SailEver processes my data to run my account and logbook." with "privacy policy" linking to /privacy |
| Terms checkbox | Shown with the privacy checkbox. Required. Check icon, bold label "I accept the terms of service", hint "I have read and accept the terms of service, including the safety disclaimer (SailEver does not replace charts, forecasts or seamanship) and that SailEver may charge for its services in the future, announced in advance." with "terms of service" linking to /terms |
| Button | "Email me a sign-in link" (mail icon) |
| Footer | "New to SailEver? Join the beta" → /beta |
A redirect_to parameter is kept through the whole flow (hidden field, magic link, passkey). It must be a path starting with / and not //; anything else is dropped.
Magic link¶
Requesting one (POST /auth/magic_link)¶
| Case | Result |
|---|---|
| Email blank | Page again (422), alert "Please enter your email address." |
Beta (SAILEVER_BETA=true), no account with this address, and redirect_to is not a working crew link |
No account, no email. Redirect to /beta, carrying the address in the flash (not in the URL). There an existing beta application is shown filled in with the notice "Please wait until your application is accepted."; otherwise the form starts with the address filled in (public-site.md). The redirect_to path is dropped. Accepted side effect: the page shows whether an address has an account, an application or neither. |
| 5 or more links requested for this address in the last 15 minutes | Page again (429), alert "Too many sign-in requests. Please wait a few minutes and try again." |
Outside the beta (or with a working crew link in redirect_to), no account with this address, privacy or terms checkbox not ticked |
No account, no email. Page again (422) with the address filled in, both checkboxes shown, and the alert "There is no account with this address yet. To create one, agree to the privacy policy, accept the terms and send the form again." Accepted side effect: the page shows whether an address has an account. |
| Same, both ticked | The account is created with the current privacy and terms versions, both timestamps now; a pending beta application with this email is set to invited. Then as below. |
| Otherwise | Account found (outside the beta: found or created), a token is generated, the email is sent at once (emails.md), redirect to /auth/magic_link?sent=1 with notice "We sent a sign-in link to your email address!" |
The token¶
| Property | Value |
|---|---|
| Format | 32 random bytes, URL-safe Base64; only its SHA-256 hash is stored |
| Lifetime | 30 minutes |
| Use | Once |
| Carries | The sanitised redirect_to path |
Following the link (/auth/magic_link/callback?token=)¶
Opening the link (GET) never signs in and never uses the token up: mail scanners such as Microsoft Safe Links open every link in an email before the user does, and would otherwise use up the one-time token. Only the button on the confirm page (POST /auth/magic_link/callback, token in the form) signs in.
| Case | GET (opening the link) |
POST (the button) |
|---|---|---|
| Already signed in as the token's user (token in any state) | Redirect to the token's redirect_to, else /my. The token stays as it is. |
as below |
| Valid, unused, not expired | Confirm page (auth layout): eyebrow "Sign in", title "Sign in to SailEver", text "Tap the button to finish signing in as |
Token used up, signed in. Redirect to the token's redirect_to, else /my. Notice "Signed in successfully!" The logbook then offers a passkey once (below). |
Used or expired, with a redirect_to |
Redirect to the sign-in page with that redirect_to kept, alert "This link has expired. Enter your email and we'll send you a new one." Signing in from there (passkey or new link) opens the target. |
The same |
Unknown, or used or expired without a redirect_to |
Redirect to the sign-in page, alert "That sign-in link is invalid or has expired. Please request a new one." | The same |
Two POSTs with the same token at once: only one signs in. A POST whose form is from an older session (the CSRF token no longer matches) redirects to the GET of the same link, which shows the confirm page again or, if the token is no longer usable, the sign-in page with the alert.
Magic links are also inside the trip email and the beta invitation (emails.md). They use the same token rules.
Passkeys¶
Passkey support¶
The browser must have PublicKeyCredential.parseCreationOptionsFromJSON and parseRequestOptionsFromJSON (Chrome/Edge 129+, Safari 18.4+, Firefox 119+). Without them every passkey button and banner stays hidden and only the magic link shows.
| Setting | Value |
|---|---|
| Relying party name | "SailEver" |
| RP id / origin | Production RP id sailever.com, origins https://<host> for every web host (SAILEVER_WEB_HOSTS, beta: https://beta.sailever.com; none set: https://sailever.com), so a passkey made on beta also works on www; development localhost (ports 3000, 3100); test localhost:3399 |
| Credential type | Discoverable (resident key required), user verification preferred |
| User handle | users.webauthn_id, generated on the first passkey |
Signing in with a passkey¶
- The page asks
POST /auth/passkey/optionsfor a challenge (kept in the session). No allow-list: the browser offers any SailEver passkey it has. - On page load the browser's autofill (conditional UI) is armed; its failures show nothing. The "Sign in with passkey" button cancels that request, waits until the browser has closed it (Chrome on Android refuses a second request while the first is still open), and then opens the passkey prompt.
POST /auth/passkeychecks the answer, the stored public key and the sign count (a count going down is refused), then updates the sign count and "last used".
| Case | Result |
|---|---|
| Success | Signed in, notice "Signed in successfully!", the page goes to redirect_to or /my |
| Cancelled in the browser, or no SailEver passkey on this device (browsers report both the same way) | Hint under the button: "No SailEver passkey found on this device. If you added one on another device, it can take a few minutes to arrive. Until then, get a sign-in link by email below." The email form stays |
| Unknown passkey, bad signature, wrong challenge, or any other browser error | Error under the button: "That passkey didn't work. Use the email link instead." |
| More than 5 attempts from one IP in 15 minutes | Error "Too many sign-in requests. Please wait a few minutes and try again." (429) |
Passkey offer after a magic link¶
After a sign-in by magic link, /my shows a banner once: lock icon, "Sign in faster next time: add a passkey." and a button "Add passkey". It only shows if the user has no passkey yet and the browser supports passkeys. It is gone on the next page load.
Sessions¶
- Signing in (magic link or passkey) resets the session and stores the user id in the Rails cookie session.
- The session cookie (
_sailever_session) expires 60 days after the last request: every request renews it, so a user stays signed in on a browser until 60 days without a visit (config/initializers/session_store.rb). - Sign out (
DELETE /auth/logout, from the account menu) resets the session and redirects to/with "Signed out successfully." - While signed in, every page renders in English (i18n.md).
Privacy consent¶
The current privacy policy version and its date are in config/privacy.yml (version: 2, effective: 2026-10-22: sharing, crew, the first-name setting, terms and the 60-day session cookie were added); the privacy page shows them (public-site.md). Raising the version asks every user again.
Every signed-in page under /my and /pair (and the crew link) first checks that the user agreed to the current privacy version (privacy_consent_version ≥ current) and accepted the current terms (terms_version ≥ current). If not, it redirects to /my/privacy-consent?redirect_to=<the page> (the page only for GET). Not affected: public pages, sign-out, the email-change confirmation link, the account deletion pages and the watch API.
Accounts that existed before the consent was introduced count as having agreed to version 1 at their creation time; since version 2 every account agrees again, together with the terms, on its next signed-in page. Accounts made by a beta invitation have no consent and see this page on their first sign-in.
Consent page (/my/privacy-consent)¶
Asks only for what isn't current.
| Element | Content |
|---|---|
| Page head | Title "Privacy policy", "Terms of service" or "Privacy policy and terms of service". Privacy not current: "Our privacy policy has changed (version 2, effective October 22, 2026). Please read it and agree to keep using your logbook." Terms not current: "Please read our terms of service (version 1, effective October 22, 2026) and accept them to keep using your logbook. They include the safety disclaimer, sharing and that SailEver may charge for its services in the future." |
| Privacy checkbox | If privacy isn't current. Required. Lock icon, "I agree to the privacy policy", hint "I have read the privacy policy and agree that SailEver processes my data to run my account and logbook." ("privacy policy" links to /privacy) |
| Terms checkbox | If the terms aren't current. Required. As on the sign-in page ("terms of service" links to /terms) |
| Button | "Continue" (check icon) |
| Below | "Don't agree? You can delete your account." ("delete your account" → /my/account/delete) |
| Case | Result |
|---|---|
| All asked boxes ticked | Versions and times stored, redirect to redirect_to, else /my |
| Privacy box not ticked | Page again (422), alert "Please agree to the privacy policy." |
| Terms box not ticked | Page again (422), alert "Please accept the terms of service." |
Terms¶
The current terms version and date are in config/terms.yml (version: 1, effective: 2026-10-22); the terms page shows them (public-site.md). Raising the version asks every user again on their next signed-in page.
| Where the account comes from | Terms accepted |
|---|---|
| Sign-in page, unknown email (outside the beta or with a crew link) | Checkbox on the sign-in page, required |
| Beta form | Checkbox on the form, required; stored as beta_applicants.terms_accepted_at (public-site.md). The account the admin then creates accepts on its first signed-in page. |
| Admin beta invitation | On the first signed-in page (consent page) |
| Accounts from before the terms | On their next signed-in page (no backfill: version 1 is the first time terms are accepted) |
Account (/my/profile, /my/account, /my/subscription)¶
Tabs Profile (/my/profile), Account (/my/account) and Subscription (/my/subscription). The page head follows the tab: "Profile" / "Your name, your bio and your public skipper profile.", "Account" / "How you sign in, your language and passkeys.", "Subscription" / "Your plan." (also the window title).
The header account menu also links to both tabs (logbook.md).
Profile tab¶
Two cards, one below the other: Profile and Public skipper profile (profile.md).
Profile card¶
| Field | Content & rules |
|---|---|
| First name, Last name | Max 100 characters |
| Show my first name on public trip pages | Checkbox (globe icon), default off (show_first_name_publicly). Hint "On trips shared with a public link: your own and those you crewed on. Your last name and email are never shown.", plus "Add a first name first." while the first name is empty. See sharing.md. |
| Sailor bio | Optional rich text (Lexxy editor, no file or image upload), at most 2 000 characters, users.sailor_bio (Action Text; PlainRichText, same rules as the trip description). Shown under the header of the logbook and the public skipper profile. |
| Button | "Save profile" |
Account tab¶
Two cards side by side (stacked on mobile): Sign-in and language and Passkeys, then the danger zone.
| Field (Sign-in and language) | Content & rules |
|---|---|
| Email address | Shows the current address. Hint "You sign in with this address. A new one only counts once you click the link we send to it." While a change is pending: "Waiting for confirmation of |
| Preferred language | English, Deutsch, Français, Italiano, Español, Ελληνικά, Português. Hint "Used for the emails we send you." |
| Button | "Save" |
| Outcome | Message |
|---|---|
| Saved, no email change | "Profile saved." |
| Saved with a new address | "Profile saved. We sent a link to |
| Invalid | The form again (422) with the errors listed, e.g. "Email has already been taken" |
Changing the email address¶
- A different address is stored as
unconfirmed_email; name and language are saved right away. - Two emails go out (emails.md): a confirmation link to the new address, a notice to the old one.
- The old address keeps working until the link is clicked.
- The link
/my/account/email/confirm?token=works for 24 hours and stops working once the address is confirmed or replaced. It works signed out.
| Case | Result |
|---|---|
| Valid link, address still free | Address switched. Notice "From now on you sign in with |
| Address taken by another account meanwhile | Alert "That address already belongs to another account." |
| Invalid, expired or used | Alert "That link is invalid, expired or has already been used." |
Afterwards the user lands on the account page (signed in) or the sign-in page (signed out).
Passkeys card¶
| Element | Content |
|---|---|
| Title / lede | "Passkeys" / "Sign in with your fingerprint, face or device PIN instead of waiting for an email. The email link always keeps working." |
| List | One row per passkey, oldest first: nickname ("Passkey 1", "Passkey 2", …), "Added |
| Empty | "No passkeys yet." |
| Add | Button "Add passkey" (only with passkey support) |
| Action | Result |
|---|---|
| Add, success | Notice "Passkey added. Next time, sign in with it.", back to the account page |
| Add, failed, wrong challenge or any other browser error | Error in the card (or the logbook banner): "The passkey couldn't be added. Please try again." |
| Add, cancelled | Nothing |
| Add, this device already holds a passkey for the account | Error in the card: "This device already has a passkey for your account." |
| Remove | Confirm "Remove this passkey? You can still sign in with an email link." → notice "Passkey removed." Removing the last passkey is allowed. |
Passkeys already registered for the account are excluded when adding, so the same authenticator isn't added twice.
Delete account card¶
Below the two cards: danger-zone card "Delete account", text "Deletes your account with all trips, tracks, boats and devices. This can't be undone.", button "Delete account" (trash icon) → /my/account/delete.
Delete account¶
Confirmation page (/my/account/delete, reachable without the privacy consent):
| Element | Content |
|---|---|
| Page head | "Delete your account?" / "This deletes everything below for good. It can't be undone." |
| Card "What goes" | " |
| Note | "We send one last email to |
| Field | "Type |
| Buttons | "Delete my account" (danger, trash icon), "Cancel" → /my/account |
DELETE /my/account:
| Case | Result |
|---|---|
| The typed address is not the account's (compared without case and surrounding spaces) | Page again (422), alert "That isn't the email address of this account." Nothing changes. |
| It matches | Every active device of the account is revoked (token removed, revoked_at set), so the watches get 401 on their next upload (device-api.md). The session ends. AccountErasureJob is queued at once. Redirect to / with "Your account is being deleted." |
AccountErasureJob sends the account deleted email to the address, then runs AccountErasure (data-model.md). The admin can start the same job (admin.md).
Subscription tab¶
| Element | Content |
|---|---|
| Card title | "Subscription" |
| Badge | "Beta Pro (Active)" (placeholder; there is no plan model) |
| Text | "Free during the beta. Pricing will be announced in advance; nothing is billed automatically." |
Paid plans are ⏳. The pricing model is not a spec; it lives with the marketing docs (docs/marketing/).
States and edge cases¶
/pairsigned out shows a sign-in prompt that returns to/pair(devices.md).- A magic link opened in another browser signs in that browser; the first browser stays signed out.
Known deviations¶
- A failed email-format check on the new address shows "Unconfirmed email is invalid" (internal attribute name).
- The magic link inside the trip email and the beta invitation also expires after 30 minutes (emails.md); opened later, it keeps its target through a new sign-in.