Skip to content

About

An attempt at re-creating Clerk's components for the open source project Logto.

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

673 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

logto-components-next

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.

Features

  • 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 (isCurrent badge), per-session lastActiveAt with automatic 30s heartbeat (blacktop only; disabled under BACKEND_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/browser for 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/wipe are publicly accessible. All other routes redirect unauthenticated users to sign-in.
  • Debug Logging: All sensitive debug output (tokens, IPs, introspection) is production-gated.

Prerequisites

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.

Redis Setup (local dev)

1. Generate a password (do this once):

openssl rand -base64 32

2. Add to .env:

REDIS_PASSWORD=<paste-generated-password-here>
REDIS_URL=redis://:${REDIS_PASSWORD}@localhost:2998

REDIS_URL uses 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 redis

This 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_URL from .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.

Dev Setup

# 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:3000

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

Route Protection

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.

Project Structure

./
├── .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.

Docker Deployment

The project ships with a Dockerfile, .dockerignore, and docker-compose.yml for deploying behind a Cloudflare Tunnel.

Files

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

Architecture

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.

Quick Start

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_URL is your public CF tunnel URL. The container maps it to BASE_URL at runtime, which the Logto SDK uses for OIDC redirect URIs and origin checking. For local development (npm run dev), set BASE_URL=http://localhost:3000 instead.

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 -d

Updating after code changes:

git pull
docker compose build
docker compose up -d

NEXT_PUBLIC_* Vars and Docker

NEXT_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 -d

Build args currently include:

  • NEXT_PUBLIC_THEME
  • NEXT_PUBLIC_LANG_MAIN
  • NEXT_PUBLIC_LANG_AVAILABLE
  • NEXT_PUBLIC_MFA_ISSUER
  • NEXT_PUBLIC_DEFAULT_THEME_MODE
  • NEXT_PUBLIC_USER_SHAPE
  • NEXT_PUBLIC_NAME_TYPE
  • NEXT_PUBLIC_LOAD_TABS
  • NEXT_PUBLIC_DELETE_REDIRECT_DELAY
  • NEXT_PUBLIC_BACKEND_TYPE
  • NEXT_PUBLIC_SIGNOUT_REDIRECT_DELAY
  • NEXT_PUBLIC_FORCE_ANIMATIONS

Runtime env passthrough currently includes backend and country behavior gates:

  • BACKEND_TYPE
  • PFP_BACKEND
  • COUNTRY_CODE_ALLOW_LIST
  • COUNTRY_CODE_BLOCK_LIST
  • LOGTO_M2M_RESOURCE
  • PAT_ENABLED (private PAT feature gate; defaults to false)

Security Architecture

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

Identity Verification (BUG-001 — v0.4.0 server-sealed cookie)

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.

Environment Variables

Required

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

Identity Verification Cookie (BUG-001 sealed cookie)

# 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

Permission-Based Access Control & Account Management

# 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/introspection

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

Personal Access Tokens (private, default off)

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:

  1. Set the private runtime variable PAT_ENABLED=true.
  2. Include dev (or one of its aliases) in LOAD_TABS when using an explicit tab list. If the list is empty, Dev joins the normal default order only while PAT is enabled.
  3. 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.

Tab Configuration

# 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,dev

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

Account Deletion

# Redirect delay after account deletion in ms (default: 3000)
DELETE_REDIRECT_DELAY=3000

Theme Configuration

# 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

Name Type Configuration

# 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

# MFA Configuration
# Name that will show up in the TOTP QR code issuer field
MFA_ISSUER=YourAppName

WebAuthn Origins: For WebAuthn passkey operations to work from your app's domain, you must configure webauthnRelatedOrigins in your Logto tenant's Account Center settings. This allows cross-origin passkey operations from your deployed app domain.

i18n Configuration

# Default language
LANG_MAIN=en-US

# Available languages
LANG_AVAILABLE=en-US,ka-GE,uk-UA

Debug Logging

# Set to true to enable verbose server-side debug logging
# (token introspection, API request flow, permission checks)
# DEBUG=true

Logging Configuration

# 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

Error Verbosity

# 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 Image Origin

# CSP img-src directive origin for allowed image sources
# Used in Content-Security-Policy header construction
# IMG_ORIGIN=https://your-cdn.example.com

Animation Control

# Force CSS animations on or off, overriding prefers-reduced-motion
# Options: true, false, or unset (respects system preference)
# NEXT_PUBLIC_FORCE_ANIMATIONS=true

Backend Type Configuration

Set BACKEND_TYPE to select the capability mode of your Logto backend environment:

# Values: blacktop | upstream
# Server default: upstream (if unset or invalid)
BACKEND_TYPE=upstream
  • blacktop (Custom Fork Mode): Enables all advanced features. This includes:
    • Verification-code locale pass-through: The dashboard sends the user's selected language as a locale field in POST /api/verifications/verification-code so 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 lastActiveAt timestamps for active user sessions.
    • Logto Native Avatar Uploads: Allows choosing PFP_BACKEND=logto to store profile pictures natively in Logto customData.
  • 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 locale field 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 logto native backend is disabled. S3-compatible storage (s3) is strictly forced as the effective backend, even if PFP_BACKEND is set to logto.

Set BACKEND_TYPE explicitly in your env file. The resolver falls back to upstream when missing or invalid.

Phone Country Code Filtering

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 Storage Backend

Avatar backend selection follows this matrix:

  • BACKEND_TYPE=blacktop: PFP_BACKEND can be logto or s3
  • BACKEND_TYPE=upstream: effective avatar backend is always s3

Choose between these storage backends when BACKEND_TYPE=blacktop:

Option 1: S3-Compatible Storage

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_key

Use this when you want full control over storage, already have S3 infrastructure, or need CDN integration.

Option 2: Logto Native Avatar API

Set PFP_BACKEND=logto to use Logto's built-in avatar handling:

PFP_BACKEND=logto

No 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.example and the Avatar Upload section below.

NEXT_PUBLIC_* Variants

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 Configuration

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:2998

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

Docker-Only Variables

# 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.org

Theme System

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

How it works

  • data-theme attribute on <html> controls which CSS custom properties are active
  • useThemeMode() provides { mode, colors, setMode, toggleMode } for inline React styles
  • ThemeColors interface + DARK_COLORS / LIGHT_COLORS constants 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 @import in app/globals.css - change these paths to switch theme folders

Customizing

  1. Copy app/logto-kit/themes/default/ to a new folder like app/logto-kit/themes/my-brand/
  2. Edit the hex values in dark.css and light.css
  3. Update app/globals.css to point to your new folder:
    @import './logto-kit/themes/my-brand/dark.css';
    @import './logto-kit/themes/my-brand/light.css';
  4. Optionally export matching ThemeColors constants 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.

User Shape Configuration

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-radius

Demo App

The project includes a demo app at /demo that acts as a self-documenting showcase for the logto-kit components.

What It Is

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

How It Works

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

Documentation Format

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.tsx pattern)
  • Single column - For detailed content like the Security tab's FlowModal architecture

