Skip to main content

Login & Token Management

Overview

The CMS uses a two-step login because tokens in this system are not tied to a user alone — they are also scoped to the LINE OA (channel) the user is currently working with.

  • Step 1 — authenticate with email and password to obtain an initial token pair that does not yet identify an OA.
  • Step 2 — pick the LINE OA to work with, exchanging the initial token for a new one that carries lineOaId.

As a result, every subsequent request is automatically scoped to the selected OA. Users who manage several OAs switch context simply by returning to the OA selection page.

This page is aimed at administrators and developers who need to understand why users are forced to pick an OA before reaching any other menu, and how tokens are refreshed and revoked.

Business Flow

Step 1 — Sign in with email and password

  1. The user opens /login and enters their email and password. The screen also links to "Forgot password" and "Sign up".
  2. Credentials are posted to POST /auth/login, which returns an accessToken and a refreshToken.
  3. Both tokens are stored in localStorage, and the user profile is decoded from the JWT (name, organization, role, maximum channel count) into global state.
  4. The app navigates to the OA selection page with a full page reload, so that the permission layer is recalculated from the newly issued token.
  5. On failure a modal explains the error. If the API reports that the organization is not yet active (Organization is inactive), a dedicated "pending approval" message is shown instead of the generic one.

Step 2 — Select a LINE OA to exchange the token

  1. /line-oa-management lists all channels belonging to the organization as cards.
  2. The user clicks an active OA card, and the app calls POST /auth/login-with-line-oa to exchange the token.
  3. The new token carries lineOaId and the OA details, and overwrites the previous pair.
  4. Permissions are reloaded immediately before navigating away, so the user is not left with the earlier, more restricted rule set.
  5. Cached filters from every list screen are cleared, and the user lands on the dashboard.

Route guard

Each time the application loads, the sign-in state is evaluated in order:

  • No token — unless the current path is a public one (login, register, forgot/reset password, verify email, or a public page under /p/), the user is redirected to /login.
  • Token present but no OA selected — the app always grants access to the OA selection page regardless of what the permission API returns, and redirects any non-account-level page back to OA selection.
  • Fully authenticated — permissions are fetched from GET /user/{id}/permission and compiled into the application-wide rule set. If that call fails, the user can still reach the OA selection page and nothing else.

Token refresh

Rather than a fixed timer, the app uses an idle check that runs on every user interaction (throttled to once per second):

  • Access token expired but refresh token still valid — call POST /auth/refresh-access-token for a new pair.
  • Both expired — sign the user out immediately.
  • In addition, a shared response interceptor catches HTTP 401 on any request and forces a sign-out with token revocation.

Sign out

  1. The user selects "Logout" from the profile menu in the top bar.
  2. The app clears cached filters from sessionStorage and calls POST /auth/revoke-token to revoke the token server-side.
  3. accessToken, refreshToken and the persisted state are removed from localStorage, and the user is returned to /login.

Key Screens & Components

Login screen

/login renders an email/password form inside the shared authentication layout (AuthLayout). The container waits for the component to finish mounting before showing the form, avoiding hydration mismatches caused by the static export build; a loading message is displayed in the meantime.

A "remember me" option exists in the form's data shape but is not enabled in the current release.

Authentication service layer

A shared authentication service (src/services/auth.service.ts) owns reading, writing and clearing tokens in localStorage, decoding the JWT, refreshing tokens and revoking them. Its individual functions are called both from the UI and from the HTTP interceptors.

The two login steps map to POST /auth/login and POST /auth/login-with-line-oa. The first is issued through a bare axios call because no token exists yet; the second goes through the shared instance so the step-1 token is attached.

Application provider

AppProvider is the single place that combines the route guard, permission loading and idle token checks. Because permissions are compiled there, the whole application's access rules are re-established on every page load.

Top bar and profile menu

The application's top bar exposes a profile menu that gathers the entry points for "Manage LINE OA" (switching OA), "Change password" and "Logout".

JWT contents

