* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

* chore: lint, format, and bump version to 2.9.0

* chore: skip crowdin pre-translate in release for now

* chore: switch to calver, bump version to 26.10.0

* fix(sso): send the PKCE verifier for GitHub login (#1534)

* fix(remote-desktop): clip cursor overflow without disabling zoom (#1535)

* test(remote-desktop): preserve sessions beyond one hour (#1536)

* fix(hosts): expose controls for overflowing editor tabs (#1537)

* fix(hosts): expose controls for overflowing editor tabs

* test(hosts): mock resize observation in admin panel tests

* fix(status): skip TCP probes while host sessions are active (#1538)

* fix(database): batch session activity persistence (#1539)

* docs(api): describe cookie and API key authentication (#1540)

* fix(snippets): repair missing note column on SQLite upgrades (#1541)

* fix(docker): retain stored SSH credential association (#1543)

* fix(file-manager): render transfer toasts without plugin hooks (#1546)

* feat(hosts): keep compact row actions inline (#1547)

* fix(auth): allow retrying unavailable second-factor interfaces (#1549)

* fix(auth): reject unresolved legacy identity provisioning (#1550)

* fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551)

* fix(tmux): pass full session info to the terminal's session picker (#1552)

The tmux.sessions service reduced detected sessions to bare names, but the
picker reads session.name, so every entry rendered blank and selecting one
sent an empty name, which created a new session instead of attaching.

* fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553)

The column holds the text "true"/"false", and the resolver passed it
through as-is. The string "false" is truthy, so the password provider
treated every saved host as forced keyboard-interactive and left the
password out. Jump hops are built straight from the resolved host, so a
password hop whose server doesn't offer keyboard-interactive failed with
"All configured authentication methods failed".

* fix(database): batch database saves for bulk host writes (#1554)

* fix(database): yield to the event loop between database saves

Each SQLite save serializes and encrypts the whole database into new
buffers, which V8 only releases once the event loop turns. Boot
migrations and bulk host import write plugin settings per host and per
key in an awaited loop, so hundreds of full copies piled up and the
container ran out of memory.

* fix(database): save once per bulk host write instead of once per row

On SQLite every repository write force-saves the whole database. Boot
plugin data migrations, host defaults materialization and the bulk
host routes write one row per host per setting, so they serialized and
encrypted the full database hundreds of times in a row.

DatabaseSaveTrigger.batched wraps a function so force saves made while
it runs collapse into a single save when it finishes. Nested scopes fold
into the outer one, and work that outlives its scope saves normally.

* feat(hosts): add instance-wide predefined tag suggestions (#1548)

* feat(hosts): add shared predefined tag suggestions

* style(hosts): format tag catalog routes

* test(database): advance fake timers past the save yield (#1555)

* fix(i18n): complete Simplified Chinese core and plugin translations (#1542)

* fix(i18n): complete Chinese onboarding and navigation labels

* fix(i18n): translate remaining Chinese core and plugin interfaces

* fix(i18n): translate host editor tab overflow controls

* fix(i18n): use consistent Chinese fleet terminology

* fix(i18n): localize hardcoded controls and plugin views

* fix(i18n): translate built-in homepage widget catalog

* test(homepage): follow translated timezone placeholder

* fix(i18n): translate plugin-provided homepage widgets

* fix(i18n): localize feature settings section titles

* fix: 2.8 oidc accounts unable to sign in after upgrading (#1381)

* fix: plugins losing the saved ssh login when copying a host (#1391)

* fix: fleets, proxmox and automations not getting the host sudo password

* fix: host imports running a defaults pass and metrics restart per host (#1384)

* fix: high cpu from status probes and full database saves on every sample (#1300)

* fix: user data export freezing the server (#1393)

* fix: op:// secret references rejected as ssh keys on credentials (#1394)

* fix: slow file deletes from the trash lookups on every delete (#1390)

* fix: add openapi docs and error handling to host tag routes

* test: compare download stream buffers directly so it stops timing out

* chore: drop em dash from host row comment

* chore: update release notes for 2.9.1

* feat(ai): add remote coding agent sessions for SSH hosts (#1545)

* feat(ai): add host coding agent sessions with configured providers

* Wait for agent initialization and shutdown before resuming sessions

* Keep Pi transcripts outside its configuration migration directory

* Cover Pi session directory separation

* Update core permission catalog expectations for remote agents

* Install remote agent runtimes from verified official sources

* Wait for the identity panel credentials request in its test

* Add scoped SSH forwarding setup for remote agents

* Verify forwarding setup through a fresh SSH connection

* Bound the SSH forwarding verification timeout

* Return the forwarding verification callback result

* fix(ai): support agent send and interrupt keyboard shortcuts

* feat(ai): add agent queues attachments worktrees and session recovery

* fix(ai): handle deleted workspace paths and duplicate status events

* fix(ai): accept file references in root workspaces

* fix(ai): refresh paused queues after agent errors

* fix(ai): store agent sessions in plugin tables and lock down OpenCode

Agent sessions lived in a single ctx.kv value holding up to 2000 events,
which passed the 256 KB kv limit within minutes of a normal run. Every
save after that failed, so history, nativeId, queue and archive changes
were lost and every event wrote a kv audit row.

Sessions, events and queued prompts now have their own tables. A running
agent appends events in batches at most once a second with a lazy save;
session and queue changes save immediately. Prompts, drafts and stored
event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no
longer requested.

The OpenCode server now requires a per-session password, so other local
accounts on the host cannot drive the agent through its loopback port.

* test(upgrade): include agent session tables in the reviewed upgrade SQL

* test: run the posix-only ai agent tests on linux only

* fix: restore space to add host tags and redesign predefined tags editor

* fix: host key silently accepted when the host is missing from the database (#1397)

* fix: clearer host login failed status label and fix its translations (#1396)

* chore: add host key and status label fixes to release notes

* fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles

* chore: increment ver

* fix(audit): store plugin entries with no acting user as a null user_id (#1556)

Plugin audit entries written outside a request used the literal "system"
as user_id. That column references users.id, so the insert was refused
(Postgres logs it as an FK violation) and the entry was silently dropped.
Write null instead and keep "system" / plugin:<id> in username.

* chore: add sso/ldap dialog and audit log fixes to release notes

* fix: tunnels and host settings missing from shared hosts on desktop

* fix: remote desktop logins missing from shared hosts on desktop

* fix: simplify host status to online/offline and keep it live without a refresh

* chore: sync Crowdin translations for 2.9.1

* fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi

* chore: rework plugin readmes, json changelogs and standalone repo setup

* chore: move plugins to their own repos and drop the plugins folder

* chore(deps-dev): bump globals (#1560)

Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals).


Updates `globals` from 17.12.0 to 17.13.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: drop the 2.8 columns, moves and aliases kept for a downgrade

* chore: remove the 2.9 finish list

* feat: port the demo redesign with Manage and Settings tabs

* chore: pin the plugin signing key and use TERMIX_PAT in workflows

* chore: rename the plugin sdk to @termix-ssh/plugin-sdk

* feat: add a publish workflow for the plugin sdk

* chore: cleanup

* fix: harden plugin sockets, public routes, prebuilt binaries and login fallback

* chore: add a 2.9.1 upgrade check for postgres and mysql

* fix: stop full database rewrites on every plugin db call

* fix: stop full database rewrites on every plugin db call

* fix: restore release video link in 2.9.2 notes

* fix: use TERMIX_PAT in workflows

* feat: pin the official plugins in the docker image

* feat: implement plugin management UI

* feat: shared list kit, settings search, rail order and ui consistency fixes

* chore: run all workflows on blacksmith runners

* chore: split dashboard quick actions into quarters and drop host status row lines

* fix: open plain settings from the profile button, navigation only from the rail menu

* feat: consent check, developer mode file installs, install counts and issue links for plugins

* feat: row detail options for hosts and credentials, open manage from sidebar header

* fix: unify connection failure screens and rebuild stale local plugins

* feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev

* chore: clean up npm scripts, make npm run dev the full runner and drop concurrently

* fix: settle quick connect panel loads in tests so they stop flaking

* fix: stop the dev runner rebuilding plugins when windows reports file reads as changes

* chore: update the plugin sdk ui export snapshot

* feat: full screen onboarding with a plugin picker and per step versions

* feat: changelog.md release notes, v tags, plugin release notes and videos

* chore: add temp changelog

* chore: release the plugin sdk as 1.0.1

* feat: show only the latest plugin release notes and cap long ones

* chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub

* chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN

* chore: renew mac app store provisioning profile

* chore: drop docs job from release workflow

* chore: run sdk publish on github hosted runner

* chore: remove dependabot

* chore: general file cleanup

* chore: rewrite readme and taglines for plugin-based termix

* chore: drop optional from ai plugin row in readme

* feat: per-repo issue forms and prefilled bug reports instead of the support repo

* feat: add report a bug link to the your account settings section

* fix: open settings goes to the plugin's page and hides when it has none

* feat: show plugin features from the manifest on the plugin page

* fix: retry failed or blocked plugins on restart instead of leaving them disabled

* fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling

* feat: request a feature link for plugins and use the bug form for unverified plugins

* feat: beta channels for core and plugins with one stable/beta release workflow

* fix(sync): resolve shared hosts for connect-only recipients (#1568)

* fix(ssh): accept full passwords in keyboard-interactive prompts (#1569)

* fix(ssh): normalize PKCS8 private keys before connecting (#1570)

* fix(sidebar): reclaim space from hidden folder actions (#1571)

* fix(hosts): display protocol credential usernames for non-SSH hosts (#1572)

* fix: ask for a push/TOTP menu choice as text instead of a push confirm

* fix: keep client tunnels open when one of their connections fails (Support#1417)

* fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420)

* fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419)

* fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418)

* fix: refresh cached plugin translations when only a translation changes (Support#1411)

* fix: import sshpk as a default so the backend starts under ESM

* feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2

* fix: drop the ./ from the plugin sdk bin path so npm stops warning

* chore: name core and sdk releases Termix vX and Plugin SDK vX

* fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g)

* fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh)

* fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw)

* fix: refuse sessionless tokens and keep the secrets file owner only

* feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm)

* chore: standardize repo setup files

* chore: remove unused knip config

* chore: add LICENSE and use Node 24 from .nvmrc

* chore: update translation guidance, only en.json needs editing

* feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3

* fix: header separators render as borders and center the status divider

* fix: lighter confirm prompt overlay to stop lag when it opens

* fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4

* fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK

* fix: keep plugin tailwind variants in core's layer so overrides keep their order

* fix: manage tab refetches before giving up on a host or credential it cannot find

* fix: tab buttons like share show as soon as the terminal is ready

* fix: folder rows no longer nest action buttons inside a button

* fix: repin remote-desktop after its 1.0.0 re-release

* chore: repin bundled plugins after the 1.0.0 re-release

* fix: recent activity rows use the same separator as host status

* feat: upload banner

* fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24

* fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks

* fix: bundled plugins path for source runs and backend cleanup

* fix: load plugin strings on guest pages, plugin wording and drop unused keys

* fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms

* docs: 26.10.0 changelog

* fix: current version in the version route example

* chore: stop pushing the release- docker tag

* feat: remove the donation reminder and its column

* docs: note the removed donation reminder

* fix: link server wizard always opens inline, onboarding card grows for it

* feat: plugin text columns are longtext on mysql so they are not capped at 64KB

* fix: linked desktops keep a plugin when its update from the server fails

* chore: default mobile download links to v tags

* fix: sso only accounts cannot sign in with a passkey or other local method

* feat: note in the plugins tab that community plugins are coming

* fix: plugin validate accepts beta versions

* chore: pin bundled plugins rebuilt on sdk 1.0.5

* fix: skip locale fetches for disabled plugins

* fix: show translated names for the built-in admin and user roles

* fix: show host keys as SHA256 fingerprints like ssh-keygen

* fix: host editor scrolls to a newly enabled protocol section

* fix: pass the host to dashboard host row plugins

* fix: plugin cards show dividers between author, version and category

* fix: refuse plugin archives with paths outside the plugin folder

* fix: theme picker shows one dropdown arrow

* fix: read activity and audit times as UTC so they show in local time

* fix: show the real error for a wrong password or code on plugin sign in

* fix: explain that external sign in needs Auto-create external accounts

* fix: clear leftover plugin unpack folders at startup

* fix: shared session links open for guests again

* fix: share panels no longer list yourself as a share target

* fix: a host update without an auth type keeps the saved password

* fix: guest pages get the tab ids a guest plugin registers

* fix: refuse a 2.8 database, which already had a plugins table

* fix: dashboard cards fit on a phone

* fix: desktop setup no longer asks to secure the local account

* fix: the desktop app keeps the desktop layout in a narrow window

* fix: the desktop app shows the rail and no phone bar in a narrow window

* fix: name every sync type in the Sync panel and pluralize the link prompt

* chore: update the sdk ui snapshot for the host key dialog

* fix: turning a plugin back on restarts the plugins it turned off

* fix: quick connect asks to trust the host key

* fix: revoking a manager also removes the shares they handed out

* fix: database export works for users with TOTP on (#1426)

* fix: rdp and vnc logins use the folder credential when none is picked (#1429)

* fix: imported hosts get status checks without a restart

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: re-pin bundled plugins after search bar fix

* chore: update app icon to smaller version

* chore: round the readme logo, keep square source for app icons

* feat: grid columns and table view in the plugins tab

* fix: match the host key copy button to the fingerprint height

* fix: use brand color for toggles in sync settings

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: web terminals failing auth behind a proxy that rewrites Host (Support#651)

* fix: re-pin snippets and termix-identity after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* chore: remove Crowdin from the release workflow

* chore: remove crowdin config

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 14:58:27 -05:00
1 parent a075ff4808
commit 0149260926
3656 files changed
+116210 -460999

No files matched your search

+491 -562
View File
File diff suppressed because it is too large. Load diff
+5 -1
View File
@@ -2,6 +2,7 @@ import {
authApi,
handleApiError,
markUserAuthenticated,
TRUST_TOKEN_KEY,
type AuthResponse,
} from "@/main-axios";
@@ -26,8 +27,11 @@ export type LoginResponse = AuthResponse & {
userId?: string;
};
function remember(data: LoginResponse): LoginResponse {
function remember(
data: LoginResponse & { trustToken?: string },
): LoginResponse {
if (data?.token) localStorage.setItem("jwt", data.token);
if (data?.trustToken) localStorage.setItem(TRUST_TOKEN_KEY, data.trustToken);
if (data?.success && !data.requires_totp) markUserAuthenticated();
return data;
}
+361 -1
View File
@@ -1,4 +1,12 @@
import { rbacApi } from "@/main-axios";
import type { ChoiceAdjustment, PluginChoice } from "@/types/plugin-onboarding";
import type {
ChangelogRelease,
ReleaseNotes,
} from "@termix-ssh/plugin-sdk/changelog";
import type { PluginEnvVar } from "@termix-ssh/plugin-sdk/docs";
export type { PluginChoice };
export interface PluginTabContribution {
id: string;
@@ -84,7 +92,7 @@ export interface PluginContributions {
export {
isRedactedSecret,
type RedactedSecret,
} from "@termix/plugin-sdk/settings";
} from "@termix-ssh/plugin-sdk/settings";
export type PluginSettingsValues = Record<string, unknown>;
@@ -237,9 +245,361 @@ export interface PluginSummary {
lastError?: string | null;
/** Paths a running plugin serves without login. Admins only. */
publicRoutes?: { http: string[]; ws: string[] };
registryId?: string | null;
autoUpdate?: boolean;
pinnedVersion?: string | null;
channel?: PluginChannel;
description?: string;
author?: string;
repository?: string;
/** A YouTube video id from the manifest. Admins only. */
videoId?: string;
/** What the plugin does, from the manifest. Admins only. */
features?: string[];
/** https docs link from the manifest. */
docs?: string;
/** Environment variables the plugin reads. Admins only. */
env?: PluginEnvVar[];
signedBy?: string | null;
}
export async function getPlugins(): Promise<PluginSummary[]> {
const response = await rbacApi.get("/plugins");
return Array.isArray(response.data) ? response.data : [];
}
/** Fired after any change to installed plugins; the loader resyncs on it. */
export const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
function announcePluginsChanged(): void {
window.dispatchEvent(new CustomEvent(PLUGINS_CHANGED_EVENT));
}
export type PluginChannel = "stable" | "beta";
export interface RegistryPluginVersion {
version: string;
/** A beta, offered only to plugins on the beta channel. */
prerelease?: boolean;
compatible: boolean;
capabilities: string[];
publishedAt?: string;
releaseNotesUrl?: string;
notes?: ReleaseNotes;
size: number;
}
export interface RegistryPluginEntry {
id: string;
name: string;
description: string;
author: string;
category: string;
repository?: string;
icon?: string;
videoId?: string;
features?: string[];
docs?: string;
versions: RegistryPluginVersion[];
/** Newest release on the plugin's channel. */
latestVersion: string | null;
/** Newest beta, when one is newer than stable. */
latestBeta?: string | null;
channel?: PluginChannel;
installed: boolean;
installedVersion: string | null;
updateAvailable: boolean;
addedCapabilities: string[];
pinnedVersion: string | null;
autoUpdate: boolean;
bundled: boolean;
/** Active installs when known, release downloads otherwise. */
installCount: number | null;
installCountSource: string | null;
}
export interface RegistryListing {
registry: {
id: string;
url: string;
lastCheckedAt: string | null;
error: string | null;
};
managedByServer: boolean;
plugins: RegistryPluginEntry[];
}
export interface PluginStateChange {
id: string;
enabled: boolean;
enable: string[];
disable: string[];
missing: string[];
state?: string;
}
export interface PluginDataSummary {
id: string;
tables: Array<{ name: string; rows: number | null }>;
kvKeys: number;
settings: { admin: number; user: number; host: number; secret: number };
migrations: Array<{ id: string; appliedAt: string | null }>;
grants: Array<{
capability: string;
source: string;
grantedAt: string | null;
}>;
filesBytes: number;
}
export interface UpdateAllResult {
updated: Array<{ id: string; version: string }>;
needsReview: Array<{ id: string; capabilities: string[] }>;
failed: Array<{ id: string; error: string }>;
}
const pluginPath = (pluginId: string) =>
`/plugins/${encodeURIComponent(pluginId)}`;
export async function getPluginRegistry(
refresh = false,
): Promise<RegistryListing> {
const response = await rbacApi.get("/plugins/registry", {
params: refresh ? { refresh: 1 } : undefined,
});
return response.data;
}
/** `capabilities` is exactly what the consent prompt showed. */
export async function installPlugin(
pluginId: string,
options: { version?: string; capabilities: string[] },
): Promise<{ id: string; version: string; state: string }> {
try {
const response = await rbacApi.post(
`${pluginPath(pluginId)}/install`,
options,
);
return response.data;
} finally {
announcePluginsChanged();
}
}
export async function updatePlugin(
pluginId: string,
options: {
version?: string;
acceptCapabilities?: boolean;
capabilities?: string[];
} = {},
): Promise<{ id: string; version: string; state: string }> {
try {
const response = await rbacApi.post(
`${pluginPath(pluginId)}/update`,
options,
);
return response.data;
} finally {
announcePluginsChanged();
}
}
export async function updateAllPlugins(): Promise<UpdateAllResult> {
try {
const response = await rbacApi.post("/plugins/update-all");
return response.data;
} finally {
announcePluginsChanged();
}
}
export async function uninstallPlugin(pluginId: string): Promise<void> {
try {
await rbacApi.delete(pluginPath(pluginId));
} finally {
announcePluginsChanged();
}
}
export async function previewPluginState(
pluginId: string,
enabled: boolean,
): Promise<PluginStateChange> {
const response = await rbacApi.patch(
`${pluginPath(pluginId)}/state`,
{ enabled },
{ params: { dryRun: 1 } },
);
return response.data;
}
export async function setPluginState(
pluginId: string,
enabled: boolean,
): Promise<PluginStateChange> {
try {
const response = await rbacApi.patch(`${pluginPath(pluginId)}/state`, {
enabled,
});
return response.data;
} finally {
announcePluginsChanged();
}
}
export async function retryPlugin(pluginId: string): Promise<void> {
try {
await rbacApi.post(`${pluginPath(pluginId)}/retry`);
} finally {
announcePluginsChanged();
}
}
export async function setPluginOptions(
pluginId: string,
options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel },
): Promise<{
autoUpdate: boolean;
pinnedVersion: string | null;
channel: PluginChannel;
}> {
const response = await rbacApi.patch(
`${pluginPath(pluginId)}/options`,
options,
);
return response.data;
}
export async function setAllPluginChannels(
channel: PluginChannel,
): Promise<{ changed: string[] }> {
const response = await rbacApi.post("/plugins/channel", { channel });
return response.data;
}
export async function getPluginData(
pluginId: string,
): Promise<PluginDataSummary> {
const response = await rbacApi.get(`${pluginPath(pluginId)}/data`);
return response.data;
}
export async function getPluginChangelog(
pluginId: string,
): Promise<ChangelogRelease[]> {
const response = await rbacApi.get(`${pluginPath(pluginId)}/changelog`);
return response.data?.releases ?? [];
}
export async function deletePluginData(pluginId: string): Promise<void> {
try {
await rbacApi.delete(`${pluginPath(pluginId)}/data`);
} finally {
announcePluginsChanged();
}
}
export interface DeveloperModeState {
enabled: boolean;
/** TERMIX_REQUIRE_SIGNED_PLUGINS blocks file installs even in developer mode. */
signedOnly: boolean;
}
export async function getDeveloperMode(): Promise<DeveloperModeState> {
const response = await rbacApi.get("/plugins/developer-mode");
return response.data;
}
/** Fired after developer mode changes; detail is the new value. */
export const DEVELOPER_MODE_CHANGED_EVENT = "termix:plugin-developer-mode";
export async function setDeveloperMode(enabled: boolean): Promise<void> {
await rbacApi.put("/plugins/developer-mode", { enabled });
window.dispatchEvent(
new CustomEvent(DEVELOPER_MODE_CHANGED_EVENT, { detail: enabled }),
);
}
export interface PluginUploadPreview {
token: string;
id: string;
name: string;
version: string;
description: string;
author: string;
features?: string[];
capabilities: string[];
replaces: string | null;
}
export async function uploadPlugin(file: Blob): Promise<PluginUploadPreview> {
const response = await rbacApi.post("/plugins/upload", file, {
headers: { "Content-Type": "application/octet-stream" },
});
return response.data;
}
export async function installUploadedPlugin(
token: string,
capabilities: string[],
): Promise<{ id: string; version: string; state: string }> {
try {
const response = await rbacApi.post(
`/plugins/upload/${encodeURIComponent(token)}/install`,
{ capabilities },
);
return response.data;
} finally {
announcePluginsChanged();
}
}
export interface OnboardingPluginInfo {
id: string;
name: string;
description: string;
icon: string | null;
category: string;
version: string;
source: "bundled" | "user";
state: string;
dependencies: string[];
recommended: boolean;
consent: boolean;
}
export interface OnboardingPluginList {
pending: boolean;
/** On an upgrade the picker starts from what is on, not the defaults. */
reason: "fresh" | "upgrade" | null;
managedByLinkedServer: boolean;
plugins: OnboardingPluginInfo[];
}
export interface OnboardingApplyResult {
resolved: Record<string, PluginChoice>;
adjustments: ChoiceAdjustment[];
enabled: string[];
disabled: string[];
removed: string[];
failed: { id: string; error: string; code?: string }[];
}
export async function getOnboardingPlugins(): Promise<OnboardingPluginList> {
const response = await rbacApi.get("/plugins/onboarding");
return response.data;
}
export async function applyOnboardingPlugins(
choices: Record<string, PluginChoice>,
): Promise<OnboardingApplyResult> {
try {
const response = await rbacApi.post("/plugins/onboarding/apply", {
choices,
});
return response.data;
} finally {
announcePluginsChanged();
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
import i18n from "i18next";
import {
handleApiError,
rbacApi,
@@ -16,7 +17,7 @@ async function getSharingTarget(hostId: number, syncId?: string | null) {
if (!api || !syncId) return { api: rbacApi, hostId };
const remoteHostId = await resolveRemoteHostId(syncId);
if (remoteHostId === null) {
throw new Error("The synced host does not exist on the remote server");
throw new Error(i18n.t("errors.syncedHostMissing"));
}
return { api, hostId: remoteHostId };
}
@@ -27,7 +28,7 @@ async function getSharedCopyTarget(hostId: number, syncId?: string | null) {
const api = await getConnectedRemoteApi();
const remoteHostId = api ? await resolveRemoteHostId(syncId) : null;
if (!api || remoteHostId === null) {
throw new Error("The linked server is not reachable");
throw new Error(i18n.t("errors.linkedServerUnreachable"));
}
return { api, hostId: remoteHostId };
}
-3
View File
@@ -43,9 +43,6 @@ export async function updateSessionTimeout(
}
}
// Tailscale settings/device API wrappers moved to
// plugins/tailscale/src/frontend/tailscale-api.ts.
// ============================================================================
// BRANDING SETTINGS
// ============================================================================
+41
View File
@@ -42,6 +42,7 @@ export async function getReleasesRSS(
export interface VersionInfo {
status?: "up_to_date" | "requires_update" | "beta" | "unknown";
channel?: "stable" | "beta";
/** Same value as remoteVersion; the endpoint sends both. */
version?: string;
localVersion?: string;
@@ -81,6 +82,46 @@ export async function getVersionInfo(checkRemote = true): Promise<VersionInfo> {
}
}
export type UpdateChannel = "stable" | "beta";
export interface UpdateChannelState {
channel: UpdateChannel;
/** The admin's choice; a beta build reports beta whatever it is. */
stored: UpdateChannel;
runningBeta: boolean;
}
export async function getUpdateChannel(): Promise<UpdateChannelState> {
const response = await authApi.get("/version/channel");
return response.data;
}
export async function setUpdateChannel(
channel: UpdateChannel,
): Promise<UpdateChannelState> {
const response = await authApi.put("/version/channel", { channel });
return response.data;
}
export interface ReleaseInfo {
version: string;
tagName: string;
name: string;
url: string;
publishedAt: string;
prerelease: boolean;
notes: string;
}
export async function getChannelReleases(): Promise<{
localVersion: string | null;
stable: ReleaseInfo | null;
beta: ReleaseInfo | null;
}> {
const response = await authApi.get("/version/releases");
return response.data;
}
// ============================================================================
// DATABASE HEALTH
// ============================================================================
+43 -81
View File
@@ -1,4 +1,9 @@
/* eslint-disable react-refresh/only-export-components */
import {
isInElectronWebView,
isInMobileWebView,
type EmbeddedFrameWindow,
} from "@/lib/embedded-frame";
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
@@ -48,6 +53,7 @@ import { useLoginMethods, useSecondFactors } from "@/plugin-host/auth-registry";
import { startPreLoginPlugins } from "@/plugin-host/loader";
import { Checkbox } from "@/components/checkbox";
import { useBranding } from "@/contexts/BrandingContext";
import { BrandPanel, CompactBrand } from "./auth-bits";
import {
changeAppLanguage,
normalizeLanguageCode,
@@ -182,26 +188,6 @@ interface AuthProps {
onLogin: (username: string, userId?: string, isAdmin?: boolean) => void;
}
interface ExtendedWindow extends Window {
IS_ELECTRON_WEBVIEW?: boolean;
ReactNativeWebView?: { postMessage: (msg: string) => void };
}
const isInMobileWebView = () =>
/Termix-Mobile\/(Android|iOS)/.test(navigator.userAgent) ||
!!(window as ExtendedWindow).ReactNativeWebView;
const isInElectronWebView = () => {
if (isInMobileWebView()) return false;
if ((window as ExtendedWindow).IS_ELECTRON_WEBVIEW) return true;
try {
if (window.self !== window.top) return true;
} catch {
return true;
}
return false;
};
function PasswordInput({
value,
onChange,
@@ -420,7 +406,7 @@ export function Auth({ onLogin }: AuthProps) {
);
return;
}
toast.error(event.data.error || t("errors.failedOidcLogin"));
toast.error(event.data.error || t("errors.failedExternalLogin"));
};
window.addEventListener("message", handleExternalBrowserResult);
@@ -604,7 +590,7 @@ export function Auth({ onLogin }: AuthProps) {
toast.error(t("messages.userNotAllowed"));
else if (error === "second_factor_unavailable")
toast.error(t("auth.secondFactorUnavailable"));
else toast.error(`${t("errors.oidcAuthFailed")}: ${error}`);
else toast.error(`${t("errors.externalAuthFailed")}: ${error}`);
window.history.replaceState({}, document.title, window.location.pathname);
return;
}
@@ -615,7 +601,7 @@ export function Auth({ onLogin }: AuthProps) {
// (termix-mobile:-origin callbacks include one), otherwise read it back
// from the cookie via /users/me/token before handing it to the app.
const postToken = (token: string) => {
(window as ExtendedWindow).ReactNativeWebView?.postMessage(
(window as EmbeddedFrameWindow).ReactNativeWebView?.postMessage(
JSON.stringify({ type: "AUTH_SUCCESS", token }),
);
setWebviewAuthSuccess(true);
@@ -739,6 +725,10 @@ export function Auth({ onLogin }: AuthProps) {
toast.error(t("auth.secondFactorUnavailable"));
return;
}
if (error?.response?.data?.code === "external_login_required") {
toast.error(t("auth.externalLoginRequired"));
return;
}
toast.error(
error?.response?.data?.error || error?.message || t(fallbackKey),
);
@@ -761,7 +751,7 @@ export function Auth({ onLogin }: AuthProps) {
if (isInMobileWebView()) {
// Native-app requests get the JWT in the login response body.
const token = res?.token ?? "";
(window as ExtendedWindow).ReactNativeWebView?.postMessage(
(window as EmbeddedFrameWindow).ReactNativeWebView?.postMessage(
JSON.stringify({ type: "AUTH_SUCCESS", token }),
);
setWebviewAuthSuccess(true);
@@ -1047,7 +1037,7 @@ export function Auth({ onLogin }: AuthProps) {
throw new Error(t("errors.invalidAuthUrl"));
window.location.replace(authUrl);
} catch (err: unknown) {
showLoginError(err, "errors.failedOidcLogin");
showLoginError(err, "errors.failedExternalLogin");
setLoading(false);
}
},
@@ -1133,7 +1123,7 @@ export function Auth({ onLogin }: AuthProps) {
}
if (urlTriggered) {
toast.info(t("errors.silentSigninOidcUnavailable"));
toast.info(t("errors.silentSigninExternalUnavailable"));
}
}, [startRedirect, authMethodsLoaded, externalMethods, t]);
@@ -1204,7 +1194,11 @@ export function Auth({ onLogin }: AuthProps) {
{t("messages.databaseConnectionFailed")}
</p>
</div>
<Button onClick={() => window.location.reload()}>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
>
{t("common.refresh")}
</Button>
<LanguageRow
@@ -1228,39 +1222,26 @@ export function Auth({ onLogin }: AuthProps) {
return (
<div className="fixed inset-0 flex flex-col bg-background overflow-hidden">
<div className="flex flex-1 overflow-hidden">
<div className="hidden lg:flex flex-col w-[420px] shrink-0 bg-sidebar border-r border-border relative overflow-hidden select-none">
<div
className="absolute inset-0"
style={{
backgroundImage:
"radial-gradient(circle, color-mix(in oklch, var(--border) 80%, transparent) 1px, transparent 1px)",
backgroundSize: "24px 24px",
}}
/>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10 px-12">
<span className="text-4xl font-bold tracking-[0.3em] font-mono">
TERMIX
</span>
<div className="w-8 h-px bg-accent-brand" />
<span className="text-[11px] font-mono text-muted-foreground uppercase tracking-[0.25em]">
{t("auth.tagline")}
</span>
</div>
</div>
<BrandPanel
logo={branding.logo}
appName={branding.appName}
tagline={branding.tagline || t("auth.tagline")}
/>
<div className="flex flex-1 items-center justify-center p-6 overflow-y-auto relative">
<div className="w-full max-w-sm flex flex-col gap-6">
<div className="w-full max-w-[380px] flex flex-col gap-6 border border-border bg-card p-5">
<CompactBrand logo={branding.logo} appName={branding.appName} />
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<h1 className="text-xl font-bold">
{desktopManualLogoutActive
? "Local desktop signed out"
: "Local desktop session unavailable"}
? t("auth.localDesktopSignedOut")
: t("auth.localDesktopUnavailable")}
</h1>
<p className="text-xs text-muted-foreground leading-relaxed">
{desktopManualLogoutActive
? "You signed out of the local desktop session. Continue locally to use this device's embedded Termix server again."
: "Termix could not create a local desktop session. Retry the embedded local session instead of registering a new account."}
? t("auth.localDesktopSignedOutDesc")
: t("auth.localDesktopUnavailableDesc")}
</p>
</div>
<Button
@@ -1270,8 +1251,8 @@ export function Auth({ onLogin }: AuthProps) {
onClick={continueLocalDesktopSession}
>
{desktopManualLogoutActive
? "Continue with local desktop"
: "Retry local desktop session"}
? t("auth.continueLocalDesktop")
: t("auth.retryLocalDesktop")}
</Button>
<Separator />
<LanguageRow
@@ -1308,37 +1289,16 @@ export function Auth({ onLogin }: AuthProps) {
return (
<div className="fixed inset-0 flex flex-col bg-background overflow-hidden">
<div className="flex flex-1 overflow-hidden">
{/* Left decorative panel */}
<div className="hidden lg:flex flex-col w-[420px] shrink-0 bg-sidebar border-r border-border relative overflow-hidden select-none">
<div
className="absolute inset-0"
style={{
backgroundImage:
"radial-gradient(circle, color-mix(in oklch, var(--border) 80%, transparent) 1px, transparent 1px)",
backgroundSize: "24px 24px",
}}
/>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10 px-12">
{branding.logo && (
<img
src={branding.logo}
alt=""
className="w-16 h-16 object-contain mb-1"
/>
)}
<span className="text-4xl font-bold tracking-[0.3em] font-mono uppercase">
{branding.appName}
</span>
<div className="w-8 h-px bg-accent-brand" />
<span className="text-[11px] font-mono text-muted-foreground uppercase tracking-[0.25em]">
{branding.tagline || t("auth.tagline")}
</span>
</div>
</div>
<BrandPanel
logo={branding.logo}
appName={branding.appName}
tagline={branding.tagline || t("auth.tagline")}
/>
{/* Right panel */}
<div className="flex flex-1 items-center justify-center p-6 overflow-y-auto relative">
<div className="w-full max-w-sm flex flex-col gap-6">
<div className="w-full max-w-[380px] flex flex-col gap-6 border border-border bg-card p-5">
<CompactBrand logo={branding.logo} appName={branding.appName} />
{/* TOTP view */}
{view === "second-factor" && (
<div className="flex flex-col gap-5">
@@ -1384,11 +1344,13 @@ export function Auth({ onLogin }: AuthProps) {
)}
</p>
<Button
variant="outline"
type="button"
disabled={authPluginsLoading}
onClick={() =>
void retryAuthPlugins().catch(() => {})
}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
>
{t("auth.retrySecondFactorUI")}
</Button>
+4 -4
View File
@@ -98,7 +98,7 @@ export function ElectronLoginForm({
return;
}
// OIDC login requested from inside the iframe — open the system browser
// OIDC login requested from inside the iframe, open the system browser
// so captcha stages (e.g. Cloudflare Turnstile) render correctly.
if (type === "OIDC_SYSTEM_BROWSER_AUTH" && authUrl && callbackPort) {
const sendResultToIframe = (result: {
@@ -138,7 +138,7 @@ export function ElectronLoginForm({
if (!electronAPI?.externalBrowserLogin) {
sendResultToIframe({
success: false,
error: t("errors.failedOidcLogin"),
error: t("errors.failedExternalLogin"),
});
return;
}
@@ -162,7 +162,7 @@ export function ElectronLoginForm({
}
sendResultToIframe({
success: false,
error: result.error || t("errors.failedOidcLogin"),
error: result.error || t("errors.failedExternalLogin"),
});
}
} catch (err) {
@@ -172,7 +172,7 @@ export function ElectronLoginForm({
typeof event.data.providerId === "number"
? event.data.providerId
: undefined;
const error = getErrorMessage(err, t("errors.failedOidcLogin"));
const error = getErrorMessage(err, t("errors.failedExternalLogin"));
iframeRef.current?.contentWindow?.postMessage(
{
type: "OIDC_SYSTEM_BROWSER_AUTH_RESULT",
+58
View File
@@ -0,0 +1,58 @@
/** The left half of the sign-in screen: brand mark, tagline, a rule. */
export function BrandPanel({
logo,
appName,
tagline,
}: {
logo?: string | null;
appName: string;
tagline: string;
}) {
return (
<div className="relative hidden w-[46%] max-w-[560px] shrink-0 select-none flex-col justify-between overflow-hidden border-r border-border bg-sidebar p-10 lg:flex">
<div
className="absolute inset-0 opacity-70"
style={{
backgroundImage:
"radial-gradient(circle, color-mix(in oklch, var(--border) 80%, transparent) 1px, transparent 1px)",
backgroundSize: "22px 22px",
}}
/>
<div className="absolute inset-x-0 top-0 h-px bg-accent-brand/60" />
<div className="relative flex items-center gap-3">
{logo && <img src={logo} alt="" className="size-8 object-contain" />}
<span className="font-mono text-lg font-bold uppercase tracking-[0.35em]">
{appName}
</span>
</div>
<div className="relative max-w-[380px]">
<h2 className="text-2xl font-semibold leading-tight tracking-tight">
{tagline}
</h2>
<div className="mt-4 h-px w-10 bg-accent-brand" />
</div>
<div className="relative h-3" />
</div>
);
}
/** The brand line shown above the form when the brand panel is hidden. */
export function CompactBrand({
logo,
appName,
}: {
logo?: string | null;
appName: string;
}) {
return (
<div className="flex items-center gap-2.5 lg:hidden">
{logo && <img src={logo} alt="" className="size-7 object-contain" />}
<span className="font-mono text-sm font-bold uppercase tracking-[0.3em]">
{appName}
</span>
</div>
);
}
+105
View File
@@ -0,0 +1,105 @@
/* eslint-disable react-refresh/only-export-components */
import {
Children,
Fragment,
isValidElement,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import type React from "react";
import { PANEL } from "@/components/panel-layout";
import { cn } from "@/lib/utils";
/** Column counts per container width tier, for each column preference. */
const COLUMN_STEPS: Record<number, [number, number, number, number]> = {
1: [1, 1, 1, 1],
2: [1, 2, 2, 2],
3: [1, 2, 3, 3],
4: [1, 2, 3, 4],
};
const TIERS = { md: 640, lg: 960, xl: 1240 };
/** How many columns fit a container of this width for a column preference. */
export function masonryColumns(width: number, columns: number): number {
const steps = COLUMN_STEPS[columns] ?? COLUMN_STEPS[3];
if (width >= TIERS.xl) return steps[3];
if (width >= TIERS.lg) return steps[2];
if (width >= TIERS.md) return steps[1];
return steps[0];
}
function flattenChildren(children: React.ReactNode): React.ReactNode[] {
return Children.toArray(children).flatMap((child) =>
isValidElement(child) && child.type === Fragment
? flattenChildren(
(child.props as { children?: React.ReactNode }).children,
)
: [child],
);
}
/**
* Cards dealt round-robin into columns that stack tight. The assignment only
* depends on child order and column count, so live updates never reshuffle
* cards between columns. Width comes from the container, not the window, so
* split panes and docks get the right count.
*/
export function CardMasonry({
columns,
stack = false,
className,
children,
}: {
columns: number;
/** Force one full-width column, for cards holding wide tables. */
stack?: boolean;
className?: string;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setWidth(el.clientWidth);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
observer.observe(el);
return () => observer.disconnect();
}, []);
const count = stack ? 1 : masonryColumns(width, columns);
const items = useMemo(() => flattenChildren(children), [children]);
const buckets = useMemo(() => {
const next: React.ReactNode[][] = Array.from({ length: count }, () => []);
items.forEach((child, i) => next[i % count].push(child));
return next;
}, [items, count]);
return (
<div
ref={ref}
className={cn(
count === 1
? `flex flex-col ${PANEL.gap}`
: `flex items-start ${PANEL.gap}`,
className,
)}
>
{count === 1
? items
: buckets.map((bucket, i) => (
<div
key={i}
className={`flex min-w-0 flex-1 flex-col ${PANEL.gap}`}
>
{bucket}
</div>
))}
</div>
);
}
+4 -7
View File
@@ -1,5 +1,6 @@
import { useRef, useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { parseServerTime } from "@/lib/server-time";
export interface LineChartSeries {
key: string;
@@ -23,12 +24,8 @@ const X_LABEL_H = 20;
const TOP_PAD = 6;
const TICK_COUNT = 5;
function parseTs(ts: string): Date {
return new Date(ts.includes("T") ? ts : ts.replace(" ", "T") + "Z");
}
function formatTimestamp(ts: string, rangeMs: number): string {
const d = parseTs(ts);
const d = parseServerTime(ts);
if (rangeMs <= 25 * 60 * 60 * 1000) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
@@ -104,8 +101,8 @@ export function LineChart({
const rangeMs =
timestamps.length >= 2
? parseTs(timestamps[timestamps.length - 1]).getTime() -
parseTs(timestamps[0]).getTime()
? parseServerTime(timestamps[timestamps.length - 1]).getTime() -
parseServerTime(timestamps[0]).getTime()
: 0;
const yTicks = (() => {
@@ -0,0 +1,267 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import type React from "react";
import { useConnectionRetry } from "@termix-ssh/plugin-sdk/frontend";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext";
import {
ConnectionScreen,
type ConnectionUnavailable,
} from "@/components/connection/ConnectionScreen";
import type { ConnectionStatus } from "@/components/connection/connection-status";
import type { ConnectionStage } from "@/types/connection-log";
import { getErrorMessage } from "@/lib/error-message";
function messageOf(err: unknown): string {
return typeof err === "string" ? err : getErrorMessage(err);
}
type LogFn = (message: string, stage?: ConnectionStage) => void;
export interface ConnectionGateController {
status: ConnectionStatus;
attempt: number;
maxAttempts: number;
nextRetryInMs: number | null;
error: string | null;
unavailable: ConnectionUnavailable | null;
log: { info: LogFn; success: LogFn; warning: LogFn; error: LogFn };
markConnected: () => void;
/** It did not come up. Logs the reason and schedules a retry. */
markFailed: (error?: unknown) => void;
/** It was up and dropped. No automatic retry. */
markDisconnected: (error?: unknown) => void;
/** It cannot run here, like a feature that is off for this host. */
markUnavailable: (info: ConnectionUnavailable) => void;
retryNow: () => void;
reset: () => void;
}
export interface ConnectionGateProps {
/** Opens the connection. Throwing or rejecting counts as a failure. */
connect: (gate: ConnectionGateController) => void | Promise<void>;
message: string;
detail?: string;
disconnectedMessage?: string;
retry?: {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
autoStart?: boolean;
enabled?: boolean;
};
/** Keep children mounted under the screen, for a terminal or display. */
keepMounted?: boolean;
backgroundColor?: string;
extraActions?: React.ReactNode;
className?: string;
children:
React.ReactNode | ((gate: ConnectionGateController) => React.ReactNode);
}
const GateContext = createContext<ConnectionGateController | null>(null);
/** The controller of the ConnectionGate this renders inside. */
export function useConnectionGate(): ConnectionGateController {
const gate = useContext(GateContext);
if (!gate) {
throw new Error("useConnectionGate must be used inside a ConnectionGate");
}
return gate;
}
/**
* One connection lifecycle for every tab: connecting, failed with retry and
* a log, dropped, or unavailable. Children render once it is up.
*/
export function ConnectionGate(props: ConnectionGateProps) {
return (
<ConnectionLogProvider>
<Gate {...props} />
</ConnectionLogProvider>
);
}
function Gate({
connect,
message,
detail,
disconnectedMessage,
retry,
keepMounted,
backgroundColor,
extraActions,
className,
children,
}: ConnectionGateProps) {
const { addLog, clearLogs } = useConnectionLog();
const [error, setError] = useState<string | null>(null);
const [unavailable, setUnavailable] = useState<ConnectionUnavailable | null>(
null,
);
const [dropped, setDropped] = useState(false);
const maxAttempts = retry?.maxAttempts ?? 8;
const gateRef = useRef<ConnectionGateController | null>(null);
const write = useCallback(
(type: "info" | "success" | "warning" | "error"): LogFn =>
(text, stage = "connection") =>
addLog({ type, stage, message: text }),
[addLog],
);
const retryState = useConnectionRetry({
connect: async () => {
setError(null);
setUnavailable(null);
setDropped(false);
try {
await connect(gateRef.current!);
} catch (err) {
gateRef.current?.markFailed(err);
}
},
maxAttempts,
baseDelayMs: retry?.baseDelayMs,
maxDelayMs: retry?.maxDelayMs,
autoStart: retry?.autoStart,
enabled: retry?.enabled,
});
const {
markConnected: retryConnected,
markFailed: retryFailed,
retryNow: retryAgain,
reset: retryReset,
} = retryState;
const log = useMemo(
() => ({
info: write("info"),
success: write("success"),
warning: write("warning"),
error: write("error"),
}),
[write],
);
const markFailed = useCallback(
(err?: unknown) => {
if (err !== undefined) {
const text = messageOf(err);
setError(text);
log.error(text, "error");
}
retryFailed();
},
[log, retryFailed],
);
const markDisconnected = useCallback(
(err?: unknown) => {
if (err !== undefined) {
const text = messageOf(err);
setError(text);
log.warning(text, "connection");
}
setDropped(true);
},
[log],
);
const markUnavailable = useCallback((info: ConnectionUnavailable) => {
setUnavailable(info);
}, []);
const retryNow = useCallback(() => {
clearLogs();
setError(null);
setUnavailable(null);
setDropped(false);
retryAgain();
}, [clearLogs, retryAgain]);
const reset = useCallback(() => {
setError(null);
setUnavailable(null);
setDropped(false);
retryReset();
}, [retryReset]);
let status: ConnectionStatus = retryState.status;
if (dropped) status = "disconnected";
const controller = useMemo<ConnectionGateController>(
() => ({
status,
attempt: retryState.attempt,
maxAttempts,
nextRetryInMs: retryState.nextRetryInMs,
error,
unavailable,
log,
markConnected: () => {
setDropped(false);
setError(null);
retryConnected();
},
markFailed,
markDisconnected,
markUnavailable,
retryNow,
reset,
}),
[
status,
retryState.attempt,
retryState.nextRetryInMs,
maxAttempts,
error,
unavailable,
log,
retryConnected,
markFailed,
markDisconnected,
markUnavailable,
retryNow,
reset,
],
);
gateRef.current = controller;
const up = status === "connected" && !unavailable;
const content =
typeof children === "function" ? children(controller) : children;
return (
<GateContext.Provider value={controller}>
<div className={`relative h-full w-full ${className ?? ""}`}>
{(up || keepMounted) && content}
<ConnectionScreen
status={status}
message={message}
detail={detail}
errorDetail={error}
unavailable={unavailable}
disconnectedMessage={disconnectedMessage}
attempt={retryState.attempt}
maxAttempts={maxAttempts}
nextRetryInMs={retryState.nextRetryInMs}
onManualRetry={retryNow}
extraActions={extraActions}
backgroundColor={backgroundColor}
logPosition={status === "connecting" ? "bottom" : "top"}
/>
</div>
</GateContext.Provider>
);
}
@@ -1,36 +1,43 @@
import React, { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useOptionalConnectionLog } from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard.ts";
import { Button } from "@/components/button.tsx";
import { cn } from "@/lib/utils.ts";
import {
ChevronDown,
ChevronUp,
Copy,
Info,
CheckCircle2,
AlertTriangle,
XCircle,
} from "lucide-react";
import { Check, ChevronDown, ChevronUp, Copy } from "lucide-react";
import { toast } from "sonner";
interface ConnectionLogPanelProps {
isConnecting: boolean;
isConnected: boolean;
hasConnectionError: boolean;
/** Kept for older callers. The log always sits at the bottom. */
position?: "top" | "bottom";
className?: string;
}
const COLLAPSED_HEIGHT = "h-[140px]";
const EXPANDED_HEIGHT = "h-[45%] min-h-[240px]";
const COLLAPSED_HEIGHT = "h-[136px]";
const EXPANDED_HEIGHT = "h-[46%] min-h-[220px]";
/** A dot per line, so the log reads as one column of text. */
const DOT: Record<string, string> = {
info: "bg-muted-foreground/50",
success: "bg-accent-brand",
warning: "bg-warning",
error: "bg-destructive",
};
const TEXT: Record<string, string> = {
info: "text-foreground-secondary",
success: "text-foreground",
warning: "text-warning",
error: "text-destructive",
};
export function ConnectionLogPanel({
isConnecting,
isConnected,
hasConnectionError,
position = "bottom",
className,
}: ConnectionLogPanelProps) {
const { t } = useTranslation();
@@ -39,6 +46,7 @@ export function ConnectionLogPanel({
connectionLog ?? {};
const lastLogRef = useRef<HTMLDivElement>(null);
const [manuallyCollapsed, setManuallyCollapsed] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (hasConnectionError && setIsExpanded) {
@@ -56,7 +64,7 @@ export function ConnectionLogPanel({
useEffect(() => {
if (lastLogRef.current) {
lastLogRef.current.scrollIntoView({ block: "end" });
lastLogRef.current.scrollIntoView?.({ block: "end" });
}
}, [logs]);
@@ -88,113 +96,99 @@ export function ConnectionLogPanel({
.join("\n");
const ok = await copyToClipboard(logsText);
if (ok) toast.success(t("terminal.connectionLogCopied"));
else toast.error(t("terminal.connectionLogCopyFailed"));
};
const getIcon = (type: string) => {
switch (type) {
case "info":
return <Info className="h-4 w-4 text-blue-500 shrink-0" />;
case "success":
return <CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />;
case "warning":
return <AlertTriangle className="h-4 w-4 text-yellow-500 shrink-0" />;
case "error":
return <XCircle className="h-4 w-4 text-red-500 shrink-0" />;
default:
return <Info className="h-4 w-4 shrink-0" />;
}
};
const getTextColor = (type: string) => {
switch (type) {
case "info":
return "text-blue-400";
case "success":
return "text-green-400";
case "warning":
return "text-yellow-400";
case "error":
return "text-red-400";
default:
return "text-muted-foreground";
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 1400);
toast.success(t("sshAuth.connectionLogCopied"));
} else {
toast.error(t("sshAuth.connectionLogCopyFailed"));
}
};
return (
<div
className={cn(
"relative z-10 shrink-0 flex flex-col bg-bg-base",
position === "bottom"
? "border-t border-border"
: "border-b border-border",
"relative z-10 flex shrink-0 flex-col bg-surface-dim/60",
"border-t border-border",
expanded ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT,
"transition-[height] duration-150",
"transition-[height] duration-200",
className,
)}
>
<div className="flex items-center justify-between px-3 py-1.5 shrink-0 border-b border-border/60">
<Button
variant="ghost"
size="sm"
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border px-2 pr-1.5">
<button
type="button"
onClick={handleToggle}
className="flex items-center gap-2 -ml-2"
aria-expanded={expanded}
className="flex min-w-0 items-center gap-1.5 px-1.5 py-1 text-foreground-secondary transition-colors hover:text-foreground"
>
{expanded ? (
<ChevronDown className="h-4 w-4" />
<ChevronDown className="size-3.5 shrink-0" />
) : (
<ChevronUp className="h-4 w-4" />
<ChevronUp className="size-3.5 shrink-0" />
)}
<span className="text-sm font-medium">
{t("terminal.connectionLogTitle")} ({logs.length})
<span className="text-[10px] font-semibold uppercase tracking-widest">
{t("sshAuth.connectionLogTitle")}
</span>
</Button>
</button>
<span className="text-xs tabular-nums text-muted-foreground">
{logs.length}
</span>
{logs.length > 0 && (
<Button
variant="ghost"
size="icon-sm"
onClick={copyLogsToClipboard}
title={t("terminal.connectionLogCopy")}
title={t("sshAuth.connectionLogCopy")}
className="ml-auto text-muted-foreground"
>
<Copy className="h-4 w-4" />
{copied ? (
<Check className="size-3.5 text-accent-brand" />
) : (
<Copy className="size-3.5" />
)}
</Button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden thin-scrollbar">
<div className="px-3 py-2">
{logs.length === 0 ? (
<div className="py-4 text-center text-sm text-muted-foreground">
{isConnecting
? t("terminal.connectionLogWaiting")
: t("terminal.connectionLogEmpty")}
</div>
) : (
<div className="space-y-1 font-mono text-xs">
{logs.map((log, index) => (
<div
key={log.id}
ref={index === logs.length - 1 ? lastLogRef : null}
className="flex items-start gap-2"
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
{logs.length === 0 ? (
<p className="px-3 py-4 text-xs text-muted-foreground">
{isConnecting
? t("sshAuth.connectionLogWaiting")
: t("sshAuth.connectionLogEmpty")}
</p>
) : (
<div className="px-2 py-1.5 font-mono text-[11px] leading-[1.7]">
{logs.map((log, index) => (
<div
key={log.id}
ref={index === logs.length - 1 ? lastLogRef : null}
className="flex items-baseline gap-2 px-1 hover:bg-foreground/[0.03]"
>
<span className="shrink-0 tabular-nums text-muted-foreground/70">
{log.timestamp.toLocaleTimeString([], { hour12: false })}
</span>
<span
className={cn(
"size-1.5 shrink-0 translate-y-[-1px] rounded-full",
DOT[log.type] ?? DOT.info,
)}
/>
<span
className={cn(
"min-w-0 flex-1 whitespace-pre-wrap break-all",
TEXT[log.type] ?? TEXT.info,
)}
>
<span className="shrink-0 text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>
{getIcon(log.type)}
<span
className={cn(
"flex-1 min-w-0 break-all whitespace-pre-wrap",
getTextColor(log.type),
)}
>
{log.message}
</span>
</div>
))}
</div>
)}
</div>
{log.message}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
+170 -45
View File
@@ -2,120 +2,245 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils.ts";
import { Button } from "@/components/button.tsx";
import { Loader2, RefreshCw } from "lucide-react";
import { RefreshCw, X } from "lucide-react";
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
import { useOptionalConnectionLog } from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { useSurfaceClose } from "@/components/surface/surface-scope.tsx";
import type { ConnectionStatus } from "@/components/connection/connection-status.ts";
interface ConnectionScreenProps {
export interface ConnectionUnavailable {
title: string;
hint?: string;
/** Another way forward, like opening the host editor. */
action?: React.ReactNode;
}
export interface ConnectionScreenProps {
status: ConnectionStatus;
/** Headline while connecting. */
message?: string;
/** Second line under the headline, usually user@host:port. */
detail?: string;
/** The reason it failed. Falls back to the last error in the log. */
errorDetail?: string | null;
/** Shown instead of a failure when the feature cannot run here. */
unavailable?: ConnectionUnavailable | null;
backgroundColor?: string;
attempt?: number;
maxAttempts?: number;
nextRetryInMs?: number | null;
onManualRetry?: () => void;
retryLabel?: string;
/** Headline when it was up and dropped. */
disconnectedMessage?: string;
/** Overrides closing the tab this sits in. False hides the button. */
onClose?: (() => void) | false;
extraActions?: React.ReactNode;
logPosition?: "top" | "bottom";
emptyState?: React.ReactNode;
className?: string;
}
/** The one screen every connection shows until it is up. */
export function ConnectionScreen({
status,
message,
detail,
errorDetail,
unavailable,
backgroundColor,
attempt = 0,
maxAttempts = 0,
nextRetryInMs = null,
onManualRetry,
retryLabel,
disconnectedMessage,
onClose,
extraActions,
logPosition = "bottom",
emptyState,
className,
}: ConnectionScreenProps) {
const { t } = useTranslation();
const surfaceClose = useSurfaceClose();
const logs = useOptionalConnectionLog()?.logs;
const close = onClose === false ? null : (onClose ?? surfaceClose);
if (status === "connected" && !emptyState) {
if (status === "connected" && !emptyState && !unavailable) {
return null;
}
const showSpinner = status === "connecting";
const showRetryButton = status === "disconnected" && !!onManualRetry;
const showLog = status !== "connected";
const connecting = status === "connecting" && !unavailable;
const failed = status === "error" || status === "disconnected";
const retrying =
status === "error" && attempt > 0 && !!nextRetryInMs && nextRetryInMs > 0;
const stopped = failed || !!unavailable;
const showRetryButton = stopped && !!onManualRetry;
const showClose = stopped && !!close;
const reason =
errorDetail ||
[...(logs ?? [])].reverse().find((entry) => entry.type === "error")
?.message ||
null;
const showLog = !emptyState && (status !== "connected" || !!unavailable);
let headline = message;
if (unavailable) headline = unavailable.title;
else if (retrying) headline = t("connection.failedRetrying");
else if (status === "disconnected" && disconnectedMessage)
headline = disconnectedMessage;
else if (failed) headline = t("connection.failed");
const showCountdown = attempt > 0 && (connecting || retrying);
return (
<div
role="status"
aria-live="polite"
data-status={status}
data-status={unavailable ? "unavailable" : status}
className={cn(
"motion-context-enter absolute inset-0 z-[100] flex flex-col",
className,
)}
style={{ backgroundColor: backgroundColor || "var(--bg-base)" }}
>
<div className="flex-1 min-h-0 flex items-center justify-center">
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
{emptyState ? (
emptyState
) : (
<div className="flex flex-col items-center gap-4 px-6 text-center">
{showSpinner && (
<Loader2
className="size-8 animate-spin text-accent-brand"
aria-hidden="true"
/>
)}
{message && (
<p className="text-sm text-foreground-secondary font-medium">
{message}
</p>
)}
{attempt > 0 && status !== "disconnected" && (
<p className="text-xs text-muted-foreground">
{nextRetryInMs && nextRetryInMs > 0
? t("connection.retryingIn", {
seconds: Math.ceil(nextRetryInMs / 1000),
attempt,
max: maxAttempts,
})
: t("connection.retryingNow", { attempt, max: maxAttempts })}
</p>
)}
{showRetryButton && (
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-foreground-secondary font-medium">
{disconnectedMessage || t("connection.disconnected")}
<div className="flex max-w-md flex-col items-center gap-3.5 text-center">
<ConnectionMark
state={
unavailable
? "unavailable"
: connecting || retrying
? "busy"
: "failed"
}
/>
<div className="space-y-1.5">
{headline && (
<p className="text-sm font-semibold tracking-tight text-foreground">
{headline}
</p>
<div className="flex gap-2">
)}
{detail && (
<p className="font-mono text-xs text-muted-foreground">
{detail}
</p>
)}
{unavailable?.hint && (
<p className="text-xs leading-snug text-muted-foreground">
{unavailable.hint}
</p>
)}
{failed && !unavailable && reason && (
<p className="line-clamp-3 text-xs leading-snug text-destructive/80">
{reason}
</p>
)}
{showCountdown && (
<p className="text-xs tabular-nums text-muted-foreground">
{nextRetryInMs && nextRetryInMs > 0
? t("connection.retryingIn", {
seconds: Math.ceil(nextRetryInMs / 1000),
attempt,
max: maxAttempts,
})
: t("connection.retryingNow", {
attempt,
max: maxAttempts,
})}
</p>
)}
</div>
{(showRetryButton ||
showClose ||
(stopped && extraActions) ||
unavailable?.action) && (
<div className="flex flex-wrap justify-center gap-2 pt-0.5">
{showRetryButton && (
<Button
variant="outline"
size="default"
size="sm"
onClick={onManualRetry}
className="gap-2 font-semibold"
>
<RefreshCw className="size-3.5" />
{retryLabel || t("connection.reconnect")}
{retrying
? t("connection.retryNow")
: t("connection.reconnect")}
</Button>
{extraActions}
</div>
)}
{unavailable?.action}
{stopped && extraActions}
{showClose && (
<Button
variant="outline"
size="sm"
onClick={close}
className="gap-2 font-semibold"
>
<X className="size-3.5" />
{t("connection.close")}
</Button>
)}
</div>
)}
</div>
)}
</div>
{showLog && !emptyState && (
{showLog && (
<ConnectionLogPanel
isConnecting={status === "connecting"}
isConnecting={connecting}
isConnected={false}
hasConnectionError={status === "error" || status === "disconnected"}
hasConnectionError={stopped}
position={logPosition}
/>
)}
</div>
);
}
/** A thin arc while working, a still ring with a dot once it stops. */
function ConnectionMark({
state,
}: {
state: "busy" | "failed" | "unavailable";
}) {
return (
<div className="relative size-9" aria-hidden="true">
<svg viewBox="0 0 36 36" className="size-full">
<circle
cx="18"
cy="18"
r="15"
fill="none"
strokeWidth="2"
className="stroke-border"
/>
{state === "busy" && (
<circle
cx="18"
cy="18"
r="15"
fill="none"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray="26 68"
className="origin-center animate-spin stroke-accent-brand [animation-duration:900ms]"
/>
)}
</svg>
{state !== "busy" && (
<span
className={cn(
"absolute inset-0 m-auto size-1.5 rounded-full",
state === "failed" ? "bg-destructive" : "bg-muted-foreground",
)}
/>
)}
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import type React from "react";
import { cn } from "@/lib/utils";
import {
PANEL,
type PanelDensity,
type PanelViewMode,
} from "@/components/panel-layout";
export interface DataColumn<T> {
key: string;
header: string;
/** Any grid track: "1fr", "120px", "minmax(0,2fr)". */
width: string;
cell: (item: T) => React.ReactNode;
/** Dropped below md, for columns that are nice rather than necessary. */
hideBelow?: "md" | "lg";
align?: "start" | "end";
}
export interface GridColumns {
base?: number;
md?: number;
lg?: number;
xl?: number;
}
// Spelled out so Tailwind can see every class.
const COLS: Record<number, string> = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
};
const MD_COLS: Record<number, string> = {
1: "md:grid-cols-1",
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-4",
};
const LG_COLS: Record<number, string> = {
1: "lg:grid-cols-1",
2: "lg:grid-cols-2",
3: "lg:grid-cols-3",
4: "lg:grid-cols-4",
};
const XL_COLS: Record<number, string> = {
1: "xl:grid-cols-1",
2: "xl:grid-cols-2",
3: "xl:grid-cols-3",
4: "xl:grid-cols-4",
};
function gridClass(columns: GridColumns): string {
return cn(
COLS[columns.base ?? 1],
columns.md && MD_COLS[columns.md],
columns.lg && LG_COLS[columns.lg],
columns.xl && XL_COLS[columns.xl],
);
}
/** One collection as a card grid or a dense row table. */
export function DataView<T>({
items,
view,
density,
getKey,
renderCard,
columns,
listColumns,
onRowClick,
isRowActive,
empty,
className,
}: {
items: T[];
view: PanelViewMode;
density?: PanelDensity;
getKey: (item: T) => string;
renderCard: (item: T) => React.ReactNode;
columns: GridColumns;
listColumns: DataColumn<T>[];
onRowClick?: (item: T) => void;
isRowActive?: (item: T) => boolean;
empty: React.ReactNode;
className?: string;
}) {
if (items.length === 0) {
return (
<div className="flex flex-1 items-center justify-center">{empty}</div>
);
}
if (view === "grid") {
return (
<div className={cn("grid", PANEL.gap, gridClass(columns), className)}>
{items.map((item) => (
<div key={getKey(item)}>{renderCard(item)}</div>
))}
</div>
);
}
const template = listColumns.map((c) => c.width).join(" ");
const rowHeight = density === "compact" ? "h-7" : "h-9";
return (
<div className={cn("border border-border bg-card", className)} role="table">
<div
className="sticky top-0 z-10 grid items-center border-b border-border bg-card px-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
style={{ gridTemplateColumns: template }}
>
{listColumns.map((column) => (
<span
key={column.key}
className={cn(
"truncate py-2",
column.align === "end" && "text-right",
column.hideBelow === "md" && "hidden md:block",
column.hideBelow === "lg" && "hidden lg:block",
)}
>
{column.header}
</span>
))}
</div>
<div>
{items.map((item) => {
const active = isRowActive?.(item) ?? false;
return (
<div
key={getKey(item)}
onClick={onRowClick ? () => onRowClick(item) : undefined}
className={cn(
"grid items-center gap-x-2 border-b border-border/50 px-3 text-xs transition-colors last:border-0",
rowHeight,
onRowClick && "cursor-pointer",
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
)}
style={{ gridTemplateColumns: template }}
>
{listColumns.map((column) => (
<div
key={column.key}
className={cn(
"min-w-0 truncate",
column.align === "end" && "text-right",
column.hideBelow === "md" && "hidden md:block",
column.hideBelow === "lg" && "hidden lg:block",
)}
>
{column.cell(item)}
</div>
))}
</div>
);
})}
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
/* eslint-disable react-refresh/only-export-components */
import { BookOpen } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { pluginDocsPage } from "@termix-ssh/plugin-sdk/docs";
import { docsUrl, type DocsPage } from "@/lib/docs";
import { cn } from "@/lib/utils";
import { usePluginScope } from "@/plugin-host/scope";
import { usePluginStore } from "@/plugin-host/plugin-store";
/** A plugin's docs link from its manifest, or null when it has none. */
export function usePluginDocsUrl(
pluginId: string | null | undefined,
page?: string,
anchor?: string,
): string | null {
const { records } = usePluginStore();
const base = pluginId ? records.get(pluginId)?.summary.docs : undefined;
return base ? pluginDocsPage(base, page, anchor) : null;
}
export interface DocsLinkProps {
/** A core docs page. */
core?: DocsPage;
/**
* A page of the current plugin's docs, like "setup". Inside a plugin this
* resolves against its manifest docs link; "" is its main page.
*/
page?: string;
anchor?: string;
/** A full URL, used as is. */
href?: string | null;
/** "text" is a small inline link, "icon" a square header button. */
variant?: "text" | "icon";
children?: ReactNode;
className?: string;
}
/**
* The one way the app links to docs. Renders nothing when there is nowhere
* to go, like a community plugin with no docs link.
*/
export function DocsLink({
core,
page,
anchor,
href,
variant = "text",
children,
className,
}: DocsLinkProps) {
const { t } = useTranslation();
const pluginId = usePluginScope();
const pluginUrl = usePluginDocsUrl(
page !== undefined ? pluginId : null,
page,
anchor,
);
const url =
href ?? (core ? withAnchor(docsUrl(core), anchor) : null) ?? pluginUrl;
if (!url) return null;
const label = t("common.docs");
if (variant === "icon") {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
title={label}
aria-label={label}
className={cn(
"flex size-7 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground",
className,
)}
>
<BookOpen className="size-3.5" />
</a>
);
}
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"shrink-0 text-[10px] text-accent-brand hover:underline",
className,
)}
>
{children ?? label}
</a>
);
}
function withAnchor(url: string, anchor?: string): string {
if (!anchor) return url;
return `${url.split("#")[0]}#${anchor.replace(/^#/, "")}`;
}
+480
View File
@@ -0,0 +1,480 @@
/* eslint-disable react-refresh/only-export-components */
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, X } from "lucide-react";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { Button } from "@/components/button";
import { SettingRow, FakeSwitch } from "@/components/section-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import { cn } from "@/lib/utils";
/** Label + control + optional hint, the shape every field shares. */
export function Field({
label,
hint,
error,
aside,
htmlFor,
className,
children,
}: {
label: string;
hint?: ReactNode;
error?: string;
aside?: ReactNode;
htmlFor?: string;
className?: string;
children: ReactNode;
}) {
return (
<div data-setting-row="" className={cn("flex flex-col gap-1.5", className)}>
{(label || aside) && (
<div className="flex items-center gap-2">
<label
htmlFor={htmlFor}
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
>
{label}
</label>
{aside && <span className="ml-auto">{aside}</span>}
</div>
)}
{children}
{error ? (
<span className="text-[10px] text-destructive">{error}</span>
) : hint ? (
<span className="text-[10px] text-muted-foreground">{hint}</span>
) : null}
</div>
);
}
export function TextField({
label,
value,
onChange,
placeholder,
hint,
error,
aside,
disabled,
mono,
className,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
hint?: ReactNode;
error?: string;
aside?: ReactNode;
disabled?: boolean;
mono?: boolean;
className?: string;
}) {
return (
<Field
label={label}
hint={hint}
error={error}
aside={aside}
className={className}
>
<Input
value={value}
placeholder={placeholder}
disabled={disabled}
aria-invalid={!!error}
className={mono ? "font-mono" : undefined}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
);
}
export function NumberField({
label,
value,
onChange,
placeholder,
hint,
error,
min,
max,
disabled,
className,
}: {
label: string;
value: number;
onChange: (value: number) => void;
placeholder?: string;
hint?: ReactNode;
error?: string;
min?: number;
max?: number;
disabled?: boolean;
className?: string;
}) {
return (
<Field label={label} hint={hint} error={error} className={className}>
<Input
type="number"
value={Number.isFinite(value) ? value : ""}
placeholder={placeholder}
min={min}
max={max}
disabled={disabled}
aria-invalid={!!error}
onChange={(e) => onChange(Number(e.target.value))}
/>
</Field>
);
}
/** Values the server sends in place of a stored secret. */
export const SECRET_SENTINELS = [
"existing_password",
"existing_key",
"existing_key_password",
"existing_sudo_password",
"existing_rdp_password",
"existing_vnc_password",
"existing_telnet_password",
];
export function isSecretSentinel(value: string): boolean {
return SECRET_SENTINELS.includes(value);
}
export function SecretField({
label,
value,
onChange,
placeholder,
hint,
error,
disabled,
className,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
hint?: ReactNode;
error?: string;
disabled?: boolean;
className?: string;
}) {
const { t } = useTranslation();
const saved = isSecretSentinel(value);
return (
<Field
label={label}
hint={saved ? t("fields.secretSaved") : hint}
error={error}
className={className}
>
<PasswordInput
value={saved ? "" : value}
placeholder={saved ? "••••••••" : placeholder}
disabled={disabled}
aria-invalid={!!error}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
);
}
/** A Radix dropdown, so its list uses the theme tokens. */
export function SelectField<T extends string>({
label,
value,
onChange,
options,
hint,
error,
disabled,
className,
placeholder,
}: {
label: string;
value: T;
onChange: (value: T) => void;
options: readonly { value: T; label: string }[];
hint?: ReactNode;
error?: string;
disabled?: boolean;
className?: string;
placeholder?: string;
}) {
// Radix treats "" as no selection, so empty options get a stand-in value.
const EMPTY = "__empty__";
return (
<Field label={label} hint={hint} error={error} className={className}>
<Select
value={value === ("" as T) ? EMPTY : value}
disabled={disabled}
onValueChange={(next) => onChange((next === EMPTY ? "" : next) as T)}
>
<SelectTrigger aria-invalid={!!error} className="h-8 w-full text-xs">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value === "" ? EMPTY : option.value}
className="text-xs"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
export function TextAreaField({
label,
value,
onChange,
placeholder,
rows = 3,
hint,
error,
aside,
mono,
disabled,
className,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
hint?: ReactNode;
error?: string;
aside?: ReactNode;
mono?: boolean;
disabled?: boolean;
className?: string;
}) {
return (
<Field
label={label}
hint={hint}
error={error}
aside={aside}
className={className}
>
<textarea
rows={rows}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
className={cn(
"w-full resize-none border border-input bg-background px-3 py-2 text-xs text-foreground transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30",
mono && "font-mono text-[10px]",
)}
/>
</Field>
);
}
/** A labelled switch. */
export function SwitchRow({
label,
description,
badge,
checked,
onChange,
disabled,
defaultKey,
rowId,
}: {
label: string;
description?: ReactNode;
badge?: string;
checked: boolean;
onChange: (value: boolean) => void;
disabled?: boolean;
defaultKey?: string;
rowId?: string;
}) {
return (
<SettingRow
label={label}
description={description}
badge={badge}
defaultKey={defaultKey}
rowId={rowId}
>
<FakeSwitch checked={checked} onChange={onChange} disabled={disabled} />
</SettingRow>
);
}
/** A list of rows you add to and remove from. */
export function Repeater<T>({
label,
items,
onChange,
makeItem,
empty,
addLabel,
hint,
renderItem,
}: {
label: string;
items: T[];
onChange: (items: T[]) => void;
makeItem: () => T;
empty: string;
addLabel: string;
hint?: ReactNode;
renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2 py-3">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</span>
<Button
type="button"
variant="outline"
size="xs"
className="ml-auto"
onClick={() => onChange([...items, makeItem()])}
>
<Plus className="mr-1 size-3" />
{addLabel}
</Button>
</div>
{hint && (
<span className="text-[10px] text-muted-foreground">{hint}</span>
)}
{items.length === 0 ? (
<span className="border border-dashed border-border px-3 py-2 text-[10px] text-muted-foreground">
{empty}
</span>
) : (
<div className="flex flex-col gap-2">
{items.map((item, index) => (
<div
key={index}
className="flex items-start gap-2 border border-border bg-muted/10 p-2"
>
<div className="min-w-0 flex-1">
{renderItem(
item,
(next) =>
onChange(items.map((x, i) => (i === index ? next : x))),
index,
)}
</div>
<button
type="button"
title={t("common.remove")}
aria-label={t("common.remove")}
onClick={() => onChange(items.filter((_, i) => i !== index))}
className="mt-0.5 shrink-0 text-muted-foreground transition-colors hover:text-destructive focus-visible:ring-1 focus-visible:ring-ring"
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
)}
</div>
);
}
/** Chip input for tags. */
export function TagInput({
tags,
input,
placeholder,
onTagsChange,
onInputChange,
}: {
tags: string[];
input: string;
placeholder: string;
onTagsChange: (tags: string[]) => void;
onInputChange: (value: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex min-h-8 flex-wrap items-center gap-1 border border-input bg-background px-2 py-1 focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/50 dark:bg-input/30">
{tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-0.5 border border-border/60 bg-muted px-1.5 py-0.5 text-[10px] text-foreground"
>
{tag}
<button
type="button"
aria-label={t("common.remove")}
onClick={() => onTagsChange(tags.filter((tg) => tg !== tag))}
className="ml-0.5 text-muted-foreground hover:text-destructive"
>
<X className="size-2.5" />
</button>
</span>
))}
<input
className="min-w-16 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50"
placeholder={tags.length === 0 ? placeholder : ""}
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={(e) => {
if ((e.key === " " || e.key === "Enter") && input.trim()) {
e.preventDefault();
const tag = input.trim();
if (!tags.includes(tag)) onTagsChange([...tags, tag]);
onInputChange("");
} else if (e.key === "Backspace" && !input && tags.length > 0) {
onTagsChange(tags.slice(0, -1));
}
}}
/>
</div>
);
}
/** Two fields side by side above md, stacked below. */
export function FieldPair({ children }: { children: ReactNode }) {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">{children}</div>
);
}
/** Reads a picked file's text, for keys and certificates. */
export function useFileText(onText: (text: string) => void) {
const [reading, setReading] = useState(false);
return {
reading,
onFile: async (file: File | null | undefined) => {
if (!file) return;
setReading(true);
try {
onText((await file.text()).trim());
} finally {
setReading(false);
}
},
};
}
+467
View File
@@ -0,0 +1,467 @@
import { useState } from "react";
import type React from "react";
import { ChevronRight, Plus, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Button } from "@/components/button";
export type ListRowTone =
"brand" | "muted" | "success" | "warning" | "destructive" | "none";
const STRIPE: Record<ListRowTone, string> = {
brand: "bg-accent-brand/60",
muted: "bg-muted-foreground/30",
success: "bg-green-500/70",
warning: "bg-warning/70",
destructive: "bg-destructive/70",
none: "bg-transparent",
};
/** Scrolling list body. Rows sit flush, divided by their own borders. */
export function PanelList({
empty,
className,
children,
}: {
/** Shown instead of the rows when there are none. */
empty?: React.ReactNode;
className?: string;
children?: React.ReactNode;
}) {
const hasRows = Array.isArray(children)
? children.flat().some(Boolean)
: Boolean(children);
return (
<div
className={cn(
"flex min-h-0 flex-1 flex-col overflow-y-auto thin-scrollbar",
className,
)}
>
{hasRows ? children : empty}
</div>
);
}
/**
* One row in a sidebar list, drawn like the host list: a coloured stripe on
* the left, a name with an optional line under it, and a row of action
* buttons that opens under it on hover.
*/
export function ListRow({
title,
icon,
badges,
meta,
stripe = 0,
tone = "brand",
color,
selected,
active,
dimmed,
leading,
trailing,
actions,
onClick,
className,
children,
...rest
}: {
title: React.ReactNode;
icon?: React.ReactNode;
/** Small chips after the title. */
badges?: React.ReactNode;
/** Muted line under the title, like a description or Facts. */
meta?: React.ReactNode;
/** Position in the list, odd rows get the zebra tint. */
stripe?: number;
tone?: ListRowTone;
/** A custom stripe colour, like a group's own colour. */
color?: string;
selected?: boolean;
/** Keeps the tray open, like while its menu is showing. */
active?: boolean;
dimmed?: boolean;
/** Sits between the stripe and the text, like a drag handle. */
leading?: React.ReactNode;
/** Always visible on the right, like a switch. */
trailing?: React.ReactNode;
/** Buttons under the row that open on hover, like the host list. */
actions?: React.ReactNode;
onClick?: () => void;
className?: string;
/** Extra lines under the meta. */
children?: React.ReactNode;
} & Omit<
React.HTMLAttributes<HTMLDivElement>,
"title" | "onClick" | "children"
>) {
return (
<div
{...rest}
role={onClick ? "button" : rest.role}
tabIndex={onClick ? 0 : rest.tabIndex}
onClick={onClick}
onKeyDown={(e) => {
rest.onKeyDown?.(e);
if (!onClick || e.defaultPrevented || e.target !== e.currentTarget)
return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
data-selected={selected || undefined}
className={cn(
"group/row relative flex items-stretch select-none border-b border-border/40 transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
onClick && "cursor-pointer",
selected
? "bg-accent-brand/10 hover:bg-accent-brand/15"
: active
? "bg-muted/50"
: stripe % 2 === 1 && "bg-muted/15",
dimmed && "opacity-60",
className,
)}
>
<div
className={cn(
"w-[3px] shrink-0",
selected ? "bg-accent-brand" : !color && STRIPE[tone],
)}
style={color && !selected ? { backgroundColor: color } : undefined}
/>
{leading}
<div className="flex min-w-0 flex-1 flex-col gap-[3.5px] py-[7px] pl-[8.75px] pr-[7px]">
<div className="flex min-w-0 items-center gap-1.5">
{icon && (
<span className="flex shrink-0 text-muted-foreground/60 [&_svg]:size-3">
{icon}
</span>
)}
<span className="truncate text-[13px] font-semibold leading-none tracking-tight text-foreground">
{title}
</span>
{badges}
</div>
{meta && (
<div className="min-w-0 truncate text-[11px] leading-tight text-muted-foreground/80">
{meta}
</div>
)}
{children}
{actions && (
<div
onClick={(e) => e.stopPropagation()}
className={cn(
"flex-wrap items-center gap-[1.75px] border-t border-border/30 pt-[3.5px]",
active
? "flex"
: "hidden group-hover/row:flex group-focus-within/row:flex pointer-coarse:flex",
)}
>
{actions}
</div>
)}
</div>
{trailing && (
<div className="flex shrink-0 items-center pr-2">{trailing}</div>
)}
</div>
);
}
/** Square icon button for a ListRow tray. */
export function ListRowAction({
label,
tone = "default",
className,
children,
...rest
}: {
label: string;
tone?: "default" | "brand" | "destructive";
className?: string;
children: React.ReactNode;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
ref?: React.Ref<HTMLButtonElement>;
}) {
return (
<button
type="button"
title={label}
aria-label={label}
{...rest}
className={cn(
"flex size-[22.75px] items-center justify-center transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5",
tone === "brand"
? "text-accent-brand/80 hover:text-accent-brand"
: tone === "destructive"
? "text-muted-foreground/60 hover:bg-destructive/10 hover:text-destructive"
: "text-muted-foreground/60 hover:text-foreground",
className,
)}
>
{children}
</button>
);
}
/** Small uppercase chip for a ListRow's badges. */
export function ListBadge({
tone = "muted",
className,
children,
}: {
tone?: "muted" | "brand" | "warning" | "destructive" | "success";
className?: string;
children: React.ReactNode;
}) {
return (
<span
className={cn(
"flex shrink-0 items-center gap-0.5 border px-1 py-px text-[9px] uppercase leading-none tracking-wider [&_svg]:size-2.5",
tone === "brand" &&
"border-accent-brand/30 bg-accent-brand/10 text-accent-brand",
tone === "warning" && "border-warning/30 bg-warning/10 text-warning",
tone === "destructive" &&
"border-destructive/30 bg-destructive/10 text-destructive",
tone === "success" &&
"border-green-500/30 bg-green-500/10 text-green-500",
tone === "muted" && "border-border bg-muted/40 text-muted-foreground",
className,
)}
>
{children}
</span>
);
}
/** A collapsible group in a ListRow list, drawn like a host folder. */
export function ListRowFolder({
name,
icon,
count,
open,
onToggle,
stripe = 0,
actions,
dropActive,
onDropItem,
emptyText,
className,
children,
}: {
name: React.ReactNode;
icon?: React.ReactNode;
count?: number;
open: boolean;
onToggle: () => void;
stripe?: number;
/** Shown on hover, usually a DropdownMenu trigger. */
actions?: React.ReactNode;
/** True while something is being dragged that could drop in here. */
dropActive?: boolean;
onDropItem?: () => void;
emptyText?: React.ReactNode;
className?: string;
children?: React.ReactNode;
}) {
const [dragOver, setDragOver] = useState(false);
const empty = count === 0;
return (
<div className={cn("border-b border-border/40", className)}>
<div
role="button"
tabIndex={0}
aria-expanded={open}
onClick={onToggle}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggle();
}
}}
onDragOver={(e) => {
if (!dropActive) return;
e.preventDefault();
setDragOver(true);
}}
onDragLeave={(e) => {
if (e.currentTarget === e.target) setDragOver(false);
}}
onDrop={(e) => {
if (!dropActive) return;
e.preventDefault();
setDragOver(false);
onDropItem?.();
}}
className={cn(
"group/folder flex w-full cursor-pointer select-none items-center gap-2 py-1.5 pl-2.5 pr-2 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
open ? "bg-muted/40" : "hover:bg-muted/30",
stripe % 2 === 1 && !open && "bg-muted/[0.08]",
dragOver &&
dropActive &&
"bg-accent-brand/10 ring-1 ring-inset ring-accent-brand",
)}
>
<ChevronRight
className={cn(
"size-3.5 shrink-0 text-muted-foreground/60 transition-transform",
open && "rotate-90",
)}
/>
{icon && (
<span
className={cn(
"flex shrink-0 [&_svg]:size-4",
open ? "text-accent-brand" : "text-muted-foreground/70",
)}
>
{icon}
</span>
)}
<span className="min-w-0 flex-1 truncate text-[13px] font-bold tracking-tight text-foreground">
{name}
</span>
{count !== undefined && (
<span className="shrink-0 bg-muted/70 px-1.5 py-[1px] text-[10px] tabular-nums text-muted-foreground/70">
{count}
</span>
)}
{actions && (
<div
onClick={(e) => e.stopPropagation()}
className="flex shrink-0 items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover/folder:opacity-100 pointer-coarse:opacity-100 has-[[data-state=open]]:opacity-100"
>
{actions}
</div>
)}
</div>
{open && (
<div className="ml-[27px] border-l border-t border-border/50 border-t-border/40 [&>*:last-child]:border-b-0">
{empty && emptyText ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground/60">
{emptyText}
</div>
) : (
children
)}
</div>
)}
</div>
);
}
/** The one "add" button, placed beside a panel's search box. */
export function AddButton({
label,
compact,
className,
...rest
}: {
label?: string;
/** Icon only, for tight toolbars. */
compact?: boolean;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
ref?: React.Ref<HTMLButtonElement>;
}) {
const { t } = useTranslation();
const text = label ?? t("common.new");
return (
<Button
variant="outline"
size={compact ? "icon" : "default"}
title={text}
aria-label={text}
{...rest}
className={cn(
"shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10",
className,
)}
>
<Plus className="size-3.5" />
{!compact && <span>{text}</span>}
</Button>
);
}
/** The one Save bar, for a PanelShell or InlineView footer. */
export function FormFooter({
onCancel,
onSave,
saveLabel,
cancelLabel,
saving,
dirty,
status,
disabled,
onDelete,
deleteLabel,
extra,
saveType = "button",
form,
}: {
onCancel?: () => void;
onSave?: () => void;
saveLabel?: string;
cancelLabel?: string;
saving?: boolean;
/** Shows the unsaved changes note. */
dirty?: boolean;
/** Replaces the unsaved note, like an error count. */
status?: React.ReactNode;
disabled?: boolean;
onDelete?: () => void;
deleteLabel?: string;
/** More buttons, placed before Save. */
extra?: React.ReactNode;
saveType?: "button" | "submit";
/** Id of the form a submit Save belongs to. */
form?: string;
}) {
const { t } = useTranslation();
return (
<div className="flex w-full min-w-0 items-center gap-2">
{onDelete && (
<Button
variant="ghost"
size="sm"
onClick={onDelete}
disabled={saving}
className="text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/10"
>
<Trash2 />
{deleteLabel ?? t("common.delete")}
</Button>
)}
<div className="min-w-0 flex-1 truncate text-[11px]">
{status ??
(dirty && (
<span className="text-warning">{t("common.unsavedChanges")}</span>
))}
</div>
{onCancel && (
<Button variant="ghost" size="sm" onClick={onCancel} disabled={saving}>
{cancelLabel ?? t("common.cancel")}
</Button>
)}
{extra}
{(onSave || saveType === "submit") && (
<Button
type={saveType}
form={form}
variant="outline"
size="sm"
onClick={onSave}
disabled={disabled || saving}
className="border-accent-brand/40 px-6 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
>
{saving ? t("common.saving") : (saveLabel ?? t("common.save"))}
</Button>
)}
</div>
);
}
+1 -1
View File
@@ -52,7 +52,7 @@ export function ManagerCardShell({
>
{error ? (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<AlertTriangle className="size-6 text-yellow-500" />
<AlertTriangle className="size-6 text-warning" />
<span className="text-xs text-muted-foreground">{error.message}</span>
{error.code === "SUDO_REQUIRED" && (
<span className="text-[10px] text-muted-foreground">
+55
View File
@@ -0,0 +1,55 @@
import { cn } from "@/lib/utils";
import { usageBarColor, usageColor } from "@/lib/usage-color";
/** A usage bar on the shared color ramp. */
export function Meter({
percent,
className,
}: {
percent: number | null;
className?: string;
}) {
const clamped = percent === null ? 0 : Math.min(100, Math.max(0, percent));
return (
<div className={cn("h-1.5 w-full overflow-hidden bg-muted", className)}>
<div
className={cn("motion-meter h-full", usageBarColor(percent))}
style={{ width: `${clamped}%` }}
/>
</div>
);
}
/**
* A meter with its readout. Pass `label` for the stacked card form; leave it off
* for the bare form a table cell wants.
*/
export function UsagePair({
label,
percent,
detail,
}: {
label?: string;
percent: number;
detail?: string;
}) {
const clamped = Math.min(100, Math.max(0, percent));
return (
<div className="flex min-w-0 items-center gap-2">
{label && (
<span className="w-10 shrink-0 text-[10px] uppercase text-muted-foreground">
{label}
</span>
)}
<Meter percent={clamped} className="flex-1" />
<span
className={cn(
"shrink-0 text-right text-[11px] tabular-nums",
detail ? "text-muted-foreground" : usageColor(clamped),
)}
>
{detail ?? `${Math.round(clamped)}%`}
</span>
</div>
);
}
+458
View File
@@ -0,0 +1,458 @@
/* eslint-disable react-refresh/only-export-components */
import { Children, Fragment, isValidElement, useRef } from "react";
import type React from "react";
import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/separator";
import { DocsLink } from "@/components/docs-link";
/** One spacing rhythm for every panel and tab. */
export const PANEL = {
body: "p-2.5",
gap: "gap-2",
band: "px-3 py-2",
cardHead: "px-3 py-2.5",
header: "h-12.5",
} as const;
export type PanelViewMode = "grid" | "list";
export type PanelDensity = "comfortable" | "compact";
/** The header, toolbar band and body every tab and panel draws. */
export function PanelShell({
chrome = true,
icon,
title,
status,
leading,
actions,
toolbar,
tabs,
footer,
scroll = true,
docs,
className,
children,
}: {
/** False inside a sidebar, which draws its own header. */
chrome?: boolean;
icon?: React.ReactNode;
title?: React.ReactNode;
/** Short caption beside the title, like "12 of 18 running". */
status?: React.ReactNode;
/** Sits before the title, usually a BackButton. */
leading?: React.ReactNode;
actions?: React.ReactNode;
toolbar?: React.ReactNode;
/** A full-bleed tab strip in place of a toolbar. */
tabs?: React.ReactNode;
/** A bar pinned under the body, like Cancel and Save. */
footer?: React.ReactNode;
/** False when the body scrolls itself. */
scroll?: boolean;
/** Docs URL for this screen, shown as a book icon in the header. */
docs?: string | null;
className?: string;
children: React.ReactNode;
}) {
const body = (
<div
className={cn(
"flex min-h-0 flex-1 flex-col",
scroll && "overflow-y-auto thin-scrollbar",
className,
)}
>
{children}
</div>
);
const bands = (
<>
{toolbar && (
<div
className={`flex shrink-0 items-center gap-2 border-b border-border ${PANEL.band}`}
>
{toolbar}
</div>
)}
{tabs && (
<div className="shrink-0 border-b border-border px-1">{tabs}</div>
)}
</>
);
const foot = footer && (
<div className="flex shrink-0 items-center gap-2 border-t border-border px-3 py-2">
{footer}
</div>
);
if (!chrome) {
return (
<div className="flex min-h-0 flex-1 flex-col">
{bands}
{body}
{foot}
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden bg-background">
<header
className={`flex ${PANEL.header} shrink-0 flex-row items-center border-b border-border`}
>
{leading && (
<div className="flex h-full shrink-0 border-r border-border">
{leading}
</div>
)}
<div className="flex min-w-0 flex-1 items-center gap-2 px-3">
{icon && <span className="shrink-0 text-accent-brand">{icon}</span>}
<span className="truncate text-base font-bold tracking-tight">
{title}
</span>
{status && (
<>
<Separator
orientation="vertical"
className="h-4 data-[orientation=vertical]:self-center"
/>
<span className="truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{status}
</span>
</>
)}
</div>
{(actions || docs) && (
<div className="flex shrink-0 items-center gap-1 px-2">
{actions}
{docs && <DocsLink href={docs} variant="icon" />}
</div>
)}
</header>
{bands}
{body}
{foot}
</div>
);
}
/** Square back button sized for the PanelShell header. */
export function BackButton({
onClick,
label,
className,
}: {
onClick: () => void;
label?: string;
className?: string;
}) {
const { t } = useTranslation();
const text = label ?? t("common.back");
return (
<button
type="button"
onClick={onClick}
title={text}
aria-label={text}
className={cn(
"flex h-full w-12.5 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring",
className,
)}
>
<ArrowLeft className="size-4" />
</button>
);
}
/** Search box sized to match toolbar buttons. Esc clears it. */
export function PanelSearch({
value,
onChange,
placeholder,
fill,
autoFocus,
disabled,
title,
onEnter,
inputRef,
className,
}: {
value: string;
onChange: (next: string) => void;
placeholder?: string;
/** Takes the whole toolbar, for panels where search is the only control. */
fill?: boolean;
autoFocus?: boolean;
disabled?: boolean;
title?: string;
/** Runs on Enter, for searches that query a server. */
onEnter?: () => void;
inputRef?: React.Ref<HTMLInputElement>;
className?: string;
}) {
const { t } = useTranslation();
const localRef = useRef<HTMLInputElement>(null);
const text = placeholder ?? t("common.search");
return (
<div
className={cn(
"relative",
fill ? "w-full min-w-0 flex-1" : "w-40 md:w-56",
className,
)}
>
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
ref={(el) => {
localRef.current = el;
if (typeof inputRef === "function") inputRef(el);
else if (inputRef)
(
inputRef as React.MutableRefObject<HTMLInputElement | null>
).current = el;
}}
type="text"
value={value}
autoFocus={autoFocus}
disabled={disabled}
title={title}
aria-label={text}
placeholder={text}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape" && value) {
e.stopPropagation();
onChange("");
}
if (e.key === "Enter" && onEnter) {
e.preventDefault();
onEnter();
}
}}
className="h-8 w-full min-w-0 border border-input bg-transparent pl-8 pr-7 text-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30"
/>
{value && (
<button
type="button"
aria-label={t("panel.clearSearch")}
title={t("panel.clearSearch")}
onClick={() => {
onChange("");
localRef.current?.focus();
}}
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
</button>
)}
</div>
);
}
/** Square segmented control. */
export function Segmented<T extends string>({
value,
onChange,
options,
className,
}: {
value: T;
onChange: (next: T) => void;
options: readonly {
value: NoInfer<T>;
label: string;
icon?: React.ReactNode;
count?: number;
title?: string;
}[];
className?: string;
}) {
return (
<div
role="radiogroup"
className={cn("flex items-center border border-border", className)}
>
{options.map((option, i) => {
const active = option.value === value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={active}
title={option.title ?? option.label}
onClick={() => onChange(option.value)}
className={cn(
"flex h-8 items-center justify-center gap-1.5 px-2.5 text-[11px] font-medium transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
i > 0 && "border-l border-border",
active
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.icon}
{option.label}
{option.count !== undefined && (
<span className="text-[10px] tabular-nums opacity-60">
{option.count}
</span>
)}
</button>
);
})}
</div>
);
}
/** Grid or list, plus the compact rows toggle. */
export function ViewToggle({
view,
onView,
density,
onDensity,
}: {
view: PanelViewMode;
onView: (next: PanelViewMode) => void;
density?: PanelDensity;
onDensity?: (next: PanelDensity) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center border border-border">
<ToggleButton
active={view === "grid"}
onClick={() => onView("grid")}
title={t("panel.gridView")}
>
<Grid3X3 className="size-4" />
</ToggleButton>
<ToggleButton
active={view === "list"}
onClick={() => onView("list")}
title={t("panel.listView")}
bordered
>
<List className="size-4" />
</ToggleButton>
{density && onDensity && (
<ToggleButton
active={density === "compact"}
onClick={() =>
onDensity(density === "compact" ? "comfortable" : "compact")
}
title={
density === "compact"
? t("panel.comfortableRows")
: t("panel.compactRows")
}
bordered
>
<Rows3 className="size-4" />
</ToggleButton>
)}
</div>
);
}
function ToggleButton({
active,
onClick,
title,
bordered,
children,
}: {
active: boolean;
onClick: () => void;
title: string;
bordered?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
aria-pressed={active}
className={cn(
"flex size-8 items-center justify-center transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
bordered && "border-l border-border",
active
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground",
)}
>
{children}
</button>
);
}
/**
* A run of peer facts with a rule between each pair, like "12 online | 40
* hosts". Never wraps; a fact that does not fit is truncated.
*/
/** Children with fragments opened up, so each fact gets its own divider. */
function flattenFacts(children: React.ReactNode): React.ReactNode[] {
return Children.toArray(children).flatMap((child) =>
isValidElement<{ children?: React.ReactNode }>(child) &&
child.type === Fragment
? flattenFacts(child.props.children)
: [child],
);
}
export function Facts({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
const items = flattenFacts(children);
if (items.length === 0) return null;
return (
<span className={cn("flex min-w-0 items-center gap-2", className)}>
{items.map((child, i) => (
<Fragment key={i}>
{i > 0 && (
<span aria-hidden className="h-3 w-px shrink-0 bg-border" />
)}
<span className="min-w-0 truncate">{child}</span>
</Fragment>
))}
</span>
);
}
/** Section label with a rule running to the edge. */
export function GroupHeading({
title,
count,
action,
className,
}: {
title: string;
count?: number;
action?: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex items-center gap-2", className)}>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{title}
</span>
{count !== undefined && (
<span className="text-[10px] tabular-nums text-muted-foreground/60">
{count}
</span>
)}
<div className="h-px flex-1 bg-border" />
{action}
</div>
);
}
+17 -4
View File
@@ -8,20 +8,24 @@ export function SectionCard({
action,
children,
className,
rowId,
}: {
title: string;
icon: React.ReactNode;
action?: React.ReactNode;
children: React.ReactNode;
className?: string;
/** Lets settings search jump to this card. */
rowId?: string;
}) {
return (
<div
data-setting-row={rowId}
className={`flex flex-col border border-border bg-card overflow-hidden ${className ?? ""}`}
>
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
<span className="text-muted-foreground">{icon}</span>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground flex-1">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground flex-1">
{title}
</span>
{action && <div className="ml-auto">{action}</div>}
@@ -36,6 +40,7 @@ export function SettingRow({
badge,
description,
defaultKey,
rowId,
children,
}: {
label: string;
@@ -46,18 +51,23 @@ export function SettingRow({
* field key, or "core.<key>". Shows where the value comes from.
*/
defaultKey?: string;
/** Lets settings search jump to this row. */
rowId?: string;
children: React.ReactNode;
}) {
return (
<DefaultsOnly settingKey={defaultKey ?? ""}>
{/* In a gap-4 form stack the gap already spaces the row, so its own
padding and divider would double up. */}
<div className="flex items-center justify-between gap-3 py-3 border-b border-border last:border-0 [.gap-4>&]:py-0 [.gap-4>&]:border-0">
<div
data-setting-row={rowId ?? ""}
className="flex items-center justify-between gap-3 py-3 border-b border-border last:border-0 [.gap-4>&]:py-0 [.gap-4>&]:border-0"
>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium leading-snug">{label}</span>
{badge && (
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
<span className="text-[10px] font-bold text-warning border border-warning/40 px-1 shrink-0">
{badge}
</span>
)}
@@ -90,13 +100,16 @@ export function FakeSwitch({
const on = checked !== undefined ? checked : internalOn;
return (
<button
type="button"
role="switch"
aria-checked={on}
disabled={disabled}
onClick={() => {
const next = !on;
if (checked === undefined) setInternalOn(next);
onChange?.(next);
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${on ? "translate-x-4" : "translate-x-0.5"}`}
+1 -1
View File
@@ -154,7 +154,7 @@ function Select2({
aria-expanded={open}
aria-required={required}
className={cn(
"border-input bg-background text-foreground flex h-9 w-full items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"border-input bg-transparent dark:bg-input/30 dark:hover:bg-input/50 text-foreground flex h-9 w-full items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
onKeyDown={(event) => {
+1 -1
View File
@@ -15,7 +15,7 @@ function Separator({
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
className,
)}
{...props}
@@ -0,0 +1,9 @@
/** Buttons shared by every PanePrompt, so confirms and prompts match. */
export const PROMPT_BUTTON =
"inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
export const PROMPT_PRIMARY_BUTTON =
"inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
export const PROMPT_DESTRUCTIVE_BUTTON =
"inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
+487
View File
@@ -0,0 +1,487 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import type React from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { BackButton, PanelShell } from "@/components/panel-layout";
import {
PROMPT_BUTTON,
PROMPT_DESTRUCTIVE_BUTTON,
PROMPT_PRIMARY_BUTTON,
} from "./prompt-styles";
/**
* A surface is a sidebar panel, a tab or a split pane. Inline views and
* confirmations draw over the surface that asked, never over the whole app.
*/
export type EditingWidth = boolean | "wide";
export type SurfaceKind = "panel" | "tab" | "pane";
export interface ConfirmOptions {
title: string;
description?: React.ReactNode;
confirmLabel?: string;
cancelLabel?: string;
/** Red confirm button. Defaults to true, since most confirms delete. */
destructive?: boolean;
}
interface PendingConfirm extends ConfirmOptions {
id: number;
resolve: (ok: boolean) => void;
}
interface ScopeValue {
kind: SurfaceKind;
overlay: HTMLElement | null;
openView: (id: string, width: EditingWidth) => void;
closeView: (id: string) => void;
topView: string | null;
confirm: (options: ConfirmOptions) => Promise<boolean>;
close: (() => void) | null;
}
const ScopeContext = createContext<ScopeValue | null>(null);
function widest(widths: EditingWidth[]): EditingWidth {
if (widths.includes("wide")) return "wide";
return widths.some(Boolean);
}
// Only the most recently opened confirm, anywhere in the app, owns the keyboard.
const keyOwners: number[] = [];
export function SurfaceScope({
kind = "panel",
onEditingChange,
onClose,
className,
children,
}: {
kind?: SurfaceKind;
/** Closes whatever this surface is, like the tab it fills. */
onClose?: () => void;
/** Gets the widest open inline view, so a sidebar can widen for it. */
onEditingChange?: (editing: EditingWidth) => void;
className?: string;
children: React.ReactNode;
}) {
const [overlay, setOverlay] = useState<HTMLElement | null>(null);
const [views, setViews] = useState<{ id: string; width: EditingWidth }[]>([]);
const [confirms, setConfirms] = useState<PendingConfirm[]>([]);
const nextConfirm = useRef(0);
const editingRef = useRef(onEditingChange);
editingRef.current = onEditingChange;
const closeRef = useRef(onClose);
closeRef.current = onClose;
const hasClose = !!onClose;
const close = useMemo(
() => (hasClose ? () => closeRef.current?.() : null),
[hasClose],
);
const openView = useCallback((id: string, width: EditingWidth) => {
setViews((prev) => [...prev.filter((v) => v.id !== id), { id, width }]);
}, []);
const closeView = useCallback((id: string) => {
setViews((prev) => prev.filter((v) => v.id !== id));
}, []);
const editing = widest(views.map((v) => v.width));
useEffect(() => {
editingRef.current?.(editing);
}, [editing]);
useEffect(() => () => editingRef.current?.(false), []);
const confirm = useCallback(
(options: ConfirmOptions) =>
new Promise<boolean>((resolve) => {
const id = ++nextConfirm.current;
setConfirms((prev) => [...prev, { ...options, id, resolve }]);
}),
[],
);
const settle = useCallback((id: number, ok: boolean) => {
setConfirms((prev) => {
prev.find((c) => c.id === id)?.resolve(ok);
return prev.filter((c) => c.id !== id);
});
}, []);
// A scope that unmounts with a question open answers it with no.
const confirmsRef = useRef(confirms);
confirmsRef.current = confirms;
useEffect(
() => () => confirmsRef.current.forEach((c) => c.resolve(false)),
[],
);
const value = useMemo<ScopeValue>(
() => ({
kind,
overlay,
openView,
closeView,
topView: views.length ? views[views.length - 1].id : null,
confirm,
close,
}),
[kind, overlay, openView, closeView, views, confirm, close],
);
const current = confirms[0];
return (
<ScopeContext.Provider value={value}>
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col",
className,
)}
>
{children}
<div
ref={setOverlay}
className={cn(
"absolute inset-0 z-40 flex flex-col bg-background",
views.length === 0 && "hidden",
)}
/>
{current && (
<ConfirmCard
key={current.id}
options={current}
onSettle={(ok) => settle(current.id, ok)}
/>
)}
</div>
</ScopeContext.Provider>
);
}
/** Which kind of surface this code renders in, or null outside any. */
export function useSurfaceKind(): SurfaceKind | null {
return useContext(ScopeContext)?.kind ?? null;
}
/** Closes the surface this code renders in, or null when it can't be closed. */
export function useSurfaceClose(): (() => void) | null {
return useContext(ScopeContext)?.close ?? null;
}
/**
* Asks a yes or no question over the surface that called it. Enter confirms
* and Esc cancels, before a terminal can see the key.
*/
export function useConfirm(): (options: ConfirmOptions) => Promise<boolean> {
const scope = useContext(ScopeContext);
return useCallback(
(options: ConfirmOptions) => {
if (scope) return scope.confirm(options);
const text = [options.title, options.description]
.filter((part) => typeof part === "string")
.join("\n\n");
return Promise.resolve(window.confirm(text));
},
[scope],
);
}
function ConfirmCard({
options,
onSettle,
}: {
options: ConfirmOptions;
onSettle: (ok: boolean) => void;
}) {
const { t } = useTranslation();
const confirmRef = useRef<HTMLButtonElement>(null);
const titleId = useId();
const destructive = options.destructive ?? true;
const settleRef = useRef(onSettle);
settleRef.current = onSettle;
useEffect(() => {
const owner = Math.random();
keyOwners.push(owner);
const previous = document.activeElement as HTMLElement | null;
confirmRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (keyOwners[keyOwners.length - 1] !== owner) return;
if (e.key !== "Enter" && e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
settleRef.current(e.key === "Enter");
};
window.addEventListener("keydown", onKey, true);
return () => {
window.removeEventListener("keydown", onKey, true);
keyOwners.splice(keyOwners.indexOf(owner), 1);
if (previous && document.contains(previous)) previous.focus();
};
}, []);
return (
<PanePrompt
open
role="alertdialog"
labelledBy={titleId}
title={options.title}
titleId={titleId}
description={options.description}
tone={destructive ? "destructive" : "default"}
onCancel={() => onSettle(false)}
actions={
<>
<button
type="button"
onClick={() => onSettle(false)}
className={PROMPT_BUTTON}
>
{options.cancelLabel ?? t("common.cancel")}
</button>
<button
ref={confirmRef}
type="button"
onClick={() => onSettle(true)}
className={
destructive ? PROMPT_DESTRUCTIVE_BUTTON : PROMPT_PRIMARY_BUTTON
}
>
{options.confirmLabel ??
(destructive ? t("common.delete") : t("common.confirm"))}
<span aria-hidden className="ml-1.5 opacity-60">
↵
</span>
</button>
</>
}
/>
);
}
/**
* A small card over the surface it sits in. Confirmations use it, and so do
* prompts that belong to a connection, like a sudo password.
*/
export function PanePrompt({
open,
title,
titleId,
description,
icon,
tone = "default",
actions,
onCancel,
role = "dialog",
labelledBy,
layer = "pane",
backgroundColor,
className,
children,
}: {
open: boolean;
title: React.ReactNode;
titleId?: string;
description?: React.ReactNode;
icon?: React.ReactNode;
tone?: "default" | "destructive";
actions?: React.ReactNode;
/** Clicking the dimmed backdrop. */
onCancel?: () => void;
role?: "dialog" | "alertdialog";
labelledBy?: string;
/**
* "connection" covers a connecting surface (a terminal waiting on a login)
* with an opaque backdrop, above everything the surface draws.
*/
layer?: "pane" | "connection";
/** Backdrop color for the connection layer, usually the terminal theme. */
backgroundColor?: string;
className?: string;
children?: React.ReactNode;
}) {
const fallbackId = useId();
if (!open) return null;
const headingId = titleId ?? fallbackId;
return (
<div
className={cn(
"motion-fade-enter absolute inset-0 flex items-center justify-center overflow-y-auto p-4",
layer === "connection" ? "z-500 bg-canvas" : "z-50 bg-background/85",
)}
style={
layer === "connection" && backgroundColor
? { backgroundColor }
: undefined
}
onMouseDown={(e) => {
if (e.target === e.currentTarget) onCancel?.();
}}
>
<div
role={role}
aria-modal="true"
aria-labelledby={labelledBy ?? headingId}
className={cn(
"motion-context-enter my-auto flex w-full max-w-sm flex-col border bg-popover shadow-xl",
tone === "destructive" ? "border-destructive/40" : "border-border",
className,
)}
>
<div className="flex items-start gap-2.5 px-4 pt-4">
{icon && (
<span
className={cn(
"mt-0.5 shrink-0",
tone === "destructive"
? "text-destructive"
: "text-accent-brand",
)}
>
{icon}
</span>
)}
<div className="flex min-w-0 flex-col gap-1">
<p
id={headingId}
className="text-sm font-semibold tracking-tight text-foreground"
>
{title}
</p>
{description && (
<div className="text-xs leading-snug text-muted-foreground">
{description}
</div>
)}
</div>
</div>
{children && <div className="px-4 pt-3">{children}</div>}
{actions && (
<div className="flex justify-end gap-2 px-4 py-3">{actions}</div>
)}
{!actions && <div className="h-4" />}
</div>
</div>
);
}
/**
* What used to be a modal: a view that takes over the surface it was opened
* from, with a Back button. Opened from a sidebar it widens the sidebar.
*/
export function InlineView({
open,
onOpenChange,
title,
icon,
status,
actions,
toolbar,
footer,
width = true,
onBeforeClose,
scroll = true,
bare = false,
className,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
icon?: React.ReactNode;
status?: React.ReactNode;
actions?: React.ReactNode;
toolbar?: React.ReactNode;
/** Pinned under the body, usually Cancel and Save. */
footer?: React.ReactNode;
/** How far a sidebar widens while this is open. */
width?: EditingWidth;
/** Return false to stay open, for an unsaved changes guard. */
onBeforeClose?: () => boolean | Promise<boolean>;
scroll?: boolean;
/** Draw children edge to edge, for views with their own bands. */
bare?: boolean;
className?: string;
children: React.ReactNode;
}) {
const scope = useContext(ScopeContext);
const id = useId();
const { openView, closeView } = scope ?? {};
useLayoutEffect(() => {
if (!open || !openView || !closeView) return;
openView(id, width);
return () => closeView(id);
}, [open, id, width, openView, closeView]);
const requestClose = useCallback(async () => {
if (onBeforeClose && !(await onBeforeClose())) return;
onOpenChange(false);
}, [onBeforeClose, onOpenChange]);
if (!open) return null;
const padded = scope?.kind === "tab" || scope?.kind === "pane";
const view = (
<div
className={cn(
"absolute inset-0 flex flex-col",
scope && scope.topView !== id && "hidden",
)}
onKeyDown={(e) => {
if (e.key === "Escape" && !e.defaultPrevented) {
e.stopPropagation();
void requestClose();
}
}}
>
<PanelShell
leading={<BackButton onClick={() => void requestClose()} />}
icon={icon}
title={title}
status={status}
actions={actions}
toolbar={toolbar}
footer={footer}
scroll={scroll}
>
{bare ? (
children
) : (
<div
className={cn(
"flex w-full flex-col gap-3",
padded ? "mx-auto max-w-3xl p-3" : "p-3",
!scroll && "min-h-0 flex-1",
className,
)}
>
{children}
</div>
)}
</PanelShell>
</div>
);
if (scope?.overlay) return createPortal(view, scope.overlay);
if (scope) return null;
return createPortal(
<div className="fixed inset-0 z-50 bg-background">{view}</div>,
document.body,
);
}
+2 -4
View File
@@ -11,16 +11,14 @@ function Switch({
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-[background-color,border-color,box-shadow,opacity] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-accent-brand data-[state=checked]:border-accent-brand data-[state=unchecked]:bg-muted data-[state=unchecked]:border-border",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
className="pointer-events-none block h-3 w-3 bg-background shadow-sm transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5"
/>
</SwitchPrimitive.Root>
);
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
export interface TabStripItem {
id: string;
label: string;
icon?: ReactNode;
/** Small count after the label. */
count?: number;
}
/** Scrollable underline tabs with overflow arrows. */
export function TabStrip({
tabs,
activeTab,
onTabChange,
isActive,
variant = "primary",
trailing,
className,
}: {
tabs: TabStripItem[];
activeTab: string;
onTabChange: (id: string) => void;
isActive?: (id: string) => boolean;
variant?: "primary" | "secondary";
/** Sits after the tabs, outside the scroll area. */
trailing?: ReactNode;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const [scroll, setScroll] = useState({ left: false, right: false });
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () =>
setScroll({
left: el.scrollLeft > 1,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
});
const observer =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
observer?.observe(el);
if (el.firstElementChild) observer?.observe(el.firstElementChild);
el.addEventListener("scroll", update);
update();
return () => {
observer?.disconnect();
el.removeEventListener("scroll", update);
};
}, [tabs]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
e.preventDefault();
el.scrollLeft += e.deltaY;
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
const renderTab = (tab: TabStripItem) => {
const active = isActive ? isActive(tab.id) : activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => onTabChange(tab.id)}
className={`flex items-center gap-1.5 px-3 ${
variant === "secondary" ? "py-1.5 text-[11px]" : "py-2 text-xs"
} font-medium whitespace-nowrap border-b-2 transition-colors shrink-0 ${
active
? "border-accent-brand text-accent-brand"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{tab.icon}
{tab.label}
{tab.count !== undefined && (
<span className="text-[10px] tabular-nums opacity-60">
{tab.count}
</span>
)}
</button>
);
};
const overflow = scroll.left || scroll.right;
return (
<div
className={cn(
"flex min-w-0 items-center",
variant === "secondary" && "border-t border-border bg-card",
className,
)}
>
{overflow && (
<button
type="button"
aria-label={t("common.scrollTabsLeft")}
title={t("common.scrollTabsLeft")}
disabled={!scroll.left}
className="shrink-0 p-1 disabled:opacity-30"
onClick={() =>
ref.current?.scrollBy({
left: -ref.current.clientWidth * 0.75,
behavior: "smooth",
})
}
>
<ChevronLeft className="size-4" />
</button>
)}
<div ref={ref} className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
<div className="flex min-w-max">{tabs.map(renderTab)}</div>
</div>
{overflow && (
<button
type="button"
aria-label={t("common.scrollTabsRight")}
title={t("common.scrollTabsRight")}
disabled={!scroll.right}
className="shrink-0 p-1 disabled:opacity-30"
onClick={() =>
ref.current?.scrollBy({
left: ref.current.clientWidth * 0.75,
behavior: "smooth",
})
}
>
<ChevronRight className="size-4" />
</button>
)}
{trailing && (
<div className="flex shrink-0 items-center gap-1 px-1">{trailing}</div>
)}
</div>
);
}
+8 -5
View File
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
interface VersionBadgeProps {
status: "up_to_date" | "requires_update" | "beta" | "unknown";
@@ -14,13 +15,15 @@ export function VersionBadge({
const { t } = useTranslation();
if (status === "unknown") return null;
const badgeClassName = `text-[10px] px-1.5 py-0.5 font-semibold leading-none ${
const badgeClassName = cn(
"text-[10px] px-1.5 py-0.5 font-semibold leading-none",
status === "beta"
? "bg-blue-500/20 text-blue-400"
: status === "requires_update"
? "bg-yellow-500/20 text-yellow-400"
: "bg-accent-brand/20 text-accent-brand"
}${className ? ` ${className}` : ""}`;
? "bg-warning/20 text-warning"
: "bg-accent-brand/20 text-accent-brand",
className,
);
const label =
status === "beta"
@@ -42,7 +45,7 @@ export function VersionBadge({
rel="noopener noreferrer"
title={linkLabel}
aria-label={linkLabel}
className={`${badgeClassName} cursor-pointer hover:underline`}
className={cn(badgeClassName, "cursor-pointer hover:underline")}
>
{label}
</a>
+122 -50
View File
@@ -21,13 +21,13 @@ import {
resolvePluginArea,
type UiPluginPresets,
sanitizeUiPreferences,
UI_ONBOARDING_VERSION,
type UiAreaKey,
type UiAreaPreferences,
type UiOverrides,
type UiPreferences,
type UiPreset,
} from "@/types/ui-preferences";
import { mergeSeen } from "@/types/onboarding";
const SAVE_DEBOUNCE_MS = 500;
const LS_KEY = "uiPreferences";
@@ -62,7 +62,21 @@ interface UiPreferencesContextValue {
) => void;
clearArea: (area: UiAreaKey) => void;
clearAllOverrides: () => void;
completeOnboarding: (skipped: boolean) => void;
/**
* Records onboarding steps as seen (per-step max, never lowered).
* `completed` stamps completedAt the first time a run finishes, and
* `baselineDone` clears the carried-over-user flag.
*/
markOnboardingSeen: (
seen: Record<string, number>,
options?: {
completed?: boolean;
skipped?: boolean;
baselineDone?: boolean;
},
) => void;
/** Sends any queued change now instead of after the debounce. */
flushNow: () => Promise<void>;
/** A plugin's own area, from the presets it declared. */
resolvePlugin: (
pluginId: string,
@@ -85,15 +99,26 @@ const UiPreferencesContext = createContext<UiPreferencesContextValue | null>(
* deep and treats null as "clear this", so handing a knob back to the preset is
* a single PUT rather than a read-modify-write of the whole document.
*/
export function UiPreferencesProvider({ children }: { children: ReactNode }) {
export function UiPreferencesProvider({
children,
initial,
}: {
children: ReactNode;
/** Already fetched (by the onboarding gate), so no second request. */
initial?: UiPreferences | null;
}) {
const [preferences, setPreferences] = useState<UiPreferences>(
() => readCache() ?? defaultUiPreferences(),
() => initial ?? readCache() ?? defaultUiPreferences(),
);
const [loaded, setLoaded] = useState(false);
const [loaded, setLoaded] = useState(!!initial);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingPatch = useRef<Record<string, unknown>>({});
useEffect(() => {
if (initial) {
writeCache(initial);
return;
}
let cancelled = false;
getUiPreferences()
@@ -112,49 +137,74 @@ export function UiPreferencesProvider({ children }: { children: ReactNode }) {
return () => {
cancelled = true;
};
// initial only matters on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const flush = useCallback((): Promise<void> => {
if (saveTimer.current) {
clearTimeout(saveTimer.current);
saveTimer.current = null;
}
const body = pendingPatch.current;
pendingPatch.current = {};
if (Object.keys(body).length === 0) return Promise.resolve();
return getUserPreferences()
.then((prefs) => {
if (prefs.storageMode === "cloud") return saveUiPreferences(body);
// The server copy replaces the cache on load, so onboarding has to
// reach it even in local mode or it reruns every time.
if (body.onboarding)
return saveUiPreferences({
onboarding: body.onboarding as UiPreferences["onboarding"],
});
})
.catch(() => {
/* best-effort; cache already holds it */
});
}, []);
// A pending edit must not be lost when the provider goes away.
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
void flush();
};
}, []);
}, [flush]);
const queueSave = useCallback((patch: Record<string, unknown>) => {
// Overrides accumulate key by key so a burst of edits still sends every
// change; anything else is last-write-wins.
const merged = { ...pendingPatch.current };
for (const [key, value] of Object.entries(patch)) {
if (key === "overrides" && value && typeof value === "object") {
merged.overrides = {
...((merged.overrides as Record<string, unknown>) ?? {}),
...(value as Record<string, unknown>),
};
} else {
merged[key] = value;
const queueSave = useCallback(
(patch: Record<string, unknown>) => {
// Overrides accumulate key by key so a burst of edits still sends every
// change, and onboarding seen maps merge; anything else is
// last-write-wins.
const merged = { ...pendingPatch.current };
for (const [key, value] of Object.entries(patch)) {
if (key === "overrides" && value && typeof value === "object") {
merged.overrides = {
...((merged.overrides as Record<string, unknown>) ?? {}),
...(value as Record<string, unknown>),
};
} else if (key === "onboarding" && value && typeof value === "object") {
const prev = (merged.onboarding ?? {}) as Record<string, unknown>;
const next = value as Record<string, unknown>;
merged.onboarding = {
...prev,
...next,
seen: mergeSeen(
prev.seen as Record<string, number>,
next.seen as Record<string, number>,
),
};
} else {
merged[key] = value;
}
}
}
pendingPatch.current = merged;
pendingPatch.current = merged;
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
const body = pendingPatch.current;
pendingPatch.current = {};
getUserPreferences()
.then((prefs) => {
if (prefs.storageMode === "cloud") return saveUiPreferences(body);
// The server copy replaces the cache on load, so onboarding has to
// reach it even in local mode or it reruns every time.
if (body.onboarding)
return saveUiPreferences({
onboarding: body.onboarding as UiPreferences["onboarding"],
});
})
.catch(() => {
/* best-effort; cache already holds it */
});
}, SAVE_DEBOUNCE_MS);
}, []);
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => void flush(), SAVE_DEBOUNCE_MS);
},
[flush],
);
/** Applies the local mirror of a patch, then queues the same patch remotely. */
const applyLocal = useCallback(
@@ -227,14 +277,34 @@ export function UiPreferencesProvider({ children }: { children: ReactNode }) {
applyLocal((prev) => ({ ...prev, overrides: {} }), { overrides: null });
}, [applyLocal]);
const completeOnboarding = useCallback(
(skipped: boolean) => {
const onboarding = {
completedVersion: UI_ONBOARDING_VERSION,
completedAt: new Date().toISOString(),
skipped,
};
applyLocal((prev) => ({ ...prev, onboarding }), { onboarding });
const markOnboardingSeen = useCallback(
(
seen: Record<string, number>,
options: {
completed?: boolean;
skipped?: boolean;
baselineDone?: boolean;
} = {},
) => {
const patch: Record<string, unknown> = { seen };
if (options.completed) patch.completedAt = new Date().toISOString();
if (options.skipped !== undefined) patch.skipped = options.skipped;
if (options.baselineDone) patch.baselinePending = false;
applyLocal(
(prev) => ({
...prev,
onboarding: {
...prev.onboarding,
...patch,
completedAt:
prev.onboarding.completedAt ??
(patch.completedAt as string | undefined) ??
null,
seen: mergeSeen(prev.onboarding.seen, seen),
},
}),
{ onboarding: patch },
);
},
[applyLocal],
);
@@ -248,7 +318,8 @@ export function UiPreferencesProvider({ children }: { children: ReactNode }) {
setOverride,
clearArea,
clearAllOverrides,
completeOnboarding,
markOnboardingSeen,
flushNow: flush,
resolvePlugin: (pluginId, presets) =>
resolvePluginArea(preferences, pluginId, presets),
setPluginOverride: (pluginId, key, value) =>
@@ -265,7 +336,8 @@ export function UiPreferencesProvider({ children }: { children: ReactNode }) {
setOverride,
clearArea,
clearAllOverrides,
completeOnboarding,
markOnboardingSeen,
flush,
],
);
File diff suppressed because it is too large. Load diff
@@ -21,6 +21,8 @@ export interface RegisteredDashboardCard {
defaultHeight?: number;
/** Which column a preset places this card in by default. Defaults to "main". */
defaultPanel?: "main" | "side";
/** "framed" gets a titled header from the dashboard; "bare" draws its own. */
frame?: "framed" | "bare";
component: ComponentType<DashboardCardRenderProps>;
}
+448
View File
@@ -0,0 +1,448 @@
import { useIsMobile } from "@/hooks/use-mobile";
import { useTranslation } from "react-i18next";
import {
ExternalLink,
KeyRound,
Plus,
Server,
Settings,
User,
} from "lucide-react";
import { ComponentSlot } from "@/shell/ActionSlot";
import { VersionBadge } from "@/components/version-badge";
import { Facts } from "@/components/panel-layout";
import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols";
import { activityTarget } from "@/lib/activity-types";
import {
defaultConnectAction,
hostActionsFor,
listHostActions,
} from "@/sidebar/host-contributions";
import {
getStatusClasses,
useStatusColorScheme,
} from "@/hooks/use-status-color-scheme";
import { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
import type { RecentActivityItem } from "@/main-axios";
import type { Host, TabType } from "@/types/ui-types";
import { quarterBorders } from "./quarter-borders";
import { parseServerTime } from "@/lib/server-time";
export type VersionStatus =
"up_to_date" | "requires_update" | "beta" | "unknown";
function Stat({
label,
aside,
children,
}: {
label: string;
/** Sits beside the label, like the release channel. */
aside?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="flex min-w-0 flex-col justify-center gap-1 px-3 py-2">
{children}
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[10px] text-muted-foreground">
{label}
</span>
{aside}
</div>
</div>
);
}
export function StatsStrip({
hosts,
uptimeFormatted,
versionText,
versionStatus,
releaseUrl,
dbHealth,
}: {
hosts: Host[];
uptimeFormatted: string;
versionText: string;
versionStatus: VersionStatus;
releaseUrl: string;
dbHealth: "healthy" | "error";
}) {
const { t } = useTranslation();
const online = hosts.filter((h) => h.status === "online").length;
const big = "text-2xl font-bold leading-none tracking-tight";
return (
<div className="grid h-full grid-cols-2 divide-x divide-border md:grid-cols-4">
<Stat
label={t("dashboard.version")}
aside={
<VersionBadge
status={versionStatus}
releaseUrl={releaseUrl}
className="shrink-0 px-1 py-px text-[9px]"
/>
}
>
<span className={`${big} truncate`} title={versionText || undefined}>
{versionText || "-"}
</span>
</Stat>
<Stat label={t("dashboard.uptime")}>
<span className={big}>{uptimeFormatted || "-"}</span>
</Stat>
<Stat label={t("dashboard.database")}>
<span
className={`${big} ${dbHealth === "healthy" ? "text-accent-brand" : "text-destructive"}`}
>
{dbHealth === "healthy"
? t("dashboard.healthy")
: t("dashboard.error")}
</span>
</Stat>
<Stat label={t("dashboardTab.hostsAvailable")}>
<div className="flex items-baseline gap-1">
<span className={big}>{online}</span>
<span className="text-sm leading-none text-muted-foreground">
/{hosts.length}
</span>
</div>
</Stat>
</div>
);
}
export function CountersStrip({
hosts,
credentialCount,
onOpenSingletonTab,
}: {
hosts: Host[];
credentialCount: number;
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
}) {
const { t } = useTranslation();
const item =
"flex items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-muted/50";
return (
<div className="grid h-full auto-cols-fr grid-flow-col divide-x divide-border">
<button
type="button"
onClick={() => onOpenSingletonTab("host-manager")}
className={item}
>
<Server className="size-3.5 shrink-0 text-muted-foreground" />
<span className="text-xl font-bold leading-none tracking-tight">
{hosts.length}
</span>
<span className="truncate text-[11px] text-muted-foreground">
{t("dashboard.totalHosts")}
</span>
</button>
<button
type="button"
onClick={() =>
onOpenSingletonTab("host-manager", "host-manager:show-credentials")
}
className={item}
>
<KeyRound className="size-3.5 shrink-0 text-muted-foreground" />
<span className="text-xl font-bold leading-none tracking-tight">
{credentialCount}
</span>
<span className="truncate text-[11px] text-muted-foreground">
{t("dashboard.totalCredentials")}
</span>
</button>
<ComponentSlot slotId="dashboard.counters" />
</div>
);
}
function ActionButton({
icon: Icon,
label,
hint,
onClick,
className,
}: {
icon: React.ElementType;
label: string;
hint: string;
onClick: () => void;
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={`group/btn flex h-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-muted/50 ${className ?? ""}`}
>
<span className="flex size-6 shrink-0 items-center justify-center border border-border bg-muted transition-colors group-hover/btn:border-accent-brand/40 group-hover/btn:bg-accent-brand/20">
<Icon className="size-3 text-accent-brand" />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs font-medium">{label}</span>
<span className="truncate text-[10px] text-muted-foreground">
{hint}
</span>
</span>
</button>
);
}
export function QuickActions({
onOpenSingletonTab,
hosts,
onOpenTab,
isAdmin,
}: {
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
hosts: Host[];
onOpenTab: (host: Host, type: TabType) => void;
isAdmin: boolean;
}) {
const { t } = useTranslation();
const pinnedHosts = hosts.filter((h) => h.pin).slice(0, 4);
const endpoint = (host: Host) => {
const protocol = host.enableSsh ? undefined : enabledHostProtocols(host)[0];
const port = host.enableSsh
? host.sshPort
: protocol
? protocolPort(host.pluginSettings, protocol)
: host.port;
return `${host.ip}:${port ?? host.port}`;
};
const actions = [
{
icon: Plus,
label: t("dashboard.addHost"),
hint: t("dashboardTab.registerNewServer"),
onClick: () =>
onOpenSingletonTab("host-manager", "host-manager:add-host"),
},
{
icon: KeyRound,
label: t("dashboard.addCredential"),
hint: t("dashboardTab.storeSshKeysOrPasswords"),
onClick: () =>
onOpenSingletonTab("host-manager", "host-manager:add-credential"),
},
...(isAdmin
? [
{
icon: Settings,
label: t("dashboard.adminSettings"),
hint: t("dashboardTab.manageUsersAndRoles"),
onClick: () => onOpenSingletonTab("admin-settings"),
},
]
: []),
{
icon: User,
label: t("dashboard.userProfile"),
hint: t("dashboardTab.manageYourAccount"),
onClick: () => onOpenSingletonTab("user-profile"),
},
];
return (
<div className="flex min-h-full flex-1 flex-col">
<div className="grid flex-1 auto-rows-fr grid-cols-1 sm:grid-cols-2">
{actions.map((action, i) => (
<ActionButton
key={action.label}
{...action}
className={quarterBorders(i, actions.length)}
/>
))}
</div>
{pinnedHosts.length > 0 && (
<div className="flex flex-col border-t border-border">
{pinnedHosts.map((host) => {
const Icon =
defaultConnectAction(listHostActions(), host)?.icon ?? Server;
return (
<button
key={host.id}
type="button"
onClick={() => {
const type = getDefaultConnectionTab(host);
if (type) onOpenTab(host, type);
}}
className="flex items-center gap-2 border-b border-border/60 px-3 py-1.5 text-left transition-colors last:border-b-0 hover:bg-muted/50"
>
<Icon className="size-3 shrink-0 text-accent-brand" />
<span className="truncate text-xs font-medium">
{host.name || host.ip}
</span>
<span className="ml-auto truncate font-mono text-[10px] text-muted-foreground">
{endpoint(host)}
</span>
</button>
);
})}
</div>
)}
</div>
);
}
export function HostStatusList({
hosts,
onOpenTab,
statusLoading,
}: {
hosts: Host[];
onOpenTab: (host: Host, type: TabType) => void;
statusLoading?: boolean;
}) {
const { t } = useTranslation();
const scheme = useStatusColorScheme();
// A phone has no room for the metric bars next to the host name.
const isMobile = useIsMobile();
if (hosts.length === 0) {
return (
<div className="flex flex-1 items-center justify-center py-8 text-xs text-muted-foreground/60">
{t("dashboardTab.noHostsConfigured")}
</div>
);
}
return (
<div className="flex flex-col">
{hosts.map((host) => {
const availability = host.status ?? "offline";
return (
<div
key={host.id}
role="button"
tabIndex={0}
onClick={() => {
// An overview action (a metrics view) wins over connecting.
const actions = hostActionsFor(listHostActions(), host);
const target =
actions.find((action) => action.overview)?.tabType ??
defaultConnectAction(actions, host)?.tabType;
if (target) onOpenTab(host, target);
}}
className="group/row flex min-w-0 cursor-pointer items-center gap-2 px-3 py-1.5 hover:bg-muted/50"
>
<span
className={`size-1.5 shrink-0 rounded-full ${getStatusClasses(availability, scheme, "dot", statusLoading)}`}
/>
<span className="min-w-0 flex-1">
<Facts className="min-w-0">
<span
className="truncate text-xs font-medium"
title={host.name}
>
{host.name}
</span>
<span
className="truncate font-mono text-[10px] text-muted-foreground"
title={host.ip}
>
{host.ip}
</span>
</Facts>
</span>
{/* Plugins add live details to a host row here. */}
{!isMobile && (
<ComponentSlot
slotId="dashboard.hostRow"
props={{
hostId: Number(host.id),
host,
online: availability === "online",
}}
/>
)}
<span
className={`shrink-0 border px-1.5 py-0.5 text-[10px] font-semibold ${getStatusClasses(availability, scheme, "badge", statusLoading)}`}
>
{statusLoading || availability === "unknown"
? t("dashboardTab.checking")
: availability === "online"
? t("hosts.status.online")
: t("hosts.status.offline")}
</span>
<ExternalLink className="size-2.5 shrink-0 text-muted-foreground/0 transition-colors group-hover/row:text-muted-foreground/60" />
</div>
);
})}
</div>
);
}
export function RecentActivityList({
activity,
hosts,
onOpenTab,
statusLoading,
}: {
activity: RecentActivityItem[];
hosts: Host[];
onOpenTab: (host: Host, type: TabType) => void;
statusLoading?: boolean;
}) {
const { t } = useTranslation();
const scheme = useStatusColorScheme();
const ago = (ts: string) => {
const diff = Math.floor(
(Date.now() - parseServerTime(ts).getTime()) / 1000,
);
if (diff < 60) return t("dashboard.justNow");
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
};
if (activity.length === 0) {
return (
<div className="flex flex-1 items-center justify-center py-8 text-xs text-muted-foreground/60">
{t("dashboard.noRecentActivity")}
</div>
);
}
return (
<div className="flex flex-col">
{activity.map((item) => {
const host = hosts.find((h) => h.id === String(item.hostId));
const target = activityTarget(item.type);
const Icon = target?.icon ?? Server;
return (
<div
key={item.id}
role="button"
tabIndex={0}
onClick={() => {
if (host && target) onOpenTab(host, target.tab);
}}
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 hover:bg-muted/50"
>
<span
className={`size-1.5 shrink-0 rounded-full ${getStatusClasses(host?.status ?? false, scheme, "dot", statusLoading)}`}
/>
<Facts className="min-w-0">
<span
className="truncate text-xs font-medium"
title={item.hostName}
>
{item.hostName}
</span>
<span className="flex min-w-0 items-center gap-1 text-[10px] text-muted-foreground">
<Icon className="size-2.5 shrink-0" />
<span className="truncate">
{target?.labelKey
? t(target.labelKey)
: item.type.replace("_", " ")}
</span>
</span>
</Facts>
<span className="ml-auto shrink-0 text-[10px] tabular-nums text-muted-foreground">
{ago(item.timestamp)}
</span>
</div>
);
})}
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Lines only between cells, so the actions read as quarters: one column on
* small screens, two above. An odd last cell spans the full row.
*/
export function quarterBorders(index: number, count: number): string {
const classes = ["border-border"];
const last = index === count - 1;
if (!last) classes.push("border-b");
if (Math.floor(index / 2) === Math.floor((count - 1) / 2)) {
classes.push("sm:border-b-0");
}
if (index % 2 === 0 && !last) classes.push("sm:border-r");
if (last && count % 2 === 1) classes.push("sm:col-span-2");
return classes.join(" ");
}
+5 -1
View File
@@ -52,6 +52,10 @@ function contextKey(context: Record<string, unknown> | undefined): string {
try {
return JSON.stringify(context);
} catch {
return String(Object.keys(context));
// A value that will not serialize still counts as there or not, so a
// handle filling in changes the key.
return Object.entries(context)
.map(([key, value]) => `${key}:${value == null ? "-" : typeof value}`)
.join(",");
}
}
-167
View File
@@ -1,167 +0,0 @@
import { useState, useEffect, useCallback } from "react";
import { toast } from "sonner";
interface ConfirmationOptions {
title: string;
description: string;
confirmText?: string;
cancelText?: string;
variant?: "default" | "destructive";
}
interface ToastConfirmOptions {
confirmOnEnter?: boolean;
duration?: number;
}
export function useConfirmation() {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<ConfirmationOptions | null>(null);
const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null);
const [activeToastId, setActiveToastId] = useState<string | number | null>(
null,
);
const [pendingConfirmCallback, setPendingConfirmCallback] = useState<
(() => void) | null
>(null);
const [pendingResolve, setPendingResolve] = useState<
((value: boolean) => void) | null
>(null);
const handleEnterKey = useCallback(
(event: KeyboardEvent) => {
if (event.key === "Enter" && activeToastId !== null) {
event.preventDefault();
event.stopPropagation();
if (pendingConfirmCallback) {
pendingConfirmCallback();
}
if (pendingResolve) {
pendingResolve(true);
}
toast.dismiss(activeToastId);
setActiveToastId(null);
setPendingConfirmCallback(null);
setPendingResolve(null);
}
},
[activeToastId, pendingConfirmCallback, pendingResolve],
);
useEffect(() => {
if (activeToastId !== null) {
// Use capture phase to intercept Enter before terminal receives it
window.addEventListener("keydown", handleEnterKey, true);
return () => {
window.removeEventListener("keydown", handleEnterKey, true);
};
}
}, [activeToastId, handleEnterKey]);
const confirm = (opts: ConfirmationOptions, callback: () => void) => {
setOptions(opts);
setOnConfirm(() => callback);
setIsOpen(true);
};
const handleConfirm = () => {
if (onConfirm) {
onConfirm();
}
setIsOpen(false);
setOptions(null);
setOnConfirm(null);
};
const handleCancel = () => {
setIsOpen(false);
setOptions(null);
setOnConfirm(null);
};
const confirmWithToast = (
opts: ConfirmationOptions | string,
callback?: () => void,
variantOrConfirmLabel: "default" | "destructive" | string = "Confirm",
cancelLabel: string = "Cancel",
toastOptions: ToastConfirmOptions = { confirmOnEnter: false },
): Promise<boolean> => {
return new Promise((resolve) => {
const isVariant =
variantOrConfirmLabel === "default" ||
variantOrConfirmLabel === "destructive";
const confirmLabel = isVariant ? "Confirm" : variantOrConfirmLabel;
const { confirmOnEnter = false, duration = 8000 } = toastOptions;
const handleToastConfirm = () => {
if (callback) callback();
resolve(true);
setActiveToastId(null);
setPendingConfirmCallback(null);
setPendingResolve(null);
};
const handleToastCancel = () => {
resolve(false);
setActiveToastId(null);
setPendingConfirmCallback(null);
setPendingResolve(null);
};
const message = typeof opts === "string" ? opts : opts.description;
const actualConfirmLabel =
typeof opts === "object" && opts.confirmText
? opts.confirmText
: confirmLabel;
const actualCancelLabel =
typeof opts === "object" && opts.cancelText
? opts.cancelText
: cancelLabel;
const toastId = toast(message, {
duration,
action: {
label: confirmOnEnter
? `${actualConfirmLabel} ↵`
: actualConfirmLabel,
onClick: handleToastConfirm,
},
cancel: {
label: actualCancelLabel,
onClick: handleToastCancel,
},
onDismiss: () => {
setActiveToastId(null);
setPendingConfirmCallback(null);
setPendingResolve(null);
},
onAutoClose: () => {
resolve(false);
setActiveToastId(null);
setPendingConfirmCallback(null);
setPendingResolve(null);
},
} as NonNullable<Parameters<typeof toast>[1]>);
if (confirmOnEnter) {
setActiveToastId(toastId);
setPendingConfirmCallback(() => () => {
if (callback) callback();
});
setPendingResolve(() => resolve);
}
});
};
return {
isOpen,
options,
confirm,
handleConfirm,
handleCancel,
confirmWithToast,
};
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
import {
getPlugins,
PLUGINS_CHANGED_EVENT,
type PluginSummary,
} from "@/api/plugins-api";
/**
* Every installed plugin, read again whenever one is enabled, disabled,
* installed or removed, so what a plugin adds shows up without a reload.
*/
export function useInstalledPlugins(): PluginSummary[] {
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
useEffect(() => {
let cancelled = false;
let request = 0;
const load = () => {
const current = ++request;
void getPlugins()
.then((loaded) => {
if (!cancelled && current === request) setPlugins(loaded);
})
.catch(() => {});
};
load();
window.addEventListener(PLUGINS_CHANGED_EVENT, load);
return () => {
cancelled = true;
window.removeEventListener(PLUGINS_CHANGED_EVENT, load);
};
}, []);
return plugins;
}
+6
View File
@@ -1,13 +1,19 @@
import * as React from "react";
import { isElectron } from "@/lib/electron";
const MOBILE_BREAKPOINT = 768;
/** Phone layout below the breakpoint. The desktop app always stays desktop. */
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
if (isElectron()) {
setIsMobile(false);
return;
}
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
+8
View File
@@ -56,6 +56,14 @@ export async function hasPermission(permission: string): Promise<boolean> {
return matchesPermission(permissions, isAdmin, permission);
}
/** A sync has() over the current grants, for code outside a component. */
export async function loadPermissionChecker(): Promise<
(permission: string) => boolean
> {
const { permissions, isAdmin } = await load();
return (permission) => matchesPermission(permissions, isAdmin, permission);
}
/**
* Warms the permissions cache before the shell mounts, so permission-gated
* rail items are already known on first paint instead of popping in once
+3 -3
View File
@@ -56,9 +56,9 @@ export function getStatusClasses(
const online = status === true || status === "online";
if (loading || status === "unknown") {
if (scheme === "status") {
if (variant === "dot") return "bg-yellow-400 animate-pulse";
if (variant === "stripe") return "bg-yellow-400/40 animate-pulse";
return "border-yellow-400/40 text-yellow-400 bg-yellow-400/10 animate-pulse";
if (variant === "dot") return "bg-warning animate-pulse";
if (variant === "stripe") return "bg-warning/40 animate-pulse";
return "border-warning/40 text-warning bg-warning/10 animate-pulse";
}
if (variant === "dot") return "bg-muted-foreground/40 animate-pulse";
if (variant === "stripe") return "bg-muted-foreground/20 animate-pulse";
+2
View File
@@ -168,6 +168,8 @@ i18n
react: {
useSuspense: false,
// Repaint when a plugin enabled later loads its strings.
bindI18n: "languageChanged loaded",
},
});
+53
View File
@@ -88,6 +88,14 @@
--sidebar-accent-foreground: oklch(0.2115 0.0042 128.68);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--warning: oklch(0.795 0.162 86.05);
--terminal-bg: #ffffff;
--surface-dim: oklch(0.945 0 0);
--bg-base: var(--background);
--border-base: var(--border);
--foreground-secondary: var(--muted-foreground);
--canvas: var(--background);
--overlay: oklch(0 0 0 / 50%);
}
.dark {
@@ -124,6 +132,8 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 12%);
--sidebar-ring: oklch(0.556 0 0);
--warning: oklch(0.852 0.156 91.94);
--terminal-bg: #0c0d0b;
}
/* ── Dracula ────────────────────────────────────────────────── */
@@ -162,6 +172,8 @@
--sidebar-accent-foreground: #f8f8f2;
--sidebar-border: rgba(168, 179, 217, 0.18);
--sidebar-ring: #8be9fd;
--warning: #f1fa8c;
--terminal-bg: #282a36;
}
/* ── Catppuccin Mocha ───────────────────────────────────────── */
@@ -200,6 +212,8 @@
--sidebar-accent-foreground: #cdd6f4;
--sidebar-border: rgba(186, 194, 222, 0.18);
--sidebar-ring: #89b4fa;
--warning: #f9e2af;
--terminal-bg: #1e1e2e;
}
/* ── Nord ───────────────────────────────────────────────────── */
@@ -238,6 +252,8 @@
--sidebar-accent-foreground: #eceff4;
--sidebar-border: rgba(180, 192, 212, 0.18);
--sidebar-ring: #88c0d0;
--warning: #ebcb8b;
--terminal-bg: #2e3440;
}
/* ── Solarized Dark ─────────────────────────────────────────── */
@@ -276,6 +292,8 @@
--sidebar-accent-foreground: #d5dddd;
--sidebar-border: rgba(159, 179, 179, 0.2);
--sidebar-ring: #268bd2;
--warning: #b58900;
--terminal-bg: #002b36;
}
/* ── Tokyo Night ────────────────────────────────────────────── */
@@ -315,6 +333,8 @@
--sidebar-accent-foreground: #c0caf5;
--sidebar-border: rgba(139, 147, 189, 0.2);
--sidebar-ring: #7aa2f7;
--warning: #e0af68;
--terminal-bg: #1a1b26;
}
/* ── One Dark ───────────────────────────────────────────────── */
@@ -353,6 +373,8 @@
--sidebar-accent-foreground: #abb2bf;
--sidebar-border: rgba(154, 163, 178, 0.18);
--sidebar-ring: #61afef;
--warning: #e5c07b;
--terminal-bg: #282c34;
}
/* ── Gruvbox Dark ───────────────────────────────────────────── */
@@ -392,6 +414,8 @@
--sidebar-accent-foreground: #ebdbb2;
--sidebar-border: rgba(196, 181, 157, 0.18);
--sidebar-ring: #fabd2f;
--warning: #fabd2f;
--terminal-bg: #282828;
}
@layer base {
@@ -612,6 +636,11 @@ html.fs-xl {
var(--motion-ease-enter) both;
}
.motion-fade-enter {
animation: motion-fade-enter var(--motion-duration-context)
var(--motion-ease-enter) both;
}
.motion-workspace-enter {
animation: motion-workspace-enter var(--motion-duration-panel)
var(--motion-ease-enter) both;
@@ -699,6 +728,12 @@ html.fs-xl {
}
}
@keyframes motion-fade-enter {
from {
opacity: 0;
}
}
@keyframes motion-workspace-enter {
from {
opacity: 0;
@@ -759,6 +794,7 @@ html.fs-xl {
.motion-panel-enter,
.motion-context-enter,
.motion-fade-enter,
.motion-workspace-enter,
.motion-workspace-layout,
.motion-transfer-phase,
@@ -784,3 +820,20 @@ html.fs-xl {
transition-duration: 1ms !important;
}
}
/* A settings search result, marked briefly after the page opens. */
.settings-search-hit {
outline: 1px solid var(--accent-brand);
outline-offset: 2px;
transition: outline-color 1.2s ease;
}
/* Cards and rows the settings page search filtered out. */
[data-search-hidden] {
display: none !important;
}
::highlight(settings-search) {
background-color: color-mix(in oklab, var(--accent-brand) 35%, transparent);
color: inherit;
}
+42
View File
@@ -0,0 +1,42 @@
{
"home": "",
"install": "install",
"updating": "install/updating",
"betas": "configure/betas",
"envVars": "configure/environment-variables",
"https": "configure/https",
"database": "configure/database",
"reverseProxy": "configure/reverse-proxy",
"desktopSync": "configure/desktop-sync",
"interface": "guide/interface",
"dashboard": "guide/dashboard",
"manage": "guide/manage",
"hosts": "guide/hosts",
"portKnocking": "guide/hosts#port-knocking",
"hostDefaults": "guide/host-defaults",
"credentials": "guide/credentials",
"sharing": "guide/sharing",
"account": "guide/account",
"accountSecurity": "guide/account#security",
"accountData": "guide/account#your-data",
"apiKeys": "guide/api-keys",
"commandPalette": "guide/command-palette",
"settings": "guide/settings",
"interfaceSettings": "guide/settings#interface",
"appearance": "guide/settings#appearance",
"keybindings": "guide/settings#keybindings",
"users": "guide/users",
"sessions": "guide/users#sessions",
"roles": "guide/roles",
"adminGeneral": "guide/admin-settings",
"branding": "guide/admin-settings#branding",
"adminDatabase": "guide/admin-settings#database",
"adminSsl": "configure/https",
"auditLog": "guide/audit-log",
"importExport": "guide/import-export",
"plugins": "guide/plugins",
"pluginCatalog": "plugins",
"cli": "cli",
"develop": "develop",
"communityRegistry": "develop/community-registry"
}
+17
View File
@@ -0,0 +1,17 @@
import DOCS_PAGES from "./docs-pages.json";
export const DOCS_URL = "https://docs.termix.site";
/**
* Every core docs page the app links to. The docs site checks each one
* exists, so add new links here rather than writing URLs inline.
*/
export type DocsPage = keyof typeof DOCS_PAGES;
/** Full URL of a core docs page. */
export function docsUrl(page: DocsPage = "home"): string {
const path = DOCS_PAGES[page] ?? "";
return path ? `${DOCS_URL}/${path}` : `${DOCS_URL}/`;
}
export { DOCS_PAGES };
+32
View File
@@ -0,0 +1,32 @@
/**
* The two places this web UI runs only to sign someone in for another app:
* the Termix mobile app's WebView, and the iframe a desktop app shows a
* server's login in while linking. Neither should ever show onboarding.
*/
export interface EmbeddedFrameWindow extends Window {
IS_ELECTRON_WEBVIEW?: boolean;
ReactNativeWebView?: { postMessage: (msg: string) => void };
}
export function isInMobileWebView(): boolean {
return (
/Termix-Mobile\/(Android|iOS)/.test(navigator.userAgent) ||
!!(window as EmbeddedFrameWindow).ReactNativeWebView
);
}
export function isInElectronWebView(): boolean {
if (isInMobileWebView()) return false;
if ((window as EmbeddedFrameWindow).IS_ELECTRON_WEBVIEW) return true;
try {
if (window.self !== window.top) return true;
} catch {
return true;
}
return false;
}
export function isEmbeddedAuthFrame(): boolean {
return isInMobileWebView() || isInElectronWebView();
}
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Extract a stable, human-readable message from an unknown thrown value.
*
* A thrown value is not guaranteed to be an `Error` — libraries may throw
* A thrown value is not guaranteed to be an `Error`, libraries may throw
* strings or plain objects, which carry no useful `.message`. When that
* happens the caller gets `fallback` instead, so error paths never surface a
* raw `[object Object]` in logs or UI.
+16
View File
@@ -0,0 +1,16 @@
import { sha256 } from "@noble/hashes/sha2.js";
/**
* The backend sends the raw host key as hex. This turns it into the
* SHA256:... form ssh-keygen -lf prints, so users can compare the two.
*/
export function hostKeyFingerprint(hex: string): string {
if (!hex || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return hex;
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
let binary = "";
for (const byte of sha256(bytes)) binary += String.fromCharCode(byte);
return `SHA256:${btoa(binary).replace(/=+$/, "")}`;
}
+115
View File
@@ -0,0 +1,115 @@
import { isElectron } from "@/lib/electron";
export const CORE_REPO_URL = "https://github.com/Termix-SSH/Termix";
const MAX_URL_LENGTH = 7500;
/** A GitHub repo URL without .git or a trailing slash, or null if it is not one. */
export function normalizeGitHubRepo(repository?: string | null): string | null {
const repo = repository
?.trim()
.replace(/\.git$/, "")
.replace(/\/+$/, "");
if (!repo || !/^https:\/\/github\.com\/[^/]+\/[^/]+$/.test(repo)) {
return null;
}
return repo;
}
/**
* A new issue from one of the repo's issue forms, with fields prefilled by
* their form ids. Logs get cut first when the URL would be too long.
*/
export function buildIssueUrl(
repo: string,
options: {
template: string;
title?: string;
fields?: Record<string, string | null | undefined>;
},
): string {
const fields = Object.fromEntries(
Object.entries(options.fields ?? {}).filter(
(entry): entry is [string, string] => !!entry[1]?.trim(),
),
);
const build = () => {
const params = new URLSearchParams({ template: options.template });
if (options.title) params.set("title", options.title);
for (const [key, value] of Object.entries(fields)) params.set(key, value);
return `${repo}/issues/new?${params.toString()}`;
};
let url = build();
while (url.length > MAX_URL_LENGTH && fields.logs) {
const cut = Math.max(0, fields.logs.length - (url.length - MAX_URL_LENGTH));
fields.logs =
cut > 200 ? `${fields.logs.slice(0, cut - 20)}\n...(cut)` : undefined;
if (!fields.logs) delete fields.logs;
url = build();
}
return url;
}
function osName(ua: string): string {
if (/Android/i.test(ua)) return "Android";
if (/iPhone|iPad|iPod/i.test(ua)) return "iOS";
if (/Windows/i.test(ua)) return "Windows";
if (/Mac OS X|Macintosh/i.test(ua)) return "macOS";
if (/CrOS/i.test(ua)) return "ChromeOS";
if (/Linux/i.test(ua)) return "Linux";
return "Unknown OS";
}
function browserName(ua: string): string {
const match =
ua.match(/(Edg)\/(\d+)/) ??
ua.match(/(OPR)\/(\d+)/) ??
ua.match(/(Firefox)\/(\d+)/) ??
ua.match(/(Chrome)\/(\d+)/) ??
ua.match(/Version\/(\d+).*(Safari)/);
if (!match) return "Unknown browser";
if (match[2] === "Safari") return `Safari ${match[1]}`;
const names: Record<string, string> = { Edg: "Edge", OPR: "Opera" };
return `${names[match[1]] ?? match[1]} ${match[2]}`;
}
/** One line on where Termix is running, for the environment field. */
export function describeEnvironment(
userAgent: string = typeof navigator === "undefined"
? ""
: navigator.userAgent,
desktop: boolean = isElectron(),
): string {
const os = osName(userAgent);
return desktop
? `Desktop app on ${os}`
: `Web, ${browserName(userAgent)} on ${os}`;
}
/** A bug report on the Termix core repo. */
export function reportCoreIssueUrl(termixVersion?: string): string {
return buildIssueUrl(CORE_REPO_URL, {
template: "bug_report.yml",
fields: {
"termix-version": termixVersion,
environment: describeEnvironment(),
},
});
}
/** Beta feedback on the core repo, listing every plugin running a beta. */
export function reportBetaFeedbackUrl(
termixVersion?: string,
betaPlugins: Array<{ id: string; version: string }> = [],
): string {
return buildIssueUrl(CORE_REPO_URL, {
template: "beta_feedback.yml",
fields: {
"termix-version": termixVersion,
environment: describeEnvironment(),
"beta-plugins": betaPlugins
.map((plugin) => `${plugin.id} ${plugin.version}`)
.join("\n"),
},
});
}
+26 -3
View File
@@ -12,7 +12,7 @@
* - Electron pointed at a remote Termix server, for hosts whose connection
* origin resolves there.
*
* A7 re-exports both through @termix/plugin-sdk/frontend. They live here for
* A7 re-exports both through @termix-ssh/plugin-sdk/frontend. They live here for
* now because plugin frontends are still bundled with the shell.
*/
@@ -61,7 +61,10 @@ export function createPluginApi(pluginId: string): AxiosInstance {
property === "options" ||
property === "post" ||
property === "put" ||
property === "patch"
property === "patch" ||
property === "postForm" ||
property === "putForm" ||
property === "patchForm"
) {
return (url: string, ...rest: unknown[]) =>
(value as (...args: unknown[]) => unknown).call(
@@ -123,6 +126,26 @@ export function pluginApiFor(
return client;
}
/**
* A one minute socket token for the web app. The HttpOnly cookie alone fails
* behind a proxy that rewrites Host. Null for a guest or an older server, and
* the socket then falls back to the cookie.
*/
async function fetchSocketTicket(): Promise<string | null> {
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
try {
const response = await fetch(`${base}/users/ws-ticket`, {
method: "POST",
credentials: "include",
});
if (!response.ok) return null;
const data = (await response.json()) as { ticket?: unknown };
return typeof data.ticket === "string" ? data.ticket : null;
} catch {
return null;
}
}
/**
* The WebSocket URL for /plugin-ws/<id>/<path>, plus the subprotocols that
* carry the JWT.
@@ -148,7 +171,7 @@ export async function pluginWsUrl(
});
}
const token = localStorage.getItem("jwt");
const token = (await fetchSocketTicket()) ?? localStorage.getItem("jwt");
const protocols = websocketAuthProtocols(token);
// Dev without a configured API host goes through Vite's proxy, which is
+12
View File
@@ -0,0 +1,12 @@
import type { TFunction } from "i18next";
/** System roles store an i18n key as their display name. */
export function roleLabel<T extends string | null | undefined>(
t: TFunction,
displayName: T,
): T | string {
return typeof displayName === "string" &&
displayName.startsWith("rbac.roles.")
? t(displayName)
: displayName;
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Parses a timestamp from the API. SQLite stores CURRENT_TIMESTAMP as UTC
* with no zone ("2026-10-09 23:11:54"), which browsers read as local time.
*/
export function parseServerTime(ts: string): Date {
const bare = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/.test(ts);
return new Date(bare ? `${ts.replace(" ", "T")}Z` : ts);
}
+17
View File
@@ -0,0 +1,17 @@
/** Usage ramp for resource readouts: warning from 75, destructive from 90. */
/** Text color class for a percentage. */
export function usageColor(percent: number | null): string {
if (percent === null) return "text-muted-foreground";
if (percent >= 90) return "text-destructive";
if (percent >= 75) return "text-warning";
return "text-accent-brand";
}
/** Background class for the same ramp. */
export function usageBarColor(percent: number | null): string {
if (percent === null) return "bg-muted-foreground/30";
if (percent >= 90) return "bg-destructive";
if (percent >= 75) return "bg-warning";
return "bg-accent-brand";
}
+4 -2
View File
@@ -1,3 +1,5 @@
# DO NOT MODIFY TRANSLATED FILES DIRECTLY
# Translations
Instead, use Crowdsin. See [docs](https://docs.termix.site/translations).
Only `en.json` needs to be updated. Every other language in `translated/` is filled in automatically for any string that doesn't have a translation yet.
If an automatic translation is wrong, fix it in that language's file. Strings that already have a translation are never overwritten.
+534 -231
View File
File diff suppressed because it is too large. Load diff
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Forseer sleutelbord-interaktief",
"overrideCredentialUsername": "Oorskryf geloofsbriewe gebruikersnaam",
"overrideCredentialUsernameDesc": "Gebruik die gebruikersnaam wat hierbo gespesifiseer is in plaas van die gebruikersnaam van die geloofsbriewe",
"oidcUsernameHint": "Gebruik $external.username om die naam in te vul waarmee jy aangemeld het via SSO of LDAP.",
"externalUsernameHint": "Gebruik $external.username om die naam in te vul waarmee jy aangemeld het via SSO of LDAP.",
"jumpHostChain": "Springgasheerketting",
"portKnocking": "Port Knocking",
"addKnock": "Voeg poort by",
@@ -977,7 +977,7 @@
"noSearchResults": "Geen verbindings stem ooreen met jou soektog nie",
"rename": "Hernoem sessie"
},
"terminal": {
"sshAuth": {
"totpRequired": "Tweefaktor-verifikasie vereis",
"totpCodeLabel": "Verifikasiekode",
"totpVerify": "Verifieer",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Kon nie die terugstelkode verifieer nie",
"failedCompleteReset": "Kon nie wagwoordterugstelling voltooi nie",
"invalidTotpCode": "Ongeldige TOTP-kode",
"failedOidcLogin": "Kon nie eksterne aanmelding begin nie",
"silentSigninOidcUnavailable": "Stille aanmelding is versoek, maar geen eksterne aanmelding is opgestel nie.",
"failedExternalLogin": "Kon nie eksterne aanmelding begin nie",
"silentSigninExternalUnavailable": "Stille aanmelding is versoek, maar geen eksterne aanmelding is opgestel nie.",
"failedUserInfo": "Kon nie gebruikersinligting kry na aanmelding nie",
"oidcAuthFailed": "Eksterne aanmelding het misluk",
"externalAuthFailed": "Eksterne aanmelding het misluk",
"invalidAuthUrl": "Ongeldige magtigings-URL ontvang van backend",
"requiredField": "Hierdie veld is verpligtend",
"minLength": "Minimum lengte is {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Laat nuwe gebruikers hulself registreer met 'n gebruikersnaam en wagwoord",
"allowPasswordLogin": "Laat Wagwoord Aanmelding Toe",
"allowPasswordLoginDesc": "Gebruikersnaam/wagwoord aanmelding",
"oidcAutoProvision": "Skep eksterne rekeninge outomaties",
"oidcAutoProvisionDesc": "Skep 'n rekening die eerste keer wat iemand aanmeld deur 'n eksterne aanmelding soos SSO of LDAP, selfs wanneer registrasie afgeskakel is.",
"externalAutoProvision": "Skep eksterne rekeninge outomaties",
"externalAutoProvisionDesc": "Skep 'n rekening die eerste keer wat iemand aanmeld deur 'n eksterne aanmelding soos SSO of LDAP, selfs wanneer registrasie afgeskakel is.",
"secondFactorAfterExternalLogin": "Vra vir 'n tweede faktor na eksterne aanmeldings (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Af, 'n ingeskrewe tweede faktor word slegs gevra na wagwoord en ander plaaslike aanmeldings, dieselfde as voorheen. Aan, dit word gevra na elke aanmeldmetode, insluitend SSO en LDAP.",
"allowPasswordReset": "Laat wagwoordherstel toe",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Geen vervaldatum",
"revokedBadge": "HERROEP",
"authTypeDual": "Dubbele Magtiging",
"authTypeOidc": "Eksterne",
"authTypeExternal": "Eksterne",
"authTypeLocal": "Plaaslik",
"adminStatusAdministrator": "Administrateur",
"adminStatusRegularUser": "Gereelde Gebruiker",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Laat beide eksterne en wagwoordaanmelding toe",
"linkAccountTargetUsername": "Gebruikersnaam van die plaaslike rekening",
"linkAccountTargetPlaceholder": "Voer die plaaslike rekeninggebruikersnaam in om aan te koppel",
"linkAccountOidcUsername": "Eksterne rekening gebruikersnaam",
"linkAccountOidcPlaceholder": "Voer die gebruikersnaam van die slegs-eksterne rekening in om saam te voeg",
"linkAccountOidcNotFound": "Geen eksterne rekening gevind met daardie gebruikersnaam nie",
"linkAccountExternalUsername": "Eksterne rekening gebruikersnaam",
"linkAccountExternalPlaceholder": "Voer die gebruikersnaam van die slegs-eksterne rekening in om saam te voeg",
"linkAccountExternalNotFound": "Geen eksterne rekening gevind met daardie gebruikersnaam nie",
"linkAccounts": "Koppel rekeninge",
"linkAccountSuccess": "Rekeninge suksesvol gekoppel",
"linkAccountFailed": "Kon nie rekeninge koppel nie",
@@ -1400,7 +1400,6 @@
"saving": "Stoor...",
"updateRegistrationFailed": "Kon nie registrasie-instellings opdateer nie",
"updatePasswordLoginFailed": "Kon nie wagwoord-aanmeldinstelling opdateer nie",
"updateOidcAutoProvisionFailed": "Kon nie die outomatiese skep-instelling opdateer nie",
"updateSecondFactorAfterExternalLoginFailed": "Kon nie die tweede faktorinstelling opdateer nie",
"updatePasswordResetFailed": "Kon nie wagwoordterugstellinginstelling opdateer nie",
"sessionTimeoutRange2": "Sessie-tydsberekening moet tussen 1 en 720 uur wees",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "GESLUIT",
"dataLockedNotice": "{{username}}se data bly gesluit totdat hulle weer aanmeld. Hul gashere, geloofsbriewe en brokkies kan nie tot dan besigtig of geredigeer word nie.",
"resetPasswordTitle": "Herstel wagwoord",
"resetPasswordOidcOnly": "Hierdie gebruiker meld aan deur 'n eksterne verskaffer en het geen wagwoord nie.",
"resetPasswordExternalOnly": "Hierdie gebruiker meld aan deur 'n eksterne verskaffer en het geen wagwoord nie.",
"resetPasswordPlaceholder": "Nuwe wagwoord",
"resetPasswordBtn": "Herstel",
"resetPasswordWorking": "Herstel tans...",
@@ -1742,7 +1741,7 @@
"importFailed": "Invoer het misluk: {{error}}",
"roleUser": "Gebruiker",
"authMethodDual": "Dubbele Magtiging",
"authMethodOidc": "Eksterne",
"authMethodExternal": "Eksterne",
"done": "Klaar",
"apiKeyNameRequired": "Sleutelnaam word vereis",
"apiKeyCreated": "API-sleutel \"{{name}}\" geskep",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "فرض تفاعل لوحة المفاتيح",
"overrideCredentialUsername": "تجاوز اسم مستخدم الاعتماد",
"overrideCredentialUsernameDesc": "استخدم اسم المستخدم المحدد أعلاه بدلاً من اسم مستخدم الاعتماد",
"oidcUsernameHint": "استخدم $external.username لملء الاسم الذي قمت بتسجيل الدخول به من خلال SSO أو LDAP.",
"externalUsernameHint": "استخدم $external.username لملء الاسم الذي قمت بتسجيل الدخول به من خلال SSO أو LDAP.",
"jumpHostChain": "سلسلة مضيفات الانتقال",
"portKnocking": "طرق المنفذ",
"addKnock": "إضافة منفذ",
@@ -977,7 +977,7 @@
"noSearchResults": "لا توجد اتصالات تطابق بحثك",
"rename": "إعادة تسمية الجلسة"
},
"terminal": {
"sshAuth": {
"totpRequired": "مطلوب المصادقة الثنائية",
"totpCodeLabel": "رمز التحقق",
"totpVerify": "تحقق",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "فشل التحقق من رمز إعادة التعيين",
"failedCompleteReset": "فشل إكمال إعادة تعيين كلمة المرور",
"invalidTotpCode": "رمز TOTP غير صالح",
"failedOidcLogin": "فشل بدء تسجيل الدخول الخارجي",
"silentSigninOidcUnavailable": "تم طلب تسجيل الدخول الصامت، ولكن لم يتم إعداد أي تسجيل دخول خارجي.",
"failedExternalLogin": "فشل بدء تسجيل الدخول الخارجي",
"silentSigninExternalUnavailable": "تم طلب تسجيل الدخول الصامت، ولكن لم يتم إعداد أي تسجيل دخول خارجي.",
"failedUserInfo": "فشل الحصول على معلومات المستخدم بعد تسجيل الدخول",
"oidcAuthFailed": "فشل تسجيل الدخول الخارجي",
"externalAuthFailed": "فشل تسجيل الدخول الخارجي",
"invalidAuthUrl": "تم استلام عنوان URL تفويض غير صالح من الخادم الخلفي",
"requiredField": "هذا الحقل مطلوب",
"minLength": "الحد الأدنى للطول هو {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "يسمح للمستخدمين الجدد بالتسجيل الذاتي باستخدام اسم مستخدم وكلمة مرور",
"allowPasswordLogin": "السماح بتسجيل الدخول بكلمة المرور",
"allowPasswordLoginDesc": "تسجيل الدخول باسم المستخدم/كلمة المرور",
"oidcAutoProvision": "إنشاء حسابات خارجية تلقائياً",
"oidcAutoProvisionDesc": "أنشئ حسابًا في المرة الأولى التي يسجل فيها شخص ما الدخول من خلال تسجيل دخول خارجي مثل SSO أو LDAP، حتى عندما يكون التسجيل معطلاً",
"externalAutoProvision": "إنشاء حسابات خارجية تلقائياً",
"externalAutoProvisionDesc": "أنشئ حسابًا في المرة الأولى التي يسجل فيها شخص ما الدخول من خلال تسجيل دخول خارجي مثل SSO أو LDAP، حتى عندما يكون التسجيل معطلاً",
"secondFactorAfterExternalLogin": "اطلب عامل مصادقة ثانٍ بعد عمليات تسجيل الدخول الخارجية (SSO، GitHub، Google، LDAP)",
"secondFactorAfterExternalLoginDesc": "في حالة عدم تفعيل عامل التحقق الثاني، يُطلب فقط بعد إدخال كلمة المرور وتسجيلات الدخول المحلية الأخرى، كما كان الحال سابقًا. أما في حالة تفعيله، فيُطلب بعد كل طريقة تسجيل دخول، بما في ذلك تسجيل الدخول الموحد (SSO) وبروتوكول LDAP.",
"allowPasswordReset": "السماح بإعادة تعيين كلمة المرور",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "بدون انتهاء صلاحية",
"revokedBadge": "ملغى",
"authTypeDual": "مصادقة مزدوجة",
"authTypeOidc": "خارجي",
"authTypeExternal": "خارجي",
"authTypeLocal": "محلي",
"adminStatusAdministrator": "مدير",
"adminStatusRegularUser": "مستخدم عادي",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "السماح بتسجيل الدخول الخارجي وتسجيل الدخول باستخدام كلمة المرور",
"linkAccountTargetUsername": "اسم مستخدم الحساب المحلي",
"linkAccountTargetPlaceholder": "أدخل اسم مستخدم الحساب المحلي للربط به",
"linkAccountOidcUsername": "اسم مستخدم الحساب الخارجي",
"linkAccountOidcPlaceholder": "أدخل اسم مستخدم الحساب الخارجي فقط لدمجه",
"linkAccountOidcNotFound": "لم يتم العثور على حساب خارجي فقط بهذا الاسم المستخدم",
"linkAccountExternalUsername": "اسم مستخدم الحساب الخارجي",
"linkAccountExternalPlaceholder": "أدخل اسم مستخدم الحساب الخارجي فقط لدمجه",
"linkAccountExternalNotFound": "لم يتم العثور على حساب خارجي فقط بهذا الاسم المستخدم",
"linkAccounts": "ربط الحسابات",
"linkAccountSuccess": "تم ربط الحسابات بنجاح",
"linkAccountFailed": "فشل ربط الحسابات",
@@ -1400,7 +1400,6 @@
"saving": "جارٍ الحفظ...",
"updateRegistrationFailed": "فشل تحديث إعداد التسجيل",
"updatePasswordLoginFailed": "فشل تحديث إعداد تسجيل الدخول بكلمة المرور",
"updateOidcAutoProvisionFailed": "فشل تحديث إعداد الإنشاء التلقائي",
"updateSecondFactorAfterExternalLoginFailed": "فشل تحديث إعدادات العامل الثاني",
"updatePasswordResetFailed": "فشل تحديث إعداد إعادة تعيين كلمة المرور",
"sessionTimeoutRange2": "يجب أن تكون مهلة الجلسة بين 1 و 720 ساعة",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "مقفل",
"dataLockedNotice": "تظل بيانات {{username}} مقفلة حتى تسجيل الدخول التالي. لا يمكن عرض أو تعديل مضيفيه وبيانات اعتماده ومقاطع حتى ذلك الحين.",
"resetPasswordTitle": "إعادة تعيين كلمة المرور",
"resetPasswordOidcOnly": "يسجل هذا المستخدم الدخول عبر مزود خارجي ولا يملك كلمة مرور.",
"resetPasswordExternalOnly": "يسجل هذا المستخدم الدخول عبر مزود خارجي ولا يملك كلمة مرور.",
"resetPasswordPlaceholder": "كلمة مرور جديدة",
"resetPasswordBtn": "إعادة تعيين",
"resetPasswordWorking": "جارٍ إعادة التعيين...",
@@ -1742,7 +1741,7 @@
"importFailed": "فشل الاستيراد: {{error}}",
"roleUser": "مستخدم",
"authMethodDual": "مصادقة مزدوجة",
"authMethodOidc": "خارجي",
"authMethodExternal": "خارجي",
"done": "تم",
"apiKeyNameRequired": "اسم المفتاح مطلوب",
"apiKeyCreated": "تم إنشاء مفتاح API \"{{name}}\"",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Принудително интерактивно с клавиатура",
"overrideCredentialUsername": "Замяна на потребителско име за идентификационни данни",
"overrideCredentialUsernameDesc": "Използвайте посоченото по-горе потребителско име вместо потребителското име от идентификационните данни",
"oidcUsernameHint": "Използвайте $external.username, за да попълните името, с което сте влезли чрез SSO или LDAP.",
"externalUsernameHint": "Използвайте $external.username, за да попълните името, с което сте влезли чрез SSO или LDAP.",
"jumpHostChain": "Верига за прескачане на хост",
"portKnocking": "Порт Нокинг",
"addKnock": "Добавяне на порт",
@@ -977,7 +977,7 @@
"noSearchResults": "Няма връзки, които да отговарят на търсенето ви",
"rename": "Преименуване на сесия"
},
"terminal": {
"sshAuth": {
"totpRequired": "Изисква се двуфакторно удостоверяване",
"totpCodeLabel": "Код за потвърждение",
"totpVerify": "Проверка",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Неуспешно потвърждаване на кода за нулиране",
"failedCompleteReset": "Неуспешно завършване на нулирането на паролата",
"invalidTotpCode": "Невалиден TOTP код",
"failedOidcLogin": "Стартирането на външно влизане не бе успешно",
"silentSigninOidcUnavailable": "Заявено е безшумно влизане, но не е настроено външно влизане.",
"failedExternalLogin": "Стартирането на външно влизане не бе успешно",
"silentSigninExternalUnavailable": "Заявено е безшумно влизане, но не е настроено външно влизане.",
"failedUserInfo": "Неуспешно получаване на потребителска информация след влизане",
"oidcAuthFailed": "Външното влизане не бе успешно",
"externalAuthFailed": "Външното влизане не бе успешно",
"invalidAuthUrl": "Невалиден URL адрес за оторизация, получен от бекенд системата",
"requiredField": "Това поле е задължително",
"minLength": "Минималната дължина е {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Позволете на новите потребители да се регистрират сами с потребителско име и парола",
"allowPasswordLogin": "Разрешаване на вход с парола",
"allowPasswordLoginDesc": "Вход с потребителско име/парола",
"oidcAutoProvision": "Автоматично създаване на външни акаунти",
"oidcAutoProvisionDesc": "Създайте акаунт при първото влизане на някого чрез външен вход, като например SSO или LDAP, дори когато регистрацията е изключена",
"externalAutoProvision": "Автоматично създаване на външни акаунти",
"externalAutoProvisionDesc": "Създайте акаунт при първото влизане на някого чрез външен вход, като например SSO или LDAP, дори когато регистрацията е изключена",
"secondFactorAfterExternalLogin": "Искайте втори фактор след външни входове (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Изключено, регистриран втори фактор се изисква само след парола и други локални входове, както преди. Включено, той се изисква след всеки метод за вход, включително SSO и LDAP.",
"allowPasswordReset": "Разрешаване на нулиране на паролата",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Без срок на годност",
"revokedBadge": "ОТМЕНЕНО",
"authTypeDual": "Двойно удостоверяване",
"authTypeOidc": "Външен",
"authTypeExternal": "Външен",
"authTypeLocal": "Местно",
"adminStatusAdministrator": "Администратор",
"adminStatusRegularUser": "Редовен потребител",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Разрешаване на външно влизане и влизане с парола",
"linkAccountTargetUsername": "Потребителско име за локален акаунт",
"linkAccountTargetPlaceholder": "Въведете потребителското име на локалния акаунт, към който да се свържете",
"linkAccountOidcUsername": "Потребителско име за външен акаунт",
"linkAccountOidcPlaceholder": "Въведете потребителското име само за външен акаунт, за да го обедините",
"linkAccountOidcNotFound": "Не е намерен само външен акаунт с това потребителско име",
"linkAccountExternalUsername": "Потребителско име за външен акаунт",
"linkAccountExternalPlaceholder": "Въведете потребителското име само за външен акаунт, за да го обедините",
"linkAccountExternalNotFound": "Не е намерен само външен акаунт с това потребителско име",
"linkAccounts": "Свързване на акаунти",
"linkAccountSuccess": "Профилите са свързани успешно",
"linkAccountFailed": "Свързването на акаунти не бе успешно",
@@ -1400,7 +1400,6 @@
"saving": "Запазване...",
"updateRegistrationFailed": "Актуализирането на настройката за регистрация не бе успешно",
"updatePasswordLoginFailed": "Актуализирането на настройката за вход с парола не бе успешно",
"updateOidcAutoProvisionFailed": "Актуализирането на настройката за автоматично създаване не бе успешно",
"updateSecondFactorAfterExternalLoginFailed": "Актуализирането на настройката за втори фактор не бе успешно",
"updatePasswordResetFailed": "Актуализирането на настройката за нулиране на паролата не бе успешно",
"sessionTimeoutRange2": "Времето за изчакване на сесията трябва да е между 1 и 720 часа",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "ЗАКЛЮЧЕНО",
"dataLockedNotice": "Данните на {{username}}остават заключени до следващото им влизане. Техните хостове, идентификационни данни и фрагменти не могат да бъдат преглеждани или редактирани дотогава.",
"resetPasswordTitle": "Нулиране на парола",
"resetPasswordOidcOnly": "Този потребител влиза чрез външен доставчик и няма парола.",
"resetPasswordExternalOnly": "Този потребител влиза чрез външен доставчик и няма парола.",
"resetPasswordPlaceholder": "Нова парола",
"resetPasswordBtn": "Нулиране",
"resetPasswordWorking": "Нулиране...",
@@ -1742,7 +1741,7 @@
"importFailed": "Импортирането не бе успешно: {{error}}",
"roleUser": "Потребител",
"authMethodDual": "Двойно удостоверяване",
"authMethodOidc": "Външен",
"authMethodExternal": "Външен",
"done": "Готово",
"apiKeyNameRequired": "Името на ключа е задължително",
"apiKeyCreated": "API ключът „{{name}}“ е създаден",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "ফোর্স কীবোর্ড-ইন্টারেক্টিভ",
"overrideCredentialUsername": "ক্রেডেনশিয়াল ব্যবহারকারীর নাম ওভাররাইড করুন",
"overrideCredentialUsernameDesc": "ক্রেডেনশিয়ালের ইউজারনেমের পরিবর্তে উপরে উল্লেখিত ইউজারনেমটি ব্যবহার করুন।",
"oidcUsernameHint": "SSO বা LDAP-এর মাধ্যমে আপনি যে নামটি দিয়ে সাইন ইন করেছেন, সেটি পূরণ করতে $external.username ব্যবহার করুন।",
"externalUsernameHint": "SSO বা LDAP-এর মাধ্যমে আপনি যে নামটি দিয়ে সাইন ইন করেছেন, সেটি পূরণ করতে $external.username ব্যবহার করুন।",
"jumpHostChain": "জাম্প হোস্ট চেইন",
"portKnocking": "পোর্ট নকিং",
"addKnock": "পোর্ট যোগ করুন",
@@ -977,7 +977,7 @@
"noSearchResults": "আপনার অনুসন্ধানের সাথে কোনো সংযোগ মেলেনি।",
"rename": "পুনঃনামকরণ অধিবেশন"
},
"terminal": {
"sshAuth": {
"totpRequired": "দ্বি-স্তরীয় প্রমাণীকরণ আবশ্যক",
"totpCodeLabel": "যাচাইকরণ কোড",
"totpVerify": "যাচাই করুন",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "রিসেট কোড যাচাই করতে ব্যর্থ হয়েছে",
"failedCompleteReset": "পাসওয়ার্ড রিসেট সম্পন্ন করা যায়নি",
"invalidTotpCode": "অবৈধ TOTP কোড",
"failedOidcLogin": "বাহ্যিক সাইন-ইন শুরু করতে ব্যর্থ হয়েছে",
"silentSigninOidcUnavailable": "নীরব সাইন-ইন-এর জন্য অনুরোধ করা হয়েছিল, কিন্তু কোনো বাহ্যিক সাইন-ইন সেট আপ করা নেই।",
"failedExternalLogin": "বাহ্যিক সাইন-ইন শুরু করতে ব্যর্থ হয়েছে",
"silentSigninExternalUnavailable": "নীরব সাইন-ইন-এর জন্য অনুরোধ করা হয়েছিল, কিন্তু কোনো বাহ্যিক সাইন-ইন সেট আপ করা নেই।",
"failedUserInfo": "লগইন করার পর ব্যবহারকারীর তথ্য পেতে ব্যর্থ হয়েছে",
"oidcAuthFailed": "বাহ্যিক সাইন-ইন ব্যর্থ হয়েছে",
"externalAuthFailed": "বাহ্যিক সাইন-ইন ব্যর্থ হয়েছে",
"invalidAuthUrl": "ব্যাকএন্ড থেকে প্রাপ্ত অনুমোদন URLটি অবৈধ।",
"requiredField": "এই ক্ষেত্রটি আবশ্যক",
"minLength": "সর্বনিম্ন দৈর্ঘ্য হল {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "নতুন ব্যবহারকারীদের একটি ইউজারনেম ও পাসওয়ার্ড দিয়ে স্ব-নিবন্ধন করতে দিন।",
"allowPasswordLogin": "পাসওয়ার্ড লগইনের অনুমতি দিন",
"allowPasswordLoginDesc": "ব্যবহারকারীর নাম/পাসওয়ার্ড লগইন",
"oidcAutoProvision": "স্বয়ংক্রিয়ভাবে বাহ্যিক অ্যাকাউন্ট তৈরি করুন",
"oidcAutoProvisionDesc": "কেউ যখন SSO বা LDAP-এর মতো কোনো বাহ্যিক লগইনের মাধ্যমে প্রথমবার সাইন ইন করে, তখন রেজিস্ট্রেশন বন্ধ থাকলেও একটি অ্যাকাউন্ট তৈরি করুন।",
"externalAutoProvision": "স্বয়ংক্রিয়ভাবে বাহ্যিক অ্যাকাউন্ট তৈরি করুন",
"externalAutoProvisionDesc": "কেউ যখন SSO বা LDAP-এর মতো কোনো বাহ্যিক লগইনের মাধ্যমে প্রথমবার সাইন ইন করে, তখন রেজিস্ট্রেশন বন্ধ থাকলেও একটি অ্যাকাউন্ট তৈরি করুন।",
"secondFactorAfterExternalLogin": "বাহ্যিক লগইনের (এসএসও, গিটহাব, গুগল, এলড্যাপ) পর দ্বিতীয় ফ্যাক্টরের জন্য অনুরোধ করুন।",
"secondFactorAfterExternalLoginDesc": "অফ থাকলে, আগের মতোই শুধুমাত্র পাসওয়ার্ড এবং অন্যান্য লোকাল লগইনের পরেই নথিভুক্ত দ্বিতীয় ফ্যাক্টরটি চাওয়া হয়। অন থাকলে, SSO এবং LDAP সহ প্রতিটি লগইন পদ্ধতির পরেই এটি চাওয়া হয়।",
"allowPasswordReset": "পাসওয়ার্ড রিসেট করার অনুমতি দিন",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "কোনো মেয়াদ নেই",
"revokedBadge": "বাতিল করা হয়েছে",
"authTypeDual": "দ্বৈত প্রমাণীকরণ",
"authTypeOidc": "বাহ্যিক",
"authTypeExternal": "বাহ্যিক",
"authTypeLocal": "স্থানীয়",
"adminStatusAdministrator": "প্রশাসক",
"adminStatusRegularUser": "নিয়মিত ব্যবহারকারী",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "বাহ্যিক এবং পাসওয়ার্ড উভয় সাইন-ইনের অনুমতি দিন",
"linkAccountTargetUsername": "স্থানীয় অ্যাকাউন্টের ব্যবহারকারীর নাম",
"linkAccountTargetPlaceholder": "লিঙ্ক করার জন্য স্থানীয় অ্যাকাউন্টের ব্যবহারকারীর নাম লিখুন",
"linkAccountOidcUsername": "বাহ্যিক অ্যাকাউন্টের ব্যবহারকারীর নাম",
"linkAccountOidcPlaceholder": "মার্জ করার জন্য শুধুমাত্র বাহ্যিক অ্যাকাউন্টের ইউজারনেমটি প্রবেশ করান।",
"linkAccountOidcNotFound": "এই ইউজারনেম দিয়ে কোনো শুধুমাত্র বাহ্যিক ব্যবহারের অ্যাকাউন্ট খুঁজে পাওয়া যায়নি।",
"linkAccountExternalUsername": "বাহ্যিক অ্যাকাউন্টের ব্যবহারকারীর নাম",
"linkAccountExternalPlaceholder": "মার্জ করার জন্য শুধুমাত্র বাহ্যিক অ্যাকাউন্টের ইউজারনেমটি প্রবেশ করান।",
"linkAccountExternalNotFound": "এই ইউজারনেম দিয়ে কোনো শুধুমাত্র বাহ্যিক ব্যবহারের অ্যাকাউন্ট খুঁজে পাওয়া যায়নি।",
"linkAccounts": "অ্যাকাউন্ট লিঙ্ক করুন",
"linkAccountSuccess": "অ্যাকাউন্টগুলি সফলভাবে লিঙ্ক করা হয়েছে",
"linkAccountFailed": "অ্যাকাউন্ট লিঙ্ক করতে ব্যর্থ হয়েছে",
@@ -1400,7 +1400,6 @@
"saving": "সঞ্চয়...",
"updateRegistrationFailed": "নিবন্ধন সেটিং আপডেট করতে ব্যর্থ হয়েছে",
"updatePasswordLoginFailed": "পাসওয়ার্ড লগইন সেটিং আপডেট করতে ব্যর্থ হয়েছে",
"updateOidcAutoProvisionFailed": "স্বয়ংক্রিয়-সৃষ্টি সেটিং আপডেট করতে ব্যর্থ হয়েছে",
"updateSecondFactorAfterExternalLoginFailed": "দ্বিতীয় ফ্যাক্টর সেটিং আপডেট করতে ব্যর্থ হয়েছে",
"updatePasswordResetFailed": "পাসওয়ার্ড রিসেট সেটিং আপডেট করতে ব্যর্থ হয়েছে",
"sessionTimeoutRange2": "সেশন টাইমআউট অবশ্যই ১ থেকে ৭২০ ঘণ্টার মধ্যে হতে হবে।",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "লক করা",
"dataLockedNotice": "{{username}}-এর ডেটা পরবর্তী লগইন পর্যন্ত লক করা থাকে। ততক্ষণ পর্যন্ত তাদের হোস্ট, ক্রেডেনশিয়াল এবং স্নিপেট দেখা বা সম্পাদনা করা যায় না।",
"resetPasswordTitle": "পাসওয়ার্ড রিসেট করুন",
"resetPasswordOidcOnly": "এই ব্যবহারকারী একটি বাহ্যিক প্রদানকারীর মাধ্যমে সাইন ইন করেন এবং তার কোনো পাসওয়ার্ড নেই।",
"resetPasswordExternalOnly": "এই ব্যবহারকারী একটি বাহ্যিক প্রদানকারীর মাধ্যমে সাইন ইন করেন এবং তার কোনো পাসওয়ার্ড নেই।",
"resetPasswordPlaceholder": "নতুন পাসওয়ার্ড",
"resetPasswordBtn": "রিসেট",
"resetPasswordWorking": "রিসেট করা হচ্ছে...",
@@ -1742,7 +1741,7 @@
"importFailed": "আমদানি ব্যর্থ হয়েছে: {{error}}",
"roleUser": "ব্যবহারকারী",
"authMethodDual": "দ্বৈত প্রমাণীকরণ",
"authMethodOidc": "বাহ্যিক",
"authMethodExternal": "বাহ্যিক",
"done": "সম্পন্ন হয়েছে",
"apiKeyNameRequired": "কী-এর নাম আবশ্যক।",
"apiKeyCreated": "এপিআই কী \"{{name}}\" তৈরি করা হয়েছে",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Forçar la interacció del teclat",
"overrideCredentialUsername": "Substitueix les credencials i el nom d'usuari",
"overrideCredentialUsernameDesc": "Feu servir el nom d'usuari especificat anteriorment en comptes del nom d'usuari de la credencial",
"oidcUsernameHint": "Feu servir $external.username per omplir el nom amb què heu iniciat la sessió mitjançant SSO o LDAP.",
"externalUsernameHint": "Feu servir $external.username per omplir el nom amb què heu iniciat la sessió mitjançant SSO o LDAP.",
"jumpHostChain": "Cadena d'amfitrió de salt",
"portKnocking": "Cop de port",
"addKnock": "Afegeix un port",
@@ -977,7 +977,7 @@
"noSearchResults": "No hi ha connexions que coincideixin amb la teva cerca",
"rename": "Canvia el nom de la sessió"
},
"terminal": {
"sshAuth": {
"totpRequired": "Cal autenticació de dos factors",
"totpCodeLabel": "Codi de verificació",
"totpVerify": "Verifica",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "No s'ha pogut verificar el codi de restabliment",
"failedCompleteReset": "No s'ha pogut completar el restabliment de la contrasenya",
"invalidTotpCode": "Codi TOTP no vàlid",
"failedOidcLogin": "No s'ha pogut iniciar l'inici de sessió extern",
"silentSigninOidcUnavailable": "S'ha sol·licitat l'inici de sessió silenciós, però no hi ha cap inici de sessió extern configurat.",
"failedExternalLogin": "No s'ha pogut iniciar l'inici de sessió extern",
"silentSigninExternalUnavailable": "S'ha sol·licitat l'inici de sessió silenciós, però no hi ha cap inici de sessió extern configurat.",
"failedUserInfo": "No s'ha pogut obtenir la informació de l'usuari després de l'inici de sessió",
"oidcAuthFailed": "L'inici de sessió extern ha fallat",
"externalAuthFailed": "L'inici de sessió extern ha fallat",
"invalidAuthUrl": "URL d'autorització no vàlida rebuda del backend",
"requiredField": "Aquest camp és obligatori",
"minLength": "La longitud mínima és {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Permet que els nous usuaris s'autoregistrin amb un nom d'usuari i una contrasenya",
"allowPasswordLogin": "Permet l'inici de sessió amb contrasenya",
"allowPasswordLoginDesc": "Inici de sessió amb nom d'usuari/contrasenya",
"oidcAutoProvision": "Crea automàticament comptes externs",
"oidcAutoProvisionDesc": "Crea un compte la primera vegada que algú iniciï la sessió mitjançant un inici de sessió extern com ara SSO o LDAP, fins i tot quan el registre estigui desactivat",
"externalAutoProvision": "Crea automàticament comptes externs",
"externalAutoProvisionDesc": "Crea un compte la primera vegada que algú iniciï la sessió mitjançant un inici de sessió extern com ara SSO o LDAP, fins i tot quan el registre estigui desactivat",
"secondFactorAfterExternalLogin": "Demanar un segon factor després d'inicis de sessió externs (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Desactivat, només es demana un segon factor inscrit després de la contrasenya i altres inicis de sessió locals, igual que abans. Activat, es demana després de cada mètode d'inici de sessió, inclosos SSO i LDAP.",
"allowPasswordReset": "Permet el restabliment de la contrasenya",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Sense caducitat",
"revokedBadge": "REVOCAT",
"authTypeDual": "Doble autenticació",
"authTypeOidc": "Extern",
"authTypeExternal": "Extern",
"authTypeLocal": "Local",
"adminStatusAdministrator": "Administrador/a",
"adminStatusRegularUser": "Usuari habitual",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Permet l'inici de sessió extern i amb contrasenya",
"linkAccountTargetUsername": "Nom d'usuari del compte local",
"linkAccountTargetPlaceholder": "Introduïu el nom d'usuari del compte local al qual voleu enllaçar",
"linkAccountOidcUsername": "Nom d'usuari del compte extern",
"linkAccountOidcPlaceholder": "Introduïu el nom d'usuari del compte només extern per fusionar-lo",
"linkAccountOidcNotFound": "No s'ha trobat cap compte només extern amb aquest nom d'usuari",
"linkAccountExternalUsername": "Nom d'usuari del compte extern",
"linkAccountExternalPlaceholder": "Introduïu el nom d'usuari del compte només extern per fusionar-lo",
"linkAccountExternalNotFound": "No s'ha trobat cap compte només extern amb aquest nom d'usuari",
"linkAccounts": "Enllaçar comptes",
"linkAccountSuccess": "Comptes enllaçats correctament",
"linkAccountFailed": "No s'han pogut enllaçar els comptes",
@@ -1400,7 +1400,6 @@
"saving": "Desant...",
"updateRegistrationFailed": "No s'ha pogut actualitzar la configuració del registre",
"updatePasswordLoginFailed": "No s'ha pogut actualitzar la configuració d'inici de sessió amb contrasenya",
"updateOidcAutoProvisionFailed": "No s'ha pogut actualitzar la configuració de creació automàtica",
"updateSecondFactorAfterExternalLoginFailed": "No s'ha pogut actualitzar la configuració del segon factor",
"updatePasswordResetFailed": "No s'ha pogut actualitzar la configuració de restabliment de la contrasenya",
"sessionTimeoutRange2": "El temps d'espera de la sessió ha d'estar entre 1 i 720 hores",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "BLOQUEJAT",
"dataLockedNotice": "Les dades de {{username}}romanen bloquejades fins que inicien la sessió de nou. Els seus amfitrions, credencials i fragments no es poden veure ni editar fins aleshores.",
"resetPasswordTitle": "Restablir contrasenya",
"resetPasswordOidcOnly": "Aquest usuari inicia la sessió a través d'un proveïdor extern i no té contrasenya.",
"resetPasswordExternalOnly": "Aquest usuari inicia la sessió a través d'un proveïdor extern i no té contrasenya.",
"resetPasswordPlaceholder": "Nova contrasenya",
"resetPasswordBtn": "Restablir",
"resetPasswordWorking": "Restableix...",
@@ -1742,7 +1741,7 @@
"importFailed": "La importació ha fallat: {{error}}",
"roleUser": "Usuari",
"authMethodDual": "Doble autenticació",
"authMethodOidc": "Extern",
"authMethodExternal": "Extern",
"done": "Fet",
"apiKeyNameRequired": "El nom de la clau és obligatori",
"apiKeyCreated": "Clau API \"{{name}}\" creada",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Vynutit interaktivní klávesnici",
"overrideCredentialUsername": "Přepsat uživatelské jméno pro přihlašovací údaje",
"overrideCredentialUsernameDesc": "Použijte výše uvedené uživatelské jméno místo uživatelského jména z přihlašovacích údajů.",
"oidcUsernameHint": "Použijte $external.username k vyplnění jména, pod kterým jste se přihlásili přes SSO nebo LDAP.",
"externalUsernameHint": "Použijte $external.username k vyplnění jména, pod kterým jste se přihlásili přes SSO nebo LDAP.",
"jumpHostChain": "Přejít k hostitelskému řetězci",
"portKnocking": "Klepnutí na přístav",
"addKnock": "Přidat port",
@@ -977,7 +977,7 @@
"noSearchResults": "Vašemu vyhledávání neodpovídají žádná spojení",
"rename": "Přejmenovat relaci"
},
"terminal": {
"sshAuth": {
"totpRequired": "Vyžadováno dvoufaktorové ověřování",
"totpCodeLabel": "Ověřovací kód",
"totpVerify": "Ověřit",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Nepodařilo se ověřit resetovací kód",
"failedCompleteReset": "Obnovení hesla se nepodařilo dokončit",
"invalidTotpCode": "Neplatný kód TOTP",
"failedOidcLogin": "Nepodařilo se spustit externí přihlášení",
"silentSigninOidcUnavailable": "Bylo požádáno o tiché přihlášení, ale není nastaveno žádné externí přihlášení.",
"failedExternalLogin": "Nepodařilo se spustit externí přihlášení",
"silentSigninExternalUnavailable": "Bylo požádáno o tiché přihlášení, ale není nastaveno žádné externí přihlášení.",
"failedUserInfo": "Po přihlášení se nepodařilo získat informace o uživateli",
"oidcAuthFailed": "Externí přihlášení se nezdařilo",
"externalAuthFailed": "Externí přihlášení se nezdařilo",
"invalidAuthUrl": "Z backendu byla přijata neplatná autorizační URL.",
"requiredField": "Toto pole je povinné",
"minLength": "Minimální délka je {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Umožnit novým uživatelům samoregistraci pomocí uživatelského jména a hesla",
"allowPasswordLogin": "Povolit přihlášení heslem",
"allowPasswordLoginDesc": "Přihlášení pomocí uživatelského jména/hesla",
"oidcAutoProvision": "Automatické vytváření externích účtů",
"oidcAutoProvisionDesc": "Vytvořte účet při prvním přihlášení uživatele pomocí externího přihlašovacího systému, jako je SSO nebo LDAP, a to i v případě, že je registrace vypnutá.",
"externalAutoProvision": "Automatické vytváření externích účtů",
"externalAutoProvisionDesc": "Vytvořte účet při prvním přihlášení uživatele pomocí externího přihlašovacího systému, jako je SSO nebo LDAP, a to i v případě, že je registrace vypnutá.",
"secondFactorAfterExternalLogin": "Požadovat druhý faktor po externích přihlášeních (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Pokud je vypnuto, je zaregistrovaný druhý faktor vyžadován pouze po zadání hesla a dalších lokálních přihlášeních, stejně jako dříve. Pokud je zapnuto, je vyžadován po každé metodě přihlášení, včetně SSO a LDAP.",
"allowPasswordReset": "Povolit resetování hesla",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Bez expirace",
"revokedBadge": "ZRUŠENO",
"authTypeDual": "Duální ověřování",
"authTypeOidc": "Externí",
"authTypeExternal": "Externí",
"authTypeLocal": "Místní",
"adminStatusAdministrator": "Správce",
"adminStatusRegularUser": "Běžný uživatel",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Povolit externí přihlášení i přihlášení pomocí hesla",
"linkAccountTargetUsername": "Uživatelské jméno lokálního účtu",
"linkAccountTargetPlaceholder": "Zadejte uživatelské jméno místního účtu, ke kterému chcete připojit",
"linkAccountOidcUsername": "Uživatelské jméno externího účtu",
"linkAccountOidcPlaceholder": "Zadejte uživatelské jméno externího účtu, který chcete sloučit",
"linkAccountOidcNotFound": "S tímto uživatelským jménem nebyl nalezen žádný externí účet.",
"linkAccountExternalUsername": "Uživatelské jméno externího účtu",
"linkAccountExternalPlaceholder": "Zadejte uživatelské jméno externího účtu, který chcete sloučit",
"linkAccountExternalNotFound": "S tímto uživatelským jménem nebyl nalezen žádný externí účet.",
"linkAccounts": "Propojit účty",
"linkAccountSuccess": "Účty byly úspěšně propojeny",
"linkAccountFailed": "Propojení účtů se nezdařilo",
@@ -1400,7 +1400,6 @@
"saving": "Ukládání...",
"updateRegistrationFailed": "Nepodařilo se aktualizovat nastavení registrace",
"updatePasswordLoginFailed": "Nepodařilo se aktualizovat nastavení přihlašovacího hesla",
"updateOidcAutoProvisionFailed": "Nepodařilo se aktualizovat nastavení automatického vytváření",
"updateSecondFactorAfterExternalLoginFailed": "Nepodařilo se aktualizovat nastavení druhého faktoru",
"updatePasswordResetFailed": "Nastavení resetování hesla se nepodařilo aktualizovat",
"sessionTimeoutRange2": "Časový limit relace musí být mezi 1 a 720 hodinami.",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "ZAMČENO",
"dataLockedNotice": "Data uživatele {{username}}zůstanou uzamčena až do jeho dalšího přihlášení. Do té doby nelze jeho hostitele, přihlašovací údaje a úryvky zobrazit ani upravovat.",
"resetPasswordTitle": "Obnovit heslo",
"resetPasswordOidcOnly": "Tento uživatel se přihlašuje přes externího poskytovatele a nemá heslo.",
"resetPasswordExternalOnly": "Tento uživatel se přihlašuje přes externího poskytovatele a nemá heslo.",
"resetPasswordPlaceholder": "Nové heslo",
"resetPasswordBtn": "Obnovit",
"resetPasswordWorking": "Resetování...",
@@ -1742,7 +1741,7 @@
"importFailed": "Import se nezdařil: {{error}}",
"roleUser": "Uživatel",
"authMethodDual": "Duální ověřování",
"authMethodOidc": "Externí",
"authMethodExternal": "Externí",
"done": "Hotovo",
"apiKeyNameRequired": "Název klíče je povinný",
"apiKeyCreated": "Klíč API „{{name}}“ byl vytvořen",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Tving tastaturinteraktivt",
"overrideCredentialUsername": "Tilsidesæt brugernavn til legitimationsoplysninger",
"overrideCredentialUsernameDesc": "Brug det ovenfor angivne brugernavn i stedet for legitimationsoplysningernes brugernavn",
"oidcUsernameHint": "Brug $external.username til at udfylde det navn, du loggede ind med via SSO eller LDAP.",
"externalUsernameHint": "Brug $external.username til at udfylde det navn, du loggede ind med via SSO eller LDAP.",
"jumpHostChain": "Jump Host Chain",
"portKnocking": "Port Knocking",
"addKnock": "Tilføj port",
@@ -977,7 +977,7 @@
"noSearchResults": "Ingen forbindelser matcher din søgning",
"rename": "Omdøb session"
},
"terminal": {
"sshAuth": {
"totpRequired": "Tofaktorgodkendelse påkrævet",
"totpCodeLabel": "Bekræftelseskode",
"totpVerify": "Verificere",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Kunne ikke bekræfte nulstillingskoden",
"failedCompleteReset": "Nulstilling af adgangskode mislykkedes",
"invalidTotpCode": "Ugyldig TOTP-kode",
"failedOidcLogin": "Kunne ikke starte ekstern login",
"silentSigninOidcUnavailable": "Der blev anmodet om lydløs login, men der er ikke konfigureret ekstern login.",
"failedExternalLogin": "Kunne ikke starte ekstern login",
"silentSigninExternalUnavailable": "Der blev anmodet om lydløs login, men der er ikke konfigureret ekstern login.",
"failedUserInfo": "Kunne ikke hente brugeroplysninger efter login",
"oidcAuthFailed": "Ekstern login mislykkedes",
"externalAuthFailed": "Ekstern login mislykkedes",
"invalidAuthUrl": "Ugyldig godkendelses-URL modtaget fra backend",
"requiredField": "Dette felt er påkrævet",
"minLength": "Minimumslængden er {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Lad nye brugere selv registrere sig med brugernavn og adgangskode",
"allowPasswordLogin": "Tillad adgangskodelogin",
"allowPasswordLoginDesc": "Brugernavn/adgangskode til login",
"oidcAutoProvision": "Opret eksterne konti automatisk",
"oidcAutoProvisionDesc": "Opret en konto første gang nogen logger ind via et eksternt login, f.eks. SSO eller LDAP, selv når registrering er slået fra.",
"externalAutoProvision": "Opret eksterne konti automatisk",
"externalAutoProvisionDesc": "Opret en konto første gang nogen logger ind via et eksternt login, f.eks. SSO eller LDAP, selv når registrering er slået fra.",
"secondFactorAfterExternalLogin": "Bed om en anden faktor efter eksterne logins (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Fra, en tilmeldt sekundær faktor bliver kun bedt om efter adgangskode og andre lokale logins, ligesom før. Til, bliver bedt om den efter hver loginmetode, inklusive SSO og LDAP.",
"allowPasswordReset": "Tillad nulstilling af adgangskode",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Ingen udløbsdato",
"revokedBadge": "TILBAGETAGET",
"authTypeDual": "Dobbeltgodkendelse",
"authTypeOidc": "Ekstern",
"authTypeExternal": "Ekstern",
"authTypeLocal": "Lokal",
"adminStatusAdministrator": "Administrator",
"adminStatusRegularUser": "Almindelig bruger",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Tillad både ekstern login og login med adgangskode",
"linkAccountTargetUsername": "Brugernavn for lokal konto",
"linkAccountTargetPlaceholder": "Indtast brugernavnet på den lokale konto, der skal linkes til",
"linkAccountOidcUsername": "Brugernavn til ekstern konto",
"linkAccountOidcPlaceholder": "Indtast brugernavnet til den eksterne konto, der skal flettes ind",
"linkAccountOidcNotFound": "Ingen ekstern konto fundet med det brugernavn",
"linkAccountExternalUsername": "Brugernavn til ekstern konto",
"linkAccountExternalPlaceholder": "Indtast brugernavnet til den eksterne konto, der skal flettes ind",
"linkAccountExternalNotFound": "Ingen ekstern konto fundet med det brugernavn",
"linkAccounts": "Tilknyt konti",
"linkAccountSuccess": "Konti er blevet linket",
"linkAccountFailed": "Kunne ikke linke konti",
@@ -1400,7 +1400,6 @@
"saving": "Gemmer...",
"updateRegistrationFailed": "Kunne ikke opdatere registreringsindstillingerne",
"updatePasswordLoginFailed": "Kunne ikke opdatere adgangskodeindstillingen for login",
"updateOidcAutoProvisionFailed": "Kunne ikke opdatere indstillingen for automatisk oprettelse",
"updateSecondFactorAfterExternalLoginFailed": "Kunne ikke opdatere indstillingen for anden faktor",
"updatePasswordResetFailed": "Indstillingen for nulstilling af adgangskode kunne ikke opdateres",
"sessionTimeoutRange2": "Sessionstimeout skal være mellem 1 og 720 timer",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "LÅST",
"dataLockedNotice": "{{username}}s data forbliver låst, indtil de logger ind næste gang. Deres værter, legitimationsoplysninger og kodestykker kan ikke ses eller redigeres indtil da.",
"resetPasswordTitle": "Nulstil adgangskode",
"resetPasswordOidcOnly": "Denne bruger logger ind via en ekstern udbyder og har ingen adgangskode.",
"resetPasswordExternalOnly": "Denne bruger logger ind via en ekstern udbyder og har ingen adgangskode.",
"resetPasswordPlaceholder": "Ny adgangskode",
"resetPasswordBtn": "Nulstil",
"resetPasswordWorking": "Nulstiller...",
@@ -1742,7 +1741,7 @@
"importFailed": "Import mislykkedes: {{error}}",
"roleUser": "Bruger",
"authMethodDual": "Dobbeltgodkendelse",
"authMethodOidc": "Ekstern",
"authMethodExternal": "Ekstern",
"done": "Færdig",
"apiKeyNameRequired": "Nøglenavn er påkrævet",
"apiKeyCreated": "API-nøglen \"{{name}}\" blev oprettet",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Keyboard-Interactive erzwingen",
"overrideCredentialUsername": "Benutzernamen der Anmeldeinformationen überschreiben",
"overrideCredentialUsernameDesc": "Den oben angegebenen Benutzernamen anstelle des Benutzernamens der Anmeldeinformationen verwenden",
"oidcUsernameHint": "Verwenden Sie $external.username, um den Namen einzufügen, mit dem Sie sich über SSO oder LDAP angemeldet haben.",
"externalUsernameHint": "Verwenden Sie $external.username, um den Namen einzufügen, mit dem Sie sich über SSO oder LDAP angemeldet haben.",
"jumpHostChain": "Jump-Host-Kette",
"portKnocking": "Port-Knocking",
"addKnock": "Port hinzufügen",
@@ -977,7 +977,7 @@
"noSearchResults": "Keine Verbindungen entsprechen Ihrer Suche",
"rename": "Sitzung umbenennen"
},
"terminal": {
"sshAuth": {
"totpRequired": "Zwei-Faktor-Authentifizierung erforderlich",
"totpCodeLabel": "Verifizierungscode",
"totpVerify": "Verifizieren",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Bestätigungscode konnte nicht überprüft werden",
"failedCompleteReset": "Passwort-Zurücksetzung konnte nicht abgeschlossen werden",
"invalidTotpCode": "Ungültiger TOTP-Code",
"failedOidcLogin": "Externe Anmeldung konnte nicht gestartet werden",
"silentSigninOidcUnavailable": "Es wurde eine stille Anmeldung angefordert, aber es ist keine externe Anmeldung eingerichtet.",
"failedExternalLogin": "Externe Anmeldung konnte nicht gestartet werden",
"silentSigninExternalUnavailable": "Es wurde eine stille Anmeldung angefordert, aber es ist keine externe Anmeldung eingerichtet.",
"failedUserInfo": "Benutzerinfo nach Anmeldung konnte nicht abgerufen werden",
"oidcAuthFailed": "Externe Anmeldung fehlgeschlagen",
"externalAuthFailed": "Externe Anmeldung fehlgeschlagen",
"invalidAuthUrl": "Ungültige Autorisierungs-URL vom Backend erhalten",
"requiredField": "Dieses Feld ist erforderlich",
"minLength": "Mindestlänge ist {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Neuen Benutzern die Selbstregistrierung mit Benutzernamen und Passwort erlauben",
"allowPasswordLogin": "Passwort-Login erlauben",
"allowPasswordLoginDesc": "Login mit Benutzernamen/Passwort",
"oidcAutoProvision": "Automatische Erstellung externer Konten",
"oidcAutoProvisionDesc": "Erstellen Sie ein Konto, sobald sich jemand zum ersten Mal über einen externen Login wie SSO oder LDAP anmeldet, auch wenn die Registrierung deaktiviert ist.",
"externalAutoProvision": "Automatische Erstellung externer Konten",
"externalAutoProvisionDesc": "Erstellen Sie ein Konto, sobald sich jemand zum ersten Mal über einen externen Login wie SSO oder LDAP anmeldet, auch wenn die Registrierung deaktiviert ist.",
"secondFactorAfterExternalLogin": "Fordern Sie nach externen Anmeldungen (SSO, GitHub, Google, LDAP) einen zweiten Faktor an.",
"secondFactorAfterExternalLoginDesc": "Ist die Zwei-Faktor-Authentifizierung deaktiviert, wird sie wie bisher nur nach dem Passwort und anderen lokalen Anmeldungen abgefragt. Ist sie aktiviert, wird sie nach jeder Anmeldemethode abgefragt, einschließlich SSO und LDAP.",
"allowPasswordReset": "Passwort-Zurücksetzen erlauben",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Kein Ablauf",
"revokedBadge": "WIDERRUFEN",
"authTypeDual": "Duale Authentifizierung",
"authTypeOidc": "Extern",
"authTypeExternal": "Extern",
"authTypeLocal": "Lokal",
"adminStatusAdministrator": "Administrator",
"adminStatusRegularUser": "Normaler Benutzer",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Sowohl externe Anmeldung als auch Anmeldung mit Passwort zulassen",
"linkAccountTargetUsername": "Benutzername des lokalen Kontos",
"linkAccountTargetPlaceholder": "Geben Sie den Benutzernamen des lokalen Kontos ein, mit dem verknüpft werden soll",
"linkAccountOidcUsername": "Benutzername des externen Kontos",
"linkAccountOidcPlaceholder": "Geben Sie den Benutzernamen des externen Kontos ein, das zusammengeführt werden soll.",
"linkAccountOidcNotFound": "Es wurde kein externes Konto mit diesem Benutzernamen gefunden.",
"linkAccountExternalUsername": "Benutzername des externen Kontos",
"linkAccountExternalPlaceholder": "Geben Sie den Benutzernamen des externen Kontos ein, das zusammengeführt werden soll.",
"linkAccountExternalNotFound": "Es wurde kein externes Konto mit diesem Benutzernamen gefunden.",
"linkAccounts": "Konten verknüpfen",
"linkAccountSuccess": "Konten erfolgreich verknüpft",
"linkAccountFailed": "Fehler beim Verknüpfen der Konten",
@@ -1400,7 +1400,6 @@
"saving": "Speichern...",
"updateRegistrationFailed": "Fehler beim Aktualisieren der Registrierungseinstellung",
"updatePasswordLoginFailed": "Fehler beim Aktualisieren der Passwort-Anmeldeeinstellung",
"updateOidcAutoProvisionFailed": "Die Aktualisierung der Einstellung für die automatische Erstellung ist fehlgeschlagen.",
"updateSecondFactorAfterExternalLoginFailed": "Zweiter-Faktor-Einstellung konnte nicht aktualisiert werden",
"updatePasswordResetFailed": "Fehler beim Aktualisieren der Passwort-Zurücksetzen-Einstellung",
"sessionTimeoutRange2": "Sitzungszeitüberschreitung muss zwischen 1 und 720 Stunden liegen",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "GESPERRT",
"dataLockedNotice": "{{username}}s Daten bleiben gesperrt, bis die nächste Anmeldung erfolgt. Hosts, Zugangsdaten und Snippets können bis dahin nicht eingesehen oder bearbeitet werden.",
"resetPasswordTitle": "Passwort zurücksetzen",
"resetPasswordOidcOnly": "Dieser Benutzer meldet sich über einen externen Anbieter an und hat kein Passwort.",
"resetPasswordExternalOnly": "Dieser Benutzer meldet sich über einen externen Anbieter an und hat kein Passwort.",
"resetPasswordPlaceholder": "Neues Passwort",
"resetPasswordBtn": "Zurücksetzen",
"resetPasswordWorking": "Wird zurückgesetzt...",
@@ -1742,7 +1741,7 @@
"importFailed": "Import fehlgeschlagen: {{error}}",
"roleUser": "Benutzer",
"authMethodDual": "Duale Authentifizierung",
"authMethodOidc": "Extern",
"authMethodExternal": "Extern",
"done": "Fertig",
"apiKeyNameRequired": "Schlüsselname ist erforderlich",
"apiKeyCreated": "API-Schlüssel \"{{name}}\" erstellt",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Force Keyboard-Interactive",
"overrideCredentialUsername": "Παράκαμψη ονόματος χρήστη διαπιστευτηρίων",
"overrideCredentialUsernameDesc": "Χρησιμοποιήστε το όνομα χρήστη που καθορίστηκε παραπάνω αντί για το όνομα χρήστη του πιστοποιητικού",
"oidcUsernameHint": "Χρησιμοποιήστε την εντολή $external.username για να συμπληρώσετε το όνομα με το οποίο συνδεθήκατε μέσω SSO ή LDAP.",
"externalUsernameHint": "Χρησιμοποιήστε την εντολή $external.username για να συμπληρώσετε το όνομα με το οποίο συνδεθήκατε μέσω SSO ή LDAP.",
"jumpHostChain": "Αλυσίδα υποδοχής άλματος",
"portKnocking": "Χτύπημα στο λιμάνι",
"addKnock": "Προσθήκη θύρας",
@@ -977,7 +977,7 @@
"noSearchResults": "Δεν υπάρχουν συνδέσεις που να αντιστοιχούν στην αναζήτησή σας",
"rename": "Μετονομασία συνεδρίας"
},
"terminal": {
"sshAuth": {
"totpRequired": "Απαιτείται έλεγχος ταυτότητας δύο παραγόντων",
"totpCodeLabel": "Κωδικός επαλήθευσης",
"totpVerify": "Επαληθεύω",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Αποτυχία επαλήθευσης κωδικού επαναφοράς",
"failedCompleteReset": "Η ολοκλήρωση της επαναφοράς του κωδικού πρόσβασης απέτυχε",
"invalidTotpCode": "Μη έγκυρος κωδικός TOTP",
"failedOidcLogin": "Αποτυχία έναρξης εξωτερικής σύνδεσης",
"silentSigninOidcUnavailable": "Ζητήθηκε αθόρυβη σύνδεση, αλλά δεν έχει ρυθμιστεί εξωτερική σύνδεση.",
"failedExternalLogin": "Αποτυχία έναρξης εξωτερικής σύνδεσης",
"silentSigninExternalUnavailable": "Ζητήθηκε αθόρυβη σύνδεση, αλλά δεν έχει ρυθμιστεί εξωτερική σύνδεση.",
"failedUserInfo": "Αποτυχία λήψης πληροφοριών χρήστη μετά τη σύνδεση",
"oidcAuthFailed": "Η εξωτερική σύνδεση απέτυχε",
"externalAuthFailed": "Η εξωτερική σύνδεση απέτυχε",
"invalidAuthUrl": "Λήψη μη έγκυρης διεύθυνσης URL εξουσιοδότησης από το backend",
"requiredField": "Αυτό το πεδίο είναι υποχρεωτικό",
"minLength": "Το ελάχιστο μήκος είναι {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Επιτρέψτε στους νέους χρήστες να εγγραφούν μόνοι τους με όνομα χρήστη και κωδικό πρόσβασης",
"allowPasswordLogin": "Επιτρέψτε την είσοδο με κωδικό πρόσβασης",
"allowPasswordLoginDesc": "Όνομα χρήστη/κωδικός πρόσβασης σύνδεσης",
"oidcAutoProvision": "Αυτόματη δημιουργία εξωτερικών λογαριασμών",
"oidcAutoProvisionDesc": "Δημιουργήστε έναν λογαριασμό την πρώτη φορά που κάποιος συνδέεται μέσω εξωτερικής σύνδεσης, όπως SSO ή LDAP, ακόμα και όταν η εγγραφή είναι απενεργοποιημένη.",
"externalAutoProvision": "Αυτόματη δημιουργία εξωτερικών λογαριασμών",
"externalAutoProvisionDesc": "Δημιουργήστε έναν λογαριασμό την πρώτη φορά που κάποιος συνδέεται μέσω εξωτερικής σύνδεσης, όπως SSO ή LDAP, ακόμα και όταν η εγγραφή είναι απενεργοποιημένη.",
"secondFactorAfterExternalLogin": "Ζητήστε έναν δεύτερο παράγοντα μετά από εξωτερικές συνδέσεις (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Όταν είναι απενεργοποιημένο, ζητείται ένας εγγεγραμμένος δεύτερος παράγοντας μόνο μετά τον κωδικό πρόσβασης και άλλες τοπικές συνδέσεις, όπως και πριν. Όταν είναι ενεργοποιημένο, ζητείται μετά από κάθε μέθοδο σύνδεσης, συμπεριλαμβανομένων των SSO και LDAP.",
"allowPasswordReset": "Να επιτρέπεται η επαναφορά κωδικού πρόσβασης",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Χωρίς λήξη",
"revokedBadge": "ΑΝΑΚΛΗΘΗΚΕ",
"authTypeDual": "Διπλή Εξουσιοδότηση",
"authTypeOidc": "Εξωτερικός",
"authTypeExternal": "Εξωτερικός",
"authTypeLocal": "Τοπικός",
"adminStatusAdministrator": "Διαχειριστής",
"adminStatusRegularUser": "Τακτικός χρήστης",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Να επιτρέπεται η εξωτερική σύνδεση και η σύνδεση με κωδικό πρόσβασης",
"linkAccountTargetUsername": "Όνομα χρήστη τοπικού λογαριασμού",
"linkAccountTargetPlaceholder": "Εισαγάγετε το όνομα χρήστη του τοπικού λογαριασμού για να συνδεθείτε",
"linkAccountOidcUsername": "Όνομα χρήστη εξωτερικού λογαριασμού",
"linkAccountOidcPlaceholder": "Εισαγάγετε το όνομα χρήστη που προορίζεται μόνο για εξωτερικούς λογαριασμούς για συγχώνευση",
"linkAccountOidcNotFound": "Δεν βρέθηκε λογαριασμός μόνο για εξωτερικούς χρήστες με αυτό το όνομα χρήστη",
"linkAccountExternalUsername": "Όνομα χρήστη εξωτερικού λογαριασμού",
"linkAccountExternalPlaceholder": "Εισαγάγετε το όνομα χρήστη που προορίζεται μόνο για εξωτερικούς λογαριασμούς για συγχώνευση",
"linkAccountExternalNotFound": "Δεν βρέθηκε λογαριασμός μόνο για εξωτερικούς χρήστες με αυτό το όνομα χρήστη",
"linkAccounts": "Σύνδεση λογαριασμών",
"linkAccountSuccess": "Οι λογαριασμοί συνδέθηκαν με επιτυχία",
"linkAccountFailed": "Η σύνδεση λογαριασμών απέτυχε",
@@ -1400,7 +1400,6 @@
"saving": "Οικονομία...",
"updateRegistrationFailed": "Η ενημέρωση της ρύθμισης εγγραφής απέτυχε",
"updatePasswordLoginFailed": "Αποτυχία ενημέρωσης ρύθμισης κωδικού πρόσβασης σύνδεσης",
"updateOidcAutoProvisionFailed": "Η ενημέρωση της ρύθμισης αυτόματης δημιουργίας απέτυχε",
"updateSecondFactorAfterExternalLoginFailed": "Αποτυχία ενημέρωσης της ρύθμισης του δεύτερου παράγοντα",
"updatePasswordResetFailed": "Η ενημέρωση της ρύθμισης επαναφοράς κωδικού πρόσβασης απέτυχε",
"sessionTimeoutRange2": "Το χρονικό όριο λήξης της περιόδου σύνδεσης πρέπει να είναι μεταξύ 1 και 720 ωρών",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "ΚΛΕΙΔΩΜΕΝΟ",
"dataLockedNotice": "Τα δεδομένα του χρήστη {{username}}παραμένουν κλειδωμένα μέχρι την επόμενη σύνδεσή τους. Οι κεντρικοί υπολογιστές, τα διαπιστευτήρια και τα αποσπάσματα δεδομένων τους δεν μπορούν να προβληθούν ή να επεξεργαστούν μέχρι τότε.",
"resetPasswordTitle": "Επαναφορά κωδικού πρόσβασης",
"resetPasswordOidcOnly": "Αυτός ο χρήστης συνδέεται μέσω εξωτερικού παρόχου και δεν έχει κωδικό πρόσβασης.",
"resetPasswordExternalOnly": "Αυτός ο χρήστης συνδέεται μέσω εξωτερικού παρόχου και δεν έχει κωδικό πρόσβασης.",
"resetPasswordPlaceholder": "Νέος κωδικός πρόσβασης",
"resetPasswordBtn": "Επαναφορά",
"resetPasswordWorking": "Επαναφορά...",
@@ -1742,7 +1741,7 @@
"importFailed": "Η εισαγωγή απέτυχε: {{error}}",
"roleUser": "Μεταχειριζόμενος",
"authMethodDual": "Διπλή Εξουσιοδότηση",
"authMethodOidc": "Εξωτερικός",
"authMethodExternal": "Εξωτερικός",
"done": "Γινώμενος",
"apiKeyNameRequired": "Απαιτείται όνομα κλειδιού",
"apiKeyCreated": "Κλειδί API \"{{name}}\" δημιουργήθηκε",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Forzar autenticación interactiva por teclado",
"overrideCredentialUsername": "Anular nombre de usuario de la credencial",
"overrideCredentialUsernameDesc": "Usar el nombre de usuario especificado arriba en lugar del nombre de usuario de la credencial",
"oidcUsernameHint": "Utilice $external.username para completar el nombre con el que inició sesión a través de SSO o LDAP.",
"externalUsernameHint": "Utilice $external.username para completar el nombre con el que inició sesión a través de SSO o LDAP.",
"jumpHostChain": "Cadena de hosts de salto",
"portKnocking": "Port Knocking",
"addKnock": "Añadir puerto",
@@ -977,7 +977,7 @@
"noSearchResults": "Ninguna conexión coincide con tu búsqueda",
"rename": "Renombrar sesión"
},
"terminal": {
"sshAuth": {
"totpRequired": "Autenticación de dos factores requerida",
"totpCodeLabel": "Código de verificación",
"totpVerify": "Verificar",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Error al verificar el código de restablecimiento",
"failedCompleteReset": "Error al completar el restablecimiento de contraseña",
"invalidTotpCode": "Código TOTP no válido",
"failedOidcLogin": "No se pudo iniciar el inicio de sesión externo.",
"silentSigninOidcUnavailable": "Se solicitó el inicio de sesión silencioso, pero no hay ningún inicio de sesión externo configurado.",
"failedExternalLogin": "No se pudo iniciar el inicio de sesión externo.",
"silentSigninExternalUnavailable": "Se solicitó el inicio de sesión silencioso, pero no hay ningún inicio de sesión externo configurado.",
"failedUserInfo": "Error al obtener la información del usuario después del inicio de sesión",
"oidcAuthFailed": "Falló el inicio de sesión externo",
"externalAuthFailed": "Falló el inicio de sesión externo",
"invalidAuthUrl": "URL de autorización no válida recibida del backend",
"requiredField": "Este campo es obligatorio",
"minLength": "La longitud mínima es {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Permite a los nuevos usuarios registrarse con un nombre de usuario y contraseña",
"allowPasswordLogin": "Permitir inicio de sesión con contraseña",
"allowPasswordLoginDesc": "Inicio de sesión con usuario/contraseña",
"oidcAutoProvision": "Crear automáticamente cuentas externas",
"oidcAutoProvisionDesc": "Crea una cuenta la primera vez que alguien inicie sesión a través de un inicio de sesión externo como SSO o LDAP, incluso cuando el registro esté desactivado.",
"externalAutoProvision": "Crear automáticamente cuentas externas",
"externalAutoProvisionDesc": "Crea una cuenta la primera vez que alguien inicie sesión a través de un inicio de sesión externo como SSO o LDAP, incluso cuando el registro esté desactivado.",
"secondFactorAfterExternalLogin": "Solicita un segundo factor de autenticación después de los inicios de sesión externos (SSO, GitHub, Google, LDAP).",
"secondFactorAfterExternalLoginDesc": "Desactivado, el segundo factor registrado solo se solicita después de la contraseña y otros inicios de sesión locales, igual que antes. Activado, se solicita después de cada método de inicio de sesión, incluidos SSO y LDAP.",
"allowPasswordReset": "Permitir restablecimiento de contraseña",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Sin caducidad",
"revokedBadge": "REVOCADA",
"authTypeDual": "Autenticación dual",
"authTypeOidc": "Externo",
"authTypeExternal": "Externo",
"authTypeLocal": "Local",
"adminStatusAdministrator": "Administrador",
"adminStatusRegularUser": "Usuario normal",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Permitir tanto el inicio de sesión externo como el inicio de sesión mediante contraseña.",
"linkAccountTargetUsername": "Nombre de usuario de la cuenta local",
"linkAccountTargetPlaceholder": "Introduzca el nombre de usuario de la cuenta local a vincular",
"linkAccountOidcUsername": "Nombre de usuario de cuenta externa",
"linkAccountOidcPlaceholder": "Ingrese el nombre de usuario de la cuenta externa para fusionarla",
"linkAccountOidcNotFound": "No se encontró ninguna cuenta externa con ese nombre de usuario.",
"linkAccountExternalUsername": "Nombre de usuario de cuenta externa",
"linkAccountExternalPlaceholder": "Ingrese el nombre de usuario de la cuenta externa para fusionarla",
"linkAccountExternalNotFound": "No se encontró ninguna cuenta externa con ese nombre de usuario.",
"linkAccounts": "Vincular cuentas",
"linkAccountSuccess": "Cuentas vinculadas correctamente",
"linkAccountFailed": "Error al vincular cuentas",
@@ -1400,7 +1400,6 @@
"saving": "Guardando...",
"updateRegistrationFailed": "Error al actualizar la configuración de registro",
"updatePasswordLoginFailed": "Error al actualizar la configuración de inicio de sesión por contraseña",
"updateOidcAutoProvisionFailed": "No se pudo actualizar la configuración de creación automática.",
"updateSecondFactorAfterExternalLoginFailed": "No se pudo actualizar la configuración del segundo factor.",
"updatePasswordResetFailed": "Error al actualizar la configuración de restablecimiento de contraseña",
"sessionTimeoutRange2": "El tiempo de espera de sesión debe estar entre 1 y 720 horas",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "BLOQUEADO",
"dataLockedNotice": "Los datos de {{username}} permanecen bloqueados hasta que inicien sesión de nuevo. Sus hosts, credenciales y fragmentos no se pueden ver ni editar hasta entonces.",
"resetPasswordTitle": "Restablecer contraseña",
"resetPasswordOidcOnly": "Este usuario inicia sesión a través de un proveedor externo y no tiene contraseña.",
"resetPasswordExternalOnly": "Este usuario inicia sesión a través de un proveedor externo y no tiene contraseña.",
"resetPasswordPlaceholder": "Nueva contraseña",
"resetPasswordBtn": "Restablecer",
"resetPasswordWorking": "Restableciendo...",
@@ -1742,7 +1741,7 @@
"importFailed": "Error al importar: {{error}}",
"roleUser": "Usuario",
"authMethodDual": "Autenticación dual",
"authMethodOidc": "Externo",
"authMethodExternal": "Externo",
"done": "Listo",
"apiKeyNameRequired": "El nombre de la clave es obligatorio",
"apiKeyCreated": "Clave API \"{{name}}\" creada",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Pakota näppäimistöinteraktiivinen",
"overrideCredentialUsername": "Ohita tunnistetiedot Käyttäjätunnus",
"overrideCredentialUsernameDesc": "Käytä yllä määritettyä käyttäjätunnusta tunnistetiedon käyttäjätunnuksen sijaan.",
"oidcUsernameHint": "Käytä muotoa $external.username täyttääksesi nimen, jolla kirjauduit sisään SSO:n tai LDAP:n kautta.",
"externalUsernameHint": "Käytä muotoa $external.username täyttääksesi nimen, jolla kirjauduit sisään SSO:n tai LDAP:n kautta.",
"jumpHostChain": "Hyppyisäntäketju",
"portKnocking": "Portin koputus",
"addKnock": "Lisää portti",
@@ -977,7 +977,7 @@
"noSearchResults": "Ei hakuasi vastaavia yhteyksiä",
"rename": "Nimeä istunto uudelleen"
},
"terminal": {
"sshAuth": {
"totpRequired": "Kaksivaiheinen todennus vaaditaan",
"totpCodeLabel": "Vahvistuskoodi",
"totpVerify": "Vahvista",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Nollauskoodin vahvistaminen epäonnistui",
"failedCompleteReset": "Salasanan vaihtaminen epäonnistui",
"invalidTotpCode": "Virheellinen TOTP-koodi",
"failedOidcLogin": "Ulkoisen kirjautumisen aloittaminen epäonnistui",
"silentSigninOidcUnavailable": "Hiljaista kirjautumista pyydettiin, mutta ulkoista kirjautumista ei ole määritetty.",
"failedExternalLogin": "Ulkoisen kirjautumisen aloittaminen epäonnistui",
"silentSigninExternalUnavailable": "Hiljaista kirjautumista pyydettiin, mutta ulkoista kirjautumista ei ole määritetty.",
"failedUserInfo": "Käyttäjätietojen hakeminen epäonnistui kirjautumisen jälkeen",
"oidcAuthFailed": "Ulkoinen kirjautuminen epäonnistui",
"externalAuthFailed": "Ulkoinen kirjautuminen epäonnistui",
"invalidAuthUrl": "Virheellinen valtuutus-URL-osoite vastaanotettu taustajärjestelmästä",
"requiredField": "Tämä kenttä on pakollinen",
"minLength": "Minimipituus on {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Anna uusien käyttäjien rekisteröityä itse käyttäjätunnuksella ja salasanalla",
"allowPasswordLogin": "Salli salasanalla kirjautuminen",
"allowPasswordLoginDesc": "Käyttäjätunnus/salasana kirjautuminen",
"oidcAutoProvision": "Luo ulkoiset tilit automaattisesti",
"oidcAutoProvisionDesc": "Luo tili, kun joku kirjautuu sisään ensimmäisen kerran ulkoisella kirjautumisella, kuten SSO:lla tai LDAP:lla, vaikka rekisteröinti olisi pois päältä.",
"externalAutoProvision": "Luo ulkoiset tilit automaattisesti",
"externalAutoProvisionDesc": "Luo tili, kun joku kirjautuu sisään ensimmäisen kerran ulkoisella kirjautumisella, kuten SSO:lla tai LDAP:lla, vaikka rekisteröinti olisi pois päältä.",
"secondFactorAfterExternalLogin": "Kysy toista kirjautumistekijää ulkoisten kirjautumisten (SSO, GitHub, Google, LDAP) jälkeen",
"secondFactorAfterExternalLoginDesc": "Pois päältä -tilassa rekisteröityä toista kirjautumistapaa kysytään vain salasanan ja muiden paikallisten kirjautumisten jälkeen, kuten aiemmin. Päällä -tilassa sitä kysytään jokaisen kirjautumistavan, mukaan lukien kertakirjautumisen ja LDAP:n, jälkeen.",
"allowPasswordReset": "Salli salasanan palautus",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Ei vanhenemista",
"revokedBadge": "PERUTTU",
"authTypeDual": "Kaksoistutkimus",
"authTypeOidc": "Ulkoinen",
"authTypeExternal": "Ulkoinen",
"authTypeLocal": "Paikallinen",
"adminStatusAdministrator": "Ylläpitäjä",
"adminStatusRegularUser": "Tavallinen käyttäjä",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Salli sekä ulkoinen että salasanalla kirjautuminen",
"linkAccountTargetUsername": "Paikallisen tilin käyttäjätunnus",
"linkAccountTargetPlaceholder": "Anna paikallisen tilin käyttäjätunnus, johon linkitetään",
"linkAccountOidcUsername": "Ulkoisen tilin käyttäjätunnus",
"linkAccountOidcPlaceholder": "Anna yhdistettävän ulkoisen tilin käyttäjätunnus",
"linkAccountOidcNotFound": "Käyttäjätunnuksella ei löytynyt ulkoista tiliä.",
"linkAccountExternalUsername": "Ulkoisen tilin käyttäjätunnus",
"linkAccountExternalPlaceholder": "Anna yhdistettävän ulkoisen tilin käyttäjätunnus",
"linkAccountExternalNotFound": "Käyttäjätunnuksella ei löytynyt ulkoista tiliä.",
"linkAccounts": "Linkitä tilit",
"linkAccountSuccess": "Tilit linkitetty onnistuneesti",
"linkAccountFailed": "Tilien linkittäminen epäonnistui",
@@ -1400,7 +1400,6 @@
"saving": "Tallennetaan...",
"updateRegistrationFailed": "Rekisteröintiasetuksen päivittäminen epäonnistui",
"updatePasswordLoginFailed": "Salasanan kirjautumisasetusten päivittäminen epäonnistui",
"updateOidcAutoProvisionFailed": "Automaattisen luomisen asetuksen päivittäminen epäonnistui",
"updateSecondFactorAfterExternalLoginFailed": "Toisen vaiheen asetuksen päivittäminen epäonnistui",
"updatePasswordResetFailed": "Salasanan palautusasetuksen päivittäminen epäonnistui",
"sessionTimeoutRange2": "Istunnon aikakatkaisun on oltava 1–720 tuntia",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "LUKITTU",
"dataLockedNotice": "{{username}}:n tiedot pysyvät lukittuina, kunnes he kirjautuvat sisään seuraavalla kerralla. Heidän palvelimiaan, tunnistetietojaan ja koodinpätkiään ei voida tarkastella tai muokata ennen sitä.",
"resetPasswordTitle": "Palauta salasana",
"resetPasswordOidcOnly": "Tämä käyttäjä kirjautuu sisään ulkoisen palveluntarjoajan kautta eikä hänellä ole salasanaa.",
"resetPasswordExternalOnly": "Tämä käyttäjä kirjautuu sisään ulkoisen palveluntarjoajan kautta eikä hänellä ole salasanaa.",
"resetPasswordPlaceholder": "Uusi salasana",
"resetPasswordBtn": "Nollaa",
"resetPasswordWorking": "Nollataan...",
@@ -1742,7 +1741,7 @@
"importFailed": "Tuonti epäonnistui: {{error}}",
"roleUser": "Käyttäjä",
"authMethodDual": "Kaksoistutkimus",
"authMethodOidc": "Ulkoinen",
"authMethodExternal": "Ulkoinen",
"done": "Tehty",
"apiKeyNameRequired": "Avaimen nimi on pakollinen",
"apiKeyCreated": "API-avain \"{{name}}\" luotu",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Forcer Keyboard-Interactive",
"overrideCredentialUsername": "Remplacer le nom d'utilisateur des identifiants",
"overrideCredentialUsernameDesc": "Utiliser le nom d'utilisateur spécifié ci-dessus au lieu de celui des identifiants",
"oidcUsernameHint": "Utilisez $external.username pour renseigner le nom avec lequel vous vous êtes connecté via SSO ou LDAP.",
"externalUsernameHint": "Utilisez $external.username pour renseigner le nom avec lequel vous vous êtes connecté via SSO ou LDAP.",
"jumpHostChain": "Chaîne de Jump Hosts",
"portKnocking": "Port Knocking",
"addKnock": "Ajouter un port",
@@ -977,7 +977,7 @@
"noSearchResults": "Aucune connexion ne correspond à votre recherche",
"rename": "Renommer la session"
},
"terminal": {
"sshAuth": {
"totpRequired": "Authentification à deux facteurs requise",
"totpCodeLabel": "Code de vérification",
"totpVerify": "Vérifier",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Échec de la vérification du code de réinitialisation",
"failedCompleteReset": "Échec de la finalisation de la réinitialisation du mot de passe",
"invalidTotpCode": "Code TOTP invalide",
"failedOidcLogin": "Échec de la connexion externe",
"silentSigninOidcUnavailable": "Une connexion silencieuse a été demandée, mais aucune connexion externe n'est configurée.",
"failedExternalLogin": "Échec de la connexion externe",
"silentSigninExternalUnavailable": "Une connexion silencieuse a été demandée, mais aucune connexion externe n'est configurée.",
"failedUserInfo": "Échec de la récupération des informations utilisateur après la connexion",
"oidcAuthFailed": "Échec de la connexion externe",
"externalAuthFailed": "Échec de la connexion externe",
"invalidAuthUrl": "URL d'autorisation invalide reçue du backend",
"requiredField": "Ce champ est obligatoire",
"minLength": "La longueur minimale est de {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Permettre aux nouveaux utilisateurs de s'enregistrer avec un nom d'utilisateur et un mot de passe",
"allowPasswordLogin": "Autoriser la connexion par mot de passe",
"allowPasswordLoginDesc": "Connexion par nom d'utilisateur/mot de passe",
"oidcAutoProvision": "Création automatique de comptes externes",
"oidcAutoProvisionDesc": "Créer un compte lors de la première connexion via une authentification externe telle que SSO ou LDAP, même si l'inscription est désactivée.",
"externalAutoProvision": "Création automatique de comptes externes",
"externalAutoProvisionDesc": "Créer un compte lors de la première connexion via une authentification externe telle que SSO ou LDAP, même si l'inscription est désactivée.",
"secondFactorAfterExternalLogin": "Demander une authentification à deux facteurs après les connexions externes (SSO, GitHub, Google, LDAP).",
"secondFactorAfterExternalLoginDesc": "Désactivé, le second facteur d'authentification n'est demandé qu'après la saisie du mot de passe et les autres connexions locales, comme auparavant. Activé, il est demandé après chaque méthode de connexion, y compris l'authentification unique (SSO) et LDAP.",
"allowPasswordReset": "Autoriser la réinitialisation du mot de passe",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Aucune expiration",
"revokedBadge": "RÉVOQUÉE",
"authTypeDual": "Authentification double",
"authTypeOidc": "Externe",
"authTypeExternal": "Externe",
"authTypeLocal": "Locale",
"adminStatusAdministrator": "Administrateur",
"adminStatusRegularUser": "Utilisateur standard",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Autoriser la connexion externe et par mot de passe",
"linkAccountTargetUsername": "Nom d'utilisateur du compte local",
"linkAccountTargetPlaceholder": "Saisissez le nom d'utilisateur du compte local à lier",
"linkAccountOidcUsername": "Nom d'utilisateur du compte externe",
"linkAccountOidcPlaceholder": "Saisissez le nom d'utilisateur du compte externe uniquement à fusionner.",
"linkAccountOidcNotFound": "Aucun compte externe n'a été trouvé avec ce nom d'utilisateur.",
"linkAccountExternalUsername": "Nom d'utilisateur du compte externe",
"linkAccountExternalPlaceholder": "Saisissez le nom d'utilisateur du compte externe uniquement à fusionner.",
"linkAccountExternalNotFound": "Aucun compte externe n'a été trouvé avec ce nom d'utilisateur.",
"linkAccounts": "Lier les comptes",
"linkAccountSuccess": "Comptes liés avec succès",
"linkAccountFailed": "Échec de la liaison des comptes",
@@ -1400,7 +1400,6 @@
"saving": "Enregistrement…",
"updateRegistrationFailed": "Échec de la mise à jour du paramètre d'enregistrement",
"updatePasswordLoginFailed": "Échec de la mise à jour du paramètre de connexion par mot de passe",
"updateOidcAutoProvisionFailed": "Échec de la mise à jour du paramètre de création automatique",
"updateSecondFactorAfterExternalLoginFailed": "Échec de la mise à jour du paramètre de deuxième facteur",
"updatePasswordResetFailed": "Échec de la mise à jour du paramètre de réinitialisation du mot de passe",
"sessionTimeoutRange2": "Le délai d'expiration de session doit être compris entre 1 et 720 heures",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "VERROUILLÉ",
"dataLockedNotice": "Les données de {{username}} restent verrouillées jusqu'à leur prochaine connexion. Leurs hôtes, identifiants et extraits ne peuvent être consultés ni modifiés d'ici là.",
"resetPasswordTitle": "Réinitialiser le mot de passe",
"resetPasswordOidcOnly": "Cet utilisateur se connecte via un fournisseur externe et n'a pas de mot de passe.",
"resetPasswordExternalOnly": "Cet utilisateur se connecte via un fournisseur externe et n'a pas de mot de passe.",
"resetPasswordPlaceholder": "Nouveau mot de passe",
"resetPasswordBtn": "Réinitialiser",
"resetPasswordWorking": "Réinitialisation en cours...",
@@ -1742,7 +1741,7 @@
"importFailed": "Échec de l'importation : {{error}}",
"roleUser": "Utilisateur",
"authMethodDual": "Authentification double",
"authMethodOidc": "Externe",
"authMethodExternal": "Externe",
"done": "Terminé",
"apiKeyNameRequired": "Le nom de la clé est requis",
"apiKeyCreated": "Clé API \"{{name}}\" créée",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "כפיית מקלדת אינטראקטיבית",
"overrideCredentialUsername": "עקיפת שם משתמש של פרטי כניסה",
"overrideCredentialUsernameDesc": "השתמש בשם המשתמש שצוין למעלה במקום שם המשתמש של פרטי האישור",
"oidcUsernameHint": "השתמשו ב- $external.username כדי למלא את השם שאיתו נכנסתם דרך SSO או LDAP.",
"externalUsernameHint": "השתמשו ב- $external.username כדי למלא את השם שאיתו נכנסתם דרך SSO או LDAP.",
"jumpHostChain": "שרשרת מארח קפיצה",
"portKnocking": "פורט נוקינג",
"addKnock": "הוסף יציאה",
@@ -977,7 +977,7 @@
"noSearchResults": "אין קשרים התואמים את החיפוש שלך",
"rename": "שינוי שם של סשן"
},
"terminal": {
"sshAuth": {
"totpRequired": "נדרש אימות דו-שלבי",
"totpCodeLabel": "קוד אימות",
"totpVerify": "לְאַמֵת",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "נכשל אימות קוד האיפוס",
"failedCompleteReset": "נכשל השלמת איפוס הסיסמה",
"invalidTotpCode": "קוד TOTP לא חוקי",
"failedOidcLogin": "נכשלה התחלת הכניסה החיצונית",
"silentSigninOidcUnavailable": "התבקשה כניסה שקטה, אך לא הוגדרה כניסה חיצונית.",
"failedExternalLogin": "נכשלה התחלת הכניסה החיצונית",
"silentSigninExternalUnavailable": "התבקשה כניסה שקטה, אך לא הוגדרה כניסה חיצונית.",
"failedUserInfo": "נכשלה קבלת פרטי המשתמש לאחר הכניסה",
"oidcAuthFailed": "הכניסה החיצונית נכשלה",
"externalAuthFailed": "הכניסה החיצונית נכשלה",
"invalidAuthUrl": "כתובת URL לא חוקית להרשאה התקבלה מה-backend",
"requiredField": "שדה זה נדרש",
"minLength": "אורך מינימלי הוא {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "לאפשר למשתמשים חדשים להירשם באופן עצמאי באמצעות שם משתמש וסיסמה",
"allowPasswordLogin": "אפשר כניסה עם סיסמה",
"allowPasswordLoginDesc": "שם משתמש/סיסמה להתחברות",
"oidcAutoProvision": "יצירה אוטומטית של חשבונות חיצוניים",
"oidcAutoProvisionDesc": "צור חשבון בפעם הראשונה שמישהו מתחבר דרך התחברות חיצונית כגון SSO או LDAP, גם כאשר ההרשמה כבויה",
"externalAutoProvision": "יצירה אוטומטית של חשבונות חיצוניים",
"externalAutoProvisionDesc": "צור חשבון בפעם הראשונה שמישהו מתחבר דרך התחברות חיצונית כגון SSO או LDAP, גם כאשר ההרשמה כבויה",
"secondFactorAfterExternalLogin": "בקשת גורם שני לאחר כניסות חיצוניות (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "כבוי, גורם שני רשום מתבקש רק לאחר סיסמה וכניסות מקומיות אחרות, כמו קודם. פעיל, הוא מתבקש לאחר כל שיטת כניסה, כולל SSO ו-LDAP.",
"allowPasswordReset": "אפשר איפוס סיסמה",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "אין תפוגה",
"revokedBadge": "בוטל",
"authTypeDual": "אימות כפול",
"authTypeOidc": "חִיצוֹנִי",
"authTypeExternal": "חִיצוֹנִי",
"authTypeLocal": "מְקוֹמִי",
"adminStatusAdministrator": "מְנַהֵל",
"adminStatusRegularUser": "משתמש רגיל",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "אפשר כניסה חיצונית וכניסה באמצעות סיסמה",
"linkAccountTargetUsername": "שם משתמש של חשבון מקומי",
"linkAccountTargetPlaceholder": "הזן את שם המשתמש של החשבון המקומי שאליו ברצונך לקשר",
"linkAccountOidcUsername": "שם משתמש של חשבון חיצוני",
"linkAccountOidcPlaceholder": "הזן את שם המשתמש של החשבון החיצוני בלבד כדי למזג אותו",
"linkAccountOidcNotFound": "לא נמצא חשבון חיצוני בלבד עם שם משתמש זה",
"linkAccountExternalUsername": "שם משתמש של חשבון חיצוני",
"linkAccountExternalPlaceholder": "הזן את שם המשתמש של החשבון החיצוני בלבד כדי למזג אותו",
"linkAccountExternalNotFound": "לא נמצא חשבון חיצוני בלבד עם שם משתמש זה",
"linkAccounts": "קישור חשבונות",
"linkAccountSuccess": "חשבונות קושרו בהצלחה",
"linkAccountFailed": "קישור החשבונות נכשל",
@@ -1400,7 +1400,6 @@
"saving": "חִסָכוֹן...",
"updateRegistrationFailed": "עדכון הגדרות הרישום נכשל",
"updatePasswordLoginFailed": "נכשל עדכון הגדרות הכניסה באמצעות סיסמה",
"updateOidcAutoProvisionFailed": "נכשל עדכון הגדרת היצירה האוטומטית",
"updateSecondFactorAfterExternalLoginFailed": "נכשל עדכון הגדרת הגורם השני",
"updatePasswordResetFailed": "נכשל עדכון הגדרת איפוס הסיסמה",
"sessionTimeoutRange2": "זמן הקצוב לסשן חייב להיות בין שעה ל-720 שעות",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "נָעוּל",
"dataLockedNotice": "הנתונים של {{username}}נשארים נעולים עד לכניסה הבאה שלהם. לא ניתן יהיה לצפות או לערוך את המארחים, האישורים והקטעים שלהם עד אז.",
"resetPasswordTitle": "איפוס סיסמה",
"resetPasswordOidcOnly": "משתמש זה מתחבר דרך ספק חיצוני ואין לו סיסמה.",
"resetPasswordExternalOnly": "משתמש זה מתחבר דרך ספק חיצוני ואין לו סיסמה.",
"resetPasswordPlaceholder": "סיסמה חדשה",
"resetPasswordBtn": "אִתחוּל",
"resetPasswordWorking": "איפוס...",
@@ -1742,7 +1741,7 @@
"importFailed": "ייבוא נכשל: {{error}}",
"roleUser": "מִשׁתַמֵשׁ",
"authMethodDual": "אימות כפול",
"authMethodOidc": "חִיצוֹנִי",
"authMethodExternal": "חִיצוֹנִי",
"done": "נַעֲשָׂה",
"apiKeyNameRequired": "נדרש שם מפתח",
"apiKeyCreated": "מפתח ה-API \"{{name}}\" נוצר",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "कीबोर्ड-इंटरैक्टिव बाध्य करें",
"overrideCredentialUsername": "क्रेडेंशियल यूज़रनेम ओवरराइड करें",
"overrideCredentialUsernameDesc": "क्रेडेंशियल के यूज़रनेम के बजाय ऊपर दिए गए यूज़रनेम का उपयोग करें",
"oidcUsernameHint": "SSO या LDAP के माध्यम से साइन इन करने के लिए आपने जिस नाम का उपयोग किया है, उसे भरने के लिए $external.username का उपयोग करें।",
"externalUsernameHint": "SSO या LDAP के माध्यम से साइन इन करने के लिए आपने जिस नाम का उपयोग किया है, उसे भरने के लिए $external.username का उपयोग करें।",
"jumpHostChain": "जंप होस्ट श्रृंखला",
"portKnocking": "पोर्ट नॉकिंग",
"addKnock": "पोर्ट जोड़ें",
@@ -977,7 +977,7 @@
"noSearchResults": "आपकी खोज से मेल खाने वाला कोई कनेक्शन नहीं",
"rename": "सत्र का नाम बदलें"
},
"terminal": {
"sshAuth": {
"totpRequired": "दो-कारक प्रमाणीकरण आवश्यक है",
"totpCodeLabel": "सत्यापन कोड",
"totpVerify": "सत्यापित करें",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "रीसेट कोड सत्यापित करने में विफल",
"failedCompleteReset": "पासवर्ड रीसेट पूरा करने में विफल",
"invalidTotpCode": "अमान्य TOTP कोड",
"failedOidcLogin": "बाहरी साइन-इन शुरू करने में विफल",
"silentSigninOidcUnavailable": "साइलेंट साइन-इन का अनुरोध किया गया था, लेकिन कोई बाहरी साइन-इन सेट अप नहीं किया गया है।",
"failedExternalLogin": "बाहरी साइन-इन शुरू करने में विफल",
"silentSigninExternalUnavailable": "साइलेंट साइन-इन का अनुरोध किया गया था, लेकिन कोई बाहरी साइन-इन सेट अप नहीं किया गया है।",
"failedUserInfo": "लॉगिन के बाद उपयोगकर्ता जानकारी प्राप्त करने में विफल",
"oidcAuthFailed": "बाहरी साइन-इन विफल रहा",
"externalAuthFailed": "बाहरी साइन-इन विफल रहा",
"invalidAuthUrl": "बैकएंड से अमान्य प्राधिकरण URL प्राप्त हुआ",
"requiredField": "यह फ़ील्ड आवश्यक है",
"minLength": "न्यूनतम लंबाई {{min}} है",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "नए उपयोगकर्ताओं को उपयोगकर्ता नाम और पासवर्ड के साथ स्वयं पंजीकरण करने दें",
"allowPasswordLogin": "पासवर्ड लॉगिन की अनुमति दें",
"allowPasswordLoginDesc": "उपयोगकर्ता नाम/पासवर्ड लॉगिन",
"oidcAutoProvision": "बाह्य खाते स्वतः बनाएँ",
"oidcAutoProvisionDesc": "जब कोई व्यक्ति एसएसओ या एलडीएपी जैसे बाहरी लॉगिन के माध्यम से पहली बार साइन इन करता है, तो पंजीकरण बंद होने पर भी एक खाता बनाएं।",
"externalAutoProvision": "बाह्य खाते स्वतः बनाएँ",
"externalAutoProvisionDesc": "जब कोई व्यक्ति एसएसओ या एलडीएपी जैसे बाहरी लॉगिन के माध्यम से पहली बार साइन इन करता है, तो पंजीकरण बंद होने पर भी एक खाता बनाएं।",
"secondFactorAfterExternalLogin": "बाहरी लॉगिन (एसएसओ, गिटहब, गूगल, एलडीएपी) के बाद दूसरे फैक्टर की मांग करें।",
"secondFactorAfterExternalLoginDesc": "बंद होने पर, पासवर्ड और अन्य स्थानीय लॉगिन के बाद ही पंजीकृत द्वितीय कारक प्रमाणीकरण मांगा जाता है, जैसा कि पहले होता था। चालू होने पर, एसएसओ और एलडीएपी सहित प्रत्येक लॉगिन विधि के बाद इसे मांगा जाता है।",
"allowPasswordReset": "पासवर्ड रीसेट की अनुमति दें",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "कोई समाप्ति नहीं",
"revokedBadge": "रद्द",
"authTypeDual": "दोहरा प्रमाणीकरण",
"authTypeOidc": "बाहरी",
"authTypeExternal": "बाहरी",
"authTypeLocal": "स्थानीय",
"adminStatusAdministrator": "प्रशासक",
"adminStatusRegularUser": "सामान्य उपयोगकर्ता",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "बाहरी और पासवर्ड दोनों तरह के साइन-इन की अनुमति दें",
"linkAccountTargetUsername": "स्थानीय खाते का उपयोगकर्ता नाम",
"linkAccountTargetPlaceholder": "लिंक करने के लिए स्थानीय खाते का उपयोगकर्ता नाम दर्ज करें",
"linkAccountOidcUsername": "बाह्य खाता उपयोगकर्ता नाम",
"linkAccountOidcPlaceholder": "विलय करने के लिए केवल बाहरी खाते का उपयोगकर्ता नाम दर्ज करें",
"linkAccountOidcNotFound": "उस उपयोगकर्ता नाम से कोई बाहरी खाता नहीं मिला।",
"linkAccountExternalUsername": "बाह्य खाता उपयोगकर्ता नाम",
"linkAccountExternalPlaceholder": "विलय करने के लिए केवल बाहरी खाते का उपयोगकर्ता नाम दर्ज करें",
"linkAccountExternalNotFound": "उस उपयोगकर्ता नाम से कोई बाहरी खाता नहीं मिला।",
"linkAccounts": "खाते लिंक करें",
"linkAccountSuccess": "खाते सफलतापूर्वक लिंक किए गए",
"linkAccountFailed": "खाते लिंक करने में विफल",
@@ -1400,7 +1400,6 @@
"saving": "सहेजा जा रहा है...",
"updateRegistrationFailed": "पंजीकरण सेटिंग अपडेट करने में विफल",
"updatePasswordLoginFailed": "पासवर्ड लॉगिन सेटिंग अपडेट करने में विफल",
"updateOidcAutoProvisionFailed": "ऑटो-क्रिएट सेटिंग को अपडेट करने में विफलता",
"updateSecondFactorAfterExternalLoginFailed": "द्वितीय कारक सेटिंग को अपडेट करने में विफल",
"updatePasswordResetFailed": "पासवर्ड रीसेट सेटिंग अपडेट करने में विफल",
"sessionTimeoutRange2": "सत्र समय-सीमा 1 से 720 घंटों के बीच होनी चाहिए",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "लॉक्ड",
"dataLockedNotice": "{{username}} का डेटा तब तक लॉक रहता है जब तक वे अगली बार लॉग इन नहीं कर लेते। तब तक उनके होस्ट, क्रेडेंशियल और स्निपेट को न तो देखा जा सकता है और न ही संपादित किया जा सकता है।",
"resetPasswordTitle": "पासवर्ड रीसेट करें",
"resetPasswordOidcOnly": "यह उपयोगकर्ता किसी बाहरी प्रदाता के माध्यम से साइन इन करता है और उसका कोई पासवर्ड नहीं है।",
"resetPasswordExternalOnly": "यह उपयोगकर्ता किसी बाहरी प्रदाता के माध्यम से साइन इन करता है और उसका कोई पासवर्ड नहीं है।",
"resetPasswordPlaceholder": "नया पासवर्ड",
"resetPasswordBtn": "रीसेट",
"resetPasswordWorking": "रीसेट हो रहा है...",
@@ -1742,7 +1741,7 @@
"importFailed": "आयात विफल: {{error}}",
"roleUser": "उपयोगकर्ता",
"authMethodDual": "दोहरा प्रमाणीकरण",
"authMethodOidc": "बाहरी",
"authMethodExternal": "बाहरी",
"done": "पूर्ण",
"apiKeyNameRequired": "कुंजी नाम आवश्यक है",
"apiKeyCreated": "API कुंजी \"{{name}}\" बनाई गई",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Kényszerítse a billentyűzet interaktivitását",
"overrideCredentialUsername": "Hitelesítő adat felülírása Felhasználónév",
"overrideCredentialUsernameDesc": "Használja a fent megadott felhasználónevet a hitelesítő adat felhasználóneve helyett.",
"oidcUsernameHint": "A $external.username paraméterrel töltse ki azt a nevet, amellyel SSO-n vagy LDAP-on keresztül bejelentkezett.",
"externalUsernameHint": "A $external.username paraméterrel töltse ki azt a nevet, amellyel SSO-n vagy LDAP-on keresztül bejelentkezett.",
"jumpHostChain": "Jump Host Chain",
"portKnocking": "Port Knocking",
"addKnock": "Port hozzáadása",
@@ -977,7 +977,7 @@
"noSearchResults": "Nincsenek a keresésnek megfelelő kapcsolatok",
"rename": "Munkamenet átnevezése"
},
"terminal": {
"sshAuth": {
"totpRequired": "Kétfaktoros hitelesítés szükséges",
"totpCodeLabel": "Ellenőrző kód",
"totpVerify": "Ellenőrzés",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Nem sikerült ellenőrizni a visszaállítási kódot",
"failedCompleteReset": "Nem sikerült befejezni a jelszó visszaállítását",
"invalidTotpCode": "Érvénytelen TOTP-kód",
"failedOidcLogin": "Nem sikerült elindítani a külső bejelentkezést",
"silentSigninOidcUnavailable": "Csendes bejelentkezést kértek, de nincs beállítva külső bejelentkezés.",
"failedExternalLogin": "Nem sikerült elindítani a külső bejelentkezést",
"silentSigninExternalUnavailable": "Csendes bejelentkezést kértek, de nincs beállítva külső bejelentkezés.",
"failedUserInfo": "Bejelentkezés után nem sikerült lekérni a felhasználói adatokat",
"oidcAuthFailed": "Külső bejelentkezés sikertelen",
"externalAuthFailed": "Külső bejelentkezés sikertelen",
"invalidAuthUrl": "Érvénytelen engedélyezési URL érkezett a háttérrendszertől",
"requiredField": "Ez a mező kötelező",
"minLength": "A minimális hossz {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Új felhasználók regisztrálhatnak felhasználónévvel és jelszóval",
"allowPasswordLogin": "Jelszó bejelentkezés engedélyezése",
"allowPasswordLoginDesc": "Felhasználónév/jelszó bejelentkezés",
"oidcAutoProvision": "Külső fiókok automatikus létrehozása",
"oidcAutoProvisionDesc": "Fiók létrehozása, amikor valaki először jelentkezik be külső bejelentkezéssel, például SSO-val vagy LDAP-pal, még akkor is, ha a regisztráció ki van kapcsolva.",
"externalAutoProvision": "Külső fiókok automatikus létrehozása",
"externalAutoProvisionDesc": "Fiók létrehozása, amikor valaki először jelentkezik be külső bejelentkezéssel, például SSO-val vagy LDAP-pal, még akkor is, ha a regisztráció ki van kapcsolva.",
"secondFactorAfterExternalLogin": "Külső bejelentkezések (SSO, GitHub, Google, LDAP) után kérjen második tényezőt",
"secondFactorAfterExternalLoginDesc": "Kikapcsolt állapotban a regisztrált második bejelentkezési tényezőt csak a jelszó és más helyi bejelentkezések után kéri a rendszer, ugyanúgy, mint korábban. Bekapcsolt állapotban minden bejelentkezési módszer után kéri, beleértve az SSO-t és az LDAP-t is.",
"allowPasswordReset": "Jelszó-visszaállítás engedélyezése",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Nincs lejárati idő",
"revokedBadge": "VISSZAVONVA",
"authTypeDual": "Kettős hitelesítés",
"authTypeOidc": "Külső",
"authTypeExternal": "Külső",
"authTypeLocal": "Helyi",
"adminStatusAdministrator": "Adminisztrátor",
"adminStatusRegularUser": "Rendszeres felhasználó",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Külső és jelszóval történő bejelentkezés engedélyezése",
"linkAccountTargetUsername": "Helyi fiók felhasználóneve",
"linkAccountTargetPlaceholder": "Add meg a helyi fiók felhasználónevét a csatoláshoz",
"linkAccountOidcUsername": "Külső fiók felhasználóneve",
"linkAccountOidcPlaceholder": "Adja meg a csak külső fiókhoz tartozó felhasználónevet az egyesítéshez",
"linkAccountOidcNotFound": "Nem található külső fiók ezzel a felhasználónévvel.",
"linkAccountExternalUsername": "Külső fiók felhasználóneve",
"linkAccountExternalPlaceholder": "Adja meg a csak külső fiókhoz tartozó felhasználónevet az egyesítéshez",
"linkAccountExternalNotFound": "Nem található külső fiók ezzel a felhasználónévvel.",
"linkAccounts": "Fiókok összekapcsolása",
"linkAccountSuccess": "Fiókok sikeresen összekapcsolva",
"linkAccountFailed": "Nem sikerült összekapcsolni a fiókokat",
@@ -1400,7 +1400,6 @@
"saving": "Megtakarítás...",
"updateRegistrationFailed": "Nem sikerült frissíteni a regisztrációs beállításokat",
"updatePasswordLoginFailed": "Nem sikerült frissíteni a jelszó bejelentkezési beállítását",
"updateOidcAutoProvisionFailed": "Nem sikerült frissíteni az automatikus létrehozási beállítást",
"updateSecondFactorAfterExternalLoginFailed": "Nem sikerült frissíteni a második tényező beállítását",
"updatePasswordResetFailed": "Nem sikerült frissíteni a jelszó-visszaállítási beállítást",
"sessionTimeoutRange2": "A munkamenet időkorlátjának 1 és 720 óra között kell lennie.",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "BEZÁRT",
"dataLockedNotice": "{{username}}adatai zárolva maradnak a következő bejelentkezésig. A hosztjaik, hitelesítő adataik és kódrészleteik addig nem tekinthetők meg és nem szerkeszthetők.",
"resetPasswordTitle": "Jelszó visszaállítása",
"resetPasswordOidcOnly": "Ez a felhasználó egy külső szolgáltatón keresztül jelentkezik be, és nincs jelszava.",
"resetPasswordExternalOnly": "Ez a felhasználó egy külső szolgáltatón keresztül jelentkezik be, és nincs jelszava.",
"resetPasswordPlaceholder": "Új jelszó",
"resetPasswordBtn": "Visszaállítás",
"resetPasswordWorking": "Visszaállítás...",
@@ -1742,7 +1741,7 @@
"importFailed": "Importálás sikertelen: {{error}}",
"roleUser": "Felhasználó",
"authMethodDual": "Kettős hitelesítés",
"authMethodOidc": "Külső",
"authMethodExternal": "Külső",
"done": "Kész",
"apiKeyNameRequired": "Kulcsnév megadása kötelező",
"apiKeyCreated": "„{{name}}” API-kulcs létrehozva",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Paksa Interaktif Keyboard",
"overrideCredentialUsername": "Ganti Nama Pengguna Kredensial",
"overrideCredentialUsernameDesc": "Gunakan nama pengguna yang telah ditentukan di atas, bukan nama pengguna kredensial.",
"oidcUsernameHint": "Gunakan $external.username untuk mengisi nama yang Anda gunakan saat masuk melalui SSO atau LDAP.",
"externalUsernameHint": "Gunakan $external.username untuk mengisi nama yang Anda gunakan saat masuk melalui SSO atau LDAP.",
"jumpHostChain": "Rantai Host Lompatan",
"portKnocking": "Ketukan Pelabuhan",
"addKnock": "Tambahkan Port",
@@ -977,7 +977,7 @@
"noSearchResults": "Tidak ada koneksi yang sesuai dengan pencarian Anda.",
"rename": "Ganti nama sesi"
},
"terminal": {
"sshAuth": {
"totpRequired": "Diperlukan Otentikasi Dua Faktor",
"totpCodeLabel": "Kode Verifikasi",
"totpVerify": "Memeriksa",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Gagal memverifikasi kode reset",
"failedCompleteReset": "Gagal menyelesaikan pengaturan ulang kata sandi.",
"invalidTotpCode": "Kode TOTP tidak valid",
"failedOidcLogin": "Gagal memulai proses masuk eksternal.",
"silentSigninOidcUnavailable": "Login tanpa interaksi pengguna telah diminta, tetapi tidak ada login eksternal yang diatur.",
"failedExternalLogin": "Gagal memulai proses masuk eksternal.",
"silentSigninExternalUnavailable": "Login tanpa interaksi pengguna telah diminta, tetapi tidak ada login eksternal yang diatur.",
"failedUserInfo": "Gagal mendapatkan informasi pengguna setelah login.",
"oidcAuthFailed": "Login eksternal gagal.",
"externalAuthFailed": "Login eksternal gagal.",
"invalidAuthUrl": "URL otorisasi yang diterima dari backend tidak valid.",
"requiredField": "Kolom ini wajib diisi.",
"minLength": "Panjang minimumnya adalah {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Izinkan pengguna baru mendaftar sendiri dengan nama pengguna dan kata sandi.",
"allowPasswordLogin": "Izinkan Login dengan Kata Sandi",
"allowPasswordLoginDesc": "Login dengan nama pengguna/kata sandi",
"oidcAutoProvision": "Buat akun eksternal secara otomatis",
"oidcAutoProvisionDesc": "Buat akun saat pertama kali seseorang masuk melalui login eksternal seperti SSO atau LDAP, bahkan saat pendaftaran dimatikan.",
"externalAutoProvision": "Buat akun eksternal secara otomatis",
"externalAutoProvisionDesc": "Buat akun saat pertama kali seseorang masuk melalui login eksternal seperti SSO atau LDAP, bahkan saat pendaftaran dimatikan.",
"secondFactorAfterExternalLogin": "Mintalah faktor otentikasi kedua setelah login eksternal (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Jika dinonaktifkan, faktor otentikasi kedua yang terdaftar hanya diminta setelah kata sandi dan login lokal lainnya, sama seperti sebelumnya. Jika diaktifkan, faktor otentikasi kedua diminta setelah setiap metode login, termasuk SSO dan LDAP.",
"allowPasswordReset": "Izinkan Reset Kata Sandi",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Tidak ada tanggal kedaluwarsa",
"revokedBadge": "DICABUT",
"authTypeDual": "Otorisasi Ganda",
"authTypeOidc": "Luar",
"authTypeExternal": "Luar",
"authTypeLocal": "Lokal",
"adminStatusAdministrator": "Administrator",
"adminStatusRegularUser": "Pengguna Biasa",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Izinkan login eksternal dan login menggunakan kata sandi.",
"linkAccountTargetUsername": "Nama Pengguna Akun Lokal",
"linkAccountTargetPlaceholder": "Masukkan nama pengguna akun lokal yang ingin dihubungkan.",
"linkAccountOidcUsername": "Nama Pengguna Akun Eksternal",
"linkAccountOidcPlaceholder": "Masukkan nama pengguna akun khusus eksternal yang ingin digabungkan.",
"linkAccountOidcNotFound": "Tidak ditemukan akun eksternal saja dengan nama pengguna tersebut.",
"linkAccountExternalUsername": "Nama Pengguna Akun Eksternal",
"linkAccountExternalPlaceholder": "Masukkan nama pengguna akun khusus eksternal yang ingin digabungkan.",
"linkAccountExternalNotFound": "Tidak ditemukan akun eksternal saja dengan nama pengguna tersebut.",
"linkAccounts": "Tautkan Akun",
"linkAccountSuccess": "Akun berhasil dihubungkan.",
"linkAccountFailed": "Gagal menautkan akun",
@@ -1400,7 +1400,6 @@
"saving": "Penghematan...",
"updateRegistrationFailed": "Gagal memperbarui pengaturan pendaftaran",
"updatePasswordLoginFailed": "Gagal memperbarui pengaturan login kata sandi.",
"updateOidcAutoProvisionFailed": "Gagal memperbarui pengaturan pembuatan otomatis.",
"updateSecondFactorAfterExternalLoginFailed": "Gagal memperbarui pengaturan faktor kedua.",
"updatePasswordResetFailed": "Gagal memperbarui pengaturan pengaturan ulang kata sandi.",
"sessionTimeoutRange2": "Batas waktu sesi harus antara 1 dan 720 jam.",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "TERKUNCI",
"dataLockedNotice": "Data {{username}}tetap terkunci hingga mereka masuk kembali. Host, kredensial, dan cuplikan mereka tidak dapat dilihat atau diedit hingga saat itu.",
"resetPasswordTitle": "Atur Ulang Kata Sandi",
"resetPasswordOidcOnly": "Pengguna ini masuk melalui penyedia eksternal dan tidak memiliki kata sandi.",
"resetPasswordExternalOnly": "Pengguna ini masuk melalui penyedia eksternal dan tidak memiliki kata sandi.",
"resetPasswordPlaceholder": "Kata sandi baru",
"resetPasswordBtn": "Mengatur ulang",
"resetPasswordWorking": "Mengatur ulang...",
@@ -1742,7 +1741,7 @@
"importFailed": "Impor gagal: {{error}}",
"roleUser": "Pengguna",
"authMethodDual": "Otorisasi Ganda",
"authMethodOidc": "Luar",
"authMethodExternal": "Luar",
"done": "Selesai",
"apiKeyNameRequired": "Nama kunci wajib diisi.",
"apiKeyCreated": "Kunci API \"{{name}}\" berhasil dibuat",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Forza Keyboard-Interactive",
"overrideCredentialUsername": "Sovrascrivi nome utente della credenziale",
"overrideCredentialUsernameDesc": "Utilizza il nome utente specificato sopra invece di quello della credenziale",
"oidcUsernameHint": "Utilizza $external.username per inserire il nome con cui hai effettuato l'accesso tramite SSO o LDAP.",
"externalUsernameHint": "Utilizza $external.username per inserire il nome con cui hai effettuato l'accesso tramite SSO o LDAP.",
"jumpHostChain": "Catena Jump Host",
"portKnocking": "Port Knocking",
"addKnock": "Aggiungi porta",
@@ -977,7 +977,7 @@
"noSearchResults": "Nessuna connessione corrisponde alla ricerca",
"rename": "Rinomina sessione"
},
"terminal": {
"sshAuth": {
"totpRequired": "Autenticazione a due fattori richiesta",
"totpCodeLabel": "Codice di verifica",
"totpVerify": "Verifica",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Verifica del codice di ripristino non riuscita",
"failedCompleteReset": "Completamento del ripristino della password non riuscito",
"invalidTotpCode": "Codice TOTP non valido",
"failedOidcLogin": "Impossibile avviare l'accesso esterno",
"silentSigninOidcUnavailable": "È stata richiesta l'attivazione dell'accesso silenzioso, ma non è stato configurato alcun accesso esterno.",
"failedExternalLogin": "Impossibile avviare l'accesso esterno",
"silentSigninExternalUnavailable": "È stata richiesta l'attivazione dell'accesso silenzioso, ma non è stato configurato alcun accesso esterno.",
"failedUserInfo": "Impossibile ottenere le informazioni utente dopo l'accesso",
"oidcAuthFailed": "Accesso esterno non riuscito",
"externalAuthFailed": "Accesso esterno non riuscito",
"invalidAuthUrl": "URL di autorizzazione non valido ricevuto dal backend",
"requiredField": "Questo campo è obbligatorio",
"minLength": "La lunghezza minima è {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Consenti ai nuovi utenti di registrarsi autonomamente con nome utente e password",
"allowPasswordLogin": "Consenti accesso con password",
"allowPasswordLoginDesc": "Accesso con nome utente/password",
"oidcAutoProvision": "Creazione automatica di account esterni",
"oidcAutoProvisionDesc": "Crea un account la prima volta che qualcuno effettua l'accesso tramite un login esterno come SSO o LDAP, anche quando la registrazione è disattivata",
"externalAutoProvision": "Creazione automatica di account esterni",
"externalAutoProvisionDesc": "Crea un account la prima volta che qualcuno effettua l'accesso tramite un login esterno come SSO o LDAP, anche quando la registrazione è disattivata",
"secondFactorAfterExternalLogin": "Richiedi un secondo fattore di autenticazione dopo l'accesso da fonti esterne (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "In modalità disattivata, l'autenticazione a due fattori viene richiesta solo dopo la password e altri accessi locali, come in precedenza. In modalità attivata, viene richiesta dopo ogni metodo di accesso, inclusi SSO e LDAP.",
"allowPasswordReset": "Consenti reset password",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Senza scadenza",
"revokedBadge": "REVOCATO",
"authTypeDual": "Autenticazione doppia",
"authTypeOidc": "Esterno",
"authTypeExternal": "Esterno",
"authTypeLocal": "Locale",
"adminStatusAdministrator": "Amministratore",
"adminStatusRegularUser": "Utente regolare",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Consenti l'accesso sia tramite credenziali esterne che tramite password.",
"linkAccountTargetUsername": "Nome utente account locale",
"linkAccountTargetPlaceholder": "Inserisci il nome utente dell'account locale da collegare",
"linkAccountOidcUsername": "Nome utente dell'account esterno",
"linkAccountOidcPlaceholder": "Inserisci il nome utente dell'account esterno da unire",
"linkAccountOidcNotFound": "Nessun account esterno trovato con quel nome utente",
"linkAccountExternalUsername": "Nome utente dell'account esterno",
"linkAccountExternalPlaceholder": "Inserisci il nome utente dell'account esterno da unire",
"linkAccountExternalNotFound": "Nessun account esterno trovato con quel nome utente",
"linkAccounts": "Collega account",
"linkAccountSuccess": "Account collegati con successo",
"linkAccountFailed": "Collegamento account non riuscito",
@@ -1400,7 +1400,6 @@
"saving": "Salvataggio in corso...",
"updateRegistrationFailed": "Aggiornamento impostazione registrazione non riuscito",
"updatePasswordLoginFailed": "Aggiornamento impostazione accesso con password non riuscito",
"updateOidcAutoProvisionFailed": "Impossibile aggiornare l'impostazione di creazione automatica",
"updateSecondFactorAfterExternalLoginFailed": "Impossibile aggiornare l'impostazione del secondo fattore di autenticazione.",
"updatePasswordResetFailed": "Aggiornamento impostazione reimpostazione password non riuscito",
"sessionTimeoutRange2": "Il timeout della sessione deve essere compreso tra 1 e 720 ore",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "BLOCCATO",
"dataLockedNotice": "I dati di {{username}} rimangono bloccati fino al prossimo accesso. I relativi host, credenziali e frammenti non possono essere visualizzati o modificati fino ad allora.",
"resetPasswordTitle": "Reimposta password",
"resetPasswordOidcOnly": "Questo utente accede tramite un provider esterno e non ha una password.",
"resetPasswordExternalOnly": "Questo utente accede tramite un provider esterno e non ha una password.",
"resetPasswordPlaceholder": "Nuova password",
"resetPasswordBtn": "Reimposta",
"resetPasswordWorking": "Reimpostazione in corso...",
@@ -1742,7 +1741,7 @@
"importFailed": "Importazione non riuscita: {{error}}",
"roleUser": "Utente",
"authMethodDual": "Doppia autenticazione",
"authMethodOidc": "Esterno",
"authMethodExternal": "Esterno",
"done": "Fatto",
"apiKeyNameRequired": "Il nome della chiave è obbligatorio",
"apiKeyCreated": "Chiave API \"{{name}}\" creata",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Keyboard-Interactive を強制",
"overrideCredentialUsername": "認証情報のユーザー名を上書き",
"overrideCredentialUsernameDesc": "認証情報のユーザー名の代わりに上記で指定したユーザー名を使用します",
"oidcUsernameHint": "$external.username を使用して、SSO または LDAP でサインインした名前を入力します。",
"externalUsernameHint": "$external.username を使用して、SSO または LDAP でサインインした名前を入力します。",
"jumpHostChain": "ジャンプホストチェーン",
"portKnocking": "ポートノッキング",
"addKnock": "ポートを追加",
@@ -977,7 +977,7 @@
"noSearchResults": "検索条件に一致する接続はありません",
"rename": "セッション名を変更"
},
"terminal": {
"sshAuth": {
"totpRequired": "二要素認証が必要です",
"totpCodeLabel": "確認コード",
"totpVerify": "確認",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "リセットコードの検証に失敗しました",
"failedCompleteReset": "パスワードリセットの完了に失敗しました",
"invalidTotpCode": "無効なTOTPコードです",
"failedOidcLogin": "外部サインインの開始に失敗しました",
"silentSigninOidcUnavailable": "サイレントサインインが要求されましたが、外部サインインは設定されていません。",
"failedExternalLogin": "外部サインインの開始に失敗しました",
"silentSigninExternalUnavailable": "サイレントサインインが要求されましたが、外部サインインは設定されていません。",
"failedUserInfo": "ログイン後のユーザー情報の取得に失敗しました",
"oidcAuthFailed": "外部からのサインインに失敗しました",
"externalAuthFailed": "外部からのサインインに失敗しました",
"invalidAuthUrl": "バックエンドから無効な認証URLを受信しました",
"requiredField": "この項目は必須です",
"minLength": "最小{{min}}文字です",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "新規ユーザーがユーザー名とパスワードで自己登録できます",
"allowPasswordLogin": "パスワードログインを許可",
"allowPasswordLoginDesc": "ユーザー名/パスワードログイン",
"oidcAutoProvision": "外部アカウントを自動作成",
"oidcAutoProvisionDesc": "SSOやLDAPなどの外部ログイン経由で初めてサインインした際に、登録が無効になっている場合でもアカウントを作成する",
"externalAutoProvision": "外部アカウントを自動作成",
"externalAutoProvisionDesc": "SSOやLDAPなどの外部ログイン経由で初めてサインインした際に、登録が無効になっている場合でもアカウントを作成する",
"secondFactorAfterExternalLogin": "外部ログイン(SSO、GitHub、Google、LDAPなど)後に二段階認証を要求する",
"secondFactorAfterExternalLoginDesc": "オフの場合、登録済みの二要素認証は、以前と同様にパスワードやその他のローカルログイン後にのみ要求されます。オンの場合、SSOやLDAPを含むすべてのログイン方法後に要求されます。",
"allowPasswordReset": "パスワードリセットを許可",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "期限なし",
"revokedBadge": "失効",
"authTypeDual": "二要素認証",
"authTypeOidc": "外部",
"authTypeExternal": "外部",
"authTypeLocal": "ローカル",
"adminStatusAdministrator": "管理者",
"adminStatusRegularUser": "一般ユーザー",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "外部アカウントとパスワードによるサインインの両方を許可する",
"linkAccountTargetUsername": "ローカルアカウントのユーザー名",
"linkAccountTargetPlaceholder": "リンク先のローカルアカウントのユーザー名を入力",
"linkAccountOidcUsername": "外部アカウントのユーザー名",
"linkAccountOidcPlaceholder": "外部専用アカウントのユーザー名を入力して統合します",
"linkAccountOidcNotFound": "そのユーザー名の外部専用アカウントは見つかりませんでした。",
"linkAccountExternalUsername": "外部アカウントのユーザー名",
"linkAccountExternalPlaceholder": "外部専用アカウントのユーザー名を入力して統合します",
"linkAccountExternalNotFound": "そのユーザー名の外部専用アカウントは見つかりませんでした。",
"linkAccounts": "アカウントをリンク",
"linkAccountSuccess": "アカウントのリンクに成功しました",
"linkAccountFailed": "アカウントのリンクに失敗しました",
@@ -1400,7 +1400,6 @@
"saving": "保存中...",
"updateRegistrationFailed": "登録設定の更新に失敗しました",
"updatePasswordLoginFailed": "パスワードログイン設定の更新に失敗しました",
"updateOidcAutoProvisionFailed": "自動作成設定の更新に失敗しました",
"updateSecondFactorAfterExternalLoginFailed": "2段階認証設定の更新に失敗しました",
"updatePasswordResetFailed": "パスワードリセット設定の更新に失敗しました",
"sessionTimeoutRange2": "セッションタイムアウトは1時間から720時間の間である必要があります",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "ロック済み",
"dataLockedNotice": "{{username}} のデータは次回ログインまでロックされたままです。それまで、ホスト、認証情報、スニペットの閲覧や編集はできません。",
"resetPasswordTitle": "パスワードをリセット",
"resetPasswordOidcOnly": "このユーザーは外部プロバイダー経由でサインインしており、パスワードはありません。",
"resetPasswordExternalOnly": "このユーザーは外部プロバイダー経由でサインインしており、パスワードはありません。",
"resetPasswordPlaceholder": "新しいパスワード",
"resetPasswordBtn": "リセット",
"resetPasswordWorking": "リセット中...",
@@ -1742,7 +1741,7 @@
"importFailed": "インポートに失敗しました: {{error}}",
"roleUser": "ユーザー",
"authMethodDual": "二要素認証",
"authMethodOidc": "外部",
"authMethodExternal": "外部",
"done": "完了",
"apiKeyNameRequired": "キー名は必須です",
"apiKeyCreated": "APIキー「{{name}}」が作成されました",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "키보드 인터랙티브 강제",
"overrideCredentialUsername": "자격 증명 사용자 이름 재정의",
"overrideCredentialUsernameDesc": "자격 증명의 사용자 이름 대신 위에 지정된 사용자 이름을 사용합니다",
"oidcUsernameHint": "$external.username을 사용하여 SSO 또는 LDAP를 통해 로그인할 때 사용한 이름을 입력하세요.",
"externalUsernameHint": "$external.username을 사용하여 SSO 또는 LDAP를 통해 로그인할 때 사용한 이름을 입력하세요.",
"jumpHostChain": "점프 호스트 체인",
"portKnocking": "포트 노킹",
"addKnock": "포트 추가",
@@ -977,7 +977,7 @@
"noSearchResults": "검색과 일치하는 연결이 없습니다.",
"rename": "세션 이름 바꾸기"
},
"terminal": {
"sshAuth": {
"totpRequired": "2단계 인증 필요",
"totpCodeLabel": "인증 코드",
"totpVerify": "인증",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "재설정 코드 인증 실패",
"failedCompleteReset": "비밀번호 재설정 완료 실패",
"invalidTotpCode": "잘못된 TOTP 코드",
"failedOidcLogin": "외부 로그인 시작에 실패했습니다.",
"silentSigninOidcUnavailable": "자동 로그인 요청이 있었지만 외부 로그인은 설정되어 있지 않습니다.",
"failedExternalLogin": "외부 로그인 시작에 실패했습니다.",
"silentSigninExternalUnavailable": "자동 로그인 요청이 있었지만 외부 로그인은 설정되어 있지 않습니다.",
"failedUserInfo": "로그인 후 사용자 정보를 가져오지 못했습니다",
"oidcAuthFailed": "외부 로그인 실패",
"externalAuthFailed": "외부 로그인 실패",
"invalidAuthUrl": "백엔드에서 받은 잘못된 인증 URL",
"requiredField": "필수 입력란입니다",
"minLength": "최소 {{min}}자",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "새 사용자가 사용자 이름과 비밀번호로 자가 등록할 수 있도록 허용",
"allowPasswordLogin": "비밀번호 로그인 허용",
"allowPasswordLoginDesc": "사용자 이름/비밀번호 로그인",
"oidcAutoProvision": "외부 계정 자동 생성",
"oidcAutoProvisionDesc": "회원가입이 비활성화된 경우에도 SSO 또는 LDAP와 같은 외부 로그인을 통해 처음 로그인할 때 계정을 생성합니다.",
"externalAutoProvision": "외부 계정 자동 생성",
"externalAutoProvisionDesc": "회원가입이 비활성화된 경우에도 SSO 또는 LDAP와 같은 외부 로그인을 통해 처음 로그인할 때 계정을 생성합니다.",
"secondFactorAfterExternalLogin": "외부 로그인(SSO, GitHub, Google, LDAP) 후에는 2단계 인증을 요청하세요.",
"secondFactorAfterExternalLoginDesc": "비활성화된 경우, 등록된 2단계 인증은 이전과 마찬가지로 비밀번호 및 기타 로컬 로그인 후에만 요청됩니다. 활성화된 경우, SSO 및 LDAP를 포함한 모든 로그인 방식 후에 2단계 인증이 요청됩니다.",
"allowPasswordReset": "비밀번호 재설정 허용",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "만료 없음",
"revokedBadge": "취소됨",
"authTypeDual": "이중 인증",
"authTypeOidc": "외부",
"authTypeExternal": "외부",
"authTypeLocal": "로컬",
"adminStatusAdministrator": "관리자",
"adminStatusRegularUser": "일반 사용자",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "외부 로그인과 비밀번호 로그인을 모두 허용합니다.",
"linkAccountTargetUsername": "로컬 계정 사용자 이름",
"linkAccountTargetPlaceholder": "연결할 로컬 계정 사용자 이름 입력",
"linkAccountOidcUsername": "외부 계정 사용자 이름",
"linkAccountOidcPlaceholder": "병합할 외부 전용 계정 사용자 이름을 입력하세요.",
"linkAccountOidcNotFound": "해당 사용자 이름으로 된 외부 전용 계정을 찾을 수 없습니다.",
"linkAccountExternalUsername": "외부 계정 사용자 이름",
"linkAccountExternalPlaceholder": "병합할 외부 전용 계정 사용자 이름을 입력하세요.",
"linkAccountExternalNotFound": "해당 사용자 이름으로 된 외부 전용 계정을 찾을 수 없습니다.",
"linkAccounts": "계정 연결",
"linkAccountSuccess": "계정이 성공적으로 연결되었습니다.",
"linkAccountFailed": "계정 연결에 실패했습니다.",
@@ -1400,7 +1400,6 @@
"saving": "저장 중...",
"updateRegistrationFailed": "등록 설정 업데이트에 실패했습니다.",
"updatePasswordLoginFailed": "비밀번호 로그인 설정 업데이트에 실패했습니다.",
"updateOidcAutoProvisionFailed": "자동 생성 설정을 업데이트하는 데 실패했습니다.",
"updateSecondFactorAfterExternalLoginFailed": "두 번째 인증 설정 업데이트에 실패했습니다.",
"updatePasswordResetFailed": "비밀번호 재설정 설정 업데이트에 실패했습니다.",
"sessionTimeoutRange2": "세션 제한 시간은 1시간 이상 720시간 이하여야 합니다.",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "잠김",
"dataLockedNotice": "{{username}}님의 데이터는 다음 로그인까지 잠금 상태로 유지됩니다. 그때까지 호스트, 자격 증명 및 스니펫을 보거나 편집할 수 없습니다.",
"resetPasswordTitle": "비밀번호 재설정",
"resetPasswordOidcOnly": "이 사용자는 외부 제공업체를 통해 로그인하며 비밀번호가 없습니다.",
"resetPasswordExternalOnly": "이 사용자는 외부 제공업체를 통해 로그인하며 비밀번호가 없습니다.",
"resetPasswordPlaceholder": "새 비밀번호",
"resetPasswordBtn": "재설정",
"resetPasswordWorking": "재설정 중...",
@@ -1742,7 +1741,7 @@
"importFailed": "가져오기 실패: {{error}}",
"roleUser": "사용자",
"authMethodDual": "이중 인증",
"authMethodOidc": "외부",
"authMethodExternal": "외부",
"done": "완료",
"apiKeyNameRequired": "API 키 이름이 필요합니다",
"apiKeyCreated": "API 키 \"{{name}}\" 생성됨",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Toetsenbordinteractief forceren",
"overrideCredentialUsername": "Gebruikersnaam voor het overschrijven van inloggegevens",
"overrideCredentialUsernameDesc": "Gebruik de hierboven opgegeven gebruikersnaam in plaats van de gebruikersnaam van de inloggegevens.",
"oidcUsernameHint": "Gebruik $external.username om de naam in te vullen waarmee u zich via SSO of LDAP hebt aangemeld.",
"externalUsernameHint": "Gebruik $external.username om de naam in te vullen waarmee u zich via SSO of LDAP hebt aangemeld.",
"jumpHostChain": "Spring naar de hostketen",
"portKnocking": "Havenkloppen",
"addKnock": "Poort toevoegen",
@@ -977,7 +977,7 @@
"noSearchResults": "Er zijn geen resultaten gevonden die overeenkomen met uw zoekopdracht.",
"rename": "Sessie hernoemen"
},
"terminal": {
"sshAuth": {
"totpRequired": "Tweefactorauthenticatie vereist",
"totpCodeLabel": "Verificatiecode",
"totpVerify": "Verifiëren",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Het verifiëren van de resetcode is mislukt.",
"failedCompleteReset": "Wachtwoordherstel is mislukt.",
"invalidTotpCode": "Ongeldige TOTP-code",
"failedOidcLogin": "Externe aanmelding is mislukt.",
"silentSigninOidcUnavailable": "Er is een verzoek ingediend voor een stille aanmelding, maar er is geen externe aanmelding ingesteld.",
"failedExternalLogin": "Externe aanmelding is mislukt.",
"silentSigninExternalUnavailable": "Er is een verzoek ingediend voor een stille aanmelding, maar er is geen externe aanmelding ingesteld.",
"failedUserInfo": "Het ophalen van gebruikersgegevens na het inloggen is mislukt.",
"oidcAuthFailed": "Externe aanmelding mislukt",
"externalAuthFailed": "Externe aanmelding mislukt",
"invalidAuthUrl": "Ongeldige autorisatie-URL ontvangen van de backend.",
"requiredField": "Dit veld is verplicht.",
"minLength": "De minimale lengte is {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Laat nieuwe gebruikers zich zelf registreren met een gebruikersnaam en wachtwoord.",
"allowPasswordLogin": "Wachtwoordaanmelding toestaan",
"allowPasswordLoginDesc": "Inloggen met gebruikersnaam/wachtwoord",
"oidcAutoProvision": "Externe accounts automatisch aanmaken",
"oidcAutoProvisionDesc": "Maak een account aan de eerste keer dat iemand inlogt via een externe login zoals SSO of LDAP, zelfs als registratie is uitgeschakeld.",
"externalAutoProvision": "Externe accounts automatisch aanmaken",
"externalAutoProvisionDesc": "Maak een account aan de eerste keer dat iemand inlogt via een externe login zoals SSO of LDAP, zelfs als registratie is uitgeschakeld.",
"secondFactorAfterExternalLogin": "Vraag om een tweede authenticatiefactor na externe aanmeldingen (SSO, GitHub, Google, LDAP).",
"secondFactorAfterExternalLoginDesc": "Uitgeschakeld: een geregistreerde tweede authenticatiefactor wordt alleen na het wachtwoord en andere lokale aanmeldingen gevraagd, net als voorheen. Aangeschakeld: deze wordt na elke aanmeldingsmethode gevraagd, inclusief SSO en LDAP.",
"allowPasswordReset": "Wachtwoordherstel toestaan",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Geen vervaldatum",
"revokedBadge": "INGETROKKEN",
"authTypeDual": "Dubbele authenticatie",
"authTypeOidc": "Extern",
"authTypeExternal": "Extern",
"authTypeLocal": "Lokaal",
"adminStatusAdministrator": "Beheerder",
"adminStatusRegularUser": "Regelmatige gebruiker",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Sta zowel externe aanmelding als aanmelding met wachtwoord toe.",
"linkAccountTargetUsername": "Lokale accountgebruikersnaam",
"linkAccountTargetPlaceholder": "Voer de gebruikersnaam van het lokale account in om te koppelen.",
"linkAccountOidcUsername": "Externe accountgebruikersnaam",
"linkAccountOidcPlaceholder": "Voer de gebruikersnaam van het externe account in om deze samen te voegen.",
"linkAccountOidcNotFound": "Er is geen extern account gevonden met die gebruikersnaam.",
"linkAccountExternalUsername": "Externe accountgebruikersnaam",
"linkAccountExternalPlaceholder": "Voer de gebruikersnaam van het externe account in om deze samen te voegen.",
"linkAccountExternalNotFound": "Er is geen extern account gevonden met die gebruikersnaam.",
"linkAccounts": "Accounts koppelen",
"linkAccountSuccess": "Accounts succesvol gekoppeld",
"linkAccountFailed": "Het koppelen van accounts is mislukt.",
@@ -1400,7 +1400,6 @@
"saving": "Besparing...",
"updateRegistrationFailed": "Het bijwerken van de registratie-instellingen is mislukt.",
"updatePasswordLoginFailed": "Het bijwerken van de wachtwoordinstellingen is mislukt.",
"updateOidcAutoProvisionFailed": "Het bijwerken van de instelling voor automatisch aanmaken is mislukt.",
"updateSecondFactorAfterExternalLoginFailed": "Het bijwerken van de tweede authenticatie-instelling is mislukt.",
"updatePasswordResetFailed": "Het bijwerken van de instellingen voor het opnieuw instellen van het wachtwoord is mislukt.",
"sessionTimeoutRange2": "De sessietime-out moet tussen 1 en 720 uur liggen.",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "VERGRENDELD",
"dataLockedNotice": "De gegevens van {{username}}blijven vergrendeld tot de volgende keer dat ze inloggen. Hun hosts, inloggegevens en codefragmenten kunnen tot die tijd niet worden bekeken of bewerkt.",
"resetPasswordTitle": "Wachtwoord opnieuw instellen",
"resetPasswordOidcOnly": "Deze gebruiker meldt zich aan via een externe provider en heeft geen wachtwoord.",
"resetPasswordExternalOnly": "Deze gebruiker meldt zich aan via een externe provider en heeft geen wachtwoord.",
"resetPasswordPlaceholder": "Nieuw wachtwoord",
"resetPasswordBtn": "Reset",
"resetPasswordWorking": "Opnieuw instellen...",
@@ -1742,7 +1741,7 @@
"importFailed": "Import mislukt: {{error}}",
"roleUser": "Gebruiker",
"authMethodDual": "Dubbele authenticatie",
"authMethodOidc": "Extern",
"authMethodExternal": "Extern",
"done": "Klaar",
"apiKeyNameRequired": "Sleutelnaam is vereist",
"apiKeyCreated": "API-sleutel \"{{name}}\" aangemaakt",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Tving tastaturinteraktivt",
"overrideCredentialUsername": "Overstyr brukernavn for legitimasjon",
"overrideCredentialUsernameDesc": "Bruk brukernavnet som er spesifisert ovenfor i stedet for brukernavnet til legitimasjonen",
"oidcUsernameHint": "Bruk $external.username for å fylle ut navnet du logget på med via SSO eller LDAP.",
"externalUsernameHint": "Bruk $external.username for å fylle ut navnet du logget på med via SSO eller LDAP.",
"jumpHostChain": "Hoppvertkjede",
"portKnocking": "Port Knocking",
"addKnock": "Legg til port",
@@ -977,7 +977,7 @@
"noSearchResults": "Ingen forbindelser samsvarer med søket ditt",
"rename": "Gi nytt navn til økten"
},
"terminal": {
"sshAuth": {
"totpRequired": "Tofaktorautentisering kreves",
"totpCodeLabel": "Verifiseringskode",
"totpVerify": "Verifisere",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Kunne ikke bekrefte tilbakestillingskoden",
"failedCompleteReset": "Kunne ikke fullføre tilbakestillingen av passordet",
"invalidTotpCode": "Ugyldig TOTP-kode",
"failedOidcLogin": "Kunne ikke starte ekstern pålogging",
"silentSigninOidcUnavailable": "Stille pålogging ble forespurt, men ingen ekstern pålogging er konfigurert.",
"failedExternalLogin": "Kunne ikke starte ekstern pålogging",
"silentSigninExternalUnavailable": "Stille pålogging ble forespurt, men ingen ekstern pålogging er konfigurert.",
"failedUserInfo": "Klarte ikke å hente brukerinformasjon etter innlogging",
"oidcAuthFailed": "Ekstern pålogging mislyktes",
"externalAuthFailed": "Ekstern pålogging mislyktes",
"invalidAuthUrl": "Ugyldig autorisasjons-URL mottatt fra backend",
"requiredField": "Dette feltet er obligatorisk",
"minLength": "Minimumslengden er {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "La nye brukere registrere seg selv med brukernavn og passord",
"allowPasswordLogin": "Tillat passordpålogging",
"allowPasswordLoginDesc": "Brukernavn/passord pålogging",
"oidcAutoProvision": "Opprett eksterne kontoer automatisk",
"oidcAutoProvisionDesc": "Opprett en konto første gang noen logger på via en ekstern pålogging som SSO eller LDAP, selv når registreringen er av",
"externalAutoProvision": "Opprett eksterne kontoer automatisk",
"externalAutoProvisionDesc": "Opprett en konto første gang noen logger på via en ekstern pålogging som SSO eller LDAP, selv når registreringen er av",
"secondFactorAfterExternalLogin": "Be om en annen faktor etter eksterne pålogginger (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Av, en registrert sekundær faktor blir bare spurt etter passord og andre lokale pålogginger, samme som før. På, den blir spurt etter hver påloggingsmetode, inkludert SSO og LDAP.",
"allowPasswordReset": "Tillat tilbakestilling av passord",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Ingen utløpsdato",
"revokedBadge": "OPPHEVET",
"authTypeDual": "Dobbel autentisering",
"authTypeOidc": "Ekstern",
"authTypeExternal": "Ekstern",
"authTypeLocal": "Lokalt",
"adminStatusAdministrator": "Administrator",
"adminStatusRegularUser": "Vanlig bruker",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Tillat både ekstern pålogging og passordpålogging",
"linkAccountTargetUsername": "Brukernavn for lokal konto",
"linkAccountTargetPlaceholder": "Skriv inn brukernavnet for den lokale kontoen som skal kobles til",
"linkAccountOidcUsername": "Brukernavn for ekstern konto",
"linkAccountOidcPlaceholder": "Skriv inn brukernavnet for den eksterne kontoen som skal slås sammen",
"linkAccountOidcNotFound": "Ingen ekstern konto funnet med det brukernavnet",
"linkAccountExternalUsername": "Brukernavn for ekstern konto",
"linkAccountExternalPlaceholder": "Skriv inn brukernavnet for den eksterne kontoen som skal slås sammen",
"linkAccountExternalNotFound": "Ingen ekstern konto funnet med det brukernavnet",
"linkAccounts": "Koble sammen kontoer",
"linkAccountSuccess": "Kontoer er koblet sammen",
"linkAccountFailed": "Kunne ikke koble kontoer",
@@ -1400,7 +1400,6 @@
"saving": "Lagrer...",
"updateRegistrationFailed": "Kunne ikke oppdatere registreringsinnstillingene",
"updatePasswordLoginFailed": "Kunne ikke oppdatere passordinnstillingen for pålogging",
"updateOidcAutoProvisionFailed": "Kunne ikke oppdatere innstillingen for automatisk oppretting",
"updateSecondFactorAfterExternalLoginFailed": "Kunne ikke oppdatere innstillingen for den andre faktoren",
"updatePasswordResetFailed": "Kunne ikke oppdatere innstillingen for tilbakestilling av passord",
"sessionTimeoutRange2": "Øktens tidsavbrudd må være mellom 1 og 720 timer",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "LÅST",
"dataLockedNotice": "{{username}}sine data forblir låst til de logger seg inn igjen. Vertene, påloggingsinformasjonen og kodeutdragene deres kan ikke vises eller redigeres før da.",
"resetPasswordTitle": "Tilbakestill passord",
"resetPasswordOidcOnly": "Denne brukeren logger seg på via en ekstern leverandør og har ikke noe passord.",
"resetPasswordExternalOnly": "Denne brukeren logger seg på via en ekstern leverandør og har ikke noe passord.",
"resetPasswordPlaceholder": "Nytt passord",
"resetPasswordBtn": "Tilbakestill",
"resetPasswordWorking": "Tilbakestiller...",
@@ -1742,7 +1741,7 @@
"importFailed": "Importen mislyktes: {{error}}",
"roleUser": "Bruker",
"authMethodDual": "Dobbel autentisering",
"authMethodOidc": "Ekstern",
"authMethodExternal": "Ekstern",
"done": "Ferdig",
"apiKeyNameRequired": "Nøkkelnavn er obligatorisk",
"apiKeyCreated": "API-nøkkelen «{{name}}» ble opprettet",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Wymuś keyboard-interactive",
"overrideCredentialUsername": "Zastąp nazwę użytkownika poświadczeń",
"overrideCredentialUsernameDesc": "Użyj nazwy użytkownika określonej powyżej zamiast nazwy użytkownika podanej w poświadczeniu",
"oidcUsernameHint": "Użyj $external.username, aby wpisać nazwę, której użyłeś do zalogowania się za pomocą SSO lub LDAP.",
"externalUsernameHint": "Użyj $external.username, aby wpisać nazwę, której użyłeś do zalogowania się za pomocą SSO lub LDAP.",
"jumpHostChain": "Łańcuch hostów pośrednich",
"portKnocking": "Port Knocking",
"addKnock": "Dodaj port",
@@ -977,7 +977,7 @@
"noSearchResults": "Brak połączeń pasujących do wyszukiwania",
"rename": "Zmień nazwę sesji"
},
"terminal": {
"sshAuth": {
"totpRequired": "Wymagane uwierzytelnianie dwuskładnikowe",
"totpCodeLabel": "Kod weryfikacyjny",
"totpVerify": "Zweryfikuj",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Nie udało się zweryfikować kodu resetowania",
"failedCompleteReset": "Nie udało się ukończyć resetowania hasła",
"invalidTotpCode": "Nieprawidłowy kod TOTP",
"failedOidcLogin": "Nie udało się rozpocząć logowania zewnętrznego",
"silentSigninOidcUnavailable": "Poproszono o ciche logowanie, ale nie skonfigurowano logowania zewnętrznego.",
"failedExternalLogin": "Nie udało się rozpocząć logowania zewnętrznego",
"silentSigninExternalUnavailable": "Poproszono o ciche logowanie, ale nie skonfigurowano logowania zewnętrznego.",
"failedUserInfo": "Nie udało się pobrać informacji o użytkowniku po zalogowaniu",
"oidcAuthFailed": "Nie udało się zalogować z zewnątrz",
"externalAuthFailed": "Nie udało się zalogować z zewnątrz",
"invalidAuthUrl": "Otrzymano nieprawidłowy adres URL autoryzacji z serwera",
"requiredField": "To pole jest wymagane",
"minLength": "Minimalna długość to {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Pozwól nowym użytkownikom na samodzielną rejestrację przy użyciu nazwy użytkownika i hasła",
"allowPasswordLogin": "Zezwalaj na logowanie hasłem",
"allowPasswordLoginDesc": "Logowanie nazwą użytkownika i hasłem",
"oidcAutoProvision": "Automatyczne tworzenie kont zewnętrznych",
"oidcAutoProvisionDesc": "Utwórz konto za pierwszym razem, gdy ktoś zaloguje się za pomocą logowania zewnętrznego, takiego jak SSO lub LDAP, nawet jeśli rejestracja jest wyłączona",
"externalAutoProvision": "Automatyczne tworzenie kont zewnętrznych",
"externalAutoProvisionDesc": "Utwórz konto za pierwszym razem, gdy ktoś zaloguje się za pomocą logowania zewnętrznego, takiego jak SSO lub LDAP, nawet jeśli rejestracja jest wyłączona",
"secondFactorAfterExternalLogin": "Poproś o drugi czynnik po logowaniu zewnętrznym (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Wyłączona, drugi czynnik uwierzytelniania jest wymagany tylko po podaniu hasła i innych lokalnych logowaniach, tak jak poprzednio. Włączona, jest wymagany po każdej metodzie logowania, w tym SSO i LDAP.",
"allowPasswordReset": "Zezwól na resetowanie hasła",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Brak wygaśnięcia",
"revokedBadge": "UNIEWAŻNIONY",
"authTypeDual": "Podwójne uwierzytelnianie",
"authTypeOidc": "Zewnętrzny",
"authTypeExternal": "Zewnętrzny",
"authTypeLocal": "Lokalne",
"adminStatusAdministrator": "Administrator",
"adminStatusRegularUser": "Zwykły użytkownik",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Zezwalaj na logowanie zewnętrzne i za pomocą hasła",
"linkAccountTargetUsername": "Nazwa użytkownika konta lokalnego",
"linkAccountTargetPlaceholder": "Wprowadź nazwę użytkownika konta lokalnego, z którym chcesz je połączyć",
"linkAccountOidcUsername": "Nazwa użytkownika konta zewnętrznego",
"linkAccountOidcPlaceholder": "Wprowadź nazwę użytkownika konta przeznaczonego wyłącznie do użytku zewnętrznego, aby je scalić",
"linkAccountOidcNotFound": "Nie znaleziono konta przeznaczonego wyłącznie do użytku zewnętrznego o tej nazwie użytkownika",
"linkAccountExternalUsername": "Nazwa użytkownika konta zewnętrznego",
"linkAccountExternalPlaceholder": "Wprowadź nazwę użytkownika konta przeznaczonego wyłącznie do użytku zewnętrznego, aby je scalić",
"linkAccountExternalNotFound": "Nie znaleziono konta przeznaczonego wyłącznie do użytku zewnętrznego o tej nazwie użytkownika",
"linkAccounts": "Połącz konta",
"linkAccountSuccess": "Konta zostały pomyślnie połączone",
"linkAccountFailed": "Nie udało się połączyć kont",
@@ -1400,7 +1400,6 @@
"saving": "Zapisywanie...",
"updateRegistrationFailed": "Nie udało się zaktualizować ustawień rejestracji",
"updatePasswordLoginFailed": "Nie udało się zaktualizować ustawień logowania hasłem",
"updateOidcAutoProvisionFailed": "Nie udało się zaktualizować ustawienia automatycznego tworzenia",
"updateSecondFactorAfterExternalLoginFailed": "Nie udało się zaktualizować ustawień drugiego czynnika uwierzytelniania",
"updatePasswordResetFailed": "Nie udało się zaktualizować ustawienia resetowania hasła",
"sessionTimeoutRange2": "Limit czasu sesji musi mieścić się w przedziale od 1 do 720 godzin",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "ZABLOKOWANE",
"dataLockedNotice": "Dane użytkownika {{username}} pozostaną zablokowane do jego następnego zalogowania. Do tego czasu nie można przeglądać ani edytować jego hostów, poświadczeń i snippetów.",
"resetPasswordTitle": "Zresetuj hasło",
"resetPasswordOidcOnly": "Ten użytkownik loguje się za pośrednictwem zewnętrznego dostawcy i nie ma hasła.",
"resetPasswordExternalOnly": "Ten użytkownik loguje się za pośrednictwem zewnętrznego dostawcy i nie ma hasła.",
"resetPasswordPlaceholder": "Nowe hasło",
"resetPasswordBtn": "Resetuj",
"resetPasswordWorking": "Resetowanie...",
@@ -1742,7 +1741,7 @@
"importFailed": "Import nie powiódł się: {{error}}",
"roleUser": "Użytkownik",
"authMethodDual": "Podwójne uwierzytelnianie",
"authMethodOidc": "Zewnętrzny",
"authMethodExternal": "Zewnętrzny",
"done": "Gotowe",
"apiKeyNameRequired": "Nazwa klucza jest wymagana",
"apiKeyCreated": "Utworzono klucz API „{{name}}”",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Forçar interação por teclado",
"overrideCredentialUsername": "Substituir nome de usuário da credencial",
"overrideCredentialUsernameDesc": "Use o nome de usuário especificado acima em vez do nome de usuário da credencial.",
"oidcUsernameHint": "Use $external.username para preencher o nome com o qual você fez login por meio de SSO ou LDAP.",
"externalUsernameHint": "Use $external.username para preencher o nome com o qual você fez login por meio de SSO ou LDAP.",
"jumpHostChain": "Cadeia de anfitriões de salto",
"portKnocking": "Batendo ao porto",
"addKnock": "Adicionar porta",
@@ -977,7 +977,7 @@
"noSearchResults": "Nenhuma conexão corresponde à sua pesquisa.",
"rename": "Renomear sessão"
},
"terminal": {
"sshAuth": {
"totpRequired": "Autenticação de dois fatores obrigatória",
"totpCodeLabel": "Código de verificação",
"totpVerify": "Verificar",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Falha ao verificar o código de reinicialização",
"failedCompleteReset": "Falha ao concluir a redefinição de senha.",
"invalidTotpCode": "Código TOTP inválido",
"failedOidcLogin": "Falha ao iniciar o login externo.",
"silentSigninOidcUnavailable": "Foi solicitado o login silencioso, mas nenhum login externo está configurado.",
"failedExternalLogin": "Falha ao iniciar o login externo.",
"silentSigninExternalUnavailable": "Foi solicitado o login silencioso, mas nenhum login externo está configurado.",
"failedUserInfo": "Falha ao obter informações do usuário após o login",
"oidcAuthFailed": "Falha no login externo",
"externalAuthFailed": "Falha no login externo",
"invalidAuthUrl": "URL de autorização inválida recebida do backend.",
"requiredField": "Este campo é obrigatório",
"minLength": "O comprimento mínimo é {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Permita que novos usuários se cadastrem automaticamente com um nome de usuário e senha.",
"allowPasswordLogin": "Permitir login com senha",
"allowPasswordLoginDesc": "Login com nome de usuário/senha",
"oidcAutoProvision": "Criar contas externas automaticamente",
"oidcAutoProvisionDesc": "Criar uma conta na primeira vez que alguém fizer login por meio de um método externo, como SSO ou LDAP, mesmo quando o registro estiver desativado.",
"externalAutoProvision": "Criar contas externas automaticamente",
"externalAutoProvisionDesc": "Criar uma conta na primeira vez que alguém fizer login por meio de um método externo, como SSO ou LDAP, mesmo quando o registro estiver desativado.",
"secondFactorAfterExternalLogin": "Solicitar um segundo fator de autenticação após logins externos (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Desativado, o segundo fator de autenticação cadastrado só é solicitado após a senha e outros logins locais, como antes. Ativado, ele é solicitado após todos os métodos de login, incluindo SSO e LDAP.",
"allowPasswordReset": "Permitir redefinição de senha",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Sem data de validade",
"revokedBadge": "REVOGADO",
"authTypeDual": "Autenticação dupla",
"authTypeOidc": "Externo",
"authTypeExternal": "Externo",
"authTypeLocal": "Local",
"adminStatusAdministrator": "Administrador",
"adminStatusRegularUser": "Usuário regular",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Permitir login externo e com senha",
"linkAccountTargetUsername": "Nome de usuário da conta local",
"linkAccountTargetPlaceholder": "Insira o nome de usuário da conta local para vincular.",
"linkAccountOidcUsername": "Nome de usuário da conta externa",
"linkAccountOidcPlaceholder": "Insira o nome de usuário da conta externa para mesclar.",
"linkAccountOidcNotFound": "Nenhuma conta externa encontrada com esse nome de usuário.",
"linkAccountExternalUsername": "Nome de usuário da conta externa",
"linkAccountExternalPlaceholder": "Insira o nome de usuário da conta externa para mesclar.",
"linkAccountExternalNotFound": "Nenhuma conta externa encontrada com esse nome de usuário.",
"linkAccounts": "Vincular contas",
"linkAccountSuccess": "Contas vinculadas com sucesso",
"linkAccountFailed": "Falha ao vincular as contas",
@@ -1400,7 +1400,6 @@
"saving": "Salvando...",
"updateRegistrationFailed": "Falha ao atualizar as configurações de registro",
"updatePasswordLoginFailed": "Falha ao atualizar as configurações de login da senha.",
"updateOidcAutoProvisionFailed": "Falha ao atualizar a configuração de criação automática.",
"updateSecondFactorAfterExternalLoginFailed": "Falha ao atualizar a configuração do segundo fator.",
"updatePasswordResetFailed": "Falha ao atualizar as configurações de redefinição de senha",
"sessionTimeoutRange2": "O tempo limite da sessão deve estar entre 1 e 720 horas.",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "TRANCADO",
"dataLockedNotice": "Os dados de {{username}}permanecem bloqueados até o próximo login. Seus hosts, credenciais e trechos de código não podem ser visualizados ou editados até então.",
"resetPasswordTitle": "Redefinir senha",
"resetPasswordOidcOnly": "Este usuário faz login por meio de um provedor externo e não possui senha.",
"resetPasswordExternalOnly": "Este usuário faz login por meio de um provedor externo e não possui senha.",
"resetPasswordPlaceholder": "Nova Senha",
"resetPasswordBtn": "Reiniciar",
"resetPasswordWorking": "Reiniciando...",
@@ -1742,7 +1741,7 @@
"importFailed": "A importação falhou: {{error}}",
"roleUser": "Usuário",
"authMethodDual": "Autenticação dupla",
"authMethodOidc": "Externo",
"authMethodExternal": "Externo",
"done": "Feito",
"apiKeyNameRequired": "É necessário nomear a chave.",
"apiKeyCreated": "Chave de API \"{{name}}\" criada",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Forçar autenticação Keyboard-Interactive",
"overrideCredentialUsername": "Substituir nome de utilizador da credencial",
"overrideCredentialUsernameDesc": "Utilizar o nome de utilizador especificado acima em vez do nome de utilizador da credencial",
"oidcUsernameHint": "Utilize $external. username para preencher o nome com o qual iniciou sessão através de SSO ou LDAP.",
"externalUsernameHint": "Utilize $external. username para preencher o nome com o qual iniciou sessão através de SSO ou LDAP.",
"jumpHostChain": "Cadeia de hosts de salto",
"portKnocking": "Port Knocking",
"addKnock": "Adicionar porta",
@@ -977,7 +977,7 @@
"noSearchResults": "Nenhuma ligação corresponde à sua pesquisa",
"rename": "Renomear sessão"
},
"terminal": {
"sshAuth": {
"totpRequired": "Autenticação de dois fatores necessária",
"totpCodeLabel": "Código de verificação",
"totpVerify": "Verificar",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Falha ao verificar o código de reposição",
"failedCompleteReset": "Falha ao concluir a reposição da palavra-passe",
"invalidTotpCode": "Código TOTP inválido",
"failedOidcLogin": "Falha ao iniciar o login externo.",
"silentSigninOidcUnavailable": "Foi solicitado o login silencioso, mas não está configurado nenhum login externo.",
"failedExternalLogin": "Falha ao iniciar o login externo.",
"silentSigninExternalUnavailable": "Foi solicitado o login silencioso, mas não está configurado nenhum login externo.",
"failedUserInfo": "Falha ao obter informações do utilizador após o início de sessão",
"oidcAuthFailed": "Falha no login externo",
"externalAuthFailed": "Falha no login externo",
"invalidAuthUrl": "URL de autorização inválida recebida do backend",
"requiredField": "Este campo é obrigatório",
"minLength": "O comprimento mínimo é {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Permitir que novos utilizadores se auto-registem com nome de utilizador e palavra-passe",
"allowPasswordLogin": "Permitir Início de Sessão com Palavra-passe",
"allowPasswordLoginDesc": "Início de sessão com nome de utilizador/palavra-passe",
"oidcAutoProvision": "Criar contas externas automaticamente",
"oidcAutoProvisionDesc": "Criar uma conta na primeira vez que alguém inicia sessão através de um método externo, como SSO ou LDAP, mesmo quando o registo está desativado.",
"externalAutoProvision": "Criar contas externas automaticamente",
"externalAutoProvisionDesc": "Criar uma conta na primeira vez que alguém inicia sessão através de um método externo, como SSO ou LDAP, mesmo quando o registo está desativado.",
"secondFactorAfterExternalLogin": "Solicitar um segundo fator de autenticação após os logins externos (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Desativado, o segundo fator de autenticação registado só é solicitado após a password e outros logins locais, como anteriormente. Ativado, é solicitado após todos os métodos de login, incluindo SSO e LDAP.",
"allowPasswordReset": "Permitir Reposição de Palavra-passe",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Sem expiração",
"revokedBadge": "REVOGADA",
"authTypeDual": "Autenticação Dupla",
"authTypeOidc": "Externo",
"authTypeExternal": "Externo",
"authTypeLocal": "Local",
"adminStatusAdministrator": "Administrador",
"adminStatusRegularUser": "Utilizador Regular",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Permitir login externo e com password",
"linkAccountTargetUsername": "Nome de utilizador da conta local",
"linkAccountTargetPlaceholder": "Introduza o nome de utilizador da conta local a associar",
"linkAccountOidcUsername": "Nome de utilizador da conta externa",
"linkAccountOidcPlaceholder": "Introduza o nome de utilizador da conta externa para fundir.",
"linkAccountOidcNotFound": "Nenhuma conta externa encontrada com este nome de utilizador.",
"linkAccountExternalUsername": "Nome de utilizador da conta externa",
"linkAccountExternalPlaceholder": "Introduza o nome de utilizador da conta externa para fundir.",
"linkAccountExternalNotFound": "Nenhuma conta externa encontrada com este nome de utilizador.",
"linkAccounts": "Associar contas",
"linkAccountSuccess": "Contas associadas com sucesso",
"linkAccountFailed": "Falha ao associar contas",
@@ -1400,7 +1400,6 @@
"saving": "A guardar...",
"updateRegistrationFailed": "Falha ao atualizar a definição de registo",
"updatePasswordLoginFailed": "Falha ao atualizar a definição de início de sessão por palavra-passe",
"updateOidcAutoProvisionFailed": "Falha ao atualizar a configuração de criação automática.",
"updateSecondFactorAfterExternalLoginFailed": "Falha ao atualizar a configuração do segundo fator.",
"updatePasswordResetFailed": "Falha ao atualizar a definição de reposição de palavra-passe",
"sessionTimeoutRange2": "O tempo limite da sessão deve estar entre 1 e 720 horas",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "BLOQUEADO",
"dataLockedNotice": "Os dados de {{username}} permanecem bloqueados até ao próximo início de sessão. As suas máquinas, credenciais e fragmentos não podem ser visualizados nem editados até lá.",
"resetPasswordTitle": "Redefinir palavra-passe",
"resetPasswordOidcOnly": "Este utilizador autentica-se através de um fornecedor externo e não tem palavra-passe.",
"resetPasswordExternalOnly": "Este utilizador autentica-se através de um fornecedor externo e não tem palavra-passe.",
"resetPasswordPlaceholder": "Nova palavra-passe",
"resetPasswordBtn": "Redefinir",
"resetPasswordWorking": "A redefinir...",
@@ -1742,7 +1741,7 @@
"importFailed": "A importação falhou: {{error}}",
"roleUser": "Utilizador",
"authMethodDual": "Autenticação Dupla",
"authMethodOidc": "Externo",
"authMethodExternal": "Externo",
"done": "Concluído",
"apiKeyNameRequired": "Nome da chave é obrigatório",
"apiKeyCreated": "Chave de API \"{{name}}\" criada",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Forțați interacțiunea cu tastatura",
"overrideCredentialUsername": "Suprascrierea acreditărilor de utilizator",
"overrideCredentialUsernameDesc": "Folosește numele de utilizator specificat mai sus în locul numelui de utilizator al acreditării",
"oidcUsernameHint": "Folosește $external.username pentru a completa numele cu care te-ai conectat prin SSO sau LDAP.",
"externalUsernameHint": "Folosește $external.username pentru a completa numele cu care te-ai conectat prin SSO sau LDAP.",
"jumpHostChain": "Lanțul gazdă Jump",
"portKnocking": "Lovitura în port",
"addKnock": "Adăugați port",
@@ -977,7 +977,7 @@
"noSearchResults": "Nicio conexiune nu corespunde căutării dvs.",
"rename": "Redenumiți sesiunea"
},
"terminal": {
"sshAuth": {
"totpRequired": "Autentificare cu doi factori necesară",
"totpCodeLabel": "Cod de verificare",
"totpVerify": "Verifica",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Nu s-a putut verifica codul de resetare",
"failedCompleteReset": "Resetarea parolei nu a reușit",
"invalidTotpCode": "Cod TOTP nevalid",
"failedOidcLogin": "Nu s-a putut porni conectarea externă",
"silentSigninOidcUnavailable": "A fost solicitată conectarea silențioasă, dar nu este configurată nicio conectare externă.",
"failedExternalLogin": "Nu s-a putut porni conectarea externă",
"silentSigninExternalUnavailable": "A fost solicitată conectarea silențioasă, dar nu este configurată nicio conectare externă.",
"failedUserInfo": "Nu s-au putut obține informații despre utilizator după conectare",
"oidcAuthFailed": "Conectarea externă a eșuat",
"externalAuthFailed": "Conectarea externă a eșuat",
"invalidAuthUrl": "Adresă URL de autorizare nevalidă primită de la backend",
"requiredField": "Acest câmp este obligatoriu",
"minLength": "Lungimea minimă este {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Permiteți noilor utilizatori să se înregistreze automat cu un nume de utilizator și o parolă",
"allowPasswordLogin": "Permiteți conectarea cu parolă",
"allowPasswordLoginDesc": "Nume de utilizator/parolă de conectare",
"oidcAutoProvision": "Creați automat conturi externe",
"oidcAutoProvisionDesc": "Creați un cont prima dată când cineva se conectează printr-o conexiune externă, cum ar fi SSO sau LDAP, chiar și atunci când înregistrarea este dezactivată",
"externalAutoProvision": "Creați automat conturi externe",
"externalAutoProvisionDesc": "Creați un cont prima dată când cineva se conectează printr-o conexiune externă, cum ar fi SSO sau LDAP, chiar și atunci când înregistrarea este dezactivată",
"secondFactorAfterExternalLogin": "Solicitați un al doilea factor după autentificările externe (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Dezactivat, un al doilea factor înregistrat este solicitat doar după parolă și alte autentificări locale, la fel ca înainte. Activat, este solicitat după fiecare metodă de autentificare, inclusiv SSO și LDAP.",
"allowPasswordReset": "Permite resetarea parolei",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Fără expirare",
"revokedBadge": "REVOCAT",
"authTypeDual": "Autentificare duală",
"authTypeOidc": "Extern",
"authTypeExternal": "Extern",
"authTypeLocal": "Local",
"adminStatusAdministrator": "Administrator",
"adminStatusRegularUser": "Utilizator obișnuit",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Permiteți atât conectarea externă, cât și cea cu parolă",
"linkAccountTargetUsername": "Nume de utilizator al contului local",
"linkAccountTargetPlaceholder": "Introduceți numele de utilizator al contului local la care doriți să faceți legătura",
"linkAccountOidcUsername": "Nume de utilizator al contului extern",
"linkAccountOidcPlaceholder": "Introduceți numele de utilizator al contului extern pentru care doriți să îl îmbinați",
"linkAccountOidcNotFound": "Nu s-a găsit niciun cont extern cu acel nume de utilizator.",
"linkAccountExternalUsername": "Nume de utilizator al contului extern",
"linkAccountExternalPlaceholder": "Introduceți numele de utilizator al contului extern pentru care doriți să îl îmbinați",
"linkAccountExternalNotFound": "Nu s-a găsit niciun cont extern cu acel nume de utilizator.",
"linkAccounts": "Conectare conturi",
"linkAccountSuccess": "Conturi conectate cu succes",
"linkAccountFailed": "Conectarea conturilor nu a reușit",
@@ -1400,7 +1400,6 @@
"saving": "Economisire...",
"updateRegistrationFailed": "Nu s-a putut actualiza setările de înregistrare",
"updatePasswordLoginFailed": "Setările de conectare cu parola nu au putut fi actualizate.",
"updateOidcAutoProvisionFailed": "Nu s-a putut actualiza setarea de creare automată",
"updateSecondFactorAfterExternalLoginFailed": "Nu s-a putut actualiza setarea celui de-al doilea factor",
"updatePasswordResetFailed": "Setările de resetare a parolei nu au putut fi actualizate.",
"sessionTimeoutRange2": "Timpul de expirare al sesiunii trebuie să fie între 1 și 720 de ore",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "BLOCAT",
"dataLockedNotice": "Datele utilizatorului {{username}}rămân blocate până la următoarea conectare. Gazdele, acreditările și fragmentele de date nu pot fi vizualizate sau editate până atunci.",
"resetPasswordTitle": "Resetare parolă",
"resetPasswordOidcOnly": "Acest utilizator se conectează printr-un furnizor extern și nu are parolă.",
"resetPasswordExternalOnly": "Acest utilizator se conectează printr-un furnizor extern și nu are parolă.",
"resetPasswordPlaceholder": "Parolă Nouă",
"resetPasswordBtn": "Resetare",
"resetPasswordWorking": "Resetare...",
@@ -1742,7 +1741,7 @@
"importFailed": "Importul a eșuat: {{error}}",
"roleUser": "Utilizator",
"authMethodDual": "Autentificare duală",
"authMethodOidc": "Extern",
"authMethodExternal": "Extern",
"done": "Făcut",
"apiKeyNameRequired": "Numele cheii este obligatoriu",
"apiKeyCreated": "Cheia API „{{name}}” a fost creată",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Принудительно Keyboard-Interactive",
"overrideCredentialUsername": "Переопределить имя пользователя учётных данных",
"overrideCredentialUsernameDesc": "Использовать указанное выше имя пользователя вместо имени из учётных данных",
"oidcUsernameHint": "Используйте $external.username, чтобы указать имя, под которым вы вошли в систему через SSO или LDAP.",
"externalUsernameHint": "Используйте $external.username, чтобы указать имя, под которым вы вошли в систему через SSO или LDAP.",
"jumpHostChain": "Цепочка Jump Host",
"portKnocking": "Port Knocking",
"addKnock": "Добавить порт",
@@ -977,7 +977,7 @@
"noSearchResults": "Нет подключений, соответствующих поиску",
"rename": "Переименовать сессию"
},
"terminal": {
"sshAuth": {
"totpRequired": "Требуется двухфакторная аутентификация",
"totpCodeLabel": "Код подтверждения",
"totpVerify": "Подтвердить",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Не удалось проверить код сброса",
"failedCompleteReset": "Не удалось завершить сброс пароля",
"invalidTotpCode": "Неверный TOTP-код",
"failedOidcLogin": "Не удалось запустить внешний вход в систему.",
"silentSigninOidcUnavailable": "Был запрошен бесшумный вход в систему, но внешний вход не настроен.",
"failedExternalLogin": "Не удалось запустить внешний вход в систему.",
"silentSigninExternalUnavailable": "Был запрошен бесшумный вход в систему, но внешний вход не настроен.",
"failedUserInfo": "Не удалось получить информацию о пользователе после входа",
"oidcAuthFailed": "Внешняя авторизация не удалась",
"externalAuthFailed": "Внешняя авторизация не удалась",
"invalidAuthUrl": "Получен некорректный URL авторизации от серверной части",
"requiredField": "Обязательное поле",
"minLength": "Минимальная длина: {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Позволяет новым пользователям регистрироваться, указывая имя пользователя и пароль",
"allowPasswordLogin": "Разрешить вход по паролю",
"allowPasswordLoginDesc": "Вход по имени пользователя и паролю",
"oidcAutoProvision": "Автоматическое создание внешних учетных записей",
"oidcAutoProvisionDesc": "При первом входе пользователя через внешний источник авторизации, такой как SSO или LDAP, учетная запись создается, даже если регистрация отключена.",
"externalAutoProvision": "Автоматическое создание внешних учетных записей",
"externalAutoProvisionDesc": "При первом входе пользователя через внешний источник авторизации, такой как SSO или LDAP, учетная запись создается, даже если регистрация отключена.",
"secondFactorAfterExternalLogin": "После авторизации через внешние сервисы (SSO, GitHub, Google, LDAP) может потребоваться дополнительная авторизация.",
"secondFactorAfterExternalLoginDesc": "Если отключено, зарегистрированный второй фактор аутентификации запрашивается только после ввода пароля и других локальных учетных записей, как и раньше. Если включено, он запрашивается после каждого метода входа в систему, включая SSO и LDAP.",
"allowPasswordReset": "Разрешить сброс пароля",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Без срока действия",
"revokedBadge": "ОТОЗВАН",
"authTypeDual": "Двухфакторная аутентификация",
"authTypeOidc": "Внешний",
"authTypeExternal": "Внешний",
"authTypeLocal": "Локальная",
"adminStatusAdministrator": "Администратор",
"adminStatusRegularUser": "Обычный пользователь",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Разрешите вход как извне, так и по паролю.",
"linkAccountTargetUsername": "Имя пользователя локального аккаунта",
"linkAccountTargetPlaceholder": "Введите имя пользователя локального аккаунта для связывания",
"linkAccountOidcUsername": "Имя пользователя внешней учетной записи",
"linkAccountOidcPlaceholder": "Введите имя пользователя внешней учетной записи для объединения.",
"linkAccountOidcNotFound": "Внешняя учетная запись с таким именем пользователя не найдена.",
"linkAccountExternalUsername": "Имя пользователя внешней учетной записи",
"linkAccountExternalPlaceholder": "Введите имя пользователя внешней учетной записи для объединения.",
"linkAccountExternalNotFound": "Внешняя учетная запись с таким именем пользователя не найдена.",
"linkAccounts": "Связать аккаунты",
"linkAccountSuccess": "Аккаунты успешно связаны",
"linkAccountFailed": "Не удалось связать аккаунты",
@@ -1400,7 +1400,6 @@
"saving": "Сохранение...",
"updateRegistrationFailed": "Не удалось обновить настройки регистрации",
"updatePasswordLoginFailed": "Не удалось обновить настройки входа по паролю",
"updateOidcAutoProvisionFailed": "Не удалось обновить настройку автоматического создания.",
"updateSecondFactorAfterExternalLoginFailed": "Не удалось обновить настройки второго фактора.",
"updatePasswordResetFailed": "Не удалось обновить настройки сброса пароля",
"sessionTimeoutRange2": "Тайм-аут сеанса должен быть от 1 до 720 часов",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "ЗАБЛОКИРОВАНО",
"dataLockedNotice": "Данные {{username}} остаются заблокированными до следующего входа. Хосты, учётные данные и сниппеты нельзя просмотреть или отредактировать до тех пор.",
"resetPasswordTitle": "Сброс пароля",
"resetPasswordOidcOnly": "Этот пользователь входит через внешнего провайдера и не имеет пароля.",
"resetPasswordExternalOnly": "Этот пользователь входит через внешнего провайдера и не имеет пароля.",
"resetPasswordPlaceholder": "Новый пароль",
"resetPasswordBtn": "Сбросить",
"resetPasswordWorking": "Сброс...",
@@ -1742,7 +1741,7 @@
"importFailed": "Импорт не удался: {{error}}",
"roleUser": "Пользователь",
"authMethodDual": "Двойная аутентификация",
"authMethodOidc": "Внешний",
"authMethodExternal": "Внешний",
"done": "Готово",
"apiKeyNameRequired": "Требуется имя ключа",
"apiKeyCreated": "API-ключ «{{name}}» создан",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Присилна интерактивност тастатуре",
"overrideCredentialUsername": "Замени корисничко име акредитива",
"overrideCredentialUsernameDesc": "Користите корисничко име наведено горе уместо корисничког имена акредитива",
"oidcUsernameHint": "Користите $external.корисничко име да бисте попунили име са којим сте се пријавили путем SSO или LDAP.",
"externalUsernameHint": "Користите $external.корисничко име да бисте попунили име са којим сте се пријавили путем SSO или LDAP.",
"jumpHostChain": "Скок ланца хоста",
"portKnocking": "Лук Нокинг",
"addKnock": "Додај порт",
@@ -977,7 +977,7 @@
"noSearchResults": "Ниједна веза не одговара вашој претрази",
"rename": "Преименуј сесију"
},
"terminal": {
"sshAuth": {
"totpRequired": "Потребна је двофакторска аутентификација",
"totpCodeLabel": "Верификациони код",
"totpVerify": "Верификуј",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Није успело потврђивање кода за ресетовање",
"failedCompleteReset": "Ресетовање лозинке није успело",
"invalidTotpCode": "Неважећи TOTP код",
"failedOidcLogin": "Покретање екстерног пријављивања није успело",
"silentSigninOidcUnavailable": "Затражено је тихо пријављивање, али није подешено спољно пријављивање.",
"failedExternalLogin": "Покретање екстерног пријављивања није успело",
"silentSigninExternalUnavailable": "Затражено је тихо пријављивање, али није подешено спољно пријављивање.",
"failedUserInfo": "Није успело добијање корисничких података након пријаве",
"oidcAuthFailed": "Спољно пријављивање није успело",
"externalAuthFailed": "Спољно пријављивање није успело",
"invalidAuthUrl": "Неважећи URL за ауторизацију примљен од бекенда",
"requiredField": "Ово поље је обавезно",
"minLength": "Минимална дужина је {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Дозволите новим корисницима да се сами региструју помоћу корисничког имена и лозинке",
"allowPasswordLogin": "Дозволи пријаву лозинком",
"allowPasswordLoginDesc": "Пријава корисничким именом/лозинком",
"oidcAutoProvision": "Аутоматски креирај екстерне налоге",
"oidcAutoProvisionDesc": "Креирајте налог први пут када се неко пријави путем екстерног пријављивања као што је SSO или LDAP, чак и када је регистрација искључена",
"externalAutoProvision": "Аутоматски креирај екстерне налоге",
"externalAutoProvisionDesc": "Креирајте налог први пут када се неко пријави путем екстерног пријављивања као што је SSO или LDAP, чак и када је регистрација искључена",
"secondFactorAfterExternalLogin": "Захтевајте други фактор након екстерних пријављивања (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Искључено, регистровани други фактор се тражи само након лозинке и других локалних пријављивања, исто као и пре. Укључено, тражи се након сваке методе пријављивања, укључујући SSO и LDAP.",
"allowPasswordReset": "Дозволи ресетовање лозинке",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Без истека",
"revokedBadge": "ПОНИШТЕНО",
"authTypeDual": "Двострука аутентификација",
"authTypeOidc": "Спољни",
"authTypeExternal": "Спољни",
"authTypeLocal": "Локално",
"adminStatusAdministrator": "Администратор",
"adminStatusRegularUser": "Редован корисник",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Дозволи и спољашње пријављивање и пријављивање лозинком",
"linkAccountTargetUsername": "Корисничко име локалног налога",
"linkAccountTargetPlaceholder": "Унесите корисничко име локалног налога за повезивање",
"linkAccountOidcUsername": "Корисничко име екстерног налога",
"linkAccountOidcPlaceholder": "Унесите корисничко име само за екстерни налог за спајање",
"linkAccountOidcNotFound": "Није пронађен ниједан спољни налог са тим корисничким именом",
"linkAccountExternalUsername": "Корисничко име екстерног налога",
"linkAccountExternalPlaceholder": "Унесите корисничко име само за екстерни налог за спајање",
"linkAccountExternalNotFound": "Није пронађен ниједан спољни налог са тим корисничким именом",
"linkAccounts": "Повежи налоге",
"linkAccountSuccess": "Налози су успешно повезани",
"linkAccountFailed": "Повезивање налога није успело",
@@ -1400,7 +1400,6 @@
"saving": "Чување...",
"updateRegistrationFailed": "Ажурирање подешавања регистрације није успело",
"updatePasswordLoginFailed": "Ажурирање подешавања за пријаву лозинком није успело",
"updateOidcAutoProvisionFailed": "Ажурирање подешавања аутоматског креирања није успело",
"updateSecondFactorAfterExternalLoginFailed": "Ажурирање подешавања другог фактора није успело",
"updatePasswordResetFailed": "Ажурирање подешавања за ресетовање лозинке није успело",
"sessionTimeoutRange2": "Временско ограничење сесије мора бити између 1 и 720 сати",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "ЗАКЉУЧАНО",
"dataLockedNotice": "Подаци корисника {{username}}остају закључани до њиховог следећег пријављивања. Њихови хостови, акредитиви и исечци не могу бити прегледани или уређивани до тада.",
"resetPasswordTitle": "Ресетуј лозинку",
"resetPasswordOidcOnly": "Овај корисник се пријављује преко екстерног провајдера и нема лозинку.",
"resetPasswordExternalOnly": "Овај корисник се пријављује преко екстерног провајдера и нема лозинку.",
"resetPasswordPlaceholder": "Нова лозинка",
"resetPasswordBtn": "Ресетуј",
"resetPasswordWorking": "Ресетовање...",
@@ -1742,7 +1741,7 @@
"importFailed": "Увоз није успео: {{error}}",
"roleUser": "Корисник",
"authMethodDual": "Двострука аутентификација",
"authMethodOidc": "Спољни",
"authMethodExternal": "Спољни",
"done": "Готово",
"apiKeyNameRequired": "Назив кључа је обавезан",
"apiKeyCreated": "API кључ „{{name}}“ је креиран",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Tvinga tangentbordsinteraktivt",
"overrideCredentialUsername": "Åsidosätt användarnamn för inloggningsuppgifter",
"overrideCredentialUsernameDesc": "Använd användarnamnet som anges ovan istället för användarnamnet för inloggningsuppgifterna",
"oidcUsernameHint": "Använd $external.username för att fylla i namnet du loggade in med via SSO eller LDAP.",
"externalUsernameHint": "Använd $external.username för att fylla i namnet du loggade in med via SSO eller LDAP.",
"jumpHostChain": "Hoppa värdkedja",
"portKnocking": "Port Knocking",
"addKnock": "Lägg till port",
@@ -977,7 +977,7 @@
"noSearchResults": "Inga kontakter matchar din sökning",
"rename": "Byt namn på sessionen"
},
"terminal": {
"sshAuth": {
"totpRequired": "Tvåfaktorsautentisering krävs",
"totpCodeLabel": "Verifieringskod",
"totpVerify": "Kontrollera",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Misslyckades med att verifiera återställningskoden",
"failedCompleteReset": "Misslyckades med att slutföra återställning av lösenord",
"invalidTotpCode": "Ogiltig TOTP-kod",
"failedOidcLogin": "Misslyckades med att starta extern inloggning",
"silentSigninOidcUnavailable": "Tyst inloggning begärdes, men ingen extern inloggning är konfigurerad.",
"failedExternalLogin": "Misslyckades med att starta extern inloggning",
"silentSigninExternalUnavailable": "Tyst inloggning begärdes, men ingen extern inloggning är konfigurerad.",
"failedUserInfo": "Misslyckades med att hämta användarinformation efter inloggning",
"oidcAuthFailed": "Extern inloggning misslyckades",
"externalAuthFailed": "Extern inloggning misslyckades",
"invalidAuthUrl": "Ogiltig auktoriserings-URL mottagen från backend",
"requiredField": "Detta fält är obligatoriskt",
"minLength": "Minimilängden är {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Låt nya användare registrera sig själva med användarnamn och lösenord",
"allowPasswordLogin": "Tillåt lösenordsinloggning",
"allowPasswordLoginDesc": "Användarnamn/lösenord inloggning",
"oidcAutoProvision": "Skapa externa konton automatiskt",
"oidcAutoProvisionDesc": "Skapa ett konto första gången någon loggar in via en extern inloggning som SSO eller LDAP, även när registreringen är avstängd",
"externalAutoProvision": "Skapa externa konton automatiskt",
"externalAutoProvisionDesc": "Skapa ett konto första gången någon loggar in via en extern inloggning som SSO eller LDAP, även när registreringen är avstängd",
"secondFactorAfterExternalLogin": "Be om en andra faktor efter externa inloggningar (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Av, en registrerad andra faktor efterfrågas endast efter lösenord och andra lokala inloggningar, samma som tidigare. På, den efterfrågas efter varje inloggningsmetod, inklusive SSO och LDAP.",
"allowPasswordReset": "Tillåt lösenordsåterställning",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Ingen utgångsdatum",
"revokedBadge": "ÅTERKALLAD",
"authTypeDual": "Dubbel autentisering",
"authTypeOidc": "Extern",
"authTypeExternal": "Extern",
"authTypeLocal": "Lokal",
"adminStatusAdministrator": "Administratör",
"adminStatusRegularUser": "Vanlig användare",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Tillåt både extern inloggning och lösenordsinloggning",
"linkAccountTargetUsername": "Användarnamn för lokalt konto",
"linkAccountTargetPlaceholder": "Ange användarnamnet för det lokala kontot som ska länkas till",
"linkAccountOidcUsername": "Användarnamn för externt konto",
"linkAccountOidcPlaceholder": "Ange användarnamnet för det endast externa kontot som ska sammanfogas",
"linkAccountOidcNotFound": "Inget externt konto hittades med det användarnamnet",
"linkAccountExternalUsername": "Användarnamn för externt konto",
"linkAccountExternalPlaceholder": "Ange användarnamnet för det endast externa kontot som ska sammanfogas",
"linkAccountExternalNotFound": "Inget externt konto hittades med det användarnamnet",
"linkAccounts": "Länka konton",
"linkAccountSuccess": "Konton har länkats",
"linkAccountFailed": "Misslyckades med att länka konton",
@@ -1400,7 +1400,6 @@
"saving": "Sparande...",
"updateRegistrationFailed": "Misslyckades med att uppdatera registreringsinställningen",
"updatePasswordLoginFailed": "Misslyckades med att uppdatera lösenordsinställningen för inloggning",
"updateOidcAutoProvisionFailed": "Misslyckades med att uppdatera inställningen för automatiskt skapande",
"updateSecondFactorAfterExternalLoginFailed": "Misslyckades med att uppdatera inställningen för den andra faktorn",
"updatePasswordResetFailed": "Misslyckades med att uppdatera inställningen för lösenordsåterställning",
"sessionTimeoutRange2": "Sessionens tidsgräns måste vara mellan 1 och 720 timmar",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "LÅST",
"dataLockedNotice": "{{username}}s data förblir låsta tills de loggar in nästa gång. Deras värdar, inloggningsuppgifter och utdrag kan inte visas eller redigeras förrän då.",
"resetPasswordTitle": "Återställ lösenord",
"resetPasswordOidcOnly": "Den här användaren loggar in via en extern leverantör och har inget lösenord.",
"resetPasswordExternalOnly": "Den här användaren loggar in via en extern leverantör och har inget lösenord.",
"resetPasswordPlaceholder": "Nytt lösenord",
"resetPasswordBtn": "Återställa",
"resetPasswordWorking": "Återställer...",
@@ -1742,7 +1741,7 @@
"importFailed": "Importen misslyckades: {{error}}",
"roleUser": "Användare",
"authMethodDual": "Dubbel autentisering",
"authMethodOidc": "Extern",
"authMethodExternal": "Extern",
"done": "Gjort",
"apiKeyNameRequired": "Nyckelnamn krävs",
"apiKeyCreated": "API-nyckeln \"{{name}}\" skapad",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "แป้นพิมพ์แบบอินเทอร์แอคทีฟของ Force",
"overrideCredentialUsername": "แทนที่ชื่อผู้ใช้ข้อมูลประจำตัว",
"overrideCredentialUsernameDesc": "ใช้ชื่อผู้ใช้ที่ระบุไว้ข้างต้นแทนชื่อผู้ใช้ของข้อมูลประจำตัว",
"oidcUsernameHint": "ใช้ $external.username เพื่อกรอกชื่อที่คุณใช้เข้าสู่ระบบผ่าน SSO หรือ LDAP",
"externalUsernameHint": "ใช้ $external.username เพื่อกรอกชื่อที่คุณใช้เข้าสู่ระบบผ่าน SSO หรือ LDAP",
"jumpHostChain": "ห่วงโซ่ Jump Host",
"portKnocking": "การเคาะพอร์ต",
"addKnock": "เพิ่มพอร์ต",
@@ -977,7 +977,7 @@
"noSearchResults": "ไม่พบผลลัพธ์ที่ตรงกับการค้นหาของคุณ",
"rename": "เปลี่ยนชื่อเซสชัน"
},
"terminal": {
"sshAuth": {
"totpRequired": "ต้องใช้การยืนยันตัวตนสองขั้นตอน",
"totpCodeLabel": "รหัสยืนยัน",
"totpVerify": "ตรวจสอบ",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "ไม่สามารถตรวจสอบรหัสรีเซ็ตได้",
"failedCompleteReset": "การรีเซ็ตรหัสผ่านล้มเหลว",
"invalidTotpCode": "รหัส TOTP ไม่ถูกต้อง",
"failedOidcLogin": "ไม่สามารถเริ่มต้นการเข้าสู่ระบบจากภายนอกได้",
"silentSigninOidcUnavailable": "มีการร้องขอการเข้าสู่ระบบแบบเงียบ แต่ไม่ได้ตั้งค่าการเข้าสู่ระบบภายนอกไว้",
"failedExternalLogin": "ไม่สามารถเริ่มต้นการเข้าสู่ระบบจากภายนอกได้",
"silentSigninExternalUnavailable": "มีการร้องขอการเข้าสู่ระบบแบบเงียบ แต่ไม่ได้ตั้งค่าการเข้าสู่ระบบภายนอกไว้",
"failedUserInfo": "ไม่สามารถดึงข้อมูลผู้ใช้ได้หลังจากเข้าสู่ระบบ",
"oidcAuthFailed": "การเข้าสู่ระบบจากภายนอกล้มเหลว",
"externalAuthFailed": "การเข้าสู่ระบบจากภายนอกล้มเหลว",
"invalidAuthUrl": "ได้รับ URL การอนุญาตที่ไม่ถูกต้องจากแบ็กเอนด์",
"requiredField": "ช่องนี้จำเป็นต้องกรอก",
"minLength": "ความยาวขั้นต่ำคือ {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "อนุญาตให้ผู้ใช้ใหม่ลงทะเบียนด้วยตนเองโดยใช้ชื่อผู้ใช้และรหัสผ่าน",
"allowPasswordLogin": "อนุญาตให้เข้าสู่ระบบด้วยรหัสผ่าน",
"allowPasswordLoginDesc": "เข้าสู่ระบบด้วยชื่อผู้ใช้/รหัสผ่าน",
"oidcAutoProvision": "สร้างบัญชีภายนอกโดยอัตโนมัติ",
"oidcAutoProvisionDesc": "สร้างบัญชีผู้ใช้ในครั้งแรกที่มีคนเข้าสู่ระบบผ่านการเข้าสู่ระบบภายนอก เช่น SSO หรือ LDAP แม้ว่าการลงทะเบียนจะปิดอยู่ก็ตาม",
"externalAutoProvision": "สร้างบัญชีภายนอกโดยอัตโนมัติ",
"externalAutoProvisionDesc": "สร้างบัญชีผู้ใช้ในครั้งแรกที่มีคนเข้าสู่ระบบผ่านการเข้าสู่ระบบภายนอก เช่น SSO หรือ LDAP แม้ว่าการลงทะเบียนจะปิดอยู่ก็ตาม",
"secondFactorAfterExternalLogin": "ขอให้ยืนยันตัวตนสองขั้นตอนหลังจากเข้าสู่ระบบจากภายนอก (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "หากปิดใช้งาน ระบบจะขอการยืนยันตัวตนสองขั้นตอนที่ลงทะเบียนไว้เฉพาะหลังจากป้อนรหัสผ่านและการเข้าสู่ระบบภายในเครื่องอื่นๆ เท่านั้น เช่นเดียวกับก่อนหน้านี้ หากเปิดใช้งาน ระบบจะขอการยืนยันตัวตนสองขั้นตอนหลังจากวิธีการเข้าสู่ระบบทุกวิธี รวมถึง SSO และ LDAP",
"allowPasswordReset": "อนุญาตให้รีเซ็ตรหัสผ่าน",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "ไม่มีวันหมดอายุ",
"revokedBadge": "เพิกถอน",
"authTypeDual": "การตรวจสอบสิทธิ์แบบคู่",
"authTypeOidc": "ภายนอก",
"authTypeExternal": "ภายนอก",
"authTypeLocal": "ท้องถิ่น",
"adminStatusAdministrator": "ผู้ดูแลระบบ",
"adminStatusRegularUser": "ผู้ใช้ทั่วไป",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "อนุญาตให้เข้าสู่ระบบทั้งจากภายนอกและโดยใช้รหัสผ่าน",
"linkAccountTargetUsername": "ชื่อผู้ใช้บัญชีภายในเครื่อง",
"linkAccountTargetPlaceholder": "ป้อนชื่อผู้ใช้บัญชีท้องถิ่นเพื่อเชื่อมโยง",
"linkAccountOidcUsername": "ชื่อผู้ใช้บัญชีภายนอก",
"linkAccountOidcPlaceholder": "ป้อนชื่อผู้ใช้บัญชีภายนอกเพื่อผสานรวม",
"linkAccountOidcNotFound": "ไม่พบบัญชีผู้ใช้ภายนอกที่มีชื่อผู้ใช้ดังกล่าว",
"linkAccountExternalUsername": "ชื่อผู้ใช้บัญชีภายนอก",
"linkAccountExternalPlaceholder": "ป้อนชื่อผู้ใช้บัญชีภายนอกเพื่อผสานรวม",
"linkAccountExternalNotFound": "ไม่พบบัญชีผู้ใช้ภายนอกที่มีชื่อผู้ใช้ดังกล่าว",
"linkAccounts": "เชื่อมโยงบัญชี",
"linkAccountSuccess": "เชื่อมโยงบัญชีสำเร็จแล้ว",
"linkAccountFailed": "ไม่สามารถเชื่อมโยงบัญชีได้",
@@ -1400,7 +1400,6 @@
"saving": "ประหยัด...",
"updateRegistrationFailed": "ไม่สามารถอัปเดตการตั้งค่าการลงทะเบียนได้",
"updatePasswordLoginFailed": "ไม่สามารถอัปเดตการตั้งค่าการเข้าสู่ระบบด้วยรหัสผ่านได้",
"updateOidcAutoProvisionFailed": "ไม่สามารถอัปเดตการตั้งค่าการสร้างอัตโนมัติได้",
"updateSecondFactorAfterExternalLoginFailed": "ไม่สามารถอัปเดตการตั้งค่าปัจจัยยืนยันตัวตนที่สองได้",
"updatePasswordResetFailed": "ไม่สามารถอัปเดตการตั้งค่าการรีเซ็ตรหัสผ่านได้",
"sessionTimeoutRange2": "เวลาหมดอายุของเซสชันต้องอยู่ระหว่าง 1 ถึง 720 ชั่วโมง",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "ล็อกอยู่",
"dataLockedNotice": "ข้อมูลของ {{username}}จะถูกล็อกไว้จนกว่าพวกเขาจะเข้าสู่ระบบครั้งถัดไป จะไม่สามารถดูหรือแก้ไขโฮสต์ ข้อมูลประจำตัว และข้อความย่อของพวกเขาได้จนกว่าจะถึงเวลานั้น",
"resetPasswordTitle": "รีเซ็ตรหัสผ่าน",
"resetPasswordOidcOnly": "ผู้ใช้รายนี้เข้าสู่ระบบผ่านผู้ให้บริการภายนอกและไม่มีรหัสผ่าน",
"resetPasswordExternalOnly": "ผู้ใช้รายนี้เข้าสู่ระบบผ่านผู้ให้บริการภายนอกและไม่มีรหัสผ่าน",
"resetPasswordPlaceholder": "รหัสผ่านใหม่",
"resetPasswordBtn": "รีเซ็ต",
"resetPasswordWorking": "กำลังรีเซ็ต...",
@@ -1742,7 +1741,7 @@
"importFailed": "การนำเข้าล้มเหลว: {{error}}",
"roleUser": "ผู้ใช้",
"authMethodDual": "การตรวจสอบสิทธิ์แบบคู่",
"authMethodOidc": "ภายนอก",
"authMethodExternal": "ภายนอก",
"done": "เสร็จแล้ว",
"apiKeyNameRequired": "จำเป็นต้องระบุชื่อคีย์",
"apiKeyCreated": "สร้างคีย์ API \"{{name}}\" แล้ว",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Klavye Etkileşimini Zorunlu Kıl",
"overrideCredentialUsername": "Kimlik Kullanıcı Adını Geçersiz Kıl",
"overrideCredentialUsernameDesc": "Kimlik bilgisinin kullanıcı adı yerine yukarıda belirtilen kullanıcı adını kullan",
"oidcUsernameHint": "SSO veya LDAP aracılığıyla oturum açtığınız adı girmek için $external.username ifadesini kullanın.",
"externalUsernameHint": "SSO veya LDAP aracılığıyla oturum açtığınız adı girmek için $external.username ifadesini kullanın.",
"jumpHostChain": "Atlama Sunucu Zinciri",
"portKnocking": "Port Vuruşu",
"addKnock": "Port Ekle",
@@ -977,7 +977,7 @@
"noSearchResults": "Aramanızla eşleşen bağlantı yok",
"rename": "Oturumu yeniden adlandır"
},
"terminal": {
"sshAuth": {
"totpRequired": "İki Faktörlü Kimlik Doğrulama Gerekli",
"totpCodeLabel": "Doğrulama Kodu",
"totpVerify": "Doğrula",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Sıfırlama kodu doğrulanamadı",
"failedCompleteReset": "Şifre sıfırlama tamamlanamadı",
"invalidTotpCode": "Geçersiz TOTP kodu",
"failedOidcLogin": "Harici oturum açma işlemi başlatılamadı.",
"silentSigninOidcUnavailable": "Sessiz oturum açma isteği yapıldı, ancak harici oturum açma ayarı yapılmamış.",
"failedExternalLogin": "Harici oturum açma işlemi başlatılamadı.",
"silentSigninExternalUnavailable": "Sessiz oturum açma isteği yapıldı, ancak harici oturum açma ayarı yapılmamış.",
"failedUserInfo": "Giriş sonrası kullanıcı bilgileri alınamadı",
"oidcAuthFailed": "Harici oturum açma başarısız oldu",
"externalAuthFailed": "Harici oturum açma başarısız oldu",
"invalidAuthUrl": "Arka uçtan geçersiz yetkilendirme URL'si alındı",
"requiredField": "Bu alan zorunludur",
"minLength": "Minimum uzunluk {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Yeni kullanıcıların kullanıcı adı ve parola ile kendilerini kaydetmesine izin ver",
"allowPasswordLogin": "Parola ile Girişe İzin Ver",
"allowPasswordLoginDesc": "Kullanıcı adı/parola ile giriş",
"oidcAutoProvision": "Harici hesapları otomatik olarak oluştur",
"oidcAutoProvisionDesc": "Kayıt kapalı olsa bile, SSO veya LDAP gibi harici bir giriş yöntemiyle ilk kez oturum açıldığında bir hesap oluşturulur.",
"externalAutoProvision": "Harici hesapları otomatik olarak oluştur",
"externalAutoProvisionDesc": "Kayıt kapalı olsa bile, SSO veya LDAP gibi harici bir giriş yöntemiyle ilk kez oturum açıldığında bir hesap oluşturulur.",
"secondFactorAfterExternalLogin": "Harici oturum açma işlemlerinden sonra ikinci bir doğrulama faktörü isteyin (SSO, GitHub, Google, LDAP).",
"secondFactorAfterExternalLoginDesc": "Kapalı modda, kayıtlı ikinci faktör yalnızca parola ve diğer yerel oturum açma işlemlerinden sonra istenir, tıpkı eskisi gibi. Açık modda ise, SSO ve LDAP dahil olmak üzere her oturum açma yönteminden sonra istenir.",
"allowPasswordReset": "Parola Sıfırlamaya İzin Ver",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Süresiz",
"revokedBadge": "İPTAL EDİLDİ",
"authTypeDual": "Çift Doğrulama",
"authTypeOidc": "Harici",
"authTypeExternal": "Harici",
"authTypeLocal": "Yerel",
"adminStatusAdministrator": "Yönetici",
"adminStatusRegularUser": "Normal Kullanıcı",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Hem harici giriş hem de parola ile giriş yapılmasına izin verin.",
"linkAccountTargetUsername": "Yerel Hesap Kullanıcı Adı",
"linkAccountTargetPlaceholder": "Bağlanacak yerel hesap kullanıcı adını girin",
"linkAccountOidcUsername": "Harici Hesap Kullanıcı Adı",
"linkAccountOidcPlaceholder": "Birleştirmek istediğiniz yalnızca harici hesap kullanıcı adını girin.",
"linkAccountOidcNotFound": "Bu kullanıcı adıyla harici erişime açık bir hesap bulunamadı.",
"linkAccountExternalUsername": "Harici Hesap Kullanıcı Adı",
"linkAccountExternalPlaceholder": "Birleştirmek istediğiniz yalnızca harici hesap kullanıcı adını girin.",
"linkAccountExternalNotFound": "Bu kullanıcı adıyla harici erişime açık bir hesap bulunamadı.",
"linkAccounts": "Hesapları Bağla",
"linkAccountSuccess": "Hesaplar başarıyla bağlandı",
"linkAccountFailed": "Hesaplar bağlanamadı",
@@ -1400,7 +1400,6 @@
"saving": "Kaydediliyor...",
"updateRegistrationFailed": "Kayıt ayarı güncellenemedi",
"updatePasswordLoginFailed": "Parola girişi ayarı güncellenemedi",
"updateOidcAutoProvisionFailed": "Otomatik oluşturma ayarı güncellenemedi.",
"updateSecondFactorAfterExternalLoginFailed": "İkinci faktör ayarı güncellenemedi.",
"updatePasswordResetFailed": "Parola sıfırlama ayarı güncellenemedi",
"sessionTimeoutRange2": "Oturum zaman aşımı 1 ile 720 saat arasında olmalıdır",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "KİLİTLİ",
"dataLockedNotice": "{{username}} kullanıcısının verileri, bir sonraki oturum açana kadar kilitli kalır. Bu süre zarfında ana makineleri, kimlik bilgileri ve kod parçacıkları görüntülenemez veya düzenlenemez.",
"resetPasswordTitle": "Parolayı Sıfırla",
"resetPasswordOidcOnly": "Bu kullanıcı harici bir sağlayıcı üzerinden oturum açar ve parolası yoktur.",
"resetPasswordExternalOnly": "Bu kullanıcı harici bir sağlayıcı üzerinden oturum açar ve parolası yoktur.",
"resetPasswordPlaceholder": "Yeni parola",
"resetPasswordBtn": "Sıfırla",
"resetPasswordWorking": "Sıfırlanıyor...",
@@ -1742,7 +1741,7 @@
"importFailed": "İçe aktarma başarısız oldu: {{error}}",
"roleUser": "Kullanıcı",
"authMethodDual": "Çift Kimlik Doğrulama",
"authMethodOidc": "Harici",
"authMethodExternal": "Harici",
"done": "Tamam",
"apiKeyNameRequired": "Anahtar adı zorunludur",
"apiKeyCreated": "API anahtarı \"{{name}}\" oluşturuldu",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Примусова інтерактивна клавіатура",
"overrideCredentialUsername": "Перезаписати ім'я користувача облікових даних",
"overrideCredentialUsernameDesc": "Використовуйте вказане вище ім'я користувача замість імені користувача облікових даних",
"oidcUsernameHint": "Використовуйте $external.username, щоб ввести ім’я користувача, яке ви використовували для входу через SSO або LDAP.",
"externalUsernameHint": "Використовуйте $external.username, щоб ввести ім’я користувача, яке ви використовували для входу через SSO або LDAP.",
"jumpHostChain": "Ланцюг хоста стрибка",
"portKnocking": "Порт-Нокінґ",
"addKnock": "Додати порт",
@@ -977,7 +977,7 @@
"noSearchResults": "Немає з'єднань, що відповідають вашому пошуку",
"rename": "Перейменувати сеанс"
},
"terminal": {
"sshAuth": {
"totpRequired": "Потрібна двофакторна автентифікація",
"totpCodeLabel": "Код підтвердження",
"totpVerify": "Перевірити",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Не вдалося перевірити код скидання",
"failedCompleteReset": "Не вдалося завершити скидання пароля",
"invalidTotpCode": "Недійсний код TOTP",
"failedOidcLogin": "Не вдалося розпочати зовнішній вхід",
"silentSigninOidcUnavailable": "Було надіслано запит на тихий вхід, але зовнішній вхід не налаштовано.",
"failedExternalLogin": "Не вдалося розпочати зовнішній вхід",
"silentSigninExternalUnavailable": "Було надіслано запит на тихий вхід, але зовнішній вхід не налаштовано.",
"failedUserInfo": "Не вдалося отримати інформацію про користувача після входу",
"oidcAuthFailed": "Зовнішній вхід не вдалося",
"externalAuthFailed": "Зовнішній вхід не вдалося",
"invalidAuthUrl": "Недійсна URL-адреса авторизації отримана від серверної частини",
"requiredField": "Це поле обов'язкове",
"minLength": "Мінімальна довжина {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Дозвольте новим користувачам самостійно реєструватися, використовуючи ім'я користувача та пароль",
"allowPasswordLogin": "Дозволити вхід з паролем",
"allowPasswordLoginDesc": "Вхід за ім'ям користувача/паролем",
"oidcAutoProvision": "Автоматичне створення зовнішніх облікових записів",
"oidcAutoProvisionDesc": "Створіть обліковий запис під час першого входу через зовнішній логін, такий як SSO або LDAP, навіть якщо реєстрацію вимкнено.",
"externalAutoProvision": "Автоматичне створення зовнішніх облікових записів",
"externalAutoProvisionDesc": "Створіть обліковий запис під час першого входу через зовнішній логін, такий як SSO або LDAP, навіть якщо реєстрацію вимкнено.",
"secondFactorAfterExternalLogin": "Запитувати другий фактор після зовнішніх входів (SSO, GitHub, Google, LDAP)",
"secondFactorAfterExternalLoginDesc": "Вимкнено, зареєстрований другий фактор запитується лише після пароля та інших локальних входів, як і раніше. Увімкнено, запитується після кожного методу входу, включаючи SSO та LDAP.",
"allowPasswordReset": "Дозволити скидання пароля",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Без терміну дії",
"revokedBadge": "СКАСОВАНО",
"authTypeDual": "Подвійна автентифікація",
"authTypeOidc": "Зовнішній",
"authTypeExternal": "Зовнішній",
"authTypeLocal": "Місцевий",
"adminStatusAdministrator": "Адміністратор",
"adminStatusRegularUser": "Звичайний користувач",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Дозволити як зовнішній вхід, так і вхід за паролем",
"linkAccountTargetUsername": "Ім'я користувача локального облікового запису",
"linkAccountTargetPlaceholder": "Введіть ім'я користувача локального облікового запису для підключення",
"linkAccountOidcUsername": "Ім'я користувача зовнішнього облікового запису",
"linkAccountOidcPlaceholder": "Введіть ім'я користувача зовнішнього облікового запису для об'єднання",
"linkAccountOidcNotFound": "Зовнішній обліковий запис із цим іменем користувача не знайдено",
"linkAccountExternalUsername": "Ім'я користувача зовнішнього облікового запису",
"linkAccountExternalPlaceholder": "Введіть ім'я користувача зовнішнього облікового запису для об'єднання",
"linkAccountExternalNotFound": "Зовнішній обліковий запис із цим іменем користувача не знайдено",
"linkAccounts": "Зв'язати облікові записи",
"linkAccountSuccess": "Облікові записи успішно пов’язано",
"linkAccountFailed": "Не вдалося зв'язати облікові записи",
@@ -1400,7 +1400,6 @@
"saving": "Збереження...",
"updateRegistrationFailed": "Не вдалося оновити налаштування реєстрації",
"updatePasswordLoginFailed": "Не вдалося оновити налаштування входу з паролем",
"updateOidcAutoProvisionFailed": "Не вдалося оновити налаштування автоматичного створення",
"updateSecondFactorAfterExternalLoginFailed": "Не вдалося оновити налаштування другого фактора",
"updatePasswordResetFailed": "Не вдалося оновити налаштування скидання пароля",
"sessionTimeoutRange2": "Час очікування сеансу має бути від 1 до 720 годин",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "ЗАБЛОКОВАНО",
"dataLockedNotice": "Дані {{username}}залишаються заблокованими до наступного входу в систему. До того часу їхні хости, облікові дані та фрагменти не можна переглядати або редагувати.",
"resetPasswordTitle": "Скинути пароль",
"resetPasswordOidcOnly": "Цей користувач входить через зовнішнього постачальника та не має пароля.",
"resetPasswordExternalOnly": "Цей користувач входить через зовнішнього постачальника та не має пароля.",
"resetPasswordPlaceholder": "Новий пароль",
"resetPasswordBtn": "Скинути",
"resetPasswordWorking": "Скидання...",
@@ -1742,7 +1741,7 @@
"importFailed": "Помилка імпорту: {{error}}",
"roleUser": "Користувач",
"authMethodDual": "Подвійна автентифікація",
"authMethodOidc": "Зовнішній",
"authMethodExternal": "Зовнішній",
"done": "Готово",
"apiKeyNameRequired": "Потрібне ім'я ключа",
"apiKeyCreated": "Ключ API \"{{name}}\" створено",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "Buộc dùng Keyboard-Interactive",
"overrideCredentialUsername": "Ghi đè tên đăng nhập thông tin xác thực",
"overrideCredentialUsernameDesc": "Sử dụng tên đăng nhập ở trên thay vì tên trong thông tin xác thực",
"oidcUsernameHint": "Sử dụng $external.username để điền tên bạn đã đăng nhập thông qua SSO hoặc LDAP.",
"externalUsernameHint": "Sử dụng $external.username để điền tên bạn đã đăng nhập thông qua SSO hoặc LDAP.",
"jumpHostChain": "Chuỗi máy chủ trung gian",
"portKnocking": "Port Knocking",
"addKnock": "Thêm cổng",
@@ -977,7 +977,7 @@
"noSearchResults": "Không có kết nối nào khớp với tìm kiếm của bạn",
"rename": "Đổi tên phiên"
},
"terminal": {
"sshAuth": {
"totpRequired": "Yêu cầu xác thực hai yếu tố",
"totpCodeLabel": "Mã xác minh",
"totpVerify": "Xác minh",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "Không thể xác minh mã đặt lại",
"failedCompleteReset": "Không thể hoàn tất đặt lại mật khẩu",
"invalidTotpCode": "Mã TOTP không hợp lệ",
"failedOidcLogin": "Đăng nhập bằng tài khoản bên ngoài không thành công.",
"silentSigninOidcUnavailable": "Đã yêu cầu đăng nhập im lặng, nhưng chưa thiết lập đăng nhập bằng tài khoản bên ngoài.",
"failedExternalLogin": "Đăng nhập bằng tài khoản bên ngoài không thành công.",
"silentSigninExternalUnavailable": "Đã yêu cầu đăng nhập im lặng, nhưng chưa thiết lập đăng nhập bằng tài khoản bên ngoài.",
"failedUserInfo": "Không thể lấy thông tin người dùng sau khi đăng nhập",
"oidcAuthFailed": "Đăng nhập bên ngoài không thành công",
"externalAuthFailed": "Đăng nhập bên ngoài không thành công",
"invalidAuthUrl": "URL ủy quyền không hợp lệ nhận được từ máy chủ",
"requiredField": "Trường này là bắt buộc",
"minLength": "Độ dài tối thiểu là {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "Cho phép người dùng mới tự đăng ký bằng tên người dùng và mật khẩu",
"allowPasswordLogin": "Cho phép đăng nhập bằng mật khẩu",
"allowPasswordLoginDesc": "Đăng nhập bằng tên người dùng/mật khẩu",
"oidcAutoProvision": "Tự động tạo tài khoản bên ngoài",
"oidcAutoProvisionDesc": "Tạo tài khoản lần đầu tiên khi ai đó đăng nhập thông qua phương thức đăng nhập bên ngoài như SSO hoặc LDAP, ngay cả khi tính năng đăng ký chưa được bật.",
"externalAutoProvision": "Tự động tạo tài khoản bên ngoài",
"externalAutoProvisionDesc": "Tạo tài khoản lần đầu tiên khi ai đó đăng nhập thông qua phương thức đăng nhập bên ngoài như SSO hoặc LDAP, ngay cả khi tính năng đăng ký chưa được bật.",
"secondFactorAfterExternalLogin": "Hãy yêu cầu xác thực hai yếu tố sau khi đăng nhập bằng tài khoản bên ngoài (SSO, GitHub, Google, LDAP).",
"secondFactorAfterExternalLoginDesc": "Ở chế độ Tắt, xác thực yếu tố thứ hai chỉ được yêu cầu sau khi nhập mật khẩu và các thông tin đăng nhập cục bộ khác, giống như trước đây. Ở chế độ Bật, xác thực yếu tố thứ hai sẽ được yêu cầu sau mọi phương thức đăng nhập, bao gồm cả SSO và LDAP.",
"allowPasswordReset": "Cho phép đặt lại mật khẩu",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "Không hết hạn",
"revokedBadge": "ĐÃ THU HỒI",
"authTypeDual": "Xác thực kép",
"authTypeOidc": "Bên ngoài",
"authTypeExternal": "Bên ngoài",
"authTypeLocal": "Cục bộ",
"adminStatusAdministrator": "Quản trị viên",
"adminStatusRegularUser": "Người dùng thông thường",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "Cho phép cả đăng nhập bằng tài khoản ngoài và đăng nhập bằng mật khẩu.",
"linkAccountTargetUsername": "Tên đăng nhập tài khoản cục bộ",
"linkAccountTargetPlaceholder": "Nhập tên đăng nhập tài khoản cục bộ để liên kết",
"linkAccountOidcUsername": "Tên người dùng tài khoản bên ngoài",
"linkAccountOidcPlaceholder": "Nhập tên người dùng tài khoản chỉ dùng bên ngoài để hợp nhất.",
"linkAccountOidcNotFound": "Không tìm thấy tài khoản chỉ dùng bên ngoài nào có tên người dùng đó.",
"linkAccountExternalUsername": "Tên người dùng tài khoản bên ngoài",
"linkAccountExternalPlaceholder": "Nhập tên người dùng tài khoản chỉ dùng bên ngoài để hợp nhất.",
"linkAccountExternalNotFound": "Không tìm thấy tài khoản chỉ dùng bên ngoài nào có tên người dùng đó.",
"linkAccounts": "Liên kết tài khoản",
"linkAccountSuccess": "Liên kết tài khoản thành công",
"linkAccountFailed": "Liên kết tài khoản thất bại",
@@ -1400,7 +1400,6 @@
"saving": "Đang lưu...",
"updateRegistrationFailed": "Cập nhật cài đặt đăng ký thất bại",
"updatePasswordLoginFailed": "Cập nhật cài đặt đăng nhập bằng mật khẩu thất bại",
"updateOidcAutoProvisionFailed": "Không thể cập nhật cài đặt tự động tạo.",
"updateSecondFactorAfterExternalLoginFailed": "Không thể cập nhật cài đặt yếu tố xác thực thứ hai.",
"updatePasswordResetFailed": "Cập nhật cài đặt đặt lại mật khẩu thất bại",
"sessionTimeoutRange2": "Thời gian chờ phiên phải từ 1 đến 720 giờ",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "ĐÃ KHÓA",
"dataLockedNotice": "Dữ liệu của {{username}} vẫn bị khóa cho đến khi họ đăng nhập lần tiếp theo. Máy chủ, thông tin đăng nhập và đoạn mã của họ không thể xem hoặc chỉnh sửa cho đến lúc đó.",
"resetPasswordTitle": "Đặt lại mật khẩu",
"resetPasswordOidcOnly": "Người dùng này đăng nhập qua nhà cung cấp bên ngoài và không có mật khẩu.",
"resetPasswordExternalOnly": "Người dùng này đăng nhập qua nhà cung cấp bên ngoài và không có mật khẩu.",
"resetPasswordPlaceholder": "Mật khẩu mới",
"resetPasswordBtn": "Đặt lại",
"resetPasswordWorking": "Đang đặt lại...",
@@ -1742,7 +1741,7 @@
"importFailed": "Nhập khẩu thất bại: {{error}}",
"roleUser": "Người dùng",
"authMethodDual": "Xác thực kép",
"authMethodOidc": "Bên ngoài",
"authMethodExternal": "Bên ngoài",
"done": "Xong",
"apiKeyNameRequired": "Tên khóa là bắt buộc",
"apiKeyCreated": "Đã tạo khóa API \"{{name}}\"",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "强制键盘交互认证",
"overrideCredentialUsername": "覆盖凭据用户名",
"overrideCredentialUsernameDesc": "使用上方指定的用户名代替凭据中的用户名",
"oidcUsernameHint": "使用 $external.username 填写您通过 SSO 或 LDAP 登录的名称。",
"externalUsernameHint": "使用 $external.username 填写您通过 SSO 或 LDAP 登录的名称。",
"jumpHostChain": "跳板主机链",
"portKnocking": "端口敲门",
"addKnock": "添加端口",
@@ -977,7 +977,7 @@
"noSearchResults": "没有连接匹配您的搜索",
"rename": "重命名会话"
},
"terminal": {
"sshAuth": {
"totpRequired": "需要双因素认证",
"totpCodeLabel": "验证码",
"totpVerify": "验证",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "验证重置代码失败",
"failedCompleteReset": "无法完成密码重置",
"invalidTotpCode": "无效的 TOTP 代码",
"failedOidcLogin": "外部登录失败",
"silentSigninOidcUnavailable": "已请求静默登录,但未设置外部登录。",
"failedExternalLogin": "外部登录失败",
"silentSigninExternalUnavailable": "已请求静默登录,但未设置外部登录。",
"failedUserInfo": "登录后无法获取用户信息",
"oidcAuthFailed": "外部登录失败",
"externalAuthFailed": "外部登录失败",
"invalidAuthUrl": "从后端接收到无效的授权 URL",
"requiredField": "此字段为必填项",
"minLength": "最小长度为 {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "允许新用户使用用户名和密码自行注册",
"allowPasswordLogin": "允许密码登录",
"allowPasswordLoginDesc": "用户名/密码登录",
"oidcAutoProvision": "自动创建外部帐户",
"oidcAutoProvisionDesc": "即使注册功能已关闭,当用户首次通过外部登录(例如 SSO 或 LDAP)登录时,也应创建帐户。",
"externalAutoProvision": "自动创建外部帐户",
"externalAutoProvisionDesc": "即使注册功能已关闭,当用户首次通过外部登录(例如 SSO 或 LDAP)登录时,也应创建帐户。",
"secondFactorAfterExternalLogin": "在外部登录(SSO、GitHub、Google、LDAP)后要求进行第二重验证",
"secondFactorAfterExternalLoginDesc": "关闭双因素身份验证后,仅在密码登录和其他本地登录后才会要求输入已注册的双因素身份验证信息,与之前相同。开启双因素身份验证后,每次登录都会要求输入双因素身份验证信息,包括单点登录 (SSO) 和 LDAP 登录。",
"allowPasswordReset": "允许密码重置",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "无过期",
"revokedBadge": "已吊销",
"authTypeDual": "双重认证",
"authTypeOidc": "外部",
"authTypeExternal": "外部",
"authTypeLocal": "本地",
"adminStatusAdministrator": "管理员",
"adminStatusRegularUser": "普通用户",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "允许外部登录和密码登录",
"linkAccountTargetUsername": "本地账号用户名",
"linkAccountTargetPlaceholder": "输入要链接的本地账号用户名",
"linkAccountOidcUsername": "外部帐户用户名",
"linkAccountOidcPlaceholder": "输入仅供外部使用的帐户用户名以进行合并",
"linkAccountOidcNotFound": "未找到使用该用户名的仅限外部访问的帐户",
"linkAccountExternalUsername": "外部帐户用户名",
"linkAccountExternalPlaceholder": "输入仅供外部使用的帐户用户名以进行合并",
"linkAccountExternalNotFound": "未找到使用该用户名的仅限外部访问的帐户",
"linkAccounts": "链接账号",
"linkAccountSuccess": "账号已成功链接",
"linkAccountFailed": "链接账号失败",
@@ -1400,7 +1400,6 @@
"saving": "保存中...",
"updateRegistrationFailed": "更新注册设置失败",
"updatePasswordLoginFailed": "更新密码登录设置失败",
"updateOidcAutoProvisionFailed": "自动创建设置更新失败",
"updateSecondFactorAfterExternalLoginFailed": "更新第二因素设置失败",
"updatePasswordResetFailed": "更新密码重置设置失败",
"sessionTimeoutRange2": "会话超时必须在 1 到 720 小时之间",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "已锁定",
"dataLockedNotice": "{{username}} 的数据将保持锁定状态,直到其下次登录。在此之前,无法查看或编辑其主机、凭据和代码片段。",
"resetPasswordTitle": "重置密码",
"resetPasswordOidcOnly": "此用户通过外部提供商登录,没有密码。",
"resetPasswordExternalOnly": "此用户通过外部提供商登录,没有密码。",
"resetPasswordPlaceholder": "新密码",
"resetPasswordBtn": "重置",
"resetPasswordWorking": "正在重置...",
@@ -1742,7 +1741,7 @@
"importFailed": "导入失败:{{error}}",
"roleUser": "用户",
"authMethodDual": "双重认证",
"authMethodOidc": "外部",
"authMethodExternal": "外部",
"done": "完成",
"apiKeyNameRequired": "密钥名称为必填项",
"apiKeyCreated": "API 密钥 \"{{name}}\" 已创建",
+13 -14
View File
@@ -377,7 +377,7 @@
"forceKeyboardInteractive": "強制鍵盤互動式驗證",
"overrideCredentialUsername": "覆寫憑證使用者名稱",
"overrideCredentialUsernameDesc": "使用上方指定的使用者名稱,而非憑證中的使用者名稱",
"oidcUsernameHint": "使用 $external.username 填寫您透過 SSO 或 LDAP 登入的名稱。",
"externalUsernameHint": "使用 $external.username 填寫您透過 SSO 或 LDAP 登入的名稱。",
"jumpHostChain": "跳板主機鏈",
"portKnocking": "連接埠敲門",
"addKnock": "新增連接埠",
@@ -977,7 +977,7 @@
"noSearchResults": "無符合搜尋條件的連線",
"rename": "重新命名工作階段"
},
"terminal": {
"sshAuth": {
"totpRequired": "需要雙因素驗證",
"totpCodeLabel": "驗證碼",
"totpVerify": "驗證",
@@ -1089,10 +1089,10 @@
"failedVerifyCode": "驗證重設碼失敗",
"failedCompleteReset": "無法完成密碼重設",
"invalidTotpCode": "無效的 TOTP 驗證碼",
"failedOidcLogin": "外部登入失敗",
"silentSigninOidcUnavailable": "已要求靜默登錄,但未設定外部登入。",
"failedExternalLogin": "外部登入失敗",
"silentSigninExternalUnavailable": "已要求靜默登錄,但未設定外部登入。",
"failedUserInfo": "登入後無法取得使用者資訊",
"oidcAuthFailed": "外部登入失敗",
"externalAuthFailed": "外部登入失敗",
"invalidAuthUrl": "從後端接收到無效的授權 URL",
"requiredField": "此欄位為必填",
"minLength": "最小長度為 {{min}}",
@@ -1266,8 +1266,8 @@
"allowRegistrationDesc": "允許新使用者使用使用者名稱和密碼自行註冊",
"allowPasswordLogin": "允許密碼登入",
"allowPasswordLoginDesc": "使用者名稱/密碼登入",
"oidcAutoProvision": "自動建立外部帳戶",
"oidcAutoProvisionDesc": "即使註冊功能已關閉,當使用者首次透過外部登入(例如 SSO 或 LDAP)登入時,也應建立帳戶。",
"externalAutoProvision": "自動建立外部帳戶",
"externalAutoProvisionDesc": "即使註冊功能已關閉,當使用者首次透過外部登入(例如 SSO 或 LDAP)登入時,也應建立帳戶。",
"secondFactorAfterExternalLogin": "在外部登入(SSO、GitHub、Google、LDAP)後要求進行第二重驗證",
"secondFactorAfterExternalLoginDesc": "關閉雙重認證後,僅在密碼登入和其他本地登入後才會要求輸入已註冊的雙重認證訊息,與之前相同。開啟雙重認證後,每次登入都會要求輸入雙重認證訊息,包括單一登入 (SSO) 和 LDAP 登入。",
"allowPasswordReset": "允許密碼重設",
@@ -1324,7 +1324,7 @@
"apiKeyNoExpiry": "無到期日",
"revokedBadge": "已撤銷",
"authTypeDual": "雙重驗證",
"authTypeOidc": "外部",
"authTypeExternal": "外部",
"authTypeLocal": "本機",
"adminStatusAdministrator": "管理員",
"adminStatusRegularUser": "一般使用者",
@@ -1383,9 +1383,9 @@
"linkAccountEffect3": "允許外部登入和密碼登入",
"linkAccountTargetUsername": "本機帳號使用者名稱",
"linkAccountTargetPlaceholder": "輸入要連結到的本機帳號使用者名稱",
"linkAccountOidcUsername": "外部帳戶使用者名稱",
"linkAccountOidcPlaceholder": "輸入僅供外部使用的帳戶使用者名稱以進行合併",
"linkAccountOidcNotFound": "未找到使用該使用者名稱的僅限外部存取的帳戶",
"linkAccountExternalUsername": "外部帳戶使用者名稱",
"linkAccountExternalPlaceholder": "輸入僅供外部使用的帳戶使用者名稱以進行合併",
"linkAccountExternalNotFound": "未找到使用該使用者名稱的僅限外部存取的帳戶",
"linkAccounts": "連結帳號",
"linkAccountSuccess": "帳號連結成功",
"linkAccountFailed": "帳號連結失敗",
@@ -1400,7 +1400,6 @@
"saving": "儲存中...",
"updateRegistrationFailed": "更新註冊設定失敗",
"updatePasswordLoginFailed": "更新密碼登入設定失敗",
"updateOidcAutoProvisionFailed": "自動建立設定更新失敗",
"updateSecondFactorAfterExternalLoginFailed": "更新第二因素設定失敗",
"updatePasswordResetFailed": "更新密碼重設設定失敗",
"sessionTimeoutRange2": "工作階段逾時必須介於 1 到 720 小時之間",
@@ -1428,7 +1427,7 @@
"dataLockedBadge": "已鎖定",
"dataLockedNotice": "{{username}} 的資料將保持鎖定,直到他們下次登入為止。在此之前無法檢視或編輯其主機、憑證和程式碼片段。",
"resetPasswordTitle": "重設密碼",
"resetPasswordOidcOnly": "此使用者透過外部提供者登入,沒有密碼。",
"resetPasswordExternalOnly": "此使用者透過外部提供者登入,沒有密碼。",
"resetPasswordPlaceholder": "新密碼",
"resetPasswordBtn": "重設",
"resetPasswordWorking": "重設中...",
@@ -1742,7 +1741,7 @@
"importFailed": "導入失敗: {{error}}",
"roleUser": "使用者",
"authMethodDual": "雙重驗證",
"authMethodOidc": "外部",
"authMethodExternal": "外部",
"done": "完成",
"apiKeyNameRequired": "必須提供金鑰名稱",
"apiKeyCreated": "API 金鑰「{{name}}」已建立",
+28 -21
View File
@@ -77,8 +77,6 @@ export interface AuthResponse {
username?: string;
userId?: string;
is_external?: boolean;
/** 2.8 name for is_external. */
is_oidc?: boolean;
totp_enabled?: boolean;
requires_totp?: boolean;
temp_token?: string;
@@ -96,12 +94,9 @@ export interface UserInfo {
is_admin: boolean;
/** Signs in through an external login such as SSO or LDAP. */
is_external?: boolean;
/** 2.8 name for is_external. */
is_oidc: boolean;
is_dual_auth?: boolean;
password_hash?: string;
data_unlocked?: boolean;
show_donation_modal?: boolean;
/** On a desktop linked to a server, the account it is signed in to there. */
linked?: LinkedAccountInfo | null;
}
@@ -139,6 +134,8 @@ type ElectronWindow = Window &
export { isElectron };
export const TRUST_TOKEN_KEY = "termixTrustToken";
function getLoggerForService(serviceName: string) {
if (serviceName.includes("SSH") || serviceName.includes("ssh")) {
return sshLogger;
@@ -172,7 +169,6 @@ if (isElectron()) {
if (!localStorage.getItem(key)) {
electronSettingsCache.set(key, value);
localStorage.setItem(key, value);
console.log(`[Electron] Loaded setting ${key} from main process`);
} else {
// Even if we don't overwrite localStorage, update the cache
electronSettingsCache.set(key, localStorage.getItem(key)!);
@@ -206,8 +202,6 @@ export function setCookie(
console.error(`[Electron] Failed to persist setting ${name}:`, err);
});
}
console.log(`[Electron] Set setting: ${name}`);
} catch (error) {
console.error(`[Electron] Failed to set setting: ${name}`, error);
}
@@ -232,7 +226,6 @@ export function getCookie(name: string): string | undefined {
if (token) {
electronSettingsCache.set(name, token);
}
console.log(`[Electron] Get setting: ${name} = ${token}`);
return token;
} catch (error) {
console.error(`[Electron] Failed to get setting: ${name}`, error);
@@ -374,6 +367,17 @@ function createApiInstance(
config.headers["Authorization"] = `Bearer ${jwt}`;
}
}
// The desktop app has no cookies with the server, so it carries the
// remember-this-device token itself, and only to login.
const trustToken = localStorage.getItem(TRUST_TOKEN_KEY);
const isLogin = isLoginRequestUrl(config.url);
if (trustToken && isLogin) {
if (config.headers.set) {
config.headers.set("X-Termix-Trust-Token", trustToken);
} else {
config.headers["X-Termix-Trust-Token"] = trustToken;
}
}
}
if (
@@ -722,6 +726,19 @@ class ApiError extends Error {
}
}
/**
* Requests that sign someone in. A 401 from one of these means wrong
* credentials or a wrong code, not an expired session.
*/
export function isLoginRequestUrl(url: string | undefined): boolean {
if (!url) return false;
return (
url.includes("/users/login") ||
url.includes("/users/totp/verify-login") ||
/\/users\/auth\/(second-factor\/)?[^/?]+\/verify(\?|$)/.test(url)
);
}
export function handleApiError(error: unknown, operation: string): never {
const context: LogContext = {
operation: "error_handling",
@@ -753,8 +770,7 @@ export function handleApiError(error: unknown, operation: string): never {
errorContext,
);
const isLoginEndpoint = url?.includes("/users/login");
const errorMessage = isLoginEndpoint
const errorMessage = isLoginRequestUrl(url)
? message
: "Authentication required. Please log in again.";
@@ -960,8 +976,7 @@ export async function loginUser(
requires_totp: response.data.requires_totp,
temp_token: response.data.temp_token,
rememberMe: response.data.rememberMe,
is_external: response.data.is_external ?? response.data.is_oidc,
is_oidc: response.data.is_oidc,
is_external: response.data.is_external,
totp_enabled: response.data.totp_enabled,
token: response.data.token,
};
@@ -1012,14 +1027,6 @@ export async function getUserInfo(): Promise<UserInfo> {
}
}
export async function dismissDonationModal(): Promise<void> {
try {
await authApi.post("/users/me/dismiss-donation-modal");
} catch (error) {
handleApiError(error, "dismiss donation modal");
}
}
export async function getCurrentToken(): Promise<string | null> {
try {
const response = await authApi.get("/users/me/token");
+357
View File
@@ -0,0 +1,357 @@
/* eslint-disable react-refresh/only-export-components */
import type React from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
type RefObject,
} from "react";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import { GroupHeading, PANEL, PanelSearch } from "@/components/panel-layout";
import { TabStrip } from "@/components/tab-strip";
import { EmptyState } from "@/components/empty-state";
import { HIDDEN_ATTR, useSettingsPageFilter } from "@/settings/page-filter";
import { cn } from "@/lib/utils";
/** True while the editor is searched, so every lazy section mounts. */
const SearchingContext = createContext(false);
export interface EditorNavItem {
id: string;
label: string;
icon?: ReactNode;
/** Marks a section holding something that needs fixing. */
error?: boolean;
}
export interface EditorNavBand {
id: string;
label: string;
items: EditorNavItem[];
}
/**
* The element that really scrolls: the editor's own area, or the page around
* it when the editor is embedded somewhere that scrolls itself.
*/
function scroller(el: HTMLElement): HTMLElement {
let node: HTMLElement | null = el;
while (node) {
const overflow = getComputedStyle(node).overflowY;
if (
(overflow === "auto" || overflow === "scroll") &&
node.scrollHeight > node.clientHeight + 1
) {
return node;
}
node = node.parentElement;
}
return el;
}
/** Which section is in view, and a way to jump to one. */
export function useScrollSpy(
containerRef: RefObject<HTMLElement | null>,
ids: string[],
): { active: string; scrollTo: (id: string) => void } {
const [active, setActive] = useState(ids[0] ?? "");
const idsKey = ids.join("|");
// A jump scrolls past every section between; the spy waits it out.
const lockUntil = useRef(0);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const onScroll = () => {
if (Date.now() < lockUntil.current) return;
const area = scroller(container);
const top = area.getBoundingClientRect().top;
let current = ids[0] ?? "";
for (const id of ids) {
const el = container.querySelector<HTMLElement>(
`[data-section="${id}"]`,
);
if (!el) continue;
if (el.getBoundingClientRect().top - top <= 24) current = id;
}
if (
area.scrollHeight > area.clientHeight &&
area.scrollTop + area.clientHeight >= area.scrollHeight - 8
) {
current = ids[ids.length - 1] ?? current;
}
setActive(current);
};
onScroll();
// Captured on the document so an outer scroller is heard too.
document.addEventListener("scroll", onScroll, {
passive: true,
capture: true,
});
return () =>
document.removeEventListener("scroll", onScroll, { capture: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [containerRef, idsKey]);
return {
active: ids.includes(active) ? active : (ids[0] ?? ""),
scrollTo: (id: string) => {
const container = containerRef.current;
const el = container?.querySelector<HTMLElement>(
`[data-section="${id}"]`,
);
if (!container || !el) return;
lockUntil.current = Date.now() + 600;
setActive(id);
const area = scroller(container);
const target = () =>
el.getBoundingClientRect().top -
area.getBoundingClientRect().top +
area.scrollTop -
8;
let lastTop = target();
let lastHeight = area.scrollHeight;
area.scrollTo?.({ top: lastTop, behavior: "smooth" });
// Sections above can still be mounting, which moves the target or
// cuts the scroll short. Follow it until the layout settles.
const started = Date.now();
const follow = () => {
if (!el.isConnected || Date.now() - started > 1500) return;
const top = target();
if (Math.abs(top - lastTop) > 4 || area.scrollHeight !== lastHeight) {
lastTop = top;
lastHeight = area.scrollHeight;
lockUntil.current = Date.now() + 600;
area.scrollTo?.({ top, behavior: "smooth" });
}
requestAnimationFrame(follow);
};
requestAnimationFrame(follow);
},
};
}
/** Wraps a section so the nav can find it. */
export function EditorSection({
id,
label,
showHeading = true,
children,
}: {
id: string;
label?: string;
showHeading?: boolean;
children: ReactNode;
}) {
return (
<section data-section={id} className={`flex flex-col ${PANEL.gap}`}>
{showHeading && label && <GroupHeading title={label} className="pt-1" />}
{children}
</section>
);
}
/**
* Mounts its children the first time they scroll near view, so a long editor
* does not start every section's requests at once.
*/
export function LazySection({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const searching = useContext(SearchingContext);
const [shown, setShown] = useState(
() => typeof IntersectionObserver === "undefined",
);
useEffect(() => {
if (searching) setShown(true);
}, [searching]);
useLayoutEffect(() => {
if (shown) return;
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setShown(true);
observer.disconnect();
}
},
{ rootMargin: "400px 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [shown]);
return (
<div ref={ref} className={shown ? "contents" : "min-h-24"}>
{shown && children}
</div>
);
}
/**
* An editor laid out like the settings screen: a section nav beside one
* scrolling form, and the actions pinned under it.
*/
export function EditorPane({
bands,
banner,
footer,
errorCount = 0,
revealRef,
children,
className,
}: {
bands: EditorNavBand[];
/** Filled with a function that scrolls a section into view. */
revealRef?: React.MutableRefObject<((id: string) => void) | null>;
/** Sits above the form, inside the scroll area. */
banner?: ReactNode;
/** Usually Cancel and Save, pinned at the bottom. */
footer?: ReactNode;
/** Shown beside the footer so a problem in a hidden section is not missed. */
errorCount?: number;
children: ReactNode;
className?: string;
}) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const formRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set());
const searching = query.trim().length > 0;
const onFiltered = useCallback((root: HTMLElement) => {
const next = new Set<string>();
for (const el of root.querySelectorAll<HTMLElement>(
`[data-section][${HIDDEN_ATTR}]`,
)) {
if (el.dataset.section) next.add(el.dataset.section);
}
setHiddenIds((prev) =>
prev.size === next.size && [...next].every((id) => prev.has(id))
? prev
: next,
);
}, []);
const anyShown = useSettingsPageFilter(
formRef,
query,
"editor",
"[data-section]",
onFiltered,
);
const visibleBands = bands
.map((band) => ({
...band,
items: band.items.filter((item) => !hiddenIds.has(item.id)),
}))
.filter((band) => band.items.length > 0);
const ids = visibleBands.flatMap((band) => band.items.map((item) => item.id));
const { active, scrollTo } = useScrollSpy(scrollRef, ids);
const showNav = bands.flatMap((band) => band.items).length > 1;
if (revealRef) revealRef.current = scrollTo;
return (
<div className={cn("flex min-h-0 flex-1", className)}>
{showNav && (
<nav
aria-label={t("manage.sections")}
className="hidden w-52 shrink-0 flex-col gap-3 overflow-y-auto border-r border-border py-2.5 thin-scrollbar lg:flex"
>
{visibleBands.map((band) => (
<div key={band.id} className="flex flex-col">
<GroupHeading title={band.label} className="px-2.5 pb-1.5" />
{band.items.map((item) => {
const on = item.id === active;
return (
<button
key={item.id}
type="button"
aria-current={on ? "true" : undefined}
onClick={() => scrollTo(item.id)}
className={cn(
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2 text-left transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
on
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{item.icon && (
<span className="flex shrink-0 [&_svg]:size-3.5">
{item.icon}
</span>
)}
<span className="truncate text-xs font-medium">
{item.label}
</span>
{item.error && (
<span
title={t("manage.sectionNeedsAttention")}
className="ml-auto size-1.5 shrink-0 rounded-full bg-destructive"
/>
)}
</button>
);
})}
</div>
))}
</nav>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
<div className="mx-auto w-full max-w-3xl">
<PanelSearch
value={query}
onChange={setQuery}
placeholder={t("manage.searchSettings")}
fill
/>
</div>
</div>
{showNav && (
<div className="shrink-0 border-b border-border px-1 lg:hidden">
<TabStrip
tabs={visibleBands.flatMap((band) =>
band.items.map((item) => ({ id: item.id, label: item.label })),
)}
activeTab={active}
onTabChange={scrollTo}
/>
</div>
)}
<div
ref={scrollRef}
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
>
{!anyShown && (
<EmptyState icon={Search} title={t("manage.noMatchingSettings")} />
)}
<div
ref={formRef}
className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}
>
{banner}
<SearchingContext.Provider value={searching}>
{children}
</SearchingContext.Provider>
</div>
</div>
{(footer || errorCount > 0) && (
<div className="flex shrink-0 items-center gap-3 border-t border-border px-3 py-2">
{errorCount > 0 && (
<span className="text-[10px] text-destructive">
{t("manage.needsAttention", { count: errorCount })}
</span>
)}
<div className="ml-auto flex items-center gap-2">{footer}</div>
</div>
)}
</div>
</div>
);
}
+467
View File
@@ -0,0 +1,467 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
ChevronRight,
Folder,
FolderOpen,
Globe,
KeyRound,
Plus,
Server,
SlidersHorizontal,
User,
} from "lucide-react";
import { Button } from "@/components/button";
import { EmptyState } from "@/components/empty-state";
import { PanelSearch } from "@/components/panel-layout";
import { cn } from "@/lib/utils";
import { rem } from "@/lib/rem";
import type { Credential, Host } from "@/types/ui-types";
import { defaultsRows, type DefaultsRow } from "./defaults-rows";
import {
allFolderPaths,
allParentIds,
buildCredentialManageTree,
buildHostManageTree,
flattenTree,
type ManageRow,
} from "./manage-tree";
import type { ManageMode } from "./manage-requests";
import {
getStatusClasses,
useStatusColorScheme,
} from "@/hooks/use-status-color-scheme";
function indent(depth: number) {
return 6 + depth * 13;
}
/** Rules marking each open ancestor, so deep rows still read as nested. */
function Guides({ depth }: { depth: number }) {
if (depth === 0) return null;
return (
<>
{Array.from({ length: depth }, (_, i) => (
<span
key={i}
aria-hidden
className="absolute inset-y-0 w-px bg-border"
style={{ left: rem(indent(i) + 7) }}
/>
))}
</>
);
}
const rowFocus =
"focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring";
/** The list column of the Manage tab. */
export function ManageList({
editing,
mode,
onMode,
hosts,
credentials,
canEditInstanceDefaults,
isOnline,
selectedKey,
onPickHost,
onPickCredential,
onPickDefaults,
onAdd,
}: {
editing: boolean;
mode: ManageMode;
onMode: (mode: ManageMode) => void;
hosts: Host[];
credentials: Credential[];
canEditInstanceDefaults: boolean;
isOnline: (host: Host) => boolean;
/** "h:<id>", "c:<id>" or a defaults row key. */
selectedKey: string | null;
onPickHost: (host: Host) => void;
onPickCredential: (credential: Credential) => void;
onPickDefaults: (row: DefaultsRow) => void;
onAdd: () => void;
}) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const needle = query.trim().toLowerCase();
const tree = useMemo(
() =>
mode === "credentials"
? buildCredentialManageTree(credentials)
: buildHostManageTree(hosts, isOnline),
[mode, hosts, credentials, isOnline],
);
// Everything starts open; closing something is remembered while mounted.
const [open, setOpen] = useState<Set<string>>(new Set());
useEffect(() => {
setOpen(new Set([...allFolderPaths(tree), ...allParentIds(tree)]));
// Rebuilt only when the side changes, so toggles survive an edit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
const toggle = (key: string) =>
setOpen((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const rows = useMemo(
() => (mode === "defaults" ? [] : flattenTree(tree, open, needle)),
[mode, tree, open, needle],
);
const defaults = useMemo(
() =>
defaultsRows(hosts, canEditInstanceDefaults, t).filter(
(row) => !needle || row.label.toLowerCase().includes(needle),
),
[hosts, canEditInstanceDefaults, t, needle],
);
const pick = (id: string) => {
if (mode === "hosts") {
const host = hosts.find((h) => h.id === id);
if (host) onPickHost(host);
} else {
const credential = credentials.find((c) => c.id === id);
if (credential) onPickCredential(credential);
}
};
const empty = mode === "defaults" ? defaults.length === 0 : rows.length === 0;
return (
<div
className={cn(
editing ? "hidden md:flex" : "flex",
"w-full shrink-0 flex-col border-r border-border bg-background md:w-72",
)}
>
<nav
aria-label={t("manage.sections")}
className="flex shrink-0 flex-col border-b border-border py-1"
>
{(
[
{
value: "hosts",
label: t("nav.hosts"),
icon: Server,
count: hosts.length,
},
{
value: "credentials",
label: t("nav.credentials"),
icon: KeyRound,
count: credentials.length,
},
{
value: "defaults",
label: t("manage.defaults"),
icon: SlidersHorizontal,
},
] as {
value: ManageMode;
label: string;
icon: typeof Server;
count?: number;
}[]
).map((item) => {
const active = item.value === mode;
const Icon = item.icon;
return (
<button
key={item.value}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onMode(item.value)}
className={cn(
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2.5 text-left transition-colors",
rowFocus,
active
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{item.label}
</span>
{item.count !== undefined && (
<span className="shrink-0 text-[10px] tabular-nums opacity-60">
{item.count}
</span>
)}
</button>
);
})}
</nav>
<div className="flex flex-col gap-2 border-b border-border px-2.5 py-2">
<div className="flex items-center gap-2">
<PanelSearch
value={query}
onChange={setQuery}
placeholder={
mode === "hosts"
? t("manage.searchHosts")
: mode === "credentials"
? t("manage.searchCredentials")
: t("manage.searchDefaults")
}
fill
/>
{mode !== "defaults" && (
<Button
variant="outline"
size="icon"
title={
mode === "hosts"
? t("hosts.addHost")
: t("manage.addCredential")
}
aria-label={
mode === "hosts"
? t("hosts.addHost")
: t("manage.addCredential")
}
onClick={onAdd}
className="shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
>
<Plus className="size-3.5" />
</Button>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-1 thin-scrollbar">
{empty ? (
<EmptyState
icon={mode === "credentials" ? KeyRound : Server}
title={needle ? t("manage.nothingMatches") : t("manage.nothingYet")}
hint={
needle
? undefined
: mode === "hosts"
? t("manage.emptyHostsHint")
: t("manage.emptyCredentialsHint")
}
/>
) : mode === "defaults" ? (
defaults.map((row) => {
const Icon =
row.level === "admin"
? Globe
: row.level === "user"
? User
: Folder;
const selected = selectedKey === row.key;
return (
<button
key={row.key}
type="button"
onClick={() => onPickDefaults(row)}
className={cn(
"flex w-full items-center gap-2 border-l-2 px-2.5 py-1.5 text-left transition-colors",
rowFocus,
selected
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-foreground hover:bg-muted",
)}
>
<Icon
className={cn(
"size-3.5 shrink-0",
!selected && "text-muted-foreground",
)}
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{row.label}
</span>
<SlidersHorizontal className="size-3 shrink-0 text-muted-foreground/60" />
</button>
);
})
) : (
rows.map((row) =>
row.kind === "folder" ? (
<FolderRow
key={`f:${row.folder.path}`}
row={row}
showOnline={mode === "hosts"}
open={!!needle || open.has(row.folder.path)}
locked={!!needle}
onToggle={() => toggle(row.folder.path)}
/>
) : (
<ItemRow
key={`i:${row.item.id}`}
row={row}
isHost={mode === "hosts"}
selected={
selectedKey ===
`${mode === "hosts" ? "h" : "c"}:${row.item.id}`
}
open={!!needle || open.has(row.item.id)}
locked={!!needle}
onToggle={() => toggle(row.item.id)}
onPick={() => pick(row.item.id)}
/>
),
)
)}
</div>
</div>
);
}
function FolderRow({
row,
open,
locked,
showOnline,
onToggle,
}: {
row: Extract<ManageRow, { kind: "folder" }>;
open: boolean;
/** Search forces folders open, so the chevron stops responding. */
locked: boolean;
showOnline: boolean;
onToggle: () => void;
}) {
const Icon = open ? FolderOpen : Folder;
return (
<div className="relative">
<Guides depth={row.depth} />
<button
type="button"
aria-expanded={open}
onClick={locked ? undefined : onToggle}
style={{ paddingLeft: rem(indent(row.depth)) }}
className={cn(
"group flex w-full items-center gap-1.5 py-1.5 pr-2.5 text-left transition-colors hover:bg-muted",
rowFocus,
)}
>
{open ? (
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
)}
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{row.folder.name}
</span>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/60">
{showOnline ? `${row.online}/${row.count}` : row.count}
</span>
</button>
</div>
);
}
function ItemRow({
row,
isHost,
selected,
open,
locked,
onToggle,
onPick,
}: {
row: Extract<ManageRow, { kind: "item" }>;
isHost: boolean;
selected: boolean;
open: boolean;
locked: boolean;
onToggle: () => void;
onPick: () => void;
}) {
const { t } = useTranslation();
const scheme = useStatusColorScheme();
const nests = row.item.children.length > 0;
const Icon = isHost ? Server : KeyRound;
return (
<div className="relative">
<Guides depth={row.depth} />
<div
className={cn(
"flex items-center border-l-2 transition-colors",
selected
? "border-accent-brand bg-accent-brand/10"
: "border-transparent hover:bg-muted",
)}
>
<div
className="flex shrink-0 items-center justify-center"
style={{ paddingLeft: rem(indent(row.depth)) }}
>
{nests ? (
<button
type="button"
onClick={locked ? undefined : onToggle}
aria-expanded={open}
title={open ? t("manage.collapse") : t("manage.expand")}
aria-label={open ? t("manage.collapse") : t("manage.expand")}
className={cn(
"flex size-4 items-center justify-center text-muted-foreground hover:text-foreground",
rowFocus,
)}
>
{open ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
</button>
) : (
<span aria-hidden className="size-4" />
)}
</div>
<button
type="button"
onClick={onPick}
aria-current={selected ? "true" : undefined}
className={cn(
"flex min-w-0 flex-1 items-center gap-2 py-1.5 pl-1 pr-2.5 text-left transition-colors",
rowFocus,
selected ? "text-accent-brand" : "text-foreground",
)}
>
<Icon
className={cn(
"size-3.5 shrink-0",
!selected && "text-muted-foreground",
)}
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{row.item.name}
</span>
<span className="shrink-0 truncate font-mono text-[10px] text-muted-foreground">
{row.item.note}
</span>
{isHost && (
<span
aria-hidden
title={row.item.online ? t("common.online") : t("common.offline")}
className={cn(
"size-1.5 shrink-0 rounded-full",
getStatusClasses(!!row.item.online, scheme, "dot"),
)}
/>
)}
</button>
</div>
</div>
);
}
+588
View File
@@ -0,0 +1,588 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound, LibraryBig, Plus, Server, Trash2 } from "lucide-react";
import { toast } from "sonner";
import type { HostDraft } from "@termix-ssh/plugin-sdk/frontend";
import { Button } from "@/components/button";
import { EmptyState } from "@/components/empty-state";
import { FakeSwitch } from "@/components/section-card";
import { BackButton, PanelShell } from "@/components/panel-layout";
import { useConfirm } from "@/components/surface/surface-scope";
import { useUiPreference } from "@/contexts/UiPreferencesContext";
import { usePermissions } from "@/hooks/use-permissions";
import { useServerStatus } from "@/lib/ServerStatusContext";
import {
deleteCredential,
deleteSSHHost,
getCredentialDetails,
getCredentials,
getSSHHosts,
type SSHHostWithStatus,
} from "@/main-axios";
import { getFolderDefaultsId } from "@/api/host-defaults-api";
import { invalidateHostsAndStatusCaches } from "@/lib/hosts-request-cache";
import { registerTabCloseGuard } from "@/shell/tab-close-guards";
import { HostEditor } from "@/sidebar/HostEditor";
import { CredentialEditorView } from "@/sidebar/CredentialEditorView";
import {
HostDefaultsEditorView,
hostDefaultsTitle,
type HostDefaultsEditorTarget,
} from "@/sidebar/host-defaults/HostDefaultsEditorView";
import { mapCredentials, sshHostToHost } from "@/sidebar/HostManagerData";
import {
hostProtocolFlags,
type HostProtocols,
} from "@/sidebar/host-protocols";
import { canDeleteHost } from "@/sidebar/host-permissions";
import type { Credential, Host } from "@/types/ui-types";
import { ManageList } from "./ManageList";
import type { DefaultsRow } from "./defaults-rows";
import {
MANAGE_REQUEST_EVENT,
takePendingManageRequest,
type ManageMode,
type ManageRequest,
} from "./manage-requests";
import { docsUrl } from "@/lib/docs";
type Editing =
| {
kind: "host";
host: Host | null;
draft?: HostDraft;
key: number;
protocols: HostProtocols;
}
| { kind: "credential"; credential: Credential | null; key: number }
| { kind: "defaults"; target: HostDefaultsEditorTarget; rowKey: string };
const TAB_ID = "host-manager";
/** Fetches a credential with its secrets marked as stored, for the editor. */
async function loadCredentialForEdit(cred: Credential): Promise<Credential> {
try {
const full = (await getCredentialDetails(Number(cred.id))) as {
hasKey?: boolean;
hasKeyPassword?: boolean;
password?: string;
certPublicKey?: string;
};
return {
...cred,
value: full.hasKey ? "existing_key" : (full.password ?? ""),
password: full.password ?? "",
passphrase: full.hasKeyPassword ? "existing_key_password" : "",
publicKey: full.certPublicKey ?? cred.publicKey,
};
} catch {
return cred;
}
}
/**
* Hosts, credentials and host defaults, edited at full width: a list on the
* left, the thing you picked on the right.
*/
export function ManageTab() {
const { t } = useTranslation();
const confirm = useConfirm();
const { has } = usePermissions();
const { statuses } = useServerStatus();
const presetMode = useUiPreference("hostEditor", "mode");
const [mode, setMode] = useState<ManageMode>("hosts");
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [editing, setEditing] = useState<Editing | null>(null);
const [dirty, setDirty] = useState(false);
// Starts from the preset and stays where the user puts it this session.
const [advanced, setAdvanced] = useState(presetMode !== "simple");
// The host a credential was opened from, so Back returns to it.
const [returnHost, setReturnHost] = useState<Host | "new" | null>(null);
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const credentialsRef = useRef(credentials);
credentialsRef.current = credentials;
const dirtyRef = useRef(dirty);
dirtyRef.current = dirty;
const reloadHosts = useCallback(() => {
return getSSHHosts()
.then((raw) => {
const list = raw.map(sshHostToHost);
hostsRef.current = list;
setHosts(list);
})
.catch(() => {});
}, []);
const reloadCredentials = useCallback(() => {
return getCredentials()
.then((res) => {
const list = mapCredentials(res);
credentialsRef.current = list;
setCredentials(list);
})
.catch(() => {});
}, []);
const loaded = useRef<Promise<unknown>>(Promise.resolve());
useEffect(() => {
loaded.current = Promise.all([reloadHosts(), reloadCredentials()]);
}, [reloadHosts, reloadCredentials]);
useEffect(() => {
window.addEventListener("termix:hosts-changed", reloadHosts);
window.addEventListener("ssh-hosts:changed", reloadHosts);
window.addEventListener("hosts:refresh", reloadHosts);
window.addEventListener("termix:credentials-changed", reloadCredentials);
return () => {
window.removeEventListener("termix:hosts-changed", reloadHosts);
window.removeEventListener("ssh-hosts:changed", reloadHosts);
window.removeEventListener("hosts:refresh", reloadHosts);
window.removeEventListener(
"termix:credentials-changed",
reloadCredentials,
);
};
}, [reloadHosts, reloadCredentials]);
/** Asks before throwing away unsaved changes. True means go ahead. */
const confirmDiscard = useCallback(async () => {
if (!dirtyRef.current) return true;
return confirm({
title: t("common.unsavedChanges"),
description: t("hosts.unsavedChangesDescription"),
confirmLabel: t("hosts.discardChanges"),
cancelLabel: t("hosts.keepEditing"),
});
}, [confirm, t]);
useEffect(
() => registerTabCloseGuard(TAB_ID, confirmDiscard),
[confirmDiscard],
);
const openHost = useCallback((host: Host | null, draft?: HostDraft) => {
setMode("hosts");
setReturnHost(null);
setDirty(false);
setEditing({
kind: "host",
host,
draft,
key: Date.now(),
protocols: hostProtocolFlags(host),
});
}, []);
const openCredential = useCallback(async (credential: Credential | null) => {
setMode("credentials");
setDirty(false);
const full = credential ? await loadCredentialForEdit(credential) : null;
setEditing({ kind: "credential", credential: full, key: Date.now() });
}, []);
const openDefaults = useCallback(
async (row: Pick<DefaultsRow, "level" | "folderName">) => {
setMode("defaults");
setReturnHost(null);
setDirty(false);
const rowKey =
row.level === "folder" ? `folder:${row.folderName}` : row.level;
if (row.level === "folder" && row.folderName) {
try {
const folderId = await getFolderDefaultsId(row.folderName);
setEditing({
kind: "defaults",
rowKey,
target: { level: "folder", folderId, folderName: row.folderName },
});
} catch {
toast.error(t("hostDefaults.loadFailed"));
}
return;
}
setEditing({ kind: "defaults", rowKey, target: { level: row.level } });
},
[t],
);
const apply = useCallback(
async (request: ManageRequest | null) => {
if (!request) return;
if (!(await confirmDiscard())) return;
await loaded.current;
if (request.kind === "host") {
const find = () =>
hostsRef.current.find((h) => h.id === request.hostId) ?? null;
let host = request.hostId ? find() : null;
// Our list can lag the sidebar's, so refetch once before giving up.
if (request.hostId && !host) {
invalidateHostsAndStatusCaches();
await reloadHosts();
host = find();
if (!host) {
toast.error(t("manage.hostNotFound"));
return;
}
}
openHost(host, request.draft);
} else if (request.kind === "credential") {
const find = () =>
credentialsRef.current.find(
(c) => String(c.id) === String(request.credentialId),
) ?? null;
let credential = request.credentialId ? find() : null;
if (request.credentialId && !credential) {
await reloadCredentials();
credential = find();
}
void openCredential(credential);
} else if (request.kind === "defaults") {
void openDefaults(request);
} else {
setDirty(false);
setEditing(null);
setMode(request.mode);
}
},
[
confirmDiscard,
openHost,
openCredential,
openDefaults,
reloadHosts,
reloadCredentials,
t,
],
);
// The request that opened this tab was parked before it mounted.
useEffect(() => {
void apply(takePendingManageRequest());
const onRequest = () => void apply(takePendingManageRequest());
window.addEventListener(MANAGE_REQUEST_EVENT, onRequest);
return () => window.removeEventListener(MANAGE_REQUEST_EVENT, onRequest);
}, [apply]);
const guarded =
<A extends unknown[]>(fn: (...args: A) => void) =>
async (...args: A) => {
if (await confirmDiscard()) fn(...args);
};
const close = () => {
setDirty(false);
setEditing(null);
};
const backFromCredential = () => {
if (returnHost) {
const host = returnHost;
setReturnHost(null);
openHost(host === "new" ? null : host);
return;
}
close();
};
const editCredentialFromHost = async (credentialId: string) => {
const cred = credentials.find((c) => String(c.id) === String(credentialId));
if (!cred || editing?.kind !== "host") return;
if (!(await confirmDiscard())) return;
setReturnHost(editing.host ?? "new");
await openCredential(cred);
};
const deleteCurrent = async () => {
if (editing?.kind === "host" && editing.host) {
const host = editing.host;
const ok = await confirm({
title: t("hosts.deleteHostConfirm", { name: host.name || host.ip }),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
try {
await deleteSSHHost(Number(host.id));
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
toast.success(t("hosts.deletedCount", { count: 1 }));
close();
} catch {
toast.error(t("hosts.failedToDeleteCount", { count: 1 }));
}
} else if (editing?.kind === "credential" && editing.credential) {
const cred = editing.credential;
const ok = await confirm({
title: t("credentials.deleteCredentialConfirm", { name: cred.name }),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
try {
await deleteCredential(Number(cred.id));
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
toast.success(t("credentials.deletedCredential", { name: cred.name }));
close();
} catch {
toast.error(t("credentials.failedToDeleteCredential"));
}
}
};
const isOnline = useCallback(
(host: Host) => statuses.get(Number(host.id))?.status === "online",
[statuses],
);
const existingFolders = useMemo(
() =>
[
...new Set(
credentials.map((c) => c.folder).filter((f): f is string => !!f),
),
].sort(),
[credentials],
);
const selectedKey = !editing
? null
: editing.kind === "host"
? editing.host
? `h:${editing.host.id}`
: null
: editing.kind === "credential"
? editing.credential
? `c:${editing.credential.id}`
: null
: editing.rowKey;
const status = !editing
? mode === "hosts"
? t("manage.hostCount", { count: hosts.length })
: mode === "credentials"
? t("manage.credentialCount", { count: credentials.length })
: t("manage.defaults")
: editing.kind === "host"
? editing.host
? editing.host.name || editing.host.ip
: t("manage.newHost")
: editing.kind === "credential"
? editing.credential
? editing.credential.name
: t("manage.newCredential")
: hostDefaultsTitle(editing.target, t);
const canDelete =
(editing?.kind === "host" &&
!!editing.host &&
canDeleteHost(editing.host)) ||
(editing?.kind === "credential" && !!editing.credential);
const renderEditor = () => {
if (!editing) return null;
if (editing.kind === "defaults") {
return (
<HostDefaultsEditorView
key={editing.rowKey}
target={editing.target}
onClose={guarded(close)}
onDirtyChange={setDirty}
hosts={hosts}
credentials={credentials}
/>
);
}
if (editing.kind === "credential") {
return (
<CredentialEditorView
key={`c:${editing.credential?.id ?? "new"}:${editing.key}`}
credential={editing.credential}
existingFolders={existingFolders}
saveAsNewHost={returnHost ?? undefined}
onBack={backFromCredential}
onSave={(saved, options) => {
const id = String((saved as { id: number | string }).id);
void reloadCredentials();
if (options?.assignToHost && returnHost) {
const host = returnHost;
setReturnHost(null);
setDirty(false);
setMode("hosts");
const next =
host === "new" ? null : { ...host, credentialId: id };
setEditing({
kind: "host",
host: next,
key: Date.now(),
protocols: hostProtocolFlags(next),
});
return;
}
if (returnHost) {
backFromCredential();
return;
}
close();
}}
/>
);
}
return (
<HostEditor
key={`h:${editing.host?.id ?? "new"}:${editing.key}`}
host={editing.host}
draft={editing.host ? undefined : editing.draft}
advanced={advanced}
onBack={guarded(close)}
onSave={(saved) => {
const updated = sshHostToHost(saved as unknown as SSHHostWithStatus);
setHosts((prev) => {
const idx = prev.findIndex((h) => h.id === updated.id);
if (idx < 0) return [...prev, updated];
const next = [...prev];
next[idx] = updated;
return next;
});
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
close();
}}
protocols={editing.protocols}
onProtocolChange={(p) =>
setEditing((current) =>
current?.kind === "host"
? { ...current, protocols: { ...current.protocols, ...p } }
: current,
)
}
onDirtyChange={setDirty}
hosts={hosts}
credentials={credentials}
onEditCredential={(id) => void editCredentialFromHost(id)}
/>
);
};
return (
<PanelShell
icon={<LibraryBig className="size-4" />}
title={t("nav.manage")}
status={status}
scroll={false}
docs={docsUrl(
mode === "credentials"
? "credentials"
: mode === "hosts"
? "hosts"
: "hostDefaults",
)}
leading={
editing ? (
<span className="flex h-full md:hidden">
<BackButton
onClick={() =>
void guarded(
editing.kind === "credential" ? backFromCredential : close,
)()
}
/>
</span>
) : undefined
}
actions={
<>
{editing?.kind === "host" && (
<label className="flex items-center gap-2 px-1">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("manage.advanced")}
</span>
<FakeSwitch checked={advanced} onChange={setAdvanced} />
</label>
)}
{canDelete && (
<Button
variant="ghost"
size="icon-sm"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={() => void deleteCurrent()}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
)}
</>
}
>
<div className="flex min-h-0 flex-1">
<ManageList
editing={!!editing}
mode={mode}
onMode={(next) =>
void guarded(() => {
setMode(next);
close();
})()
}
hosts={hosts}
credentials={credentials}
canEditInstanceDefaults={has("admin.settings.manage")}
isOnline={isOnline}
selectedKey={selectedKey}
onPickHost={(host) => void guarded(() => openHost(host))()}
onPickCredential={(cred) =>
void guarded(() => void openCredential(cred))()
}
onPickDefaults={(row) => void guarded(() => void openDefaults(row))()}
onAdd={() =>
void guarded(() =>
mode === "credentials"
? void openCredential(null)
: openHost(null),
)()
}
/>
{editing ? (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{renderEditor()}
</div>
) : (
<div className="hidden min-h-0 flex-1 items-center justify-center md:flex">
<EmptyState
icon={mode === "credentials" ? KeyRound : Server}
title={
mode === "hosts"
? t("manage.pickHost")
: mode === "credentials"
? t("manage.pickCredential")
: t("manage.pickDefaults")
}
hint={t("manage.pickHint")}
action={
mode !== "defaults" ? (
<Button
variant="outline"
size="sm"
onClick={() =>
mode === "hosts"
? openHost(null)
: void openCredential(null)
}
>
<Plus className="mr-1 size-3" />
{mode === "hosts"
? t("hosts.addHost")
: t("manage.addCredential")}
</Button>
) : undefined
}
/>
</div>
)}
</div>
</PanelShell>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { Host } from "@/types/ui-types";
import type { HostDefaultsLevel } from "@/types/host-defaults";
export interface DefaultsRow {
key: string;
level: HostDefaultsLevel;
folderName?: string;
label: string;
}
/** The levels of host defaults a user can open: instance, their own, folders. */
export function defaultsRows(
hosts: Host[],
canEditInstance: boolean,
t: (key: string, options?: Record<string, unknown>) => string,
): DefaultsRow[] {
const rows: DefaultsRow[] = [];
if (canEditInstance)
rows.push({
key: "admin",
level: "admin",
label: t("hostDefaults.titleAdmin"),
});
rows.push({ key: "user", level: "user", label: t("hostDefaults.titleUser") });
const folders = new Set<string>();
for (const host of hosts) {
if (!host.folder) continue;
let path = "";
for (const part of host.folder.split(" / ")) {
path = path ? `${path} / ${part}` : part;
folders.add(path);
}
}
for (const folder of [...folders].sort((a, b) => a.localeCompare(b))) {
rows.push({
key: `folder:${folder}`,
level: "folder",
folderName: folder,
label: folder,
});
}
return rows;
}
+21
View File
@@ -0,0 +1,21 @@
import type { HostEditorForm, HostProtocols } from "@/sidebar/HostEditorData";
/** Field keys mapped to an i18n key saying what is wrong. */
export type HostFormErrors = Partial<Record<"ip" | "sshPort", string>>;
function validPort(port: unknown): boolean {
const n = Number(port);
return Number.isInteger(n) && n >= 1 && n <= 65535;
}
/** What has to be fixed before a host can be saved. */
export function validateHostForm(
form: Pick<HostEditorForm, "ip" | "sshPort">,
protocols: Pick<HostProtocols, "enableSsh">,
): HostFormErrors {
const errors: HostFormErrors = {};
if (!form.ip.trim()) errors.ip = "manage.errorAddressRequired";
if (protocols.enableSsh && !validPort(form.sshPort))
errors.sshPort = "manage.errorPortRange";
return errors;
}
+73
View File
@@ -0,0 +1,73 @@
import type { HostDraft } from "@termix-ssh/plugin-sdk/frontend";
import type { HostDefaultsLevel } from "@/types/host-defaults";
export type ManageMode = "hosts" | "credentials" | "defaults";
/** What to open in the Manage tab. */
export type ManageRequest =
| { kind: "host"; hostId: string | null; draft?: HostDraft }
| { kind: "credential"; credentialId: string | null }
| { kind: "defaults"; level: HostDefaultsLevel; folderName?: string }
| { kind: "browse"; mode: ManageMode };
export const MANAGE_REQUEST_EVENT = "manage:request";
let pending: ManageRequest | null = null;
/**
* Parks a request for the Manage tab and pokes it. The shell opens the tab on
* the same event, and the tab claims the parked request once it mounts.
*/
export function requestManage(request: ManageRequest): void {
pending = request;
window.dispatchEvent(new Event(MANAGE_REQUEST_EVENT));
}
/** Takes the parked request, leaving none behind. */
export function takePendingManageRequest(): ManageRequest | null {
const request = pending;
pending = null;
return request;
}
/**
* The window events older callers fire (the dashboard, folder menus, the
* palette), turned into a request. Null for anything else.
*/
export function requestFromLegacyEvent(
name: string,
detail?: unknown,
): ManageRequest | null {
switch (name) {
case "host-manager:add-host":
return {
kind: "host",
hostId: null,
draft: (detail as HostDraft | undefined) ?? undefined,
};
case "host-manager:edit-host":
return detail ? { kind: "host", hostId: String(detail) } : null;
case "host-manager:add-credential":
return { kind: "credential", credentialId: null };
case "host-manager:show-credentials":
return { kind: "browse", mode: "credentials" };
case "host-manager:edit-defaults":
case "termix:open-host-defaults": {
const target = detail as
{ level?: HostDefaultsLevel; folderName?: string } | undefined;
if (!target?.level) return { kind: "browse", mode: "defaults" };
return {
kind: "defaults",
level: target.level,
folderName: target.folderName,
};
}
default:
return null;
}
}
/** Test seam. */
export function resetManageRequests(): void {
pending = null;
}
+256
View File
@@ -0,0 +1,256 @@
import type { Credential, Host } from "@/types/ui-types";
/**
* The Manage list as a tree: folders from the " / " path, sub-hosts under
* their parent host.
*/
export type ManageItem = {
id: string;
name: string;
/** IP for a host, username for a credential. */
note: string;
/** Extra search text, e.g. tags. */
search: string;
online?: boolean;
children: ManageItem[];
};
export type ManageFolder = {
/** Full " / " path, unique across the tree. */
path: string;
/** Last segment, what the row shows. */
name: string;
folders: ManageFolder[];
items: ManageItem[];
};
export type ManageRow =
| {
kind: "folder";
depth: number;
folder: ManageFolder;
count: number;
online: number;
}
| { kind: "item"; depth: number; item: ManageItem; folderPath: string };
function emptyFolder(path: string, name: string): ManageFolder {
return { path, name, folders: [], items: [] };
}
/** Walks to the folder at `path`, creating each missing segment on the way. */
function folderAt(
root: ManageFolder,
index: Map<string, ManageFolder>,
path: string,
) {
if (!path) return root;
const existing = index.get(path);
if (existing) return existing;
let current = root;
let accumulated = "";
for (const part of path.split(" / ")) {
const trimmed = part.trim();
if (!trimmed) continue;
accumulated = accumulated ? `${accumulated} / ${trimmed}` : trimmed;
let next = index.get(accumulated);
if (!next) {
next = emptyFolder(accumulated, trimmed);
index.set(accumulated, next);
current.folders.push(next);
}
current = next;
}
return current;
}
export function buildHostManageTree(
hosts: Host[],
isOnline: (host: Host) => boolean = (host) => host.online,
): ManageFolder {
const root = emptyFolder("", "");
const index = new Map<string, ManageFolder>();
const toItem = (host: Host): ManageItem => ({
id: host.id,
name: host.name || host.ip,
note: host.ip,
search: `${host.username} ${(host.tags ?? []).join(" ")}`,
online: isOnline(host),
children: [],
});
const items = new Map(hosts.map((h) => [h.id, toItem(h)]));
// A parent that is gone or would close a loop drops the host back to its
// folder, the same as buildHostTree.
const byId = new Map(hosts.map((h) => [h.id, h]));
const isDescendant = (candidate: string, ancestor: string) => {
const seen = new Set<string>();
let current: string | null | undefined = candidate;
while (current) {
if (current === ancestor) return true;
if (seen.has(current)) return false;
seen.add(current);
current = byId.get(current)?.parentHostId;
}
return false;
};
const nested = new Set<string>();
for (const host of hosts) {
if (!host.parentHostId) continue;
const parent = items.get(host.parentHostId);
if (!parent) continue;
if (isDescendant(host.parentHostId, host.id)) continue;
parent.children.push(items.get(host.id)!);
nested.add(host.id);
}
for (const host of hosts) {
if (nested.has(host.id)) continue;
folderAt(root, index, host.folder ?? "").items.push(items.get(host.id)!);
}
return sortFolder(root);
}
export function buildCredentialManageTree(
credentials: Credential[],
): ManageFolder {
const root = emptyFolder("", "");
const index = new Map<string, ManageFolder>();
for (const cred of credentials) {
folderAt(root, index, cred.folder ?? "").items.push({
id: cred.id,
name: cred.name,
note: cred.username,
search: `${cred.type} ${(cred.tags ?? []).join(" ")}`,
children: [],
});
}
return sortFolder(root);
}
function sortFolder(folder: ManageFolder): ManageFolder {
folder.folders.sort((a, b) => a.name.localeCompare(b.name));
folder.items.sort((a, b) => a.name.localeCompare(b.name));
folder.folders.forEach(sortFolder);
return folder;
}
function itemMatches(item: ManageItem, needle: string): boolean {
if (`${item.name} ${item.note} ${item.search}`.toLowerCase().includes(needle))
return true;
return item.children.some((child) => itemMatches(child, needle));
}
/** Total items in a folder and its subfolders, plus how many are online. */
export function folderCounts(folder: ManageFolder): {
count: number;
online: number;
} {
let count = 0;
let online = 0;
const walk = (item: ManageItem) => {
count++;
if (item.online) online++;
item.children.forEach(walk);
};
folder.items.forEach(walk);
for (const child of folder.folders) {
const sub = folderCounts(child);
count += sub.count;
online += sub.online;
}
return { count, online };
}
/** The rows actually drawn. While searching, folders holding a match open on their own. */
export function flattenTree(
folder: ManageFolder,
open: Set<string>,
needle: string,
depth = 0,
rows: ManageRow[] = [],
): ManageRow[] {
for (const child of folder.folders) {
const counts = folderCounts(child);
const matching = filterFolder(child, needle);
if (!matching) continue;
rows.push({
kind: "folder",
depth,
folder: child,
count: counts.count,
online: counts.online,
});
if (needle || open.has(child.path))
flattenTree(matching, open, needle, depth + 1, rows);
}
for (const item of folder.items) {
if (needle && !itemMatches(item, needle)) continue;
pushItem(item, folder.path, depth, open, needle, rows);
}
return rows;
}
function pushItem(
item: ManageItem,
folderPath: string,
depth: number,
open: Set<string>,
needle: string,
rows: ManageRow[],
) {
rows.push({ kind: "item", depth, item, folderPath });
if (item.children.length === 0) return;
if (!needle && !open.has(item.id)) return;
for (const child of item.children) {
if (needle && !itemMatches(child, needle)) continue;
pushItem(child, folderPath, depth + 1, open, needle, rows);
}
}
/** Drops folders with nothing matching, so search never leaves empty headers. */
function filterFolder(
folder: ManageFolder,
needle: string,
): ManageFolder | null {
if (!needle) return folder;
const folders = folder.folders
.map((child) => filterFolder(child, needle))
.filter((child): child is ManageFolder => child !== null);
const items = folder.items.filter((item) => itemMatches(item, needle));
if (folders.length === 0 && items.length === 0) return null;
return { ...folder, folders, items };
}
/** Every folder path in the tree, for expand-all and the default open set. */
export function allFolderPaths(
folder: ManageFolder,
out: string[] = [],
): string[] {
for (const child of folder.folders) {
out.push(child.path);
allFolderPaths(child, out);
}
return out;
}
/** Every parent host id, so sub-hosts can start expanded too. */
export function allParentIds(
folder: ManageFolder,
out: string[] = [],
): string[] {
const walk = (item: ManageItem) => {
if (item.children.length > 0) out.push(item.id);
item.children.forEach(walk);
};
folder.items.forEach(walk);
folder.folders.forEach((child) => allParentIds(child, out));
return out;
}
-158
View File
@@ -1,158 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { Button } from "@/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { useActionSlot } from "@/hooks/use-action-slot";
import {
relevantSteps,
type OnboardingContext,
type OnboardingStep,
} from "./onboarding-steps";
/** Plugin steps go in before this one. */
const PLUGIN_STEPS_BEFORE = "security";
export function OnboardingDialog({
open,
context,
onClose,
}: {
open: boolean;
context: OnboardingContext;
onClose: (skipped: boolean) => void;
}) {
const { t } = useTranslation();
const ctx = useUiPreferencesContext();
const pluginSteps = useActionSlot("onboarding.steps");
const steps = useMemo(() => {
const core = relevantSteps(context);
const extra: OnboardingStep[] = pluginSteps
.filter((contribution) => contribution.component)
.map((contribution) => ({
id: contribution.actionId,
titleKey: contribution.titleKey,
Component:
contribution.component as unknown as OnboardingStep["Component"],
}));
const at = core.findIndex((step) => step.id === PLUGIN_STEPS_BEFORE);
return at < 0
? [...core, ...extra]
: [...core.slice(0, at), ...extra, ...core.slice(at)];
}, [context, pluginSteps]);
const [index, setIndex] = useState(0);
// A reopened run always starts from the top.
useEffect(() => {
if (open) setIndex(0);
}, [open]);
if (steps.length === 0) return null;
const clamped = Math.min(index, steps.length - 1);
const step = steps[clamped];
const isLast = clamped === steps.length - 1;
const StepComponent = step.Component;
function finish(skipped: boolean) {
ctx?.completeOnboarding(skipped);
onClose(skipped);
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) finish(true);
}}
>
<DialogContent
className="sm:max-w-xl max-h-[85vh] overflow-y-auto"
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle className="text-base font-bold">
{t(step.titleKey)}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("onboarding.stepCounter", {
current: clamped + 1,
total: steps.length,
})}
</DialogDescription>
</DialogHeader>
<div className="h-0.5 w-full bg-muted-foreground/15">
<div
className="h-full bg-accent-brand transition-all duration-200"
style={{ width: `${((clamped + 1) / steps.length) * 100}%` }}
/>
</div>
<div className="mt-1">
<StepComponent context={context} />
</div>
<div className="mt-3 flex items-center justify-between gap-3 border-t border-border pt-3">
<div className="flex items-center gap-1.5">
{steps.map((s, i) => (
<button
key={s.id}
type="button"
title={t(s.titleKey)}
aria-label={t(s.titleKey)}
aria-current={i === clamped}
onClick={() => setIndex(i)}
className={`h-1.5 w-1.5 rounded-full transition-colors ${
i === clamped
? "bg-accent-brand"
: "bg-muted-foreground/25 hover:bg-muted-foreground/50"
}`}
/>
))}
</div>
<div className="flex items-center gap-2">
{!isLast && (
<Button
variant="ghost"
size="sm"
className="h-7 text-[11px]"
onClick={() => finish(true)}
>
{t("onboarding.skip")}
</Button>
)}
{clamped > 0 && (
<Button
variant="outline"
size="sm"
className="h-7 text-[11px]"
onClick={() => setIndex(clamped - 1)}
>
<ArrowLeft size={12} />
{t("common.back")}
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-7 text-[11px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => (isLast ? finish(false) : setIndex(clamped + 1))}
>
{isLast ? t("onboarding.finish") : t("onboarding.next")}
{!isLast && <ArrowRight size={12} />}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { usePermissions } from "@/hooks/use-permissions";
import { isElectron } from "@/lib/electron";
import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
import { settledPromise } from "@/plugin-host/plugin-store";
import { subscribeToActionRegistry } from "@/shell/action-registry";
import { onboardingSteps, type OnboardingMode } from "./onboarding-registry";
import {
baselineSeen,
computeFlow,
needsFullFlow,
ONBOARDING_ADMIN_PERMISSION,
} from "./onboarding-flow";
import { canEnrollSecondFactor, currentStepDefs } from "./use-onboarding-steps";
const OnboardingStage = lazy(() =>
import("./OnboardingStage").then((m) => ({ default: m.OnboardingStage })),
);
/** Fired by "Run setup again" in settings. */
export const OPEN_ONBOARDING_EVENT = "termix:open-onboarding";
const RECHECK_DELAY_MS = 750;
/**
* Onboarding once the app is up: the rerun from settings, and a partial run
* whenever steps appear that the user has not seen (a plugin installed here
* or elsewhere, a step whose version went up). Both draw full screen over
* the shell without unmounting it.
*/
export function OnboardingHost({
onOpenChange,
}: {
onOpenChange?: (open: boolean) => void;
}) {
const prefs = useUiPreferencesContext();
const { has, loaded } = usePermissions();
const [mode, setMode] = useState<OnboardingMode | null>(null);
const [settled, setSettled] = useState(false);
const modeRef = useRef(mode);
modeRef.current = mode;
const embedded = isEmbeddedAuthFrame();
useEffect(() => {
onOpenChange?.(mode !== null);
}, [mode, onOpenChange]);
useEffect(() => {
let cancelled = false;
void settledPromise().then(() => !cancelled && setSettled(true));
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (embedded) return;
const open = () => {
void settledPromise().then(() => setMode("rerun"));
};
window.addEventListener(OPEN_ONBOARDING_EVENT, open);
return () => window.removeEventListener(OPEN_ONBOARDING_EVENT, open);
}, [embedded]);
const onboarding = prefs?.preferences.onboarding;
const ready = !embedded && settled && loaded && !!prefs?.loaded;
// Carried over from the old onboarding: plugin steps that already exist
// count as seen, without showing any of them.
useEffect(() => {
if (!ready || !onboarding?.baselinePending) return;
prefs?.markOnboardingSeen(baselineSeen(currentStepDefs(has)), {
baselineDone: true,
});
}, [ready, onboarding?.baselinePending, prefs, has]);
useEffect(() => {
if (!ready || !onboarding || onboarding.baselinePending) return;
if (needsFullFlow(onboarding)) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const check = () => {
if (modeRef.current !== null) return;
if (document.visibilityState === "hidden") return;
const isAdmin = has(ONBOARDING_ADMIN_PERMISSION);
const pending = computeFlow(currentStepDefs(has), onboarding.seen, {
mode: "partial",
isAdmin,
isDesktop: isElectron(),
has,
pluginSetupPending: false,
pluginsManagedElsewhere: false,
canEnrollSecondFactor: canEnrollSecondFactor(),
});
if (pending.length > 0) setMode("partial");
};
const schedule = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(check, RECHECK_DELAY_MS);
};
schedule();
const offSteps = onboardingSteps.subscribe(schedule);
const offSlots = subscribeToActionRegistry(schedule);
document.addEventListener("visibilitychange", schedule);
return () => {
if (timer) clearTimeout(timer);
offSteps();
offSlots();
document.removeEventListener("visibilitychange", schedule);
};
}, [ready, onboarding, has]);
if (!mode || embedded) return null;
return (
<Suspense fallback={null}>
<OnboardingStage
key={mode}
mode={mode}
shellReady
onDone={() => setMode(null)}
/>
</Suspense>
);
}
+358
View File
@@ -0,0 +1,358 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft, ArrowRight, Loader2, LogOut } from "lucide-react";
import { Button } from "@/components/button";
import { SurfaceScope } from "@/components/surface/surface-scope";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { usePermissions } from "@/hooks/use-permissions";
import { isElectron } from "@/lib/electron";
import {
getOnboardingPlugins,
type OnboardingPluginList,
} from "@/api/plugins-api";
import {
sortSteps,
type OnboardingCtx,
type OnboardingMode,
type OnboardingStepDef,
} from "./onboarding-registry";
import {
computeFlow,
ONBOARDING_ADMIN_PERMISSION,
seenFor,
} from "./onboarding-flow";
import { OnboardingStageContext } from "./onboarding-stage-context";
import {
useCanEnrollSecondFactor,
useOnboardingStepDefs,
} from "./use-onboarding-steps";
type BeforeNext = () => boolean | Promise<boolean>;
export interface OnboardingStageProps {
mode: OnboardingMode;
/** The picker's plugin list, when the boot gate already fetched it. */
initialPlugins?: OnboardingPluginList | null;
/** False on a first run, which shows before the app shell exists. */
shellReady: boolean;
onDone: () => void;
onLogout?: () => void;
}
/**
* Onboarding as a page of its own, laid out like the settings pages: one
* centred column with the step in a card and Back / Skip / Next under it.
* The same stage runs a new user's first run, a partial run of steps
* someone has not seen yet, and a rerun from settings.
*/
export function OnboardingStage({
mode,
initialPlugins = null,
shellReady,
onDone,
onLogout,
}: OnboardingStageProps) {
const { t } = useTranslation();
const prefs = useUiPreferencesContext();
const { has, loaded: permissionsLoaded } = usePermissions();
const isAdmin = has(ONBOARDING_ADMIN_PERMISSION);
const isDesktop = isElectron();
const canEnroll = useCanEnrollSecondFactor();
const [plugins, setPlugins] = useState<OnboardingPluginList | null>(
initialPlugins,
);
// A rerun or a partial run opened inside the app has no list yet, and
// whether the picker applies depends on it.
useEffect(() => {
if (plugins || !isAdmin) return;
let cancelled = false;
getOnboardingPlugins()
.then((list) => !cancelled && setPlugins(list))
.catch(() => {});
return () => {
cancelled = true;
};
}, [plugins, isAdmin]);
const allSteps = useOnboardingStepDefs();
const ctx = useMemo<OnboardingCtx>(
() => ({
mode,
isAdmin,
isDesktop,
has,
pluginSetupPending: !!plugins?.pending,
pluginsManagedElsewhere: !!plugins?.managedByLinkedServer,
canEnrollSecondFactor: canEnroll,
}),
[mode, isAdmin, isDesktop, has, plugins, canEnroll],
);
// What was seen when the run started. Marking steps as seen happens at the
// end, but the run itself must not shift under the user if it changes.
const seenAtStart = useRef(prefs?.preferences.onboarding.seen ?? {});
// Someone carried over from an older version gets their plugins' steps
// marked seen by the shell, not shown, so only core steps run for them.
const coreOnly = useRef(
mode === "partial" && !!prefs?.preferences.onboarding.baselinePending,
);
const sticky = useRef(new Set<string>());
const flow = useMemo(() => {
const candidates = coreOnly.current
? allSteps.filter((step) => !step.pluginId)
: allSteps;
const live = computeFlow(candidates, seenAtStart.current, ctx);
for (const step of live) sticky.current.add(step.id);
// A step that was in the run stays in it (the picker stops being
// pending once applied), unless its plugin went away.
const kept = allSteps.filter(
(step) =>
sticky.current.has(step.id) && !live.some((s) => s.id === step.id),
);
return sortSteps([...live, ...kept]);
}, [allSteps, ctx]);
const [currentId, setCurrentId] = useState<string | null>(null);
const [viewOpen, setViewOpen] = useState(false);
const lastPosition = useRef(0);
const [confirmed, setConfirmed] = useState<Set<string>>(() => new Set());
const [canContinue, setCanContinueMap] = useState<Record<string, boolean>>(
{},
);
const beforeNext = useRef<Record<string, BeforeNext | null>>({});
const [busy, setBusy] = useState(false);
const finishing = useRef(false);
// Track the current step by key: a step disappearing (its plugin was
// removed) moves on to whatever now sits in its place.
let index = flow.findIndex((step) => step.id === currentId);
if (index < 0) {
index = flow.findIndex((step) => step.position >= lastPosition.current);
if (index < 0) index = flow.length - 1;
}
const step: OnboardingStepDef | undefined = flow[index];
useEffect(() => {
if (!step) return;
lastPosition.current = step.position;
if (step.id !== currentId) setCurrentId(step.id);
}, [step, currentId]);
const finish = useCallback(
async (skipped: boolean) => {
if (finishing.current) return;
finishing.current = true;
prefs?.markOnboardingSeen(seenFor(flow), {
completed: true,
skipped,
});
await prefs?.flushNow().catch(() => {});
onDone();
},
[prefs, flow, onDone],
);
// Nothing to show (a partial run whose step vanished).
useEffect(() => {
if (permissionsLoaded && flow.length === 0) void finish(false);
}, [permissionsLoaded, flow.length, finish]);
const canSkip = !flow.some((s) => s.required && !confirmed.has(s.id));
const isLast = index === flow.length - 1;
const stepId = step?.id ?? "";
const stepCanContinue = canContinue[stepId] !== false;
const goNext = useCallback(async () => {
if (!step || busy) return;
const hook = beforeNext.current[step.id];
if (hook) {
setBusy(true);
let ok = false;
try {
ok = await hook();
} catch {
ok = false;
} finally {
setBusy(false);
}
if (!ok) return;
}
if (step.required) {
setConfirmed((prev) => new Set(prev).add(step.id));
}
if (isLast) {
void finish(false);
return;
}
setCurrentId(flow[index + 1].id);
}, [step, busy, isLast, finish, flow, index]);
const skip = useCallback(() => {
if (canSkip) void finish(true);
}, [canSkip, finish]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape" || mode === "full") return;
if (event.defaultPrevented) return;
skip();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [mode, skip]);
const setCanContinue = useCallback(
(ok: boolean) =>
setCanContinueMap((prev) =>
prev[stepId] === ok ? prev : { ...prev, [stepId]: ok },
),
[stepId],
);
const setBeforeNext = useCallback(
(fn: BeforeNext | null) => {
beforeNext.current[stepId] = fn;
},
[stepId],
);
const stageValue = useMemo(() => ({ plugins, setPlugins }), [plugins]);
if (!permissionsLoaded || !step) {
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-background">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
const StepComponent = step.Component;
const Icon = step.icon;
return (
<OnboardingStageContext.Provider value={stageValue}>
<div
className="fixed inset-0 z-[60] overflow-y-auto bg-background text-foreground"
role="dialog"
aria-modal="true"
aria-label={t("onboarding.stageLabel")}
>
<div className="mx-auto flex min-h-full w-full max-w-2xl flex-col gap-4 px-4 py-8 sm:py-14">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{mode === "partial"
? t("onboarding.partialHeading")
: t("onboarding.setupHeading")}
</span>
<span className="text-[11px] text-muted-foreground">
{t("onboarding.stepCounter", {
current: index + 1,
total: flow.length,
})}
</span>
</div>
{onLogout && mode === "full" && (
<Button
variant="ghost"
size="sm"
className="h-7 text-[11px] text-muted-foreground"
onClick={onLogout}
>
<LogOut size={12} />
{t("onboarding.signOut")}
</Button>
)}
</div>
<div className="flex gap-1">
{flow.map((s, i) => (
<div
key={s.id}
className={`h-0.5 flex-1 ${
i <= index ? "bg-accent-brand" : "bg-muted-foreground/20"
}`}
/>
))}
</div>
<SurfaceScope
kind="tab"
onEditingChange={(editing) => setViewOpen(!!editing)}
className={`relative flex flex-col border border-border bg-card ${
viewOpen ? "min-h-[70vh]" : ""
}`}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
{Icon && <Icon size={14} className="text-muted-foreground" />}
<span className="flex-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t(step.titleKey)}
</span>
</div>
<div className="flex flex-col gap-3 px-3 py-4 md:px-4">
{step.descriptionKey && (
<p className="text-xs text-muted-foreground">
{t(step.descriptionKey)}
</p>
)}
<StepComponent
key={step.id}
mode={mode}
isAdmin={isAdmin}
isDesktop={isDesktop}
shellReady={shellReady}
setCanContinue={setCanContinue}
setBeforeNext={setBeforeNext}
/>
</div>
</SurfaceScope>
<div className="flex items-center justify-between gap-3">
<div>
{canSkip && !isLast && (
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
onClick={skip}
>
{mode === "partial"
? t("onboarding.skipPartial")
: t("onboarding.skip")}
</Button>
)}
</div>
<div className="flex items-center gap-2">
{index > 0 && (
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
disabled={busy}
onClick={() => setCurrentId(flow[index - 1].id)}
>
<ArrowLeft size={13} />
{t("common.back")}
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-8 border-accent-brand/40 text-xs text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={busy || !stepCanContinue}
onClick={() => void goNext()}
>
{busy && <Loader2 size={13} className="animate-spin" />}
{isLast ? t("onboarding.finish") : t("onboarding.next")}
{!isLast && !busy && <ArrowRight size={13} />}
</Button>
</div>
</div>
</div>
</div>
</OnboardingStageContext.Provider>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { UiOnboardingState } from "@/types/ui-preferences";
import type { OnboardingCtx, OnboardingStepDef } from "./onboarding-registry";
/** The permission that makes someone an admin as far as onboarding goes. */
export const ONBOARDING_ADMIN_PERMISSION = "admin.plugins.manage";
export function isStepRelevant(
step: OnboardingStepDef,
ctx: OnboardingCtx,
): boolean {
if (step.audience === "admin" && !ctx.isAdmin) return false;
if (step.permission && !ctx.has(step.permission)) return false;
if (step.isRelevant) {
try {
if (!step.isRelevant(ctx)) return false;
} catch {
return false;
}
}
return true;
}
export function isStepPending(
step: OnboardingStepDef,
seen: Record<string, number>,
ctx: OnboardingCtx,
): boolean {
if ((seen[step.id] ?? 0) < step.version) return true;
try {
return step.forcePending?.(ctx) === true;
} catch {
return false;
}
}
/**
* The steps a run shows, in order. A full run or a rerun shows every step
* that applies to the user; a partial run only the ones they have not seen
* at their current version.
*/
export function computeFlow(
steps: OnboardingStepDef[],
seen: Record<string, number>,
ctx: OnboardingCtx,
): OnboardingStepDef[] {
const relevant = steps.filter((step) => isStepRelevant(step, ctx));
if (ctx.mode !== "partial") return relevant;
return relevant.filter((step) => isStepPending(step, seen, ctx));
}
/** A user who has never finished or skipped a run gets the full one. */
export function needsFullFlow(state: UiOnboardingState): boolean {
return Object.keys(state.seen).length === 0 && !state.completedAt;
}
/** What to record once a run ends, finished or skipped. */
export function seenFor(steps: OnboardingStepDef[]): Record<string, number> {
return Object.fromEntries(steps.map((step) => [step.id, step.version]));
}
/**
* For a user carried over from the old onboarding: every plugin step that
* exists right now counts as seen, so the upgrade does not show them a pile
* of steps for plugins they have been using all along.
*/
export function baselineSeen(
steps: OnboardingStepDef[],
): Record<string, number> {
return seenFor(steps.filter((step) => step.pluginId));
}
Loaded 100 of 3656 files, more files were not shown because too many files have changed in this diff. Show more