Skip to content

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 users row, 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).

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.

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

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 .", button "Sign in" (lock icon). The token stays unused. 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

  1. The page asks POST /auth/passkey/options for a challenge (kept in the session). No allow-list: the browser offers any SailEver passkey it has.
  2. 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.
  3. POST /auth/passkey checks 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)

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).

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.

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 . Until the link sent there is clicked, your current address keeps working."
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 : click it to switch to that address."
Invalid The form again (422) with the errors listed, e.g. "Email has already been taken"

Changing the email address

  1. A different address is stored as unconfirmed_email; name and language are saved right away.
  2. Two emails go out (emails.md): a confirmation link to the new address, a notice to the old one.
  3. The old address keeps working until the link is clicked.
  4. 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 · last used " or "· not used yet", button "Remove"
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" " trips" · " tracks with their points" · " devices: they stop uploading at once" (active devices) · "Your boats, passkeys, support requests and beta application" (singular forms for 1)
Note "We send one last email to when the account is gone."
Field "Type to confirm", required
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

  • /pair signed 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.