The fields the frontend consumes from the JWT payload are the user id (sub), organizationId, lineOaId and the OA name (present only after step 2), first and last name, roleId, roleName and maxChannel.

Dependencies

  • LINE OA Management — the OA selection page is step 2 of the login flow and the only way to obtain an OA-scoped token.
  • Permission system (CASL) — access rules are defined in one place, AppProvider. Before an OA is chosen, view access to the OA selection page is always granted.
  • Password Management — the forgot, reset and change password flows all end by returning the user to /login.
  • Sign-up — new users must register and verify their email before signing in, and their organization must have been approved.
  • Global state — profile, permissions and theme are persisted; the compiled ability rules are not, so they are rebuilt on every page load.
  • Build constraints — the site is built as a static export with trailing slashes, so the public-path list must include both the slashed and unslashed form of each route.

Backend Details (CMS API)

This section describes what cms-api actually does when it receives requests from this screen — which explains why the frontend needs two steps, and why a token that looks unexpired can still be rejected.

Endpoints used by this screen

StepEndpointBackend guard
Step 1 sign-inPOST /api/auth/loginnone (public)
Step 2 select LINE OAPOST /api/auth/login-with-line-oaJwtLoginAuth (accepts a token without an OA)
Refresh tokenPOST /api/auth/refresh-access-tokenRefreshTokenAuth
Revoke tokenPOST /api/auth/revoke-tokennone

The backend auth module is registered separately from the feature modules, because besides exposing the endpoints above it also provides the global JWT guard that every other module's route group is wrapped in.

What the backend validates at each step

Step 1 — login

  • Compares the submitted password against the bcrypt hash stored in the user table.
  • Issues an access token and refresh token that deliberately carry no lineOaId claim. That pair only passes routes using JwtLoginAuth; it cannot reach any working menu yet.

Step 2 — login-with-line-oa

  • Checks per-user OA access in the user_line_oa table, not merely that the OA belongs to the same organization.
  • Issues a new token pair embedding lineOaId, lineOaHash, organizationId and roleId — the token every working menu requires.
  • Automatic backfill: if the OA has no lineOaHash yet, the backend generates one and writes it back to the line_oa table. OAs created before this field existed therefore receive their hash the first time someone selects them.

Redis session mechanics (the most important part of this page)

Every time a token pair is issued, the backend records it in the Redis hash h_session:<userId>, storing only the first 15 characters of the token's SHA256 in the accessToken / refreshToken fields. The tokens themselves are never stored.

Consequences worth knowing:

  • Requests passing JwtAuth are not just signature-verified; the hash is also compared against Redis. A mismatch returns 401 with error code APP_001 immediately, even if the token has not expired by time.
  • Because the hash holds a single field per user, a fresh sign-in overwrites the previous session, silently invalidating other devices that were still logged in. Users experience this as being kicked out without pressing logout.
  • POST /api/auth/revoke-token deletes the whole h_session:<userId> key, so every token belonging to that user stops working at once.
  • A successful refresh requires both a refresh token still present in Redis and an nbf claim inside the token. Failures return 400 with APP_002 or APP_003, which is what drives the frontend's forced sign-out.

JwtAuth check order

The shared guard runs in this order: verify the signature, require a lineOaId claim, populate tenant values (user, OA, organization, role) into the request context that the entire permission layer reads, then compare the hash against Redis.

This explains why the frontend forces OA selection first: a step-1 token is rejected by the backend at the "must have lineOaId" stage, not merely blocked by the frontend route guard.

Security notes worth knowing

  • POST /api/auth/revoke-token has no guard, and the code notes that the intended admin-role check is still missing.
  • JWT_SECRET is a mandatory environment variable — without it the service does not boot at all.
  • Beyond the ordinary user guard there is also SuperAdmin(), which requires roleId == 1, and InternalApiKey(), which validates the X-Internal-Key header against the INTERNAL_API_KEY environment variable for internal system calls.
  • Redis keys are namespaced via the REDIS_NAMESPACE environment variable, so environments sharing one Redis instance do not collide.