feat: move totp and passkeys into plugins (C1)

This commit is contained in:
LukeGus committed 2026-09-24 16:57:24 -05:00
1 parent f4f26a9b8b
commit 5c18435105
235 files changed
+22545 -5610

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
-- webauthn_credentials moves to the webauthn plugin's adopted table at
-- activation, and the users TOTP columns are read by the TOTP boot migration
-- after this runs. Dropping either here would lose the data first.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -358,6 +358,13 @@
"when": 1790284032088,
"tag": "0050_optimal_speedball",
"breakpoints": true
},
{
"idx": 51,
"version": "5",
"when": 1790285910582,
"tag": "0051_icy_hercules",
"breakpoints": true
}
]
}
@@ -0,0 +1,4 @@
-- webauthn_credentials moves to the webauthn plugin's adopted table at
-- activation, and the users TOTP columns are read by the TOTP boot migration
-- after this runs. Dropping either here would lose the data first.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -365,6 +365,13 @@
"when": 1790284031484,
"tag": "0051_useful_slyde",
"breakpoints": true
},
{
"idx": 52,
"version": "7",
"when": 1790285909968,
"tag": "0052_motionless_krista_starr",
"breakpoints": true
}
]
}
+4
View File
@@ -0,0 +1,4 @@
-- webauthn_credentials moves to the webauthn plugin's adopted table at
-- activation, and the users TOTP columns are read by the TOTP boot migration
-- after this runs. Dropping either here would lose the data first.
SELECT 1;
File diff suppressed because it is too large. Load diff
+7
View File
@@ -337,6 +337,13 @@
"when": 1790284030834,
"tag": "0047_futuristic_microbe",
"breakpoints": true
},
{
"idx": 48,
"version": "6",
"when": 1790285909308,
"tag": "0048_cold_miek",
"breakpoints": true
}
]
}
-4
View File
@@ -39,8 +39,6 @@
"@types/js-yaml",
"@types/jsonwebtoken",
"@types/multer",
"@types/qrcode",
"@types/speakeasy",
"@types/ssh2",
"@uiw/codemirror-extensions-langs",
"@uiw/codemirror-theme-github",
@@ -71,7 +69,6 @@
"jszip",
"multer",
"nanoid",
"qrcode",
"i18next-browser-languagedetector",
"lucide-react",
"motion",
@@ -87,7 +84,6 @@
"react-xtermjs",
"remark-gfm",
"socks",
"speakeasy",
"ssh2",
"undici",
"husky",
+32 -6
View File
@@ -14,8 +14,6 @@
],
"dependencies": {
"@anthropic-ai/sdk": "^0.120.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-virtual": "^3.14.13",
"@types/compression": "^1.8.1",
"@types/ldapjs": "^3.0.6",
@@ -43,12 +41,10 @@
"nanoid": "^6.0.1",
"node-pty": "^1.1.0",
"pg": "^8.23.0",
"qrcode": "^1.5.4",
"redis": "^6.2.1",
"semver": "^7.8.5",
"sharp": "^0.35.4",
"socks": "^2.8.10",
"speakeasy": "^2.0.0",
"ssh2": "^1.17.0",
"undici": "^8.10.2",
"ws": "^8.21.3"
@@ -101,10 +97,8 @@
"@types/multer": "^2.2.0",
"@types/node": "^26.2.0",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/speakeasy": "^2.0.10",
"@types/ssh2": "^1.15.6",
"@types/ws": "^8.18.1",
"@uiw/codemirror-extensions-langs": "^4.25.11",
@@ -7048,6 +7042,10 @@
"resolved": "plugins/tmux-monitor",
"link": true
},
"node_modules/@termix-plugin/totp": {
"resolved": "plugins/totp",
"link": true
},
"node_modules/@termix-plugin/tunnels": {
"resolved": "plugins/tunnels",
"link": true
@@ -7060,6 +7058,10 @@
"resolved": "plugins/web-endpoint",
"link": true
},
"node_modules/@termix-plugin/webauthn": {
"resolved": "plugins/webauthn",
"link": true
},
"node_modules/@termix-plugin/workspaces": {
"resolved": "plugins/workspaces",
"link": true
@@ -18692,6 +18694,19 @@
"@termix/plugin-sdk": "*"
}
},
"plugins/totp": {
"name": "@termix-plugin/totp",
"version": "1.0.0",
"dependencies": {
"qrcode": "^1.5.4",
"speakeasy": "^2.0.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10"
}
},
"plugins/tunnels": {
"name": "@termix-plugin/tunnels",
"version": "1.0.0",
@@ -18713,6 +18728,17 @@
"@termix/plugin-sdk": "*"
}
},
"plugins/webauthn": {
"name": "@termix-plugin/webauthn",
"version": "1.0.0",
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
},
"plugins/workspaces": {
"name": "@termix-plugin/workspaces",
"version": "1.0.0",
-6
View File
@@ -56,8 +56,6 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.120.0",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"@tanstack/react-virtual": "^3.14.13",
"@types/compression": "^1.8.1",
"@types/ldapjs": "^3.0.6",
@@ -85,12 +83,10 @@
"nanoid": "^6.0.1",
"node-pty": "^1.1.0",
"pg": "^8.23.0",
"qrcode": "^1.5.4",
"redis": "^6.2.1",
"semver": "^7.8.5",
"sharp": "^0.35.4",
"socks": "^2.8.10",
"speakeasy": "^2.0.0",
"ssh2": "^1.17.0",
"undici": "^8.10.2",
"ws": "^8.21.3"
@@ -143,10 +139,8 @@
"@types/multer": "^2.2.0",
"@types/node": "^26.2.0",
"@types/pg": "^8.23.1",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@types/speakeasy": "^2.0.10",
"@types/ssh2": "^1.15.6",
"@types/ws": "^8.18.1",
"@uiw/codemirror-extensions-langs": "^4.25.11",
+75 -10
View File
@@ -454,7 +454,7 @@ boundary and Suspense.
| `registerAction`, `declareActionSlot` | Frontend actions and the slots a plugin owns |
| `registerSlotContribution`, `invokeAction` | Fill a slot with a `button` or a `component`, with an optional `when`; call an action and get its result |
| `registerSshAuthEditor` | An SSH auth method's editor in the host editor |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method and a second factor challenge (plus an optional `enrollment` section) |
| `registerLoginMethod`, `registerSecondFactorUI` | Login screen UI for a method (`placement: "inline"` beside the password form) and a second factor challenge, each with an optional `enrollment` |
| `api`, `wsUrl(path)` | axios on `/plugin-api/<id>/` and the plugin's WebSocket URL |
| `apiFor(origin)` | The same client for a resolved connection origin: `"remote"` reaches the desktop app's connected server (**B14**) |
| `fetch(path, init)` | A raw `fetch` on `/plugin-api/<id>/<path>` with core's auth, for a streamed response (SSE) axios cannot read (**B18**) |
@@ -575,7 +575,9 @@ popover, scroll-area, select, select2, separator, skeleton, switch, textarea,
tooltip, sheet, section-card, metric-card, charts, the card grid,
`ConnectionScreen` and the connection status helpers,
`SnippetVariablesDialog`, `FullScreenAppWrapper`, the connection log context,
`TOTPDialog`, `SSHAuthDialog`, `WarpgateDialog`, `PassphraseDialog`,
`TOTPDialog` (its `MFAPromptMode` type lives in
`@termix/plugin-sdk/frontend` since **C1**, so the shell spells no `"totp"`),
`SSHAuthDialog`, `WarpgateDialog`, `PassphraseDialog`,
`OPKSSHDialog` (until opkssh moves in Phase C), `HostKeyVerificationDialog`,
`useTabs`/`useTabsSafe`, `ActionSlot`, `ComponentSlot`, `PluginComponent` and
`FOLDER_COLORS` (the colour swatches folders and workspaces pick from).
@@ -980,7 +982,11 @@ and activates it again that way.
`layout` (what `app.tabs.getLayout()` returns) and `ready` (fire
`app.tabs.onReady`). `app.tabs.applyLayout` is recorded in `shellCalls` and runs
the shell's real restore rules, `openedTabs()` lists what it opened, and
`renderOpenedTab(i)` renders one the way the shell would, placeholder included. `renderWithApp` activates the plugin against core's real registries,
`renderOpenedTab(i)` renders one the way the shell would, placeholder included.
**C1** added `registered.loginMethods()` / `.secondFactors()` and
`renderLoginMethod`, `renderSecondFactor` and `renderEnrollment`, and the
backend doubles take `refuseEnrollment` to make `recordEnrollment` refuse the
way core's policy does. `renderWithApp` activates the plugin against core's real registries,
records what it registered and the shell calls it made, renders any tab,
panel, card, host editor section, settings component or slot, and
`deactivate()` disposes it all. Every bundled plugin has a
@@ -993,13 +999,50 @@ auto-session and trusted devices, plus the base SSH auth types password, key,
stored credential, agent and none. Every other login method, second factor and
SSH auth method is a plugin through `ctx.auth`.
In 2.9.0, OIDC (and GitHub, Google), LDAP, passkeys, TOTP, OPKSSH, Step-CA,
Vault, Tailscale and Warpgate still live in core. They register through the
same interfaces from `src/backend/auth/legacy-providers.ts` and
In 2.9.0, OIDC (and GitHub, Google), LDAP, OPKSSH, Step-CA, Vault, Tailscale
and Warpgate still live in core. They register through the same interfaces
from `src/backend/auth/legacy-providers.ts` and
`src/ui/auth/legacy-auth-ui.tsx` with `pluginId: "core"`. Phase C moves each
one into its plugin by moving its block out of those two files; D1 deletes them.
Nothing else in core branches on those type names.
**C1** moved TOTP and passkeys out:
- `totp` registers the `totp` second factor. It owns `p_totp_enrollments`
(one row per user: the active secret, an unfinished setup's secret and the
backup codes, all sealed with `ctx.secrets.seal`), so `verify` reads them
during login with no acting user. Its routes at `/plugin-api/totp/`
(`status`, `setup`, `enable`, `disable`, `backup-codes`) re-authenticate
with a TOTP or backup code, never the account password, which a plugin
cannot see. `enable` calls `ctx.auth.recordEnrollment` before it turns
anything on, so core's policy can refuse it cleanly.
- `webauthn` registers the `passkey` login method (inline on the login
screen) and adopts `webauthn_credentials`. A passkey is a login method only,
as in 2.8: an assertion whose authenticator verified the user returns
`mfaSatisfied`, which skips core's second-factor step; without it the user
still gets their factors. The relying party id and origin come from the
request exactly as before (no core setting holds them); the relying party
name is `ctx.settings.readCore("app_name")`.
`POST /plugin-api/webauthn/authenticate/options` is public; the assertion
goes to core's `/users/auth/passkey/verify`.
- `src/backend/utils/crypto-migration/totp-migration.ts` takes a 2.8
database across: every `core`/`totp` enrolment row (A8) becomes
`totp`/`totp`, every user with `users.totp_enabled` gets one, and once
the plugin's table exists each secret and its backup codes are decrypted
with the user's data key, sealed into it, and the `users` columns cleared.
The rows move even while the plugin is off, so those users fail closed. The
`users` TOTP columns left `schema.ts`, and the drizzle migration that would
drop them is a no-op, like an adopted table.
- Core keeps no TOTP knowledge. `totp_enabled` in the login body and
`/users/me` is still sent under that name for 2.8 clients (Termix-Mobile,
older remote-sync peers) but means "has any second factor"; the admin user
list sends `second_factor_enabled`. Trusted proxy login, "disable password
login" and a password reset that wipes data go through
`user_second_factors` and `resetUserSecondFactors`.
`/users/admin/totp/disable` is gone; the generic admin reset covers it.
`AuthManager.unlockWithSystemKey` (was `authenticateWebAuthnUser`) opens
the data key for any identity that carries no password.
#### One SSH connect pipeline
Every SSH connection, core's and plugins', goes through
@@ -1130,8 +1173,18 @@ Routes, all under `/users/auth`: `GET methods` (public), `GET :methodId/start`,
`POST second-factor/:factorId/challenge`, `POST second-factor/:factorId/verify`.
A redirect method's provider must send the browser back to
`/users/auth/<methodId>/callback`. The older routes (`/users/login`,
`/users/totp/verify-login`, `/users/oidc/*`, `/users/ldap/login`,
`/users/webauthn/authenticate/verify`) are thin wrappers over the same pipeline.
`/users/oidc/*`, `/users/ldap/login`) are thin wrappers over the same
pipeline. `/users/totp/verify-login` stays for 2.8 clients: it answers with
the factor named in `factor`, or the user's first required factor, and core
never names one itself.
**Enrolment policy (C1).** `ctx.auth.recordEnrollment` refuses with a
`LoginMethodError` (409) while trusted proxy login is on or the password
login setting is off, the rules the 2.8 TOTP route had, now applied to every
factor. When it records one it also revokes the user's other sessions and
forgets their trusted devices, so nothing that skipped the factor before
keeps skipping it. The session to keep is the request's: the HTTP middleware
puts the session id next to the actor (`getActorSessionId`).
An admin clears a user's factors, including orphaned ones, with
`DELETE /users/admin/:userId/second-factors` (audited as
@@ -1152,7 +1205,11 @@ lists (props: `instances`, `rememberMe`, `submit`, `startRedirect`,
`complete`); a redirect method with no UI gets one button per instance.
`app.registerSecondFactorUI` draws the challenge (props: `verify`,
`challenge`, `cancel`) and, with `enrollment`, a section in Settings >
Security. At activation before sign-in `app.api` calls fail with 401, so do
Security. **C1** gave `app.registerLoginMethod` the same `enrollment` (a
passkey is registered there) and `placement: "inline"`, which also draws
the method under the password form; the login screen no longer knows the
passkey method by name. A server that sends a second-factor step without
naming its factors gets every factor UI registered on the screen. At activation before sign-in `app.api` calls fail with 401, so do
nothing there but register.
### 13. Where plugins live
@@ -1497,6 +1554,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.services.provide` / `.get` / `.providers` | per-service RBAC | **A1**, named **B12** |
| `ctx.secrets.offer` / `.withdraw` / `.getShared` | per-secret RBAC | **A1** |
| `ctx.secrets.get` / `.set` / `.delete` | `secrets:own` | **B18** |
| `ctx.secrets.seal` / `.unseal` | `secrets:own` | **C1** |
| `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
@@ -1756,6 +1814,13 @@ notification)` delivers to the ones among them that are theirs and
`ctx.db.persist`, because a plugin reads its keys on every request that
uses them. The AI assistant keeps each provider's key under
`provider:<id>` instead of in its providers table.
- **C1** added `ctx.secrets.seal(value)` and `.unseal(sealed)`: the same
installation-key encryption, returned to the plugin instead of stored, for
a secret that lives in the plugin's own table and has to be read without
an acting user (a second factor checked during login). Both need
`secrets:own`; neither needs an actor or writes an audit line. `unseal`
answers null for anything `seal` did not write. The totp plugin keeps its
secrets and backup codes this way.
- `ctx.fetch` takes a `signal`. It aborts the request and a streamed body;
`timeoutMs` still only covers the wait for the headers, so a long model
stream is not cut.
@@ -2296,7 +2361,7 @@ Then, with the app running:
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2), remote-desktop (B14), docker (B15), host-metrics (B16), automations
(B17), ai (B18) and homepage (B19), which import
(B17), ai (B18), homepage (B19), totp and webauthn (C1), which import
nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
+19
View File
@@ -453,6 +453,18 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
`web-endpoint`'s "answers 503 while the tunnels plugin is not available"
test gets 502. Confirmed present on `dev-2.9.0` before this step (checked
by stashing B20's changes and re-running). Owner: D0.
- **C1 (totp):** `totp-migration.ts` moves a user's TOTP secret only when
their data key opens at boot. A 2.8 user whose key is still a pre-2.5.1
password wrap keeps their enrolment row (fail closed) but has no secret in
`p_totp_enrollments` until a boot after their next password login migrates
the key, so that one login fails at the TOTP step. Likewise, enabling the
totp plugin for the first time without a restart does not move secrets
until the next boot. Decide whether either needs handling (a migration run
on plugin activation, or an admin reset message). Owner: D0.
- **C1 (totp/webauthn):** the `users` columns `totp_secret`, `totp_enabled`
and `totp_backup_codes` stay in the database (dropped from `schema.ts`, the
drizzle drop is a no-op) until 3.0.0 removes them, after the boot
migration has run everywhere. Owner: 3.0.0.
## Manual checks after 2.9.0
@@ -520,3 +532,10 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
plugin disabled; the AI assistant lists homepage items and says
unavailable with homepage off; disable homepage in admin (the tab, its
dashboard cards and the toggle disappear) then enable it again.
- TOTP and passkeys: on a 2.8 database a TOTP user is still asked for a
code (and a backup code works once); set up TOTP from Settings > Security,
add a second device, regenerate backup codes, disable it; enabling signs
out other sessions; "remember me" skips it next time; disable the totp
plugin and a TOTP user's login is refused until an admin resets their
factors; register a passkey, sign in with it with and without a PIN (no
PIN still asks for TOTP), delete it; existing 2.8 passkeys still sign in.
+8
View File
@@ -232,6 +232,14 @@ export interface PluginSecrets {
set: (key: string, value: string | null) => Promise<void>;
/** Removes the acting user's secret. Needs secrets:own. Audited. */
delete: (key: string) => Promise<void>;
/**
* Encrypts a value with the installation key, for a plugin that keeps a
* secret in its own table and has to read it without an acting user (a
* second factor checked during login). Needs secrets:own. Not audited.
*/
seal: (value: string) => Promise<string>;
/** Reverses seal(). Null when the value cannot be decrypted. */
unseal: (sealed: string) => Promise<string | null>;
offer: (
key: string,
resolve: (userId: string) => Promise<string | null> | string | null,
+14
View File
@@ -466,8 +466,22 @@ export interface LoginMethodContribution {
titleKey: string;
icon?: IconComponent;
component: ComponentType<LoginMethodUIProps>;
/**
* "inline" also draws the method under the password form, for a local
* method people use instead of a password (a passkey). Otherwise it only
* shows in the list of other sign-in methods.
*/
placement?: "inline";
/** Enrolment, shown in Settings > Security (registering a passkey). */
enrollment?: ComponentType<Record<string, unknown>>;
}
/**
* How an SSH keyboard-interactive prompt is shown by TOTPDialog: a code field
* (the default), a password, a menu choice, or a push approval.
*/
export type MFAPromptMode = "totp" | "password" | "menu" | "push";
/** What the second-factor step hands a factor's UI. */
export interface SecondFactorUIProps {
factorId: string;
+40 -1
View File
@@ -8,7 +8,7 @@
* The vitest config these run under comes from @termix/plugin-sdk/vitest-preset.
*/
import { PluginCapabilityError } from "./backend.js";
import { LoginMethodError, PluginCapabilityError } from "./backend.js";
import type {
PluginContext,
PluginDisposables,
@@ -118,6 +118,11 @@ export interface FakeContextOptions {
* reaches the network by accident.
*/
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
/**
* Makes ctx.auth.recordEnrollment refuse the way core's policy does (trusted
* proxy login on, password login off), with this message and a 409.
*/
refuseEnrollment?: string;
}
export interface FakeAuthRegistrations {
@@ -508,6 +513,12 @@ export function createFakeContext(
delete: async (key) => {
secretStore.delete(`${actor ?? ""}:${key}`);
},
seal: async (value) =>
`sealed:${Buffer.from(value, "utf8").toString("base64")}`,
unseal: async (sealed) =>
sealed?.startsWith("sealed:")
? Buffer.from(sealed.slice(7), "base64").toString("utf8")
: null,
offer: () => {},
withdraw: () => false,
getShared: async () => null,
@@ -782,6 +793,9 @@ export function createFakeContext(
auth.secondFactors.push(factor);
},
recordEnrollment: async (userId, factorId) => {
if (options.refuseEnrollment) {
throw new LoginMethodError(options.refuseEnrollment, 409);
}
auth.enrollments.add(`${userId}:${factorId}`);
},
removeEnrollment: async (userId, factorId) => {
@@ -927,6 +941,8 @@ export interface MockContextOptions {
* reaches the network by accident.
*/
fetch?: (url: string, init?: PluginFetchInit) => Promise<Response>;
/** See FakeContextOptions. */
refuseEnrollment?: string;
}
export interface MockPluginContext extends FakePluginContext {
@@ -969,6 +985,7 @@ export function createMockCtx(
hostStatuses: options.hostStatuses,
notificationChannels: options.notificationChannels,
fetch: options.fetch,
refuseEnrollment: options.refuseEnrollment,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -1271,6 +1288,14 @@ export function createMockCtx(
require("secrets:own");
return ctx.secrets.delete(key);
},
seal: async (value) => {
require("secrets:own");
return ctx.secrets.seal(value);
},
unseal: async (sealed) => {
require("secrets:own");
return ctx.secrets.unseal(sealed);
},
},
capabilities: {
@@ -1485,6 +1510,8 @@ export interface RenderedPluginApp {
settingsComponents: () => string[];
slot: (slotId: string) => string[];
actions: () => string[];
loginMethods: () => string[];
secondFactors: () => string[];
};
/** Shell calls made by the plugin's code, applyLayout included. */
shellCalls: ShellCall[];
@@ -1510,6 +1537,18 @@ export interface RenderedPluginApp {
componentId: string,
props?: Record<string, unknown>,
) => HTMLElement;
/** Renders a login method's UI with login screen props, overridable. */
renderLoginMethod: (
id: string,
props?: Record<string, unknown>,
) => HTMLElement;
/** Renders a second factor's challenge with overridable props. */
renderSecondFactor: (
id: string,
props?: Record<string, unknown>,
) => HTMLElement;
/** Renders the Settings > Security section a factor or method brought. */
renderEnrollment: (id: string) => HTMLElement;
/** Renders a slot the way its owner would. */
renderSlot: (slotId: string, props?: Record<string, unknown>) => HTMLElement;
/** Runs deactivate and every disposer, as disabling the plugin does. */
+42
View File
@@ -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,34 @@
{
"challenge": {
"enterCode": "Voer verifikasiekode in",
"verify": "Verifieer Kode"
},
"enrollment": {
"title": "TOTP-verifikasie",
"on": "2FA is geaktiveer",
"off": "Voeg ekstra aanmeldsekuriteit by",
"enable": "Aktiveer",
"disable": "Deaktiveer",
"addDevice": "Voeg toestel by",
"addTitle": "Voeg verifikasietoestel by",
"addScanInstructions": "Skandeer hierdie QR-kode met die bykomende verifikator. Bestaande verifikators sal aanhou werk.",
"addSuccess": "Bykomende outentiseerder ingeskryf",
"addFailed": "Kon nie verifieerder byvoeg nie",
"setupTitle": "Stel TOTP op",
"setupFailed": "Kon nie TOTP-opstelling begin nie",
"qrCode": "QR-kode",
"secretCopied": "Geheim gekopieer na knipbord",
"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,34 @@
{
"challenge": {
"enterCode": "أدخل رمز التحقق",
"verify": "التحقق من الرمز"
},
"enrollment": {
"title": "مُوثِّق TOTP",
"on": "التحقق بخطوتين ممكّن",
"off": "إضافة أمان إضافي لتسجيل الدخول",
"enable": "تمكين",
"disable": "تعطيل",
"addDevice": "إضافة جهاز",
"addTitle": "إضافة جهاز المصادقة",
"addScanInstructions": "امسح رمز الاستجابة السريعة هذا باستخدام أداة المصادقة الإضافية. ستستمر أدوات المصادقة الحالية في العمل.",
"addSuccess": "تم تسجيل مصادق إضافي",
"addFailed": "فشل إضافة المصادقة",
"setupTitle": "إعداد TOTP",
"setupFailed": "فشل بدء إعداد TOTP",
"qrCode": "رمز الاستجابة السريعة",
"secretCopied": "تم نسخ السر إلى الحافظة",
"continueVerify": "متابعة للتحقق",
"verifyTitle": "التحقق من الرمز",
"verify": "تحقق",
"enter6Digits": "أدخل رمزًا من 6 أرقام",
"enabledSuccess": "تم تمكين المصادقة الثنائية",
"invalidCode": "رمز غير صالح، يرجى المحاولة مرة أخرى",
"disableTitle": "تعطيل المصادقة الثنائية",
"disabledSuccess": "تم تعطيل المصادقة الثنائية",
"disableFailed": "فشل تعطيل المصادقة الثنائية",
"backupTitle": "رموز النسخ الاحتياطي",
"downloadBackup": "تنزيل رموز النسخ الاحتياطي",
"done": "تم"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "Въведете код за потвърждение",
"verify": "Код за проверка"
},
"enrollment": {
"title": "TOTP удостоверител",
"on": "2FA е активирана",
"off": "Добавете допълнителна сигурност за вход",
"enable": "Активиране",
"disable": "Деактивиране",
"addDevice": "Добавяне на устройство",
"addTitle": "Добавяне на устройство за удостоверяване",
"addScanInstructions": "Сканирайте този QR код с допълнителния автентификатор. Съществуващите автентификатори ще продължат да работят.",
"addSuccess": "Допълнителен регистриран удостоверител",
"addFailed": "Добавянето на удостоверител не бе успешно",
"setupTitle": "Настройка на TOTP",
"setupFailed": "Неуспешно стартиране на настройката на TOTP",
"qrCode": "QR код",
"secretCopied": "Тайната е копирана в клипборда",
"continueVerify": "Продължете с проверката",
"verifyTitle": "Код за проверка",
"verify": "Проверка",
"enter6Digits": "Въведете 6-цифрен код",
"enabledSuccess": "Двуфакторно удостоверяване е активирано",
"invalidCode": "Невалиден код, моля опитайте отново",
"disableTitle": "Деактивиране на 2FA",
"disabledSuccess": "Двуфакторното удостоверяване е деактивирано",
"disableFailed": "Деактивирането на 2FA не бе успешно",
"backupTitle": "Резервни кодове",
"downloadBackup": "Изтегляне на резервни кодове",
"done": "Готово"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "যাচাইকরণ কোড প্রবেশ করান",
"verify": "কোড যাচাই করুন"
},
"enrollment": {
"title": "TOTP প্রমাণীকরণকারী",
"on": "2FA সক্রিয় করা হয়েছে",
"off": "অতিরিক্ত লগইন নিরাপত্তা যোগ করুন",
"enable": "সক্ষম করুন",
"disable": "অক্ষম করুন",
"addDevice": "ডিভাইস যোগ করুন",
"addTitle": "প্রমাণীকরণ ডিভাইস যোগ করুন",
"addScanInstructions": "অতিরিক্ত অথেন্টিকেটর দিয়ে এই কিউআর কোডটি স্ক্যান করুন। বিদ্যমান অথেন্টিকেটরগুলো কাজ করতে থাকবে।",
"addSuccess": "অতিরিক্ত প্রমাণীকরণকারী নথিভুক্ত করা হয়েছে",
"addFailed": "প্রমাণীকরণকারী যোগ করতে ব্যর্থ হয়েছে",
"setupTitle": "TOTP সেটআপ করুন",
"setupFailed": "TOTP সেটআপ শুরু করতে ব্যর্থ হয়েছে",
"qrCode": "কিউআর কোড",
"secretCopied": "গোপনীয় তথ্য ক্লিপবোর্ডে কপি করা হয়েছে",
"continueVerify": "যাচাই চালিয়ে যান",
"verifyTitle": "কোড যাচাই করুন",
"verify": "যাচাই করুন",
"enter6Digits": "একটি ৬-সংখ্যার কোড প্রবেশ করান",
"enabledSuccess": "দ্বি-স্তর প্রমাণীকরণ সক্রিয় করা হয়েছে",
"invalidCode": "কোডটি ভুল, অনুগ্রহ করে আবার চেষ্টা করুন।",
"disableTitle": "2FA নিষ্ক্রিয় করুন",
"disabledSuccess": "দ্বি-স্তর প্রমাণীকরণ নিষ্ক্রিয় করা হয়েছে",
"disableFailed": "2FA নিষ্ক্রিয় করতে ব্যর্থ হয়েছে",
"backupTitle": "ব্যাকআপ কোড",
"downloadBackup": "ব্যাকআপ কোড ডাউনলোড করুন",
"done": "সম্পন্ন হয়েছে"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "Introduïu el codi de verificació",
"verify": "Verifica el codi"
},
"enrollment": {
"title": "Autenticador TOTP",
"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ó",
"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",
"setupTitle": "Configura TOTP",
"setupFailed": "No s'ha pogut iniciar la configuració del TOTP",
"qrCode": "Codi QR",
"secretCopied": "Secret copiat al porta-retalls",
"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,34 @@
{
"challenge": {
"enterCode": "Zadejte ověřovací kód",
"verify": "Ověřte kód"
},
"enrollment": {
"title": "Ověřovač TOTP",
"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í",
"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",
"setupTitle": "Nastavení TOTP",
"setupFailed": "Nepodařilo se spustit nastavení TOTP",
"qrCode": "QR kód",
"secretCopied": "Tajný kód zkopírován do schránky",
"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,34 @@
{
"challenge": {
"enterCode": "Indtast bekræftelseskode",
"verify": "Bekræft kode"
},
"enrollment": {
"title": "TOTP-godkendelse",
"on": "2FA er aktiveret",
"off": "Tilføj ekstra loginsikkerhed",
"enable": "Aktivér",
"disable": "Deaktiver",
"addDevice": "Tilføj enhed",
"addTitle": "Tilføj godkendelsesenhed",
"addScanInstructions": "Scan denne QR-kode med den ekstra godkendelsesfunktion. Eksisterende godkendelsesfunktioner vil fortsat virke.",
"addSuccess": "Yderligere godkendelsesfunktion tilmeldt",
"addFailed": "Kunne ikke tilføje godkendelse",
"setupTitle": "Opsæt TOTP",
"setupFailed": "Kunne ikke starte TOTP-opsætningen",
"qrCode": "QR-kode",
"secretCopied": "Hemmelighed kopieret til udklipsholder",
"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,34 @@
{
"challenge": {
"enterCode": "Bestätigungscode eingeben",
"verify": "Code überprüfen"
},
"enrollment": {
"title": "TOTP-Authentifikator",
"on": "2FA ist aktiviert",
"off": "Zusätzliche Anmeldesicherheit hinzufügen",
"enable": "Aktivieren",
"disable": "Deaktivieren",
"addDevice": "Gerät hinzufügen",
"addTitle": "Authentifizierungsgerät hinzufügen",
"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",
"setupTitle": "TOTP einrichten",
"setupFailed": "Fehler beim Starten der TOTP-Einrichtung",
"qrCode": "QR-Code",
"secretCopied": "Secret in die Zwischenablage kopiert",
"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,34 @@
{
"challenge": {
"enterCode": "Εισαγάγετε τον κωδικό επαλήθευσης",
"verify": "Επαλήθευση Κωδικού"
},
"enrollment": {
"title": "Επαληθευτής TOTP",
"on": "Το 2FA είναι ενεργοποιημένο",
"off": "Προσθέστε επιπλέον ασφάλεια σύνδεσης",
"enable": "Καθιστώ ικανό",
"disable": "Καθιστώ ανίκανο",
"addDevice": "Προσθήκη συσκευής",
"addTitle": "Προσθήκη συσκευής ελέγχου ταυτότητας",
"addScanInstructions": "Σαρώστε αυτόν τον κωδικό QR με τον πρόσθετο έλεγχο ταυτότητας. Οι υπάρχοντες έλεγχοι ταυτότητας θα συνεχίσουν να λειτουργούν.",
"addSuccess": "Εγγράφηκε επιπλέον υπεύθυνος ελέγχου ταυτότητας",
"addFailed": "Αποτυχία προσθήκης ελέγχου ταυτότητας",
"setupTitle": "Ρύθμιση TOTP",
"setupFailed": "Αποτυχία έναρξης ρύθμισης TOTP",
"qrCode": "Κωδικός QR",
"secretCopied": "Το μυστικό αντιγράφηκε στο πρόχειρο",
"continueVerify": "Συνέχεια για επαλήθευση",
"verifyTitle": "Επαλήθευση Κωδικού",
"verify": "Επαληθεύω",
"enter6Digits": "Εισαγάγετε έναν 6ψήφιο κωδικό",
"enabledSuccess": "Ενεργοποιήθηκε ο έλεγχος ταυτότητας δύο παραγόντων",
"invalidCode": "Μη έγκυρος κωδικός, δοκιμάστε ξανά",
"disableTitle": "Απενεργοποίηση 2FA",
"disabledSuccess": "Ο έλεγχος ταυτότητας δύο παραγόντων απενεργοποιήθηκε",
"disableFailed": "Αποτυχία απενεργοποίησης 2FA",
"backupTitle": "Κωδικοί αντιγράφων ασφαλείας",
"downloadBackup": "Λήψη αντιγράφων ασφαλείας κωδικών",
"done": "Γινώμενος"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "Introduzca el código de verificación",
"verify": "Verificar código"
},
"enrollment": {
"title": "Autenticador TOTP",
"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",
"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.",
"setupTitle": "Configurar TOTP",
"setupFailed": "No se pudo iniciar la configuración de TOTP",
"qrCode": "Código QR",
"secretCopied": "Secreto copiado al portapapeles",
"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,34 @@
{
"challenge": {
"enterCode": "Syötä vahvistuskoodi",
"verify": "Vahvista koodi"
},
"enrollment": {
"title": "TOTP-todentaja",
"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",
"addScanInstructions": "Skannaa tämä QR-koodi lisätodentajalla. Nykyiset todentajat toimivat edelleen.",
"addSuccess": "Lisätodentaja rekisteröity",
"addFailed": "Todentajan lisääminen epäonnistui",
"setupTitle": "TOTP:n määrittäminen",
"setupFailed": "TOTP-asennuksen aloittaminen epäonnistui",
"qrCode": "QR-koodi",
"secretCopied": "Salaisuus kopioitu leikepöydälle",
"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,34 @@
{
"challenge": {
"enterCode": "Saisir le code",
"verify": "Vérifier le code"
},
"enrollment": {
"title": "Authentificateur TOTP",
"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",
"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",
"setupTitle": "Configurer TOTP",
"setupFailed": "Échec du démarrage de la configuration TOTP",
"qrCode": "Code QR",
"secretCopied": "Secret copié dans le presse-papiers",
"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,34 @@
{
"challenge": {
"enterCode": "הזן קוד אימות",
"verify": "אימות קוד"
},
"enrollment": {
"title": "מאמת TOTP",
"on": "2FA מופעל",
"off": "הוסף אבטחת התחברות נוספת",
"enable": "לְאַפשֵׁר",
"disable": "השבת",
"addDevice": "הוסף מכשיר",
"addTitle": "הוסף מכשיר אימות",
"addScanInstructions": "סרוק את קוד ה-QR הזה עם המאמת הנוסף. המאמתים הקיימים ימשיכו לפעול.",
"addSuccess": "מאמת נוסף נרשם",
"addFailed": "נכשלה הוספת המאמת",
"setupTitle": "הגדר TOTP",
"setupFailed": "נכשל בהתחלת הגדרת TOTP",
"qrCode": "קוד QR",
"secretCopied": "סוד הועתק ללוח",
"continueVerify": "המשך לאימות",
"verifyTitle": "אימות קוד",
"verify": "לְאַמֵת",
"enter6Digits": "הזן קוד בן 6 ספרות",
"enabledSuccess": "אימות דו-שלבי מופעל",
"invalidCode": "קוד לא תקין, אנא נסה שוב",
"disableTitle": "השבת 2FA",
"disabledSuccess": "אימות דו-שלבי מושבת",
"disableFailed": "נכשל בהשבתת 2FA",
"backupTitle": "קודי גיבוי",
"downloadBackup": "הורד קודי גיבוי",
"done": "נַעֲשָׂה"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "सत्यापन कोड दर्ज करें",
"verify": "कोड सत्यापित करें"
},
"enrollment": {
"title": "TOTP प्रमाणक",
"on": "2FA सक्षम है",
"off": "अतिरिक्त लॉगिन सुरक्षा जोड़ें",
"enable": "सक्षम करें",
"disable": "अक्षम करें",
"addDevice": "डिवाइस जोडे",
"addTitle": "प्रमाणीकरण उपकरण जोड़ें",
"addScanInstructions": "इस क्यूआर कोड को अतिरिक्त प्रमाणीकरण उपकरण से स्कैन करें। मौजूदा प्रमाणीकरण उपकरण काम करते रहेंगे।",
"addSuccess": "अतिरिक्त प्रमाणीकरणकर्ता नामांकित किया गया",
"addFailed": "प्रमाणीकरणकर्ता जोड़ने में विफल",
"setupTitle": "TOTP सेटअप करें",
"setupFailed": "TOTP सेटअप शुरू करने में विफल",
"qrCode": "QR कोड",
"secretCopied": "सीक्रेट क्लिपबोर्ड पर कॉपी किया गया",
"continueVerify": "सत्यापन जारी रखें",
"verifyTitle": "कोड सत्यापित करें",
"verify": "सत्यापित करें",
"enter6Digits": "6-अंकीय कोड दर्ज करें",
"enabledSuccess": "दो-कारक प्रमाणीकरण सक्षम किया गया",
"invalidCode": "अमान्य कोड, कृपया पुनः प्रयास करें",
"disableTitle": "2FA अक्षम करें",
"disabledSuccess": "दो-कारक प्रमाणीकरण अक्षम किया गया",
"disableFailed": "2FA अक्षम करने में विफल",
"backupTitle": "बैकअप कोड",
"downloadBackup": "बैकअप कोड डाउनलोड करें",
"done": "पूर्ण"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "Írja be az ellenőrző kódot",
"verify": "Kód ellenőrzése"
},
"enrollment": {
"title": "TOTP hitelesítő",
"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",
"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",
"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",
"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,34 @@
{
"challenge": {
"enterCode": "Masukkan kode verifikasi",
"verify": "Verifikasi Kode"
},
"enrollment": {
"title": "Otentikator TOTP",
"on": "Otentikasi dua faktor (2FA) diaktifkan.",
"off": "Tambahkan keamanan login ekstra",
"enable": "Memungkinkan",
"disable": "Cacat",
"addDevice": "Tambahkan perangkat",
"addTitle": "Tambahkan perangkat otentikasi",
"addScanInstructions": "Pindai kode QR ini dengan otentikator tambahan. Otentikator yang sudah ada akan tetap berfungsi.",
"addSuccess": "Autentikator tambahan terdaftar",
"addFailed": "Gagal menambahkan autentikator",
"setupTitle": "Pengaturan TOTP",
"setupFailed": "Gagal memulai pengaturan TOTP",
"qrCode": "Kode QR",
"secretCopied": "Rahasia disalin ke papan klip",
"continueVerify": "Lanjutkan Verifikasi",
"verifyTitle": "Verifikasi Kode",
"verify": "Memeriksa",
"enter6Digits": "Masukkan kode 6 digit",
"enabledSuccess": "Autentikasi dua faktor diaktifkan",
"invalidCode": "Kode tidak valid, silakan coba lagi.",
"disableTitle": "Nonaktifkan otentikasi dua faktor (2FA).",
"disabledSuccess": "Autentikasi dua faktor dinonaktifkan",
"disableFailed": "Gagal menonaktifkan 2FA.",
"backupTitle": "Kode Cadangan",
"downloadBackup": "Unduh Kode Cadangan",
"done": "Selesai"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "Inserisci codice di verifica",
"verify": "Verifica codice"
},
"enrollment": {
"title": "Autenticatore TOTP",
"on": "2FA è attivata",
"off": "Aggiungi sicurezza aggiuntiva all'accesso",
"enable": "Abilita",
"disable": "Disabilita",
"addDevice": "Aggiungi dispositivo",
"addTitle": "Aggiungi dispositivo di autenticazione",
"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",
"setupTitle": "Configura TOTP",
"setupFailed": "Impossibile avviare la configurazione TOTP",
"qrCode": "Codice QR",
"secretCopied": "Segreto copiato negli appunti",
"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": "Fatto"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "認証コードを入力",
"verify": "コードを確認"
},
"enrollment": {
"title": "TOTP認証",
"on": "2FAが有効です",
"off": "ログインのセキュリティを強化",
"enable": "有効にする",
"disable": "無効にする",
"addDevice": "デバイスを追加する",
"addTitle": "認証デバイスを追加する",
"addScanInstructions": "追加の認証デバイスでこのQRコードをスキャンしてください。既存の認証デバイスは引き続きご利用いただけます。",
"addSuccess": "追加の認証デバイスを登録しました",
"addFailed": "認証システムの追加に失敗しました",
"setupTitle": "TOTPを設定",
"setupFailed": "TOTPのセットアップを開始できませんでした",
"qrCode": "QRコード",
"secretCopied": "シークレットをクリップボードにコピーしました",
"continueVerify": "確認に進む",
"verifyTitle": "コードの確認",
"verify": "確認",
"enter6Digits": "6桁のコードを入力してください",
"enabledSuccess": "二要素認証が有効になりました",
"invalidCode": "コードが無効です。もう一度お試しください",
"disableTitle": "2FAを無効にする",
"disabledSuccess": "二要素認証が無効になりました",
"disableFailed": "2FAの無効化に失敗しました",
"backupTitle": "バックアップコード",
"downloadBackup": "バックアップコードをダウンロード",
"done": "完了"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "인증 코드 입력",
"verify": "코드 확인"
},
"enrollment": {
"title": "TOTP 인증기",
"on": "2FA가 활성화되었습니다",
"off": "추가 로그인 보안을 설정하세요",
"enable": "활성화",
"disable": "비활성화",
"addDevice": "기기 추가",
"addTitle": "인증 장치 추가",
"addScanInstructions": "추가 인증 수단으로 이 QR 코드를 스캔하세요. 기존 인증 수단도 계속 사용할 수 있습니다.",
"addSuccess": "추가 인증자가 등록되었습니다",
"addFailed": "인증자 추가에 실패했습니다.",
"setupTitle": "TOTP 설정",
"setupFailed": "TOTP 설정 시작 실패",
"qrCode": "QR 코드",
"secretCopied": "시크릿이 클립보드에 복사됨",
"continueVerify": "인증 계속하기",
"verifyTitle": "코드 확인",
"verify": "확인",
"enter6Digits": "6자리 코드 입력",
"enabledSuccess": "2단계 인증이 활성화되었습니다.",
"invalidCode": "유효하지 않은 코드입니다. 다시 시도하세요.",
"disableTitle": "2FA 비활성화",
"disabledSuccess": "2단계 인증이 비활성화되었습니다.",
"disableFailed": "2FA 비활성화 실패",
"backupTitle": "백업 코드",
"downloadBackup": "백업 코드 다운로드",
"done": "완료"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "Voer de verificatiecode in.",
"verify": "Code verifiëren"
},
"enrollment": {
"title": "TOTP-authenticator",
"on": "Tweefactorauthenticatie (2FA) is ingeschakeld.",
"off": "Voeg extra inlogbeveiliging toe.",
"enable": "Inschakelen",
"disable": "Uitzetten",
"addDevice": "Apparaat toevoegen",
"addTitle": "Authenticatorapparaat toevoegen",
"addScanInstructions": "Scan deze QR-code met de extra authenticatie-app. Bestaande authenticatie-apps blijven gewoon werken.",
"addSuccess": "Extra authenticatie-app geregistreerd",
"addFailed": "Authenticator toevoegen mislukt",
"setupTitle": "TOTP instellen",
"setupFailed": "Het starten van de TOTP-configuratie is mislukt.",
"qrCode": "QR-code",
"secretCopied": "Geheim gekopieerd naar het klembord",
"continueVerify": "Doorgaan met verifiëren",
"verifyTitle": "Code verifiëren",
"verify": "Verifiëren",
"enter6Digits": "Voer een 6-cijferige code in",
"enabledSuccess": "Tweefactorauthenticatie ingeschakeld",
"invalidCode": "Ongeldige code, probeer het opnieuw.",
"disableTitle": "Schakel 2FA uit",
"disabledSuccess": "Tweefactorauthenticatie uitgeschakeld",
"disableFailed": "Het uitschakelen van 2FA is mislukt.",
"backupTitle": "Back-upcodes",
"downloadBackup": "Back-upcodes downloaden",
"done": "Klaar"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "Skriv inn bekreftelseskoden",
"verify": "Bekreft kode"
},
"enrollment": {
"title": "TOTP-autentisering",
"on": "2FA er aktivert",
"off": "Legg til ekstra sikkerhet for pålogging",
"enable": "Aktiver",
"disable": "Deaktiver",
"addDevice": "Legg til enhet",
"addTitle": "Legg til autentiseringsenhet",
"addScanInstructions": "Skann denne QR-koden med den ekstra autentiseringsfunksjonen. Eksisterende autentiseringsfunksjoner vil fortsette å fungere.",
"addSuccess": "Ekstra autentiseringsfunksjon registrert",
"addFailed": "Kunne ikke legge til autentiseringsfunksjonen",
"setupTitle": "Konfigurer TOTP",
"setupFailed": "Kunne ikke starte TOTP-oppsettet",
"qrCode": "QR-kode",
"secretCopied": "Hemmelighet kopiert til utklippstavlen",
"continueVerify": "Fortsett å bekrefte",
"verifyTitle": "Bekreft kode",
"verify": "Verifisere",
"enter6Digits": "Skriv inn en 6-sifret kode",
"enabledSuccess": "Tofaktorautentisering aktivert",
"invalidCode": "Ugyldig kode, prøv på nytt",
"disableTitle": "Deaktiver 2FA",
"disabledSuccess": "Tofaktorautentisering deaktivert",
"disableFailed": "Kunne ikke deaktivere 2FA",
"backupTitle": "Sikkerhetskopieringskoder",
"downloadBackup": "Last ned sikkerhetskoder",
"done": "Ferdig"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "Wprowadź kod weryfikacyjny",
"verify": "Zweryfikuj kod"
},
"enrollment": {
"title": "Aplikacja TOTP",
"on": "Włączono uwierzytelnianie dwuskładnikowe",
"off": "Dodaj dodatkowe zabezpieczenia logowania",
"enable": "Włącz",
"disable": "Wyłącz",
"addDevice": "Dodaj urządzenie",
"addTitle": "Dodaj urządzenie uwierzytelniające",
"addScanInstructions": "Zeskanuj ten kod QR za pomocą dodatkowego uwierzytelniacza. Istniejące uwierzytelniacze będą nadal działać.",
"addSuccess": "Zarejestrowano dodatkowego uwierzytelniacza",
"addFailed": "Nie udało się dodać uwierzytelniacza",
"setupTitle": "Skonfiguruj TOTP",
"setupFailed": "Nie udało się uruchomić konfiguracji TOTP",
"qrCode": "Kod QR",
"secretCopied": "Skopiowano sekret do schowka",
"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,34 @@
{
"challenge": {
"enterCode": "Insira o código de verificação",
"verify": "Código de verificação"
},
"enrollment": {
"title": "Autenticador TOTP",
"on": "A autenticação de dois fatores está ativada.",
"off": "Adicione segurança extra ao login.",
"enable": "Habilitar",
"disable": "Desativar",
"addDevice": "Adicionar dispositivo",
"addTitle": "Adicionar dispositivo autenticador",
"addScanInstructions": "Escaneie este código QR com o autenticador adicional. Os autenticadores existentes continuarão funcionando.",
"addSuccess": "Autenticador adicional cadastrado",
"addFailed": "Falha ao adicionar autenticador",
"setupTitle": "Configurar TOTP",
"setupFailed": "Falha ao iniciar a configuração TOTP",
"qrCode": "Código QR",
"secretCopied": "Segredo copiado para a área de transferência",
"continueVerify": "Continuar a verificar",
"verifyTitle": "Código de verificação",
"verify": "Verificar",
"enter6Digits": "Digite um código de 6 dígitos",
"enabledSuccess": "Autenticação de dois fatores ativada",
"invalidCode": "Código inválido, tente novamente.",
"disableTitle": "Desative a autenticação de dois fatores (2FA).",
"disabledSuccess": "Autenticação de dois fatores desativada",
"disableFailed": "Falha ao desativar a autenticação de dois fatores (2FA).",
"backupTitle": "Códigos de backup",
"downloadBackup": "Baixar códigos de backup",
"done": "Feito"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "Introduza o código de verificação",
"verify": "Verificar código"
},
"enrollment": {
"title": "Autenticador TOTP",
"on": "2FA está ativada",
"off": "Adicionar segurança extra de início de sessão",
"enable": "Ativar",
"disable": "Desativar",
"addDevice": "Adicionar dispositivo",
"addTitle": "Adicionar dispositivo autenticador",
"addScanInstructions": "Digitalize este código QR com o autenticador adicional. Os autenticadores existentes continuarão a funcionar.",
"addSuccess": "Autenticador adicional registado",
"addFailed": "Falha ao adicionar autenticador",
"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",
"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": "Concluído"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "Introduceți codul de verificare",
"verify": "Verificați codul"
},
"enrollment": {
"title": "Autentificator TOTP",
"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",
"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",
"setupTitle": "Configurați TOTP",
"setupFailed": "Nu s-a putut porni configurarea TOTP",
"qrCode": "Cod QR",
"secretCopied": "Secret copiat în clipboard",
"continueVerify": "Continuați verificarea",
"verifyTitle": "Verificați codul",
"verify": "Verifica",
"enter6Digits": "Introduceți un cod de 6 cifre",
"enabledSuccess": "Autentificarea 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,34 @@
{
"challenge": {
"enterCode": "Введите проверочный код",
"verify": "Проверить код"
},
"enrollment": {
"title": "Аутентификатор TOTP",
"on": "2FA включена",
"off": "Добавить дополнительную защиту",
"enable": "Включить",
"disable": "Отключить",
"addDevice": "Добавить устройство",
"addTitle": "Добавить устройство аутентификации",
"addScanInstructions": "Отсканируйте этот QR-код с помощью дополнительного аутентификатора. Существующие аутентификаторы продолжат работать.",
"addSuccess": "Зарегистрирован дополнительный аутентификатор.",
"addFailed": "Не удалось добавить аутентификатор.",
"setupTitle": "Настроить TOTP",
"setupFailed": "Не удалось запустить настройку TOTP",
"qrCode": "QR-код",
"secretCopied": "Секрет скопирован в буфер обмена",
"continueVerify": "Продолжить проверку",
"verifyTitle": "Проверка кода",
"verify": "Проверить",
"enter6Digits": "Введите 6-значный код",
"enabledSuccess": "Двухфакторная аутентификация включена",
"invalidCode": "Неверный код, попробуйте снова",
"disableTitle": "Отключить 2FA",
"disabledSuccess": "Двухфакторная аутентификация отключена",
"disableFailed": "Не удалось отключить 2FA",
"backupTitle": "Резервные коды",
"downloadBackup": "Скачать резервные коды",
"done": "Готово"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "Унесите верификациони код",
"verify": "Верификујте код"
},
"enrollment": {
"title": "TOTP аутентификатор",
"on": "2FA је омогућена",
"off": "Додајте додатну безбедност пријављивања",
"enable": "Омогући",
"disable": "Онемогући",
"addDevice": "Додај уређај",
"addTitle": "Додај уређај за аутентификовање",
"addScanInstructions": "Скенирајте овај QR код додатним аутентификатором. Постојећи аутентификатори ће наставити да раде.",
"addSuccess": "Додатни аутентификатор је регистрован",
"addFailed": "Додавање аутентификатора није успело",
"setupTitle": "Подешавање TOTP-а",
"setupFailed": "Покретање подешавања TOTP-а није успело",
"qrCode": "QR код",
"secretCopied": "Тајни токен је копиран у међуспремник",
"continueVerify": "Настави са верификацијом",
"verifyTitle": "Верификујте код",
"verify": "Верификуј",
"enter6Digits": "Унесите шестоцифрени код",
"enabledSuccess": "Омогућена је двофакторска аутентификација",
"invalidCode": "Неважећи код, покушајте поново",
"disableTitle": "Онемогући 2FA",
"disabledSuccess": "Двофакторска аутентификација је онемогућена",
"disableFailed": "Онемогућавање 2FA није успело",
"backupTitle": "Резервни кодови",
"downloadBackup": "Преузмите резервне кодове",
"done": "Готово"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "Ange verifieringskod",
"verify": "Verifiera kod"
},
"enrollment": {
"title": "TOTP-autentiserare",
"on": "2FA är aktiverat",
"off": "Lägg till extra inloggningssäkerhet",
"enable": "Aktivera",
"disable": "Inaktivera",
"addDevice": "Lägg till enhet",
"addTitle": "Lägg till autentiseringsenhet",
"addScanInstructions": "Skanna den här QR-koden med den extra autentiseringsfunktionen. Befintliga autentiseringsfunktioner kommer att fortsätta fungera.",
"addSuccess": "Ytterligare autentiserare registrerad",
"addFailed": "Misslyckades med att lägga till autentiserare",
"setupTitle": "Konfigurera TOTP",
"setupFailed": "Misslyckades med att starta TOTP-installationen",
"qrCode": "QR-kod",
"secretCopied": "Hemlighet kopierad till urklipp",
"continueVerify": "Fortsätt att verifiera",
"verifyTitle": "Verifiera kod",
"verify": "Kontrollera",
"enter6Digits": "Ange en 6-siffrig kod",
"enabledSuccess": "Tvåfaktorsautentisering aktiverad",
"invalidCode": "Ogiltig kod, försök igen",
"disableTitle": "Inaktivera 2FA",
"disabledSuccess": "Tvåfaktorsautentisering inaktiverad",
"disableFailed": "Misslyckades med att inaktivera 2FA",
"backupTitle": "Reservkoder",
"downloadBackup": "Ladda ner reservkoder",
"done": "Gjort"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "ป้อนรหัสยืนยัน",
"verify": "ยืนยันรหัส"
},
"enrollment": {
"title": "ตัวตรวจสอบสิทธิ์ TOTP",
"on": "เปิดใช้งาน 2FA แล้ว",
"off": "เพิ่มระบบรักษาความปลอดภัยในการเข้าสู่ระบบเพิ่มเติม",
"enable": "เปิดใช้งาน",
"disable": "ปิดใช้งาน",
"addDevice": "เพิ่มอุปกรณ์",
"addTitle": "เพิ่มอุปกรณ์ยืนยันตัวตน",
"addScanInstructions": "สแกนคิวอาร์โค้ดนี้ด้วยอุปกรณ์ยืนยันตัวตนเพิ่มเติม อุปกรณ์ยืนยันตัวตนที่มีอยู่เดิมยังคงใช้งานได้ต่อไป",
"addSuccess": "ลงทะเบียนตัวยืนยันตัวตนเพิ่มเติมแล้ว",
"addFailed": "ไม่สามารถเพิ่มตัวตรวจสอบสิทธิ์ได้",
"setupTitle": "ตั้งค่า TOTP",
"setupFailed": "ไม่สามารถเริ่มการตั้งค่า TOTP ได้",
"qrCode": "รหัส QR",
"secretCopied": "คัดลอกข้อมูลลับไปยังคลิปบอร์ดแล้ว",
"continueVerify": "ดำเนินการตรวจสอบต่อไป",
"verifyTitle": "ยืนยันรหัส",
"verify": "ตรวจสอบ",
"enter6Digits": "ป้อนรหัส 6 หลัก",
"enabledSuccess": "เปิดใช้งานการตรวจสอบสิทธิ์แบบสองขั้นตอน",
"invalidCode": "รหัสไม่ถูกต้อง โปรดลองอีกครั้ง",
"disableTitle": "ปิดใช้งาน 2FA",
"disabledSuccess": "ปิดใช้งานการตรวจสอบสิทธิ์แบบสองขั้นตอน",
"disableFailed": "ไม่สามารถปิดใช้งาน 2FA ได้",
"backupTitle": "รหัสสำรอง",
"downloadBackup": "ดาวน์โหลดรหัสสำรอง",
"done": "เสร็จแล้ว"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "Doğrulama kodunu girin",
"verify": "Kodu Doğrula"
},
"enrollment": {
"title": "TOTP Kimlik Doğrulayıcı",
"on": "2FA etkin",
"off": "Ek giriş güvenliği ekleyin",
"enable": "Etkinleştir",
"disable": "Devre Dışı Bırak",
"addDevice": "Cihaz ekle",
"addTitle": "Kimlik doğrulama cihazı ekle",
"addScanInstructions": "Bu QR kodunu ek kimlik doğrulayıcı ile tarayın. Mevcut kimlik doğrulayıcılar çalışmaya devam edecektir.",
"addSuccess": "Ek kimlik doğrulayıcı kaydedildi.",
"addFailed": "Kimlik doğrulayıcı eklenemedi.",
"setupTitle": "TOTP Kurulumu",
"setupFailed": "TOTP kurulumu başlatılamadı",
"qrCode": "QR Kodu",
"secretCopied": "Gizli anahtar panoya kopyalandı",
"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,34 @@
{
"challenge": {
"enterCode": "Введіть код підтвердження",
"verify": "Підтвердити код"
},
"enrollment": {
"title": "Аутентифікатор TOTP",
"on": "Двофакторна аутентифікація (2FA) увімкнена",
"off": "Додайте додаткову безпеку входу",
"enable": "Увімкнути",
"disable": "Вимкнути",
"addDevice": "Додати пристрій",
"addTitle": "Додати пристрій автентифікатора",
"addScanInstructions": "Відскануйте цей QR-код за допомогою додаткового автентифікатора. Існуючі автентифікатори продовжуватимуть працювати.",
"addSuccess": "Зареєстровано додатковий автентифікатор",
"addFailed": "Не вдалося додати автентифікатор",
"setupTitle": "Налаштування TOTP",
"setupFailed": "Не вдалося розпочати налаштування TOTP",
"qrCode": "QR-код",
"secretCopied": "Секрет скопійовано в буфер обміну",
"continueVerify": "Продовжити перевірку",
"verifyTitle": "Підтвердити код",
"verify": "Перевірити",
"enter6Digits": "Введіть 6-значний код",
"enabledSuccess": "Двофакторна автентифікація ввімкнена",
"invalidCode": "Недійсний код, спробуйте ще раз",
"disableTitle": "Вимкнути 2FA",
"disabledSuccess": "Двофакторну автентифікацію вимкнено",
"disableFailed": "Не вдалося вимкнути 2FA",
"backupTitle": "Резервні коди",
"downloadBackup": "Завантажити резервні коди",
"done": "Готово"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "Nhập mã xác minh",
"verify": "Xác minh mã"
},
"enrollment": {
"title": "Trình xác thực TOTP",
"on": "2FA đã được bật",
"off": "Thêm bảo mật đăng nhập bổ sung",
"enable": "Bật",
"disable": "Tắt",
"addDevice": "Thêm thiết bị",
"addTitle": "Thêm thiết bị xác thực",
"addScanInstructions": "Quét mã QR này bằng thiết bị xác thực bổ sung. Các thiết bị xác thực hiện có vẫn sẽ hoạt động bình thường.",
"addSuccess": "Đã đăng ký thêm trình xác thực.",
"addFailed": "Thêm trình xác thực không thành công",
"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",
"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,34 @@
{
"challenge": {
"enterCode": "输入验证码",
"verify": "验证"
},
"enrollment": {
"title": "TOTP 身份验证器",
"on": "双因素认证已启用",
"off": "添加额外的登录安全",
"enable": "启用",
"disable": "禁用",
"addDevice": "添加设备",
"addTitle": "添加身份验证器设备",
"addScanInstructions": "请使用新增的验证器扫描此二维码。现有验证器将继续有效。",
"addSuccess": "已注册其他身份验证器",
"addFailed": "添加身份验证器失败",
"setupTitle": "设置 TOTP",
"setupFailed": "启动 TOTP 设置失败",
"qrCode": "二维码",
"secretCopied": "密钥已复制到剪贴板",
"continueVerify": "继续验证",
"verifyTitle": "验证验证码",
"verify": "验证",
"enter6Digits": "输入 6 位验证码",
"enabledSuccess": "双因素认证已启用",
"invalidCode": "验证码无效,请重试",
"disableTitle": "禁用 2FA",
"disabledSuccess": "双因素认证已禁用",
"disableFailed": "禁用 2FA 失败",
"backupTitle": "备份验证码",
"downloadBackup": "下载备份验证码",
"done": "完成"
}
}
@@ -0,0 +1,34 @@
{
"challenge": {
"enterCode": "輸入驗證碼",
"verify": "驗證代碼"
},
"enrollment": {
"title": "TOTP 驗證器",
"on": "雙因素驗證已啟用",
"off": "新增額外的登入安全性",
"enable": "啟用",
"disable": "停用",
"addDevice": "新增設備",
"addTitle": "新增身份驗證器設備",
"addScanInstructions": "請使用新增的驗證器掃描此二維碼。現有驗證器將繼續有效。",
"addSuccess": "已註冊其他身份驗證器",
"addFailed": "新增身份驗證器失敗",
"setupTitle": "設定 TOTP",
"setupFailed": "無法啟動 TOTP 設定",
"qrCode": "QR 碼",
"secretCopied": "密鑰已複製到剪貼簿",
"continueVerify": "繼續驗證",
"verifyTitle": "驗證碼",
"verify": "驗證",
"enter6Digits": "輸入 6 位數驗證碼",
"enabledSuccess": "雙因素驗證已啟用",
"invalidCode": "驗證碼無效,請再試一次",
"disableTitle": "停用雙因素驗證",
"disabledSuccess": "雙因素驗證已停用",
"disableFailed": "無法停用雙因素驗證",
"backupTitle": "備用驗證碼",
"downloadBackup": "下載備用驗證碼",
"done": "完成"
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"id": "totp",
"name": "TOTP",
"version": "1.0.0",
"description": "Ask for a code from an authenticator app after signing in, with backup codes for when the phone is not at hand.",
"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"
],
"contributes": {
"auth": {
"secondFactors": ["totp"]
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -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
);
+35
View File
@@ -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
);
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@termix-plugin/totp",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"dependencies": {
"qrcode": "^1.5.4",
"speakeasy": "^2.0.0"
},
"devDependencies": {
"@termix/plugin-sdk": "*",
"@types/qrcode": "^1.5.6",
"@types/speakeasy": "^2.0.10"
}
}
+41
View File
@@ -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.
}
+69
View File
@@ -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();
},
};
}
+268
View File
@@ -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);
}
});
}
+24
View File
@@ -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];
+136
View File
@@ -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>
);
}
+12
View File
@@ -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,
});
}
+17
View File
@@ -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;
}
+95
View File
@@ -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();
},
};
}
+258
View File
@@ -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);
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
+22
View File
@@ -0,0 +1,22 @@
{
"title": "Passkeys",
"signIn": "Sign in with passkey",
"loginFailed": "Passkey login failed",
"description": "Use WebAuthn/FIDO2 credentials to sign in without a password",
"namePlaceholder": "Passkey name",
"defaultName": "Passkey",
"uv": {
"preferred": "Preferred",
"required": "Required",
"discouraged": "Discouraged"
},
"add": "Add Passkey",
"none": "No passkeys registered.",
"added": "Passkey added",
"addFailed": "Failed to add passkey",
"delete": "Delete passkey",
"deleted": "Passkey deleted",
"deleteFailed": "Failed to delete passkey",
"unknownDevice": "unknown",
"synced": "synced"
}
@@ -0,0 +1,18 @@
{
"title": "Wagwoorde",
"signIn": "Teken aan met wagwoord",
"loginFailed": "Wagwoord-aanmelding het misluk",
"description": "Gebruik WebAuthn/FIDO2-bewyse om aan te meld sonder 'n wagwoord",
"namePlaceholder": "Wagwoordnaam",
"uv": {
"preferred": "Verkies",
"required": "Vereis",
"discouraged": "Ontmoedig"
},
"add": "Voeg wagwoord by",
"none": "Geen wagwoorde geregistreer nie.",
"added": "Wagwoord bygevoeg",
"addFailed": "Kon nie wagwoord byvoeg nie",
"deleted": "Wagwoord verwyder",
"deleteFailed": "Kon nie wagwoord verwyder nie"
}
@@ -0,0 +1,18 @@
{
"title": "مفاتيح المرور",
"signIn": "تسجيل الدخول باستخدام مفتاح المرور",
"loginFailed": "فشل تسجيل الدخول بمفتاح المرور",
"description": "استخدم بيانات اعتماد WebAuthn/FIDO2 لتسجيل الدخول بدون كلمة مرور",
"namePlaceholder": "اسم مفتاح المرور",
"uv": {
"preferred": "مفضل",
"required": "مطلوب",
"discouraged": "غير مشجع"
},
"add": "إضافة مفتاح مرور",
"none": "لا توجد مفاتيح مرور مسجلة.",
"added": "تمت إضافة مفتاح المرور",
"addFailed": "فشل إضافة مفتاح المرور",
"deleted": "تم حذف مفتاح المرور",
"deleteFailed": "فشل حذف مفتاح المرور"
}
@@ -0,0 +1,18 @@
{
"title": "Пароли",
"signIn": "Влезте с парола",
"loginFailed": "Влизането с парола не бе успешно",
"description": "Използвайте WebAuthn/FIDO2 идентификационни данни за влизане без парола",
"namePlaceholder": "Име на паролата",
"uv": {
"preferred": "Предпочитан",
"required": "Задължително",
"discouraged": "Обезкуражен"
},
"add": "Добавяне на парола",
"none": "Няма регистрирани пароли.",
"added": "Добавен е ключ за достъп",
"addFailed": "Добавянето на ключ за достъп не бе успешно",
"deleted": "Ключът за достъп е изтрит",
"deleteFailed": "Изтриването на ключа за достъп не бе успешно"
}
@@ -0,0 +1,18 @@
{
"title": "পাসকি",
"signIn": "পাসকি দিয়ে সাইন ইন করুন",
"loginFailed": "পাসকি লগইন ব্যর্থ হয়েছে",
"description": "পাসওয়ার্ড ছাড়া সাইন ইন করতে WebAuthn/FIDO2 ক্রেডেনশিয়াল ব্যবহার করুন।",
"namePlaceholder": "পাসকি নাম",
"uv": {
"preferred": "পছন্দের",
"required": "প্রয়োজনীয়",
"discouraged": "হতাশ"
},
"add": "পাসকি যোগ করুন",
"none": "কোনো পাসকি নিবন্ধিত নেই।",
"added": "পাসকি যোগ করা হয়েছে",
"addFailed": "পাসকি যোগ করতে ব্যর্থ হয়েছে",
"deleted": "পাসকি মুছে ফেলা হয়েছে",
"deleteFailed": "পাসকি মুছে ফেলতে ব্যর্থ হয়েছে"
}
@@ -0,0 +1,18 @@
{
"title": "Claus d'accés",
"signIn": "Inicia la sessió amb la clau d'accés",
"loginFailed": "Error d'inici de sessió amb contrasenya",
"description": "Utilitzeu les credencials de WebAuthn/FIDO2 per iniciar la sessió sense contrasenya",
"namePlaceholder": "Nom de la clau d'accés",
"uv": {
"preferred": "Preferit",
"required": "Obligatori",
"discouraged": "Desanimat"
},
"add": "Afegeix una contrasenya",
"none": "No hi ha cap clau d'accés registrada.",
"added": "Clau d'accés afegida",
"addFailed": "No s'ha pogut afegir la contrasenya",
"deleted": "Clau d'accés suprimida",
"deleteFailed": "No s'ha pogut suprimir la clau d'accés"
}
@@ -0,0 +1,18 @@
{
"title": "Přístupové klíče",
"signIn": "Přihlásit se pomocí hesla",
"loginFailed": "Přihlášení k heslu se nezdařilo",
"description": "Pro přihlášení bez hesla použijte přihlašovací údaje WebAuthn/FIDO2",
"namePlaceholder": "Název přístupového klíče",
"uv": {
"preferred": "Preferované",
"required": "Požadovaný",
"discouraged": "Odradit"
},
"add": "Přidat přístupový klíč",
"none": "Nejsou registrovány žádné přístupové klíče.",
"added": "Přístupový klíč byl přidán",
"addFailed": "Nepodařilo se přidat přístupový klíč",
"deleted": "Přístupový klíč smazán",
"deleteFailed": "Nepodařilo se smazat přístupový klíč"
}
@@ -0,0 +1,18 @@
{
"title": "Adgangsnøgler",
"signIn": "Log ind med adgangsnøgle",
"loginFailed": "Login med adgangsnøgle mislykkedes",
"description": "Brug WebAuthn/FIDO2-legitimationsoplysninger til at logge ind uden en adgangskode",
"namePlaceholder": "Adgangsnøglenavn",
"uv": {
"preferred": "Foretrukket",
"required": "Påkrævet",
"discouraged": "Modløs"
},
"add": "Tilføj adgangsnøgle",
"none": "Ingen adgangsnøgler registreret.",
"added": "Adgangsnøgle tilføjet",
"addFailed": "Kunne ikke tilføje adgangsnøgle",
"deleted": "Adgangsnøgle slettet",
"deleteFailed": "Kunne ikke slette adgangsnøglen"
}
@@ -0,0 +1,18 @@
{
"title": "Passkeys",
"signIn": "Mit Passkey anmelden",
"loginFailed": "Passkey-Anmeldung fehlgeschlagen",
"description": "WebAuthn/FIDO2-Anmeldedaten für eine Anmeldung ohne Passwort verwenden",
"namePlaceholder": "Passkey-Name",
"uv": {
"preferred": "Bevorzugt",
"required": "Erforderlich",
"discouraged": "Nicht empfohlen"
},
"add": "Passkey hinzufügen",
"none": "Keine Passkeys registriert.",
"added": "Passkey hinzugefügt",
"addFailed": "Fehler beim Hinzufügen des Passkeys",
"deleted": "Passkey gelöscht",
"deleteFailed": "Löschen des Passkeys fehlgeschlagen"
}
@@ -0,0 +1,18 @@
{
"title": "Κλειδιά πρόσβασης",
"signIn": "Σύνδεση με κλειδί πρόσβασης",
"loginFailed": "Η σύνδεση με κωδικό πρόσβασης απέτυχε",
"description": "Χρησιμοποιήστε διαπιστευτήρια WebAuthn/FIDO2 για να συνδεθείτε χωρίς κωδικό πρόσβασης",
"namePlaceholder": "Όνομα κλειδιού πρόσβασης",
"uv": {
"preferred": "Προνομιούχος",
"required": "Υποχρεούμαι",
"discouraged": "Αποθαρρυμένος"
},
"add": "Προσθήκη κλειδιού πρόσβασης",
"none": "Δεν έχουν καταχωρηθεί κλειδιά πρόσβασης.",
"added": "Προστέθηκε κλειδί πρόσβασης",
"addFailed": "Αποτυχία προσθήκης κλειδιού πρόσβασης",
"deleted": "Ο κωδικός πρόσβασης διαγράφηκε",
"deleteFailed": "Η διαγραφή του κλειδιού πρόσβασης απέτυχε"
}
@@ -0,0 +1,18 @@
{
"title": "Passkeys",
"signIn": "Iniciar sesión con clave de acceso",
"loginFailed": "Error al iniciar sesión con clave de acceso",
"description": "Usa credenciales WebAuthn/FIDO2 para iniciar sesión sin contraseña",
"namePlaceholder": "Nombre de la passkey",
"uv": {
"preferred": "Preferida",
"required": "Requerida",
"discouraged": "Desaconsejada"
},
"add": "Añadir passkey",
"none": "No hay passkeys registradas.",
"added": "Passkey añadida",
"addFailed": "No se pudo añadir la passkey",
"deleted": "Passkey eliminada",
"deleteFailed": "Error al eliminar la clave de acceso"
}
@@ -0,0 +1,18 @@
{
"title": "Salasanat",
"signIn": "Kirjaudu sisään salasanalla",
"loginFailed": "Salasanan kirjautuminen epäonnistui",
"description": "Kirjaudu sisään ilman salasanaa WebAuthn/FIDO2-tunnuksilla",
"namePlaceholder": "Salasanan nimi",
"uv": {
"preferred": "Ensisijainen",
"required": "Pakollinen",
"discouraged": "Lannistunut"
},
"add": "Lisää salasana",
"none": "Ei rekisteröityjä salasanoja.",
"added": "Salasana lisätty",
"addFailed": "Salasanan lisääminen epäonnistui",
"deleted": "Salasana poistettu",
"deleteFailed": "Salasanan poistaminen epäonnistui"
}
@@ -0,0 +1,18 @@
{
"title": "Clés d’accès",
"signIn": "Se connecter avec une clé d'accès",
"loginFailed": "Échec de la connexion par clé d'accès",
"description": "Utilisez des identifiants WebAuthn/FIDO2 pour vous connecter sans mot de passe",
"namePlaceholder": "Nom de la clé d’accès",
"uv": {
"preferred": "Préféré",
"required": "Requis",
"discouraged": "Déconseillé"
},
"add": "Ajouter une clé d’accès",
"none": "Aucune clé d’accès enregistrée.",
"added": "Clé d’accès ajoutée",
"addFailed": "Échec de l’ajout de la clé d’accès",
"deleted": "Clé d’accès supprimée",
"deleteFailed": "Échec de la suppression de la clé d'accès"
}
@@ -0,0 +1,18 @@
{
"title": "מפתחות סיסמה",
"signIn": "כניסה עם סיסמה",
"loginFailed": "כניסה עם סיסמה נכשלה",
"description": "השתמש בפרטי הכניסה של WebAuthn/FIDO2 ללא סיסמה",
"namePlaceholder": "שם המפתח",
"uv": {
"preferred": "מועדף",
"required": "דָרוּשׁ",
"discouraged": "מיואש"
},
"add": "הוסף סיסמה",
"none": "אין סיסמות רשומות.",
"added": "סיסמה נוספת",
"addFailed": "נכשלה הוספת סיסמה",
"deleted": "סיסמת הגישה נמחקה",
"deleteFailed": "מחיקת המפתח נכשלה"
}
@@ -0,0 +1,18 @@
{
"title": "पासकीज़",
"signIn": "पासकी से साइन इन करें",
"loginFailed": "पासकी लॉगिन विफल",
"description": "बिना पासवर्ड साइन इन करने के लिए WebAuthn/FIDO2 क्रेडेंशियल का उपयोग करें",
"namePlaceholder": "पासकी नाम",
"uv": {
"preferred": "पसंदीदा",
"required": "आवश्यक",
"discouraged": "हतोत्साहित"
},
"add": "पासकी जोड़ें",
"none": "कोई पासकी पंजीकृत नहीं है।",
"added": "पासकी जोड़ी गई",
"addFailed": "पासकी जोड़ने में विफल",
"deleted": "पासकी हटाई गई",
"deleteFailed": "पासकी हटाने में विफल"
}
@@ -0,0 +1,18 @@
{
"title": "Jelszavak",
"signIn": "Bejelentkezés jelszóval",
"loginFailed": "Jelszóval történő bejelentkezés sikertelen",
"description": "Jelszó nélküli bejelentkezéshez használja a WebAuthn/FIDO2 hitelesítő adatokat",
"namePlaceholder": "Jelszó neve",
"uv": {
"preferred": "Előnyben részesített",
"required": "Kívánt",
"discouraged": "Elbátortalanodva"
},
"add": "Jelszó hozzáadása",
"none": "Nincsenek regisztrált jelszók.",
"added": "Hozzáadott jelszó",
"addFailed": "Nem sikerült hozzáadni a jelszót",
"deleted": "Jelszó törölve",
"deleteFailed": "Nem sikerült törölni a jelszót"
}
@@ -0,0 +1,18 @@
{
"title": "Kode akses",
"signIn": "Masuk dengan kata sandi",
"loginFailed": "Login dengan kata sandi gagal.",
"description": "Gunakan kredensial WebAuthn/FIDO2 untuk masuk tanpa kata sandi.",
"namePlaceholder": "Nama kata sandi",
"uv": {
"preferred": "Lebih disukai",
"required": "Diperlukan",
"discouraged": "Berkecil hati"
},
"add": "Tambahkan Kata Sandi",
"none": "Tidak ada kata sandi yang terdaftar.",
"added": "Kata sandi telah ditambahkan",
"addFailed": "Gagal menambahkan kata sandi.",
"deleted": "Kata sandi telah dihapus.",
"deleteFailed": "Gagal menghapus kata sandi."
}
@@ -0,0 +1,18 @@
{
"title": "Passkeys",
"signIn": "Accedi con passkey",
"loginFailed": "Accesso con passkey non riuscito",
"description": "Utilizza credenziali WebAuthn/FIDO2 per accedere senza password",
"namePlaceholder": "Nome passkey",
"uv": {
"preferred": "Preferita",
"required": "Obbligatoria",
"discouraged": "Sconsigliata"
},
"add": "Aggiungi passkey",
"none": "Nessuna passkey registrata.",
"added": "Passkey aggiunta",
"addFailed": "Impossibile aggiungere la passkey",
"deleted": "Passkey eliminata",
"deleteFailed": "Impossibile eliminare la passkey"
}
@@ -0,0 +1,18 @@
{
"title": "パスキー",
"signIn": "パスキーでサインイン",
"loginFailed": "パスキーログインに失敗しました",
"description": "WebAuthn/FIDO2認証情報を使用してパスワードなしでサインインできます",
"namePlaceholder": "パスキー名",
"uv": {
"preferred": "優先",
"required": "必須",
"discouraged": "非推奨"
},
"add": "パスキーを追加",
"none": "登録されたパスキーはありません。",
"added": "パスキーが追加されました",
"addFailed": "パスキーの追加に失敗しました",
"deleted": "パスキーが削除されました",
"deleteFailed": "パスキーの削除に失敗しました"
}
@@ -0,0 +1,18 @@
{
"title": "패스키",
"signIn": "Passkey로 로그인",
"loginFailed": "패스키 로그인 실패",
"description": "WebAuthn/FIDO2 자격 증명을 사용하여 비밀번호 없이 로그인하세요.",
"namePlaceholder": "패스키 이름",
"uv": {
"preferred": "선호",
"required": "필수",
"discouraged": "권장 안 함"
},
"add": "패스키 추가",
"none": "등록된 패스키가 없습니다.",
"added": "패스키가 추가되었습니다.",
"addFailed": "패스키 추가 실패",
"deleted": "패스키가 삭제되었습니다.",
"deleteFailed": "패스키 삭제 실패"
}
@@ -0,0 +1,18 @@
{
"title": "Wachtwoorden",
"signIn": "Aanmelden met wachtwoord",
"loginFailed": "Aanmelden met wachtwoord mislukt",
"description": "Gebruik WebAuthn/FIDO2-gegevens om zonder wachtwoord in te loggen.",
"namePlaceholder": "Wachtwoordnaam",
"uv": {
"preferred": "Voorkeur",
"required": "Vereist",
"discouraged": "Ontmoedigd"
},
"add": "Voeg wachtwoord toe",
"none": "Er zijn geen wachtwoorden geregistreerd.",
"added": "Wachtwoord toegevoegd",
"addFailed": "Wachtwoord toevoegen mislukt",
"deleted": "Wachtwoord verwijderd",
"deleteFailed": "Het verwijderen van de toegangscode is mislukt."
}
@@ -0,0 +1,18 @@
{
"title": "Passord",
"signIn": "Logg inn med passord",
"loginFailed": "Innlogging med passord mislyktes",
"description": "Bruk WebAuthn/FIDO2-legitimasjon for å logge på uten passord",
"namePlaceholder": "Navn på passord",
"uv": {
"preferred": "Foretrukket",
"required": "Obligatorisk",
"discouraged": "Motløs"
},
"add": "Legg til tilgangsnøkkel",
"none": "Ingen passord registrert.",
"added": "Tilgangsnøkkel lagt til",
"addFailed": "Kunne ikke legge til passord",
"deleted": "Tilgangsnøkkel slettet",
"deleteFailed": "Kunne ikke slette passordet"
}
@@ -0,0 +1,18 @@
{
"title": "Klucze dostępu",
"signIn": "Zaloguj się za pomocą klucza dostępu",
"loginFailed": "Nie udało się zalogować za pomocą klucza dostępu",
"description": "Używaj poświadczeń WebAuthn/FIDO2, aby logować się bez hasła",
"namePlaceholder": "Nazwa klucza dostępu",
"uv": {
"preferred": "Preferowane",
"required": "Wymagane",
"discouraged": "Odradzane"
},
"add": "Dodaj klucz dostępu",
"none": "Nie zarejestrowano żadnych kluczy dostępu.",
"added": "Dodano klucz dostępu",
"addFailed": "Nie udało się dodać klucza dostępu",
"deleted": "Usunięto klucz dostępu",
"deleteFailed": "Nie udało się usunąć klucza dostępu"
}
@@ -0,0 +1,18 @@
{
"title": "Senhas",
"signIn": "Faça login com a chave de acesso.",
"loginFailed": "Falha no login com a chave de acesso",
"description": "Use as credenciais WebAuthn/FIDO2 para fazer login sem senha.",
"namePlaceholder": "Nome da chave de acesso",
"uv": {
"preferred": "Preferido",
"required": "Obrigatório",
"discouraged": "Desencorajado"
},
"add": "Adicionar chave de acesso",
"none": "Nenhuma chave de acesso registrada.",
"added": "Senha adicionada",
"addFailed": "Falha ao adicionar a chave de acesso.",
"deleted": "Senha excluída",
"deleteFailed": "Falha ao excluir a chave de acesso."
}
@@ -0,0 +1,18 @@
{
"title": "Passkeys",
"signIn": "Iniciar sessão com passkey",
"loginFailed": "Falha no início de sessão com passkey",
"description": "Utilize credenciais WebAuthn/FIDO2 para iniciar sessão sem palavra-passe",
"namePlaceholder": "Nome da passkey",
"uv": {
"preferred": "Preferencial",
"required": "Obrigatório",
"discouraged": "Desaconselhado"
},
"add": "Adicionar Passkey",
"none": "Nenhuma passkey registada.",
"added": "Passkey adicionada",
"addFailed": "Falha ao adicionar passkey",
"deleted": "Passkey eliminada",
"deleteFailed": "Falha ao eliminar chave de acesso"
}
@@ -0,0 +1,18 @@
{
"title": "Chei de acces",
"signIn": "Conectați-vă cu cheia de acces",
"loginFailed": "Conectarea cu parola a eșuat",
"description": "Folosește acreditările WebAuthn/FIDO2 pentru a te conecta fără parolă",
"namePlaceholder": "Numele cheii de acces",
"uv": {
"preferred": "Preferat",
"required": "Necesar",
"discouraged": "Descurajat"
},
"add": "Adăugați o cheie de acces",
"none": "Nicio cheie de acces înregistrată.",
"added": "Cheie de acces adăugată",
"addFailed": "Nu s-a putut adăuga parola",
"deleted": "Cheia de acces a fost ștearsă",
"deleteFailed": "Nu s-a putut șterge parola"
}
@@ -0,0 +1,18 @@
{
"title": "Ключи доступа",
"signIn": "Войти с ключом доступа",
"loginFailed": "Вход по ключу не удался",
"description": "Используйте учётные данные WebAuthn/FIDO2 для входа без пароля",
"namePlaceholder": "Название ключа доступа",
"uv": {
"preferred": "Предпочтительно",
"required": "Обязательно",
"discouraged": "Не рекомендуется"
},
"add": "Добавить ключ доступа",
"none": "Нет зарегистрированных ключей доступа.",
"added": "Ключ доступа добавлен",
"addFailed": "Не удалось добавить ключ доступа",
"deleted": "Ключ доступа удалён",
"deleteFailed": "Ошибка удаления ключа доступа"
}
@@ -0,0 +1,18 @@
{
"title": "Лозинке",
"signIn": "Пријавите се помоћу лозинке",
"loginFailed": "Пријава лозинком није успела",
"description": "Користите WebAuthn/FIDO2 акредитиве за пријаву без лозинке",
"namePlaceholder": "Назив лозинке",
"uv": {
"preferred": "Преферирано",
"required": "Обавезно",
"discouraged": "Обесхрабрено"
},
"add": "Додај лозинку",
"none": "Није регистрована ниједна лозинка.",
"added": "Лозинка је додата",
"addFailed": "Додавање лозинке није успело",
"deleted": "Лозинка је обрисана",
"deleteFailed": "Брисање лозинке није успело"
}
Loaded 100 of 235 files, more files were not shown because too many files have changed in this diff. Show more