Skip to main content

Changing a session

A session changes only through Rails, and JodApp Web notices only through revalidation.

  • The change starts in the browser, in a clientAction.
    • React Router gives a route two kinds of action export. action runs on Node. clientAction runs in the browser.
    • JodApp Web uses clientAction for every session change, and no route exports a server action.
  • Rails changes the database and the cookies.
  • React Router loads route data again, and the root loader reads the new session.
  • Nothing in JodApp Web updates a session by hand.
  • This page covers six things.
    • What counts as a session change.
    • Why every one of them is a clientAction and not a server action.
    • Login, then logout and sign out everywhere, step by step.
    • How the API client adds the CSRF header.
    • What each refused response means.
    • When the app notices a change.

The words this page uses​

WordMeaning
React RouterThe library that runs JodApp Web's routes, in both runtimes. In the browser it handles every click and form submit, runs the clientLoader and clientAction exports, and sends .data requests to Node. On Node it runs the middleware and loader exports for each request. When a page says React Router does something, it means this library code, not code we wrote
Route actionThe clientAction export of a route. It runs in the browser when a form on that route submits. When it returns, React Router loads route data again. A server action also exists in React Router, and JodApp Web does not use one
Resource routeA route with a path and no component. It exports an action and nothing else, so it never draws anything. /logout is one
error.codeThe code field Rails sends with every error, carried by ApiError in app/errors/api-error.js. A client decides what to do from the code, never from the message text
The API client's hookThe beforeRequest hook in app/api/ky-client.js. It runs before every request the API client sends and adds the X-CSRF-Token header when one is needed
replace()The React Router redirect that replaces the current history entry. A person who presses Back does not return to the form they just submitted
data()The React Router helper that returns a value with an HTTP status from an action. data({ error }, { status: 400 }) gives the form its message
RevalidationReact Router loading route data again, so the page shows current records. In the browser it sends one .data request to Node for the matched loader exports, and runs the matched clientLoader exports. React Router revalidation in JodApp Web states the rules

What counts as a session change​

A session change is any write that can change the current-session JSON. Use a clientAction for every one of them.

ChangeWhat moves in the current-session JSON
User loginThe session goes from null to a person
User logout, and sign out everywhereThe session goes from a person to null
Admin login and logoutThe same, for identitiesAdminSession
Talent setup completiontalent_access.state moves from setup_required to ready
Employer account creationemployer_access.state moves from missing to active
A change to the person's name or emailidentities_user carries those fields
The password resetThe session goes from a person to null on every device. Rails ends every session of the person when it saves the new password. The browser makes no session call for it

A write that cannot change the current-session JSON is normal page work, and this page does not cover it. Three examples:

  • editing a job
  • uploading a CV
  • switching the company the person acts as, which changes is_selected on two Org::Membership rows and nothing in the current-session JSON

Every session change is a clientAction​

The browser makes every session change, because only the browser holds the credential that Rails will accept for a write.

Take the logout button as the example. It is a React Router <Form> that posts to /logout, and /logout is a route whose file exports a clientAction.

// Inside the navbar, or anywhere a "Log out" button appears
import { Form } from 'react-router'

<Form method="post" action="/logout">
<button type="submit">Log out</button>
</Form>

What happens when Mei clicks it, in order:

  1. React Router catches the submit in the browser. No HTTP form post reaches Node.
    • It finds the route whose path is /logout, the file app/routes/logout.js, and runs that file's clientAction.
  2. The clientAction calls Rails through the API client.
    • identitiesUserSessionsApi.destroy() sends DELETE /identities/user_sessions/current.
    • The browser adds both cookies. The API client adds X-CSRF-Token.
  3. Rails deletes the Identities::UserSession, and deletes both cookies in its response.
  4. The clientAction returns replace('/').
    • A login action returns replace(target) on success, or data({ error }, { status: 400 }) when the form must show a message.
  5. React Router navigates to / and loads route data again.
    • It sends one .data request to Node for the matched loader exports. That request carries no session cookie now, so the root loader answers null.
    • Every policy and every component sees the new session on that same reload.

How a form finds its clientAction:

The formSubmits to
<Form method="post" action="/logout">The clientAction of the route at /logout
<Form method="post"> with no action attributeThe clientAction of the route the page is on
useSubmit() called from a handler, as the login form doesThe clientAction of the route the page is on, /login, so login-page.jsx receives it

After the clientAction succeeds, the code does nothing else.

  • No update to a provider, because there is no provider.
  • No copy of the response into state.
  • No navigation by hand to show the new state. The redirect is the whole answer.

Login, step by step​

Login is a clientAction on the login page. Mei signs in from her laptop.

Legend: The orange box is JodApp Web, this page's subject. The write goes straight from the browser to Rails. The read that follows goes through Node, on the .data request the redirect causes. The action itself reads nothing from the login response. The cookies are the change.

The action, on the user login page:

// app/routes/login-page.jsx
import { data, replace } from 'react-router'
import { identitiesUserSessionsApi } from '@/api/identities-user-sessions-api'
import { IDENTITIES_USER_SESSIONS_ERROR_CODES } from '@/domains/identities-session/identities-session-constants'
import { authSafeRedirect } from '@/auth/auth-safe-redirect'
import { authEntryRules } from '@/auth/auth-entry-rules'

export const clientAction = async ({ request }) => {
const { identifier, password } = await request.json()

try {
await identitiesUserSessionsApi.create({ identifier: identifier, password: password })
} catch (error) {
// Two codes are messages for the form. They go back as 400, never as Rails's
// own status, because a failed login changed nothing about the session.
if (error.code === IDENTITIES_USER_SESSIONS_ERROR_CODES.INVALID_LOGIN) return data({ error: 'Wrong email, phone number or password.' }, { status: 400 })
if (error.code === IDENTITIES_USER_SESSIONS_ERROR_CODES.RATE_LIMITED) return data({ error: 'Too many attempts. Try again in a few minutes.' }, { status: 400 })

// Everything else is not the person's mistake. It reaches the error boundary.
throw error
}

const target = authSafeRedirect.internalPathOrDefault(
authSafeRedirect.redirectTarget(new URL(request.url)),
'/',
authEntryRules.USER_GUEST_PATHS
)

return replace(target)
}

What each part does:

PartWhy
request.json()The form submits with encType: 'application/json', so the action reads a JSON body. react-hook-form and zod validate the fields before the submit
error.codeRails names each failure. The action switches on the code, never on the status, because 422 also answers other validation failures and 403 also answers a refused permission. The codes live in IDENTITIES_USER_SESSIONS_ERROR_CODES, in app/domains/identities-session/identities-session-constants.js, so no page compares error.code with a string
status: 400 on a form messageThe root routes load route data again when an action fails with 401 or 403, because those mean the session is stale. A wrong password changed nothing, so it must not look like a stale session
throw errorA 403 with a CSRF code, a 422 with credential_not_allowed, a 5xx and a network failure are not the person's mistake. The error boundary shows them and reports them
authSafeRedirectredirect_to comes from the URL, so a stranger can set it. The helper allows only a path inside our site, and refuses the login pages themselves so nobody loops
replace(target)The login page is not a destination. Back must not return to it

The page reads the message with useActionData() and shows it above the form. The employer login page and the admin login page have the same shape, with their own default target and their own guest paths.

What login returns is the current-session JSON, and the action does not read it. The cookies in the response are the change, and the reload after the redirect reads the truth through the normal path.

Logout, and sign out everywhere​

Logout is a clientAction on a resource route. A logout button is a form that posts to that route.

<Form method="post" action="/logout">
<button type="submit">Log out</button>
</Form>
Resource routeFileCallsEnds
/logoutapp/routes/logout.jsDELETE /identities/user_sessions/currentThis browser's session
/logout/everywhereapp/routes/logout-everywhere.jsDELETE /identities/user_sessionsEvery session of this person, on every device
/team/logoutapp/routes/team/team-logout.jsDELETE /team/identities/admin_sessions/currentThis browser's admin session
/team/logout/everywhereapp/routes/team/team-logout-everywhere.jsDELETE /team/identities/admin_sessionsEvery session of this admin

Legend: The write goes straight from the browser to Rails, with the CSRF header the API client added. The read that follows reaches Node without a cookie, so Node calls nothing and answers null. Nobody told the app "we are signed out". The cookie is gone, and the next request shows it.

The action:

// app/routes/logout.js
import { replace } from 'react-router'
import { identitiesUserSessionsApi } from '@/api/identities-user-sessions-api'

export const clientAction = async () => {
try {
await identitiesUserSessionsApi.destroy()
} catch (error) {
// 401 means there was no session left to end. The outcome is the same: signed out.
if (error.status !== 401) throw error
}

// This route draws nothing and sits under no policy, so nothing else would send
// the person anywhere. replace(): the page after logout is a correction, not a destination.
return replace('/')
}

Sign out everywhere is the same file shape, calling identitiesUserSessionsApi.destroyAll(). Rails deletes every session of the person, this browser's included, so the redirect is the same.

The button that posts to /logout/everywhere sits where the plain log out already is: one item below "Log out" in the navbar's account menu, on every user navbar, behind the same confirm dialog. It says "Sign out of all devices". The Team button sits in the Team sidebar, under the existing log out form, with the same words. The words match the reset-password message so the two read as one idea. On the employer dashboard the plain log out asks the person to confirm as well, because an employer is often on a shared computer and a wrong click there costs more than one on a phone.

Why logout lives on a resource route and nowhere else:

Route that could own logoutWhy not
A visual layoutA visual layout exports no action. Auth would creep back into layouts
The login page, behind an intent fieldOne URL would do two opposite things, and the route tree would not show that the login page can end a session
A page with a componentLogout shows nothing. A route with nothing to show has no component

