Skip to main content

App Shell & Navigation

Overview

This document covers the cross-cutting structure shared by every screen in the CMS. Whenever a feature document mentions a container, a breadcrumb, the section header, the request layer, or the standard filter bar styling, this is where the detail lives.

The application is built on the Next.js App Router configured for static export, and every page is a client component — there are no server components or server actions anywhere.

The shell is built from seven pieces:

  • A single layout that branches into four modes depending on the route being opened
  • Two provider layers, one for authentication and theming and one for translation
  • A side menu generated from the user's permissions — there is no static menu configuration file; entries are appended one at a time as each permission check passes
  • Three state stores, for application state, language, and the Workflow canvas
  • A bilingual system running entirely in the browser, with no per-language routing
  • A container/presenter pattern separating permission checking, state management, and rendering
  • A shared data layer wrapping the request library and routing every service through a single API client

Tokens, token refresh, unauthenticated route guarding, and handling of server rejections are covered in the authentication document; this page only references them.

Business Flow

Opening a CMS page proceeds as follows.

1. Root layout

  1. Loads all global styles, fonts, and icons. Two fonts are loaded by different mechanisms: the Thai-capable face through a link tag, and the theme face through the framework's font pipeline.
  2. Sets the browser page title from the current route, using a lookup defined in code.
  3. Branches into one of four layout modes based on the route:
    • Public pages under /p render bare content with no CMS chrome at all
    • Unauthenticated pages — sign in, forgot password, reset password, register, and email verification — use a restricted shell
    • Account-level pages such as LINE OA management and user management use the restricted header with no side menu
    • Ordinary CMS pages get the quick-access bar, the header, the side menu, and a content area with breadcrumbs above it
  4. The excluded-route list must be declared both with and without a trailing slash, because the application is configured to append a trailing slash to every path.

2. Provider stack

  1. Providers nest from the outside in: the hydration handler, the translation provider, the data request client, the UI library configuration, and finally the theme wrapper.
  2. The data request client is created exactly once for the application's lifetime and configured not to refetch when the browser window regains focus.
  3. The UI library receives both the theme tokens and a locale matching the user's chosen language.
  4. Wrapping everything in the UI library's app context is what lets every page use theme-aware toast and notification messages.
  5. When the application-level loading state is set, a full-screen spinner covers the viewport.

3. Translation

  1. The chosen language is read from a store persisted in the browser.
  2. When the language is Thai, the Thai message set is deep-merged over the English one, so keys not yet translated fall back to English rather than rendering empty.
  3. On a language change, both the document language attribute and the date library's locale are updated to match.
  4. The timezone is fixed to Bangkok, and missing keys render as a string naming the namespace and key explicitly, so gaps are immediately visible on screen.

4. Side menu

  1. The menu container reads the user's permissions from the store and builds the entry list one item at a time, checking permission before appending each one.
  2. Menus with children — audience, rich menu, content management, apps, and settings — check permissions again at the child level. The rich menu opens its parent if any single child qualifies.
  3. The settings menu is always appended without a permission check, and most of its children are unchecked too; only a few are gated.
  4. The apps menu is dynamic. It queries the app registry and builds children only for enabled apps, hiding the parent entirely when nothing is enabled. Registry errors are swallowed silently.
  5. Bulletin carries a pending-count badge, fetched only once the registry confirms the app is enabled — that endpoint rejects requests when the app is off.
  6. Menu order is defined by a separate ordering list; entries absent from it are pushed to the bottom.
  7. The menu renderer reads the active entry from the store, auto-expands any parent containing an active child, and swaps in the active icon variant.
  8. The collapsed state is persisted in the browser. When collapsed, parent menus are flattened into leaf icons, because the library shows no tooltip for parent entries in rail mode; clicking one re-expands the rail and opens that submenu.

5. Header and breadcrumbs

  1. The header takes a mode and renders accordingly. CMS mode shows the logo, the search button, the language switch, the current LINE OA's name and avatar, and the profile menu.
  2. Account-level mode hides the logo, search, and OA details, showing instead a button back to LINE OA management and a page title derived from the route.
  3. The search button does not use shared state. It synthesises the keyboard shortcut event to trigger the quick-access listener — a decision documented in the code itself.
  4. The profile menu offers LINE OA management, change password, and sign out.
  5. Every page sets its own breadcrumb and active menu entry. The breadcrumb renderer draws nothing at all when the list is empty.

6. Permission checking and entering the feature container

  1. The permission guard is a higher-order component that wraps the target page along with the required action and subject.
  2. While permissions are still loading, the page stays blank; if the user lacks the permission, a no-permission screen is rendered instead.
  3. Once past the guard, the feature container takes over — it owns the API calls and all state.