To add documentation for a new topic/section:

  1. Create a TSX file inside app/demo/content/{topic}/{section}.tsx
  2. Add its loader to CONTENT_REGISTRY in app/(docs)/[topic]/[section]/page.tsx
  3. Add the topic or section to NAV_ITEMS and its description to SECTION_HINTS in app/demo/nav-data.tsx
  4. Use useDocStyles for shared styling, SyntaxBlock for code, and ordinary JSX layouts for the page content

Using the Demo App

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.

Documentation Utilities

These shared utilities live in app/demo/components/ and are available to doc files:

SyntaxBlock

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.

Current content component pattern

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.

PreferencesProvider

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.

useThemeMode Hook

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 - ThemeColors object (bgPage, textPrimary, accentBlue, etc.)
  • setMode(mode) - Set specific mode
  • toggleMode() - Toggle between dark/light

useLangMode Hook

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 Props

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

Preference Persistence

Theme and language are stored in sessionStorage for instant switching, then synced to Logto customData for cross-device persistence.

Theme Priority:

  1. sessionStorage (current session - instant)
  2. Logto customData.Preferences.theme (cross-device)
  3. ENV DEFAULT_THEME_MODE (fallback)

Language Priority:

  1. sessionStorage (current session - instant)
  2. Logto customData.Preferences.lang (cross-device)
  3. ENV LANG_MAIN (fallback)

UserDataProvider

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 Props

<UserDataProvider userData={userData}>
  {children}
</UserDataProvider>
Prop Type Description
userData UserData The user data object to provide

Provider Hierarchy

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

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

useLogto Hook

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): accessToken was removed from browser exposure - it is now only available server-side via getTokenForServerAction(). The useLogto() hook no longer exposes raw tokens to the client.

SessionStorage Caching

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

Data Priority

All three user display components (UserButton, UserBadge, UserCard) use a shared useUserDisplay hook that resolves data from the provider:

  1. Prop - if userData prop passed to component, use it
  2. Provider - otherwise use userData and lang from useLogto() context (auto-updates when profile or language changes)

UserButton, UserBadge & UserCard

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" />

Props

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.

Calculator Demo

A permission-gated calculator demonstrating the Protected Actions API. Located in app/demo/components/calculator/.

Files

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