What the person sees after logout in another tab: nothing, until the next click. That click reaches Node without a cookie, the middleware answers null, and the policy on the destination sends the person to the login page.

The password reset signs the person out of every device, and the page says so​

Rails ends every session of the person when it saves a new password. The user side does it in PATCH /identities/users/password, and the Team side in PATCH /team/identities/admins/password. The rule and its reason are on the endpoints page, under Other endpoints that end sessions.

JodApp Web makes no session call for it. Its job is to tell the person, because the person did not press a sign-out button and another device will ask them to sign in without warning.

PageWhat it says, and when
The user reset page, /reset-passwordBefore the person saves: saving the new password signs them out of all their devices. In the success message: they are signed out of all their devices and sign in again with the new password
The Team reset pageThe same two messages, for the admin

Both messages use plain words. They say "all your devices", not "sessions", because the people who read them are workers and employers, not engineers.

How the API client adds the CSRF header​

Rails compares X-CSRF-Token with the session's csrf_token on every cookie-authenticated request that changes data. The API client adds that header in one place, so no action has to remember it.

// app/api/ky-client.js, inside createApiClient({ csrfCookieName, prefixUrl })
beforeRequest: [
(request) => {
const isWrite = !['GET', 'HEAD'].includes(request.method)
const isBrowser = typeof window !== 'undefined'

if (!isWrite || !isBrowser) return

const csrfToken = readCookie(csrfCookieName, document.cookie)

if (csrfToken) request.headers.set('X-CSRF-Token', csrfToken)
}
]
RuleWhy
The header goes on every request that is not GET or HEADThose are the requests Rails checks. A GET never changes data, so it carries no header
The header is read from document.cookie, in the browser onlyThe CSRF cookie is readable by scripts on purpose. Node never writes, so Node never needs the header
No cookie means no headerA signed-out browser sends a login POST with no CSRF cookie. Login needs no header, and sending the string undefined would be a bug, not a token
credentials: 'include' stays on the clientThe page on jodapp.com calls api.jodapp.com. Without it the browser would send no cookies to the API at all
The Team client reads TEAM_CSRF_TOKENThe two identity systems share the code of the hook and nothing else

What the API client does when Rails refuses a request:

  • It converts the response into an ApiError with status and code, and returns it to the caller once.
  • It never retries, never navigates, and never clears anything.

What a refused response means​

Every Rails error carries a status and a code. The code decides what the caller does.

StatusCodeMeaningWhat the caller does
401unauthorizedThe session is gone. The person is signed outIn a clientAction: return the error with its status, so the root routes reload the session and the policy on the page sends the person to login. In a direct call: revalidate(), and nothing else
403csrf_token_missing, csrf_token_invalidThe header was not sent, or does not match. The session is fine. This is a mistake in our codeThrow. The error boundary shows it and reports it. Never treat it as signed out
403Any other code, such as org_membership_required or org_membership_inactiveSigned in, but the access facts changedIn a clientAction: return the error with its status, so the root routes reload the session and the policies read the new state. In a direct call: revalidate(), and nothing else
422invalid_loginWrong identifier or passwordA form message, returned as 400
422verification_requiredThe account has neither a verified email nor a verified phoneThe login page sends the person to the forgot-password OTP, which marks the email verified when it completes
422credential_not_allowedA browser asked for a bearer token. A mistake in our codeThrow
429rate_limitedToo many login attempts from this addressA form message, returned as 400
5xx, or no responseany, or status: 0Rails failed, or the network didThrow. The error boundary shows an error page

Two rules hold across the table.

  • Only 401 means signed out. A 403 never does.
    • The CSRF codes and the access codes share a status and mean different things. The code tells them apart.
  • Only the person's own mistakes become form messages.
    • Everything else is thrown, so a bug in our code is seen and reported, and never shown as "please sign in again".

The four Rails endpoints hold the error table on the Rails side.

When the app notices a change​

Not every change starts in this browser tab. Be precise about when each kind becomes visible.

The changeHow this tab notices
A clientAction in this tab succeedsAt once. React Router loads route data again
A clientAction in this tab fails with 401 or 403At once. The root routes opt back in to the reload, and the policy on the page acts on the new session
A direct API call from a component is refused with 401 or 403When the caller asks with revalidate(). React Router does not see a call that did not pass through it
Logout in another tabOn the next request that reaches Node or Rails: a document request, a click that enters a policy's area, or a fetch that Rails refuses and the caller answers with revalidate(). The cookie is gone, so the middleware answers null and the policy redirects
An admin disables the companyOn the next refused request, or on the next click. Rails reads the database on every request, so the stale copy in this tab grants nothing

Between those moments, the tab holds a stale session. That is acceptable because the session grants nothing. Rails checks the database on every protected request, so a stale "active" in the browser cannot read one byte of protected data.