diff --git a/.agents/skills/strapi-plugin-maintenance/SKILL.md b/.agents/skills/strapi-plugin-maintenance/SKILL.md index d8f3e9d..f972837 100644 --- a/.agents/skills/strapi-plugin-maintenance/SKILL.md +++ b/.agents/skills/strapi-plugin-maintenance/SKILL.md @@ -5,8 +5,18 @@ description: Maintain Magic-Sessionmanager as a standalone Strapi 5 plugin, incl # Magic-Sessionmanager maintenance -Use Node 22. Check local rules, current Strapi 5 docs through Context7 and latest compatible 5.x against the pinned workbench before changing auth/session hooks. Do not embed credentials, encryption keys or browser auth state in skills, source or Git; do not push without an explicit request. +Use Node 22. Read the always-applied Strapi plugin rule and check current Strapi 5 docs through Context7 before using admin or Design System APIs. Compare with installed types and the pinned workbench version. If Context7 is quota-limited, tell the user and verify official docs plus the installed implementation. Do not embed credentials, encryption keys or browser auth state in skills, source or Git; do not push without an explicit request. Trace `server/src/routes/admin.js`, `content-api.js`, bootstrap JWT wrapper, session services and `admin/src/permissions.js`. Make a route matrix for anonymous, unauthorised admin, allowed admin, own session and other user's session. Check revocation, session listing, settings and analytics on the server; hiding a menu is not access control. Public hooks or compatibility routes require a documented signature/token and rate limit. Verify encryption key fallback is development-only, no token/PII logging, retention and GeoIP consent/availability; do not call external GeoIP services in a smoke test. -For the admin UI, use Strapi 5 Design System components, permission-backed navigation, accessible table actions, focus and responsive settings forms. Run `npm test`, `npm run build`, `npm run verify:runtime`; then shared app build, boot and Playwright CLI checks. Report independent unit, integration and negative-permission evidence separately. +For the admin UI, first read the workbench root `references/ui-ux-standards.md`. Use current Strapi 5 Design System components. Register a new Settings section with `app.addSettingsLink({ id, intlLabel }, links)` and add links to an existing section with `app.addSettingsLink(sectionId, linkOrLinks)`. Use permission-backed navigation, accessible controls and responsive forms. Use the Design System Switch for binary settings; in installed v2.2.4, Toggle displays Off/On choices vertically and is not the compact control. Keep the horizontal switch beside its title and description in one row. Do not stack the switch above centered copy. Use shrink-safe layouts (`width: 100%`, `min-width: 0`, responsive grid columns) and verify 320/390/1024/1440 CSS px plus the full breakpoint-adjacent matrix through Playwright CLI. Use Playwright CLI exclusively; never use CUA, Computer Use, or desktop-control tools. Preserve existing Settings routes and RBAC. If a link is missing, verify registration and the admin role before changing permissions. Run `npm test`, `npm run test:ts:front`, `npm run build`, `npm run verify`, and `npm run verify:runtime`; then shared app build, boot and authenticated Playwright CLI checks. If authentication or screenshot capture is unavailable, leave visual acceptance open. Report unit, integration, negative-permission and responsive-browser evidence separately. + +For the Users & Permissions end-user panel, use the documented Content Manager API `addEditViewSidePanel` for contextual session information. Use `addDocumentAction` or `addDocumentHeaderAction` when the task is a document action. Do not depend on `editView.informations`; Strapi marks that injection zone internal. Verify the mobile ActionsDrawer after opening it, including its constrained inner scroll area and reachability of the final action. The official API references are [Content Manager APIs](https://docs.strapi.io/cms/plugins-development/content-manager-apis) and [Admin injection zones](https://docs.strapi.io/cms/plugins-development/admin-injection-zones) (checked 2026-10-16; confirm installed package types on future upgrades). + +Keep Playwright CLI evidence private and value-free. Its accessibility snapshots may include values from password inputs. Check sensitive fields before snapshotting, enter fake credentials only in memory, and navigate away or clear the field before capturing another snapshot. Never reuse a test credential that appeared in CLI output or an artifact. + +## Shared product quality contract + +Use spec-first development: define acceptance criteria and API response/error contracts before implementation. + +Every behavior change starts with a written current-state and target-state analysis covering screens, controls, routes, roles, data, known gaps and non-goals. Define acceptance criteria and API response/error contracts before implementation, then add a failing regression test first. A primary task should take no more than four meaningful user interactions from the plugin entry page; exclude login and mandatory security confirmations, and measure and document any justified exception. Use the Strapi Design System, responsive layouts, keyboard accessibility and explicit loading, empty, success and error states. Enforce authentication, plugin permissions, validation and owner boundaries server-side. Keep API error contracts consistent within each Strapi surface and never return stack traces or internal details. Do not log secrets, tokens, cookies, request bodies, email, IP or stable user/session identifiers. Use fakes for providers and record unverified behavior accurately. Magic Session Manager remains limited to Users & Permissions end-user sessions; Strapi Admin sessions are outside scope. diff --git a/.cursor/rules/strapi-plugin-quality.mdc b/.cursor/rules/strapi-plugin-quality.mdc new file mode 100644 index 0000000..aad28f8 --- /dev/null +++ b/.cursor/rules/strapi-plugin-quality.mdc @@ -0,0 +1,40 @@ +--- +description: Always apply the Strapi plugin product, API, UI and privacy contract before changing plugin code or documentation. +alwaysApply: true +--- + +# Strapi plugin quality contract + +Before editing, read the applicable `AGENTS.md`, every always-applied Cursor rule, and the local `strapi-plugin-maintenance` skill. Inspect the root and target plugin Git status. Preserve existing changes and work on one plugin at a time. + +## Analyze and specify first + +- Record the current state: screens, controls, routes, permissions, error behavior, tests and known gaps. +- Define the target state, affected user roles and data, non-goals, API contract and measurable acceptance criteria before implementation. +- Use spec-first development. Add a failing regression test before changing behavior. Keep legacy routes and response contracts unless the approved spec explicitly changes them. + +## UI and journey + +- For any plugin admin UI, responsive, accessibility, form, or navigation change, read the workbench skill's `references/ui-ux-standards.md` before proposing or editing a design. +- Before using a Strapi admin or Design System API, check current Strapi 5 documentation and installed package types. Prefer Context7; if its quota is unavailable, say so and verify official Strapi documentation and installed code rather than silently relying on memory. +- Use the installed Strapi Design System and semantic, keyboard-accessible controls. Prefer its layout, form, table, feedback, switch and typography components over one-off styling. +- Use Playwright CLI exclusively for browser automation and responsive UI checks. Do not use CUA, Computer Use, or desktop-control tools. +- Make every admin page responsive by construction. Verify 320, 390, 1024 and 1440 CSS-pixel viewports in a real browser. Check document and component overflow, wrapping, focus, labels, contrast, and loading, empty, error and success states. Do not call source-only checks a visual acceptance pass. +- For flexible content, set `min-width: 0`, use full-width controls, and use `minmax(0, 1fr)` where content must shrink. Let descriptions wrap naturally. Avoid fixed widths/heights for text and nested padding that consumes the mobile viewport. +- Keep each field label, control and description together. Use equal desktop columns for paired fields and stack them cleanly on narrow screens without clipping or mismatched control widths. +- Keep Strapi `Toggle` controls horizontal and aligned beside their title/description in one compact row. Do not center a switch above vertically stacked copy. On narrow screens, let the copy wrap while the switch remains horizontal and reachable. +- Register plugin settings through the current `app.addSettingsLink` API. Verify the section label, expected paths and permission-backed visibility. Do not weaken or remove permissions to make a missing link appear; inspect RBAC and Strapi registration instead. +- A primary task should take no more than four meaningful user interactions from the plugin entry page to its successful outcome. Exclude authentication and mandatory security confirmations from the count. Measure the path and document any justified exception. Never remove a safety step to meet the limit. + +## API and security + +- Treat the server as the authority. Validate request shape and values, authenticate every protected route, check plugin permissions and resource ownership on every state-changing operation, and return only allowlisted fields. +- Document method, path, role, input, success response and error status for each affected route. Keep Strapi's admin and Content API conventions intact, use stable error codes/messages within each surface, and never return stack traces, raw exceptions or internal paths to clients. +- Do not put secrets in browser code. Do not log tokens, cookies, passwords, keys, full provider URLs, request bodies, IP addresses, email addresses or stable user/session identifiers. Log safe operation context and error class/code only; document and test any narrowly justified exception. +- Test anonymous and unauthorized access, invalid input, owner and other-user boundaries, provider failures, and privacy redaction. Use fake providers. Do not send messages, call live providers, mutate real user sessions or push changes unless separately authorized. + +## Verification and documentation + +- Cover the behavior with focused unit, UI, API-contract and security tests. Run the plugin's tests, type checks, verification and build, then the shared Strapi build/boot and authenticated Playwright acceptance where applicable. +- For UI changes, record Playwright evidence at 320/390/1024/1440 px, including the affected task path, measured overflow, keyboard interaction, and console state. If authenticated access or screenshot capture is unavailable, leave that gate explicitly open. +- Add or update JSDoc for exported and non-trivial functions whose contracts changed. Record measured evidence and leave unverified external or production behavior explicitly open. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42b3979..b02abfe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,8 +10,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: write - issues: write - pull-requests: write + id-token: write steps: - name: Checkout @@ -24,6 +23,10 @@ jobs: with: node-version: '22' registry-url: 'https://registry.npmjs.org' + package-manager-cache: false + + - name: Install OIDC-capable npm CLI + run: npm install --global npm@12.2.0 --no-audit --no-fund - name: Install dependencies run: npm ci --legacy-peer-deps @@ -40,5 +43,4 @@ jobs: - name: Run semantic-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: npx semantic-release diff --git a/.releaserc.json b/.releaserc.json index b23f122..25e8b89 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -5,7 +5,7 @@ "@semantic-release/release-notes-generator", "@semantic-release/changelog", "@semantic-release/npm", - ["@semantic-release/github", { "labels": false, "successComment": false }], + ["@semantic-release/github", { "labels": false, "successComment": false, "failCommentCondition": false }], [ "@semantic-release/git", { diff --git a/CHANGELOG.md b/CHANGELOG.md index 3074b97..fc68e3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## [Unreleased] + +### Added + +* Add native Strapi Users & Permissions end-user session support for `jwtManagement: 'refresh'` while retaining `legacy-support`, Magic session metadata and history, and existing endpoint contracts. The plugin does not change host authentication configuration. Strapi administrator-account sessions remain out of scope. + ## [4.7.2](https://github.com/Schero94/Magic-Sessionmanager/compare/v4.7.1...v4.7.2) (2026-07-11) diff --git a/GATES.md b/GATES.md new file mode 100644 index 0000000..710687a --- /dev/null +++ b/GATES.md @@ -0,0 +1,55 @@ +# Session Manager mobile, desktop, API and security gates + +Scope: Continue the approved Strapi-native UI direction for Users & Permissions end-user sessions. Keep Strapi Admin sessions, unrelated plugins, live user data and external provider effects out of scope. + +- [x] G1: Current-state, target-state, route inventory and click/API acceptance matrix are recorded for every Session Manager screen. + CHECK: test -f docs/plans/2026-10-16-sessionmanager-functional-acceptance.md && rg -q "Current state" docs/plans/2026-10-16-sessionmanager-functional-acceptance.md && rg -q "Target state" docs/plans/2026-10-16-sessionmanager-functional-acceptance.md && echo FUNCTIONAL_SPEC_PRESENT + EXPECT: FUNCTIONAL_SPEC_PRESENT + EVIDENCE: `docs/plans/2026-10-16-sessionmanager-functional-acceptance.md` records the current pages, the unsaved-user and empty-blocklist defects found, all 20 admin and 7 Content API routes, native end-user endpoints, click matrix, scope boundaries, and acceptance strategy. + +- [x] G2: Existing route paths, methods, response envelopes, permissions, owner checks and native/legacy branches pass focused regression tests. + CHECK: node --test tests/route-contracts.test.js tests/native-session-routes.test.js tests/content-api-permissions.test.js tests/session-controller-delete.test.js + EXPECT: # fail 0 + EVIDENCE: 36/36 focused route tests passed. They cover every inventoried admin and Content API route, protected access, route envelopes, user ownership, current-session denial, idempotent native/legacy paths, and session deletion. + +- [x] G3: The full plugin suite covers the completed UI action matrix, API contracts, privacy and regressions. + CHECK: npm test + EXPECT: # fail 0 + EVIDENCE: Fresh final quality run on 2026-10-16 passed 336 tests, 0 failures. + +- [x] G4: Backend and frontend types, package verification, runtime bundle verification and plugin build pass. + CHECK: npm run test:ts:back && npm run test:ts:front && npm run verify && npm run verify:runtime && npm run build + EXPECT: Done + EVIDENCE: Fresh `node scripts/plugins.mjs Magic-Sessionmanager quality` passed 336 tests, both TypeScript checks, package verification, runtime bundle verification and the production plugin build. It ended with `ALL PLUGIN QUALITY GATES PASSED`. + +- [x] G5: The shared Strapi admin application builds from the current Session Manager checkout. + CHECK: cd ../.. && npm run app:build + EXPECT: Building admin panel + EVIDENCE: Fresh `npm run app:build` passed on the workbench's pinned Strapi 5.58.0 host after the final plugin UI changes. + +- [x] G6: The integration host uses a new isolated database and a loopback port distinct from the existing 127.0.0.1:1337 service before any test data is created. + EVIDENCE: Full acceptance ran on 127.0.0.1:1340 with `test-app/.tmp/sessionmanager-live.QUkM30/data.db`; its database and synthetic test accounts were removed. A final no-credential boot smoke ran on 127.0.0.1:1341 with a separate temporary SQLite database, opened only the first-admin setup screen, then removed the database and freed the port. Existing listeners on 127.0.0.1:1337 and 127.0.0.1:1338 were untouched. + +- [x] G7: Every visible Session Manager control is exercised through Playwright CLI on desktop and mobile, including successful, empty, error, validation and confirmation states where applicable. + EVIDENCE: Playwright CLI covered the dashboard search/status/page-size/export/detail/terminate/remove actions; Settings save/reset, security switches, number limits, GeoIP credential storage and provider-mocked update/force-refresh, geofencing mode and country add/remove, email-alert checkboxes and all template tabs/actions, and Clean Now confirmation; Analytics and Activation loading/retry, status, optional-key reveal/hide/copy/download using a fake key; Online Users states; and Users & Permissions panel retry, empty/populated state, terminate-all, block/unblock and cancel/confirm paths. Clipboard writes were stubbed, license/update network effects were mocked, and no email or provider call was sent. + +- [x] G8: All affected screens pass the Strapi breakpoint matrix with measured document and component overflow, keyboard focus, accessible names, labels and console/network review. + EVIDENCE: Playwright CLI measured Dashboard, General Settings, Analytics, Activation and the end-user panel at 320, 390, 519, 520, 767, 768, 1024, 1079, 1080 and 1440 CSS px. All 50 document-width checks had zero horizontal overflow. At 320x568 the Content Manager ActionsDrawer opened, its inner scroll exposed the final action without page overflow, and keyboard focus reached a session action with a visible focus state. No Session Manager console error occurred. The Content Manager also emitted Strapi-core 404s for its optional AI localization job and preview URL endpoints; they are outside this plugin's route namespace and are recorded in the acceptance plan. + +- [x] G9: Each plugin-owned API route has an authorised success case and relevant anonymous, wrong-role, wrong-owner, invalid-input and failure case. Strapi-native session endpoints remain user-scoped. + EVIDENCE: The 36 focused route tests and full plugin suite passed. Browser network evidence returned 200 for sessions, count-only user statistics, settings GET/PUT, GeoIP credential save/status, license status without a key, end-user session listing and session/user mutations. Fake GeoIP-update and license-status responses covered provider-facing UI paths. Native owner, current-session and legacy compatibility cases remain covered by their regression tests. + +- [ ] G10: Browser/API evidence contains no credentials, tokens, raw session identifiers, personal data or internal exception details. No existing end-user or admin sessions are changed, and no external provider is contacted. + EVIDENCE: All account, session, email and GeoIP data was synthetic and stored only in the isolated test database, which has been deleted. A Playwright CLI snapshot did serialize a generated password for the synthetic test user once. The exact snapshot and local password file were deleted, but the tool transcript cannot be edited. No production credential or existing user/admin session was involved. No provider was contacted. + +- [x] G11: The full plugin quality command and shared-host startup are re-run after browser-driven fixes; the final Playwright sweep reflects the final code. + CHECK: node ../../scripts/plugins.mjs Magic-Sessionmanager quality && cd ../.. && npm run app:build + EXPECT: ALL PLUGIN QUALITY GATES PASSED + EVIDENCE: On 2026-10-16 the plugin quality command passed 336 tests plus both type checks, package/runtime verification and plugin build; the shared Strapi 5.58.0 admin build also passed. A fresh boot loaded all eight configured plugin keys and logged successful SessionInfoPanel injection. Playwright CLI opened `/admin/auth/register-admin` without entering credentials and reported 0 console errors and 2 Strapi warnings. The full action sweep recorded in `docs/plans/2026-10-16-sessionmanager-functional-acceptance.md` verified the saved-user panel, Geofencing controls and Settings/Activation flows against the isolated host. + +- [ ] G12: The installed Strapi dependency graph has no unresolved high-severity advisories. + EVIDENCE: Rechecked 2026-10-16. `npm audit --omit=dev` reports five high findings in the plugin checkout via its required Strapi 5 peer chain; the shared host reports 30 high and 3 moderate transitive findings. `npm view` reports `@strapi/strapi@5.58.0` and `braces@3.0.3` as current latest, while GHSA-vfj7-8cjw-p6xm affects all `braces` versions through 3.0.3. `npm audit fix --dry-run --omit=dev` still leaves the advisories; the forced remediation downgrades Strapi to 4.26.2. No safe compatible fix is published, so no downgrade or unsupported override was applied. + +ABANDON: G10 A generated local-only synthetic password appeared in the Playwright CLI transcript. Its exact snapshot artifact, local password file and isolated database have been removed, but the conversation transcript itself cannot be recalled. No real credentials, user data or sessions were exposed. + +ABANDON: G12 The upstream `braces` advisory has no patched release. The suggested forced npm fix downgrades a Strapi 5 dependency to Strapi 4 and is incompatible with the supported host. Re-run the audit after the advisory or Strapi dependency chain is fixed. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..7a831f3 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,56 @@ +# Plan: Native Users & Permissions sessions + +Depth: tree 4 Mode: orchestrated +Budget note: Multi-surface authentication refactor with isolated tests, local Strapi integration, and browser acceptance. Work is split to keep independent auth, service, API, and UI changes reviewable. + +## Contract + +Decided before fan-out. The approved design is `docs/plans/2026-10-15-native-user-sessions-design.md`; the task breakdown is `docs/plans/2026-10-15-native-user-sessions-plan.md`. + +- Interfaces: `getNativeSessionManager(strapi)` returns `null` in `legacy-support`; in `refresh`, it returns a checked facade exposing `validateAccessToken`, `validateRefreshToken`, `isSessionActive`, `listSessions`, `revokeSessionById`, and `invalidateRefreshToken`, or throws an actionable error if Strapi has no registered Users & Permissions origin. Native session owner IDs are `String(usersPermissionsUser.id)`. Magic's existing `sessionId` and `documentId` remain unchanged. Native-only view IDs use the `native_` prefix. +- Service contract: `createSession` accepts optional `nativeSessionId`; it derives the configured mode itself, storing only token hashes in native mode and preserving encrypted legacy behavior. `rotateSessionTokens` accepts optional `nativeSessionId` and updates it in the same conditional write as the token hashes. Existing list, terminate, delete, and cleanup methods keep their names and input shapes; in native mode they delegate to Strapi and preserve Magic history. `getAllSessions` continues returning an array and may expose a non-enumerable `truncated` boolean for the controller to serialize as `meta.truncated`. +- Response contract: existing Magic route paths, methods, permission chains, and response envelopes stay unchanged. The normalized session view keeps `documentId`, `id`, `sessionId`, `user`, `isActive`, `isTrulyActive`, and current status fields. Native views also carry the non-secret `nativeSessionId` needed to mark the current session and correlate revocation; they never expose tokens or the native metadata blob. Native-only row IDs use the reserved `native_` prefix plus base64url-encoded JSON `[userDocumentId, nativeSessionId]`, so a row action resolves exactly one owner without scanning every user. Missing IP/device/geolocation values are represented as unavailable. +- Data ownership: native adapter/auth leaves own `server/src/utils/native-session-manager.js`, `server/src/bootstrap.js`, `server/src/middlewares/last-seen.js`, and their dedicated tests. Service leaf owns `server/src/services/session.js`, `server/src/content-types/session/schema.json`, optional `server/src/utils/native-session-view.js`, and service tests. API leaf owns `server/src/controllers/session.js`, route files only if required, and route tests. Docs/UI leaf owns README/changelog and admin files/tests. No two leaves edit the same file. +- Error behavior: native auth/revocation failures fail closed. Do not double-issue or double-rotate native tokens. Retain existing revocation deny-list guarantees. Do not log raw tokens, credentials, or full IP addresses. Do not query internal `admin::session` storage. +- Content API permissions: Strapi runs plugin bootstraps in module order; Users & Permissions creates the `authenticated` role in its own bootstrap, which may occur after Magic. If the existing auto-permission initializer finds no role during Magic bootstrap, retry before a protected Magic Content API route after all plugin bootstraps. Retry failed setup with a cooldown. If an administrator saves the Authenticated role first, persist that decision and do not re-enable missing actions. Keep the route's Users & Permissions auth strategy and ownership checks; do not add public permissions or bypass RBAC. +- Compatibility: legacy mode stays the default and existing plugin behavior remains available. Do not change the host application's auth config or migrate existing records. Scope excludes Strapi administrator sessions. +- Worktree: continue in the existing plugin checkout because it contains user-owned, uncommitted audit changes that must be preserved. Subagents must change only their assigned paths. The driver reviews and integrates every leaf. + +## Tree + +- 1 Native end-user session integration + - 1.1 Native lifecycle .......... `gates/native-lifecycle-branch.md` + - 1.1.1 Adapter contract .......... `gates/native-adapter.md` + - 1.1.2 Login/refresh/JWT hooks .......... `gates/native-auth.md` + - 1.2 Magic compatibility layer .......... `gates/native-compatibility-branch.md` + - 1.2.1 Sidecar/list/history service .......... `gates/native-service.md` + - 1.2.2 Existing API and authorization .......... `gates/native-routes.md` + - 1.3 Product acceptance .......... `gates/native-acceptance-branch.md` + - 1.3.1 Admin display and user docs .......... `gates/native-ui-docs.md` + - 1.3.2 Local Strapi and Playwright acceptance .......... `gates/native-acceptance.md` + - 1.4 Integration root .......... `gates/native-integration-root.md` + +## Status log + +Append-only. One line per event: leaf started, leaf verified, gate abandoned. Never rewrite lines above. + +- 2026-10-15: plan written; design approved by user; native access-revocation behavior confirmed against installed Strapi 5.58.0 source. +- 2026-10-15: native adapter leaf implemented and parent-reviewed; focused adapter suite passed 8/8. Bundle-inclusion gate remains open until auth hooks import the adapter. +- 2026-10-15: confirmed the local test host is bound to loopback and uses the isolated `test-app/.tmp/plugin-audit-2026-10-15-2X6P/data.db`; no user/session rows were touched. +- 2026-10-15: baseline package suite passed 152/152; backend and frontend type checks exited 0 before native lifecycle changes. +- 2026-10-15: native service, auth lifecycle, route facade, and admin-display/docs leaves dispatched in disjoint file scopes for implementation and review. +- 2026-10-15: integration review tightened native-only action keys to carry an opaque owner/session pair, avoiding a full user enumeration for one revoke/delete action while preserving route paths. +- 2026-10-15: integration review requires normalized views to retain the non-secret native session ID so current-session flags work for linked and native-only rows. +- 2026-10-15: first post-build host restart inherited `.env`'s default `.tmp/data.db` instead of the isolated audit DB. The server was stopped before API/browser use; the default DB file's mtime/size changed during startup, so schema/cleanup effects cannot be ruled out. No restore or further access to that file is planned. Future runtime checks will use a fresh dedicated temporary database and verify the live process before creating synthetic users. +- 2026-10-15: restarted on fresh `test-app/.tmp/native-session-int.Q028Qt/data.db`, verified the `DATABASE_FILENAME` of PID 40610 and the loopback listener's open SQLite path; no synthetic session data created yet. +- 2026-10-15: fresh-database API reproduction returned 200 for native `/api/auth/sessions` but 403 for Magic `/api/magic-sessionmanager/my-sessions`. Startup logged that the Authenticated role was not found; read-only aggregate found 2 U&P roles, 0 Magic permission actions, and 2 active native-linked Magic sidecars. Root cause is Magic's permission initialization running before U&P creates its roles, not native session mapping. +- 2026-10-15: parent review added regressions for a permission save before first use, transient setup failures, and concurrent first requests. Deferred setup now preserves administrator-saved Authenticated-role choices and retries failures after a cooldown without changing route authentication. +- 2026-10-15: fresh isolated Strapi 5.58.0 boot reproduced the missing-role ordering. After registration, native `/api/auth/sessions` and Magic `/api/magic-sessionmanager/my-sessions` both returned 200; seven Magic actions were added only to Authenticated, with zero Public actions. Runtime acceptance verified refresh rotation, current-session marking, wrong-owner denial, Magic/native revocation, logout-others/all, both logout routes, and reconciliation after native revocation. Test accounts and session mutations were confined to `test-app/.tmp/native-session-fresh.FzAudR/data.db`. +- 2026-10-15: runtime acceptance found that the controller read `ctx.state.auth.credentials.sessionId`, while Strapi 5.58.0 exposes the verified native session ID at `ctx.state.session.id`. The controller now reads Strapi's verified state; regression tests and live current-session checks pass after refresh rotation. +- 2026-10-15: Playwright audit found mobile dashboard clipping, an active-only default that hides idle sessions, and inconsistent list/detail status labels. A full admin-surface design is pending user approval; no UI files were changed for that audit. +- 2026-10-15: user approved the full Strapi-native redesign across dashboard, session details, settings, analytics, activation and the Users & Permissions user panel. Scope remains end-user sessions only; Strapi Admin sessions are excluded. +- 2026-10-15: the UI follow-up corrected Strapi Design System heading props, settings dirty-state comparison, mobile settings grids and the geofencing country row. The cleanup-strategy labels are now compact. The session dashboard now paginates all filtered results and exports the full filtered set through tested pure serializers. +- 2026-10-15: Playwright verified dashboard filtering, pagination and no-results reset; detail modal expand/close; local settings changes and Reset; geofencing add/remove; email tabs and local validation; webhook toggle; activation refresh; and the end-user panel. At 390x844 and 1440x1000, checked surfaces had no document overflow. Session exports, settings saves, credentials, provider requests and destructive actions were not invoked. +- 2026-10-15: final package verification passed with 262/262 tests, frontend/backend TypeScript, plugin build, package verify and runtime bundle verify. The shared Strapi admin build passed. SessionManager gate check reported all 49 plugin gates met; workbench SessionManager audit reported all 7 gates met. +- 2026-10-15: follow-up to the earlier export note: Playwright clicked both export buttons with Blob interception. CSV reported 22 rows; JSON reported 22 sessions and no token fields. The interceptor was restored and no file was saved. Plugin pages had zero console errors; two Strapi warnings remained. +- 2026-10-15: current native-integration re-verification passed 273/273 plugin tests, frontend/backend TypeScript, plugin build/verify/runtime bundle, shared admin build, and 60 targeted native/legacy route/auth regressions. A second Strapi 5.58.0 process ran on 127.0.0.1:1338 with a new `.tmp/native-session-reverify.GAFfbvyZ/data.db`; all eight plugin keys registered. Two generated `example.invalid` Users & Permissions users exercised native registration/list/refresh/revoke, Magic list/current/delete/logout/others/all, native logout and cross-owner denial. GeoIP was disabled in this test DB. Final aggregate: 0 admin users, 0 active native sessions, 0 active Magic sidecars and 0 raw access/refresh tokens. The pre-existing 127.0.0.1:1337 listener and database were untouched; no provider request was made. diff --git a/README.md b/README.md index 834a638..b10c2ef 100644 --- a/README.md +++ b/README.md @@ -192,9 +192,7 @@ SESSION_ENCRYPTION_KEY=your-key-here **Need to kick someone out?** 1. Find their session 2. Click **"Terminate"** -3. Done! They're logged out immediately - -**Even works if they have refresh tokens!** (See below) +3. Magic Sessionmanager asks the configured session manager to revoke it. The behavior depends on the mode below. ### 3. Session Details @@ -224,52 +222,29 @@ Each login = separate session. You can see them all and logout each individually --- -## 🔒 Refresh Token Protection (Advanced) - -### The Problem (Without This Plugin) +## 🔒 Session management modes -``` -Admin kicks out a user - ↓ -User has "refresh token" - ↓ -User gets new login token automatically - ↓ -User is back in! 😱 -``` - -### The Solution (With This Plugin) - -``` -Admin kicks out a user - ↓ -User tries to use refresh token - ↓ -Plugin blocks it! 🚫 - ↓ -User MUST login again -``` +Magic Sessionmanager supports Strapi Users & Permissions end-user sessions in both the native refresh mode and the existing `legacy-support` mode. -**How to enable:** +### Native Users & Permissions sessions -Add to `config/plugins.ts`: +Set `jwtManagement` to `refresh` in the host application's Users & Permissions configuration to use Strapi's native session manager: ```typescript 'users-permissions': { config: { - jwtManagement: 'refresh', // Enable refresh tokens - sessions: { - accessTokenLifespan: 3600, // 1 hour - maxRefreshTokenLifespan: 2592000, // 30 days - }, + jwtManagement: 'refresh', }, } ``` -**What this does:** -- Users stay logged in longer (better experience) -- But admins can still force-logout completely (better security) -- Best of both worlds! ✅ +In this mode, Strapi issues, rotates and revokes native sessions. Magic Sessionmanager uses that state for end-user sessions while keeping its metadata and session history, such as device, IP, GeoIP and security details. It does not issue or rotate a second token family. Rows created natively without a Magic metadata record can still appear with a `native_` ID. Missing IP, device or GeoIP values are shown as unavailable. + +The existing Magic endpoints keep their paths, methods, permissions and response envelopes. The plugin does not change the host application's authentication configuration or migrate existing session records. Set `jwtManagement: 'refresh'` in the host application to opt into native session management. Its default Content API permission setup targets only the Authenticated role and never adds these actions to Public. If an administrator saves the Authenticated role before the first protected Magic request, the plugin preserves that role's permission choices. Strapi administrator-account sessions are not managed by this plugin. + +### Existing `legacy-support` mode + +When Users & Permissions uses `jwtManagement: 'legacy-support'`, Magic Sessionmanager keeps its existing plugin-owned session behavior. It does not switch the host to refresh mode or migrate legacy session records. Existing Magic endpoints remain available in either mode. --- @@ -616,6 +591,8 @@ Authorization: Bearer Terminates a specific session (not the current one). Useful for "Log out other devices". Use the `id` / `documentId` value from the session response. The separate `sessionId` field is a tracking identifier stored on the session record. +For a native-only row, use its `native_` value from `id`. Native-only rows may not have a `documentId` or Magic metadata. + ```bash DELETE /api/magic-sessionmanager/my-sessions/:documentId Authorization: Bearer diff --git a/admin/src/components/FormField.jsx b/admin/src/components/FormField.jsx new file mode 100644 index 0000000..aa8063b --- /dev/null +++ b/admin/src/components/FormField.jsx @@ -0,0 +1,74 @@ +import { Field } from '@strapi/design-system'; +import styled from 'styled-components'; + +const FieldFrame = styled.div` + width: 100%; + min-width: 0; + + & > div { + width: 100%; + min-width: 0; + } +`; + +const FieldControl = styled.div` + width: 100%; + min-width: 0; + + & > * { + width: 100% !important; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + } + + [role='combobox'] { + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + justify-content: space-between; + } + + input, + textarea { + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + } +`; + +const FieldHint = styled(Field.Hint)` + display: block; + width: 100%; + min-width: 0; + max-width: 72ch; + margin-top: 8px; + line-height: 1.6; + text-wrap: pretty; +`; + +/** + * Renders a Strapi Design System field with a visible label, an associated + * control, and concise helper text. The control is constrained to its grid + * column so long select values cannot widen the page on small screens. + * + * @param {{ id: string, name?: string, label: import('react').ReactNode, description: import('react').ReactNode, children: import('react').ReactNode }} props Field identity, copy, and control. + * @returns {import('react').ReactElement} A responsive, accessible form field. + */ +const FormField = ({ id, name = id, label, description, children }) => { + const hintId = `${id}-hint`; + + return ( + + + {label} + {children} + {description ? {description} : null} + + + ); +}; + +export default FormField; diff --git a/admin/src/components/Initializer.jsx b/admin/src/components/Initializer.jsx index 8c10ab0..bcb0483 100644 --- a/admin/src/components/Initializer.jsx +++ b/admin/src/components/Initializer.jsx @@ -1,14 +1,15 @@ import { useEffect, useRef } from 'react'; -import { useFetchClient } from '@strapi/strapi/admin'; import pluginId from '../pluginId'; /** - * Initializer component that sets up the plugin and maintains session - * Includes a heartbeat to keep the admin session alive + * Registers the plugin with Strapi without taking ownership of administrator + * session renewal. Strapi Admin manages its own authentication lifecycle. + * + * @param {{setPlugin: (pluginId: string) => void}} props Strapi initializer props. + * @returns {null} */ const Initializer = ({ setPlugin }) => { const ref = useRef(setPlugin); - const { get } = useFetchClient(); useEffect(() => { if (ref.current) { @@ -16,34 +17,6 @@ const Initializer = ({ setPlugin }) => { } }, []); - // Session heartbeat - keeps token alive by making periodic requests - useEffect(() => { - const HEARTBEAT_INTERVAL = 4 * 60 * 1000; // 4 minutes - - /** - * Sends a lightweight request to keep the session active - */ - const heartbeat = async () => { - try { - // Use plugin's own license status endpoint as heartbeat (lightweight, authenticated) - await get(`/${pluginId}/license/status`); - } catch (error) { - // Silently ignore errors - this is just a keep-alive - } - }; - - // Initial heartbeat after 1 minute - const initialTimeout = setTimeout(heartbeat, 60 * 1000); - - // Regular heartbeat every 4 minutes - const interval = setInterval(heartbeat, HEARTBEAT_INTERVAL); - - return () => { - clearTimeout(initialTimeout); - clearInterval(interval); - }; - }, [get]); - return null; }; diff --git a/admin/src/components/OnlineUsersWidget.jsx b/admin/src/components/OnlineUsersWidget.jsx index e2e3bd1..3f754d9 100644 --- a/admin/src/components/OnlineUsersWidget.jsx +++ b/admin/src/components/OnlineUsersWidget.jsx @@ -1,10 +1,12 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useIntl } from 'react-intl'; -import { Box, Typography, Flex, Grid } from '@strapi/design-system'; -import { Check, Cross, Clock, User, Monitor } from '@strapi/icons'; +import { Alert, Box, Button, Typography, Flex, Grid } from '@strapi/design-system'; +import ReadableText from './ReadableText'; +import { Check, Cross, Clock, User } from '@strapi/icons'; import { useFetchClient } from '@strapi/strapi/admin'; import { getTranslation } from '../utils/getTranslation'; -import { computeOnlineUserStats } from '../utils/onlineStats.mjs'; +import { computeOnlineUserStats, normalizeUserCounts } from '../utils/onlineStats.mjs'; +import { logSafeAdminError } from '../utils/safeAdminLog.mjs'; /** * Online Users Widget - Dashboard widget showing user activity statistics @@ -16,55 +18,70 @@ const OnlineUsersWidget = () => { const t = (id, defaultMessage, values) => formatMessage({ id: getTranslation(id), defaultMessage }, values); const [stats, setStats] = useState({ onlineNow: 0, - offline: 0, + offline: null, last15min: 0, last30min: 0, - totalUsers: 0, - blocked: 0, + totalUsers: null, + blocked: null, }); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const [userCountsUnavailable, setUserCountsUnavailable] = useState(false); + const requestInProgress = useRef(false); + + /** + * Loads session activity and aggregate end-user counts for the dashboard. + * User counts remain unavailable when their API request fails; they are + * never represented as zero unless the server returned a valid zero. + * + * @param {{showLoading?: boolean}} [options] Whether to show the initial or retry loading state + * @returns {Promise} + */ + const fetchStats = useCallback(async ({ showLoading = true } = {}) => { + if (requestInProgress.current) return; + requestInProgress.current = true; + if (showLoading) setLoading(true); - const fetchStats = useCallback(async () => { try { - const { data } = await get('/magic-sessionmanager/sessions'); - const sessions = data.data || []; + const { data: sessionResponse } = await get('/magic-sessionmanager/sessions'); + const sessions = sessionResponse?.data; + if (!Array.isArray(sessions)) throw new Error('Invalid session response'); + let userCounts = null; + let countsUnavailable = false; try { - // Get total users - const { data: usersData } = await get('/content-manager/collection-types/plugin::users-permissions.user?pageSize=1'); - const totalUsers = usersData?.pagination?.total || 0; - - // Get blocked users count - const { data: blockedData } = await get('/content-manager/collection-types/plugin::users-permissions.user?filters[$and][0][blocked][$eq]=true&pageSize=1'); - const blockedUsers = blockedData?.pagination?.total || 0; - - setStats(computeOnlineUserStats(sessions, { - totalUsers, - blockedUsers, - })); - } catch (err) { - console.error('[OnlineUsersWidget] Error fetching user count:', err); - setStats(computeOnlineUserStats(sessions)); + const { data: userStatsResponse } = await get('/magic-sessionmanager/users/stats'); + userCounts = normalizeUserCounts(userStatsResponse); + if (!userCounts) throw new Error('Invalid user statistics response'); + } catch (error) { + logSafeAdminError('onlineUsers.userCounts', error); + countsUnavailable = true; } - } catch (err) { - console.error('[OnlineUsersWidget] Error:', err); + + setStats(computeOnlineUserStats(sessions, userCounts || {})); + setUserCountsUnavailable(countsUnavailable); + setLoadError(false); + } catch (error) { + logSafeAdminError('onlineUsers.loadSessions', error); + setLoadError(true); } finally { - setLoading(false); + if (showLoading) setLoading(false); + requestInProgress.current = false; } }, [get]); useEffect(() => { - fetchStats(); - - // Refresh every 30 seconds - const interval = setInterval(fetchStats, 30000); + void fetchStats(); + + const interval = setInterval(() => { + void fetchStats({ showLoading: false }); + }, 30000); return () => clearInterval(interval); }, [fetchStats]); // Stat Card - styled like Project Statistics items const StatCard = ({ icon: Icon, label, value, color }) => ( { {label} - {value} + {value ?? t('common.unavailable', 'Unavailable')} @@ -106,7 +123,28 @@ const OnlineUsersWidget = () => { if (loading) { return ( - {t('common.loading', 'Loading...')} + {t('common.loading', 'Loading...')} + + ); + } + + if (loadError) { + return ( + + { void fetchStats(); }}> + {t('common.retry', 'Retry')} + + )} + > + + {t('widget.error.description', 'Try again. If the problem continues, check Session Manager API access.')} + + ); } @@ -114,6 +152,19 @@ const OnlineUsersWidget = () => { return ( + {userCountsUnavailable && ( + + + + {t('widget.partial.description', 'Session activity is shown, but total and blocked user counts could not be loaded.')} + + + + )} ( +
+ + + + {children} + + +
+); + +export default PluginPageLayout; diff --git a/admin/src/components/ReadableText.jsx b/admin/src/components/ReadableText.jsx new file mode 100644 index 0000000..a71a113 --- /dev/null +++ b/admin/src/components/ReadableText.jsx @@ -0,0 +1,93 @@ +import { Typography } from '@strapi/design-system'; +import styled, { css } from 'styled-components'; + +/** + * Shared paragraph and list rhythm for explanatory copy in plugin surfaces. + * It intentionally does not target labels, table cells, or metric values. + */ +export const paragraphRhythm = css` + p { + max-width: 72ch; + margin-block: 0; + line-height: 1.6; + text-wrap: pretty; + } + + p + p { + margin-block-start: 12px; + } + + li { + max-width: 72ch; + line-height: 1.6; + text-wrap: pretty; + } + + li + li { + margin-block-start: 6px; + } +`; + +/** + * Renders readable body copy with a comfortable line length and consistent + * separation when consecutive paragraphs are used. + * + * @returns {import('react').ReactElement} A Strapi typography paragraph. + */ +const ReadableText = styled(Typography)` + display: block; + width: 100%; + min-width: 0; + max-width: 72ch; + margin: 0; + line-height: 1.6; + text-wrap: pretty; + + & + & { + margin-block-start: 12px; + } +`; + +const ParagraphGroup = styled.div` + display: block; + width: 100%; + min-width: 0; +`; + +const InlineParagraphGroup = styled.span` + display: block; + width: 100%; + min-width: 0; +`; + +/** + * Renders translated text as separate, consistently spaced paragraphs when + * the copy contains a blank line. Single-paragraph hints keep their compact + * layout without needing a second presentation component. + * + * @param {{ id?: string, text: string, variant?: string, textColor?: string, tag?: string }} props Paragraph text and Strapi typography options. + * @returns {import('react').ReactElement} A shrink-safe group of readable paragraphs. + */ +export const ReadableParagraphs = ({ id, text, ...typographyProps }) => { + const ParagraphContainer = typographyProps.tag === 'span' ? InlineParagraphGroup : ParagraphGroup; + const paragraphs = String(text ?? '') + .split(/\n\s*\n/) + .map((paragraph) => paragraph.trim()) + .filter(Boolean); + + return ( + + {paragraphs.map((paragraph, index) => ( + + {paragraph} + + ))} + + ); +}; + +export default ReadableText; diff --git a/admin/src/components/SessionDetailModal.jsx b/admin/src/components/SessionDetailModal.jsx index e3a73e4..25d5236 100644 --- a/admin/src/components/SessionDetailModal.jsx +++ b/admin/src/components/SessionDetailModal.jsx @@ -3,6 +3,8 @@ import { useIntl } from 'react-intl'; import styled from 'styled-components'; import { Modal, + Badge, + Button, Box, Flex, Typography, @@ -13,95 +15,97 @@ import { Phone, Server, Cross, - Check, - Clock, Information, - Earth, - Shield, } from '@strapi/icons'; import { useFetchClient, useNotification } from '@strapi/strapi/admin'; import parseUserAgent from '../utils/parseUserAgent'; import pluginId from '../pluginId'; import { getTranslation } from '../utils/getTranslation'; -import { - TertiaryButton, - DangerButton, - ShowHideButton, -} from './StyledButtons'; +import { getSessionStatus } from '../utils/sessionStatus.mjs'; +import { formatSessionDateTime, getBrowserVersionFromUserAgent } from '../utils/sessionPresentation.mjs'; +import { logSafeAdminError } from '../utils/safeAdminLog.mjs'; +import ReadableText from './ReadableText'; const TwoColumnGrid = styled.div` display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; + min-width: 0; - @media (max-width: 768px) { - grid-template-columns: 1fr; + & > * { + min-width: 0; } -`; -const SectionTitle = styled(Typography)` - text-transform: uppercase; - letter-spacing: 0.5px; - font-size: 11px; - font-weight: 700; - color: var(--colors-neutral800); - margin-bottom: 16px; - padding-bottom: 8px; - border-bottom: 2px solid rgba(128, 128, 128, 0.2); - display: block; + @media (max-width: 767px) { + grid-template-columns: minmax(0, 1fr); + } `; const Section = styled(Box)` + min-width: 0; margin-bottom: 24px; `; -// Status Badge - pill-shaped with gradient -const ModalStatusBadge = styled.span` - display: inline-flex; - align-items: center; - gap: 8px; - padding: 10px 24px; - border-radius: 24px; - font-size: 14px; - font-weight: 700; - letter-spacing: 0.5px; - text-transform: uppercase; - - ${props => props.$online && ` - background: linear-gradient(135deg, rgba(22, 163, 74, 0.12) 0%, rgba(34, 197, 94, 0.3) 100%); - color: var(--colors-success600, #166534); - border: 2px solid rgba(34, 197, 94, 0.3); - box-shadow: 0 4px 12px rgba(34, 197, 94, 0.2); - `} - - ${props => !props.$online && ` - background: linear-gradient(135deg, rgba(128, 128, 128, 0.08) 0%, rgba(128, 128, 128, 0.2) 100%); - color: var(--colors-neutral600); - border: 2px solid rgba(128, 128, 128, 0.2); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - `} +const DetailsList = styled.dl` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 24px; + row-gap: 0; + width: 100%; + min-width: 0; + margin: 8px 0 0; + + @media (max-width: 519px) { + grid-template-columns: minmax(0, 1fr); + } `; -const StatusDot = styled.span` - width: 10px; - height: 10px; - border-radius: 50%; +const DetailItem = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + padding: 10px 0 12px; + border-bottom: 1px solid ${(props) => props.theme.colors.neutral150}; +`; - ${props => props.$online && ` - background: var(--colors-success600, #22C55E); - box-shadow: 0 0 8px rgba(34, 197, 94, 0.6); - animation: pulse-green 2s ease-in-out infinite; - `} +const DetailLabel = styled(Typography)` + display: block; + width: 100%; + min-width: 0; + margin: 0; + font-size: 12px; + line-height: 1.5; + font-weight: 600; + text-wrap: pretty; +`; + +const DetailValue = styled(Typography)` + display: block; + width: 100%; + min-width: 0; + margin: 0; + font-size: 14px; + line-height: 1.5; + overflow-wrap: anywhere; + word-break: break-word; +`; - ${props => !props.$online && ` - background: var(--colors-neutral500); - `} +const ModalActions = styled(Flex)` + width: 100%; + min-width: 0; + gap: 12px; + flex-wrap: wrap; - @keyframes pulse-green { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.7; transform: scale(1.15); } - } - `; + @media (max-width: 519px) { + flex-direction: column; + align-items: stretch; + + & > button { + width: 100%; + } + } +`; const getStoredGeoData = (session) => { let geoLocation = session?.geoLocation; @@ -114,22 +118,61 @@ const getStoredGeoData = (session) => { } if (!geoLocation || typeof geoLocation !== 'object') return null; - if (!geoLocation.country && !geoLocation.country_code && !geoLocation.city) return null; + if (!geoLocation.country && !geoLocation.country_code && !geoLocation.city && !geoLocation.timezone) return null; + const hasSecurityScore = typeof session.securityScore === 'number'; return { country_flag: geoLocation.country_flag || '', - country: geoLocation.country || geoLocation.country_code || 'Unknown', - city: geoLocation.city || 'Unknown', - timezone: geoLocation.timezone || 'Unknown', - securityScore: typeof session.securityScore === 'number' ? session.securityScore : 100, - riskLevel: typeof session.securityScore === 'number' && session.securityScore < 50 ? 'High' : 'Low', - isVpn: false, - isProxy: false, + country: geoLocation.country || geoLocation.country_code || null, + city: geoLocation.city || null, + timezone: geoLocation.timezone || null, + securityScore: hasSecurityScore ? session.securityScore : null, + riskLevel: hasSecurityScore ? (session.securityScore < 50 ? 'High' : 'Low') : null, + isVpn: typeof geoLocation.isVpn === 'boolean' ? geoLocation.isVpn : null, + isProxy: typeof geoLocation.isProxy === 'boolean' ? geoLocation.isProxy : null, + }; +}; + +/** + * Parses available device metadata without inferring a device from missing native fields. + * @param {object} session Normalized Magic or native session row. + * @param {string} unavailableValue Localized fallback for missing details. + * @returns {{device: string, browser: string, browserVersion: string, os: string, hasDeviceType: boolean}} + */ +const getSessionDeviceInfo = (session, unavailableValue) => { + const normalize = (value) => + typeof value === 'string' && value.trim() && value.trim().toLowerCase() !== 'unknown' + ? value.trim() + : ''; + const userAgent = typeof session?.userAgent === 'string' ? session.userAgent.trim() : ''; + if (userAgent && userAgent.toLowerCase() !== 'unknown') { + const parsed = parseUserAgent(userAgent); + const device = normalize(parsed.device); + return { + device: device || unavailableValue, + browser: normalize(parsed.browser) || unavailableValue, + browserVersion: getBrowserVersionFromUserAgent(userAgent) || normalize(session?.browserVersion), + os: normalize(parsed.os) || unavailableValue, + hasDeviceType: Boolean(device), + }; + } + + const deviceName = normalize(session?.deviceName); + const deviceType = normalize(session?.deviceType); + const browserName = normalize(session?.browserName); + const osName = normalize(session?.osName); + + return { + device: deviceName || deviceType || unavailableValue, + browser: browserName || unavailableValue, + browserVersion: normalize(session?.browserVersion), + os: osName || unavailableValue, + hasDeviceType: Boolean(deviceType), }; }; const SessionDetailModal = ({ session, onClose, onSessionTerminated }) => { - const { formatMessage } = useIntl(); + const { formatMessage, locale } = useIntl(); const { get, post } = useFetchClient(); const { toggleNotification } = useNotification(); const [terminating, setTerminating] = useState(false); @@ -137,22 +180,23 @@ const SessionDetailModal = ({ session, onClose, onSessionTerminated }) => { const [geoData, setGeoData] = useState(null); const [geoLoading, setGeoLoading] = useState(false); const t = (id, defaultMessage, values) => formatMessage({ id: getTranslation(id), defaultMessage }, values); + const unavailableValue = t('modal.user.na', 'Unavailable'); useEffect(() => { let cancelled = false; const ipAddress = session?.ipAddress; const storedGeoData = getStoredGeoData(session); - if (!ipAddress) { - setGeoData(null); + if (storedGeoData) { + setGeoData(storedGeoData); setGeoLoading(false); return () => { cancelled = true; }; } - if (storedGeoData) { - setGeoData(storedGeoData); + if (!ipAddress) { + setGeoData(null); setGeoLoading(false); return () => { cancelled = true; @@ -168,18 +212,9 @@ const SessionDetailModal = ({ session, onClose, onSessionTerminated }) => { setGeoData(data.data); } } catch (err) { - console.error('[SessionDetailModal] Error fetching geolocation:', err); + logSafeAdminError('sessionDetails.geolocation', err); if (!cancelled) { - setGeoData({ - country_flag: '', - country: 'Unknown', - city: 'Unknown', - timezone: 'Unknown', - securityScore: 50, - riskLevel: 'Unknown', - isVpn: false, - isProxy: false, - }); + setGeoData(null); } } finally { if (!cancelled) { @@ -197,19 +232,31 @@ const SessionDetailModal = ({ session, onClose, onSessionTerminated }) => { if (!session) return null; - const deviceInfo = parseUserAgent(session.userAgent); - const isOnline = session.isTrulyActive; - - // Use real data if available, otherwise fallback + const deviceInfo = getSessionDeviceInfo(session, unavailableValue); + const sessionStatus = getSessionStatus(session); + const statusVariant = { + active: 'success', + idle: 'warning', + loggedout: 'neutral', + terminated: 'neutral', + }[sessionStatus] || 'neutral'; + const statusLabel = { + active: t('modal.status.online', 'Active'), + idle: t('modal.status.idle', 'Idle'), + loggedout: t('modal.status.loggedOut', 'Logged out'), + terminated: t('modal.status.terminated', 'Terminated'), + }[sessionStatus] || t('modal.status.terminated', 'Terminated'); + + // Do not infer location or security values when the session has no GeoIP data. const securityData = geoData || { country_flag: '', - country: 'Loading...', - city: 'Loading...', - timezone: 'Loading...', - securityScore: 0, - riskLevel: 'Unknown', - isVpn: false, - isProxy: false, + country: unavailableValue, + city: unavailableValue, + timezone: unavailableValue, + securityScore: null, + riskLevel: null, + isVpn: null, + isProxy: null, }; const getDeviceIcon = (deviceType) => { @@ -218,8 +265,13 @@ const SessionDetailModal = ({ session, onClose, onSessionTerminated }) => { return Server; }; - const DeviceIcon = getDeviceIcon(deviceInfo.device); + const DeviceIcon = deviceInfo.hasDeviceType ? getDeviceIcon(deviceInfo.device) : Information; + /** + * Confirms termination and posts the selected session ID to the admin API. + * + * @returns {Promise} + */ const handleTerminate = async () => { if (!confirm(t('modal.confirm.terminate', 'Are you sure you want to terminate this session?'))) { return; @@ -237,7 +289,7 @@ const SessionDetailModal = ({ session, onClose, onSessionTerminated }) => { onSessionTerminated(); onClose(); } catch (err) { - console.error('[SessionDetailModal] Error:', err); + logSafeAdminError('sessionDetails.terminate', err); toggleNotification({ type: 'danger', message: t('notifications.error.terminate', 'Failed to terminate session'), @@ -247,68 +299,61 @@ const SessionDetailModal = ({ session, onClose, onSessionTerminated }) => { } }; - const DetailRow = ({ label, value, icon: Icon, compact }) => ( - - {Icon && ( - - - - )} - - - {label} - - - {value} - - - + const DetailField = ({ label, value }) => ( + + + {label} + + + {value} + + ); + const browserValue = [deviceInfo.browser, deviceInfo.browserVersion].filter(Boolean).join(' ') || unavailableValue; + const idleMinutes = session.minutesSinceActive; + const idleTimeValue = idleMinutes !== null && idleMinutes !== undefined && Number.isFinite(Number(idleMinutes)) + ? t('modal.timeline.minutes', '{minutes} min', { minutes: Number(idleMinutes) }) + : unavailableValue; + return ( - + - - + + - - + + {t('modal.title', 'Session Details')} - + {t('modal.id', 'ID: {id}', { id: session.id })} - - + + {/* Status Badge */} - - - - {isOnline ? t('modal.status.online', 'ONLINE') : t('modal.status.offline', 'OFFLINE')} - + + + {statusLabel} + @@ -319,85 +364,65 @@ const SessionDetailModal = ({ session, onClose, onSessionTerminated }) => { {/* User Information */}
- + {t('modal.section.user', 'User')} - + - - - + + + + +
{/* Device Information */}
- + {t('modal.section.device', 'Device')} - + - - - - + + + + + +
{/* Right Column: Timeline */}
- + {t('modal.section.timeline', 'Timeline')} - - - - - - {session.logoutTime && ( - - )} + + + + + + + {session.logoutTime && ( + + )} +
{/* Geolocation & Security Information */}
- + {t('modal.section.security', 'Location and Security')} - + {geoLoading ? ( @@ -406,93 +431,95 @@ const SessionDetailModal = ({ session, onClose, onSessionTerminated }) => { ) : ( - - - - - - - - - - - - + + + + + + + + )}
{/* User Agent - Collapsible */}
- - + + {t('modal.section.technical', 'Technical Details')} - - + - {showUserAgent && ( - - - {session.userAgent} - - - )} +
- - + + +
diff --git a/admin/src/components/SessionInfoCard.jsx b/admin/src/components/SessionInfoCard.jsx index 73b257c..2d3c9db 100644 --- a/admin/src/components/SessionInfoCard.jsx +++ b/admin/src/components/SessionInfoCard.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { Box, Typography, Flex, Badge } from '@strapi/design-system'; import { useFetchClient } from '@strapi/strapi/admin'; +import { logSafeAdminError } from '../utils/safeAdminLog.mjs'; /** * Session Info Card - shows user's session status and history @@ -33,7 +34,7 @@ const SessionInfoCard = ({ id, model }) => { const { data } = await get(`/magic-sessionmanager/user/${id}/sessions`); setSessions(data.data || []); } catch (err) { - console.error('[SessionInfoCard] Error fetching sessions:', err); + logSafeAdminError('userSessions.cardLoad', err); } finally { setLoading(false); } diff --git a/admin/src/components/SessionInfoPanel.jsx b/admin/src/components/SessionInfoPanel.jsx index b1e5f75..2bc44fc 100644 --- a/admin/src/components/SessionInfoPanel.jsx +++ b/admin/src/components/SessionInfoPanel.jsx @@ -1,54 +1,52 @@ import { useState, useEffect } from 'react'; import { useIntl } from 'react-intl'; import styled from 'styled-components'; -import { Box, Typography, Flex, Badge, Divider } from '@strapi/design-system'; -import { Check, Cross, Monitor, Phone, Server, Clock, User } from '@strapi/icons'; -import { useFetchClient, useNotification } from '@strapi/strapi/admin'; +import { Box, Typography, Flex, Badge, Button, Divider, Alert } from '@strapi/design-system'; +import { Check, Cross, Monitor, Phone, Server, Clock, User, Information } from '@strapi/icons'; +import { useFetchClient, useNotification, useRBAC } from '@strapi/strapi/admin'; import parseUserAgent from '../utils/parseUserAgent'; +import { getSessionStatus } from '../utils/sessionStatus.mjs'; +import { getManageableSessions } from '../utils/sessionFilters.mjs'; import { getTranslation } from '../utils/getTranslation'; +import pluginPermissions from '../permissions'; +import { logSafeAdminError } from '../utils/safeAdminLog.mjs'; +import ReadableText, { paragraphRhythm } from './ReadableText'; // ================ STYLED COMPONENTS ================ const PanelContainer = styled(Box)` width: 100%; + min-width: 0; + + ${paragraphRhythm} `; const StatusCard = styled(Box)` padding: 20px; - border-radius: 12px; - border: 1px solid ${props => props.$isOnline ? 'rgba(34, 197, 94, 0.3)' : 'rgba(128, 128, 128, 0.2)'}; - background: ${props => props.$isOnline - ? 'linear-gradient(135deg, rgba(22, 163, 74, 0.06) 0%, rgba(22, 163, 74, 0.12) 100%)' - : 'linear-gradient(135deg, rgba(128, 128, 128, 0.04) 0%, rgba(128, 128, 128, 0.08) 100%)'}; - transition: all 0.2s ease; + border-radius: 4px; + border: 1px solid ${(p) => p.theme.colors.neutral150}; + background: ${(p) => p.theme.colors.neutral0}; `; const BlockedWarning = styled(Box)` padding: 16px; - border-radius: 10px; - background: linear-gradient(135deg, rgba(220, 38, 38, 0.06) 0%, rgba(220, 38, 38, 0.12) 100%); - border: 1px solid rgba(239, 68, 68, 0.4); + border-radius: 4px; + background: ${(p) => p.theme.colors.danger100}; + border: 1px solid ${(p) => p.theme.colors.danger200}; `; const SessionCard = styled(Box)` padding: 16px; background: ${(p) => p.theme.colors.neutral0}; - border-radius: 10px; - border: 1px solid rgba(128, 128, 128, 0.2); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); - transition: all 0.2s ease; - - &:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - border-color: var(--colors-primary600, #0EA5E9); - } + border-radius: 4px; + border: 1px solid ${(p) => p.theme.colors.neutral150}; `; const EmptyState = styled(Box)` padding: 32px; - background: var(--colors-neutral100); - border-radius: 12px; - border: 2px dashed rgba(128, 128, 128, 0.2); + background: ${(p) => p.theme.colors.neutral0}; + border-radius: 4px; + border: 1px solid ${(p) => p.theme.colors.neutral150}; text-align: center; `; @@ -59,65 +57,9 @@ const EmptyIcon = styled(Box)` display: flex; align-items: center; justify-content: center; - background: rgba(128, 128, 128, 0.2); + background: ${(p) => p.theme.colors.neutral100}; border-radius: 50%; - color: var(--colors-neutral500); -`; - -const SectionLabel = styled(Typography)` - text-transform: uppercase; - letter-spacing: 0.5px; - font-size: 11px !important; - font-weight: 700 !important; - color: var(--colors-neutral600); - margin-bottom: 12px; - display: block; -`; - -const ActionButton = styled.button` - width: 100%; - padding: 12px 16px; - border-radius: 8px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - transition: all 0.2s ease; - - ${props => props.$variant === 'danger' && ` - background: ${(p) => p.theme.colors.neutral0}; - color: var(--colors-danger600, #DC2626); - border: 2px solid var(--colors-danger600, #DC2626); - - &:hover:not(:disabled) { - background: var(--colors-danger600, #DC2626); - color: white; - } - `} - - ${props => props.$variant === 'success' && ` - background: ${(p) => p.theme.colors.neutral0}; - color: var(--colors-success600, #16A34A); - border: 2px solid var(--colors-success600, #16A34A); - - &:hover:not(:disabled) { - background: var(--colors-success600, #16A34A); - color: white; - } - `} - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - svg { - width: 16px; - height: 16px; - } + color: ${(p) => p.theme.colors.neutral500}; `; const IconWrapper = styled(Box)` @@ -126,60 +68,79 @@ const IconWrapper = styled(Box)` display: flex; align-items: center; justify-content: center; - background: var(--colors-neutral100); + background: ${(p) => p.theme.colors.neutral100}; border-radius: 8px; flex-shrink: 0; svg { width: 16px; height: 16px; - color: var(--colors-neutral600); + color: ${(p) => p.theme.colors.neutral600}; } `; +const MutedIcon = styled.div` + display: flex; + color: ${(p) => p.theme.colors.neutral500}; +`; + /** - * Session Info Panel - Styled Components Version - * Clean sidebar panel for Content Manager user edit page + * Shows session status and confirmed session actions for a saved end user. + * New Users & Permissions entries receive a save-first message until Strapi assigns an ID. */ const SessionInfoPanel = ({ documentId, model, document }) => { const { formatMessage } = useIntl(); + const { allowedActions: { canAccess }, isLoading: permissionsLoading } = useRBAC(pluginPermissions.access); const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const [reloadKey, setReloadKey] = useState(0); const [isBlocked, setIsBlocked] = useState(false); const [actionLoading, setActionLoading] = useState(false); const { get, post: postRequest } = useFetchClient(); const { toggleNotification } = useNotification(); const t = (id, defaultMessage, values) => formatMessage({ id: getTranslation(id), defaultMessage }, values); + const unavailableValue = t('common.unavailable', 'Unavailable'); const userId = document?.documentId || documentId; useEffect(() => { - if (model !== 'plugin::users-permissions.user' || !userId) { + if (model !== 'plugin::users-permissions.user' || !userId || permissionsLoading || !canAccess) { setLoading(false); return; } + let cancelled = false; const fetchData = async () => { + setLoading(true); + setLoadError(false); try { const { data } = await get(`/magic-sessionmanager/user/${userId}/sessions`); - const activeSessions = (data.data || []).filter(s => s.isTrulyActive); - setSessions(activeSessions); + if (cancelled) return; + setSessions(getManageableSessions(data.data || [])); setIsBlocked(document?.blocked || false); } catch (err) { - console.error('[SessionInfoPanel] Error:', err); + if (!cancelled) setLoadError(true); + logSafeAdminError('userSessions.load', err); } finally { - setLoading(false); + if (!cancelled) setLoading(false); } }; fetchData(); - }, [userId, model, document, get]); + return () => { + cancelled = true; + }; + }, [userId, model, document, get, reloadKey, canAccess, permissionsLoading]); /** - * Handles terminating all user sessions + * Confirms and terminates all active sessions for the displayed user. + * + * @returns {Promise} */ const handleLogoutAll = async () => { if (!userId) return; + if (!confirm(t('panel.confirm.terminateAll', 'End all active sessions for this user?'))) return; setActionLoading(true); try { @@ -197,17 +158,24 @@ const SessionInfoPanel = ({ documentId, model, document }) => { type: 'warning', message: t('notifications.error.terminateAll', 'Failed to terminate sessions'), }); - console.error('[SessionInfoPanel] Logout all error:', error); + logSafeAdminError('userSessions.terminateAll', error); } finally { setActionLoading(false); } }; /** - * Handles toggling user block status + * Confirms the access change before blocking or unblocking the displayed user. + * Blocking also ends that user's active sessions on the server. + * + * @returns {Promise} */ const handleToggleBlock = async () => { if (!userId) return; + const confirmation = isBlocked + ? t('panel.confirm.unblockUser', 'Unblock this user?') + : t('panel.confirm.blockUser', 'Block this user and end all active sessions?'); + if (!confirm(confirmation)) return; setActionLoading(true); try { @@ -233,7 +201,7 @@ const SessionInfoPanel = ({ documentId, model, document }) => { type: 'warning', message: t('notifications.error.block', 'Failed to update user status'), }); - console.error('[SessionInfoPanel] Toggle block error:', error); + logSafeAdminError('userSessions.toggleBlock', error); } finally { setActionLoading(false); } @@ -245,7 +213,8 @@ const SessionInfoPanel = ({ documentId, model, document }) => { const getDeviceIcon = (deviceType) => { if (deviceType === 'Mobile' || deviceType === 'Tablet') return Phone; if (deviceType === 'Desktop' || deviceType === 'Laptop') return Monitor; - return Server; + if (deviceType === 'API Client') return Server; + return Information; }; // Only show for User content type @@ -253,20 +222,64 @@ const SessionInfoPanel = ({ documentId, model, document }) => { return null; } + if (permissionsLoading || !canAccess) { + return null; + } + + if (!userId) { + return { + title: t('panel.title', 'Session Info'), + content: ( + + + + {t('panel.user.saveBeforeManagingSessions', 'Save this user before managing sessions.')} + + + + ), + }; + } + if (loading) { return { title: t('panel.title', 'Session Info'), content: ( - + {t('panel.loading', 'Loading...')} - + ), }; } - const isOnline = sessions.length > 0; + if (loadError) { + return { + title: t('panel.title', 'Session Info'), + content: ( + + setReloadKey((key) => key + 1)}> + {t('common.retry', 'Retry')} + + )} + > + + {t('panel.error.description', 'Try again. If the problem continues, check permissions for this user.')} + + + + ), + }; + } + + const onlineCount = sessions.filter((session) => getSessionStatus(session) === 'active').length; + const isOnline = onlineCount > 0; return { title: t('panel.title', 'Session Info'), @@ -275,22 +288,21 @@ const SessionInfoPanel = ({ documentId, model, document }) => { {/* Status Card */} - + - - {isOnline ? t('panel.status.active', 'ONLINE') : t('panel.status.offline', 'OFFLINE')} + + {isOnline ? t('panel.status.active', 'Online') : t('panel.status.offline', 'Offline')} - - {sessions.length} {t('panel.sessions.label', 'active session')}{sessions.length !== 1 ? 's' : ''} + {t( + 'panel.sessions.currentCount', + '{count, plural, one {# current session} other {# current sessions}}', + { count: sessions.length } + )} @@ -301,22 +313,43 @@ const SessionInfoPanel = ({ documentId, model, document }) => { {t('panel.blocked.title', 'User is blocked')} - + {t('panel.blocked.description', 'Authentication disabled')} - + )} {/* Active Sessions List */} {sessions.length > 0 ? ( - - {t('panel.sessions.title', 'Active Sessions')} - + + {t('panel.sessions.title', 'Current Sessions')} + {sessions.slice(0, 5).map((session) => { - const deviceInfo = parseUserAgent(session.userAgent); - const DeviceIcon = getDeviceIcon(deviceInfo.device); + const userAgent = typeof session.userAgent === 'string' ? session.userAgent.trim() : ''; + const hasUserAgent = Boolean(userAgent && userAgent.toLowerCase() !== 'unknown'); + const parsedDevice = hasUserAgent ? parseUserAgent(userAgent) : null; + const nativeDeviceType = typeof session.deviceType === 'string' + && session.deviceType.trim().toLowerCase() !== 'unknown' + ? session.deviceType.trim() + : ''; + const nativeDeviceName = typeof session.deviceName === 'string' + && session.deviceName.trim().toLowerCase() !== 'unknown' + ? session.deviceName.trim() + : ''; + const availableValue = (value) => + typeof value === 'string' && value.trim() && value.trim().toLowerCase() !== 'unknown' + ? value.trim() + : ''; + const deviceInfo = { + device: nativeDeviceName || nativeDeviceType || availableValue(parsedDevice?.device) || unavailableValue, + browser: availableValue(session.browserName) || availableValue(parsedDevice?.browser) || unavailableValue, + os: availableValue(session.osName) || availableValue(parsedDevice?.os) || unavailableValue, + }; + const DeviceIcon = getDeviceIcon(nativeDeviceType || parsedDevice?.device); + const sessionStatus = getSessionStatus(session); + const isSessionOnline = sessionStatus === 'active'; return ( @@ -330,8 +363,10 @@ const SessionInfoPanel = ({ documentId, model, document }) => { - - {t('panel.sessions.active', 'Active')} + + {isSessionOnline + ? t('panel.sessions.active', 'Active') + : t('panel.sessions.idle', 'Idle')} @@ -341,14 +376,14 @@ const SessionInfoPanel = ({ documentId, model, document }) => { - + - {session.ipAddress} + {session.ipAddress || unavailableValue} - + {new Date(session.loginTime).toLocaleString('en-US', { month: 'short', @@ -381,16 +416,16 @@ const SessionInfoPanel = ({ documentId, model, document }) => { )} ) : ( - + - - {t('panel.empty.title', 'No active sessions')} + + {t('panel.empty.title', 'No current sessions')} - + {t('panel.empty.description', 'User has not logged in yet')} - + )} @@ -398,32 +433,36 @@ const SessionInfoPanel = ({ documentId, model, document }) => { - + {t('panel.actions.title', 'Actions')} - + - } onClick={handleLogoutAll} + loading={actionLoading} disabled={actionLoading || sessions.length === 0} type="button" > - {t('panel.actions.terminateAll', 'Terminate All Sessions')} - + - : } onClick={handleToggleBlock} + loading={actionLoading} disabled={actionLoading} type="button" > - {isBlocked ? : } {isBlocked ? t('panel.actions.unblockUser', 'Unblock User') : t('panel.actions.blockUser', 'Block User') } - +
diff --git a/admin/src/components/SettingsToggleRow.jsx b/admin/src/components/SettingsToggleRow.jsx new file mode 100644 index 0000000..0f49fd8 --- /dev/null +++ b/admin/src/components/SettingsToggleRow.jsx @@ -0,0 +1,103 @@ +import { Box, Switch, Typography } from '@strapi/design-system'; +import styled from 'styled-components'; +import { ReadableParagraphs } from './ReadableText'; + +const SettingsRowCard = styled(Box)` + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 16px; + width: 100%; + min-width: 0; + padding: 16px; + background: ${(props) => props.theme.colors.neutral0}; + border: 1px solid ${(props) => props.theme.colors.neutral150}; + border-radius: 4px; + + @media (max-width: 519px) { + gap: 8px; + padding: 12px; + } +`; + +const SettingsCopy = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + width: 100%; + min-width: 0; + overflow-wrap: anywhere; +`; + +const SwitchControl = styled.div` + flex: 0 0 auto; + min-width: 0; + padding-top: 2px; +`; + +const SettingsSwitch = styled(Switch)` + &[data-state='unchecked'] { + background-color: ${(props) => props.theme.colors.neutral600}; + } + + &[data-state='checked'] { + background-color: ${(props) => props.theme.colors.primary600}; + } + + &:focus-visible { + outline: 2px solid ${(props) => props.theme.colors.primary600}; + outline-offset: 3px; + } +`; + +/** + * Shows one setting with its explanation beside Strapi's horizontal Switch. + * The row keeps the switch accessible while allowing translated copy to wrap. + * + * @param {{ id: string, title: string, description: string, checked: boolean, onChange: (checked: boolean) => void }} props Setting state, copy, and switch callback. + * @returns {import('react').ReactElement} An accessible responsive setting row. + */ +const SettingsToggleRow = ({ + id, + title, + description, + checked, + onChange, +}) => { + const descriptionId = `${id}-description`; + + return ( + + + + {title} + + + + + + + + + ); +}; + +export default SettingsToggleRow; diff --git a/admin/src/components/StyledButtons.jsx b/admin/src/components/StyledButtons.jsx deleted file mode 100644 index 27c9bcf..0000000 --- a/admin/src/components/StyledButtons.jsx +++ /dev/null @@ -1,439 +0,0 @@ -/** - * Styled Button Components for Magic SessionManager - * Consistent button styling across all pages - */ -import styled from 'styled-components'; -import { Button } from '@strapi/design-system'; - -// ================ PRIMARY GRADIENT BUTTON ================ -export const GradientButton = styled(Button)` - && { - background: linear-gradient(135deg, #0EA5E9 0%, #A855F7 100%); - color: white; - font-weight: 600; - border: none; - padding: 10px 20px; - min-height: 40px; - transition: all 0.2s ease; - - &:hover:not(:disabled) { - background: linear-gradient(135deg, #0284C7 0%, #9333EA 100%); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(14, 165, 233, 0.3); - } - - &:active:not(:disabled) { - transform: translateY(0); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } -`; - -// ================ SECONDARY BUTTON (Gradient Outline) ================ -export const SecondaryButton = styled(Button)` - && { - background: ${(p) => p.theme.colors.neutral0}; - color: var(--colors-secondary600, #7C3AED); - font-weight: 600; - border: 2px solid transparent; - background-image: linear-gradient(${(p) => p.theme.colors.neutral0}, ${(p) => p.theme.colors.neutral0}), linear-gradient(135deg, #0EA5E9 0%, #A855F7 100%); - background-origin: border-box; - background-clip: padding-box, border-box; - padding: 10px 20px; - min-height: 40px; - transition: all 0.2s ease; - - &:hover:not(:disabled) { - background: linear-gradient(135deg, #0EA5E9 0%, #A855F7 100%); - background-clip: padding-box; - color: white; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(168, 85, 247, 0.3); - } - - &:active:not(:disabled) { - transform: translateY(0); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } -`; - -// ================ TERTIARY/GHOST BUTTON ================ -export const TertiaryButton = styled(Button)` - && { - background: transparent; - color: var(--colors-neutral600); - font-weight: 500; - border: 1px solid rgba(128, 128, 128, 0.2); - padding: 10px 20px; - min-height: 40px; - transition: all 0.2s ease; - - &:hover:not(:disabled) { - background: rgba(128, 128, 128, 0.08); - border-color: rgba(128, 128, 128, 0.3); - color: var(--colors-neutral800); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } -`; - -// ================ DANGER BUTTON ================ -export const DangerButton = styled(Button)` - && { - background: rgba(220, 38, 38, 0.12); - color: var(--colors-danger600, #DC2626); - font-weight: 600; - border: 1px solid rgba(239, 68, 68, 0.4); - padding: 10px 20px; - min-height: 40px; - transition: all 0.2s ease; - - &:hover:not(:disabled) { - background: var(--colors-danger600, #DC2626); - color: white; - border-color: var(--colors-danger600, #DC2626); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } -`; - -// ================ SUCCESS BUTTON ================ -export const SuccessButton = styled(Button)` - && { - background: linear-gradient(135deg, #10B981 0%, #059669 100%); - color: white; - font-weight: 600; - border: none; - padding: 10px 20px; - min-height: 40px; - transition: all 0.2s ease; - - &:hover:not(:disabled) { - background: linear-gradient(135deg, #059669 0%, #047857 100%); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } -`; - -// ================ ICON BUTTON (Small, for actions) ================ -export const IconButton = styled(Button)` - && { - background: linear-gradient(135deg, rgba(128, 128, 128, 0.04) 0%, rgba(128, 128, 128, 0.08) 100%); - color: var(--colors-neutral600); - border: 1px solid rgba(128, 128, 128, 0.2); - padding: 8px; - min-width: 38px; - min-height: 38px; - width: 38px; - height: 38px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); - - svg { - width: 18px; - height: 18px; - } - - &:hover:not(:disabled) { - background: linear-gradient(135deg, #0EA5E9 0%, #0284C7 100%); - border-color: var(--colors-primary600, #0EA5E9); - color: white; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(14, 165, 233, 0.25); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } -`; - -// ================ ICON BUTTON DANGER ================ -export const IconButtonDanger = styled(Button)` - && { - background: linear-gradient(135deg, rgba(220, 38, 38, 0.06) 0%, rgba(220, 38, 38, 0.12) 100%); - color: var(--colors-danger600, #EF4444); - border: 1px solid rgba(239, 68, 68, 0.4); - padding: 8px; - min-width: 38px; - min-height: 38px; - width: 38px; - height: 38px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); - - svg { - width: 18px; - height: 18px; - } - - &:hover:not(:disabled) { - background: linear-gradient(135deg, #EF4444 0%, #DC2626 100%); - border-color: var(--colors-danger600, #EF4444); - color: white; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } -`; - -// ================ ICON BUTTON PRIMARY ================ -export const IconButtonPrimary = styled(Button)` - && { - background: linear-gradient(135deg, rgba(2, 132, 199, 0.06) 0%, rgba(2, 132, 199, 0.12) 100%); - color: var(--colors-primary600, #0EA5E9); - border: 1px solid rgba(14, 165, 233, 0.3); - padding: 8px; - min-width: 38px; - min-height: 38px; - width: 38px; - height: 38px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); - - svg { - width: 18px; - height: 18px; - } - - &:hover:not(:disabled) { - background: linear-gradient(135deg, #0EA5E9 0%, #0284C7 100%); - border-color: var(--colors-primary600, #0EA5E9); - color: white; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(14, 165, 233, 0.3); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } -`; - -// ================ ICON BUTTON SUCCESS ================ -export const IconButtonSuccess = styled(Button)` - && { - background: linear-gradient(135deg, rgba(22, 163, 74, 0.06) 0%, rgba(22, 163, 74, 0.12) 100%); - color: var(--colors-success600, #22C55E); - border: 1px solid rgba(34, 197, 94, 0.3); - padding: 8px; - min-width: 38px; - min-height: 38px; - width: 38px; - height: 38px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); - - svg { - width: 18px; - height: 18px; - } - - &:hover:not(:disabled) { - background: linear-gradient(135deg, #22C55E 0%, #16A34A 100%); - border-color: var(--colors-success600, #22C55E); - color: white; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(34, 197, 94, 0.3); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } -`; - -// ================ ICON BUTTON WARNING (for terminate/logout) ================ -export const IconButtonWarning = styled(Button)` - && { - background: linear-gradient(135deg, rgba(234, 179, 8, 0.06) 0%, rgba(234, 179, 8, 0.12) 100%); - color: var(--colors-warning600, #D97706); - border: 1px solid rgba(234, 179, 8, 0.4); - padding: 8px; - min-width: 38px; - min-height: 38px; - width: 38px; - height: 38px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); - - svg { - width: 18px; - height: 18px; - } - - &:hover:not(:disabled) { - background: linear-gradient(135deg, #F59E0B 0%, #D97706 100%); - border-color: var(--colors-warning600, #F59E0B); - color: white; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3); - } - - &:disabled { - opacity: 0.5; - cursor: not-allowed; - background: var(--colors-neutral100); - border-color: rgba(128, 128, 128, 0.2); - color: var(--colors-neutral500); - } - } -`; - -// ================ LARGE CTA BUTTON ================ -export const CTAButton = styled(Button)` - && { - background: linear-gradient(135deg, #0EA5E9 0%, #A855F7 100%); - color: white; - font-weight: 700; - font-size: 1rem; - border: none; - padding: 14px 28px; - min-height: 52px; - border-radius: 12px; - transition: all 0.2s ease; - box-shadow: 0 4px 14px rgba(14, 165, 233, 0.25); - - &:hover:not(:disabled) { - background: linear-gradient(135deg, #0284C7 0%, #9333EA 100%); - transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(14, 165, 233, 0.35); - } - - &:active:not(:disabled) { - transform: translateY(0); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } -`; - -// ================ LINK STYLE BUTTON ================ -export const LinkButton = styled(Button)` - && { - background: transparent; - color: var(--colors-primary600, #0EA5E9); - font-weight: 500; - border: none; - padding: 4px 8px; - min-height: auto; - text-decoration: underline; - transition: all 0.2s ease; - - &:hover:not(:disabled) { - color: var(--colors-primary600, #0284C7); - text-decoration: none; - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } -`; - -// ================ SHOW/HIDE BUTTON ================ -export const ShowHideButton = styled(Button)` - && { - background: linear-gradient(135deg, rgba(128, 128, 128, 0.04) 0%, rgba(128, 128, 128, 0.08) 100%); - color: var(--colors-neutral600); - font-weight: 600; - border: 1px solid rgba(128, 128, 128, 0.2); - padding: 10px 16px; - min-height: 40px; - border-radius: 8px; - transition: all 0.2s ease; - - &:hover:not(:disabled) { - background: linear-gradient(135deg, rgba(128, 128, 128, 0.15) 0%, rgba(128, 128, 128, 0.25) 100%); - color: var(--colors-neutral800); - transform: translateY(-1px); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } -`; - -// ================ COPY BUTTON (for clipboard actions) ================ -export const CopyButton = styled(Button)` - && { - background: linear-gradient(135deg, rgba(2, 132, 199, 0.06) 0%, rgba(2, 132, 199, 0.12) 100%); - color: var(--colors-primary600, #0284C7); - font-weight: 600; - border: 1px solid rgba(14, 165, 233, 0.3); - padding: 10px 16px; - min-height: 40px; - border-radius: 8px; - transition: all 0.2s ease; - - &:hover:not(:disabled) { - background: linear-gradient(135deg, #0EA5E9 0%, #0284C7 100%); - color: white; - border-color: var(--colors-primary600, #0EA5E9); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(14, 165, 233, 0.25); - } - - &:disabled { - opacity: 0.6; - cursor: not-allowed; - } - } -`; diff --git a/admin/src/index.js b/admin/src/index.js index 9201dd6..96bbebd 100644 --- a/admin/src/index.js +++ b/admin/src/index.js @@ -3,6 +3,7 @@ import pluginId from './pluginId'; import Initializer from './components/Initializer'; import PluginIcon from './components/PluginIcon'; import SessionInfoPanel from './components/SessionInfoPanel'; +import { logSafeAdminError } from './utils/safeAdminLog.mjs'; // Manual fallback for prefixPluginTranslations if helper-plugin is not available const prefixPluginTranslations = (data, pluginId) => { @@ -30,12 +31,11 @@ export default { permissions: pluginPermissions, }); - // Settings section - paths should be relative to /settings (no /settings/ prefix) - app.createSettingSection( + // Settings paths stay relative to /settings (never include that prefix). + app.addSettingsLink( { - intlLabel: { id: `${pluginId}.settings.section`, defaultMessage: 'Sessions' }, id: pluginId, - to: pluginId, + intlLabel: { id: `${pluginId}.settings.section`, defaultMessage: 'Session Manager' }, }, [ { @@ -92,6 +92,7 @@ export default { }, id: 'online-users-widget', pluginId: pluginId, + permissions: pluginPermissions, }); console.log(`[${pluginId}] [SUCCESS] Online Users Widget registered`); } @@ -111,7 +112,7 @@ export default { console.warn(`[${pluginId}] Content Manager plugin or APIs not available`); } } catch (error) { - console.error(`[${pluginId}] Error injecting SessionInfoPanel:`, error); + logSafeAdminError('plugin.bootstrap', error); } }, diff --git a/admin/src/pages/ActiveSessions.jsx b/admin/src/pages/ActiveSessions.jsx index 877eeeb..6f2a6d5 100644 --- a/admin/src/pages/ActiveSessions.jsx +++ b/admin/src/pages/ActiveSessions.jsx @@ -1,10 +1,13 @@ import { Box } from '@strapi/design-system'; +import ReadableText from '../components/ReadableText'; const ActiveSessions = () => { return (

Active Sessions

-

This page displays all currently active user sessions in your Strapi instance.

+ + This page displays all currently active user sessions in your Strapi instance. +
); }; diff --git a/admin/src/pages/Analytics.jsx b/admin/src/pages/Analytics.jsx index 27365e3..242b1b3 100644 --- a/admin/src/pages/Analytics.jsx +++ b/admin/src/pages/Analytics.jsx @@ -1,12 +1,16 @@ import { useState, useEffect } from 'react'; +import { useIntl } from 'react-intl'; import { useFetchClient } from '@strapi/strapi/admin'; -import styled, { keyframes, css } from 'styled-components'; +import styled from 'styled-components'; import { Box, Typography, Flex, Loader, Badge, + Alert, + Button, + EmptyStateLayout, } from '@strapi/design-system'; import { ChartBubble, @@ -16,325 +20,139 @@ import { } from '@strapi/icons'; import pluginId from '../pluginId'; import { computeSessionAnalytics } from '../utils/sessionAnalytics.mjs'; +import PluginPageLayout from '../components/PluginPageLayout'; +import ReadableText from '../components/ReadableText'; +import { getTranslation } from '../utils/getTranslation'; +import { logSafeAdminError } from '../utils/safeAdminLog.mjs'; -// ================ THEME ================ -const theme = { - colors: { - primary: { 50: '#F0F9FF', 100: '#E0F2FE', 500: '#0EA5E9', 600: '#0284C7', 700: '#0369A1' }, - secondary: { 50: '#F5F3FF', 100: '#EDE9FE', 500: '#A855F7', 600: '#9333EA' }, - success: { 50: '#DCFCE7', 100: '#DCFCE7', 500: '#22C55E', 600: '#16A34A', 700: '#15803D' }, - warning: { 50: '#FEF3C7', 100: '#FEF3C7', 500: '#F59E0B', 600: '#D97706' }, - danger: { 50: '#FEE2E2', 100: '#FEE2E2', 500: '#EF4444', 600: '#DC2626' }, - neutral: { 0: '#FFFFFF', 50: '#F9FAFB', 100: '#F3F4F6', 200: '#E5E7EB', 600: '#4B5563', 700: '#374151', 800: '#1F2937' } - }, - shadows: { - sm: '0 1px 3px 0 rgba(0, 0, 0, 0.1)', - md: '0 4px 6px -1px rgba(0, 0, 0, 0.1)', - lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1)', - xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1)', - }, - transitions: { - fast: '150ms cubic-bezier(0.4, 0, 0.2, 1)', - normal: '300ms cubic-bezier(0.4, 0, 0.2, 1)', - slow: '500ms cubic-bezier(0.4, 0, 0.2, 1)', - }, - spacing: { xl: '32px', '2xl': '48px' }, - borderRadius: { lg: '12px', xl: '16px' }, -}; - -// ================ ANIMATIONS ================ -const fadeIn = keyframes` - from { opacity: 0; transform: translateY(20px); } - to { opacity: 1; transform: translateY(0); } -`; - -const slideIn = keyframes` - from { opacity: 0; transform: translateX(-20px); } - to { opacity: 1; transform: translateX(0); } -`; - -const shimmer = keyframes` - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } -`; - -const pulse = keyframes` - 0%, 100% { opacity: 1; } - 50% { opacity: 0.8; } -`; - -const float = keyframes` - 0%, 100% { transform: translateY(0px); } - 50% { transform: translateY(-5px); } -`; - -const growBar = keyframes` - from { width: 0; } - to { width: var(--bar-width); } -`; - -// ================ STYLED COMPONENTS ================ +// ================ STRAPI DESIGN SYSTEM LAYOUT ================ const Container = styled(Box)` - ${css`animation: ${fadeIn} 0.6s;`} - min-height: 100vh; - max-width: 1440px; - margin: 0 auto; - padding: ${theme.spacing.xl} 24px 0; -`; - -const Header = styled(Box)` - background: linear-gradient(135deg, ${'var(--colors-primary600, #0284C7)'} 0%, ${'var(--colors-secondary600, #9333EA)'} 100%); - border-radius: ${theme.borderRadius.xl}; - padding: ${theme.spacing.xl} ${theme.spacing['2xl']}; - margin-bottom: ${theme.spacing.xl}; - position: relative; - overflow: hidden; - box-shadow: ${theme.shadows.xl}; - - &::before { - content: ''; - position: absolute; - top: 0; - left: -100%; - width: 200%; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent); - ${css`animation: ${shimmer} 3s infinite;`} - } - - &::after { - content: ''; - position: absolute; - top: 0; - right: 0; - width: 100%; - height: 100%; - background-image: radial-gradient(circle at 20% 80%, transparent 50%, rgba(255, 255, 255, 0.1) 50%); - background-size: 15px 15px; - opacity: 0.3; - } -`; - -const HeaderContent = styled(Flex)` - position: relative; - z-index: 1; -`; - -const Title = styled(Typography)` - color: ${(p) => p.theme.colors.neutral0}; - font-size: 2.25rem; - font-weight: 700; - letter-spacing: -0.025em; - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - display: flex; - align-items: center; - gap: 16px; - margin-bottom: 8px; - - svg { - width: 32px; - height: 32px; - ${css`animation: ${float} 3s ease-in-out infinite;`} - } -`; - -const Subtitle = styled(Typography)` - color: rgba(255, 255, 255, 0.95); - font-size: 1rem; - font-weight: 400; - letter-spacing: 0.01em; + width: 100%; + min-width: 0; `; const StatsGrid = styled.div` display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: 24px; - margin-bottom: 40px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr)); + gap: 12px; + min-width: 0; + margin-bottom: 16px; `; const StatCard = styled(Box)` background: ${(p) => p.theme.colors.neutral0}; - border-radius: ${theme.borderRadius.lg}; - padding: 32px; - position: relative; - overflow: hidden; - transition: all ${theme.transitions.normal}; - ${css`animation: ${fadeIn} ${theme.transitions.slow} backwards;`} - animation-delay: ${props => props.$delay || '0s'}; - box-shadow: ${theme.shadows.sm}; - border: 1px solid ${'rgba(128, 128, 128, 0.2)'}; - text-align: center; + border: 1px solid ${(p) => p.theme.colors.neutral150}; + border-top: 3px solid ${(p) => p.theme.colors.primary600}; + border-radius: 4px; + min-width: 0; + padding: 16px; display: flex; flex-direction: column; - align-items: center; - justify-content: center; - - &:hover { - transform: translateY(-6px); - box-shadow: ${theme.shadows.xl}; - border-color: ${props => props.$borderColor || 'var(--colors-primary600, #0EA5E9)'}; - - .stat-icon { - transform: scale(1.15) rotate(5deg); - } - - .stat-value { - transform: scale(1.08); - color: ${props => props.$accentColor || 'var(--colors-primary600, #0284C7)'}; - } + align-items: flex-start; + gap: 4px; + + && > p { + max-width: none; + margin: 0; + line-height: 1.25; + } + + && > p + p { + margin-block-start: 0; } `; const StatIcon = styled(Box)` - width: 80px; - height: 80px; - border-radius: ${theme.borderRadius.lg}; + width: 36px; + height: 36px; + border-radius: 4px; display: flex; align-items: center; justify-content: center; - background: ${props => props.$bg || 'rgba(2, 132, 199, 0.12)'}; - transition: all ${theme.transitions.normal}; - margin: 0 auto 24px; - box-shadow: ${theme.shadows.sm}; - + background: ${(p) => p.theme.colors.primary100}; + color: ${(p) => p.theme.colors.primary600}; + margin-bottom: 8px; + svg { - width: 40px; - height: 40px; - color: ${props => props.$color || 'var(--colors-primary600, #0284C7)'}; + width: 20px; + height: 20px; } `; -const StatValue = styled(Typography)` - font-size: 3.5rem; - font-weight: 700; - color: ${'var(--colors-neutral800)'}; - line-height: 1; - margin-bottom: 12px; - transition: all ${theme.transitions.normal}; - text-align: center; -`; - -const StatLabel = styled(Typography)` - font-size: 1rem; - color: ${'var(--colors-neutral600)'}; - font-weight: 500; - text-align: center; -`; - const ChartCard = styled(Box)` background: ${(p) => p.theme.colors.neutral0}; - border-radius: ${theme.borderRadius.lg}; - padding: 36px; - box-shadow: ${theme.shadows.md}; - border: 1px solid ${'rgba(128, 128, 128, 0.2)'}; - margin-bottom: 28px; - ${css`animation: ${slideIn} ${theme.transitions.slow};`} - transition: all ${theme.transitions.normal}; - - &:hover { - box-shadow: ${theme.shadows.lg}; - border-color: ${'rgba(2, 132, 199, 0.2)'}; - } -`; - -const ChartTitle = styled(Typography)` - font-size: 1.25rem; - font-weight: 700; - color: ${'var(--colors-neutral800)'}; - margin-bottom: 24px; - display: flex; - align-items: center; - gap: 12px; - - svg { - width: 24px; - height: 24px; - color: ${'var(--colors-primary600, #0284C7)'}; - } + border-radius: 4px; + border: 1px solid ${(p) => p.theme.colors.neutral150}; + min-width: 0; + padding: 20px; `; const BarChart = styled.div` display: flex; flex-direction: column; - gap: 20px; + gap: 12px; + min-width: 0; `; const BarRow = styled.div` display: flex; align-items: center; - gap: 20px; - ${css`animation: ${fadeIn} 0.6s backwards;`} - animation-delay: ${props => props.$delay || '0s'}; -`; - -const BarLabel = styled(Typography)` - min-width: 110px; - font-size: 15px; - font-weight: 600; - color: ${'var(--colors-neutral700)'}; + gap: 12px; + min-width: 0; `; const BarContainer = styled.div` flex: 1; - height: 40px; - background: ${'var(--colors-neutral100)'}; - border-radius: 10px; + min-width: 0; + height: 28px; + background: ${(p) => p.theme.colors.neutral100}; + border-radius: 4px; overflow: hidden; position: relative; - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.06); `; const BarFill = styled.div` height: 100%; - background: linear-gradient(90deg, ${props => props.$color1 || 'var(--colors-primary600, #0EA5E9)'}, ${props => props.$color2 || 'var(--colors-primary600, #0284C7)'}); - border-radius: 10px; - --bar-width: ${props => props.$percentage || 0}%; - ${css`animation: ${growBar} 1s cubic-bezier(0.4, 0, 0.2, 1) forwards;`} - animation-delay: ${props => props.$delay || '0s'}; - display: flex; - align-items: center; - justify-content: flex-end; - padding-right: 16px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + width: ${props => `${Math.max(0, Math.min(100, props.$percentage || 0))}%`}; + background: ${(p) => p.theme.colors.primary600}; + border-radius: 4px; `; -const BarValue = styled(Typography)` - color: white; - font-size: 15px; - font-weight: 700; - text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); -`; - -const LoadingOverlay = styled.div` - display: flex; - flex-direction: column; +const LoadingOverlay = styled(Flex)` + min-height: 240px; align-items: center; justify-content: center; - min-height: 400px; - gap: 24px; - - .loader-icon { - ${css`animation: ${pulse} 2s ease-in-out infinite;`} - } `; const AnalyticsPage = () => { + const { formatMessage } = useIntl(); const { get } = useFetchClient(); + const t = (id, defaultMessage, values) => + formatMessage({ id: getTranslation(id), defaultMessage }, values); const [loading, setLoading] = useState(true); const [analytics, setAnalytics] = useState(null); + const [loadError, setLoadError] = useState(false); useEffect(() => { fetchAnalytics(); }, []); + /** + * Loads the session collection and derives analytics locally. Failures switch + * the page to its retry state without exposing response data in the console. + * + * @returns {Promise} + */ const fetchAnalytics = async () => { setLoading(true); + setLoadError(false); try { const { data } = await get(`/${pluginId}/sessions`); const sessions = data.data || []; setAnalytics(computeSessionAnalytics(sessions)); } catch (err) { - console.error('[Analytics] Error:', err); + setLoadError(true); + logSafeAdminError('analytics.load', err); } finally { setLoading(false); } @@ -343,108 +161,110 @@ const AnalyticsPage = () => { // Loading analytics data if (loading) { return ( - + - - Loading analytics data... + {t('analytics.loading', 'Loading analytics data...')} - + + ); + } + + if (loadError) { + return ( + + {t('common.retry', 'Retry')}} + > + + {t('analytics.error.description', 'Try again. If the problem continues, check session API access and permissions.')} + + + ); } const maxDevices = Math.max(...Object.values(analytics?.devices || {}), 1); const maxBrowsers = Math.max(...Object.values(analytics?.browsers || {}), 1); - const maxOS = Math.max(...Object.values(analytics?.operatingSystems || {}), 1); - const maxLoginHour = Math.max(...(analytics?.loginHours || []), 1); - - const deviceColors = { - 'Desktop': ['var(--colors-primary600, #0EA5E9)', 'var(--colors-primary600, #0284C7)'], - 'Mobile': ['var(--colors-success600, #22C55E)', 'var(--colors-success600, #16A34A)'], - 'Tablet': ['var(--colors-warning600, #F59E0B)', 'var(--colors-warning600, #D97706)'], - }; - - const browserColors = { - 'Chrome': ['var(--colors-success600, #22C55E)', 'var(--colors-success600, #16A34A)'], - 'Firefox': ['var(--colors-warning600, #F59E0B)', 'var(--colors-warning600, #D97706)'], - 'Safari': ['var(--colors-primary600, #0EA5E9)', 'var(--colors-primary600, #0284C7)'], - 'Edge': ['var(--colors-secondary500, #A855F7)', 'var(--colors-secondary600, #9333EA)'], - 'API Client': ['var(--colors-neutral600)', 'var(--colors-neutral700)'], - 'Other': ['var(--colors-neutral500)', 'var(--colors-neutral600)'], - }; + const hasSessions = Number(analytics?.totalSessions) > 0; return ( - - {/* Gradient Header */} -
- - - <ChartBubble /> Session Analytics - - - Comprehensive insights and statistics about user sessions - - -
- + + {/* Overview Stats */} - - + + - {analytics?.totalSessions || 0} - Total Sessions + {analytics?.totalSessions || 0} + {t('analytics.stats.total', 'Total sessions')} - - + + - {analytics?.activeSessions || 0} - Active Now + {analytics?.activeSessions || 0} + {t('analytics.stats.active', 'Online now')} - - + + - {analytics?.todayLogins || 0} - Today's Logins + {analytics?.todayLogins || 0} + {t('analytics.stats.today', 'Logins today')} - - + + - {analytics?.weekLogins || 0} - This Week + {analytics?.weekLogins || 0} + {t('analytics.stats.week', 'Logins this week')} + {hasSessions ? ( + <> {/* Charts Row */} {/* Devices Chart */} - + - + - Device Distribution - + + {t('analytics.devices.title', 'Devices')} + + {analytics?.devices && Object.entries(analytics.devices) .sort(([, a], [, b]) => b - a) - .map(([device, count], idx) => ( - - {device} + .map(([device, count]) => ( + + {device} - {count} + {count} ))} @@ -453,27 +273,26 @@ const AnalyticsPage = () => {
{/* Browsers Chart */} - + - + - Browser Usage - + + {t('analytics.browsers.title', 'Browsers')} + + {analytics?.browsers && Object.entries(analytics.browsers) .sort(([, a], [, b]) => b - a) - .map(([browser, count], idx) => ( - - {browser} + .map(([browser, count]) => ( + + {browser} - {count} + {count} ))} @@ -485,34 +304,34 @@ const AnalyticsPage = () => { {/* Average Session Duration */} - + - Average Session Duration - - - {analytics?.avgSessionDuration || 0} minutes + + {t('analytics.duration.title', 'Average session duration')} + + + + {t('analytics.duration.minutes', '{count} min', { count: analytics?.avgSessionDuration || 0 })} {analytics?.avgSessionDuration > 0 && ( - - - ℹ️ Average time between login and last activity across all sessions. - Lower values indicate more frequent activity, higher values may indicate idle or abandoned sessions. - + + + {t('analytics.duration.description', 'Average time between login and last activity. Higher values can indicate idle or abandoned sessions.')} + )} - + + ) : ( + + ); }; diff --git a/admin/src/pages/License.jsx b/admin/src/pages/License.jsx index fd59f61..f168108 100644 --- a/admin/src/pages/License.jsx +++ b/admin/src/pages/License.jsx @@ -1,4 +1,5 @@ import { useState, useEffect } from 'react'; +import { useIntl } from 'react-intl'; import { Box, Typography, @@ -19,81 +20,32 @@ import { Duplicate, Download, } from '@strapi/icons'; -import styled, { keyframes, css } from 'styled-components'; +import styled from 'styled-components'; import pluginId from '../pluginId'; +import PluginPageLayout from '../components/PluginPageLayout'; +import ReadableText from '../components/ReadableText'; +import { getTranslation } from '../utils/getTranslation'; +import { logSafeAdminError } from '../utils/safeAdminLog.mjs'; +import { maskSensitiveValue } from '../utils/maskSensitiveValue.mjs'; +import { copyText } from '../utils/copyText.mjs'; -// ================ THEME ================ -const theme = { - colors: { - primary: { 600: '#0284C7', 100: '#E0F2FE', 50: '#F0F9FF' }, - success: { 600: '#16A34A', 50: '#DCFCE7' }, - warning: { 50: '#FEF3C7' }, - danger: { 50: '#FEE2E2' }, - neutral: { 0: '#FFFFFF', 100: '#F3F4F6', 200: '#E5E7EB', 600: '#4B5563', 800: '#1F2937' } - }, - shadows: { sm: '0 1px 3px rgba(0,0,0,0.1)' }, - borderRadius: { lg: '12px' } -}; - -// ================ ANIMATIONS ================ -const fadeIn = keyframes` - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } -`; - -const shimmer = keyframes` - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } -`; - -// ================ STYLED COMPONENTS ================ +// ================ LAYOUT ================ const Container = styled(Box)` - ${css`animation: ${fadeIn} 0.5s;`} - max-width: 1400px; - margin: 0 auto; + width: 100%; + min-width: 0; `; -const StickySaveBar = styled(Box)` - position: sticky; - top: 0; - z-index: 10; +const LicenseKeyBanner = styled(Box)` background: ${(p) => p.theme.colors.neutral0}; - border-bottom: 1px solid rgba(128, 128, 128, 0.2); - box-shadow: 0 1px 3px rgba(0,0,0,0.1); + border: 1px solid ${(p) => p.theme.colors.neutral150}; + border-radius: 4px; + min-width: 0; + padding: 20px; + margin-bottom: 16px; `; -const LicenseKeyBanner = styled(Box)` - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - border-radius: ${theme.borderRadius.lg}; - padding: 28px 32px; - color: white; - position: relative; - overflow: hidden; - box-shadow: 0 4px 20px rgba(102, 126, 234, 0.25); - margin-bottom: 24px; - - &::after { - content: ''; - position: absolute; - top: -50%; - right: -50%; - width: 200%; - height: 200%; - background: linear-gradient( - 45deg, - transparent, - rgba(255, 255, 255, 0.08), - transparent - ); - ${css`animation: ${shimmer} 3s infinite;`} - pointer-events: none; - z-index: 0; - } - - & > * { - position: relative; - z-index: 1; - } +const FeaturePanel = styled(Box)` + border: 1px solid ${(p) => p.theme.colors.neutral150}; `; const LoaderContainer = styled(Flex)` @@ -106,42 +58,63 @@ const LoaderContainer = styled(Flex)` // ================ MAIN COMPONENT ================ const LicensePage = () => { + const { formatMessage, locale } = useIntl(); const { get } = useFetchClient(); const { toggleNotification } = useNotification(); + const t = (id, defaultMessage, values) => + formatMessage({ id: getTranslation(id), defaultMessage }, values); const [loading, setLoading] = useState(true); const [licenseData, setLicenseData] = useState(null); + const [showLicenseKey, setShowLicenseKey] = useState(false); const [error, setError] = useState(null); + /** + * Reloads optional activation status from the protected plugin endpoint. + * + * @returns {Promise} + */ const fetchLicenseStatus = async () => { setLoading(true); + setShowLicenseKey(false); setError(null); try { const response = await get(`/${pluginId}/license/status`); setLicenseData(response.data); } catch (err) { - console.error('[magic-sessionmanager/License] Error fetching license:', err); - setError('Failed to load activation information'); + logSafeAdminError('activation.load', err); + setError(t('license.error.load', 'Activation information could not be loaded.')); } finally { setLoading(false); } }; + /** + * Copies the locally stored activation key when one is available. + * + * @returns {Promise} + */ const handleCopyLicenseKey = async () => { try { - await navigator.clipboard.writeText(licenseData?.data?.licenseKey || ''); + await copyText(licenseData?.data?.licenseKey || ''); toggleNotification({ type: 'success', - message: 'Activation key copied to clipboard!', + message: t('license.success.copied', 'Activation key copied to clipboard.'), }); } catch (err) { + logSafeAdminError('activation.copyKey', err); toggleNotification({ type: 'danger', - message: 'Failed to copy activation key', + message: t('license.error.copy', 'Could not copy the activation key.'), }); } }; + /** + * Downloads a text file containing the activation key and holder details. + * + * @returns {void} + */ const handleDownloadLicenseKey = () => { try { const data = licenseData?.data || {}; @@ -178,7 +151,7 @@ Generated: ${new Date().toLocaleString()} const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; - link.download = `session-manager-activation-${licenseKey.substring(0, 8)}.txt`; + link.download = 'session-manager-activation.txt'; document.body.appendChild(link); link.click(); document.body.removeChild(link); @@ -186,12 +159,12 @@ Generated: ${new Date().toLocaleString()} toggleNotification({ type: 'success', - message: 'Activation key downloaded successfully!', + message: t('license.success.downloaded', 'Activation key downloaded.'), }); } catch (err) { toggleNotification({ type: 'danger', - message: 'Failed to download activation key', + message: t('license.error.download', 'Could not download the activation key.'), }); } }; @@ -202,23 +175,42 @@ Generated: ${new Date().toLocaleString()} if (loading) { return ( - + - Loading activation information... + {t('license.loading', 'Loading activation information...')} - + ); } if (error) { return ( - + - - {error} + + {t('common.retry', 'Retry')} + + )} + > + + {error} + - + ); } @@ -232,48 +224,36 @@ Generated: ${new Date().toLocaleString()} const hasKey = licenseData?.hasKey ?? Boolean(data.licenseKey); return ( - - {/* Sticky Header */} - - - - - Optional Activation - - - Store and inspect an optional activation key. All features work without one. - - - - - - - {/* Content */} - + } onClick={fetchLicenseStatus} variant="secondary"> + {t('license.refresh', 'Refresh status')} + + )} + > + + {/* Status Alert */} {!hasKey ? ( - - All Session Manager features are available. An activation key is optional and only used for install tracking or display. + + + {t('license.status.noKey.description', 'All Session Manager features are available. An activation key is optional and only used for install tracking or display.')} + ) : isValid ? ( - - Your optional activation key is stored. All features remain available with or without this key. + + + {t('license.status.stored.description', 'Your optional activation key is stored. All features remain available with or without this key.')} + ) : ( - - The activation server could not verify this key. The plugin still keeps all features available. + + + {t('license.status.failed.description', 'The activation server could not verify this key. The plugin still keeps all features available.')} + )} @@ -281,51 +261,53 @@ Generated: ${new Date().toLocaleString()} {data.licenseKey && ( - - - - Activation Key - - - {data.licenseKey} + + + + {t('license.key.label', 'Activation key')} - + + + {showLicenseKey ? data.licenseKey : maskSensitiveValue(data.licenseKey)} + + + + - - {data.isActive ? 'ACTIVE' : 'INACTIVE'} + + {data.isActive ? t('license.active', 'Active') : t('license.inactive', 'Inactive')} @@ -339,7 +321,7 @@ Generated: ${new Date().toLocaleString()} - Account Information + {t('license.account.title', 'Account information')} @@ -347,20 +329,20 @@ Generated: ${new Date().toLocaleString()} - Email Address + {t('license.account.email', 'Email address')} - {data.email || 'Not provided'} + {data.email || t('license.value.notProvided', 'Not provided')} - Activation Holder + {t('license.account.holder', 'Activation holder')} {data.firstName && data.lastName ? `${data.firstName} ${data.lastName}` - : 'Not specified' + : t('license.value.notSpecified', 'Not specified') } @@ -373,7 +355,7 @@ Generated: ${new Date().toLocaleString()} - Activation Details + {t('license.details.title', 'Activation details')} @@ -381,32 +363,34 @@ Generated: ${new Date().toLocaleString()} - {data.isExpired ? 'Expired On' : 'Expires On'} + {data.isExpired + ? t('license.details.expiredOn', 'Expired on') + : t('license.details.expiresOn', 'Expires on')} {data.expiresAt - ? new Date(data.expiresAt).toLocaleDateString('en-US', { + ? new Date(data.expiresAt).toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric', }) - : 'Never'} + : t('license.value.never', 'Never')} - Device Name + {t('license.details.deviceName', 'Device name')} - {data.deviceName || 'Unknown'} + {data.deviceName || t('license.value.unknown', 'Unknown')} - IP Address + {t('license.details.ipAddress', 'IP address')} - {data.ipAddress || 'Not detected'} + {data.ipAddress || t('license.value.notDetected', 'Not detected')} @@ -418,30 +402,30 @@ Generated: ${new Date().toLocaleString()} - Features & Capabilities + {t('license.features.title', 'Features and capabilities')} - - - All Features Included + + + {t('license.features.included', 'All features included')} - - - IP geolocation tracking with country, city, and timezone - - - Security scoring, VPN/proxy detection, and threat analysis - - - Session analytics, CSV/JSON export, and real-time monitoring - - - Email alerts, Discord/Slack webhooks, and geofencing - + + + {t('license.features.geolocation', 'IP geolocation with country, city, and timezone')} + + + {t('license.features.security', 'Security scoring, VPN and proxy detection, and threat analysis')} + + + {t('license.features.analytics', 'Session analytics, CSV and JSON export, and real-time monitoring')} + + + {t('license.features.alerts', 'Email alerts, Discord and Slack webhooks, and geofencing')} + - + @@ -450,7 +434,7 @@ Generated: ${new Date().toLocaleString()} - System Status + {t('license.system.title', 'System status')} @@ -458,33 +442,33 @@ Generated: ${new Date().toLocaleString()} - Activation Status + {t('license.system.activation', 'Activation status')} - {data.isActive ? 'Active' : 'Inactive'} + {data.isActive ? t('license.active', 'Active') : t('license.inactive', 'Inactive')} - Connection + {t('license.system.connection', 'Connection')} - {data.isOnline ? 'Online' : 'Offline'} + {data.isOnline ? t('license.system.online', 'Online') : t('license.system.offline', 'Offline')} - Last Sync + {t('license.system.lastSync', 'Last sync')} {data.lastPingAt - ? new Date(data.lastPingAt).toLocaleTimeString() - : 'Never'} + ? new Date(data.lastPingAt).toLocaleTimeString(locale) + : t('license.value.never', 'Never')} - Device Limit + {t('license.system.deviceLimit', 'Device limit')} {data.currentDevices || 0} / {data.maxDevices || 1} @@ -497,7 +481,8 @@ Generated: ${new Date().toLocaleString()} - + + ); }; diff --git a/admin/src/pages/Settings.jsx b/admin/src/pages/Settings.jsx index 47a7f9d..bfdc839 100644 --- a/admin/src/pages/Settings.jsx +++ b/admin/src/pages/Settings.jsx @@ -16,156 +16,165 @@ import { Badge, Accordion, Grid, - Toggle, NumberInput, - Field, } from '@strapi/design-system'; import { useFetchClient, useNotification } from '@strapi/strapi/admin'; import { Check, Duplicate, Trash, Mail, Code, Cog, Shield, Clock } from '@strapi/icons'; -import styled, { keyframes, css } from 'styled-components'; +import styled from 'styled-components'; import pluginId from '../pluginId'; import { getTranslation } from '../utils/getTranslation'; import { COUNTRIES, normalizeCountryCode, formatCountry } from '../utils/countries'; +import { areSettingsEqual } from '../utils/settingsEquality.mjs'; +import { logSafeAdminError } from '../utils/safeAdminLog.mjs'; import { - GradientButton, - SecondaryButton, - TertiaryButton, - DangerButton, - ShowHideButton, - CopyButton -} from '../components/StyledButtons'; - -// ================ THEME ================ -const theme = { - colors: { - primary: { 600: '#0284C7', 700: '#075985', 100: '#E0F2FE', 50: '#F0F9FF' }, - success: { 600: '#16A34A', 700: '#15803D', 100: '#DCFCE7', 50: '#F0FDF4' }, - danger: { 600: '#DC2626', 700: '#B91C1C', 100: '#FEE2E2', 50: '#FEF2F2' }, - warning: { 600: '#D97706', 700: '#A16207', 100: '#FEF3C7', 50: '#FFFBEB' }, - neutral: { 0: '#FFFFFF', 50: '#F9FAFB', 100: '#F3F4F6', 200: 'rgba(128, 128, 128, 0.2)', 400: '#9CA3AF', 600: '#4B5563', 700: '#374151', 800: '#1F2937' } - }, - shadows: { sm: '0 1px 3px rgba(0,0,0,0.1)', md: '0 4px 6px rgba(0,0,0,0.1)', xl: '0 20px 25px rgba(0,0,0,0.1)' }, - borderRadius: { md: '8px', lg: '12px', xl: '16px' } -}; + getGeofencingField, + inferGeofencingMode, + selectGeofencingMode, +} from '../utils/geofencingMode.mjs'; +import { copyText } from '../utils/copyText.mjs'; +import { maskSensitiveValue } from '../utils/maskSensitiveValue.mjs'; +import PluginPageLayout from '../components/PluginPageLayout'; +import FormField from '../components/FormField'; +import ReadableText, { ReadableParagraphs } from '../components/ReadableText'; +import SettingsToggleRow from '../components/SettingsToggleRow'; + +// ================ STYLED COMPONENTS ================ +const Container = styled(Box)` + width: 100%; + min-width: 0; + + .settings-grid > * { + min-width: 0; + } + + @media (max-width: 519px) { + .settings-grid { + grid-template-columns: minmax(0, 1fr) !important; + column-gap: 0; + row-gap: 16px; + } -// ================ ANIMATIONS ================ -const fadeIn = keyframes` - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } + .settings-grid > * { + grid-column: 1 / -1 !important; + } + } `; -const shimmer = keyframes` - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } +const SettingsSectionCard = styled(Box)` + width: 100%; + min-width: 0; + border: 1px solid ${(props) => props.theme.colors.neutral150}; + border-radius: 4px; + margin-bottom: 32px; `; -// ================ STYLED COMPONENTS ================ -const Container = styled(Box)` - ${css`animation: ${fadeIn} 0.5s;`} - max-width: 1400px; - margin: 0 auto; +const EncryptionKeyControls = styled(Flex)` + width: 100%; + min-width: 0; + align-items: flex-end; + + @media (max-width: 519px) { + flex-direction: column; + align-items: stretch; + + & > div:first-child { + flex: 0 0 auto !important; + width: 100%; + } + + & > button { + align-self: flex-start; + } + } `; -const StickySaveBar = styled(Box)` - position: sticky; - top: 0; - z-index: 10; - background: ${(p) => p.theme.colors.neutral0}; - border-bottom: 1px solid rgba(128, 128, 128, 0.2); - box-shadow: 0 1px 3px rgba(0,0,0,0.1); +const SettingsFooterCard = styled(Box)` + width: 100%; + min-width: 0; + margin-top: 32px; + border: 1px solid ${(props) => props.theme.colors.neutral150}; + border-radius: 4px; `; -const ToggleCard = styled(Box)` - background: ${props => props.$active - ? 'linear-gradient(135deg, rgba(22, 163, 74, 0.06) 0%, rgba(22, 163, 74, 0.12) 100%)' - : 'linear-gradient(135deg, rgba(128, 128, 128, 0.04) 0%, rgba(128, 128, 128, 0.08) 100%)'}; - border-radius: ${theme.borderRadius.lg}; - padding: 24px; - min-height: 120px; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - border: 2px solid ${props => props.$active ? 'var(--colors-success600, #16A34A)' : 'rgba(128, 128, 128, 0.2)'}; - box-shadow: ${props => props.$active - ? '0 4px 20px rgba(34, 197, 94, 0.15)' - : '0 2px 8px rgba(0, 0, 0, 0.06)'}; - position: relative; - display: flex; - align-items: center; +const PrimaryShieldIcon = styled(Shield)` + color: ${(props) => props.theme.colors.primary600}; + flex-shrink: 0; +`; + +const DangerTrashIcon = styled(Trash)` + color: ${(props) => props.theme.colors.danger600}; + flex-shrink: 0; +`; - &:hover { - box-shadow: ${props => props.$active - ? '0 4px 20px rgba(34, 197, 94, 0.15)' - : '0 2px 8px rgba(0, 0, 0, 0.06)'}; - border-color: ${props => props.$active ? 'var(--colors-success600, #15803D)' : 'rgba(128, 128, 128, 0.3)'}; +const RelaxedModeAlert = styled(Alert)` + [role='status'] > p { + flex: 0 0 100%; + width: 100%; + min-width: 0; } - ${props => props.$active && ` - &::before { - content: 'ACTIVE'; - position: absolute; - top: 12px; - right: 12px; - background: ${'var(--colors-success600, #16A34A)'}; - color: white; - padding: 4px 10px; - border-radius: 6px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.5px; - box-shadow: 0 2px 6px rgba(22, 163, 74, 0.3); + @media (max-width: 519px) { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-rows: auto auto; + align-items: flex-start; + gap: 12px !important; + + & > div:first-child { + grid-column: 1; + grid-row: 1; + } + + & > [role='status'] { + grid-column: 1 / -1; + grid-row: 2; + width: 100%; + min-width: 0; } - `} - - ${props => !props.$active && ` - &::before { - content: 'INACTIVE'; - position: absolute; - top: 12px; - right: 12px; - background: ${'rgba(128, 128, 128, 0.4)'}; - color: white; - padding: 4px 10px; - border-radius: 6px; - font-size: 10px; - font-weight: 700; - letter-spacing: 0.5px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + + & > button { + grid-column: 2; + grid-row: 1; + margin-top: 0; + justify-self: end; } - `} + } `; -const GreenToggle = styled.div` - ${props => props.$isActive && ` - button[role="switch"] { - background-color: var(--colors-success600, #16A34A) !important; - border-color: var(--colors-success600, #16A34A) !important; +const PrimaryCodeIcon = styled(Code)` + color: ${(props) => props.theme.colors.primary600}; + flex-shrink: 0; +`; - &:hover { - background-color: var(--colors-success600, #15803D) !important; - border-color: var(--colors-success600, #15803D) !important; - } +const PrimaryCheckIcon = styled(Check)` + color: ${(props) => props.theme.colors.primary600}; + flex-shrink: 0; +`; - &:focus { - background-color: var(--colors-success600, #16A34A) !important; - border-color: var(--colors-success600, #16A34A) !important; - box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.2) !important; - } - } +const EncryptionKeyValue = styled.code` + color: ${(props) => props.theme.colors.primary600}; +`; - /* Toggle handle */ - button[role="switch"] > span { - background-color: white !important; - } - `} +const AlertTypeCard = styled(Box)` + width: 100%; + min-height: 64px; + display: flex; + align-items: center; + border-radius: 4px; + border: 1px solid ${(p) => p.$selected ? p.theme.colors.primary600 : p.theme.colors.neutral150}; + background: ${(p) => p.$selected ? p.theme.colors.primary100 : p.theme.colors.neutral0}; + transition: border-color 150ms ease-out, background-color 150ms ease-out; +`; - ${props => !props.$isActive && ` - button[role="switch"] { - background-color: rgba(128, 128, 128, 0.2); +const WebhookInputFrame = styled(Box)` + border: 1px solid ${(p) => p.theme.colors.neutral200}; + border-radius: 4px; + overflow: hidden; + background: ${(p) => p.theme.colors.neutral100}; - &:hover { - background-color: rgba(128, 128, 128, 0.2); - } - } - `} + textarea { + color: ${(p) => p.theme.colors.neutral800}; + } `; // Template variable definitions @@ -408,29 +417,26 @@ const GeofencingPanel = ({ }) => { const [pendingCode, setPendingCode] = useState(''); - const mode = (() => { - if (Array.isArray(settings.allowedCountries) && settings.allowedCountries.length > 0) { - return 'allowlist'; - } - if (Array.isArray(settings.blockedCountries) && settings.blockedCountries.length > 0) { - return 'blocklist'; - } - return 'allowlist'; - })(); - - const activeField = mode === 'allowlist' ? 'allowedCountries' : 'blockedCountries'; + const [mode, setMode] = useState(() => inferGeofencingMode(settings)); + const activeField = getGeofencingField(mode); const activeList = Array.isArray(settings[activeField]) ? settings[activeField] : []; - const setMode = (newMode) => { - if (newMode === mode) return; + /** + * Keeps an empty list's selected mode in the form draft and clears the + * opposite list before the next settings save. + * + * @param {'allowlist' | 'blocklist'} newMode The mode selected by the admin. + * @returns {void} + */ + const handleModeChange = (newMode) => { + const selection = selectGeofencingMode(mode, newMode); + if (selection.mode === mode) return; + + setMode(selection.mode); // Mutual exclusivity: switching modes clears the other list so the // server never receives both a non-empty allowlist AND a non-empty // blocklist — that would be a contradictory rule set. - if (newMode === 'allowlist') { - handleChange('blockedCountries', []); - } else { - handleChange('allowedCountries', []); - } + if (selection.clearField) handleChange(selection.clearField, []); }; const addCode = (raw) => { @@ -446,42 +452,27 @@ const GeofencingPanel = ({ }; return ( - + - - - - - {t('settings.geofencing.enable.title', 'Enable Geofencing')} - - - {t( - 'settings.geofencing.enable.description', - 'When enabled, the pre-login guard compares the caller\'s country against the configured list and rejects logins that do not match.' - )} - - - handleChange('enableGeofencing', !settings.enableGeofencing)} - /> - - + handleChange('enableGeofencing', checked)} + /> - - - - {t('settings.geofencing.provider.title', 'GeoIP Provider')} - + + handleChange('geoIpProvider', value)} @@ -499,14 +490,16 @@ const GeofencingPanel = ({ {t('settings.geofencing.provider.disabled', 'Disabled')} - + - - - - {t('settings.geofencing.failureMode.title', 'Lookup Failure Mode')} - + + handleChange('geoLookupFailureMode', value)} @@ -521,21 +514,26 @@ const GeofencingPanel = ({ {t('settings.geofencing.failureMode.block', 'Block')} - + - - - {t('settings.geofencing.databasePath.title', 'Local MMDB Path')} - + handleChange('geoIpDatabasePath', e.target.value)} - placeholder="/var/lib/strapi/GeoLite2-City.mmdb" + placeholder="GeoLite2-City.mmdb" /> - + @@ -560,7 +558,7 @@ const GeofencingPanel = ({ - {t('settings.geofencing.database.path', 'Path')}: {geoIpStatus?.databasePath || settings.geoIpDatabasePath || 'data/GeoLite2-City.mmdb'} + {t('settings.geofencing.database.path', 'Path')}: {geoIpStatus?.databasePath || settings.geoIpDatabasePath || 'data/magic-sessionmanager/geoip/GeoLite2-City.mmdb'} @@ -573,25 +571,38 @@ const GeofencingPanel = ({ )} - - - + + setGeoIpCredentials(prev => ({ ...prev, accountId: e.target.value }))} - placeholder={t('settings.geofencing.maxmind.accountId', 'MaxMind Account ID')} - /> + label={t('settings.geofencing.maxmind.accountId', 'MaxMind Account ID')} + description={t('settings.geofencing.maxmind.accountId.hint', 'Used only to download the local GeoIP database.')} + > + setGeoIpCredentials(prev => ({ ...prev, accountId: e.target.value }))} + /> + - - + setGeoIpCredentials(prev => ({ ...prev, licenseKey: e.target.value }))} - placeholder={geoIpStatus?.hasCredentials - ? t('settings.geofencing.maxmind.licenseStored', 'License key stored') - : t('settings.geofencing.maxmind.licenseKey', 'MaxMind License Key')} - /> + label={t('settings.geofencing.maxmind.licenseKey', 'MaxMind License Key')} + description={t('settings.geofencing.maxmind.licenseKey.hint', 'Stored on the server and kept masked in this form.')} + > + setGeoIpCredentials(prev => ({ ...prev, licenseKey: e.target.value }))} + placeholder={geoIpStatus?.hasCredentials + ? t('settings.geofencing.maxmind.licenseStored', 'License key stored') + : t('settings.geofencing.maxmind.licenseKey', 'MaxMind License Key')} + /> + @@ -639,14 +650,14 @@ const GeofencingPanel = ({ - )} - - + + + - - + ) : null} + > + {/* Content */} - + {/* Accordion Layout */} @@ -1079,18 +1158,20 @@ const SettingsPage = () => { - + {/* Session Timeout */} - + {t('settings.general.timeout.title', 'SESSION TIMEOUT')} - - - - - {t('settings.general.timeout.inactivity', 'Inactivity Timeout')} - + + + handleChange('inactivityTimeout', parseInt(value))} @@ -1102,17 +1183,16 @@ const SettingsPage = () => { {t('settings.general.timeout.1hour', '1 hour (Relaxed)')} {t('settings.general.timeout.2hours', '2 hours (Very Relaxed)')} - - {t('settings.general.timeout.inactivityHint', 'Sessions inactive for more than {minutes} minutes will be marked as offline', { minutes: settings.inactivityTimeout })} - - + - - - - {t('settings.general.rateLimit.title', 'Last Seen Rate Limit')} - + + handleChange('lastSeenRateLimit', parseInt(value))} @@ -1123,24 +1203,23 @@ const SettingsPage = () => { {t('settings.general.rateLimit.2min', '2 minutes')} {t('settings.general.rateLimit.5min', '5 minutes')} - - {t('settings.general.rateLimit.hint', 'Prevents excessive database writes. Updates throttled to once every {seconds} seconds', { seconds: settings.lastSeenRateLimit })} - - + {/* Cleanup & Retention */} - - 🧹 {t('settings.general.cleanup.title', 'AUTO-CLEANUP & RETENTION')} + + {t('settings.general.cleanup.title', 'Auto-cleanup and retention')} - - - - - {t('settings.general.cleanup.interval', 'Cleanup Interval')} - + + + handleChange('cleanupInterval', parseInt(value))} @@ -1150,17 +1229,18 @@ const SettingsPage = () => { {t('settings.general.cleanup.1hour', '1 hour')} {t('settings.general.cleanup.2hours', '2 hours')} - - {t('settings.general.cleanup.intervalHint', 'Inactive sessions are automatically cleaned every {minutes} minutes', { minutes: settings.cleanupInterval })} - - + - - - - {t('settings.general.retention.title', 'Retention Period')} - + + handleChange('retentionDays', parseInt(value))} @@ -1173,20 +1253,19 @@ const SettingsPage = () => { {t('settings.general.retention.1year', '1 year')} {t('settings.general.retention.forever', 'Forever')} - - {settings.retentionDays === -1 - ? t('settings.general.retention.hintNever', 'Old sessions deleted after never') - : t('settings.general.retention.hint', 'Old sessions deleted after {days}', { days: `${settings.retentionDays} days` }) - } - - + - - - - {t('settings.general.grace.title', 'Post-Login Grace Period')} - + + handleChange('sessionCreationGraceMs', parseInt(value))} @@ -1197,53 +1276,47 @@ const SettingsPage = () => { {t('settings.general.grace.10s', '10 seconds')} {t('settings.general.grace.30s', '30 seconds')} - - {t( - 'settings.general.grace.hint', - 'Accepts a freshly-issued JWT without a matching session row for this window, preventing strict-enforcement races right after login.' - )} - - + - - - - {t('settings.general.cleanupMode.title', 'Cleanup Strategy')} - + + handleChange('cleanupUseDbDirect', value === 'db')} > - {t('settings.general.cleanupMode.ds', 'Document Service (Recommended, safe)')} + {t('settings.general.cleanupMode.ds', 'Document Service')} - {t('settings.general.cleanupMode.db', 'Direct SQL UPDATE (large-scale)')} + {t('settings.general.cleanupMode.db', 'Direct SQL')} - - {t( - 'settings.general.cleanupMode.hint', - 'Direct SQL drains the entire idle-session backlog in one statement but bypasses lifecycle hooks. Only switch if you run >50 k active sessions.' - )} - - + - + - + @@ -1272,34 +1345,28 @@ const SettingsPage = () => { - + - + {t('settings.security.options', 'SECURITY OPTIONS')} {/* Encryption Key Generator */} - - + - + {t('settings.security.encryption.description', 'Generate a secure 32-character encryption key for JWT token storage. This key is used to encrypt tokens before saving them to the database.')} - + { title={t('settings.security.encryption.important', 'Important')} style={{ marginTop: 8 }} > - {t('settings.security.encryption.envHint', 'Add this key to your .env file as SESSION_ENCRYPTION_KEY for production.')} + + {t('settings.security.encryption.envHint', 'Add this key to your .env file as SESSION_ENCRYPTION_KEY for production.')} + - - - - - {t('settings.security.encryption.label', 'Generated Encryption Key')} - + + + setEncryptionKey(e.target.value)} placeholder={t('settings.security.encryption.placeholder', "Click 'Generate Key' to create a secure key")} type={showEncryptionKey ? 'text' : 'password'} /> - + - setShowEncryptionKey(!showEncryptionKey)} size="L" + style={{ flexShrink: 0 }} > {showEncryptionKey ? t('settings.security.encryption.hide', 'Hide') : t('settings.security.encryption.show', 'Show')} - - + + - - + - } - onClick={() => { - if (encryptionKey) { - navigator.clipboard.writeText(encryptionKey); - toggleNotification({ - type: 'success', - message: t('notifications.success.keyCopied', 'Encryption key copied to clipboard!') - }); - } - }} + onClick={() => copyValue( + encryptionKey, + t('notifications.success.keyCopied', 'Encryption key copied to clipboard.'), + 'settings.copyEncryptionKey' + )} disabled={!encryptionKey} size="L" > {t('settings.security.encryption.copy', 'Copy to Clipboard')} - + - } - onClick={() => { - if (encryptionKey) { - const envLine = `SESSION_ENCRYPTION_KEY=${encryptionKey}`; - navigator.clipboard.writeText(envLine); - toggleNotification({ - type: 'success', - message: t('notifications.success.envCopied', 'Copied as .env format!') - }); - } - }} + onClick={() => copyValue( + `SESSION_ENCRYPTION_KEY=${encryptionKey}`, + t('notifications.success.envCopied', 'Copied in .env format.'), + 'settings.copyEncryptionKeyEnv' + )} disabled={!encryptionKey} size="L" > {t('settings.security.encryption.copyEnv', 'Copy for .env')} - + {encryptionKey && ( @@ -1390,7 +1457,7 @@ const SettingsPage = () => { padding={4} background="neutral100" style={{ - borderRadius: theme.borderRadius.md, + borderRadius: '4px', border: '1px solid ' + 'rgba(128, 128, 128, 0.2)', fontFamily: 'monospace', fontSize: '12px', @@ -1400,29 +1467,23 @@ const SettingsPage = () => { {t('settings.security.encryption.envLabel', 'Add to .env file:')} - - SESSION_ENCRYPTION_KEY={encryptionKey} - + + SESSION_ENCRYPTION_KEY={showEncryptionKey ? encryptionKey : maskSensitiveValue(encryptionKey)} + )} - + {/* Session Enforcement Policy */} - - - - + + - - {t('settings.security.enforcement.description', 'Controls how aggressively JWT tokens are tied to an active session record. Strict mode rejects any token without a matching session, which is more secure but breaks tokens issued before this plugin was installed.')} - - - + + + - - - - handleChange('strictSessionEnforcement', !settings.strictSessionEnforcement)} - /> - - - - {t('settings.security.enforcement.strict.title', 'Strict Session Enforcement')} - - - {t('settings.security.enforcement.strict.description', 'Reject every authenticated request that does not have a matching session record. Recommended for production.')} - - - - + handleChange('strictSessionEnforcement', checked)} + /> - - - - - {t('settings.security.enforcement.maxAge.title', 'Max Session Age')} - + + + - - - - - handleChange('trustedProxies', !settings.trustedProxies)} - /> - - - - {t('settings.security.enforcement.trustedProxies.title', 'Trust Upstream Proxy')} - - - {t('settings.security.enforcement.trustedProxies.description', 'Only enable when Strapi sits behind a trusted reverse proxy (nginx, Cloudflare). Otherwise clients can spoof X-Forwarded-For.')} - - - - + + handleChange('trustedProxies', checked)} + /> - {!settings.strictSessionEnforcement && ( - setRelaxedWarningDismissed(true)} title={t('settings.security.enforcement.warning.title', 'Running in Relaxed Mode')} > - {t('settings.security.enforcement.warning.body', 'Tokens without a matching session record are allowed through. Manual session termination is still enforced via the token hash, but we strongly recommend enabling strict mode in production.')} - + + )} - + {/* Feature Toggles */} - - - - - - - handleChange('blockSuspiciousSessions', !settings.blockSuspiciousSessions)} - /> - - - - {t('settings.security.blockSuspicious.title', 'Block Suspicious Sessions')} - - - {t('settings.security.blockSuspicious.description', 'Automatically block sessions from VPNs, proxies, or threat IPs')} - - - - - - - - - - - handleChange('enableGeolocation', !settings.enableGeolocation)} - /> - - - - {t('settings.security.geolocation.title', 'IP Geolocation')} - - - {t('settings.security.geolocation.description', 'Fetch location data for each session')} - - - - - - - - - - - handleChange('enableSecurityScoring', !settings.enableSecurityScoring)} - /> - - - - {t('settings.security.scoring.title', 'Security Scoring')} - - - {t('settings.security.scoring.description', 'Calculate security scores and detect threats')} - - - - - - - + + + handleChange('blockSuspiciousSessions', checked)} + /> + + + + handleChange('enableGeolocation', checked)} + /> + + + + handleChange('enableSecurityScoring', checked)} + /> + + {/* Max Failed Logins */} - - - - - 🚫 {t('settings.security.maxFailed.title', 'Max Failed Login Attempts')} - + + + handleChange('maxFailedLogins', val)} + onValueChange={(value) => { + const normalizedValue = value === undefined + ? undefined + : Math.max(0, Math.min(value, 100)); + handleChange('maxFailedLogins', normalizedValue); + if (value !== normalizedValue) { + setMaxFailedLoginInputRevision((revision) => revision + 1); + } + }} min={0} - max={20} + max={100} /> - + {Number(settings.maxFailedLogins) === 0 ? t('settings.security.maxFailed.disabled', 'Login lockout is disabled') : t('settings.security.maxFailed.hint', 'User will be blocked after {count} failed attempts', { count: settings.maxFailedLogins })} - + @@ -1674,7 +1652,7 @@ const SettingsPage = () => { - + { - + {/* Email Alerts Toggle */} - - - 📧 {t('settings.email.alerts.title', 'EMAIL ALERTS')} + + + {t('settings.email.alerts.title', 'Email alerts')} {t('settings.email.alerts.subtitle', 'Send security alerts to users via email')} - + - - - - handleChange('enableEmailAlerts', !settings.enableEmailAlerts)} - /> - - - - {t('settings.email.enable.title', 'Enable Email Alerts')} - - - {t('settings.email.enable.description', 'Send security alerts for suspicious logins, new locations, and VPN/Proxy usage')} - - - - + handleChange('enableEmailAlerts', checked)} + /> @@ -1750,84 +1707,66 @@ const SettingsPage = () => { {/* Alert Type Checkboxes */} {settings.enableEmailAlerts && ( <> - - ⚙️ {t('settings.email.types.title', 'ALERT TYPES')} + + {t('settings.email.types.title', 'Alert types')} - - - + + {t('settings.email.types.description', 'Choose which security events should send an email alert.')} + + + + handleChange('alertOnSuspiciousLogin', !settings.alertOnSuspiciousLogin)} + onCheckedChange={(checked) => handleChange('alertOnSuspiciousLogin', checked === true)} > {t('settings.email.types.suspicious', 'Suspicious Login')} - + - - + + handleChange('alertOnNewLocation', !settings.alertOnNewLocation)} + onCheckedChange={(checked) => handleChange('alertOnNewLocation', checked === true)} > {t('settings.email.types.newLocation', 'New Location')} - + - - + + handleChange('alertOnVpnProxy', !settings.alertOnVpnProxy)} + onCheckedChange={(checked) => handleChange('alertOnVpnProxy', checked === true)} > {t('settings.email.types.vpnProxy', 'VPN/Proxy')} - + {/* Email Templates */} - + {t('settings.email.templates.title', 'EMAIL TEMPLATES')} - + {t('settings.email.templates.subtitle', 'Customize email notification templates with dynamic variables')} - + {/* Template Tabs */} - + {t('settings.email.templates.tab.suspicious', 'Suspicious Login')} {t('settings.email.templates.tab.newLocation', 'New Location')} {t('settings.email.templates.tab.vpnProxy', 'VPN/Proxy')} @@ -1838,29 +1777,33 @@ const SettingsPage = () => { {/* Subject */} - - {t('settings.email.templates.subject', 'Email Subject')} - - { - const newTemplates = { ...settings.emailTemplates }; - newTemplates[templateKey].subject = e.target.value; - handleChange('emailTemplates', newTemplates); - }} - placeholder={t('settings.email.templates.subjectPlaceholder', 'Enter email subject...')} - /> + + { + const newTemplates = { ...settings.emailTemplates }; + newTemplates[templateKey].subject = e.target.value; + handleChange('emailTemplates', newTemplates); + }} + placeholder={t('settings.email.templates.subjectPlaceholder', 'Enter email subject...')} + /> + {/* Available Variables */} - + {t('settings.email.templates.html.badge', 'Main Template')} - { const defaultTemplates = getDefaultTemplates(); @@ -1913,69 +1858,80 @@ const SettingsPage = () => { }} > {t('settings.email.templates.html.loadDefault', 'Load Default')} - + - - {t('settings.email.templates.html.description', 'HTML template for email notifications. Use variables like {{user.email}} for dynamic content.')} - - - - template.html - - -