mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
release-2.9.0 (#1532)
* fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
* Fix/backend optional sharp (#1455)
Fix/backend optional sharp
* feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
* feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
* feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
* feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
* feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
* feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
* feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
* feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
* feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
* fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
* fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
* fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
* fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
* fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
* chore: increment ver
* feat: add category and icon fields to plugin manifest schema
* ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).
Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)
Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)
---
updated-dependencies:
- dependency-name: crowdin/github-action
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: actions/github-script
dependency-version: '9'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:
| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |
Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)
Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)
Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)
Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)
Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)
Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)
Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)
Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)
Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)
Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)
Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)
Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)
Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)
---
updated-dependencies:
- dependency-name: "@codemirror/commands"
dependency-version: 6.11.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
dependency-version: 6.7.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
dependency-version: 6.43.12
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
dependency-version: 10.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
dependency-version: 16.3.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
dependency-version: 14.6.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
dependency-version: 1.15.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
dependency-version: 6.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: cytoscape
dependency-version: 3.34.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
dependency-version: 0.5.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: i18next
dependency-version: 26.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: prettier
dependency-version: 3.9.8
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: react-i18next
dependency-version: 17.0.14
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:
| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |
Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)
Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)
Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)
Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)
Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)
Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)
---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
dependency-version: 3.14.13
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: compression
dependency-version: 1.8.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jose
dependency-version: 6.2.12
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jszip
dependency-version: 3.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: socks
dependency-version: 2.8.10
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: undici
dependency-version: 8.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
* feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
* feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.
The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.
loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
* feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.
Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
* feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.
Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.
fix: stop plugin workers seeing plaintext credentials in error messages
A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:
Authentication failed for root using password <SECRET>
where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.
Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.
Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
* feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.
Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.
fix: correct the nginx plugin-api location regex
The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
* fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
* feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
* feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
* feat: ship host metrics as a first-party in-process plugin
* fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
* feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
* fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
* feat: add a permission-gated backend service registry for plugins
* feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
* feat: add cross-plugin secret references gated by the provider's RBAC permission
* feat: move proxmox discovery/import/sync into a first-party plugin
* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
* feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
* feat: move fleets into a first-party plugin
* feat: move automations into a first-party plugin
* feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
* feat: move network topology into a first-party plugin
* feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
* feat: move workspaces into a first-party plugin
* feat: move web endpoint into a first-party plugin
* feat: collapse plugin runtime to one tier behind a plugin SDK
* feat: make each plugin an npm workspace built by a termix-plugin CLI
* feat: give plugins their own tables, migrations and sync entities
* feat: serve plugin http and websockets from one mount instead of ports
* feat: namespace plugin permissions and keep them valid when a plugin is off
* feat: give plugins their own settings and one settings screen
* feat: load plugin frontends at runtime and remove plugin ids from the shell
* fix: mock the ai status fetch in its activate test
* feat: add auth extension points and one ssh connect pipeline
* feat: rebuild workspaces as the reference plugin
* fix: gate second factors after SSO/LDAP behind an admin setting
* feat: convert network-topology to a full plugin (B1)
* feat: move snippets backend and tables into a plugin (B2)
* feat: point core frontend snippets pickers at the plugin (B2)
* feat: add the snippets plugin (B2)
* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
* feat: finish the tailscale plugin, including SSH auth (B3)
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* chore: update proxmox and host-metrics locale files for B5
* chore: regenerate drizzle migration journals for B5
* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox
* feat: move proxmox node stats and collectors into the proxmox plugin
* feat: drop proxmox host columns, use plugin settings instead
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* feat: convert file manager into a plugin
* feat: convert ssh tunnels into a plugin
* feat: finish the web-endpoint plugin, add ctx.desktop (B8)
* feat: move the terminal fully into the ssh-terminal plugin (B9)
* feat: convert tmux monitoring into a plugin (B10)
* feat: convert serial console into the serial plugin
* feat: move session sharing and collab rooms into a plugin (B12)
* feat: turn session recording into a plugin (B13)
* feat: finish the remote-desktop plugin (B14)
* feat: finish the host-metrics plugin, move host status into core (B16)
* feat: finish the docker plugin (B15)
* feat: finish the automations plugin (B17)
* feat: finish the ai plugin (B18)
* feat: finish the homepage plugin, move service links and canvas out of core (B19)
* feat: convert wake-on-lan into a plugin (B20)
* feat: convert secret-sources into a plugin (B20)
* feat: move totp and passkeys into plugins (C1)
* feat: move sso and ldap logins into plugins (C2)
* feat: move opkssh and warpgate into plugins (C3)
* feat: move step-ca into a plugin (C4)
* feat: move vault ssh signing into a plugin (C5)
* feat: move termix identity into a plugin (C6)
* feat: move acme certificate automation into a plugin (C7)
* fix: finish 2.9.0 plugin moves and get every check green
* chore: lock the plugin boundary and remove 2.9.0 leftovers
* fix: harden the plugin runtime after the 2.9.0 security review
* feat: add plugin packing, signing, pinned trust and tmxplug bundling
* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
* fix: give bundled plugin backends a real require for CJS deps
* feat: improve UI loading time and jitter
* feat: show credential sharing inline in the sidebar like host sharing
* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
* feat: unify alerts into one plugin, channels, rules and inbox
* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
* fix: set blacksmith builder cache-key to stop broken gha cache fallback
* fix: link plugin-sdk workspace before npm ci in docker deps stage
* fix: link every plugin workspace before npm ci in docker deps stage
* fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent
* fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(dashboard): reuse translated host status labels
* docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* docs(api): clarify local and remote version fields
* fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): preserve selection feedback on hover
* test(ui): await dialog focus cleanup before teardown
* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): focus file search with Ctrl or Cmd F
* fix: keep rounded-full circular in production builds
* fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability
* test(css): exercise the configured PostCSS plugin chain
* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* chore: simplify plugin readmes, changelogs and manifest descriptions
* fix: keep plugins loaded after signing in from the login screen
* fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows
* test(file-manager): target the file label in drop tests
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(file-manager): pipeline SFTP download reads (#1485)
* feat: add plugin sdk build to beta release
* fix: make link-store secret check immune to random base64 matches
* fix(ldap): preserve the directory-provided bind DN (#1486)
* fix: compute beta changes across dev branches and move the beta tag
* chore: untrack plugin sdk ARCHITECTURE.md
* chore: remove references to plugin sdk ARCHITECTURE.md
* feat: merge host editor terminal tabs into one owned by ssh-terminal
* feat: add descriptions to plugin host enable toggles
* fix(file-manager): show download progress in file viewers (#1487)
* fix(electron): signal backend readiness over IPC (#1488)
* fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID
* test(credentials): avoid flaky Ed25519 key generation
* fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes
* test(credentials): avoid flaky Ed25519 key generation
* feat(split-screen): redesign split screen with free-form panes and pane picker
* feat(sidebar): add host click behavior setting, default to always opening a new tab
* feat(snippets): redesign panel to match the host list
* fix(session-recording): stop session logs panel from refetching in a loop
* feat(snippets): bring back sharing, target hosts and drag reorder
* feat(ssh-terminal): add toolbar position, start state, display and fade host settings
* feat(ssh-terminal): add host setting to turn off password prompt auto-fill
* fix(ai): stop streams getting cut off and keep tool steps across messages
* feat: improve quick connect system using plugin api
* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
* feat: move terminal settings, look and ssh tools into ssh-terminal plugin
* feat: move snippets, keybinding actions and macros out of core into plugins
* feat: store rdp, vnc and telnet logins in a generic per-protocol table
* feat: type the plugin host record, move plugin strings out of core and add lint checks
* feat: replace core homepage widget registry with generic extension points
* feat: plugins only see their own host settings in the browser
* feat: harden plugin api for separate repos, signed updates and generic external login
* fix: keep plugin css below core utilities and version plugin assets by their css
* fix: load and save plugin host settings in the editor and tighten settings row spacing
* feat: redesign user host defaults system
* fix(remote-desktop): release held keys on focus loss (#1498)
* fix(session-sharing): replace local presentation after takeover (#1500)
* fix(file-manager): refresh disk usage with directory listing (#1503)
* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)
* feat(remote-desktop): expose display zoom controls for RDP (#1514)
* fix(file-manager): restore text selection and copying in Markdown previews (#1518)
* fix(sidebar): keep compact host addresses visible (#1524)
* fix(hosts): preserve plugin settings when reopening the editor (#1499)
* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)
* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)
* fix(session-sharing): show participant presence in member terminals (#1504)
* fix(ai): sync provider configuration and keys across linked devices (#1506)
* feat(ssh-terminal): add a duplicate tab action (#1510)
* feat(file-manager): allow disabling move-to-trash confirmation (#1511)
* feat(file-manager): default to the remote login directory (#1512)
* feat(homepage): synchronize canvas layouts across devices (#1515)
* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)
* feat(tmux-monitor): add a unified multi-host view (#1520)
* fix(electron): allow explicit quit with active connections (#1522)
* feat: reconnect all disconnected terminal tabs (#1523)
* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)
* fix(database): preserve RDP domain during startup backfill (#1497)
* feat(sidebar): add keyboard type-ahead search for hosts (#1516)
* feat(automations): add per-host maintenance mode and recurring schedules (#1521)
* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)
* fix(session-sharing): use the linked server for desktop meetings and links (#1507)
* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)
* fix: type errors and lint failures from merged plugin prs
* feat(installer): make the Windows desktop shortcut optional (#1517)
* fix: ship the plugin sdk and serialport in desktop builds
* fix: prefer ipv4 when resolving ssh host names (#1365)
* fix: readable terminal selection with the light theme (#1319)
* fix: build desktop session share links on the linked server (#1350)
* fix: show meeting guests to members and hide the roster from guests (#1353)
* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)
* fix: show disk usage for the mount being browsed (#1347)
* fix: load the linux tray icon reliably and log tray failures (#1332)
* fix: cover the gnome close path in the desktop quit test (#1332)
* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)
* feat: optional auto reconnect for dropped ssh sessions (#1321)
* feat: tighter compact host rows with inline tags (#1325)
* feat: bring back the alert feed homepage widget
* feat: pick target terminals for snippets again
* feat: share button on connection tabs again
* feat: recent servers list when linking the desktop app
* feat: list the fixed app shortcuts in keyboard shortcuts
* fix: translate hardcoded toasts and drop em dashes from ui text
* fix: translate leftover hardcoded ui text
* fix: toolbar files button opens at the shell directory again
* fix: show the remote desktop note under connection origin again
* chore: openapi docs for host metrics manager routes
* chore: openapi docs for undocumented routes and fix two broken doc blocks
* fix: declare terminal.listSessions use for snippet targets
* revert: drop the alert feed widget, the notification center replaces it
* feat: restore the alert feed homepage widget
* fix: quick connect fills the host address from plugin auth editors like tailscale
* fix: docker container logs use the base background color
* chore: fix dependabot alerts and apply safe npm upgrades
* fix: proxmox stats tab reads its switch from plugin host settings
* fix: count plugin ssh logins toward host status and stop showing reachable as a warning
* fix: hide the built-in source pill in the host editor
* fix: host status shows online when the port answers and only warns on a failed login
* fix: drop the top border on the split layout menu
* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default
* fix: remote desktop auth method labels and notes for vnc and telnet
* fix: add description to the web endpoint enable switch
* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release
* feat: move telemetry out of core into a usage statistics plugin
* fix: recent activity card no longer cuts off host names early
* fix: remove duplicate top border on split screen menu
* fix: redesign session log player controls, fix tooltip overlap and drop dot separators
* fix: file manager and sftp text showing raw i18n keys
* fix: onboarding rerunning for existing users after upgrading
* fix: fleet detail buttons overflowing the sidebar
* fix: session log player scrolling off screen horizontally
* fix: network graph lag when adding hosts and on every render
* fix: maintenance index name, shared host read only view and badge polling
* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag
* chore: update release notes
* fix: remote desktop tabs reconnecting on every tab switch
* fix: build plugin sdk before linting in release workflow
* chore: lint, format, and bump version to 2.9.0
* chore: skip crowdin pre-translate in release for now
* chore: sync Crowdin translations for 2.9.0
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
4405 files changed
+1341026
-289722
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
First release, bundled with Termix 2.9.0.
|
||||
|
||||
Asks for a code from an authenticator app after signing in. Comes with backup codes in case you lose your phone.
|
||||
@@ -0,0 +1,17 @@
|
||||
# TOTP
|
||||
|
||||
Ask for a code from an authenticator app after signing in, with backup codes in case you lose your phone.
|
||||
|
||||
## Features
|
||||
|
||||
- Ask for a code from an authenticator app after signing in.
|
||||
- Backup codes you can download and use if you lose your phone.
|
||||
- Add more than one device.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run build # build into dist/
|
||||
npm run test # run this plugin's tests
|
||||
npm run typecheck # type-check this plugin
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Enter verification code",
|
||||
"verify": "Verify Code",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP Authenticator",
|
||||
"docs": "Docs",
|
||||
"on": "2FA is enabled",
|
||||
"off": "Add extra login security",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Setup TOTP",
|
||||
"setupFailed": "Failed to start TOTP setup",
|
||||
"qrCode": "QR Code",
|
||||
"secretCopied": "Secret copied to clipboard",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Continue to Verify",
|
||||
"verifyTitle": "Verify Code",
|
||||
"verify": "Verify",
|
||||
"enter6Digits": "Enter a 6-digit code",
|
||||
"enabledSuccess": "Two-factor authentication enabled",
|
||||
"invalidCode": "Invalid code, please try again",
|
||||
"disableTitle": "Disable 2FA",
|
||||
"disabledSuccess": "Two-factor authentication disabled",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
"backupTitle": "Backup Codes",
|
||||
"downloadBackup": "Download Backup Codes",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Verifikasie-app",
|
||||
"factor": "Verifikasiekode",
|
||||
"challenge": {
|
||||
"enterCode": "Voer verifikasiekode in",
|
||||
"verify": "Verifieer Kode",
|
||||
"backupCodeHint": "Jy kan ook een van jou rugsteunkodes invoer."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP-verifikasie",
|
||||
"docs": "Dokumente",
|
||||
"on": "2FA is geaktiveer",
|
||||
"off": "Voeg ekstra aanmeldsekuriteit by",
|
||||
"enable": "Aktiveer",
|
||||
"disable": "Deaktiveer",
|
||||
"addDevice": "Voeg toestel by",
|
||||
"addTitle": "Voeg verifikasietoestel by",
|
||||
"addDescription": "Bevestig met 'n kode van jou verifikator of 'n rugsteunkode om 'n ander toestel met dieselfde sleutel in te skryf.",
|
||||
"addScanInstructions": "Skandeer hierdie QR-kode met die bykomende verifikator. Bestaande verifikators sal aanhou werk.",
|
||||
"addSuccess": "Bykomende outentiseerder ingeskryf",
|
||||
"addFailed": "Kon nie verifieerder byvoeg nie",
|
||||
"codeRequired": "Voer 'n TOTP-kode of rugsteunkode in",
|
||||
"codePlaceholder": "TOTP-kode of rugsteunkode",
|
||||
"setupTitle": "Stel TOTP op",
|
||||
"setupFailed": "Kon nie TOTP-opstelling begin nie",
|
||||
"qrCode": "QR-kode",
|
||||
"secretCopied": "Geheim gekopieer na knipbord",
|
||||
"instructions": "Skandeer die QR-kode of voer die geheim in jou verifikasie-app in, en voer dan die 6-syferkode in",
|
||||
"continueVerify": "Gaan voort om te verifieer",
|
||||
"verifyTitle": "Verifieer Kode",
|
||||
"verify": "Verifieer",
|
||||
"enter6Digits": "Voer 'n 6-syferkode in",
|
||||
"enabledSuccess": "Tweefaktor-verifikasie geaktiveer",
|
||||
"invalidCode": "Ongeldige kode, probeer asseblief weer",
|
||||
"disableTitle": "Deaktiveer 2FA",
|
||||
"disabledSuccess": "Tweefaktor-verifikasie gedeaktiveer",
|
||||
"disableFailed": "Kon nie 2FA deaktiveer nie",
|
||||
"backupTitle": "Rugsteunkodes",
|
||||
"downloadBackup": "Laai rugsteunkodes af",
|
||||
"done": "Klaar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "تطبيق المصادقة",
|
||||
"factor": "رمز المصادقة",
|
||||
"challenge": {
|
||||
"enterCode": "أدخل رمز التحقق",
|
||||
"verify": "تحقق من الرمز",
|
||||
"backupCodeHint": "يمكنك أيضًا إدخال أحد رموز النسخ الاحتياطي الخاصة بك."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "مُوثِّق TOTP",
|
||||
"docs": "المستندات",
|
||||
"on": "التحقق بخطوتين ممكّن",
|
||||
"off": "إضافة أمان إضافي لتسجيل الدخول",
|
||||
"enable": "تمكين",
|
||||
"disable": "تعطيل",
|
||||
"addDevice": "إضافة جهاز",
|
||||
"addTitle": "إضافة جهاز المصادقة",
|
||||
"addDescription": "قم بالتأكيد باستخدام رمز من جهاز المصادقة الخاص بك أو رمز احتياطي لتسجيل جهاز آخر بنفس المفتاح.",
|
||||
"addScanInstructions": "امسح رمز الاستجابة السريعة هذا باستخدام أداة المصادقة الإضافية. ستستمر أدوات المصادقة الحالية في العمل.",
|
||||
"addSuccess": "تم تسجيل مصادق إضافي",
|
||||
"addFailed": "فشل إضافة المصادقة",
|
||||
"codeRequired": "أدخل رمز TOTP أو رمز النسخ الاحتياطي",
|
||||
"codePlaceholder": "رمز TOTP أو رمز النسخ الاحتياطي",
|
||||
"setupTitle": "إعداد TOTP",
|
||||
"setupFailed": "فشل بدء إعداد TOTP",
|
||||
"qrCode": "رمز الاستجابة السريعة",
|
||||
"secretCopied": "تم نسخ السر إلى الحافظة",
|
||||
"instructions": "امسح رمز الاستجابة السريعة أو أدخل السر في تطبيق المصادقة الخاص بك، ثم أدخل الرمز المكون من 6 أرقام",
|
||||
"continueVerify": "متابعة للتحقق",
|
||||
"verifyTitle": "تحقق من الرمز",
|
||||
"verify": "تحقق",
|
||||
"enter6Digits": "أدخل رمزًا من 6 أرقام",
|
||||
"enabledSuccess": "تم تمكين المصادقة الثنائية",
|
||||
"invalidCode": "رمز غير صالح، يرجى المحاولة مرة أخرى",
|
||||
"disableTitle": "تعطيل المصادقة الثنائية",
|
||||
"disabledSuccess": "تم تعطيل المصادقة الثنائية",
|
||||
"disableFailed": "فشل تعطيل المصادقة الثنائية",
|
||||
"backupTitle": "رموز النسخ الاحتياطي",
|
||||
"downloadBackup": "تنزيل رموز النسخ الاحتياطي",
|
||||
"done": "تم"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Приложение за удостоверяване",
|
||||
"factor": "Код за удостоверяване",
|
||||
"challenge": {
|
||||
"enterCode": "Въведете код за потвърждение",
|
||||
"verify": "Код за проверка",
|
||||
"backupCodeHint": "Можете също да въведете един от резервните си кодове."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP удостоверител",
|
||||
"docs": "Документи",
|
||||
"on": "2FA е активирана",
|
||||
"off": "Добавете допълнителна сигурност за вход",
|
||||
"enable": "Активиране",
|
||||
"disable": "Деактивиране",
|
||||
"addDevice": "Добавяне на устройство",
|
||||
"addTitle": "Добавяне на устройство за удостоверяване",
|
||||
"addDescription": "Потвърдете с код от вашия удостоверител или резервен код, за да регистрирате друго устройство със същия ключ.",
|
||||
"addScanInstructions": "Сканирайте този QR код с допълнителния автентификатор. Съществуващите автентификатори ще продължат да работят.",
|
||||
"addSuccess": "Допълнителен регистриран удостоверител",
|
||||
"addFailed": "Добавянето на удостоверител не бе успешно",
|
||||
"codeRequired": "Въведете TOTP код или резервен код",
|
||||
"codePlaceholder": "TOTP код или резервен код",
|
||||
"setupTitle": "Настройка на TOTP",
|
||||
"setupFailed": "Неуспешно стартиране на настройката на TOTP",
|
||||
"qrCode": "QR код",
|
||||
"secretCopied": "Тайната е копирана в клипборда",
|
||||
"instructions": "Сканирайте QR кода или въведете тайната в приложението си за удостоверяване, след което въведете 6-цифрения код",
|
||||
"continueVerify": "Продължете с проверката",
|
||||
"verifyTitle": "Код за проверка",
|
||||
"verify": "Проверка",
|
||||
"enter6Digits": "Въведете 6-цифрен код",
|
||||
"enabledSuccess": "Двуфакторно удостоверяване е активирано",
|
||||
"invalidCode": "Невалиден код, моля опитайте отново",
|
||||
"disableTitle": "Деактивиране на 2FA",
|
||||
"disabledSuccess": "Двуфакторното удостоверяване е деактивирано",
|
||||
"disableFailed": "Деактивирането на 2FA не бе успешно",
|
||||
"backupTitle": "Резервни кодове",
|
||||
"downloadBackup": "Изтегляне на резервни кодове",
|
||||
"done": "Готово"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Enter verification code",
|
||||
"verify": "Verify Code",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP Authenticator",
|
||||
"docs": "Docs",
|
||||
"on": "2FA is enabled",
|
||||
"off": "Add extra login security",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Setup TOTP",
|
||||
"setupFailed": "Failed to start TOTP setup",
|
||||
"qrCode": "QR Code",
|
||||
"secretCopied": "Secret copied to clipboard",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Continue to Verify",
|
||||
"verifyTitle": "Verify Code",
|
||||
"verify": "Verify",
|
||||
"enter6Digits": "Enter a 6-digit code",
|
||||
"enabledSuccess": "Two-factor authentication enabled",
|
||||
"invalidCode": "Invalid code, please try again",
|
||||
"disableTitle": "Disable 2FA",
|
||||
"disabledSuccess": "Two-factor authentication disabled",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
"backupTitle": "Backup Codes",
|
||||
"downloadBackup": "Download Backup Codes",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Aplicació d'autenticació",
|
||||
"factor": "Codi d'autenticació",
|
||||
"challenge": {
|
||||
"enterCode": "Introduïu el codi de verificació",
|
||||
"verify": "Verifica el codi",
|
||||
"backupCodeHint": "També podeu introduir un dels vostres codis de còpia de seguretat."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Autenticador TOTP",
|
||||
"docs": "Documents",
|
||||
"on": "2FA està activat",
|
||||
"off": "Afegeix seguretat d'inici de sessió addicional",
|
||||
"enable": "Activa",
|
||||
"disable": "Desactiva",
|
||||
"addDevice": "Afegeix un dispositiu",
|
||||
"addTitle": "Afegeix un dispositiu d'autenticació",
|
||||
"addDescription": "Confirma amb un codi del teu autenticador o un codi de còpia de seguretat per registrar un altre dispositiu amb la mateixa clau.",
|
||||
"addScanInstructions": "Escaneja aquest codi QR amb l'autenticador addicional. Els autenticadors existents continuaran funcionant.",
|
||||
"addSuccess": "Autenticador addicional inscrit",
|
||||
"addFailed": "No s'ha pogut afegir l'autenticador",
|
||||
"codeRequired": "Introduïu un codi TOTP o un codi de còpia de seguretat",
|
||||
"codePlaceholder": "Codi TOTP o codi de còpia de seguretat",
|
||||
"setupTitle": "Configura TOTP",
|
||||
"setupFailed": "No s'ha pogut iniciar la configuració del TOTP",
|
||||
"qrCode": "Codi QR",
|
||||
"secretCopied": "Secret copiat al porta-retalls",
|
||||
"instructions": "Escaneja el codi QR o introdueix el secret a l'aplicació d'autenticació i, a continuació, introdueix el codi de 6 dígits.",
|
||||
"continueVerify": "Continua a verificar",
|
||||
"verifyTitle": "Verifica el codi",
|
||||
"verify": "Verifica",
|
||||
"enter6Digits": "Introduïu un codi de 6 dígits",
|
||||
"enabledSuccess": "Autenticació de dos factors activada",
|
||||
"invalidCode": "Codi no vàlid, si us plau, torna-ho a intentar.",
|
||||
"disableTitle": "Desactiva la 2FA",
|
||||
"disabledSuccess": "Autenticació de dos factors desactivada",
|
||||
"disableFailed": "No s'ha pogut desactivar la 2FA",
|
||||
"backupTitle": "Codis de còpia de seguretat",
|
||||
"downloadBackup": "Baixa els codis de còpia de seguretat",
|
||||
"done": "Fet"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Aplikace pro ověřování",
|
||||
"factor": "Ověřovací kód",
|
||||
"challenge": {
|
||||
"enterCode": "Zadejte ověřovací kód",
|
||||
"verify": "Ověřte kód",
|
||||
"backupCodeHint": "Můžete také zadat jeden ze svých záložních kódů."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Ověřovač TOTP",
|
||||
"docs": "Dokumenty",
|
||||
"on": "2FA je povoleno",
|
||||
"off": "Přidejte další zabezpečení přihlášení",
|
||||
"enable": "Umožnit",
|
||||
"disable": "Zakázat",
|
||||
"addDevice": "Přidat zařízení",
|
||||
"addTitle": "Přidat ověřovací zařízení",
|
||||
"addDescription": "Potvrďte kódem z ověřovacího zařízení nebo záložním kódem pro registraci dalšího zařízení se stejným klíčem.",
|
||||
"addScanInstructions": "Naskenujte tento QR kód pomocí dalšího ověřovače. Stávající ověřovače budou i nadále fungovat.",
|
||||
"addSuccess": "Další ověřovatel zaregistrován",
|
||||
"addFailed": "Přidání ověřovatele se nezdařilo",
|
||||
"codeRequired": "Zadejte kód TOTP nebo záložní kód",
|
||||
"codePlaceholder": "Kód TOTP nebo záložní kód",
|
||||
"setupTitle": "Nastavení TOTP",
|
||||
"setupFailed": "Nepodařilo se spustit nastavení TOTP",
|
||||
"qrCode": "QR kód",
|
||||
"secretCopied": "Tajný kód zkopírován do schránky",
|
||||
"instructions": "Naskenujte QR kód nebo zadejte tajný kód do ověřovací aplikace a poté zadejte 6místný kód.",
|
||||
"continueVerify": "Pokračovat k ověřování",
|
||||
"verifyTitle": "Ověřte kód",
|
||||
"verify": "Ověřit",
|
||||
"enter6Digits": "Zadejte 6místný kód",
|
||||
"enabledSuccess": "Dvoufázové ověřování povoleno",
|
||||
"invalidCode": "Neplatný kód, zkuste to prosím znovu.",
|
||||
"disableTitle": "Zakázat 2FA",
|
||||
"disabledSuccess": "Dvoufázové ověřování zakázáno",
|
||||
"disableFailed": "Nepodařilo se zakázat 2FA",
|
||||
"backupTitle": "Záložní kódy",
|
||||
"downloadBackup": "Stáhnout záložní kódy",
|
||||
"done": "Hotovo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Autentificeringsapp",
|
||||
"factor": "Autentificeringskode",
|
||||
"challenge": {
|
||||
"enterCode": "Indtast bekræftelseskode",
|
||||
"verify": "Bekræft kode",
|
||||
"backupCodeHint": "Du kan også indtaste en af dine backupkoder."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP-godkendelse",
|
||||
"docs": "Dokumenter",
|
||||
"on": "2FA er aktiveret",
|
||||
"off": "Tilføj ekstra loginsikkerhed",
|
||||
"enable": "Aktivér",
|
||||
"disable": "Deaktiver",
|
||||
"addDevice": "Tilføj enhed",
|
||||
"addTitle": "Tilføj godkendelsesenhed",
|
||||
"addDescription": "Bekræft med en kode fra din autentificeringsværktøj eller en backupkode for at tilmelde en anden enhed med den samme nøgle.",
|
||||
"addScanInstructions": "Scan denne QR-kode med den ekstra godkendelsesfunktion. Eksisterende godkendelsesfunktioner vil fortsat virke.",
|
||||
"addSuccess": "Yderligere godkendelsesfunktion tilmeldt",
|
||||
"addFailed": "Kunne ikke tilføje godkendelse",
|
||||
"codeRequired": "Indtast en TOTP-kode eller backupkode",
|
||||
"codePlaceholder": "TOTP-kode eller backupkode",
|
||||
"setupTitle": "Opsæt TOTP",
|
||||
"setupFailed": "Kunne ikke starte TOTP-opsætningen",
|
||||
"qrCode": "QR-kode",
|
||||
"secretCopied": "Hemmelighed kopieret til udklipsholder",
|
||||
"instructions": "Scan QR-koden, eller indtast hemmeligheden i din autentificeringsapp, og indtast derefter den 6-cifrede kode",
|
||||
"continueVerify": "Fortsæt med at bekræfte",
|
||||
"verifyTitle": "Bekræft kode",
|
||||
"verify": "Verificere",
|
||||
"enter6Digits": "Indtast en 6-cifret kode",
|
||||
"enabledSuccess": "Tofaktorgodkendelse aktiveret",
|
||||
"invalidCode": "Ugyldig kode, prøv igen",
|
||||
"disableTitle": "Deaktiver 2FA",
|
||||
"disabledSuccess": "Tofaktorgodkendelse deaktiveret",
|
||||
"disableFailed": "Kunne ikke deaktivere 2FA",
|
||||
"backupTitle": "Backupkoder",
|
||||
"downloadBackup": "Download backupkoder",
|
||||
"done": "Færdig"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authentifizierungs-App",
|
||||
"factor": "Authentifizierungscode",
|
||||
"challenge": {
|
||||
"enterCode": "Bestätigungscode eingeben",
|
||||
"verify": "Code verifizieren",
|
||||
"backupCodeHint": "Sie können auch einen Ihrer Backup-Codes eingeben."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP-Authentifikator",
|
||||
"docs": "Dokumentation",
|
||||
"on": "2FA ist aktiviert",
|
||||
"off": "Zusätzliche Anmeldesicherheit hinzufügen",
|
||||
"enable": "Aktivieren",
|
||||
"disable": "Deaktivieren",
|
||||
"addDevice": "Gerät hinzufügen",
|
||||
"addTitle": "Authentifizierungsgerät hinzufügen",
|
||||
"addDescription": "Bestätigen Sie mit einem Code Ihres Authentifizierungsgeräts oder einem Ersatzcode, um ein anderes Gerät mit demselben Schlüssel zu registrieren.",
|
||||
"addScanInstructions": "Scannen Sie diesen QR-Code mit dem zusätzlichen Authentifikator. Bestehende Authentifikatoren funktionieren weiterhin.",
|
||||
"addSuccess": "Zusätzlicher Authentifizierungsalgorithmus registriert",
|
||||
"addFailed": "Authentifikator konnte nicht hinzugefügt werden",
|
||||
"codeRequired": "Geben Sie einen TOTP-Code oder einen Backup-Code ein.",
|
||||
"codePlaceholder": "TOTP-Code oder Backup-Code",
|
||||
"setupTitle": "TOTP einrichten",
|
||||
"setupFailed": "Fehler beim Starten der TOTP-Einrichtung",
|
||||
"qrCode": "QR-Code",
|
||||
"secretCopied": "Secret in die Zwischenablage kopiert",
|
||||
"instructions": "Scannen Sie den QR-Code oder geben Sie das Geheimnis in Ihrer Authentifizierungs-App ein und geben Sie anschließend den 6-stelligen Code ein.",
|
||||
"continueVerify": "Weiter zur Verifizierung",
|
||||
"verifyTitle": "Code verifizieren",
|
||||
"verify": "Verifizieren",
|
||||
"enter6Digits": "Geben Sie einen 6-stelligen Code ein",
|
||||
"enabledSuccess": "Zwei-Faktor-Authentifizierung aktiviert",
|
||||
"invalidCode": "Ungültiger Code, bitte versuchen Sie es erneut",
|
||||
"disableTitle": "2FA deaktivieren",
|
||||
"disabledSuccess": "Zwei-Faktor-Authentifizierung deaktiviert",
|
||||
"disableFailed": "Fehler beim Deaktivieren der 2FA",
|
||||
"backupTitle": "Backup-Codes",
|
||||
"downloadBackup": "Backup-Codes herunterladen",
|
||||
"done": "Fertig"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Εφαρμογή ελέγχου ταυτότητας",
|
||||
"factor": "Κωδικός ελέγχου ταυτότητας",
|
||||
"challenge": {
|
||||
"enterCode": "Εισαγάγετε τον κωδικό επαλήθευσης",
|
||||
"verify": "Επαλήθευση Κωδικού",
|
||||
"backupCodeHint": "Μπορείτε επίσης να εισαγάγετε έναν από τους εφεδρικούς κωδικούς σας."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Επαληθευτής TOTP",
|
||||
"docs": "Έγγραφα",
|
||||
"on": "Το 2FA είναι ενεργοποιημένο",
|
||||
"off": "Προσθέστε επιπλέον ασφάλεια σύνδεσης",
|
||||
"enable": "Καθιστώ ικανό",
|
||||
"disable": "Καθιστώ ανίκανο",
|
||||
"addDevice": "Προσθήκη συσκευής",
|
||||
"addTitle": "Προσθήκη συσκευής ελέγχου ταυτότητας",
|
||||
"addDescription": "Επιβεβαιώστε με έναν κωδικό από τον υπεύθυνο ελέγχου ταυτότητας ή έναν εφεδρικό κωδικό για να εγγράψετε μια άλλη συσκευή με το ίδιο κλειδί.",
|
||||
"addScanInstructions": "Σαρώστε αυτόν τον κωδικό QR με τον πρόσθετο έλεγχο ταυτότητας. Οι υπάρχοντες έλεγχοι ταυτότητας θα συνεχίσουν να λειτουργούν.",
|
||||
"addSuccess": "Εγγράφηκε επιπλέον υπεύθυνος ελέγχου ταυτότητας",
|
||||
"addFailed": "Αποτυχία προσθήκης ελέγχου ταυτότητας",
|
||||
"codeRequired": "Εισαγάγετε έναν κωδικό TOTP ή έναν εφεδρικό κωδικό",
|
||||
"codePlaceholder": "Κωδικός TOTP ή εφεδρικός κωδικός",
|
||||
"setupTitle": "Ρύθμιση TOTP",
|
||||
"setupFailed": "Αποτυχία έναρξης ρύθμισης TOTP",
|
||||
"qrCode": "Κωδικός QR",
|
||||
"secretCopied": "Το μυστικό αντιγράφηκε στο πρόχειρο",
|
||||
"instructions": "Σαρώστε τον κωδικό QR ή εισαγάγετε το μυστικό κωδικό στην εφαρμογή ελέγχου ταυτότητας και, στη συνέχεια, εισαγάγετε τον 6ψήφιο κωδικό",
|
||||
"continueVerify": "Συνέχεια για επαλήθευση",
|
||||
"verifyTitle": "Επαλήθευση Κωδικού",
|
||||
"verify": "Επαληθεύω",
|
||||
"enter6Digits": "Εισαγάγετε έναν 6ψήφιο κωδικό",
|
||||
"enabledSuccess": "Ενεργοποιήθηκε ο έλεγχος ταυτότητας δύο παραγόντων",
|
||||
"invalidCode": "Μη έγκυρος κωδικός, δοκιμάστε ξανά",
|
||||
"disableTitle": "Απενεργοποίηση 2FA",
|
||||
"disabledSuccess": "Ο έλεγχος ταυτότητας δύο παραγόντων απενεργοποιήθηκε",
|
||||
"disableFailed": "Αποτυχία απενεργοποίησης 2FA",
|
||||
"backupTitle": "Κωδικοί αντιγράφων ασφαλείας",
|
||||
"downloadBackup": "Λήψη αντιγράφων ασφαλείας κωδικών",
|
||||
"done": "Γινώμενος"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Aplicación de autenticación",
|
||||
"factor": "Código de autenticación",
|
||||
"challenge": {
|
||||
"enterCode": "Introduzca el código de verificación",
|
||||
"verify": "Verificar código",
|
||||
"backupCodeHint": "También puedes introducir uno de tus códigos de respaldo."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Autenticador TOTP",
|
||||
"docs": "Documentación",
|
||||
"on": "2FA activada",
|
||||
"off": "Añadir seguridad adicional al inicio de sesión",
|
||||
"enable": "Activar",
|
||||
"disable": "Desactivar",
|
||||
"addDevice": "Agregar dispositivo",
|
||||
"addTitle": "Agregar dispositivo de autenticación",
|
||||
"addDescription": "Confirme con un código de su autenticador o con un código de respaldo para registrar otro dispositivo con la misma clave.",
|
||||
"addScanInstructions": "Escanee este código QR con el autenticador adicional. Los autenticadores existentes seguirán funcionando.",
|
||||
"addSuccess": "Autenticador adicional registrado",
|
||||
"addFailed": "No se pudo agregar el autenticador.",
|
||||
"codeRequired": "Introduzca un código TOTP o un código de respaldo.",
|
||||
"codePlaceholder": "Código TOTP o código de respaldo",
|
||||
"setupTitle": "Configurar TOTP",
|
||||
"setupFailed": "No se pudo iniciar la configuración de TOTP",
|
||||
"qrCode": "Código QR",
|
||||
"secretCopied": "Secreto copiado al portapapeles",
|
||||
"instructions": "Escanea el código QR o introduce la clave secreta en tu aplicación de autenticación, y luego introduce el código de 6 dígitos.",
|
||||
"continueVerify": "Continuar para verificar",
|
||||
"verifyTitle": "Verificar código",
|
||||
"verify": "Verificar",
|
||||
"enter6Digits": "Introduce un código de 6 dígitos",
|
||||
"enabledSuccess": "Autenticación de dos factores activada",
|
||||
"invalidCode": "Código no válido, inténtalo de nuevo",
|
||||
"disableTitle": "Desactivar 2FA",
|
||||
"disabledSuccess": "Autenticación de dos factores desactivada",
|
||||
"disableFailed": "No se pudo desactivar 2FA",
|
||||
"backupTitle": "Códigos de respaldo",
|
||||
"downloadBackup": "Descargar códigos de respaldo",
|
||||
"done": "Listo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator-sovellus",
|
||||
"factor": "Todennuskoodi",
|
||||
"challenge": {
|
||||
"enterCode": "Syötä vahvistuskoodi",
|
||||
"verify": "Vahvista koodi",
|
||||
"backupCodeHint": "Voit myös antaa jonkin varakoodeistasi."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP-todentaja",
|
||||
"docs": "Dokumentit",
|
||||
"on": "2FA on käytössä",
|
||||
"off": "Lisää kirjautumisen turvallisuutta",
|
||||
"enable": "Ota käyttöön",
|
||||
"disable": "Poista käytöstä",
|
||||
"addDevice": "Lisää laite",
|
||||
"addTitle": "Lisää todennuslaite",
|
||||
"addDescription": "Vahvista todentajasi koodilla tai varakoodilla rekisteröidäksesi toisen laitteen samalla avaimella.",
|
||||
"addScanInstructions": "Skannaa tämä QR-koodi lisätodentajalla. Nykyiset todentajat toimivat edelleen.",
|
||||
"addSuccess": "Lisätodentaja rekisteröity",
|
||||
"addFailed": "Todentajan lisääminen epäonnistui",
|
||||
"codeRequired": "Anna TOTP-koodi tai varakoodi",
|
||||
"codePlaceholder": "TOTP-koodi tai varakoodi",
|
||||
"setupTitle": "TOTP:n määrittäminen",
|
||||
"setupFailed": "TOTP-asennuksen aloittaminen epäonnistui",
|
||||
"qrCode": "QR-koodi",
|
||||
"secretCopied": "Salaisuus kopioitu leikepöydälle",
|
||||
"instructions": "Skannaa QR-koodi tai syötä salaisuus todennussovellukseesi ja syötä sitten 6-numeroinen koodi.",
|
||||
"continueVerify": "Jatka vahvistamiseen",
|
||||
"verifyTitle": "Vahvista koodi",
|
||||
"verify": "Vahvista",
|
||||
"enter6Digits": "Syötä 6-numeroinen koodi",
|
||||
"enabledSuccess": "Kaksivaiheinen todennus käytössä",
|
||||
"invalidCode": "Virheellinen koodi, yritä uudelleen",
|
||||
"disableTitle": "Poista 2FA käytöstä",
|
||||
"disabledSuccess": "Kaksivaiheinen todennus poistettu käytöstä",
|
||||
"disableFailed": "2FA:n poistaminen käytöstä epäonnistui",
|
||||
"backupTitle": "Varakoodit",
|
||||
"downloadBackup": "Lataa varakoodit",
|
||||
"done": "Tehty"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Application d'authentification",
|
||||
"factor": "Code d'authentification",
|
||||
"challenge": {
|
||||
"enterCode": "Saisissez le code de vérification",
|
||||
"verify": "Vérifier le code",
|
||||
"backupCodeHint": "Vous pouvez également saisir l'un de vos codes de secours."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Authentificateur TOTP",
|
||||
"docs": "Documentation",
|
||||
"on": "2FA activée",
|
||||
"off": "Ajouter une sécurité de connexion supplémentaire",
|
||||
"enable": "Activer",
|
||||
"disable": "Désactiver",
|
||||
"addDevice": "Ajouter un appareil",
|
||||
"addTitle": "Ajouter un dispositif d'authentification",
|
||||
"addDescription": "Confirmez avec un code provenant de votre authentificateur ou un code de secours pour enregistrer un autre appareil avec la même clé.",
|
||||
"addScanInstructions": "Scannez ce code QR avec l'authentificateur supplémentaire. Les authentificateurs existants continueront de fonctionner.",
|
||||
"addSuccess": "Authentificateur supplémentaire enregistré",
|
||||
"addFailed": "Échec de l'ajout de l'authentificateur",
|
||||
"codeRequired": "Saisissez un code TOTP ou un code de secours",
|
||||
"codePlaceholder": "Code TOTP ou code de secours",
|
||||
"setupTitle": "Configurer TOTP",
|
||||
"setupFailed": "Échec du démarrage de la configuration TOTP",
|
||||
"qrCode": "Code QR",
|
||||
"secretCopied": "Secret copié dans le presse-papiers",
|
||||
"instructions": "Scannez le code QR ou saisissez le code secret dans votre application d'authentification, puis saisissez le code à 6 chiffres.",
|
||||
"continueVerify": "Poursuivre la vérification",
|
||||
"verifyTitle": "Vérifier le code",
|
||||
"verify": "Vérifier",
|
||||
"enter6Digits": "Saisissez un code à 6 chiffres",
|
||||
"enabledSuccess": "Authentification à deux facteurs activée",
|
||||
"invalidCode": "Code invalide, veuillez réessayer",
|
||||
"disableTitle": "Désactiver l’A2F",
|
||||
"disabledSuccess": "Authentification à deux facteurs désactivée",
|
||||
"disableFailed": "Échec de la désactivation de la 2FA",
|
||||
"backupTitle": "Codes de sauvegarde",
|
||||
"downloadBackup": "Télécharger les codes de sauvegarde",
|
||||
"done": "Terminé"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "אפליקציית אימות",
|
||||
"factor": "קוד אימות",
|
||||
"challenge": {
|
||||
"enterCode": "הזן קוד אימות",
|
||||
"verify": "אימות קוד",
|
||||
"backupCodeHint": "ניתן גם להזין אחד מקודי הגיבוי שלך."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "מאמת TOTP",
|
||||
"docs": "מסמכים",
|
||||
"on": "2FA מופעל",
|
||||
"off": "הוסף אבטחת התחברות נוספת",
|
||||
"enable": "לְאַפשֵׁר",
|
||||
"disable": "השבת",
|
||||
"addDevice": "הוסף מכשיר",
|
||||
"addTitle": "הוסף מכשיר אימות",
|
||||
"addDescription": "אשר באמצעות קוד מהמאמת שלך או קוד גיבוי כדי לרשום מכשיר אחר עם אותו מפתח.",
|
||||
"addScanInstructions": "סרוק את קוד ה-QR הזה עם המאמת הנוסף. המאמתים הקיימים ימשיכו לפעול.",
|
||||
"addSuccess": "מאמת נוסף נרשם",
|
||||
"addFailed": "נכשלה הוספת המאמת",
|
||||
"codeRequired": "הזן קוד TOTP או קוד גיבוי",
|
||||
"codePlaceholder": "קוד TOTP או קוד גיבוי",
|
||||
"setupTitle": "הגדר TOTP",
|
||||
"setupFailed": "נכשל בהתחלת הגדרת TOTP",
|
||||
"qrCode": "קוד QR",
|
||||
"secretCopied": "סוד הועתק ללוח",
|
||||
"instructions": "סרוק את קוד ה-QR או הזן את הסוד באפליקציית האימות שלך, לאחר מכן הזן את הקוד בן 6 הספרות",
|
||||
"continueVerify": "המשך לאימות",
|
||||
"verifyTitle": "אימות קוד",
|
||||
"verify": "לְאַמֵת",
|
||||
"enter6Digits": "הזן קוד בן 6 ספרות",
|
||||
"enabledSuccess": "אימות דו-שלבי מופעל",
|
||||
"invalidCode": "קוד לא תקין, אנא נסה שוב",
|
||||
"disableTitle": "השבת 2FA",
|
||||
"disabledSuccess": "אימות דו-שלבי מושבת",
|
||||
"disableFailed": "נכשל בהשבתת 2FA",
|
||||
"backupTitle": "קודי גיבוי",
|
||||
"downloadBackup": "הורד קודי גיבוי",
|
||||
"done": "נַעֲשָׂה"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "सत्यापन कोड दर्ज करें",
|
||||
"verify": "कोड सत्यापित करें",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP प्रमाणक",
|
||||
"docs": "दस्तावेज़",
|
||||
"on": "2FA सक्षम है",
|
||||
"off": "अतिरिक्त लॉगिन सुरक्षा जोड़ें",
|
||||
"enable": "सक्षम करें",
|
||||
"disable": "अक्षम करें",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "TOTP सेटअप करें",
|
||||
"setupFailed": "TOTP सेटअप शुरू करने में विफल",
|
||||
"qrCode": "QR कोड",
|
||||
"secretCopied": "सीक्रेट क्लिपबोर्ड पर कॉपी किया गया",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "सत्यापन जारी रखें",
|
||||
"verifyTitle": "कोड सत्यापित करें",
|
||||
"verify": "सत्यापित करें",
|
||||
"enter6Digits": "6-अंकीय कोड दर्ज करें",
|
||||
"enabledSuccess": "दो-कारक प्रमाणीकरण सक्षम किया गया",
|
||||
"invalidCode": "अमान्य कोड, कृपया पुनः प्रयास करें",
|
||||
"disableTitle": "2FA अक्षम करें",
|
||||
"disabledSuccess": "दो-कारक प्रमाणीकरण अक्षम किया गया",
|
||||
"disableFailed": "2FA अक्षम करने में विफल",
|
||||
"backupTitle": "बैकअप कोड",
|
||||
"downloadBackup": "बैकअप कोड डाउनलोड करें",
|
||||
"done": "पूर्ण"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Hitelesítő alkalmazás",
|
||||
"factor": "Hitelesítő kód",
|
||||
"challenge": {
|
||||
"enterCode": "Írja be az ellenőrző kódot",
|
||||
"verify": "Kód ellenőrzése",
|
||||
"backupCodeHint": "Megadhatja az egyik biztonsági kódját is."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP hitelesítő",
|
||||
"docs": "Dokumentumok",
|
||||
"on": "2FA engedélyezve",
|
||||
"off": "Fokozott bejelentkezési biztonság",
|
||||
"enable": "Engedélyezés",
|
||||
"disable": "Letiltás",
|
||||
"addDevice": "Eszköz hozzáadása",
|
||||
"addTitle": "Hitelesítő eszköz hozzáadása",
|
||||
"addDescription": "Erősítse meg hitelesítőjétől kapott kóddal, vagy egy biztonsági kóddal, hogy egy másik eszközt regisztráljon ugyanazzal a kulccsal.",
|
||||
"addScanInstructions": "Olvasd be ezt a QR-kódot a további hitelesítővel. A meglévő hitelesítők továbbra is működni fognak.",
|
||||
"addSuccess": "További hitelesítő regisztrálva",
|
||||
"addFailed": "Nem sikerült hozzáadni a hitelesítőt",
|
||||
"codeRequired": "Adjon meg egy TOTP-kódot vagy biztonsági kódot",
|
||||
"codePlaceholder": "TOTP kód vagy biztonsági kód",
|
||||
"setupTitle": "TOTP beállítása",
|
||||
"setupFailed": "Nem sikerült elindítani a TOTP beállítását",
|
||||
"qrCode": "QR-kód",
|
||||
"secretCopied": "Titkos kód a vágólapra másolva",
|
||||
"instructions": "Olvasd be a QR-kódot, vagy írd be a titkos kódot a hitelesítő alkalmazásodba, majd írd be a 6 jegyű kódot",
|
||||
"continueVerify": "Tovább az ellenőrzéshez",
|
||||
"verifyTitle": "Kód ellenőrzése",
|
||||
"verify": "Ellenőrzés",
|
||||
"enter6Digits": "Adjon meg egy 6 jegyű kódot",
|
||||
"enabledSuccess": "Kétfaktoros hitelesítés engedélyezve",
|
||||
"invalidCode": "Érvénytelen kód, kérjük, próbálja újra",
|
||||
"disableTitle": "2FA letiltása",
|
||||
"disabledSuccess": "Kétfaktoros hitelesítés letiltva",
|
||||
"disableFailed": "Nem sikerült letiltani a 2FA-t",
|
||||
"backupTitle": "Biztonsági kódok",
|
||||
"downloadBackup": "Tartalékkódok letöltése",
|
||||
"done": "Kész"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Enter verification code",
|
||||
"verify": "Verify Code",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP Authenticator",
|
||||
"docs": "Docs",
|
||||
"on": "2FA is enabled",
|
||||
"off": "Add extra login security",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Setup TOTP",
|
||||
"setupFailed": "Failed to start TOTP setup",
|
||||
"qrCode": "QR Code",
|
||||
"secretCopied": "Secret copied to clipboard",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Continue to Verify",
|
||||
"verifyTitle": "Verify Code",
|
||||
"verify": "Verify",
|
||||
"enter6Digits": "Enter a 6-digit code",
|
||||
"enabledSuccess": "Two-factor authentication enabled",
|
||||
"invalidCode": "Invalid code, please try again",
|
||||
"disableTitle": "Disable 2FA",
|
||||
"disabledSuccess": "Two-factor authentication disabled",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
"backupTitle": "Backup Codes",
|
||||
"downloadBackup": "Download Backup Codes",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "App di autenticazione",
|
||||
"factor": "Codice di autenticazione",
|
||||
"challenge": {
|
||||
"enterCode": "Inserisci il codice di verifica",
|
||||
"verify": "Verifica codice",
|
||||
"backupCodeHint": "È inoltre possibile inserire uno dei propri codici di backup."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Autenticatore TOTP",
|
||||
"docs": "Documentazione",
|
||||
"on": "2FA è attivata",
|
||||
"off": "Aggiungi sicurezza aggiuntiva all'accesso",
|
||||
"enable": "Abilita",
|
||||
"disable": "Disabilita",
|
||||
"addDevice": "Aggiungi dispositivo",
|
||||
"addTitle": "Aggiungi dispositivo di autenticazione",
|
||||
"addDescription": "Conferma con un codice fornito dal tuo autenticatore o con un codice di backup per registrare un altro dispositivo con la stessa chiave.",
|
||||
"addScanInstructions": "Scansiona questo codice QR con l'autenticatore aggiuntivo. Gli autenticatori già in uso continueranno a funzionare.",
|
||||
"addSuccess": "Autenticatore aggiuntivo registrato",
|
||||
"addFailed": "Impossibile aggiungere l'autenticatore",
|
||||
"codeRequired": "Inserisci un codice TOTP o un codice di backup",
|
||||
"codePlaceholder": "Codice TOTP o codice di backup",
|
||||
"setupTitle": "Configura TOTP",
|
||||
"setupFailed": "Impossibile avviare la configurazione TOTP",
|
||||
"qrCode": "Codice QR",
|
||||
"secretCopied": "Segreto copiato negli appunti",
|
||||
"instructions": "Scansiona il codice QR o inserisci il codice segreto nella tua app di autenticazione, quindi inserisci il codice a 6 cifre",
|
||||
"continueVerify": "Procedi alla verifica",
|
||||
"verifyTitle": "Verifica codice",
|
||||
"verify": "Verifica",
|
||||
"enter6Digits": "Inserisci un codice a 6 cifre",
|
||||
"enabledSuccess": "Autenticazione a due fattori abilitata",
|
||||
"invalidCode": "Codice non valido, riprova",
|
||||
"disableTitle": "Disabilita 2FA",
|
||||
"disabledSuccess": "Autenticazione a due fattori disabilitata",
|
||||
"disableFailed": "Impossibile disabilitare la 2FA",
|
||||
"backupTitle": "Codici di backup",
|
||||
"downloadBackup": "Scarica i codici di backup",
|
||||
"done": "Fine"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "認証アプリ",
|
||||
"factor": "認証コード",
|
||||
"challenge": {
|
||||
"enterCode": "認証コードを入力してください",
|
||||
"verify": "コードの確認",
|
||||
"backupCodeHint": "バックアップコードのいずれかを入力することもできます。"
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP認証",
|
||||
"docs": "ドキュメント",
|
||||
"on": "2FAが有効です",
|
||||
"off": "ログインのセキュリティを強化",
|
||||
"enable": "有効にする",
|
||||
"disable": "無効にする",
|
||||
"addDevice": "デバイスを追加する",
|
||||
"addTitle": "認証デバイスを追加する",
|
||||
"addDescription": "認証デバイスから取得したコード、またはバックアップコードを使用して、同じキーで別のデバイスを登録してください。",
|
||||
"addScanInstructions": "追加の認証デバイスでこのQRコードをスキャンしてください。既存の認証デバイスは引き続きご利用いただけます。",
|
||||
"addSuccess": "追加の認証デバイスを登録しました",
|
||||
"addFailed": "認証システムの追加に失敗しました",
|
||||
"codeRequired": "TOTPコードまたはバックアップコードを入力してください",
|
||||
"codePlaceholder": "TOTPコードまたはバックアップコード",
|
||||
"setupTitle": "TOTPを設定",
|
||||
"setupFailed": "TOTPのセットアップを開始できませんでした",
|
||||
"qrCode": "QRコード",
|
||||
"secretCopied": "シークレットをクリップボードにコピーしました",
|
||||
"instructions": "QRコードをスキャンするか、認証アプリに秘密情報を入力し、6桁のコードを入力してください。",
|
||||
"continueVerify": "確認に進む",
|
||||
"verifyTitle": "コードの確認",
|
||||
"verify": "確認",
|
||||
"enter6Digits": "6桁のコードを入力してください",
|
||||
"enabledSuccess": "二要素認証が有効になりました",
|
||||
"invalidCode": "コードが無効です。もう一度お試しください",
|
||||
"disableTitle": "2FAを無効にする",
|
||||
"disabledSuccess": "二要素認証が無効になりました",
|
||||
"disableFailed": "2FAの無効化に失敗しました",
|
||||
"backupTitle": "バックアップコード",
|
||||
"downloadBackup": "バックアップコードをダウンロード",
|
||||
"done": "完了"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "인증 앱",
|
||||
"factor": "인증 코드",
|
||||
"challenge": {
|
||||
"enterCode": "인증 코드 입력",
|
||||
"verify": "코드 확인",
|
||||
"backupCodeHint": "백업 코드 중 하나를 입력할 수도 있습니다."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP 인증기",
|
||||
"docs": "문서",
|
||||
"on": "2FA가 활성화되었습니다",
|
||||
"off": "추가 로그인 보안을 설정하세요",
|
||||
"enable": "활성화",
|
||||
"disable": "비활성화",
|
||||
"addDevice": "기기 추가",
|
||||
"addTitle": "인증 장치 추가",
|
||||
"addDescription": "인증 앱에서 받은 코드 또는 백업 코드를 사용하여 동일한 키로 다른 기기를 등록하세요.",
|
||||
"addScanInstructions": "추가 인증 수단으로 이 QR 코드를 스캔하세요. 기존 인증 수단도 계속 사용할 수 있습니다.",
|
||||
"addSuccess": "추가 인증자가 등록되었습니다",
|
||||
"addFailed": "인증자 추가에 실패했습니다.",
|
||||
"codeRequired": "TOTP 코드 또는 백업 코드를 입력하세요.",
|
||||
"codePlaceholder": "TOTP 코드 또는 백업 코드",
|
||||
"setupTitle": "TOTP 설정",
|
||||
"setupFailed": "TOTP 설정 시작 실패",
|
||||
"qrCode": "QR 코드",
|
||||
"secretCopied": "시크릿이 클립보드에 복사됨",
|
||||
"instructions": "QR 코드를 스캔하거나 인증 앱에 비밀번호를 입력한 다음 6자리 코드를 입력하세요.",
|
||||
"continueVerify": "인증 계속하기",
|
||||
"verifyTitle": "코드 확인",
|
||||
"verify": "확인",
|
||||
"enter6Digits": "6자리 코드 입력",
|
||||
"enabledSuccess": "2단계 인증이 활성화되었습니다.",
|
||||
"invalidCode": "유효하지 않은 코드입니다. 다시 시도하세요.",
|
||||
"disableTitle": "2FA 비활성화",
|
||||
"disabledSuccess": "2단계 인증이 비활성화되었습니다.",
|
||||
"disableFailed": "2FA 비활성화 실패",
|
||||
"backupTitle": "백업 코드",
|
||||
"downloadBackup": "백업 코드 다운로드",
|
||||
"done": "완료"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Enter verification code",
|
||||
"verify": "Verify Code",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP Authenticator",
|
||||
"docs": "Docs",
|
||||
"on": "2FA is enabled",
|
||||
"off": "Add extra login security",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Setup TOTP",
|
||||
"setupFailed": "Failed to start TOTP setup",
|
||||
"qrCode": "QR Code",
|
||||
"secretCopied": "Secret copied to clipboard",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Continue to Verify",
|
||||
"verifyTitle": "Verify Code",
|
||||
"verify": "Verify",
|
||||
"enter6Digits": "Enter a 6-digit code",
|
||||
"enabledSuccess": "Two-factor authentication enabled",
|
||||
"invalidCode": "Invalid code, please try again",
|
||||
"disableTitle": "Disable 2FA",
|
||||
"disabledSuccess": "Two-factor authentication disabled",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
"backupTitle": "Backup Codes",
|
||||
"downloadBackup": "Download Backup Codes",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Angi verifikasjonskode",
|
||||
"verify": "Bekreft kode",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP Authenticator",
|
||||
"docs": "Docs",
|
||||
"on": "2FA is enabled",
|
||||
"off": "Add extra login security",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Setup TOTP",
|
||||
"setupFailed": "Kunne ikke starte TOTP-oppsett",
|
||||
"qrCode": "QR Code",
|
||||
"secretCopied": "Secret copied to clipboard",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Continue to Verify",
|
||||
"verifyTitle": "Bekreft kode",
|
||||
"verify": "Verifiser",
|
||||
"enter6Digits": "Enter a 6-digit code",
|
||||
"enabledSuccess": "Two-factor authentication enabled",
|
||||
"invalidCode": "Invalid code, please try again",
|
||||
"disableTitle": "Deaktiver 2FA",
|
||||
"disabledSuccess": "Tofaktorautentisering er deaktivert",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
"backupTitle": "Reservekoder",
|
||||
"downloadBackup": "Download Backup Codes",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Wprowadź kod weryfikacyjny",
|
||||
"verify": "Zweryfikuj kod",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Aplikacja TOTP",
|
||||
"docs": "Dokumentacja",
|
||||
"on": "Włączono uwierzytelnianie dwuskładnikowe",
|
||||
"off": "Dodaj dodatkowe zabezpieczenia logowania",
|
||||
"enable": "Włącz",
|
||||
"disable": "Wyłącz",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Skonfiguruj TOTP",
|
||||
"setupFailed": "Nie udało się uruchomić konfiguracji TOTP",
|
||||
"qrCode": "Kod QR",
|
||||
"secretCopied": "Skopiowano sekret do schowka",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Kontynuuj weryfikację",
|
||||
"verifyTitle": "Zweryfikuj kod",
|
||||
"verify": "Zweryfikuj",
|
||||
"enter6Digits": "Wprowadź 6-cyfrowy kod",
|
||||
"enabledSuccess": "Włączono uwierzytelnianie dwuskładnikowe",
|
||||
"invalidCode": "Nieprawidłowy kod, spróbuj ponownie",
|
||||
"disableTitle": "Wyłącz 2FA",
|
||||
"disabledSuccess": "Wyłączono uwierzytelnianie dwuskładnikowe",
|
||||
"disableFailed": "Nie udało się wyłączyć uwierzytelniania dwuskładnikowego",
|
||||
"backupTitle": "Kody zapasowe",
|
||||
"downloadBackup": "Pobierz kody zapasowe",
|
||||
"done": "Gotowe"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Enter verification code",
|
||||
"verify": "Verify Code",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP Authenticator",
|
||||
"docs": "Docs",
|
||||
"on": "2FA is enabled",
|
||||
"off": "Add extra login security",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Setup TOTP",
|
||||
"setupFailed": "Failed to start TOTP setup",
|
||||
"qrCode": "QR Code",
|
||||
"secretCopied": "Secret copied to clipboard",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Continue to Verify",
|
||||
"verifyTitle": "Verify Code",
|
||||
"verify": "Verify",
|
||||
"enter6Digits": "Enter a 6-digit code",
|
||||
"enabledSuccess": "Two-factor authentication enabled",
|
||||
"invalidCode": "Invalid code, please try again",
|
||||
"disableTitle": "Disable 2FA",
|
||||
"disabledSuccess": "Two-factor authentication disabled",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
"backupTitle": "Backup Codes",
|
||||
"downloadBackup": "Download Backup Codes",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Introduza o código de verificação",
|
||||
"verify": "Verificar Código",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Autenticador TOTP",
|
||||
"docs": "Documentação",
|
||||
"on": "2FA está ativada",
|
||||
"off": "Adicionar segurança extra de início de sessão",
|
||||
"enable": "Ativar",
|
||||
"disable": "Desativar",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Configurar TOTP",
|
||||
"setupFailed": "Falha ao iniciar a configuração de TOTP",
|
||||
"qrCode": "Código QR",
|
||||
"secretCopied": "Segredo copiado para a área de transferência",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Continuar para Verificação",
|
||||
"verifyTitle": "Verificar Código",
|
||||
"verify": "Verificar",
|
||||
"enter6Digits": "Insira um código de 6 dígitos",
|
||||
"enabledSuccess": "Autenticação de dois fatores ativada",
|
||||
"invalidCode": "Código inválido, tente novamente",
|
||||
"disableTitle": "Desativar 2FA",
|
||||
"disabledSuccess": "Autenticação de dois fatores desativada",
|
||||
"disableFailed": "Falha ao desativar 2FA",
|
||||
"backupTitle": "Códigos de Backup",
|
||||
"downloadBackup": "Descarregar Códigos de Backup",
|
||||
"done": "Concluir"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Aplicație de autentificare",
|
||||
"factor": "Cod de autentificare",
|
||||
"challenge": {
|
||||
"enterCode": "Introduceți codul de verificare",
|
||||
"verify": "Verificați codul",
|
||||
"backupCodeHint": "De asemenea, puteți introduce unul dintre codurile de rezervă."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Autentificator TOTP",
|
||||
"docs": "Documente",
|
||||
"on": "2FA este activat",
|
||||
"off": "Adăugați securitate suplimentară la conectare",
|
||||
"enable": "Permite",
|
||||
"disable": "Dezactivare",
|
||||
"addDevice": "Adăugați dispozitiv",
|
||||
"addTitle": "Adăugați un dispozitiv de autentificare",
|
||||
"addDescription": "Confirmați cu un cod de la autentificator sau cu un cod de rezervă pentru a înregistra un alt dispozitiv cu aceeași cheie.",
|
||||
"addScanInstructions": "Scanați acest cod QR cu ajutorul autentificatorului suplimentar. Autentificatoarele existente vor continua să funcționeze.",
|
||||
"addSuccess": "Autentificator suplimentar înscris",
|
||||
"addFailed": "Nu s-a putut adăuga autentificatorul",
|
||||
"codeRequired": "Introduceți un cod TOTP sau un cod de rezervă",
|
||||
"codePlaceholder": "Cod TOTP sau cod de rezervă",
|
||||
"setupTitle": "Configurați TOTP",
|
||||
"setupFailed": "Nu s-a putut porni configurarea TOTP",
|
||||
"qrCode": "Cod QR",
|
||||
"secretCopied": "Secret copiat în clipboard",
|
||||
"instructions": "Scanați codul QR sau introduceți secretul în aplicația de autentificare, apoi introduceți codul de 6 cifre",
|
||||
"continueVerify": "Continuați verificarea",
|
||||
"verifyTitle": "Verificați codul",
|
||||
"verify": "Verifica",
|
||||
"enter6Digits": "Introduceți un cod de 6 cifre",
|
||||
"enabledSuccess": "Autentificare cu doi factori activată",
|
||||
"invalidCode": "Cod nevalid, vă rugăm să încercați din nou",
|
||||
"disableTitle": "Dezactivați 2FA",
|
||||
"disabledSuccess": "Autentificarea cu doi factori este dezactivată",
|
||||
"disableFailed": "Nu s-a putut dezactiva 2FA",
|
||||
"backupTitle": "Coduri de rezervă",
|
||||
"downloadBackup": "Descărcați coduri de rezervă",
|
||||
"done": "Făcut"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Введите код подтверждения",
|
||||
"verify": "Проверка кода",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Аутентификатор TOTP",
|
||||
"docs": "Документация",
|
||||
"on": "2FA включена",
|
||||
"off": "Добавить дополнительную защиту",
|
||||
"enable": "Включить",
|
||||
"disable": "Отключить",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Настроить TOTP",
|
||||
"setupFailed": "Не удалось запустить настройку TOTP",
|
||||
"qrCode": "QR-код",
|
||||
"secretCopied": "Секрет скопирован в буфер обмена",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Продолжить проверку",
|
||||
"verifyTitle": "Проверка кода",
|
||||
"verify": "Подтвердить",
|
||||
"enter6Digits": "Введите 6-значный код",
|
||||
"enabledSuccess": "Двухфакторная аутентификация включена",
|
||||
"invalidCode": "Неверный код, попробуйте снова",
|
||||
"disableTitle": "Отключить 2FA",
|
||||
"disabledSuccess": "Двухфакторная аутентификация отключена",
|
||||
"disableFailed": "Не удалось отключить 2FA",
|
||||
"backupTitle": "Резервные коды",
|
||||
"downloadBackup": "Скачать резервные коды",
|
||||
"done": "Готово"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Enter verification code",
|
||||
"verify": "Verify Code",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP Authenticator",
|
||||
"docs": "Docs",
|
||||
"on": "2FA is enabled",
|
||||
"off": "Add extra login security",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Setup TOTP",
|
||||
"setupFailed": "Failed to start TOTP setup",
|
||||
"qrCode": "QR Code",
|
||||
"secretCopied": "Secret copied to clipboard",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Continue to Verify",
|
||||
"verifyTitle": "Verify Code",
|
||||
"verify": "Verify",
|
||||
"enter6Digits": "Enter a 6-digit code",
|
||||
"enabledSuccess": "Two-factor authentication enabled",
|
||||
"invalidCode": "Invalid code, please try again",
|
||||
"disableTitle": "Disable 2FA",
|
||||
"disabledSuccess": "Two-factor authentication disabled",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
"backupTitle": "Backup Codes",
|
||||
"downloadBackup": "Download Backup Codes",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Enter verification code",
|
||||
"verify": "Verify Code",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP Authenticator",
|
||||
"docs": "Docs",
|
||||
"on": "2FA is enabled",
|
||||
"off": "Add extra login security",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Setup TOTP",
|
||||
"setupFailed": "Failed to start TOTP setup",
|
||||
"qrCode": "QR Code",
|
||||
"secretCopied": "Secret copied to clipboard",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Continue to Verify",
|
||||
"verifyTitle": "Verify Code",
|
||||
"verify": "Verify",
|
||||
"enter6Digits": "Enter a 6-digit code",
|
||||
"enabledSuccess": "Two-factor authentication enabled",
|
||||
"invalidCode": "Invalid code, please try again",
|
||||
"disableTitle": "Disable 2FA",
|
||||
"disabledSuccess": "Two-factor authentication disabled",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
"backupTitle": "Backup Codes",
|
||||
"downloadBackup": "Download Backup Codes",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Enter verification code",
|
||||
"verify": "Verify Code",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP Authenticator",
|
||||
"docs": "Docs",
|
||||
"on": "2FA is enabled",
|
||||
"off": "Add extra login security",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Setup TOTP",
|
||||
"setupFailed": "Failed to start TOTP setup",
|
||||
"qrCode": "QR Code",
|
||||
"secretCopied": "Secret copied to clipboard",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Continue to Verify",
|
||||
"verifyTitle": "Verify Code",
|
||||
"verify": "Verify",
|
||||
"enter6Digits": "Enter a 6-digit code",
|
||||
"enabledSuccess": "Two-factor authentication enabled",
|
||||
"invalidCode": "Invalid code, please try again",
|
||||
"disableTitle": "Disable 2FA",
|
||||
"disabledSuccess": "Two-factor authentication disabled",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
"backupTitle": "Backup Codes",
|
||||
"downloadBackup": "Download Backup Codes",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Doğrulama kodunu girin",
|
||||
"verify": "Kodu Doğrula",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP Kimlik Doğrulayıcı",
|
||||
"docs": "Belgeler",
|
||||
"on": "2FA etkin",
|
||||
"off": "Ek giriş güvenliği ekleyin",
|
||||
"enable": "Etkinleştir",
|
||||
"disable": "Devre Dışı Bırak",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "TOTP Kurulumu",
|
||||
"setupFailed": "TOTP kurulumu başlatılamadı",
|
||||
"qrCode": "QR Kodu",
|
||||
"secretCopied": "Gizli anahtar panoya kopyalandı",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Doğrulamaya Devam Et",
|
||||
"verifyTitle": "Kodu Doğrula",
|
||||
"verify": "Doğrula",
|
||||
"enter6Digits": "6 haneli bir kod girin",
|
||||
"enabledSuccess": "İki faktörlü kimlik doğrulama etkinleştirildi",
|
||||
"invalidCode": "Geçersiz kod, lütfen tekrar deneyin",
|
||||
"disableTitle": "2FA'yı Devre Dışı Bırak",
|
||||
"disabledSuccess": "İki faktörlü kimlik doğrulama devre dışı bırakıldı",
|
||||
"disableFailed": "2FA devre dışı bırakılamadı",
|
||||
"backupTitle": "Yedek Kodlar",
|
||||
"downloadBackup": "Yedek Kodları İndir",
|
||||
"done": "Tamam"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Enter verification code",
|
||||
"verify": "Verify Code",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP Authenticator",
|
||||
"docs": "Docs",
|
||||
"on": "2FA is enabled",
|
||||
"off": "Add extra login security",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Setup TOTP",
|
||||
"setupFailed": "Failed to start TOTP setup",
|
||||
"qrCode": "QR Code",
|
||||
"secretCopied": "Secret copied to clipboard",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Continue to Verify",
|
||||
"verifyTitle": "Verify Code",
|
||||
"verify": "Verify",
|
||||
"enter6Digits": "Enter a 6-digit code",
|
||||
"enabledSuccess": "Two-factor authentication enabled",
|
||||
"invalidCode": "Invalid code, please try again",
|
||||
"disableTitle": "Disable 2FA",
|
||||
"disabledSuccess": "Two-factor authentication disabled",
|
||||
"disableFailed": "Failed to disable 2FA",
|
||||
"backupTitle": "Backup Codes",
|
||||
"downloadBackup": "Download Backup Codes",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "Nhập mã xác minh",
|
||||
"verify": "Xác minh mã",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "Trình xác thực TOTP",
|
||||
"docs": "Tài liệu",
|
||||
"on": "2FA đã được bật",
|
||||
"off": "Thêm bảo mật đăng nhập bổ sung",
|
||||
"enable": "Bật",
|
||||
"disable": "Tắt",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "Thiết lập TOTP",
|
||||
"setupFailed": "Không khởi động được cài đặt TOTP",
|
||||
"qrCode": "Mã QR",
|
||||
"secretCopied": "Đã sao chép mã bí mật vào clipboard",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "Tiếp tục xác minh",
|
||||
"verifyTitle": "Xác minh mã",
|
||||
"verify": "Xác minh",
|
||||
"enter6Digits": "Nhập mã 6 chữ số",
|
||||
"enabledSuccess": "Đã bật xác thực hai yếu tố",
|
||||
"invalidCode": "Mã không hợp lệ, vui lòng thử lại",
|
||||
"disableTitle": "Tắt 2FA",
|
||||
"disabledSuccess": "Đã tắt xác thực hai yếu tố",
|
||||
"disableFailed": "Không tắt được 2FA",
|
||||
"backupTitle": "Mã dự phòng",
|
||||
"downloadBackup": "Tải mã dự phòng",
|
||||
"done": "Xong"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "输入验证码",
|
||||
"verify": "验证",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP 身份验证器",
|
||||
"docs": "文档",
|
||||
"on": "双因素认证已启用",
|
||||
"off": "添加额外的登录安全",
|
||||
"enable": "启用",
|
||||
"disable": "禁用",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "设置 TOTP",
|
||||
"setupFailed": "启动 TOTP 设置失败",
|
||||
"qrCode": "二维码",
|
||||
"secretCopied": "密钥已复制到剪贴板",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "继续验证",
|
||||
"verifyTitle": "验证",
|
||||
"verify": "验证",
|
||||
"enter6Digits": "输入 6 位验证码",
|
||||
"enabledSuccess": "双因素认证已启用",
|
||||
"invalidCode": "验证码无效,请重试",
|
||||
"disableTitle": "禁用 2FA",
|
||||
"disabledSuccess": "双因素认证已禁用",
|
||||
"disableFailed": "禁用 2FA 失败",
|
||||
"backupTitle": "备份验证码",
|
||||
"downloadBackup": "下载备份验证码",
|
||||
"done": "完成"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"title": "Authenticator App",
|
||||
"factor": "Authenticator code",
|
||||
"challenge": {
|
||||
"enterCode": "請輸入驗證碼",
|
||||
"verify": "驗證碼",
|
||||
"backupCodeHint": "You can also enter one of your backup codes."
|
||||
},
|
||||
"enrollment": {
|
||||
"title": "TOTP 驗證器",
|
||||
"docs": "文件",
|
||||
"on": "雙因素驗證已啟用",
|
||||
"off": "新增額外的登入安全性",
|
||||
"enable": "啟用",
|
||||
"disable": "停用",
|
||||
"addDevice": "Add device",
|
||||
"addTitle": "Add authenticator device",
|
||||
"addDescription": "Confirm with a code from your authenticator or a backup code to enroll another device with the same key.",
|
||||
"addScanInstructions": "Scan this QR code with the additional authenticator. Existing authenticators will continue to work.",
|
||||
"addSuccess": "Additional authenticator enrolled",
|
||||
"addFailed": "Failed to add authenticator",
|
||||
"codeRequired": "Enter a TOTP code or backup code",
|
||||
"codePlaceholder": "TOTP code or backup code",
|
||||
"setupTitle": "設定 TOTP",
|
||||
"setupFailed": "無法啟動 TOTP 設定",
|
||||
"qrCode": "QR 碼",
|
||||
"secretCopied": "密鑰已複製到剪貼簿",
|
||||
"instructions": "Scan the QR code or enter the secret in your authenticator app, then enter the 6-digit code",
|
||||
"continueVerify": "繼續驗證",
|
||||
"verifyTitle": "驗證碼",
|
||||
"verify": "驗證",
|
||||
"enter6Digits": "輸入 6 位數驗證碼",
|
||||
"enabledSuccess": "雙因素驗證已啟用",
|
||||
"invalidCode": "驗證碼無效,請再試一次",
|
||||
"disableTitle": "停用雙因素驗證",
|
||||
"disabledSuccess": "雙因素驗證已停用",
|
||||
"disableFailed": "無法停用雙因素驗證",
|
||||
"backupTitle": "備用驗證碼",
|
||||
"downloadBackup": "下載備用驗證碼",
|
||||
"done": "完成"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"id": "totp",
|
||||
"name": "TOTP",
|
||||
"version": "1.0.0",
|
||||
"description": "Ask for a code from an authenticator app after signing in, with backup codes in case you lose your phone.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Access & Security",
|
||||
"icon": "ShieldCheck",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": [
|
||||
"db:own",
|
||||
"secrets:own",
|
||||
"network:serve",
|
||||
"auth:provide",
|
||||
"ui:surface",
|
||||
"db:core-refs"
|
||||
],
|
||||
"contributes": {
|
||||
"auth": {
|
||||
"secondFactors": ["totp"]
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"],
|
||||
"desktop": "server"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- totp 0001: create_enrollments
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `p_totp_enrollments` (
|
||||
`user_id` varchar(255) PRIMARY KEY NOT NULL,
|
||||
`secret` text,
|
||||
`pending_secret` text,
|
||||
`backup_codes` text,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- totp 0001: create_enrollments
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_totp_enrollments" (
|
||||
"user_id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"secret" text,
|
||||
"pending_secret" text,
|
||||
"backup_codes" text,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"enrollments": {
|
||||
"columns": {
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true,
|
||||
"primaryKey": true
|
||||
},
|
||||
"secret": {
|
||||
"type": "encryptedText"
|
||||
},
|
||||
"pendingSecret": {
|
||||
"type": "encryptedText"
|
||||
},
|
||||
"backupCodes": {
|
||||
"type": "encryptedText"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
}
|
||||
},
|
||||
"indexes": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
-- totp 0001: create_enrollments
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_totp_enrollments" (
|
||||
"user_id" text PRIMARY KEY NOT NULL,
|
||||
"secret" text,
|
||||
"pending_secret" text,
|
||||
"backup_codes" text,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@termix-plugin/totp",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p .",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.33.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"speakeasy": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"jsdom": "^30.0.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { enrollments } from "./tables.js";
|
||||
import { createEnrollmentRepository } from "./repository.js";
|
||||
import { createTotpService, FACTOR_ID, normalizeCode } from "./totp.js";
|
||||
import { registerTotpRoutes } from "./routes.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const table = await ctx.db.define(enrollments);
|
||||
const totp = createTotpService(
|
||||
ctx,
|
||||
createEnrollmentRepository(ctx.db, table),
|
||||
);
|
||||
|
||||
ctx.auth.registerSecondFactor({
|
||||
id: FACTOR_ID,
|
||||
labelKey: "factor",
|
||||
isEnrolled: (userId) => totp.isEnrolled(userId),
|
||||
verify: async (userId, body) => {
|
||||
const result = await totp.checkCode(
|
||||
userId,
|
||||
normalizeCode(body.totp_code ?? body.code),
|
||||
);
|
||||
if (result === null) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Your authenticator could not be read. Contact an admin to reset your second factor.",
|
||||
};
|
||||
}
|
||||
return result;
|
||||
},
|
||||
reset: (userId) => totp.remove(userId),
|
||||
});
|
||||
|
||||
registerTotpRoutes(ctx.http.router<Router>(), ctx, totp);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface EnrollmentRecord {
|
||||
userId: string;
|
||||
secret: string | null;
|
||||
pendingSecret: string | null;
|
||||
backupCodes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ctx.db.define hands the table back untyped and the drizzle handle is the
|
||||
// server's own, so both are typed at this module's edge.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type EnrollmentRepository = ReturnType<
|
||||
typeof createEnrollmentRepository
|
||||
>;
|
||||
|
||||
/** Sealed values only: nothing here ever sees a plaintext secret. */
|
||||
export function createEnrollmentRepository(db: PluginDatabase, table: Table) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
async function find(userId: string): Promise<EnrollmentRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(table.userId, userId))
|
||||
.limit(1);
|
||||
return (rows[0] as EnrollmentRecord) ?? null;
|
||||
}
|
||||
|
||||
async function save(
|
||||
userId: string,
|
||||
values: Partial<
|
||||
Pick<EnrollmentRecord, "secret" | "pendingSecret" | "backupCodes">
|
||||
>,
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
const now = new Date().toISOString();
|
||||
if (await find(userId)) {
|
||||
await drizzle
|
||||
.update(table)
|
||||
.set({ ...values, updatedAt: now })
|
||||
.where(eq(table.userId, userId));
|
||||
} else {
|
||||
await drizzle
|
||||
.insert(table)
|
||||
.values({ userId, ...values, createdAt: now, updatedAt: now });
|
||||
}
|
||||
await db.persist();
|
||||
}
|
||||
|
||||
return {
|
||||
find,
|
||||
save,
|
||||
|
||||
async remove(userId: string): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle.delete(table).where(eq(table.userId, userId));
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { Request, Response, Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import QRCode from "qrcode";
|
||||
import speakeasy from "speakeasy";
|
||||
import {
|
||||
LoginMethodError,
|
||||
type PluginContext,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { FACTOR_ID, normalizeCode, type TotpService } from "./totp.js";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export function registerTotpRoutes(
|
||||
router: Router,
|
||||
ctx: PluginContext,
|
||||
totp: TotpService,
|
||||
): void {
|
||||
const actor = () => ctx.currentActor()!;
|
||||
|
||||
async function accountLabel(userId: string): Promise<string> {
|
||||
try {
|
||||
const { users } = await ctx.db.refs<{ users: Table }>();
|
||||
const drizzle = await ctx.db.client<Drizzle>();
|
||||
const rows = await drizzle
|
||||
.select({ username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
if (rows[0]?.username) return `Termix (${rows[0].username})`;
|
||||
} catch {
|
||||
// The label is cosmetic.
|
||||
}
|
||||
return "Termix";
|
||||
}
|
||||
|
||||
const fail = (res: Response, message: string, error: unknown) => {
|
||||
ctx.log.error(
|
||||
message,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
res.status(500).json({ error: message });
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/status:
|
||||
* get:
|
||||
* summary: TOTP status for the caller
|
||||
* tags:
|
||||
* - TOTP
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Whether the caller has TOTP on.
|
||||
*/
|
||||
router.get("/status", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json({ enabled: await totp.isEnrolled(actor()) });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to read TOTP status", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/setup:
|
||||
* post:
|
||||
* summary: Start TOTP setup
|
||||
* description: Returns a new secret and QR code. With TOTP already on, a current code or backup code in `credential` returns the existing secret instead, to add another authenticator.
|
||||
* tags:
|
||||
* - TOTP
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* credential:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Secret and QR code.
|
||||
* 400:
|
||||
* description: A code is required.
|
||||
* 401:
|
||||
* description: Invalid code.
|
||||
*/
|
||||
router.post("/setup", async (req: Request, res: Response) => {
|
||||
const userId = actor();
|
||||
try {
|
||||
const label = await accountLabel(userId);
|
||||
if (await totp.isEnrolled(userId)) {
|
||||
const credential = normalizeCode(req.body?.credential);
|
||||
if (!credential) {
|
||||
return res.status(400).json({ error: "A TOTP code is required" });
|
||||
}
|
||||
if (!(await totp.checkCode(userId, credential))) {
|
||||
return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
}
|
||||
const secret = await totp.activeSecret(userId);
|
||||
if (!secret) {
|
||||
return res.status(409).json({ error: "TOTP secret is unavailable" });
|
||||
}
|
||||
const url = speakeasy.otpauthURL({
|
||||
secret,
|
||||
label,
|
||||
encoding: "base32",
|
||||
});
|
||||
return res.json({
|
||||
secret,
|
||||
qr_code: await QRCode.toDataURL(url),
|
||||
additional: true,
|
||||
});
|
||||
}
|
||||
|
||||
const { secret, otpauthUrl } = await totp.startSetup(userId, label);
|
||||
res.json({ secret, qr_code: await QRCode.toDataURL(otpauthUrl) });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to setup TOTP", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/enable:
|
||||
* post:
|
||||
* summary: Turn TOTP on
|
||||
* description: Checks a code from the secret /setup returned, turns TOTP on and returns backup codes. Signs the caller out everywhere else.
|
||||
* tags:
|
||||
* - TOTP
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP on, with backup codes.
|
||||
* 400:
|
||||
* description: Missing code, already on, or setup not started.
|
||||
* 401:
|
||||
* description: Invalid code.
|
||||
* 409:
|
||||
* description: Core does not allow second factors right now.
|
||||
*/
|
||||
router.post("/enable", async (req: Request, res: Response) => {
|
||||
const userId = actor();
|
||||
const code = normalizeCode(req.body?.totp_code);
|
||||
if (!code) return res.status(400).json({ error: "TOTP code is required" });
|
||||
try {
|
||||
if (await totp.isEnrolled(userId)) {
|
||||
return res.status(400).json({ error: "TOTP is already enabled" });
|
||||
}
|
||||
const pending = await totp.pendingSecretFor(userId, code);
|
||||
if (pending.status === "not-started") {
|
||||
return res.status(400).json({ error: "TOTP setup not initiated" });
|
||||
}
|
||||
if (pending.status === "invalid") {
|
||||
return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
}
|
||||
// Core's policy first, so a refusal leaves nothing half enabled.
|
||||
await ctx.auth.recordEnrollment(userId, FACTOR_ID);
|
||||
const backupCodes = await totp.activate(userId, pending.secret);
|
||||
res.json({
|
||||
message: "TOTP enabled successfully",
|
||||
backup_codes: backupCodes,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof LoginMethodError) {
|
||||
return res.status(error.status).json({ error: error.message });
|
||||
}
|
||||
fail(res, "Failed to enable TOTP", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/disable:
|
||||
* post:
|
||||
* summary: Turn TOTP off
|
||||
* tags:
|
||||
* - TOTP
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: TOTP off.
|
||||
* 400:
|
||||
* description: Missing code or TOTP is not on.
|
||||
* 401:
|
||||
* description: Invalid code.
|
||||
*/
|
||||
router.post("/disable", async (req: Request, res: Response) => {
|
||||
const userId = actor();
|
||||
const code = normalizeCode(req.body?.totp_code);
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: "A TOTP code is required" });
|
||||
}
|
||||
try {
|
||||
const ok = await totp.checkCode(userId, code);
|
||||
if (ok === null) {
|
||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||
}
|
||||
if (!ok) return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
await totp.remove(userId);
|
||||
await ctx.auth.removeEnrollment(userId, FACTOR_ID);
|
||||
ctx.log.info(`Two-factor authentication disabled for ${userId}`);
|
||||
res.json({ message: "TOTP disabled successfully" });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to disable TOTP", error);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/totp/backup-codes:
|
||||
* post:
|
||||
* summary: Replace the backup codes
|
||||
* tags:
|
||||
* - TOTP
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* totp_code:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: New backup codes.
|
||||
* 400:
|
||||
* description: Missing code or TOTP is not on.
|
||||
* 401:
|
||||
* description: Invalid code.
|
||||
*/
|
||||
router.post("/backup-codes", async (req: Request, res: Response) => {
|
||||
const userId = actor();
|
||||
const code = normalizeCode(req.body?.totp_code);
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: "A TOTP code is required" });
|
||||
}
|
||||
try {
|
||||
const ok = await totp.checkCode(userId, code);
|
||||
if (ok === null) {
|
||||
return res.status(400).json({ error: "TOTP is not enabled" });
|
||||
}
|
||||
if (!ok) return res.status(401).json({ error: "Invalid TOTP code" });
|
||||
res.json({ backup_codes: await totp.replaceBackupCodes(userId) });
|
||||
} catch (error) {
|
||||
fail(res, "Failed to generate backup codes", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
defineTable,
|
||||
encryptedText,
|
||||
refUser,
|
||||
timestamp,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* One row per user who started or finished setup. The secret columns hold
|
||||
* values sealed with ctx.secrets.seal, so they can be read during login
|
||||
* without an acting user. A non-null secret means the user is enrolled.
|
||||
*/
|
||||
export const enrollments = defineTable("enrollments", {
|
||||
userId: refUser().primaryKey(),
|
||||
secret: encryptedText(),
|
||||
// Set by /setup, promoted to secret once a code from it verifies.
|
||||
pendingSecret: encryptedText(),
|
||||
// A sealed JSON array of the unused backup codes.
|
||||
backupCodes: encryptedText(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const tables = [enrollments];
|
||||
@@ -0,0 +1,136 @@
|
||||
import crypto from "node:crypto";
|
||||
import speakeasy from "speakeasy";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { EnrollmentRepository } from "./repository.js";
|
||||
|
||||
export const FACTOR_ID = "totp";
|
||||
|
||||
const BACKUP_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const BACKUP_CODE_COUNT = 8;
|
||||
|
||||
/** Eight characters from a CSPRNG; Math.random is not fit for secrets. */
|
||||
export function generateBackupCode(): string {
|
||||
let code = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
code += BACKUP_CODE_ALPHABET[crypto.randomInt(BACKUP_CODE_ALPHABET.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
export function generateBackupCodes(): string[] {
|
||||
return Array.from({ length: BACKUP_CODE_COUNT }, () => generateBackupCode());
|
||||
}
|
||||
|
||||
export function verifyTotpCode(secret: string, code: string): boolean {
|
||||
return speakeasy.totp.verify({
|
||||
secret,
|
||||
encoding: "base32",
|
||||
token: code,
|
||||
window: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCode(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim().toUpperCase() : "";
|
||||
}
|
||||
|
||||
export type TotpService = ReturnType<typeof createTotpService>;
|
||||
|
||||
/**
|
||||
* Reads and checks a user's enrolment. Secrets are sealed with the
|
||||
* installation key, so this works during login with no acting user.
|
||||
*/
|
||||
export function createTotpService(
|
||||
ctx: PluginContext,
|
||||
repository: EnrollmentRepository,
|
||||
) {
|
||||
const unseal = (value: string | null) =>
|
||||
value ? ctx.secrets.unseal(value) : Promise.resolve(null);
|
||||
|
||||
async function readBackupCodes(sealed: string | null): Promise<string[]> {
|
||||
const raw = await unseal(sealed);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const codes = JSON.parse(raw);
|
||||
return Array.isArray(codes) ? codes : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function sealBackupCodes(codes: string[]): Promise<string> {
|
||||
return ctx.secrets.seal(JSON.stringify(codes));
|
||||
}
|
||||
|
||||
return {
|
||||
async isEnrolled(userId: string): Promise<boolean> {
|
||||
return !!(await repository.find(userId))?.secret;
|
||||
},
|
||||
|
||||
/** The active secret, or null when not enrolled or unreadable. */
|
||||
async activeSecret(userId: string): Promise<string | null> {
|
||||
return unseal((await repository.find(userId))?.secret ?? null);
|
||||
},
|
||||
|
||||
/**
|
||||
* True for a current code from the authenticator or an unused backup
|
||||
* code, which is burnt. Null when the user has no readable secret.
|
||||
*/
|
||||
async checkCode(userId: string, code: string): Promise<boolean | null> {
|
||||
const row = await repository.find(userId);
|
||||
const secret = await unseal(row?.secret ?? null);
|
||||
if (!row || !secret) return null;
|
||||
if (!code) return false;
|
||||
if (verifyTotpCode(secret, code)) return true;
|
||||
|
||||
const codes = await readBackupCodes(row.backupCodes);
|
||||
const index = codes.indexOf(code);
|
||||
if (index === -1) return false;
|
||||
codes.splice(index, 1);
|
||||
await repository.save(userId, {
|
||||
backupCodes: await sealBackupCodes(codes),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Starts setup with a new secret, replacing any unfinished one. */
|
||||
async startSetup(userId: string, label: string) {
|
||||
const secret = speakeasy.generateSecret({ name: label, length: 32 });
|
||||
await repository.save(userId, {
|
||||
pendingSecret: await ctx.secrets.seal(secret.base32),
|
||||
});
|
||||
return { secret: secret.base32, otpauthUrl: secret.otpauth_url ?? "" };
|
||||
},
|
||||
|
||||
/** The unfinished setup's secret, when a code from it verifies. */
|
||||
async pendingSecretFor(userId: string, code: string) {
|
||||
const pending = await unseal(
|
||||
(await repository.find(userId))?.pendingSecret ?? null,
|
||||
);
|
||||
if (!pending) return { status: "not-started" as const };
|
||||
if (!verifyTotpCode(pending, code)) return { status: "invalid" as const };
|
||||
return { status: "ok" as const, secret: pending };
|
||||
},
|
||||
|
||||
/** Makes the verified pending secret active and issues backup codes. */
|
||||
async activate(userId: string, secret: string): Promise<string[]> {
|
||||
const codes = generateBackupCodes();
|
||||
await repository.save(userId, {
|
||||
secret: await ctx.secrets.seal(secret),
|
||||
pendingSecret: null,
|
||||
backupCodes: await sealBackupCodes(codes),
|
||||
});
|
||||
return codes;
|
||||
},
|
||||
|
||||
async replaceBackupCodes(userId: string): Promise<string[]> {
|
||||
const codes = generateBackupCodes();
|
||||
await repository.save(userId, {
|
||||
backupCodes: await sealBackupCodes(codes),
|
||||
});
|
||||
return codes;
|
||||
},
|
||||
|
||||
remove: (userId: string) => repository.remove(userId),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Input } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
type SecondFactorUIProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { isValidTotpInput, normalizeTotpInput } from "./totp-input";
|
||||
|
||||
const primaryButton =
|
||||
"w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold";
|
||||
|
||||
export function TotpChallenge({
|
||||
disabled,
|
||||
verify,
|
||||
cancel,
|
||||
}: SecondFactorUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [code, setCode] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (!isValidTotpInput(code)) {
|
||||
toast.error(t("challenge.enterCode"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await verify({ totp_code: code });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("challenge.verify")}
|
||||
</span>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id="totp-code"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
maxLength={8}
|
||||
value={code}
|
||||
onChange={(e) => setCode(normalizeTotpInput(e.target.value))}
|
||||
disabled={disabled || loading}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("challenge.backupCodeHint")}
|
||||
</p>
|
||||
<Button
|
||||
type="submit"
|
||||
className={primaryButton}
|
||||
disabled={disabled || loading || !isValidTotpInput(code)}
|
||||
>
|
||||
{loading ? t("common.loading") : t("challenge.verify")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={cancel}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CheckCircle2, Copy, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Input, copyToClipboard } from "@termix/plugin-sdk/ui";
|
||||
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { apiErrorMessage } from "./totp-input";
|
||||
|
||||
type Step = "idle" | "setup" | "verify" | "backup";
|
||||
|
||||
interface SetupResponse {
|
||||
secret: string;
|
||||
qr_code: string;
|
||||
additional?: boolean;
|
||||
}
|
||||
|
||||
const accentButton =
|
||||
"text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand";
|
||||
const destructiveButton =
|
||||
"text-xs border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive";
|
||||
const box = "border border-border bg-muted/20 p-3 flex flex-col gap-3";
|
||||
const boxTitle =
|
||||
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground";
|
||||
|
||||
/** Settings > Security: turn TOTP on and off, add devices, backup codes. */
|
||||
export function TotpEnrollment() {
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [step, setStep] = useState<Step>("idle");
|
||||
const [qrCode, setQrCode] = useState("");
|
||||
const [secret, setSecret] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDisable, setShowDisable] = useState(false);
|
||||
const [disableInput, setDisableInput] = useState("");
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [addInput, setAddInput] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<{ enabled: boolean }>("status")
|
||||
.then(({ data }) => setEnabled(!!data.enabled))
|
||||
.catch(() => {});
|
||||
}, [api]);
|
||||
|
||||
async function startSetup() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.post<SetupResponse>("setup", {});
|
||||
setQrCode(data.qr_code);
|
||||
setSecret(data.secret);
|
||||
setCode("");
|
||||
setStep("setup");
|
||||
} catch {
|
||||
toast.error(t("enrollment.setupFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function addAuthenticator() {
|
||||
if (!addInput) {
|
||||
toast.error(t("enrollment.codeRequired"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.post<SetupResponse>("setup", {
|
||||
credential: addInput,
|
||||
});
|
||||
setQrCode(data.qr_code);
|
||||
setSecret(data.secret);
|
||||
setAdding(true);
|
||||
setShowAdd(false);
|
||||
setAddInput("");
|
||||
setStep("setup");
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("enrollment.addFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
if (code.length !== 6) {
|
||||
toast.error(t("enrollment.enter6Digits"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.post<{ backup_codes?: string[] }>("enable", {
|
||||
totp_code: code,
|
||||
});
|
||||
setBackupCodes(data.backup_codes ?? []);
|
||||
setEnabled(true);
|
||||
setStep("backup");
|
||||
toast.success(t("enrollment.enabledSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("enrollment.invalidCode")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disable() {
|
||||
if (!disableInput) {
|
||||
toast.error(t("enrollment.codeRequired"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post("disable", { totp_code: disableInput });
|
||||
setEnabled(false);
|
||||
setShowDisable(false);
|
||||
setDisableInput("");
|
||||
toast.success(t("enrollment.disabledSuccess"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("enrollment.disableFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBackupCodes() {
|
||||
const blob = new Blob([backupCodes.join("\n")], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "termix-backup-codes.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{t("enrollment.title")}</span>
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/totp"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("enrollment.docs")}
|
||||
</a>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{enabled ? t("enrollment.on") : t("enrollment.off")}
|
||||
</span>
|
||||
</div>
|
||||
{enabled ? (
|
||||
<div className="ml-3 flex shrink-0 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-7 ${accentButton}`}
|
||||
onClick={() => setShowAdd((open) => !open)}
|
||||
disabled={loading || step !== "idle"}
|
||||
>
|
||||
{t("enrollment.addDevice")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-7 ${destructiveButton}`}
|
||||
onClick={() => setShowDisable((open) => !open)}
|
||||
>
|
||||
{t("enrollment.disable")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`shrink-0 ml-3 h-7 ${accentButton}`}
|
||||
onClick={startSetup}
|
||||
disabled={loading || step !== "idle"}
|
||||
>
|
||||
{t("enrollment.enable")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{enabled && showAdd && (
|
||||
<div className={box}>
|
||||
<span className={boxTitle}>{t("enrollment.addTitle")}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("enrollment.addDescription")}
|
||||
</span>
|
||||
<Input
|
||||
placeholder={t("enrollment.codePlaceholder")}
|
||||
value={addInput}
|
||||
onChange={(e) => setAddInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addAuthenticator()}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => {
|
||||
setShowAdd(false);
|
||||
setAddInput("");
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex-1 ${accentButton}`}
|
||||
onClick={addAuthenticator}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && showDisable && (
|
||||
<div className={box}>
|
||||
<span className={boxTitle}>{t("enrollment.disableTitle")}</span>
|
||||
<Input
|
||||
placeholder={t("enrollment.codePlaceholder")}
|
||||
value={disableInput}
|
||||
onChange={(e) => setDisableInput(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => {
|
||||
setShowDisable(false);
|
||||
setDisableInput("");
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex-1 ${destructiveButton}`}
|
||||
onClick={disable}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("enrollment.disableTitle")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "setup" && (
|
||||
<div className={box}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={boxTitle}>{t("enrollment.setupTitle")}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setStep("idle");
|
||||
setAdding(false);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-center p-3 bg-background border border-border">
|
||||
{qrCode ? (
|
||||
<img
|
||||
src={qrCode}
|
||||
alt={t("enrollment.qrCode")}
|
||||
className="size-32"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-24 bg-muted flex items-center justify-center text-[10px] text-muted-foreground">
|
||||
{t("enrollment.qrCode")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-muted/30 border border-border px-2 py-1.5">
|
||||
<span className="text-[10px] font-mono flex-1 tracking-widest select-all truncate">
|
||||
{secret}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
copyToClipboard(secret);
|
||||
toast.info(t("enrollment.secretCopied"));
|
||||
}}
|
||||
className="text-muted-foreground hover:text-accent-brand shrink-0"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground text-center">
|
||||
{t(
|
||||
adding
|
||||
? "enrollment.addScanInstructions"
|
||||
: "enrollment.instructions",
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={accentButton}
|
||||
onClick={() => {
|
||||
if (adding) {
|
||||
setAdding(false);
|
||||
setStep("idle");
|
||||
toast.success(t("enrollment.addSuccess"));
|
||||
} else {
|
||||
setStep("verify");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(adding ? "enrollment.done" : "enrollment.continueVerify")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!enabled && step === "verify" && (
|
||||
<div className={box}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={boxTitle}>{t("enrollment.verifyTitle")}</span>
|
||||
<button
|
||||
onClick={() => setStep("setup")}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="000000"
|
||||
value={code}
|
||||
onChange={(e) =>
|
||||
setCode(e.target.value.replace(/\D/g, "").slice(0, 6))
|
||||
}
|
||||
onKeyDown={(e) => e.key === "Enter" && verify()}
|
||||
className="text-center font-mono tracking-widest text-lg h-10"
|
||||
maxLength={6}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
onClick={() => setStep("setup")}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`flex-1 ${accentButton}`}
|
||||
onClick={verify}
|
||||
disabled={loading || code.length !== 6}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
{t("enrollment.verify")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "backup" && backupCodes.length > 0 && (
|
||||
<div className={box}>
|
||||
<span className={boxTitle}>{t("enrollment.backupTitle")}</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{backupCodes.map((backupCode) => (
|
||||
<span
|
||||
key={backupCode}
|
||||
className="text-[10px] font-mono bg-muted border border-border px-2 py-1 text-center"
|
||||
>
|
||||
{backupCode}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={accentButton}
|
||||
onClick={downloadBackupCodes}
|
||||
>
|
||||
{t("enrollment.downloadBackup")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => setStep("idle")}
|
||||
>
|
||||
{t("enrollment.done")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { TotpChallenge } from "./TotpChallenge";
|
||||
import { TotpEnrollment } from "./TotpEnrollment";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerSecondFactorUI({
|
||||
id: "totp",
|
||||
titleKey: "title",
|
||||
component: TotpChallenge,
|
||||
enrollment: TotpEnrollment,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Six-digit codes, or a backup code of up to eight letters and digits. */
|
||||
export function normalizeTotpInput(value: string): string {
|
||||
return value
|
||||
.replace(/[^a-zA-Z0-9]/g, "")
|
||||
.toUpperCase()
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
export function isValidTotpInput(value: string): boolean {
|
||||
return /^[A-Z0-9]{6,8}$/.test(value);
|
||||
}
|
||||
|
||||
export function apiErrorMessage(error: unknown, fallback: string): string {
|
||||
const data = (error as { response?: { data?: { error?: unknown } } })
|
||||
?.response?.data;
|
||||
return typeof data?.error === "string" && data.error ? data.error : fallback;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin activated against a real in-memory database, its router mounted
|
||||
* on an express app the way core mounts it: JSON parsed, the acting user set
|
||||
* from the request, everything else left to the plugin.
|
||||
*/
|
||||
export async function startServer(
|
||||
options: {
|
||||
users?: string[];
|
||||
capabilities?: string[];
|
||||
refuseEnrollment?: string;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of options.users ?? ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: options.capabilities ?? manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
refuseEnrollment: options.refuseEnrollment,
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import speakeasy from "speakeasy";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
import { generateBackupCode, normalizeCode } from "../../src/backend/totp.js";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
const codeFor = (secret: string) =>
|
||||
speakeasy.totp({ secret, encoding: "base32" });
|
||||
|
||||
async function enrol(target: TestServer, user = "user-1") {
|
||||
const setup = await target.request("POST", "/setup", { user, body: {} });
|
||||
expect(setup.status).toBe(200);
|
||||
const secret: string = setup.body.secret;
|
||||
const enable = await target.request("POST", "/enable", {
|
||||
user,
|
||||
body: { totp_code: codeFor(secret) },
|
||||
});
|
||||
expect(enable.status).toBe(200);
|
||||
return { secret, backupCodes: enable.body.backup_codes as string[] };
|
||||
}
|
||||
|
||||
function factor(target: TestServer) {
|
||||
const registered = target.mock.auth.secondFactors.find(
|
||||
(entry) => entry.id === "totp",
|
||||
);
|
||||
if (!registered) throw new Error("totp factor not registered");
|
||||
return registered;
|
||||
}
|
||||
|
||||
describe("enrolment", () => {
|
||||
it("sets up, enables, records the enrolment and reports status", async () => {
|
||||
server = await startServer();
|
||||
expect((await server.request("GET", "/status")).body).toEqual({
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const setup = await server.request("POST", "/setup", { body: {} });
|
||||
expect(setup.body.qr_code).toMatch(/^data:image\/png;base64,/);
|
||||
expect(setup.body.secret).toMatch(/^[A-Z2-7]+$/);
|
||||
|
||||
const wrong = await server.request("POST", "/enable", {
|
||||
body: { totp_code: "000000" },
|
||||
});
|
||||
expect(wrong.status).toBe(401);
|
||||
expect(await factor(server).isEnrolled("user-1")).toBe(false);
|
||||
|
||||
const enable = await server.request("POST", "/enable", {
|
||||
body: { totp_code: codeFor(setup.body.secret) },
|
||||
});
|
||||
expect(enable.status).toBe(200);
|
||||
expect(enable.body.backup_codes).toHaveLength(8);
|
||||
expect(server.mock.auth.enrollments.has("user-1:totp")).toBe(true);
|
||||
expect((await server.request("GET", "/status")).body).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("stores the secret sealed, never in the clear", async () => {
|
||||
server = await startServer();
|
||||
const { secret } = await enrol(server);
|
||||
const row = server.db.sqlite
|
||||
.prepare("SELECT * FROM p_totp_enrollments WHERE user_id = 'user-1'")
|
||||
.get() as Record<string, string | null>;
|
||||
expect(row.secret).not.toContain(secret);
|
||||
expect(row.secret).toMatch(/^sealed:/);
|
||||
expect(row.pending_secret).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses enable before setup and when already on", async () => {
|
||||
server = await startServer();
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/enable", {
|
||||
body: { totp_code: "123456" },
|
||||
})
|
||||
).status,
|
||||
).toBe(400);
|
||||
const { secret } = await enrol(server);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/enable", {
|
||||
body: { totp_code: codeFor(secret) },
|
||||
})
|
||||
).status,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it("leaves nothing enabled when core refuses the enrolment", async () => {
|
||||
server = await startServer({
|
||||
refuseEnrollment: "Cannot enable 2FA while password login is disabled.",
|
||||
});
|
||||
const setup = await server.request("POST", "/setup", { body: {} });
|
||||
const enable = await server.request("POST", "/enable", {
|
||||
body: { totp_code: codeFor(setup.body.secret) },
|
||||
});
|
||||
expect(enable.status).toBe(409);
|
||||
expect(enable.body.error).toMatch(/password login/);
|
||||
expect(await factor(server).isEnrolled("user-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds another authenticator only with a valid code", async () => {
|
||||
server = await startServer();
|
||||
const { secret } = await enrol(server);
|
||||
expect((await server.request("POST", "/setup", { body: {} })).status).toBe(
|
||||
400,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/setup", {
|
||||
body: { credential: "000000" },
|
||||
})
|
||||
).status,
|
||||
).toBe(401);
|
||||
const again = await server.request("POST", "/setup", {
|
||||
body: { credential: codeFor(secret) },
|
||||
});
|
||||
expect(again.body).toMatchObject({ secret, additional: true });
|
||||
});
|
||||
|
||||
it("disables with a code and removes the enrolment", async () => {
|
||||
server = await startServer();
|
||||
const { secret } = await enrol(server);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/disable", {
|
||||
body: { totp_code: "000000" },
|
||||
})
|
||||
).status,
|
||||
).toBe(401);
|
||||
const off = await server.request("POST", "/disable", {
|
||||
body: { totp_code: codeFor(secret) },
|
||||
});
|
||||
expect(off.status).toBe(200);
|
||||
expect(server.mock.auth.enrollments.has("user-1:totp")).toBe(false);
|
||||
expect(await factor(server).isEnrolled("user-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces backup codes", async () => {
|
||||
server = await startServer();
|
||||
const { secret, backupCodes } = await enrol(server);
|
||||
const fresh = await server.request("POST", "/backup-codes", {
|
||||
body: { totp_code: codeFor(secret) },
|
||||
});
|
||||
expect(fresh.body.backup_codes).toHaveLength(8);
|
||||
expect(
|
||||
await factor(server).verify("user-1", { totp_code: backupCodes[0] }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await factor(server).verify("user-1", {
|
||||
totp_code: fresh.body.backup_codes[0],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps users apart", async () => {
|
||||
server = await startServer();
|
||||
await enrol(server, "user-1");
|
||||
expect(
|
||||
(await server.request("GET", "/status", { user: "user-2" })).body,
|
||||
).toEqual({ enabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
it("verifies a current code and refuses a wrong one", async () => {
|
||||
server = await startServer();
|
||||
const { secret } = await enrol(server);
|
||||
expect(await factor(server).verify("user-1", { totp_code: "000000" })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
await factor(server).verify("user-1", { totp_code: codeFor(secret) }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts each backup code once, typed in any case", async () => {
|
||||
server = await startServer();
|
||||
const { backupCodes } = await enrol(server);
|
||||
const [first] = backupCodes;
|
||||
expect(
|
||||
await factor(server).verify("user-1", {
|
||||
totp_code: first.toLowerCase(),
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(await factor(server).verify("user-1", { totp_code: first })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
await factor(server).verify("user-1", { totp_code: backupCodes[1] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses with a message when the user has no readable secret", async () => {
|
||||
server = await startServer();
|
||||
expect(
|
||||
await factor(server).verify("user-1", { totp_code: "123456" }),
|
||||
).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("reset removes the enrolment, as the admin reset calls it", async () => {
|
||||
server = await startServer();
|
||||
const { secret } = await enrol(server);
|
||||
await factor(server).reset!("user-1");
|
||||
expect(await factor(server).isEnrolled("user-1")).toBe(false);
|
||||
expect(
|
||||
await factor(server).verify("user-1", { totp_code: codeFor(secret) }),
|
||||
).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it("drops the enrolment with the user", async () => {
|
||||
server = await startServer();
|
||||
await enrol(server);
|
||||
server.db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
|
||||
expect(await factor(server).isEnrolled("user-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilities", () => {
|
||||
it("fails closed without auth:provide", async () => {
|
||||
await expect(
|
||||
startServer({
|
||||
capabilities: ["db:own", "secrets:own", "network:serve", "ui:surface"],
|
||||
}),
|
||||
).rejects.toThrow(/auth:provide/);
|
||||
});
|
||||
|
||||
it("fails closed without db:own", async () => {
|
||||
await expect(
|
||||
startServer({
|
||||
capabilities: [
|
||||
"secrets:own",
|
||||
"network:serve",
|
||||
"auth:provide",
|
||||
"ui:surface",
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow(/db:own/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("helpers", () => {
|
||||
it("makes backup codes from the unambiguous alphabet", () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
expect(generateBackupCode()).toMatch(/^[A-HJ-NP-Z2-9]{8}$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("normalises codes", () => {
|
||||
expect(normalizeCode(" abcd1234 ")).toBe("ABCD1234");
|
||||
expect(normalizeCode(42)).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import {
|
||||
isValidTotpInput,
|
||||
normalizeTotpInput,
|
||||
} from "../../src/frontend/totp-input";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("registers the second factor with an enrolment section", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.secondFactors()).toEqual(["totp"]);
|
||||
});
|
||||
|
||||
it("sends the typed code, backup codes included", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
const verify = vi.fn(async () => {});
|
||||
rendered.renderSecondFactor("totp", { verify });
|
||||
fireEvent.change(document.getElementById("totp-code")!, {
|
||||
target: { value: "ab-cd 12 34" },
|
||||
});
|
||||
fireEvent.submit(document.getElementById("totp-code")!.closest("form")!);
|
||||
await waitFor(() =>
|
||||
expect(verify).toHaveBeenCalledWith({ totp_code: "ABCD1234" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the enrolment state from the plugin API", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: {
|
||||
get: vi.fn(async () => ({ data: { enabled: true } })),
|
||||
} as never,
|
||||
});
|
||||
rendered.renderEnrollment("totp");
|
||||
expect(await screen.findByText(locales.enrollment.on)).toBeTruthy();
|
||||
expect(screen.getByText(locales.enrollment.addDevice)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts setup and shows the QR code", async () => {
|
||||
const post = vi.fn(async () => ({
|
||||
data: { secret: "JBSWY3DPEHPK3PXP", qr_code: "data:image/png;base64,x" },
|
||||
}));
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: {
|
||||
get: vi.fn(async () => ({ data: { enabled: false } })),
|
||||
post,
|
||||
} as never,
|
||||
});
|
||||
rendered.renderEnrollment("totp");
|
||||
fireEvent.click(await screen.findByText(locales.enrollment.enable));
|
||||
expect(await screen.findByText("JBSWY3DPEHPK3PXP")).toBeTruthy();
|
||||
expect(post).toHaveBeenCalledWith("setup", {});
|
||||
});
|
||||
|
||||
it("removes the factor on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, { manifest, locales });
|
||||
await app.deactivate();
|
||||
expect(app.registered.secondFactors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("totp input", () => {
|
||||
it("normalises and validates codes", () => {
|
||||
expect(normalizeTotpInput("12 34-56")).toBe("123456");
|
||||
expect(normalizeTotpInput("abcd1234ef")).toBe("ABCD1234");
|
||||
expect(isValidTotpInput("123456")).toBe(true);
|
||||
expect(isValidTotpInput("ABCD1234")).toBe(true);
|
||||
expect(isValidTotpInput("12345")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user