7. Data layer

  1. The shared request layer returns three tools: a read helper, a mutation helper, and the cache client. Reads default to a single retry, and mutations automatically invalidate the matching query once settled.
  2. Every service goes through a single API client whose base URL comes from an environment variable.
  3. There is no central error handler. The common pattern in each page is to read the server's message first and fall back to a translated string. Only a handful of error codes are declared as constants, and they are used in specific places rather than systematically.

8. Persisting filters across navigation

  1. Roughly ten older list screens preserve their filter values so users return to what they had, storing them in the browser's session storage keyed by page name.
  2. The pattern is: read the stored value as the initial state on mount, write the new value when Search is pressed, and clear it when filters are reset.
  3. Clearing removes the stored filters for every page, not just the current one, and the same clear-all runs on sign out and when switching LINE OA.
  4. Newer list screens do not use this mechanism at all, keeping their query parameters in local state only.

Key Screens & Components

Layout, providers, and navigation

  • src/app/layout.tsx — selects the layout mode, loads styles and fonts, and sets the browser title
  • src/providers/app.provider.tsx — the provider stack, the request client, UI library theme and locale, the application-level spinner, and the authentication work
  • src/providers/i18n.provider.tsx — the translation provider, message merging, and syncing the document language with the date library
  • src/app/permission.guard.tsx — the per-page permission higher-order component
  • src/components/layout/sidemenu/ — the container that builds entries from permissions and the app registry, and the renderer handling parent expansion and rail collapsing
  • src/components/layout/app-header/ — the two-mode header with search, language switch, and profile menu
  • src/components/layout/app-breadcrumb/ — reads breadcrumbs from the store and renders them
  • src/components/layout/quick-access/quick-access.tsx — the keyboard-shortcut command palette
  • src/components/ability/ — building the permission set from server data and mapping backend modules onto frontend subjects

Quick access in detail — it is a static registry mirroring the side menu entry by entry, which makes it a second source of truth that must be updated alongside any menu change. Dynamic apps are spliced in at a fixed anchor point, fetched lazily the first time the palette opens. Search operates bilingually at once, combining the English and Thai labels into a single haystack and requiring every search term to be present. Displayed labels follow the active language. The three most recently used entries are remembered in the browser.

State stores

  • src/store/app.store.ts — holds the theme, profile, permissions, ability set, breadcrumbs, loading states, and the active menu entry. Only the theme, profile, and permissions are persisted; the ability set and breadcrumbs must be set fresh on every page load.
  • src/store/locale.store.ts — holds the chosen language, deliberately kept separate from the main store so it survives the state reset performed at sign out
  • src/store/workflow.store.ts — holds the Workflow canvas, unpersisted

Internationalisation

  • src/i18n/config.ts — the locale list, default locale, storage key, and the full namespace registry, which serves both as the registration point and as the iteration source for the translation parity tests
  • src/i18n/messages.ts — message loading and merging
  • src/i18n/rich-tags.tsx — the tag set for formatted messages, which must be passed at every call site because the library provides no global registration point
  • messages/{en,th}/*.json — per-namespace translation files. The common namespace is shared by every page and covers menu labels, standard buttons, table headers, statuses, and validation messages.

Shared constants

  • app-config.constant.tsx — the application name, logos, menu width, the account-level route list, and the routes excluded from the CMS shell
  • cms-list-filter.constant.ts — the standard control styling for every list screen's filter bar
  • cms-table-layout.constant.ts and cms-page-classes.constant.ts — table scrolling styles and the shared page class names
  • theme.constant.ts — the UI library theme: fonts, palette, type scale, and component overrides
  • date.constant.ts — the full set of date and time formats, table page sizes, and the default page size
  • src/enums/common.enum.tsx — every permission subject, the supported actions, the standard status enum, page modes, and site modes

Shared components used across pages

  • A link wrapper that disables prefetching by default, because the framework prefetches every link in the viewport, causing the side menu and tables to fire dozens of requests per page load
  • The standard page section header with its create button
  • A wrapper giving tables horizontal scrolling
  • The standard per-row action button set, plus a hook that wires the delete button to a confirmation dialog and manages the deleting state itself
  • The shared confirmation, success, and failure dialogs with their calling helpers
  • Loading spinners, both application-level and prop-controlled
  • A file preview modal that branches by file type
  • A step-by-step guideline modal
  • The no-permission and no-data screens

Hooks and utilities

  • usePageTitle — sets the browser title from the route
  • storage.util.ts — reads, writes, and clears stored filter values
  • dayjs.util.ts and date.ts — date library configuration, timezone conversion, and formatting for tables and filters
  • form.util.ts — maps per-field server errors into the form and scrolls to the first offending field
  • line.util.ts and url.util.ts — LINE-specific validators and URL construction for the various services
  • slugify.ts — Thai transliteration and slug generation
  • donwload-file.util.ts — file downloading, prepending a byte-order mark to CSV so Thai text renders correctly in spreadsheet applications

Dependencies

  • Permission system — every subject comes from one shared enum, and the only supported actions are view and export. Permissions originate from a per-user permission request, which is transformed into an ability set and stored.
  • App registry — the shared source consulted by both the side menu and the quick-access palette to decide which optional apps to surface.
  • Authentication — tokens, route guarding, and the idle timer are covered in the authentication document and run through the same providers described here.
  • Organisation-level module settings — determine which modules are unlocked for an organisation, which in turn shapes the permissions a user receives.
  • What must be updated together when adding a page — add the subject to the enum, add a backend module mapping if it is a new module, add the side menu entry along with its ordering position, add the quick-access entry, and finally register the translation namespace with files in both languages plus the menu label key in the shared message set.

Backend Details (CMS API)

The CMS shell and its menus rest on two server-side foundations: a core HTTP layer that fixes the shape of every response, and a four-layer permission system that decides who sees and can do what.

Four permission layers that behave very differently

This is the most easily confused part of the whole system, so it is worth separating carefully.

  1. Action-level policies — declared on every route but with enforcement switched off. The policy checker lets every request through, carried over from the previous system, which also let everything through. The consequence is that the permission data in system_module and system_role_module is used only to show and hide menus in the CMS; it does not gate the API. An authenticated user who knows a path can still call endpoints whose menu entries are hidden.
  2. The super-admin guard — genuinely enforced, based on the user's role.
  3. ModuleGate — genuinely enforced. It lets a platform administrator disable a module for a specific organisation; a customer-side user calling the API directly then gets a 403.
  4. AppEnabledGuard — genuinely enforced. Add-on apps (appointment / loyalty / bulletin) must be enabled for the organisation first.

The takeaway: what the frontend reads to decide menu visibility and what actually blocks a request server-side are not the same mechanism. Hiding a menu is not a security control, and only the last three layers block anything.

The permission request the shell makes

GET /api/user/:id/permission is the source of the ability set that drives the menu and the quick-access palette. Backend behaviour:

  • A user can only read their own permissions. Passing someone else's user ID is rejected. The endpoint declares no policy; the restriction lives in the service logic instead.
  • The backend loads the baseline permissions for the user's role and merges them with the full list of modules in the system.
  • If the user is customer-side and their organisation has its own module settings, those platform-administrator-defined settings replace the role baseline. This is why two users with the same role in different organisations can see different menus.
  • If the user is platform-side, the role baseline always applies and is never overridden by organisation settings.
  • The response is a list pairing each module name with the actions available, which the frontend turns into menu decisions.

ModuleGate fails open; app enablement fails closed

  • ModuleGate reads the caller's type from the request context. Platform-side callers (and callers whose identity cannot be determined) pass straight through. Customer-side callers are checked against whether the organisation has the module enabled, using the same baseline-then-override logic as the permission request.
  • If that lookup errors, ModuleGate lets the request through and logs it. The intent is to prevent a transient database problem from locking an entire organisation out of a module they pay for.
  • App enablement does the opposite and denies on uncertainty, because the risk there is privilege escalation rather than a service hiccup.
  • The modules currently wrapped in a real ModuleGate are: API keys, attribute master, audiences, auto-response, campaigns, customer database, form builder, friend track, import mapping, all-friends report, rich menu, rich message, template message, trigger rules, and workflow. Anything outside that list has no server-side module gating at all.

The core HTTP layer that makes every screen behave alike

  • Every request runs through a fixed middleware chain: per-request context creation, security headers, CORS, logging, the error translator, and a global rate limit.
  • The per-request context holds the user, organisation, LINE OA, role, and language. The JWT guard populates it, and every service reads scoping from there. That is why no endpoint accepts an organisation ID from the payload.
  • No module writes its own response JSON. Everything goes through shared helpers, so all 280-plus endpoints share one response envelope, one error code set, and messages in both Thai and English.
  • The error translator flattens every failure into the same shape — a code, a message, and optional data — mapping status to code: one code for 401, another for 500, and a distinct code for validation errors that arrive as a list. That mapping is the origin of the three error shapes the frontend has to handle.
  • Beyond the global rate limit, specific endpoints carry stricter per-route limits — password reset being much tighter than the rest.
  • Pagination shapes are defined centrally too, in both a data-plus-total form and a full form carrying the current page, page size, and total pages.

A note on legacy compatibility

This service was ported method-by-method from the previous system and deliberately preserves the old behaviour in full, including some defects that carry code comments saying not to fix them. The reason is that the CMS frontend depends on the existing response shapes; "correcting" one side alone would break screens. Any change to API behaviour must therefore be made on both sides at once.

Startup behaviour

If the primary database cannot be reached, the service retries and then exits. Auxiliary systems such as the cache and the message queue degrade quietly without blocking startup. The consequence is that the service can come up looking healthy while the message queue is unusable, which explains why some background work can vanish silently with no error shown in the CMS.