How It Works

  1. CalculatorPanel wraps CalculatorClient with <Protected orgId="8joxv3kicmlz" perm="calc:basic">
  2. Each operation (+, −, ×, ÷, sin, cos, etc.) is sent to the Protected Actions API as an individual action (calc/add, calc/multiply, calc/sin, etc.)
  3. The API validates the user's personal role and permission, computes server-side, and returns the answer
  4. The calculator parses expressions into an AST and evaluates by calling the API for each node - it cannot calculate without the API
  5. Basic operations require calc:basic permission; scientific functions require calc:scientific
  6. Session state (expression, mode) persists via sessionStorage

See the Protected tab in the demo app for a live RBAC demo with the calculator.

AuthWatcher

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

Custom Logic (Organization & Permission-Based Access Control)

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

Exports

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


<Protected> - UI Gate Component

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 checks roleId AND perm (when provided) against them. Permissions are only skipped if perm is omitted, which is true in all scopes.
  • Organization Mode (orgId=<real>): Enforces organization-scoped RBAC. It strictly matches the target orgId against 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 />

Protected Actions API

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)

Protected automation endpoint

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/json

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


Registering Custom Actions

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 - The requiredOrgId value from the action config
  • payload - The payload sent from the client (defaults to {} if omitted)

<OrgSwitcher> - Organization Selector

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

setActiveOrg() - Server Action

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


useOrgMode() - Hook

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 updates
  • customData.Preferences.asOrg (server) - cross-session persistence

How It Works

Protected Actions API Flow

  1. Client Request - Client calls POST /api/protected with { action, payload? } (token and userId are handled server-side)
  2. Token Retrieval - Gets the session token server-side via getTokenForServerAction()
  3. Token Validation - Validates token via OIDC introspection, extracting sub as the authenticated userId
  4. RBAC Data Fetch - Fetches user orgs and active org from /oidc/me
  5. Org Membership Check - Ensures user is member of selected org (or passes through for personal RBAC with orgId="self")
  6. Permission Check - Calls Management API to verify user's organization token has required permission
  7. 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.

Organization Switching Flow

  1. User selects org from <OrgSwitcher> or Organizations tab
  2. setActiveOrg() validates membership via OIDC userinfo (live fetch, not cached JWT claims)
  3. Selection stored in:
    • sessionStorage (client) - for immediate UI updates
    • customData.Preferences.asOrg (server) - for persistence
  4. useOrgMode() hook provides asOrg and setAsOrg() throughout the app

Required Configuration

Environment Variables

# 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

Logto Console Setup

  1. Application Scopes: Add organizations
  2. M2M Application: Create a Machine-to-Machine app with:
    • Permissions to read organization roles and user data
    • Add to environment variables above

Usage Examples

Basic Permission Check

import { Protected } from './logto-kit';

export default function AdminPage() {
  return (
    <Protected perm="admin">
      <div>
        <h1>Admin Dashboard</h1>
        <DeleteUserButton />
      </div>
    </Protected>
  );
}

With Org-Specific Content

<Protected perm="view:reports" orgId="org-abc">
  <OrgReport orgId="org-abc" />
</Protected>

Troubleshooting

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

i18n System

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)

Adding a New Language

  1. Create a new file in app/logto-kit/locales/{locale-code}.ts
  2. Export a Translations object matching the interface
  3. Register in locales/index.ts
  4. Add to LANG_AVAILABLE in your .env

Tab Configuration

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.

Tab IDs and Aliases

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

Configuration Examples

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

How Tab Loading Works

  1. Private feature gate: Resolve PAT_ENABLED; anything except normalized true disables Dev/PAT
  2. ENV Parsing: LOAD_TABS is parsed as a comma-separated list, with NEXT_PUBLIC_LOAD_TABS as the existing tab-list fallback
  3. Alias Resolution: Each token is mapped to its canonical tab ID
  4. Validation: Invalid tokens are skipped with a warning
  5. Filtering and deduplication: Duplicate tabs are removed in configured order, and Dev aliases are removed while PAT is disabled
  6. 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.

Implementation Patterns

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.

Authentication Model

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
  • /demo and /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.ts enforces 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 in GET /api/auth/sign-in. The handleSignIn() function in app/callback/route.ts only 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

Adding New Public Routes

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.

Using the Dashboard Component

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.

Dashboard Provider Structure

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 data
  • useThemeMode() - 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!

Adding a Custom Tab

  1. Create your tab component in app/logto-kit/components/dashboard/tabs/
  2. Add to LOAD_TABS in your .env

The tab system is pretty simple - look at existing tabs for examples.

Adding a Theme

  1. Copy app/logto-kit/themes/default/ to app/logto-kit/themes/{your-theme}/
  2. Edit the hex values in dark.css and light.css
  3. Import your CSS files in app/layout.tsx or app/globals.css
  4. Set THEME=your-theme in .env

