This is a template implementing auth and basic UI to cover for features logto offers. Clone this repo and build your
secured application on top. See the docs site at /getting-started/pre-requisites for setup instructions.
This Next.js app provides a dashboard, user button, theme and language primitives to sync your UI state, custom protected action runners.
- Responsive & Mobile Support: Has a handler that detects portrait or landscape orientation of the monitor. This is a exposed as a primitive you should use to sync your app's orientation to what the runtime selects.
- User Management: Profile, custom data (used for user extra data, Theme, Lang, Current org if a memeber), session management with device metadata (browser, OS, IP), current-session identification (
isCurrentbadge), per-sessionlastActiveAtwith automatic 30s heartbeat (blacktop only; disabled underBACKEND_TYPE=upstream), IP geolocation minimap (terrible accuracy by the way, should probably remove it), "Revoke all other sessions", optional purpose-scoped personal access token (PAT) management (private, default-off feature), identities, organizations, MFA (TOTP, backup codes, passkeys/WebAuthn) - User Display Components: UserButton (clickable avatar), UserBadge (display-only), UserCard (avatar + name card)
- i18n Support: Multi-language support with ENV-configured locale availability and ordering.
- MFA Management: TOTP enrollment, backup codes generation, and WebAuthn passkey management (register, rename, delete). Uses
@simplewebauthn/browserfor the browser ceremony. - User Preferences: Automatic persistence of theme and language choices in Logto customData.
- Auto-Refresh on Preference Change: When theme or language is changed, tabs automatically refresh to display the latest data from the server.
- Tab Configuration: You can select which tabs to display and their order via an ENV variable.
- Cookie Recovery: Automatic handling of stale cookie contexts via
/api/wipe(supports GET for browser redirect flow and POST for CSRF-safe programmatic use). - Proxy Choke-Point Auth: The Next.js middleware (
proxy.ts) is the network-level authentication boundary. Only/(minimal redirect), docs topic paths (/getting-started/*,/user-button/*,/dashboard/*,/tabs-and-flows/*,/rbac/*,/calculator/*,/anatomy/*,/security/*),/demo/*(demo routes),/api/auth/sign-in,/callback, and/api/wipeare publicly accessible. All other routes redirect unauthenticated users to sign-in. - Debug Logging: All sensitive debug output (tokens, IPs, introspection) is production-gated.
Before running the app locally you need:
| Requirement | Notes |
|---|---|
| Node.js 20+ | Required by Next.js 16 |
| A Logto instance | OSS (self-hosted) or Logto Cloud; you need an App ID/Secret and an M2M App ID/Secret |
| Redis 7+ | Needed for distributed rate limiting and per-user in-process locks across multiple instances. Now needed for runtime to avoid Logto's Next.js SDK shortcommings. |
| Docker + Docker Compose | Only if you want to run Redis via docker compose or deploy the full stack. |
1. Generate a password (do this once):
openssl rand -base64 322. Add to .env:
REDIS_PASSWORD=<paste-generated-password-here>
REDIS_URL=redis://:${REDIS_PASSWORD}@localhost:2998
REDIS_URLuses the variable-substitution form so the password appears in only one place. Copy it exactly as shown.
3. Start Redis (Docker):
docker compose up -d redisThis starts redis:7-alpine bound to 127.0.0.1:2998 (loopback-only) with requirepass enforced.
If you prefer not to use Redis at all, omit
REDIS_URLfrom.env. The app falls back to per-process in-memory state (rate limiting, locks), which is fine for screwing around during development of your own app, but you will face bugs with session state. They usually manifest as "Cookies can only be modified in a Server Action or Route Handler" (to be clear, I do not think I at any point hand roll or touch auth cookies) so I placed the actual auth state in Redis (not great.) and let the cookie hold pointers to it. Unless you come up with a good workaround to handle this, please use Redis in prod. This solution is stable and holds up in my own tests.
# 1. Install dependencies
npm install
# 2. Copy the example env file and fill in your values
cp .env.example .env.local
# 3. (Optional) Start Redis
docker compose up -d redis
# 4. Start the dev server
npm run dev
# → http://localhost:3000The app opens at /, which immediately redirects to /getting-started/pre-requisites (a public docs page). Sign in is required to access protected routes. Click the UserButton (top-right avatar) or any protected navigation link to open the dashboard auth prompt, which triggers a full-page OAuth redirect to Logto.
The proxy (proxy.ts) is the primary authentication choke point:
| Path | Access |
|---|---|
/ |
Public — redirects to /getting-started/pre-requisites |
/getting-started/*, /user-button/*, /dashboard/*, /tabs-and-flows/*, /rbac/*, /calculator/*, /anatomy/*, /security/* |
Public — documentation pages (served by the (docs) route group) |
/demo and /demo/* |
Public — interactive demo app |
/api/auth/sign-in |
Public — OAuth sign-in initiator |
/callback |
Public — OAuth callback receiver |
/api/wipe |
Public — session/cookie cleaning route |
| Everything else | Requires authentication |
Unauthenticated users who access a protected URL directly (or via browser refresh) are redirected to /api/auth/sign-in by the proxy.
./
├── .dockerignore
├── .github/
│ └── workflows/
│ └── semgrep.yml
├── .gitignore
├── .semgrep.yml
├── .semgrepignore
├── AGENTS.md
├── app/
│ ├── (docs)/
│ │ ├── [topic]/
│ │ │ └── [section]/
│ │ │ └── page.tsx
│ │ ├── docs-error-fallback.tsx
│ │ ├── layout-client.tsx
│ │ └── layout.tsx
│ ├── api/
│ │ ├── auth/
│ │ │ └── sign-in/
│ │ │ └── route.ts
│ │ ├── protected/
│ │ │ └── route.ts
│ │ └── wipe/
│ │ └── route.ts
│ ├── callback/
│ │ └── route.ts
│ ├── demo/ # Self-documenting showcase for logto-kit
│ │ ├── components/ # Shared UI and utilities for doc pages
│ │ │ ├── calculator/
│ │ │ │ ├── CalculatorClient.tsx # Calculator UI + expression parser
│ │ │ │ └── CalculatorPanel.tsx # Protected wrapper for the calculator
│ │ │ ├── Section.tsx # Multi-page section layout with keyboard nav
│ │ │ ├── SectionComponents.tsx # Pre-built section header/wrap components
│ │ │ ├── SyntaxBlock.tsx # Syntax-highlighted code block (VSCode Dark+)
│ │ │ └── useDocStyles.ts # Shared CSS-in-JS styles for doc pages
│ │ ├── content/ # Content components for the docs
│ │ │ ├── anatomy/
│ │ │ │ ├── async-patterns.tsx
│ │ │ │ ├── i18n.tsx
│ │ │ │ ├── primitives.tsx
│ │ │ │ ├── providers.tsx
│ │ │ │ └── theme.tsx
│ │ │ ├── calculator/
│ │ │ │ ├── api-authorization.tsx
│ │ │ │ ├── live-calculator.tsx
│ │ │ │ ├── live-demo.tsx
│ │ │ │ ├── overview.tsx
│ │ │ │ └── rbac-design.tsx
│ │ │ ├── dashboard/
│ │ │ │ ├── internals.tsx
│ │ │ │ ├── mobile.tsx
│ │ │ │ ├── provider-sync.tsx
│ │ │ │ ├── rendering.tsx
│ │ │ │ └── tab-structure.tsx
│ │ │ ├── getting-started/
│ │ │ │ ├── avatar-upload.tsx
│ │ │ │ ├── backend-selection.tsx
│ │ │ │ ├── clone-and-install.tsx
│ │ │ │ ├── env-setup.tsx
│ │ │ │ ├── logto-console.tsx
│ │ │ │ ├── pre-requisites.tsx
│ │ │ │ └── replace-the-demo.tsx
│ │ │ ├── rbac/
│ │ │ │ ├── api.tsx
│ │ │ │ └── ui-protected.tsx
│ │ │ ├── security/
│ │ │ │ ├── error-handling.tsx
│ │ │ │ ├── input-guards.tsx
│ │ │ │ └── logging.tsx
│ │ │ ├── tabs-and-flows/
│ │ │ │ ├── dev.tsx
│ │ │ │ ├── identities.tsx
│ │ │ │ ├── organizations.tsx
│ │ │ │ ├── overview.tsx
│ │ │ │ ├── preferences.tsx
│ │ │ │ ├── profile.tsx
│ │ │ │ ├── security.tsx
│ │ │ │ └── sessions.tsx
│ │ │ └── user-button/
│ │ │ ├── examples.tsx
│ │ │ └── specs.tsx
│ │ ├── ContentArea.tsx
│ │ ├── index.tsx
│ │ ├── MobileDocsNav.tsx
│ │ ├── nav-data.tsx
│ │ ├── Sidebar.tsx
│ │ └── types.ts
│ ├── error.tsx
│ ├── global-error.tsx
│ ├── globals.css
│ ├── layout.tsx
│ ├── lib/
│ │ ├── distributed-state.ts
│ │ ├── log-events.ts
│ │ ├── logger.ts
│ │ ├── request-context.ts
│ │ ├── scrub-log-string.ts
│ │ ├── slugify.ts
│ │ └── with-logger.ts
│ ├── logto-kit/
│ │ ├── config.ts # Logto SDK config + M2M token helper
│ │ ├── action-registry/ # Protected action registry for /api/protected
│ │ │ ├── calc-actions.ts # Calculator action handlers (basic + scientific)
│ │ │ ├── index.ts # Registry types and getAction() loader
│ │ │ └── validate-action-config.ts
│ │ ├── components/
│ │ │ ├── auth-error-banner.tsx # Auth error display banner
│ │ │ ├── LangSync.tsx # Language synchronization component
│ │ │ ├── dashboard/
│ │ │ │ ├── client.tsx # Desktop dashboard UI
│ │ │ │ ├── dashboard-router.tsx # Responsive desktop/mobile router
│ │ │ │ ├── index.tsx # Server component: fetches data, wires actions
│ │ │ │ ├── mobile-client.tsx # Mobile dashboard UI
│ │ │ │ ├── mobile-page.tsx # Server component: mobile counterpart
│ │ │ │ ├── shared/
│ │ │ │ │ ├── CodeBlock.tsx # Themed JSON/data display block
│ │ │ │ │ ├── ContactRow.tsx # Email/phone row with verification flow
│ │ │ │ │ ├── FarewellOverlay.tsx
│ │ │ │ │ ├── FlowModal.tsx # Multi-step modal for security flows
│ │ │ │ │ ├── focus-trap.ts
│ │ │ │ │ ├── ImageCropper.tsx # Canvas-based drag/zoom image cropper
│ │ │ │ │ ├── primitives.tsx # Shared UI atoms: Card, HR, IconBox, SL, Lbl
│ │ │ │ │ ├── rbac-streams.tsx
│ │ │ │ │ ├── RefreshButton.tsx
│ │ │ │ │ ├── RoleCard.tsx # Role badge with lazy-loaded tooltip
│ │ │ │ │ ├── SessionMapModal.tsx
│ │ │ │ │ ├── SignOutModal.tsx
│ │ │ │ │ ├── TabErrorBoundary.tsx
│ │ │ │ │ ├── Toast.tsx
│ │ │ │ │ └── tooltip-position.ts
│ │ │ │ ├── tab-utils.tsx # getTabLabel (shared by desktop + mobile)
│ │ │ │ ├── tabs/
│ │ │ │ │ ├── dev.tsx # Personal access token management
│ │ │ │ │ ├── identities.tsx
│ │ │ │ │ ├── organizations.tsx
│ │ │ │ │ ├── preferences.tsx
│ │ │ │ │ ├── profile.tsx
│ │ │ │ │ ├── security.tsx
│ │ │ │ │ └── sessions.tsx
│ │ │ │ └── types.ts
│ │ │ ├── providers/ # Context providers and behavior components
│ │ │ │ ├── auth-watcher.tsx # Zero-UI component: refreshes on tab focus / reconnect
│ │ │ │ ├── logto-provider.tsx # Root provider composing theme + lang + org + dashboard
│ │ │ │ ├── preferences.tsx # Theme / lang / org context + useThemeMode etc.
│ │ │ │ ├── rbac-stream-context.tsx # RBAC streaming context
│ │ │ │ ├── session-heartbeat.tsx # Zero-UI component: pings heartbeat every 30s
│ │ │ │ ├── toast-provider.tsx # Toast notification provider
│ │ │ │ └── user-data-context.tsx # UserData context + useUserDataContext hook
│ │ │ ├── shared/
│ │ │ │ ├── Button.tsx # Themed button (5 variants)
│ │ │ │ ├── Input.tsx # Themed text input
│ │ │ │ ├── LanguageSelect.tsx # Language selection dropdown
│ │ │ │ ├── motion.tsx # Animation utilities
│ │ │ │ └── PhoneCountrySelect.tsx # Phone country code selector
│ │ │ └── UserButton.tsx # UserButton, UserBadge, UserCard components
│ │ ├── custom-logic/ # App-level feature implementations
│ │ │ ├── index.ts
│ │ │ ├── org-switcher-wrapper.tsx # Server wrapper: fetches org list automatically
│ │ │ └── set-active-org.ts # Server action: validates org membership
│ │ ├── hooks/ # React hooks
│ │ │ ├── sessions/ # Session-related hooks
│ │ │ │ ├── index.ts # Barrel export
│ │ │ │ ├── use-session-geo-locate.ts # Session IP geolocation
│ │ │ │ ├── use-session-revocation.ts # Session revocation hook
│ │ │ │ └── use-session-verification.ts # Session verification hook
│ │ │ ├── use-async-guard.ts
│ │ │ ├── use-async-list.ts
│ │ │ ├── use-auth-gated-action.ts
│ │ │ ├── use-avatar-modal.ts
│ │ │ ├── use-avatar-upload.ts # Hook wrapping the uploadAvatar server action
│ │ │ ├── use-data-fetch.ts
│ │ │ ├── use-modal-flow.ts
│ │ │ ├── use-name-form.ts
│ │ │ ├── use-org-permissions.ts
│ │ │ ├── use-org-roles.ts
│ │ │ ├── use-org-switcher.ts
│ │ │ ├── use-personal-permissions.ts
│ │ │ ├── use-personal-roles.ts
│ │ │ ├── use-refreshable.ts # Hook for unmount/remount refresh cycles
│ │ │ ├── use-scroll-lock.ts
│ │ │ └── use-tooltip-trigger.ts
│ │ ├── index.ts # Public barrel
│ │ ├── locales/
│ │ │ ├── en-US.ts
│ │ │ ├── index.ts
│ │ │ ├── ka-GE.ts
│ │ │ └── uk-UA.ts
│ │ ├── logic/
│ │ │ ├── assert-safe-route.ts # Safe route path validator
│ │ │ ├── actions/ # Internal server actions
│ │ │ │ ├── account.ts
│ │ │ │ ├── auth.ts
│ │ │ │ ├── avatar.ts # Avatar upload (S3/Logto Native)
│ │ │ │ ├── dashboard.ts
│ │ │ │ ├── heartbeat.ts
│ │ │ │ ├── helpers.ts
│ │ │ │ ├── index.ts # Barrel (re-exports all actions)
│ │ │ │ ├── introspection.ts
│ │ │ │ ├── management-request.ts
│ │ │ │ ├── mfa.ts
│ │ │ │ ├── organizations.ts
│ │ │ │ ├── pat.ts # Purpose-scoped Management API PAT actions
│ │ │ │ ├── password.ts
│ │ │ │ ├── profile.ts
│ │ │ │ ├── request.ts # Authenticated Account API HTTP client
│ │ │ │ ├── roles.ts
│ │ │ │ ├── safe.ts # safeAction wrapper + ActionResult/DataResult types
│ │ │ │ ├── sessions.ts
│ │ │ │ ├── shared.ts # patchMyAccount helper
│ │ │ │ ├── tokens.ts
│ │ │ │ ├── verification-cookie.ts
│ │ │ │ ├── verification.ts
│ │ │ │ └── webauthn.ts
│ │ │ ├── audit.ts
│ │ │ ├── cached-dashboard.ts
│ │ │ ├── cached-rbac.ts
│ │ │ ├── capture-message.ts
│ │ │ ├── client-logger.ts
│ │ │ ├── client-storage.ts # Client-side storage utilities
│ │ │ ├── constants.ts # Shared constants
│ │ │ ├── cookie-utils.ts
│ │ │ ├── country-codes.ts
│ │ │ ├── country-list-filter.ts # Phone country code filtering
│ │ │ ├── dashboard-data.ts
│ │ │ ├── debug.ts
│ │ │ ├── dev-mode.ts
│ │ │ ├── env.ts
│ │ │ ├── error-codes.ts # Error code definitions
│ │ │ ├── errors.ts
│ │ │ ├── formatting.ts # formatPhone (E.164 display)
│ │ │ ├── geo-cache.ts # IP geolocation TTL cache (no React dependency)
│ │ │ ├── guards.ts # Assert-style input guards for trust boundaries
│ │ │ ├── i18n.ts
│ │ │ ├── index.ts
│ │ │ ├── lang-allowlist.ts # Language allowlist filtering
│ │ │ ├── log.ts
│ │ │ ├── map-error-toast.ts # Maps error codes to toast messages
│ │ │ ├── origin-guard.ts # CSRF origin check for plain route handlers
│ │ │ ├── OrgSwitcher.tsx # Org selector dropdown (client, moved from custom-logic)
│ │ │ ├── preferences.ts
│ │ │ ├── Protected.tsx # Client-side UI permission gate (moved from custom-logic)
│ │ │ ├── rbac-data.ts
│ │ │ ├── tabs.ts
│ │ │ ├── types.ts # All domain types (UserData, MfaType, etc.)
│ │ │ ├── utils.ts
│ │ │ ├── validation.ts
│ │ │ └── verbosity.ts # Error verbosity control
│ │ ├── server-actions/ # Public server action adapters (client-callable)
│ │ │ ├── index.ts
│ │ │ ├── load-org-permission-descriptions.ts
│ │ │ ├── load-org-permissions.ts
│ │ │ ├── load-org-roles.ts
│ │ │ ├── load-personal-permissions.ts
│ │ │ └── load-personal-roles.ts
│ │ └── themes/
│ │ ├── default/
│ │ │ ├── dark.css
│ │ │ └── light.css
│ │ └── index.ts # ThemeColors, DARK_COLORS, LIGHT_COLORS, FONT_SANS/MONO
│ └── page.tsx
├── proxy.ts
├── docker-compose.yml
├── docker-entrypoint.sh
├── Dockerfile
├── CHANGELOG.md
├── eslint.config.mjs
├── instrumentation.ts
├── LICENSE
├── next-env.d.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── public/
│ ├── os-icons/ # OS icons for session cards
│ │ ├── Android.svg
│ │ ├── ios.svg
│ │ ├── MacOS.svg
│ │ ├── MacroSlop.svg
│ │ └── Tux.jpg
│ └── robots.txt
├── scripts/
│ ├── copy-standalone-assets.js
│ └── inject-next-public.js
├── global.d.ts
├── SECURITY.md
├── tree.py
├── tsconfig.json
├── vitest.config.ts
├── vitest.server-only.mock.ts
└── vitest.setup.ts
Test files (.test.ts / .test.tsx) are co-located alongside their source modules.
The project ships with a Dockerfile, .dockerignore, and docker-compose.yml for deploying behind a Cloudflare Tunnel.
| File | Purpose |
|---|---|
Dockerfile |
3-stage build: install deps → next build (with NEXT_PUBLIC_* baked in) → minimal standalone runtime |
.dockerignore |
Excludes node_modules, .next, .env*, test files from build context |
docker-compose.yml |
Three services: logto-dash (app) + cloudflared (CF tunnel) + redis (optional cache), on an internal bridge network |
Internet → Cloudflare Tunnel → cloudflared (container)
↓
dash-net (bridge)
↓
logto-dash:2999 (not exposed to host)
Port 2999 is never mapped to the Docker host - only cloudflared can reach it on the internal dash-net network.
1. Fill in your .env
Copy .env.example → .env and populate all required vars. Then set:
# Your public CF tunnel URL - mapped to BASE_URL at container runtime
PUBLIC_BASE_URL=https://dash.yourdomain.org
# From: Cloudflare Zero Trust → Networks → Tunnels → Create a tunnel
CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token
PUBLIC_BASE_URLis your public CF tunnel URL. The container maps it toBASE_URLat runtime, which the Logto SDK uses for OIDC redirect URIs and origin checking. For local development (npm run dev), setBASE_URL=http://localhost:3000instead.
2. Configure tunnel routing in the Cloudflare dashboard
In your tunnel's public hostname settings, point your domain to:
http://logto-dash:2999
3. Build and run
docker compose build
docker compose up -dUpdating after code changes:
git pull
docker compose build
docker compose up -dNEXT_PUBLIC_* variables are inlined into the client-side JavaScript bundle at build time by the Next.js compiler. They cannot be changed at runtime without rebuilding.
The docker-compose.yml passes them as build.args sourced from your .env file. If you change any NEXT_PUBLIC_* variable, rebuild the image:
docker compose build --no-cache
docker compose up -dBuild args currently include:
NEXT_PUBLIC_THEMENEXT_PUBLIC_LANG_MAINNEXT_PUBLIC_LANG_AVAILABLENEXT_PUBLIC_MFA_ISSUERNEXT_PUBLIC_DEFAULT_THEME_MODENEXT_PUBLIC_USER_SHAPENEXT_PUBLIC_NAME_TYPENEXT_PUBLIC_LOAD_TABSNEXT_PUBLIC_DELETE_REDIRECT_DELAYNEXT_PUBLIC_BACKEND_TYPENEXT_PUBLIC_SIGNOUT_REDIRECT_DELAYNEXT_PUBLIC_FORCE_ANIMATIONS
Runtime env passthrough currently includes backend and country behavior gates:
BACKEND_TYPEPFP_BACKENDCOUNTRY_CODE_ALLOW_LISTCOUNTRY_CODE_BLOCK_LISTLOGTO_M2M_RESOURCEPAT_ENABLED(private PAT feature gate; defaults to false)
v0.3.0 introduced dedicated security modules for defense-in-depth; v0.4.0 extended the model with server-sealed identity verification (BUG-001).
| Module | Location | Purpose |
|---|---|---|
origin-guard.ts |
app/logto-kit/logic/origin-guard.ts |
CSRF protection - validates Origin header on /api/wipe and /api/protected only (auth routes rely on OAuth state; Server Actions have built-in origin validation) |
guards.ts |
app/logto-kit/logic/guards.ts |
Input validators for all trust boundaries - IDs, user IDs, MFA types, passkey names, custom data |
audit.ts |
app/logto-kit/logic/audit.ts |
Audit log primitive - immediately emits structured records through the shared logger for mutations |
dev-mode.ts |
app/logto-kit/logic/dev-mode.ts |
NODE_ENV gate - strips dev-only features at runtime in non-development/test environments |
To replace the default audit logger path, create app/logto-kit/audit-transport.ts exporting a default async function(entry: AuditEntry).
Destructive operations (account deletion, password change, email/phone update, MFA enrollment, session revocation, identity linking) require identity re-verification via verifyPasswordForIdentity. After verification succeeds, the server seals { recordId, expiresAt } into an httpOnly, HMAC-signed cookie (app/logto-kit/logic/actions/verification-cookie.ts). Each destructive action reads that cookie via requireVerifiedIdentity(recordId), verifies the HMAC, binds the sealed recordId to the client-supplied record ID, and runs assertVerificationNotExpired(sealed.expiresAt). The expiry is server-authoritative; the client never provides its own timestamp (all 18 destructive actions had verificationTimestamp removed in commit 6964022). A dedicated LOGTO_VERIFICATION_COOKIE_SECRET is preferred in production, with COOKIE_SECRET as the fallback.
APP_ID=your-app-id
APP_SECRET=your-app-secret
ENDPOINT=https://auth.yourdomain.org
BASE_URL=http://localhost:3000
COOKIE_SECRET=your-random-secret
# Scopes (comma-separated; recommended - empty produces a warning but does not prevent startup)
# Must include: openid,profile,custom_data,email,phone,identities,sessions
# Add: organizations for org features
# Add: organization_roles for org role data
# Add: offline_access for refresh tokens
# Custom scopes pass through to the Logto SDK unchanged
SCOPES=openid,profile,custom_data,email,phone,identities,sessions,organizations,organization_roles# Dedicated verification-cookie secret (preferred); falls back to COOKIE_SECRET
# In production, one of these secrets must be set.
# Used for the BUG-001 server-sealed identity verification cookie (HMAC-signed httpOnly).
# After verifyPasswordForIdentity succeeds, the server seals { recordId, expiresAt } into
# this cookie. Each destructive action reads it via requireVerifiedIdentity(recordId),
# verifies the HMAC, binds the sealed recordId to the client-supplied record ID, and runs
# assertVerificationNotExpired(sealed.expiresAt). The expiry is server-authoritative.
LOGTO_VERIFICATION_COOKIE_SECRET=your-random-verification-cookie-secret# M2M for Management API (required for account deletion)
LOGTO_M2M_APP_ID=your-m2m-app-id
LOGTO_M2M_APP_SECRET=your-m2m-app-secret
LOGTO_M2M_RESOURCE=https://your-tenant.logto.app/api # Logto Cloud. For OSS, defaults to https://default.logto.app/api
# Token Introspection (required for Protected Actions API)
LOGTO_INTROSPECTION_URL=https://your-tenant.logto.app/oidc/token/introspectionM2M credentials are required for account deletion, enabled Dev/PAT management, and other Management API operations. PAT actions derive the user from session introspection and require no additional end-user OIDC scope. Avatar uploads use the session user's access token via the Account API (/api/my-account/avatar) when using Logto Native, or S3/Supabase storage; avatar uploads do not require M2M credentials. User data retrieval also uses the user's own access token via the Account API (/api/my-account).
PAT management has a private server-side hard lock. Missing, empty, false, 1, or any other value keeps it disabled; only PAT_ENABLED=true after trimming and case normalization enables it. There is no NEXT_PUBLIC_PAT_ENABLED setting.
Enabling PAT management requires all three of the following:
- Set the private runtime variable
PAT_ENABLED=true. - Include
dev(or one of its aliases) inLOAD_TABSwhen using an explicit tab list. If the list is empty, Dev joins the normal default order only while PAT is enabled. - Configure the existing M2M application for Logto Management API access.
No additional end-user OIDC scope is required. Turning the flag off hides the Dev tab and makes direct list/create/rename/delete actions return PAT_DISABLED before validation, session, M2M, lock, verification, or upstream work. It does not revoke PATs that were already issued by Logto.
When enabled, opening Dev starts a view password check over a locked skeleton before the token list becomes interactive. Create, rename, and delete each require one new password verification for their own purpose (pat.create, pat.rename, or pat.delete); the view verification does not authorize mutations. The view, create, and rename password prompts use the primary blue treatment; the delete password prompt is destructive red and opens directly, with no intermediate confirmation step. After creation, the result modal contains only the full one-time token value with copy and close controls—no metadata, usage prose, or generated usage snippet.
# Default-off configuration: all ordinary tabs, no Dev/PAT tab
# Allowed: profile, preferences, security, sessions, identities, organizations, dev
# Aliases: personal, user → profile; prefs, custom-data, custom, customdata → preferences; mfa, 2fa, totp → security; sessions, session, devices, activity → sessions; identity → identities; orgs, org → organizations; developer, pat, pats, pat-tokens, tokens → dev
PAT_ENABLED=false
LOAD_TABS=profile,preferences,security,sessions,identities,organizations
# Explicit PAT opt-in (also requires the existing Management API M2M setup)
# PAT_ENABLED=true
# LOAD_TABS=profile,preferences,security,sessions,identities,organizations,devPAT_ENABLED is private. dev and all of its aliases are removed from either LOAD_TABS or NEXT_PUBLIC_LOAD_TABS while the flag is off. Mixed lists retain the configured order of their non-Dev tabs and remove duplicates. Missing, empty, all-invalid, or Dev-only input falls back to all non-Dev tabs in the existing default order while PAT is off.
# Redirect delay after account deletion in ms (default: 3000)
DELETE_REDIRECT_DELAY=3000# Theme folder name (default: default)
# Folder name under app/logto-kit/themes/ containing dark.css and light.css
THEME=default
# Default theme mode: dark or light (default: dark)
DEFAULT_THEME_MODE=dark
# User avatar/badge shape: circle, sq (square), rsq (rounded square), or custom border-radius (e.g., 0.5rem, 4px)
USER_SHAPE=circle# Controls how user names are displayed in the Profile tab (default: given_family)
# given_family - shows Given Name + Family Name fields
# username - shows Username field
# full - shows all three (Given Name, Family Name, Username)
NAME_TYPE=given_family# MFA Configuration
# Name that will show up in the TOTP QR code issuer field
MFA_ISSUER=YourAppNameWebAuthn Origins: For WebAuthn passkey operations to work from your app's domain, you must configure
webauthnRelatedOriginsin your Logto tenant's Account Center settings. This allows cross-origin passkey operations from your deployed app domain.
# Default language
LANG_MAIN=en-US
# Available languages
LANG_AVAILABLE=en-US,ka-GE,uk-UA# Set to true to enable verbose server-side debug logging
# (token introspection, API request flow, permission checks)
# DEBUG=true# Logging output destination (default: console)
# Options: console, pino, both
# LOG_BACKEND=console
# Minimum log severity level (default: info)
# Options: trace, debug, info, warn, error, fatal, silent
# LOG_LEVEL=info
# Slack or Discord webhook URL for error telemetry
# Errors are posted as structured JSON payloads to this webhook
# LOGGING_WEBHOOK_URL=https://hooks.slack.com/services/T.../B.../xxx# Controls the level of detail in error responses (default: each code's registry setting)
# Options: generic, specific, silent
# If unset, the per-code default applies (specific for most registered codes).
# ERROR_VERBOSITY=specific
# NEXT_PUBLIC_ERROR_VERBOSITY=specific# CSP img-src directive origin for allowed image sources
# Used in Content-Security-Policy header construction
# IMG_ORIGIN=https://your-cdn.example.com# Force CSS animations on or off, overriding prefers-reduced-motion
# Options: true, false, or unset (respects system preference)
# NEXT_PUBLIC_FORCE_ANIMATIONS=trueSet BACKEND_TYPE to select the capability mode of your Logto backend environment:
# Values: blacktop | upstream
# Server default: upstream (if unset or invalid)
BACKEND_TYPE=upstreamblacktop(Custom Fork Mode): Enables all advanced features. This includes:- Verification-code locale pass-through: The dashboard sends the user's selected language as a
localefield inPOST /api/verifications/verification-codeso email/SMS passcodes are rendered in that language. The value is the same BCP 47 tag used for UI translations (e.g.,en-US,ka-GE,uk-UA). - Real-Time Heartbeats: Keeps the session fresh by posting heartbeats every 30 seconds.
- Last Active Timestamps: Tracks and displays precise
lastActiveAttimestamps for active user sessions. - Logto Native Avatar Uploads: Allows choosing
PFP_BACKEND=logtoto store profile pictures natively in Logto customData.
- Verification-code locale pass-through: The dashboard sends the user's selected language as a
upstream(Stock OIDC / Official SaaS Mode): Intended for stock Logto Open-Source or Cloud instances. In this mode, certain custom features are automatically disabled to guarantee compatibility:- Verification-code locale pass-through is disabled to remain compatible with stock Logto Account APIs. The
localefield is omitted entirely from the request body (not sent as null or empty); the payload matches stock Logto API calls. - Heartbeats: The 30s background heartbeat loop is completely disabled.
- Last Active Timestamps: Precise active session tracking is disabled, and timestamps are hidden in the Sessions tab.
- Avatar Uploads: The
logtonative backend is disabled. S3-compatible storage (s3) is strictly forced as the effective backend, even ifPFP_BACKENDis set tologto.
- Verification-code locale pass-through is disabled to remain compatible with stock Logto Account APIs. The
Set BACKEND_TYPE explicitly in your env file. The resolver falls back to upstream when missing or invalid.
Configure how phone number registration and update validation behaves for different countries.
You can specify either an allow-list or a block-list (they are mutually exclusive). If both are set, the allow-list takes precedence. If neither is set, filtering mode is none and all countries are allowed.
# Comma-separated list of country dial codes to ALLOW (e.g., 1,995,380).
# If specified, ONLY these country codes can be selected or saved.
# COUNTRY_CODE_ALLOW_LIST=1,995,380
# Comma-separated list of country dial codes to BLOCK (e.g., 7,86).
# If specified, phone numbers from these countries are rejected.
# COUNTRY_CODE_BLOCK_LIST=7,86- Interactive Dropdown Combobox: When adding or updating phone numbers, the PhoneCountrySelect dropdown matches and filters active countries on-the-fly. If an allow-list is active, only permitted countries are shown. If a block-list is active, blocked countries are removed from the options list.
- Empty filtered list: If filtering produces zero active countries, the selector is disabled and has no options.
- Server-Side Verification: Server actions normalize phone input to digits and enforce the country filter. In allow mode, unknown or unmapped prefixes are rejected with
PHONE_COUNTRY_NOT_ALLOWED.
Avatar backend selection follows this matrix:
BACKEND_TYPE=blacktop:PFP_BACKENDcan belogtoors3BACKEND_TYPE=upstream: effective avatar backend is alwayss3
Choose between these storage backends when BACKEND_TYPE=blacktop:
Set PFP_BACKEND=s3 (default) and configure S3 variables:
PFP_BACKEND=s3
# S3-Compatible Storage (Supabase, AWS S3, MinIO, DigitalOcean Spaces)
S3_BUCKET_NAME=avatars
S3_PUBLIC_URL=https://your-project.supabase.co/storage/v1/object/public/avatars
S3_ENDPOINT=https://your-project.supabase.co/storage/v1/s3
S3_ACCESS_KEY_ID=your_access_key
S3_SECRET_ACCESS_KEY=your_secret_key
S3_REGION=auto
# Optional: Supabase REST API (more reliable)
# SUPABASE_SERVICE_ROLE_KEY=your_service_role_keyUse this when you want full control over storage, already have S3 infrastructure, or need CDN integration.
Set PFP_BACKEND=logto to use Logto's built-in avatar handling:
PFP_BACKEND=logtoNo S3 configuration required. Logto handles upload, storage, and cleanup internally using your existing Logto endpoint. Use this for simpler deployments without external storage infrastructure.
Note: Full configuration details are in
.env.exampleand the Avatar Upload section below.
All user-facing config variables support NEXT_PUBLIC_ prefixes for Next.js build-time inlining into client bundles: NEXT_PUBLIC_THEME, NEXT_PUBLIC_DEFAULT_THEME_MODE, NEXT_PUBLIC_USER_SHAPE, NEXT_PUBLIC_LANG_MAIN, NEXT_PUBLIC_LANG_AVAILABLE, NEXT_PUBLIC_MFA_ISSUER, NEXT_PUBLIC_LOAD_TABS, NEXT_PUBLIC_NAME_TYPE, NEXT_PUBLIC_DELETE_REDIRECT_DELAY, NEXT_PUBLIC_BACKEND_TYPE, NEXT_PUBLIC_SIGNOUT_REDIRECT_DELAY, NEXT_PUBLIC_FORCE_ANIMATIONS. The private PAT_ENABLED hard lock is deliberately excluded from this list and has no public-prefixed equivalent.
Redis is optional but recommended for multi-instance deployments. It provides distributed rate limiting and per-user operation serialization.
# Generate with: openssl rand -base64 32
REDIS_PASSWORD=<generated-password>
# Variable-substitution form — password appears only once
REDIS_URL=redis://:${REDIS_PASSWORD}@localhost:2998When REDIS_URL is not set, the app falls back to per-process in-memory state. This is fine for single-instance dev, but not safe for multi-instance production.
The Docker Compose redis service uses redis:7-alpine, binds to the loopback interface only (127.0.0.1:2998:6379), and enforces requirepass via the REDIS_PASSWORD env var.
# Public base URL for Docker deployment - mapped to BASE_URL at container runtime
# Must match your Cloudflare Tunnel domain
PUBLIC_BASE_URL=https://dash.yourdomain.orgThemes are CSS-only. Each theme is a folder in app/logto-kit/themes/ containing dark.css and light.css with CSS custom properties (--ldd-*). CSS files are loaded via @import in app/globals.css - change these paths to switch theme folders.
data-themeattribute on<html>controls which CSS custom properties are activeuseThemeMode()provides{ mode, colors, setMode, toggleMode }for inline React stylesThemeColorsinterface +DARK_COLORS/LIGHT_COLORSconstants provide JS color values that mirror the CSS variables- All other design values (typography, radii, shadows, transitions) are hardcoded directly in component source code
- CSS files are imported via
@importinapp/globals.css- change these paths to switch theme folders
- Copy
app/logto-kit/themes/default/to a new folder likeapp/logto-kit/themes/my-brand/ - Edit the hex values in
dark.cssandlight.css - Update
app/globals.cssto point to your new folder:@import './logto-kit/themes/my-brand/dark.css'; @import './logto-kit/themes/my-brand/light.css';
- Optionally export matching
ThemeColorsconstants if you want inline styles to match your custom CSS:export const MY_DARK_COLORS: ThemeColors = { ...DARK_COLORS, accentBlue: '#8b5cf6' };
No JS registration or factory functions are needed.
Control the avatar/badge shape across the dashboard:
USER_SHAPE=circle # Circular (default)
USER_SHAPE=sq # Square
USER_SHAPE=rsq # Rounded square
USER_SHAPE=0.5rem # Custom border-radiusThe project includes a demo app at /demo that acts as a self-documenting showcase for the logto-kit components.
The demo app (app/demo/) is a standalone application with 8 top-level sidebar topics (each with subtopic sections):
| Tab | Type | Description |
|---|---|---|
| Getting Started | guide | Clone, configure, set up avatar upload, Logto Console setup |
| UserButton | component | Clickable avatar button, badge, and card with props table and live examples |
| Dashboard | component | Full user management dashboard modal, including Sessions and Dev/PATs (internals, provider sync, tab config, rendering, mobile) |
| Tabs & Flows | reference | Deep dive into each dashboard tab: profile, preferences, security, sessions, Dev/PATs, identities, organizations |
| RBAC | reference | <Protected />, OrgSwitcher, setActiveOrg, Protected Actions API, and permission-gated access patterns |
| Calculator | component | Permission-gated calculator demo with live RBAC examples |
| Anatomy | reference | Providers, theme system, i18n, async patterns, and reusable primitives |
| Security | reference | Error sanitization, input guards, logging infrastructure |
Subtopics: OrgSwitcher, Providers, Theme, i18n, Sessions, Error Handling, Input Guards, Logging, and Primitives are covered as subtopic pages within these 8 top-level topics.
Documentation is organized as standalone TSX files in app/demo/content/: Dashboard has 5 files, Tabs & Flows has 8, RBAC has 2, Anatomy has 5, and Security has 3. The UserButton tab has props, notes, and example cards. The tabs-and-flows docs cover Profile, Preferences, Security, Sessions, Dev/PATs, Identities, and Organizations with their props, hooks, actions, and flows.
Demo Dark Mode Palette: The docs pages and sidebar use a custom scoped dark palette (
app/globals.css→.docs-content-container) independent of the main dashboard theme for optimal reading contrast:
- Sidebar BG:
#050608- Docs content BG:
#08090a- Card/section BG:
#171c2a
The demo app consists of:
| File | Purpose |
|---|---|
index.tsx |
Demo page entry point |
Sidebar.tsx |
Navigation sidebar with user info and theme toggle |
ContentArea.tsx |
Main content area - lazy-loads doc files from the registry |
nav-data.tsx |
8-tab navigation definitions with section hints |
types.ts |
TypeScript type definitions |
content/getting-started/ |
Getting started guide - clone, configure, avatar upload, Logto Console |
content/user-button/ |
UserButton documentation - Quick Start, Props table, Notes, 6 example cards |
content/dashboard/ |
Dashboard documentation - Internals, Provider Sync, Tab Structure, Rendering, Mobile (5 pages) |
content/tabs-and-flows/ |
Detailed tabs documentation - props, hooks, actions for all dashboard tabs (8 pages, including Dev/PATs) |
content/rbac/ |
Protected component and API documentation - permission-based access control, server actions, and examples (2 pages: api, ui-protected) |
content/anatomy/ |
Providers, theme, i18n, primitives, and async patterns (5 pages) |
content/security/ |
Error handling (70 registered error codes), input guards (10 assert guards, 8 validate functions, safeUrl), and logging (3 pages: error-handling, input-guards, logging) |
content/calculator/ |
Permission-gated calculator demo with live RBAC examples |
components/SyntaxBlock.tsx |
Syntax-highlighted code block with VSCode Dark+ colors and copy button |
components/Section.tsx |
Legacy section layout component; current route content uses standalone components |
components/SectionComponents.tsx |
Pre-built page components for documentation (SectionHeader, SectionWrap) |
components/useDocStyles.ts |
Shared CSS-in-JS styles for documentation pages |
Each current doc file in content/ is a standalone client-side TSX component. Components commonly import useDocStyles for shared inline styles, use ordinary JSX headings, paragraphs, lists, and tables, and may use SyntaxBlock for code examples and SectionComponents helpers for heading IDs. The route wrapper renders the loaded component inside a plain padded <div>.
Typical layout:
- Two-column grid - Left and right sections side by side (matching
user-button.tsxpattern) - Single column - For detailed content like the Security tab's FlowModal architecture
To add documentation for a new topic/section:
- Create a TSX file inside
app/demo/content/{topic}/{section}.tsx - Add its loader to
CONTENT_REGISTRYinapp/(docs)/[topic]/[section]/page.tsx - Add the topic or section to
NAV_ITEMSand its description toSECTION_HINTSinapp/demo/nav-data.tsx - Use
useDocStylesfor shared styling,SyntaxBlockfor code, and ordinary JSX layouts for the page content
Visit /demo to see the demo app in action. It displays:
- A sidebar with 8 navigation tabs covering every major logto-kit feature
- A UserCard showing the logged-in user with name and avatar
- A theme toggle button
- A particle background effect
- Clicking any tab loads its documentation into the content area
- Selecting a topic/section loads its standalone content component through the docs route registry
The UserButton tab includes a Quick Start section, a full Props table with TypeScript interface, usage notes, and 6 interactive example cards. The Dashboard tab covers internals, provider sync, tab configuration, and rendering patterns across 5 pages with a two-column grid layout.
These shared utilities live in app/demo/components/ and are available to doc files:
A syntax-highlighted code block component using VSCode Dark+ color scheme. Includes a copy button that appears on hover.
import CodeBlock from '../components/SyntaxBlock';
// Basic
<CodeBlock code={`<UserButton Size="48px" />`} />
// With title bar
<CodeBlock title="Import" code={`import { UserButton } from './logto-kit';`} />| Prop | Type | Default | Description |
|---|---|---|---|
code |
string |
- | The code string to display |
lang |
'tsx' | 'ts' | 'bash' |
'tsx' |
Language label shown in the title bar |
title |
string |
- | Optional title shown above the code block |
Features: regex-based TSX tokenizer (no external deps), horizontal scroll for long lines, marginBottom: 6px, #1e1e1e background.
Use a standalone component with useDocStyles() and normal JSX. The route supplies the outer padding; the content component owns its headings, explanatory text, tables, code blocks, and live examples. components/Section.tsx exists in the repository but is not imported by the current content files.
The dashboard provides a PreferencesProvider that combines theme and language management. It exports both useThemeMode() and useLangMode() hooks.
Important: All hooks (
useThemeMode,useLangMode,useOrgMode,useUserDataContext) must be used within their provider contexts. Using them outside will return no-op values with silent failures - the component will appear to work but changes won't persist.
Any component can use the theme context:
import { useThemeMode } from './logto-kit';
function MyComponent() {
const { mode, colors, setMode, toggleMode } = useThemeMode();
return (
<button onClick={toggleMode}>
Current: {mode}
</button>
);
}The hook returns:
mode-'dark' | 'light'colors-ThemeColorsobject (bgPage, textPrimary, accentBlue, etc.)setMode(mode)- Set specific modetoggleMode()- Toggle between dark/light
import { useLangMode } from './logto-kit';
function MyComponent() {
const { lang, setLang } = useLangMode();
return (
<select value={lang} onChange={(e) => setLang(e.target.value)}>
<option value="en-US">English</option>
<option value="ka-GE">Georgian</option>
</select>
);
}The hook returns:
lang- Current language code (e.g.,'en-US','ka-GE')setLang(lang)- Set specific language
<PreferencesProvider
initialTheme="dark"
initialLang="en-US"
onUpdateCustomData={async (data) => { /* save to Logto */ }}
onLangChange={() => { /* called when language changes */ }}
/>| Prop | Type | Default | Description |
|---|---|---|---|
initialTheme |
'dark' | 'light' |
'dark' |
Initial theme mode |
initialLang |
string |
ENV LANG_MAIN |
Initial language code |
initialOrgId |
string | null |
- | Initial organization ID |
onUpdateCustomData |
(customData: Record<string, unknown>) => Promise<ActionResult> |
- | Optional callback to persist preferences to Logto customData |
onLangChange |
() => void |
- | Optional callback fired when language changes |
onPersistError |
(message: string) => void |
- | Optional callback for persistence failure messages |
When onUpdateCustomData is provided, theme and language changes are automatically synced to Logto for cross-device persistence.
Theme and language are stored in sessionStorage for instant switching, then synced to Logto customData for cross-device persistence.
Theme Priority:
- sessionStorage (current session - instant)
- Logto customData.Preferences.theme (cross-device)
- ENV DEFAULT_THEME_MODE (fallback)
Language Priority:
- sessionStorage (current session - instant)
- Logto customData.Preferences.lang (cross-device)
- ENV LANG_MAIN (fallback)
The dashboard provides a UserDataProvider and useUserDataContext() hook for centralized user data management.
import { useUserDataContext } from './logto-kit';
function MyComponent() {
const userData = useUserDataContext();
// Returns UserData or null if not available
}<UserDataProvider userData={userData}>
{children}
</UserDataProvider>| Prop | Type | Description |
|---|---|---|
userData |
UserData |
The user data object to provide |
The codebase uses a nested provider structure where each provider wraps its children with specific context:
LogtoProvider
├── PreferencesProvider (theme, language, org state)
│ └── LogtoProviderContent
│ └── UserDataProvider
│ └── children
When using the Dashboard component standalone (outside LogtoProvider), it creates its own provider tree:
Dashboard (Server Component)
├── UserDataProvider
│ └── PreferencesProvider
│ └── DashboardClient
LogtoProvider is a convenience wrapper that combines UserDataProvider and PreferencesProvider into a single component. It also provides a useLogto() hook for accessing user data and access token anywhere in your app.
import { LogtoProvider, useLogto } from './logto-kit';
import { Dashboard } from './logto-kit/components/dashboard';
import { MobileDashboard } from './logto-kit/components/dashboard/mobile-page';
function MyComponent() {
const { isAuthenticated, openDashboard } = useLogto();
// ...
}
<LogtoProvider
userData={userData}
dashboard={{ desktop: <Dashboard />, mobile: <MobileDashboard /> }}
initialTheme="dark"
initialLang="en-US"
onUpdateCustomData={updateCustomData}
>
<MyComponent />
</LogtoProvider>| Prop | Type | Default | Description |
|---|---|---|---|
userData |
UserData |
- | The user data object |
dashboard |
{ desktop: ReactNode; mobile: ReactNode } |
- | Optional dashboard desktop/mobile views |
initialTheme |
'dark' | 'light' |
'dark' |
Initial theme mode |
initialLang |
string |
ENV LANG_MAIN |
Initial language code |
onUpdateCustomData |
(data) => Promise<void> |
- | Callback for updating user custom data (forwarded to PreferencesProvider) |
onLangChange |
() => void |
- | Callback fired when language changes |
The useLogto() hook provides access to user data, authentication, and all preference state in one place:
import { useLogto } from './logto-kit';
function MyComponent() {
const { isAuthenticated, openDashboard, mode, colors, lang, setLang, asOrg, setAsOrg } = useLogto();
// ...
}Returns:
| Field | Type | Description |
|---|---|---|
isAuthenticated |
boolean |
Whether the user is authenticated |
mode |
'dark' | 'light' |
Current theme mode |
colors |
ThemeColors |
Color tokens for inline React styles |
setMode |
(mode: 'dark' | 'light') => void |
Set theme mode |
toggleMode |
() => void |
Toggle between dark/light |
lang |
string |
Current language code |
setLang |
(lang: string) => void |
Set language |
asOrg |
string | null |
Active organization ID (null = global) |
setAsOrg |
(orgId: string | null) => void |
Set active organization |
openDashboard |
() => void |
Open the dashboard modal |
closeDashboard |
() => void |
Close the dashboard modal |
Use this hook to access user data, theme, language, and dashboard controls anywhere within LogtoProvider.
Security note (v0.3.0):
accessTokenwas removed from browser exposure - it is now only available server-side viagetTokenForServerAction(). TheuseLogto()hook no longer exposes raw tokens to the client.
User data is automatically cached in sessionStorage under the key user-data. This enables:
- Instant access across components without re-fetching
- Persistence across page navigations within the same session
- Automatic hydration on component mount
All three user display components (UserButton, UserBadge, UserCard) use a shared useUserDisplay hook that resolves data from the provider:
- Prop - if
userDataprop passed to component, use it - Provider - otherwise use
userDataandlangfromuseLogto()context (auto-updates when profile or language changes)
The dashboard exports three components for displaying user avatars:
- UserButton - clickable avatar, opens Dashboard modal on click
- UserBadge - non-interactive display only
- UserCard - clickable card with avatar + "Logged in as" + user name, opens Dashboard on click
All three use a shared useUserDisplay hook that resolves userData and translations from the LogtoProvider context. They react to language changes automatically.
import { UserButton, UserBadge, UserCard } from './logto-kit';
// Simple avatar button (opens dashboard on click)
<UserButton />
// With options
<UserButton Size="48px" Canvas="Avatar" shape="circle" />
// With custom click handler
<UserButton do={() => console.log('clicked!')} />
// Non-interactive badge
<UserBadge Size="32px" Canvas="Avatar" shape="sq" />
// User card - avatar + "Logged in as" + name (click opens dashboard)
<UserCard Size="32px" shape="rsq" />
// Inside Dashboard - no props needed, uses provider context
<UserButton Size="32px" />
<UserCard Size="32px" shape="circle" />All three components share the same props:
| Prop | Type | Default | Description |
|---|---|---|---|
Canvas |
'Avatar' | 'Initials' |
Avatar (when omitted) |
Display mode |
Size |
string |
'6.25rem' (Button/Badge), '2.5rem' (Card) |
CSS size (e.g., '48px', '3rem') |
shape |
'circle' | 'sq' | 'rsq' |
- | Border radius shape (falls back to USER_SHAPE ENV) |
userData |
UserData |
- | User data (optional, uses provider context if not provided) |
colors |
ThemeColors |
- | Color tokens (optional, auto-detected from provider if not provided) |
do |
() => void |
- | Custom click handler (Button and Card only; defaults to openDashboard) |
UserCard's "Logged in as" label is automatically translated based on the provider's current language state - no t prop needed.
A permission-gated calculator demonstrating the Protected Actions API. Located in app/demo/components/calculator/.
| File | Purpose |
|---|---|
CalculatorPanel.tsx |
Wrapper with <Protected> gate for calc:basic permission |
CalculatorClient.tsx |
Calculator UI, expression parser, API calls on = |
action-registry/calc-actions.ts |
Action handlers for basic (+, -, x, /, %) and scientific (sin, cos, log, etc.) operations |
CalculatorPanelwrapsCalculatorClientwith<Protected orgId="8joxv3kicmlz" perm="calc:basic">- Each operation (+, −, ×, ÷, sin, cos, etc.) is sent to the Protected Actions API as an individual action (
calc/add,calc/multiply,calc/sin, etc.) - The API validates the user's personal role and permission, computes server-side, and returns the answer
- The calculator parses expressions into an AST and evaluates by calling the API for each node - it cannot calculate without the API
- Basic operations require
calc:basicpermission; scientific functions requirecalc:scientific - Session state (expression, mode) persists via
sessionStorage
See the Protected tab in the demo app for a live RBAC demo with the calculator.
AuthWatcher is a zero-UI component that automatically refreshes the page when authentication state might have changed. This keeps user data in sync without requiring manual refreshes.
import { AuthWatcher } from './logto-kit';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<AuthWatcher />
{children}
</body>
</html>
);
}What it does:
- Tab visibility - When user switches back to the tab (catches "logged out in another tab")
- Network reconnect - When device comes back online (catches expired sessions during offline)
- Periodic check - Every 30 seconds by default (catches account deletion while idle)
Props:
| Prop | Type | Default | Description |
|---|---|---|---|
refreshIntervalMs |
number |
60000 (60s) |
How often to check auth state. Set 0 to disable. |
debounceMs |
number |
1000 |
Minimum ms between refreshes to prevent spam |
⚠️ WARNING - NOT PRODUCTION READY: This module is FUNCTIONAL but NOT tested enough for production use. Use at your own risk. Extensive testing required before deploying to production. This is a work in progress - APIs may change.
The custom-logic module provides organization management server actions (the Protected and OrgSwitcher client components have moved to app/logto-kit/logic/ and are re-exported from the main barrel):
- <Protected> - Client component for gating UI based on organization permissions (now in
logic/) - OrgSwitcher - Dropdown for switching between organizations (now in
logic/) - setActiveOrg - Server action validating org membership (still in
custom-logic/) - useOrgMode hook - Access organization context throughout your app
For protected server actions, use the Protected Actions API (POST /api/protected).
// Components, hooks, and providers — from the main barrel
import {
LogtoProvider,
UserButton,
UserBadge,
UserCard,
AuthWatcher,
useLogto,
useThemeMode,
useLangMode,
useOrgMode,
useUserDataContext,
useAvatarUpload,
} from './logto-kit';
// Client components for org & permissions — re-exported from the main barrel
import { Protected, OrgSwitcher } from './logto-kit';
// OrgSwitcherWrapper and setActiveOrg are NOT re-exported from any barrel;
// import them from their direct file paths:
// import { OrgSwitcherWrapper } from './logto-kit/custom-logic/org-switcher-wrapper';
// import { setActiveOrg } from './logto-kit/custom-logic/set-active-org';
// Dashboard is not exported from any barrel — import directly:
// import { Dashboard } from './logto-kit/components/dashboard';
// Validation — from the logic barrel
import {
ValidationError,
validateEmail,
validatePassword,
validateUsername,
validateUrl,
validateE164,
} from './logto-kit/logic';
// Types
import type { ThemeColors, KitTranslations, Translations } from './logto-kit';
import type { OrganizationData } from './logto-kit/logic';
import type { ValidationResult, UserData } from './logto-kit/logic';Note: For personal RBAC, use
orgId="self"in<Protected />and the Protected Actions API. For org-scoped RBAC, pass the organization ID.
A client component that conditionally renders children based on permissions. Must be used within LogtoProvider context.
Key behavior:
- Self Mode (
orgId="self"): Shifts to user RBAC. It fetches BOTH personal roles AND personal permissions, and checksroleIdANDperm(when provided) against them. Permissions are only skipped ifpermis omitted, which is true in all scopes. - Organization Mode (
orgId=<real>): Enforces organization-scoped RBAC. It strictly matches the targetorgIdagainst the active organization (asOrg). If there is a mismatch, the component breaks immediately. If it matches, it checks BOTH roles (roleId) and permissions (perm). - Permissions are loaded asynchronously on mount or context change
- Shows
fallback(or nothing) while loading - This is a UI convenience component, not a security boundary
import { Protected } from './logto-kit';
<Protected perm="read:users">
<SensitiveData />
</Protected>
<Protected perm={['read:users', 'write:users']} requireAll={false}>
<AnyPermissionGranted />
</Protected>
<Protected perm="launch-nuke" orgId="org-123">
<NukeButton />
</Protected>Props:
| Prop | Type | Default | Description |
|---|---|---|---|
perm |
string | string[] |
- | Required permission(s) to check |
orgId |
string | null |
undefined |
Specific org to check against. If omitted, uses user's stored org preference |
orgName |
string | null |
- | Look up org by name from userData.organizations |
requireAll |
boolean |
true |
If true, ALL permissions required. If false, ANY one suffices |
fallback |
ReactNode |
null |
Placeholder shown while loading or when access denied |
With fallback:
<Protected
orgId="your-org-id"
perm="kidnap:kids"
fallback={<div className="animate-pulse">Loading...</div>}
>
<PresidentControlPanel />
</Protected>Best Practice - Separate Concerns:
Avoid wrapping protected content inline in your main page files. Instead, create a dedicated component file for the protected UI and wrap it with <Protected /> there. This keeps permission logic co-located with the component it guards:
// app/admin/admin-panel.tsx
import { Protected } from '../../logto-kit';
import { AdminDashboard } from './admin-dashboard';
export function AdminPanel() {
return (
<Protected perm="admin">
<AdminDashboard />
</Protected>
);
}// app/page.tsx
import { AdminPanel } from './admin/admin-panel';
// Just import and use - permissions are encapsulated
<AdminPanel />A secure API endpoint for executing permission-gated actions from the client.
Endpoint: POST /api/protected
This is the browser-only, same-origin session-cookie endpoint. It authenticates
with the Logto session cookie and keeps the existing CSRF Origin check. It
does not accept an Authorization header or a PAT.
import { useCallback } from 'react';
// Inside a client component
const callProtected = useCallback(async (action: string, payload: unknown) => {
const response = await fetch('/api/protected', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, payload })
});
const result = await response.json();
if (result.error) {
console.error(result.error);
return null;
}
return result.data;
}, []);Request:
| Field | Type | Required | Description |
|---|---|---|---|
action |
string | Yes | Action name registered in registry |
payload |
unknown | No | Data to pass to the action handler (defaults to {} if omitted) |
Response:
// Success (200)
{ error: null, data: <handler-return-value> }
// Error (status varies)
{ error: 'ERROR_CODE', data: null }Error Codes:
| Code | HTTP Status | Description |
|---|---|---|
MISSING_FIELDS |
400 | action missing from request body |
TOKEN_INVALID |
401 | Token not active or expired |
INTROSPECTION_ERROR |
401 | Failed to validate token |
UNAUTHORIZED |
401 | Not authenticated or session expired |
IMPROPER_SETUP_ERROR |
500 | Action config missing requiredOrgId, requiredRoleId, or requiredPermId |
ORG_NOT_MEMBER |
403 | User not member of the required org |
ACTION_NOT_FOUND |
404 | Action doesn't exist in registry |
ROLE_DENIED |
403 | User lacks the required role |
PERMISSION_DENIED |
403 | User lacks required permission |
INVALID_PAYLOAD |
400 | Handler rejected the payload shape |
INTERNAL_ERROR |
500 | Unexpected server error (catch-all) |
External automation clients use the separate
POST /api/protected/automation endpoint. It accepts the same
{ action, payload? } request and { error, data } response contract, but is
bearer-only: it never falls back to the browser cookie and never accepts a
raw pat_... value. Obtain an API-resource access token from Logto's
POST /oidc/token token exchange first, passing resource equal to the
configured PROTECTED_API_RESOURCE, then send the exchanged token:
Authorization: Bearer <exchanged-access-token>
Content-Type: application/jsonThe automation endpoint verifies the signed JWT issuer, expiration, signature,
and API-resource audience, then performs active-token introspection and the
same server-side RBAC checks as the session endpoint. User, organization, role,
and permission identity is never accepted from the request body. For
organization actions, the validated token's organization_id must match the
action configuration; the browser customData.Preferences.asOrg value is not
used for bearer requests.
The endpoint supports CORS preflight (OPTIONS). Set
PROTECTED_AUTOMATION_ALLOWED_ORIGINS=* for the temporary rollout default, or
later replace it with the exact OPNform origin, for example
https://<opnform-origin>—not a path or wildcard. CORS is only a browser
policy, not authentication: every POST still requires a valid exchanged
bearer token, and responses never enable wildcard credentials.
Automation-specific status behavior is 204 for an allowed preflight,
403 FORBIDDEN_ORIGIN for a disallowed configured origin, and 401 UNAUTHORIZED for a missing, malformed, raw-PAT, expired, invalidly signed, or
wrong-audience bearer credential. Allowed-origin POST errors (including
403 RBAC denials, 429 rate limits, and 500 failures) include the matching
CORS response headers and retain Retry-After when throttled.
Required runtime configuration:
PROTECTED_API_RESOURCE=https://your-tenant.logto.app/api
PROTECTED_AUTOMATION_ALLOWED_ORIGINS=*PROTECTED_API_RESOURCE must exactly match the resource requested during the
PAT exchange at Logto's /oidc/token endpoint. Keep the PAT confined to that
exchange; do not send it to this application endpoint.
Actions are registered in app/logto-kit/action-registry/ as async functions returning an ActionConfig with three required fields:
import type { ActionConfig } from '../logic/types';
export async function getDoSomething(): Promise<ActionConfig> {
return {
requiredOrgId: 'self', // 'self' = personal RBAC, or a real org ID
requiredRoleId: 'some-role-uuid', // role ID(s) the user must have
requiredPermId: 'do:something', // permission scope(s) required
handler: async ({ userId, orgId, payload }) => {
// Your protected logic here
return { success: true };
},
};
}Handler receives:
userId- The authenticated user's ID (from token introspection)orgId- TherequiredOrgIdvalue from the action configpayload- The payload sent from the client (defaults to{}if omitted)
A dropdown component for switching between organizations.
import { OrgSwitcher } from './logto-kit';
<OrgSwitcher
organizations={[
{ id: 'org-1', name: 'Acme Corp' },
{ id: 'org-2', name: 'Beta Inc' }
]}
currentOrgId="org-1"
mode={mode}
colors={colors}
/>Props:
| Prop | Type | Default | Description |
|---|---|---|---|
organizations |
OrganizationData[] |
- | List of organizations to display |
currentOrgId |
string |
- | Currently active organization ID |
mode |
'dark' | 'light' |
- | Current theme mode |
colors |
ThemeColors |
- | Color tokens for styling |
t |
{ organizations?: { beYourself?: string } } |
- | Optional translations |
Features:
- Shows "Be yourself (global)" option to exit org context
- Auto-selects first org if user is in only one
- Persists selection to
customData.Preferences.asOrg - Uses sessionStorage for client-side state
Validates org membership. Does NOT persist the selection - use useOrgMode().setAsOrg() for that.
import { setActiveOrg } from './logto-kit/custom-logic/set-active-org';
// Returns true if valid, false if user not in org
const isValid = await setActiveOrg('org-123');
// Clear org selection (act as global)
await setActiveOrg(null);Returns: Promise<boolean> - true if valid org membership, false otherwise
Access and manage organization context anywhere in your app. Part of PreferencesProvider alongside theme and language.
import { useOrgMode } from './logto-kit';
function MyComponent() {
const { asOrg, setAsOrg } = useOrgMode();
// asOrg: string | null (null = global context)
// setAsOrg(orgId: string | null) => void
return (
<div>
Current org: {asOrg ?? 'global'}
<button onClick={() => setAsOrg(null)}>Be yourself</button>
</div>
);
}Storage:
sessionStorage(client) - immediate UI updatescustomData.Preferences.asOrg(server) - cross-session persistence
- Client Request - Client calls
POST /api/protectedwith{ action, payload? }(token and userId are handled server-side) - Token Retrieval - Gets the session token server-side via
getTokenForServerAction() - Token Validation - Validates token via OIDC introspection, extracting
subas the authenticateduserId - RBAC Data Fetch - Fetches user orgs and active org from
/oidc/me - Org Membership Check - Ensures user is member of selected org (or passes through for personal RBAC with orgId="self")
- Permission Check - Calls Management API to verify user's organization token has required permission
- Execute Action - Runs the registered handler if all checks pass
For external automation, the corresponding flow is: exchange the PAT at
Logto's /oidc/token endpoint with the configured API resource, then call
POST /api/protected/automation with the resulting bearer access token. The
PAT itself is never accepted by this application.
- User selects org from
<OrgSwitcher>or Organizations tab setActiveOrg()validates membership via OIDC userinfo (live fetch, not cached JWT claims)- Selection stored in:
sessionStorage(client) - for immediate UI updatescustomData.Preferences.asOrg(server) - for persistence
useOrgMode()hook providesasOrgandsetAsOrg()throughout the app
# Logto Core
APP_ID=your_app_id
APP_SECRET=your_app_secret
ENDPOINT=https://your-tenant.logto.app
BASE_URL=http://localhost:3000
COOKIE_SECRET=your_cookie_secret
# Scopes (must include org scopes ON TOP of the required ones)
SCOPES=openid,profile,custom_data,email,phone,identities,sessions,organizations,organization_roles
# M2M for Management API
LOGTO_M2M_APP_ID=your_m2m_app_id
LOGTO_M2M_APP_SECRET=your_m2m_app_secret
LOGTO_M2M_RESOURCE=https://your-tenant.logto.app/api
# Token Introspection (required for Protected Actions API)
LOGTO_INTROSPECTION_URL=https://your-tenant.logto.app/oidc/token/introspection- Application Scopes: Add
organizations - M2M Application: Create a Machine-to-Machine app with:
- Permissions to read organization roles and user data
- Add to environment variables above
import { Protected } from './logto-kit';
export default function AdminPage() {
return (
<Protected perm="admin">
<div>
<h1>Admin Dashboard</h1>
<DeleteUserButton />
</div>
</Protected>
);
}<Protected perm="view:reports" orgId="org-abc">
<OrgReport orgId="org-abc" />
</Protected>| Issue | Solution |
|---|---|
| "User not in organization" | User must be a member of the org in Logto |
| Permission always returns false | Check: 1) User has the permission in Logto Console, 2) Permission string matches exactly, 3) Management API token is configured |
| Org switcher not appearing | User needs multiple organizations in their Logto claims |
| "PERMISSION_DENIED" | Verify user has the permission in Logto Console for the active organization |
| "IMPROPER_SETUP_ERROR" | Action config missing required fields - check action registry for missing requiredOrgId, requiredRoleId, or requiredPermId |
| "ORG_NOT_MEMBER" | Selected org not in user's organization list |
| "ACTION_NOT_FOUND" | Action not registered in action-registry/index.ts |
| "TOKEN_INVALID" | Token expired, revoked, or userId mismatch |
Translations are loaded from app/logto-kit/locales/:
index.ts- Locale loader and registry{locale-code}.ts- Translation files (e.g.,en-US.ts,ka-GE.ts)
- Create a new file in
app/logto-kit/locales/{locale-code}.ts - Export a
Translationsobject matching the interface - Register in
locales/index.ts - Add to
LANG_AVAILABLEin your.env
The dashboard supports configurable tabs via the LOAD_TABS environment variable. This allows you to control which enabled tabs are displayed and their order. Dev/PAT is additionally controlled by the private, strict, default-off PAT_ENABLED hard lock.
Available tab IDs with their display aliases:
| Tab ID | Aliases | Description |
|---|---|---|
profile |
personal, user |
User profile and basic info |
preferences |
prefs, custom-data, custom, customdata |
User preferences and settings |
security |
mfa, 2fa, totp |
Multi-factor authentication management (TOTP, backup codes, passkeys) |
sessions |
session, devices, activity |
Active session management and device overview |
identities |
identity |
External identity providers |
organizations |
orgs, org |
Organization memberships and roles |
dev |
developer, pat, pats, pat-tokens, tokens |
Purpose-scoped personal access token management |
# Safe default: show all non-Dev tabs in default order
PAT_ENABLED=false
LOAD_TABS=profile,preferences,security,sessions,identities,organizations
# Show only profile, security, sessions, and preferences (in that order)
LOAD_TABS=profile,security,sessions,preferences
# Opt in to PAT management. Existing Management API M2M setup is also required.
PAT_ENABLED=true
LOAD_TABS=profile,preferences,security,sessions,identities,organizations,dev
# Aliases preserve configured order and are deduplicated
LOAD_TABS=personal,prefs,mfa,devices,identity,orgs,pat-tokens
# If LOAD_TABS is missing or empty, show all enabled tabs in default order.
# With PAT_ENABLED off, "enabled tabs" excludes Dev.Only PAT_ENABLED=true after trimming and case normalization enables PAT. There is no NEXT_PUBLIC_PAT_ENABLED setting. While disabled, dev, developer, pat, pats, pat-tokens, and tokens are ignored whether they come from LOAD_TABS or its NEXT_PUBLIC_LOAD_TABS fallback.
- Private feature gate: Resolve
PAT_ENABLED; anything except normalizedtruedisables Dev/PAT - ENV Parsing:
LOAD_TABSis parsed as a comma-separated list, withNEXT_PUBLIC_LOAD_TABSas the existing tab-list fallback - Alias Resolution: Each token is mapped to its canonical tab ID
- Validation: Invalid tokens are skipped with a warning
- Filtering and deduplication: Duplicate tabs are removed in configured order, and Dev aliases are removed while PAT is disabled
- Fallback: If the source is missing/empty or no enabled tabs remain, all enabled tabs are returned in default order; with PAT off this means all non-Dev tabs
The server action boundary is independent of navigation. When PAT is disabled, direct list/create/rename/delete calls return PAT_DISABLED before input validation, session-token access or introspection, M2M acquisition, locks/rate limits, identity verification, or Logto requests. Disabling the feature does not revoke existing upstream PATs.
This section explains how to integrate and extend the dashboard. The Dashboard is a Server Component passed as a JSX prop to LogtoProvider, which renders it as a centered modal.
The app uses a proxy choke-point model — proxy.ts (Next.js middleware) is the network-level authentication boundary.
Public routes (no authentication required):
/— public redirect to/getting-started/pre-requisites/getting-started/*,/user-button/*,/dashboard/*,/tabs-and-flows/*,/rbac/*,/calculator/*,/anatomy/*,/security/*— documentation pages/demoand/demo/*— demo app and documentation/api/auth/sign-in— OAuth sign-in initiator/callback— OAuth callback receiver/api/wipe— session/cookie cleaning route
Protected routes (everything else): unauthenticated users are redirected to /api/auth/sign-in by the proxy before the page handler is reached.
proxy.tsenforces authentication for all non-public paths. It also handles session error recovery (stale cookies,invalid_grant) and sets per-request CSP headers.- Unauthenticated users see an anonymous
UserButton(generic user icon). Clicking it or any protected navigation button opens the dashboard auth-prompt modal, which triggers a full-page OAuth redirect to Logto. - Auth-gated features (e.g., the calculator demo) open the main auth modal with a "Read Only Mode" option when the user is not signed in.
- Sign-in is initiated exclusively by
signIn()from@logto/next/server-actions, called inGET /api/auth/sign-in. ThehandleSignIn()function inapp/callback/route.tsonly completes the OAuth callback after Logto redirects back. - Protected Server Actions reject unauthenticated calls explicitly with
UNAUTHENTICATED— a second security layer inside the choke point.
Basic request flow:
Request → proxy.ts
→ Session error (stale cookie / invalid_grant) → redirect to /api/wipe
→ Unauthenticated + non-public path → redirect to /api/auth/sign-in
→ Authenticated or public path → pass through to page/route handler
By default all new routes are protected. The existing public set is implemented in isPublicPath() in proxy.ts: /, /demo (and descendants), the eight docs topic prefixes, /api/auth/sign-in, /callback, and /api/wipe. Add a new public route by extending that function; add a new docs topic to DOCS_TOPIC_PREFIXES as well.
// proxy.ts
function isPublicPath(pathname: string): boolean {
if (pathname === '/') return true;
if (pathname === '/demo' || pathname.startsWith('/demo/')) return true;
const firstSegment = pathname.split('/')[1];
if (firstSegment && DOCS_TOPIC_PREFIXES.has(firstSegment)) return true;
if (pathname === '/api/auth/sign-in') return true;
if (pathname === '/callback') return true;
if (pathname === '/api/wipe') return true;
// Add an unrelated public path explicitly:
if (pathname === '/about') return true;
return false;
}Note: adding a route to isPublicPath() only bypasses the proxy redirect. Server Actions and API routes accessed from those pages still enforce their own auth checks.
The Dashboard is a Server Component that fetches data server-side and renders as a centered modal. You can import it anywhere:
import { Dashboard } from './logto-kit/components/dashboard';
export default function AdminPage() {
return (
<div>
<h1>Admin Panel</h1>
<Dashboard />
</div>
);
}This is the main use case - drop it into your app wherever you need it. The Dashboard automatically fetches user data on the server and displays as a centered modal overlay.
The Dashboard Server Component fetches user data on the server and renders as a centered modal. When imported standalone, it does not automatically wrap your consuming app. Instead, it only wraps its own DashboardClient component with two context providers:
<UserDataProvider userData={userData}>
<PreferencesProvider initialTheme={theme} initialLang={lang} onUpdateCustomData={updateCustomData}>
<DashboardClient ... />
</PreferencesProvider>
</UserDataProvider>A wrapper like <LogtoProvider> is needed to wrap the consuming application if you want these context providers and hooks to be accessible across your entire app subtree.
This means:
- UserDataProvider - Provides user data to all child components (server-fetched)
- PreferencesProvider - Manages theme and language state with automatic persistence to Logto
Both providers expose hooks that child components can use:
useUserDataContext()- Access user datauseThemeMode()- Access mode and colors (ThemeColors object)useLangMode()- Access current language
The preferences tab no longer needs props passed to it - it uses the hooks directly!
- Create your tab component in
app/logto-kit/components/dashboard/tabs/ - Add to
LOAD_TABSin your.env
The tab system is pretty simple - look at existing tabs for examples.
- Copy
app/logto-kit/themes/default/toapp/logto-kit/themes/{your-theme}/ - Edit the hex values in
dark.cssandlight.css - Import your CSS files in
app/layout.tsxorapp/globals.css - Set
THEME=your-themein.env
Note:
app/globals.csshas hardcoded@importpaths tothemes/default/. To use a custom theme, you must replace those import paths (step 3). TheTHEMEenv var is a build-time folder identifier and does NOT dynamically switch CSS at runtime.
- Create
app/logto-kit/locales/{locale-code}.ts - Follow the pattern in existing locale files
- Register in
locales/index.tsand add the locale code to theLocaleCodetype - Add to
LANG_AVAILABLEin.env
Three reusable building blocks for data that needs live-refreshing without full page reloads or re-authentication:
| Primitive | File | Purpose |
|---|---|---|
useRefreshable() hook |
hooks/use-refreshable.ts |
0→35ms→1 toggle - unmounts children, waits one render cycle, remounts fresh |
<RefreshButton /> |
components/dashboard/shared/RefreshButton.tsx |
Shared button that lives inside the guarded block |
| Direct token fetch | logic/actions/organizations.ts |
Calls Logto's /oidc/token directly, bypassing the SDK's cookie-persisted accessTokenMap cache |
Why this pattern exists:
getOrganizationToken() returns stale cached tokens. Direct POST to /oidc/token bypasses the cache without cookie writes.
Usage pattern:
function MyDataBlock({ orgId }) {
const { visible, triggerRefresh } = useRefreshable();
const [data, setData] = useState([]);
useEffect(() => { fetchMyData(orgId).then(setData); }, [orgId]);
if (!visible) return null; // 0 = unloaded
return (
<div>
<RefreshButton onClick={triggerRefresh} loading={loading} ... />
{data.map(d => <div key={d.id}>{d.label}</div>)}
</div>
);
}Documented in the Primitives tab in app/demo/content/anatomy/primitives.tsx.
The dashboard handles stale cookies automatically. When the Logto access token goes stale:
- Request to fetch data fails
- System detects "stale cookie" error
- Browser is redirected to
GET /api/wipewhich clears all app cookies - Browser is redirected to
/- a fresh token is obtained from the valid Logto session - Dashboard loads normally
/api/wipe supports both GET (browser navigation - redirect-based flow) and POST (CSRF-safe fetch for programmatic use).
⚠️ NOTE: Basic session listing (session IDs, timestamps, device info) works with any Logto version. The Sessions tab's advanced features (isCurrentflag,lastActiveAt, heartbeat endpoint) require a patched Logto backend. Until upstream PR #8748 is merged, you must run Logto from the fork branch:odinwerks/logto - feat/session-last-active-atA subset branch with only the
isCurrentflag (no heartbeat/lastActiveAt) is available atfeat/iscurrent-v1.39.
The Sessions tab features include:
isCurrentbadge - The session backing the current request is marked with a green "This device" badgelastActiveAt- Each session shows when it was last active (null/"Active now"/ ISO timestamp)- IP geolocation minimap - Sessions are shown on an interactive map via
SessionMiniMap/SessionMapModal. IP geolocation lookups are cached with a 5-minute TTL viageo-cache.ts. - Automatic heartbeat - A zero-UI
SessionHeartbeatcomponent firesrecordHeartbeat()every 30s and on tab focus - Revoke all other sessions - Safe-guarded: aborts if no
isCurrentsession is identified
Navigate to /api/wipe in a browser (GET) to clear all app cookies and be redirected to /. Or send a POST request programmatically:
curl -X POST http://localhost:3000/api/wipe -H "Cookie: <your-session-cookie>"POST to /api/wipe with query parameter ?force=true to completely sign out - clears both app cookies AND the Logto session:
curl -X POST "http://localhost:3000/api/wipe?force=true" -H "Cookie: <your-session-cookie>"The dashboard automatically stores user preferences (theme mode and language selection) in Logto's customData field under a Preferences key. This allows preferences to persist across sessions and devices.
To ensure type safety and proper validation in your Logto application, update your Logto applications JSON schema to include the Preferences key:
{
"type": "object",
"properties": {
"Preferences": {
"type": "object",
"properties": {
"theme": {
"type": "string",
"enum": ["dark", "light"]
},
"lang": {
"type": "string",
"pattern": "^[a-zA-Z]{2}-[A-Z]{2}$"
},
"asOrg": {
"type": ["string", "null"]
}
},
"additionalProperties": false
}
},
"additionalProperties": true
}This ensures that:
- The
Preferencesobject has the correct structure - Only valid theme values (
darkorlight) are accepted - Language codes follow the locale pattern (e.g.,
en-US,ka-GE) - Other custom data keys remain unaffected
- First Visit: No preferences exist, defaults from ENV are used (
DEFAULT_THEME_MODE,LANG_MAIN) - User Changes: When user changes theme or language, preferences are saved to
customData.Preferences - Subsequent Visits: Preferences are read from
customDataand override ENV defaults - ENV Changes: If supported languages change (ENV
LANG_AVAILABLE), preferences are validated and normalized
The dashboard supports user avatar uploads via drag-and-drop or file browser. The Profile tab includes a canvas-based ImageCropper (app/logto-kit/components/dashboard/shared/ImageCropper.tsx) for pre-crop before upload. Email and phone management in the Profile tab uses ContactRow (shared/ContactRow.tsx) with verification flows driven by FlowModal. When a user uploads an image, it's stored in S3-compatible storage and the URL is automatically saved to their Logto profile.
Security Note: The previous URL input box has been removed. Users can only upload images via drag-and-drop or file browser. This prevents malicious URL injection attacks. If storage is not configured in production, uploads will fail with a clear error message.
Avatar upload is implemented as a Next.js Server Action (uploadAvatar() in app/logto-kit/logic/actions/avatar.ts). The client calls the Server Action directly (no /api/protected round-trip); Next.js Server Actions provide built-in auth + CSRF protection:
┌─────────────┐ ┌─────────────────────────────────┐ ┌───────────┐
│ Client │────▶│ Server Action: uploadAvatar() │────▶│ S3 │
│ │ │ (app/logto-kit/logic/actions/ │ │ Storage │
│ 1. Select │ │ avatar.ts) │ │ │
│ file │ │ │ │ {userId}/ │
│ 2. Call │ │ 1. Derive token + userId │ │ you.png │
│ action │ │ server-side from session │ │ │
│ 3. Get URL │◀────│ 2. Validate file (MIME + size) │◀────│ │
└─────────────┘ │ 3. Upload to S3 │ └───────────┘
│ └─────────────────────────────────┘
│ 4. Push URL to Logto
▼
┌─────────────────┐
│ Logto Account │
│ API │
│ updateAvatar() │
└─────────────────┘
The access token and user ID are derived server-side from the session cookie - they are never accepted from the client. This prevents token leakage and cross-user upload attempts.
Server Actions enforce same-origin at the framework level, eliminating CSRF from cross-site origins.
Note: The same server-side credential derivation pattern is used for account deletion - the server never trusts client-supplied tokens or user IDs.
Add these to your .env file:
# ===============================================
# AVATAR UPLOAD STORAGE CONFIGURATION
# ===============================================
# S3-Compatible Storage (Supabase, AWS S3, MinIO, DigitalOcean Spaces)
# Bucket name for storing avatars
S3_BUCKET_NAME=avatars
# Public-facing URL for reading stored avatars
# This is what gets saved to Logto and displayed to users
S3_PUBLIC_URL=https://your-project.supabase.co/storage/v1/object/public/avatars
# S3 API Endpoint
# For Supabase: https://your-project.supabase.co/storage/v1/s3
# For AWS S3: https://s3.amazonaws.com
# For MinIO: http://localhost:9000
S3_ENDPOINT=https://your-project.supabase.co/storage/v1/s3
# S3 Credentials
S3_ACCESS_KEY_ID=your_access_key
S3_SECRET_ACCESS_KEY=your_secret_key
S3_REGION=auto
# ===============================================
# OPTIONAL: Supabase Storage REST API
# ===============================================
# If using Supabase Storage, you can use their REST API directly
# instead of S3 compatibility. Get the service role key from:
# Supabase Dashboard → Settings → API → Service role key
#
# SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
#
# When set, the system uses Supabase's REST API (more reliable).
# When unset, uses MinIO client (works with AWS S3, DO Spaces, MinIO).| Storage | S3_ENDPOINT | Notes |
|---|---|---|
| Supabase | https://{project}.supabase.co/storage/v1/s3 |
Use REST API with SUPABASE_SERVICE_ROLE_KEY for best results |
| AWS S3 | https://s3.amazonaws.com |
Standard AWS S3 |
| DigitalOcean Spaces | https://{region}.digitaloceanspaces.com |
S3-compatible |
| MinIO | http://localhost:9000 |
Self-hosted S3-compatible |
- Allowed types: JPEG, PNG, WebP, GIF
- Max size: 2 MB
- Storage path:
{userId}/you.png(always overwrites previous)
-
Create S3 Bucket:
- Create a new bucket named
avatars - Set it to Public (for avatar viewing)
- Or configure appropriate bucket policies
- Create a new bucket named
-
Configure Credentials:
- Add the environment variables to
.env - Ensure credentials have
s3:PutObjectpermission on the bucket
- Add the environment variables to
-
Test:
- Go to the Profile tab in the dashboard
- Try uploading an avatar image
- The image should appear and persist to your Logto profile
The avatar upload system consists of:
| File | Purpose |
|---|---|
app/logto-kit/logic/actions/avatar.ts |
Server Action - validates file, derives auth from session, uploads to S3 |
app/logto-kit/hooks/use-avatar-upload.ts |
React hook for client-side upload state management |
The hook (useAvatarUpload) is already integrated into the Profile tab component. It:
- Builds a
FormDatawith only thefilefield - Calls the
uploadAvatar()Server Action directly - On success, invokes the
onSuccesscallback with the URL; the caller (Profile tab) then callsupdateAvatarUrl()to persist the avatar to Logto
You can use the avatar upload hook in your own components:
import { useAvatarUpload } from './logto-kit';
function MyAvatarUploader() {
const { upload, isUploading, error } = useAvatarUpload({
onSuccess: (url) => console.log('Uploaded to:', url),
onError: (msg) => console.error('Failed:', msg),
});
return (
<div>
<input
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])}
disabled={isUploading}
/>
{isUploading && <p>Uploading...</p>}
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}All server actions are exported from logto-kit/logic and can be used in your own custom flows when the provided UI doesn't meet your needs.
import {
fetchDashboardData,
signOutUser,
updateUserBasicInfo,
updateUserProfile,
updateUserCustomData,
updateAvatarUrl,
updateUserPassword,
deleteUserAccount,
uploadAvatar,
verifyPasswordForIdentity,
sendEmailVerificationCode,
sendPhoneVerificationCode,
verifyVerificationCode,
updateEmailWithVerification,
updatePhoneWithVerification,
removeUserEmail,
removeUserPhone,
getMfaVerifications,
generateTotpSecret,
addMfaVerification,
deleteMfaVerification,
replaceTotpVerification,
generateBackupCodes,
getBackupCodes,
requestWebAuthnRegistration,
verifyAndLinkWebAuthn,
renamePasskey,
getUserSessions,
getSessionsWithDeviceMeta,
revokeUserSession,
revokeAllOtherSessions,
getUserGrants,
revokeUserGrant,
getOrganizationUserPermissions,
getUserRoles,
} from './logto-kit/logic';// Update basic info (name, username)
await updateUserBasicInfo({ name: 'John', username: 'johndoe' });
// Update profile (given name, family name)
await updateUserProfile({ givenName: 'John', familyName: 'Doe' });
// Update allowed customData.Preferences keys
await updateUserCustomData({ Preferences: { theme: 'dark', lang: 'en-US' } });
// Update avatar URL
await updateAvatarUrl('https://example.com/avatar.png');// Update password (requires identity verification first)
await updateUserPassword('newPassword123', 'verificationRecordId');
// Delete account (requires identity verification record ID; access token is derived server-side)
await deleteUserAccount('identityVerificationRecordId');
// Sign out and clear cookies
await signOutUser();// List all MFA methods
const mfaResult = await getMfaVerifications();
if (!mfaResult.ok) throw new Error(mfaResult.error);
const mfaList = mfaResult.data;
// Generate TOTP secret for new enrollment
const totpResult = await generateTotpSecret();
if (!totpResult.ok) throw new Error(totpResult.error);
const { secret } = totpResult.data;
// Use `secret` with qrcode.react to generate a scannable QR code
// Add MFA verification (takes 2 args: verification payload + identity verification record ID)
await addMfaVerification({ type: 'Totp', payload: { secret, code: '123456' } }, 'verificationRecordId');
// Delete MFA method
await deleteMfaVerification('mfaVerificationId', 'verificationRecordId');
// Replace an existing TOTP verification (unlinks old, links new)
await replaceTotpVerification('new-totp-secret', '123456', 'verificationRecordId');
// Generate backup codes
const generatedCodesResult = await generateBackupCodes('verificationRecordId');
if (!generatedCodesResult.ok) throw new Error(generatedCodesResult.error);
const { codes } = generatedCodesResult.data;
// Get existing backup codes
const backupCodesResult = await getBackupCodes('verificationRecordId');
if (!backupCodesResult.ok) throw new Error(backupCodesResult.error);
const backupCodes = backupCodesResult.data;// Request registration options from Logto
const registrationResult = await requestWebAuthnRegistration();
if (!registrationResult.ok) throw new Error(registrationResult.error);
const { registrationOptions, verificationRecordId } = registrationResult.data;
// Client-side: run browser ceremony with @simplewebauthn/browser
import { startRegistration } from '@simplewebauthn/browser';
const response = await startRegistration({ optionsJSON: registrationOptions });
// Verify and link the passkey to the account
await verifyAndLinkWebAuthn(response, verificationRecordId, identityVerificationRecordId);
// Rename a passkey
await renamePasskey('passkey-verification-id', 'My MacBook', identityVerificationRecordId);
// Delete a passkey (uses existing deleteMfaVerification)
await deleteMfaVerification('passkey-verification-id', identityVerificationRecordId);Note: WebAuthn requires configuring
webauthnRelatedOriginsin your Logto tenant's Account Center settings to allow cross-origin passkey operations from your app's domain.
// Get all user sessions (data from Logto API)
const sessionsResult = await getUserSessions('identityVerificationRecordId');
if (!sessionsResult.ok) throw new Error(sessionsResult.error);
const sessions = sessionsResult.data;
// Get sessions with device metadata (browser, OS, IP geo-location)
const sessionsWithMetaResult = await getSessionsWithDeviceMeta('identityVerificationRecordId');
if (!sessionsWithMetaResult.ok) throw new Error(sessionsWithMetaResult.error);
const sessionsWithMeta = sessionsWithMetaResult.data;
// Revoke a single session
await revokeUserSession('sessionId', 'identityVerificationRecordId');
// Revoke all sessions except the current one
await revokeAllOtherSessions('identityVerificationRecordId');
// Get OAuth grants (third-party app consents)
const grantsResult = await getUserGrants();
if (!grantsResult.ok) throw new Error(grantsResult.error);
const grants = grantsResult.data;
// Revoke a grant (third-party app consent)
await revokeUserGrant('grantId');// Get all permissions for a user in a specific organization
const permissionsResult = await getOrganizationUserPermissions('org-123');
if (!permissionsResult.ok) throw new Error(permissionsResult.error);
const permissions = permissionsResult.data;
// string[] (e.g., ['read:users', 'write:users'])
// Get all roles assigned to the authenticated user
const rolesResult = await getUserRoles();
if (!rolesResult.ok) throw new Error(rolesResult.error);
const roles = rolesResult.data;
// UserRole[] ({ id, name, description, type, tenantId, isDefault })import { formatPhone } from './logto-kit/logic';
// Format a raw E.164 phone number for display
const displayPhone = formatPhone('+12345678901');
// "+1 234 567 8901"// Verify password for identity operations
const verificationResult = await verifyPasswordForIdentity('password123');
if (!verificationResult.ok) throw new Error(verificationResult.error);
const { verificationRecordId } = verificationResult.data;
// Send email verification code
const emailCodeResult = await sendEmailVerificationCode('user@example.com');
if (!emailCodeResult.ok) throw new Error(emailCodeResult.error);
const { verificationId: emailVerificationId } = emailCodeResult.data;
// Send phone verification code
const phoneCodeResult = await sendPhoneVerificationCode('+1234567890');
if (!phoneCodeResult.ok) throw new Error(phoneCodeResult.error);
const { verificationId: phoneVerificationId } = phoneCodeResult.data;
// Verify any code (email, phone, or backup)
await verifyVerificationCode('email', 'user@example.com', emailVerificationId, '123456');
// Update email with verification
await updateEmailWithVerification('newemail@example.com', emailVerificationId, verificationRecordId);
// Update phone with verification
await updatePhoneWithVerification('+1234567890', phoneVerificationId, verificationRecordId);
// Remove email
await removeUserEmail('verificationRecordId');
// Remove phone
await removeUserPhone('verificationRecordId');// Upload avatar (returns the public URL)
// FormData must contain ONLY:
// - file: File object (JPEG, PNG, WebP, GIF, max 2MB)
const formData = new FormData();
formData.append('file', fileInput.files[0]);
const uploadResult = await uploadAvatar(formData);
if (!uploadResult.ok) throw new Error(uploadResult.error);
const { url } = uploadResult.data;
// The access token and user ID are derived server-side from the session cookie.npm installnpm run dev # Start the Next.js dev server on port 3000 (auto-runs `predev` via npm lifecycle `pre` hook)
npm run devautomatically triggersnpm run predevvia npm'sprehook. Runnpm run predevmanually only if you edit.envwithout restarting the dev server.
npm run buildnpm run startnpm run lintnpm run type-checkThe project uses Vitest with jsdom for browser-like test environment. Test files (.test.ts / .test.tsx) are co-located alongside their source modules.
# Run all tests
npm run test:run
# Watch mode
npm testTest suite status (135 test files, 2,209 tests; type-check PASS; lint PASS):
- Security:
origin-guard.test.ts,guards.test.ts,dev-mode.test.ts - Logic:
validation.test.ts,errors.test.ts - Actions:
sessions.test.ts,webauthn.test.ts - Components:
profile.test.tsx,SessionMapModal.test.tsx - Custom logic:
set-active-org.test.ts
Configuration: vitest.config.ts and vitest.setup.ts at the project root.
- Add runtime session meta monitoring to target user approximated location VS token activation location with basic trust. To explain, let the app know that your primary operation of the app is within some area, (say a city) and that any operation outside that city can just not be you. If a sign in attempt is detected in Mexico or Moscow while your trusted domain is Delaware, the sign-in should get rejected and you should be sent an email with the blocked attempt data. I know this sort of defeats VPNs or real travel but I'll add travel mode which relaxes the restriction for an N time frame taht you can select. This is a lot and will need modifications to Logto-blacktop.
- Make the mobile dash look pretty. It sucks.
- Conquer All.
If you cause implosion of Earth running this mesmerizingly terrible code, I am not liable. MIT.