Note: app/globals.css has hardcoded @import paths to themes/default/. To use a custom theme, you must replace those import paths (step 3). The THEME env var is a build-time folder identifier and does NOT dynamically switch CSS at runtime.

Adding a Language

  1. Create app/logto-kit/locales/{locale-code}.ts
  2. Follow the pattern in existing locale files
  3. Register in locales/index.ts and add the locale code to the LocaleCode type
  4. Add to LANG_AVAILABLE in .env

Refreshable Data Blocks (Primitives)

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.

Cookie & Session Management

The dashboard handles stale cookies automatically. When the Logto access token goes stale:

  1. Request to fetch data fails
  2. System detects "stale cookie" error
  3. Browser is redirected to GET /api/wipe which clears all app cookies
  4. Browser is redirected to / - a fresh token is obtained from the valid Logto session
  5. Dashboard loads normally

/api/wipe supports both GET (browser navigation - redirect-based flow) and POST (CSRF-safe fetch for programmatic use).

Sessions Tab - Logto Fork Required

⚠️ NOTE: Basic session listing (session IDs, timestamps, device info) works with any Logto version. The Sessions tab's advanced features (isCurrent flag, 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-at

A subset branch with only the isCurrent flag (no heartbeat/lastActiveAt) is available at feat/iscurrent-v1.39.

The Sessions tab features include:

  • isCurrent badge - The session backing the current request is marked with a green "This device" badge
  • lastActiveAt - 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 via geo-cache.ts.
  • Automatic heartbeat - A zero-UI SessionHeartbeat component fires recordHeartbeat() every 30s and on tab focus
  • Revoke all other sessions - Safe-guarded: aborts if no isCurrent session is identified

Manual Cookie Wipe

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>"

Force Sign-Out

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>"

User Preferences & JSON Schema

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.

JSON Schema Update (Important!)

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:

  1. The Preferences object has the correct structure
  2. Only valid theme values (dark or light) are accepted
  3. Language codes follow the locale pattern (e.g., en-US, ka-GE)
  4. Other custom data keys remain unaffected

How Preferences Work

  1. First Visit: No preferences exist, defaults from ENV are used (DEFAULT_THEME_MODE, LANG_MAIN)
  2. User Changes: When user changes theme or language, preferences are saved to customData.Preferences
  3. Subsequent Visits: Preferences are read from customData and override ENV defaults
  4. ENV Changes: If supported languages change (ENV LANG_AVAILABLE), preferences are validated and normalized

Avatar Upload

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.

Architecture

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() │
└─────────────────┘

Security

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.

Environment Variables

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

Supported Storage Backends

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

File Requirements

  • Allowed types: JPEG, PNG, WebP, GIF
  • Max size: 2 MB
  • Storage path: {userId}/you.png (always overwrites previous)

Setup Steps

  1. Create S3 Bucket:

    • Create a new bucket named avatars
    • Set it to Public (for avatar viewing)
    • Or configure appropriate bucket policies
  2. Configure Credentials:

    • Add the environment variables to .env
    • Ensure credentials have s3:PutObject permission on the bucket
  3. Test:

    • Go to the Profile tab in the dashboard
    • Try uploading an avatar image
    • The image should appear and persist to your Logto profile

Implementation Details

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:

  1. Builds a FormData with only the file field
  2. Calls the uploadAvatar() Server Action directly
  3. On success, invokes the onSuccess callback with the URL; the caller (Profile tab) then calls updateAvatarUrl() to persist the avatar to Logto

Usage in Custom Components

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>
  );
}

Server Actions

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

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';

Profile Updates

// 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');

Password & Account

// 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();

MFA Management

// 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;

WebAuthn Passkeys

// 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 webauthnRelatedOrigins in your Logto tenant's Account Center settings to allow cross-origin passkey operations from your app's domain.

Sessions & Grants

// 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');

Organization Permissions

// 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 })

Utilities

import { formatPhone } from './logto-kit/logic';

// Format a raw E.164 phone number for display
const displayPhone = formatPhone('+12345678901');
// "+1 234 567 8901"

Identity Verification

// 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');

Avatar Upload

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

Installation

npm install

Development

npm run dev      # Start the Next.js dev server on port 3000 (auto-runs `predev` via npm lifecycle `pre` hook)

npm run dev automatically triggers npm run predev via npm's pre hook. Run npm run predev manually only if you edit .env without restarting the dev server.

Build

npm run build

Start (Production)

npm run start

Lint

npm run lint

Type Check

npm run type-check

Testing

The 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 test

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

TODO

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

License

If you cause implosion of Earth running this mesmerizingly terrible code, I am not liable. MIT.

About

An attempt at re-creating Clerk's components for the open source project Logto.

Resources

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages