Compare commits

..

30 Commits

Author SHA1 Message Date
Owen Schwartz
63694032e8 Merge pull request #2511 from fosrl/dev
1.15.4-s.4
2026-02-20 20:45:23 -08:00
Owen Schwartz
b77aaedb58 Merge pull request #2510 from fosrl/crowdin_dev
New Crowdin updates
2026-02-20 20:44:45 -08:00
Owen Schwartz
a316d0301f New translations en-us.json (Norwegian Bokmal) 2026-02-20 20:43:45 -08:00
Owen Schwartz
dcd499720e New translations en-us.json (Chinese Simplified) 2026-02-20 20:43:44 -08:00
Owen Schwartz
e18fe21eca New translations en-us.json (Turkish) 2026-02-20 20:43:43 -08:00
Owen Schwartz
2970b51fb8 New translations en-us.json (Russian) 2026-02-20 20:43:42 -08:00
Owen Schwartz
b9236ff52e New translations en-us.json (Portuguese) 2026-02-20 20:43:41 -08:00
Owen Schwartz
38eb0ec7ed New translations en-us.json (Polish) 2026-02-20 20:43:39 -08:00
Owen Schwartz
ecba4a0b80 New translations en-us.json (Dutch) 2026-02-20 20:43:38 -08:00
Owen Schwartz
e6da18c952 New translations en-us.json (Korean) 2026-02-20 20:43:37 -08:00
Owen Schwartz
12941ac5ae New translations en-us.json (Italian) 2026-02-20 20:43:36 -08:00
Owen Schwartz
11085bda63 New translations en-us.json (German) 2026-02-20 20:43:35 -08:00
Owen Schwartz
c03211cc53 New translations en-us.json (Czech) 2026-02-20 20:43:33 -08:00
Owen Schwartz
2867459600 New translations en-us.json (Bulgarian) 2026-02-20 20:43:32 -08:00
Owen Schwartz
32b24db9bf New translations en-us.json (Spanish) 2026-02-20 20:43:31 -08:00
Owen Schwartz
660bf9ff87 New translations en-us.json (French) 2026-02-20 20:43:30 -08:00
miloschwartz
78c4ddebba set defaults 2026-02-20 20:35:57 -08:00
miloschwartz
f2dfadb37b show disabled button for owner 2026-02-20 20:28:47 -08:00
miloschwartz
3f2bdf081f allow edit admin role ssh and support null authDaemonMode 2026-02-20 20:25:14 -08:00
miloschwartz
d6ba34aeea set auth daemon type on resource 2026-02-20 17:33:21 -08:00
miloschwartz
6442eb12fb more visual adjustments 2026-02-20 10:43:25 -08:00
miloschwartz
01c15afa74 other visual adjustments 2026-02-19 23:41:04 -08:00
miloschwartz
4e88f1f38a more sidebar improvements 2026-02-19 22:41:14 -08:00
miloschwartz
13ab505f4d add ease to sidebar menu 2026-02-19 21:59:49 -08:00
miloschwartz
7d112aab27 improve alignment on sidebar 2026-02-19 21:52:47 -08:00
miloschwartz
eedf57af89 disable rybbit in saas 2026-02-19 17:54:40 -08:00
miloschwartz
7a01a4e090 ssh settings on a role 2026-02-19 17:53:11 -08:00
Owen
874794c996 Clean email 2026-02-18 14:07:50 -08:00
Owen
5e37c4e85f Resolve potential issues with processing roleIds 2026-02-18 13:55:04 -08:00
Owen
4e7eac368f Uniform ne check on niceId and dont reject clients 2026-02-18 11:56:01 -08:00
66 changed files with 3837 additions and 3589 deletions

View File

@@ -0,0 +1,121 @@
import { CommandModule } from "yargs";
import { db, orgs } from "@server/db";
import { eq } from "drizzle-orm";
import { encrypt } from "@server/lib/crypto";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { generateCA } from "@server/private/lib/sshCA";
import fs from "fs";
import yaml from "js-yaml";
type GenerateOrgCaKeysArgs = {
orgId: string;
secret?: string;
force?: boolean;
};
export const generateOrgCaKeys: CommandModule<{}, GenerateOrgCaKeysArgs> = {
command: "generate-org-ca-keys",
describe:
"Generate SSH CA public/private key pair for an organization and store them in the database (private key encrypted with server secret)",
builder: (yargs) => {
return yargs
.option("orgId", {
type: "string",
demandOption: true,
describe: "The organization ID"
})
.option("secret", {
type: "string",
describe:
"Server secret used to encrypt the CA private key. If omitted, read from config file (config.yml or config.yaml)."
})
.option("force", {
type: "boolean",
default: false,
describe:
"Overwrite existing CA keys for the org if they already exist"
});
},
handler: async (argv: {
orgId: string;
secret?: string;
force?: boolean;
}) => {
try {
const { orgId, force } = argv;
let secret = argv.secret;
if (!secret) {
const configPath = fs.existsSync(configFilePath1)
? configFilePath1
: fs.existsSync(configFilePath2)
? configFilePath2
: null;
if (!configPath) {
console.error(
"Error: No server secret provided and config file not found. " +
"Expected config.yml or config.yaml in the config directory, or pass --secret."
);
process.exit(1);
}
const configContent = fs.readFileSync(configPath, "utf8");
const config = yaml.load(configContent) as {
server?: { secret?: string };
};
if (!config?.server?.secret) {
console.error(
"Error: No server.secret in config file. Pass --secret or set server.secret in config."
);
process.exit(1);
}
secret = config.server.secret;
}
const [org] = await db
.select({
orgId: orgs.orgId,
sshCaPrivateKey: orgs.sshCaPrivateKey,
sshCaPublicKey: orgs.sshCaPublicKey
})
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
console.error(`Error: Organization with orgId "${orgId}" not found.`);
process.exit(1);
}
if (org.sshCaPrivateKey != null || org.sshCaPublicKey != null) {
if (!force) {
console.error(
"Error: This organization already has CA keys. Use --force to overwrite."
);
process.exit(1);
}
}
const ca = generateCA(`pangolin-ssh-ca-${orgId}`);
const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret);
await db
.update(orgs)
.set({
sshCaPrivateKey: encryptedPrivateKey,
sshCaPublicKey: ca.publicKeyOpenSSH
})
.where(eq(orgs.orgId, orgId));
console.log("SSH CA keys generated and stored for org:", orgId);
console.log("\nPublic key (OpenSSH format):");
console.log(ca.publicKeyOpenSSH);
process.exit(0);
} catch (error) {
console.error("Error generating org CA keys:", error);
process.exit(1);
}
}
};

View File

@@ -8,6 +8,7 @@ import { clearExitNodes } from "./commands/clearExitNodes";
import { rotateServerSecret } from "./commands/rotateServerSecret";
import { clearLicenseKeys } from "./commands/clearLicenseKeys";
import { deleteClient } from "./commands/deleteClient";
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
yargs(hideBin(process.argv))
.scriptName("pangctl")
@@ -17,5 +18,6 @@ yargs(hideBin(process.argv))
.command(rotateServerSecret)
.command(clearLicenseKeys)
.command(deleteClient)
.command(generateOrgCaKeys)
.demandCommand()
.help().argv;

View File

@@ -790,6 +790,7 @@
"accessRoleRemoved": "Ролята е премахната",
"accessRoleRemovedDescription": "Ролята беше успешно премахната.",
"accessRoleRequiredRemove": "Преди да изтриете тази роля, моля изберете нова роля, към която да прехвърлите настоящите членове.",
"network": "Мрежа",
"manage": "Управление",
"sitesNotFound": "Няма намерени сайтове.",
"pangolinServerAdmin": "Администратор на сървър - Панголин",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Частно",
"sidebarAccessControl": "Контрол на достъпа",
"sidebarLogsAndAnalytics": "Дневници и анализи",
"sidebarTeam": "Екип",
"sidebarUsers": "Потребители",
"sidebarAdmin": "Администратор",
"sidebarInvitations": "Покани",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Лог & Анализи",
"sidebarBluePrints": "Чертежи",
"sidebarOrganization": "Организация",
"sidebarManagement": "Управление",
"sidebarBillingAndLicenses": "Фактуриране & Лицензи",
"sidebarLogsAnalytics": "Анализи",
"blueprints": "Чертежи",
@@ -1289,7 +1292,6 @@
"parsedContents": "Парсирано съдържание (само за четене)",
"enableDockerSocket": "Активиране на Docker Чернова",
"enableDockerSocketDescription": "Активиране на Docker Socket маркировка за изтегляне на етикети на чернова. Пътят на гнездото трябва да бъде предоставен на Newt.",
"enableDockerSocketLink": "Научете повече",
"viewDockerContainers": "Преглед на Docker контейнери",
"containersIn": "Контейнери в {siteName}",
"selectContainerDescription": "Изберете контейнер, който да ползвате като име на хост за целта. Натиснете порт, за да ползвате порт",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "Времето е в секунди",
"requireDeviceApproval": "Изискват одобрение на устройства",
"requireDeviceApprovalDescription": "Потребители с тази роля трябва да имат нови устройства одобрени от администратор преди да могат да се свържат и да имат достъп до ресурси.",
"sshAccess": "SSH достъп",
"roleAllowSsh": "Разреши SSH",
"roleAllowSshAllow": "Разреши",
"roleAllowSshDisallow": "Забрани",
"roleAllowSshDescription": "Разреши на потребителите с тази роля да се свързват с ресурси чрез SSH. Когато е деактивирано, ролята не може да използва SSH достъп.",
"sshSudoMode": "Sudo достъп",
"sshSudoModeNone": "Няма",
"sshSudoModeNoneDescription": "Потребителят не може да изпълнява команди с sudo.",
"sshSudoModeFull": "Пълен Sudo",
"sshSudoModeFullDescription": "Потребителят може да изпълнява всяка команда с sudo.",
"sshSudoModeCommands": "Команди",
"sshSudoModeCommandsDescription": "Потребителят може да изпълнява само определени команди с sudo.",
"sshSudo": "Разреши sudo",
"sshSudoCommands": "Sudo команди",
"sshSudoCommandsDescription": "Списък с команди, които потребителят е разрешено да изпълнява с sudo.",
"sshCreateHomeDir": "Създай начална директория",
"sshUnixGroups": "Unix групи",
"sshUnixGroupsDescription": "Unix групи, в които да добавите потребителя на целевия хост.",
"retryAttempts": "Опити за повторно",
"expectedResponseCodes": "Очаквани кодове за отговор",
"expectedResponseCodesDescription": "HTTP статус код, указващ здравословно състояние. Ако бъде оставено празно, между 200-300 се счита за здравословно.",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "Контрол на достъпа.",
"editInternalResourceDialogAccessControlDescription": "Контролирайте кои роли, потребители и клиентски машини имат достъп до този ресурс, когато са свързани. Администраторите винаги имат достъп.",
"editInternalResourceDialogPortRangeValidationError": "Обхватът на портовете трябва да е \"*\" за всички портове или списък от разделени със запетая портове и диапазони (например: \"80,443,8000-9000\"). Портовете трябва да са между 1 и 65535.",
"internalResourceAuthDaemonStrategy": "Локация на SSH Auth Daemon",
"internalResourceAuthDaemonStrategyDescription": "Изберете къде ще работи демонът за SSH удостоверение: на сайта (Newt) или на отдалечен хост.",
"internalResourceAuthDaemonDescription": "Демонът за SSH удостоверение управлява подписването на SSH ключове и PAM удостоверение за този ресурс. Изберете дали да работи на сайта (Newt) или на отделен отдалечен хост. Вижте <docsLink>документацията</docsLink> за повече информация.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "Изберете стратегия",
"internalResourceAuthDaemonStrategyLabel": "Местоположение",
"internalResourceAuthDaemonSite": "На сайта",
"internalResourceAuthDaemonSiteDescription": "Демонът за удостоверение работи на сайта (Newt).",
"internalResourceAuthDaemonRemote": "Отдалечен хост",
"internalResourceAuthDaemonRemoteDescription": "Демонът за удостоверение работи на хост, който не е сайтът.",
"internalResourceAuthDaemonPort": "Порт на демона (незадължителен)",
"orgAuthWhatsThis": "Къде мога да намеря идентификатора на организацията си?",
"learnMore": "Научете повече.",
"backToHome": "Връщане към началната страница.",

View File

@@ -790,6 +790,7 @@
"accessRoleRemoved": "Role odstraněna",
"accessRoleRemovedDescription": "Role byla úspěšně odstraněna.",
"accessRoleRequiredRemove": "Před odstraněním této role vyberte novou roli, do které chcete převést existující členy.",
"network": "Síť",
"manage": "Spravovat",
"sitesNotFound": "Nebyly nalezeny žádné stránky.",
"pangolinServerAdmin": "Správce serveru - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Soukromé",
"sidebarAccessControl": "Kontrola přístupu",
"sidebarLogsAndAnalytics": "Logy & Analytika",
"sidebarTeam": "Tým",
"sidebarUsers": "Uživatelé",
"sidebarAdmin": "Admin",
"sidebarInvitations": "Pozvánky",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Plány",
"sidebarOrganization": "Organizace",
"sidebarManagement": "Správa",
"sidebarBillingAndLicenses": "Fakturace a licence",
"sidebarLogsAnalytics": "Analytici",
"blueprints": "Plány",
@@ -1289,7 +1292,6 @@
"parsedContents": "Parsed content (Pouze pro čtení)",
"enableDockerSocket": "Povolit Docker plán",
"enableDockerSocketDescription": "Povolte seškrábání štítků na Docker Socket pro popisky plánů. Nová cesta musí být k dispozici.",
"enableDockerSocketLink": "Zjistit více",
"viewDockerContainers": "Zobrazit kontejnery Dockeru",
"containersIn": "Kontejnery v {siteName}",
"selectContainerDescription": "Vyberte jakýkoli kontejner pro použití jako název hostitele pro tento cíl. Klikněte na port pro použití portu.",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "Čas je v sekundách",
"requireDeviceApproval": "Vyžadovat schválení zařízení",
"requireDeviceApprovalDescription": "Uživatelé s touto rolí potřebují nová zařízení schválená správcem, než se mohou připojit a přistupovat ke zdrojům.",
"sshAccess": "SSH přístup",
"roleAllowSsh": "Povolit SSH",
"roleAllowSshAllow": "Povolit",
"roleAllowSshDisallow": "Zakázat",
"roleAllowSshDescription": "Povolit uživatelům s touto rolí připojení k zdrojům přes SSH. Je-li zakázáno, role nemůže používat přístup SSH.",
"sshSudoMode": "Súdánský přístup",
"sshSudoModeNone": "Nic",
"sshSudoModeNoneDescription": "Uživatel nemůže spouštět příkazy se sudo.",
"sshSudoModeFull": "Úplný Súdán",
"sshSudoModeFullDescription": "Uživatel může spustit libovolný příkaz se sudo.",
"sshSudoModeCommands": "Příkazy",
"sshSudoModeCommandsDescription": "Uživatel může spustit pouze zadané příkazy s sudo.",
"sshSudo": "Povolit sudo",
"sshSudoCommands": "Sudo příkazy",
"sshSudoCommandsDescription": "Seznam příkazů, které může uživatel spouštět s sudo.",
"sshCreateHomeDir": "Vytvořit domovský adresář",
"sshUnixGroups": "Unixové skupiny",
"sshUnixGroupsDescription": "Unix skupiny přidají uživatele do cílového hostitele.",
"retryAttempts": "Opakovat pokusy",
"expectedResponseCodes": "Očekávané kódy odezvy",
"expectedResponseCodesDescription": "HTTP kód stavu, který označuje zdravý stav. Ponecháte-li prázdné, 200-300 je považováno za zdravé.",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "Řízení přístupu",
"editInternalResourceDialogAccessControlDescription": "Kontrolujte, které role, uživatelé a klienti mohou přistupovat k tomuto prostředku, když jsou připojeni. Admini mají vždy přístup.",
"editInternalResourceDialogPortRangeValidationError": "Rozsah portů musí být \"*\" pro všechny porty, nebo seznam portů a rozsahů oddělených čárkou (např. \"80,443,8000-9000\"). Porty musí být mezi 1 a 65535.",
"internalResourceAuthDaemonStrategy": "SSH Auth Démon umístění",
"internalResourceAuthDaemonStrategyDescription": "Zvolte, kde běží SSH autentizační démon: na stránce (Newt) nebo na vzdáleném serveru.",
"internalResourceAuthDaemonDescription": "SSH autentizační daemon zpracovává podpis SSH klíče a PAM autentizaci tohoto zdroje. Vyberte si, zda běží na webu (Newt) nebo na samostatném vzdáleném serveru. Více informací najdete v <docsLink>dokumentaci</docsLink>.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "Vybrat strategii",
"internalResourceAuthDaemonStrategyLabel": "Poloha",
"internalResourceAuthDaemonSite": "Na stránce",
"internalResourceAuthDaemonSiteDescription": "Auth daemon běží na webu (Newt).",
"internalResourceAuthDaemonRemote": "Vzdálený server",
"internalResourceAuthDaemonRemoteDescription": "Auth daemon běží na hostitele, který není web.",
"internalResourceAuthDaemonPort": "Daemon port (volitelné)",
"orgAuthWhatsThis": "Kde najdu ID mé organizace?",
"learnMore": "Zjistit více",
"backToHome": "Zpět na domovskou stránku",

View File

@@ -790,6 +790,7 @@
"accessRoleRemoved": "Rolle entfernt",
"accessRoleRemovedDescription": "Die Rolle wurde erfolgreich entfernt.",
"accessRoleRequiredRemove": "Bevor Sie diese Rolle löschen, wählen Sie bitte eine neue Rolle aus, zu der die bestehenden Mitglieder übertragen werden sollen.",
"network": "Netzwerk",
"manage": "Verwalten",
"sitesNotFound": "Keine Standorte gefunden.",
"pangolinServerAdmin": "Server-Admin - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Privat",
"sidebarAccessControl": "Zugriffskontrolle",
"sidebarLogsAndAnalytics": "Protokolle & Analysen",
"sidebarTeam": "Team",
"sidebarUsers": "Benutzer",
"sidebarAdmin": "Admin",
"sidebarInvitations": "Einladungen",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Log & Analytik",
"sidebarBluePrints": "Blaupausen",
"sidebarOrganization": "Organisation",
"sidebarManagement": "Management",
"sidebarBillingAndLicenses": "Abrechnung & Lizenzen",
"sidebarLogsAnalytics": "Analytik",
"blueprints": "Blaupausen",
@@ -1289,7 +1292,6 @@
"parsedContents": "Analysierte Inhalte (Nur lesen)",
"enableDockerSocket": "Docker Blueprint aktivieren",
"enableDockerSocketDescription": "Aktiviere Docker-Socket-Label-Scraping für Blueprintbeschriftungen. Der Socket-Pfad muss neu angegeben werden.",
"enableDockerSocketLink": "Mehr erfahren",
"viewDockerContainers": "Docker Container anzeigen",
"containersIn": "Container in {siteName}",
"selectContainerDescription": "Wählen Sie einen Container, der als Hostname für dieses Ziel verwendet werden soll. Klicken Sie auf einen Port, um einen Port zu verwenden.",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "Zeit ist in Sekunden",
"requireDeviceApproval": "Gerätegenehmigungen erforderlich",
"requireDeviceApprovalDescription": "Benutzer mit dieser Rolle benötigen neue Geräte, die von einem Administrator genehmigt wurden, bevor sie sich verbinden und auf Ressourcen zugreifen können.",
"sshAccess": "SSH-Zugriff",
"roleAllowSsh": "SSH erlauben",
"roleAllowSshAllow": "Erlauben",
"roleAllowSshDisallow": "Nicht zulassen",
"roleAllowSshDescription": "Benutzern mit dieser Rolle erlauben, sich über SSH mit Ressourcen zu verbinden. Wenn deaktiviert, kann die Rolle keinen SSH-Zugriff verwenden.",
"sshSudoMode": "Sudo-Zugriff",
"sshSudoModeNone": "Keine",
"sshSudoModeNoneDescription": "Benutzer kann keine Befehle mit sudo ausführen.",
"sshSudoModeFull": "Volles Sudo",
"sshSudoModeFullDescription": "Benutzer kann jeden Befehl mit sudo ausführen.",
"sshSudoModeCommands": "Befehle",
"sshSudoModeCommandsDescription": "Benutzer kann nur die angegebenen Befehle mit sudo ausführen.",
"sshSudo": "sudo erlauben",
"sshSudoCommands": "Sudo-Befehle",
"sshSudoCommandsDescription": "Liste der Befehle, die der Benutzer mit sudo ausführen darf.",
"sshCreateHomeDir": "Home-Verzeichnis erstellen",
"sshUnixGroups": "Unix-Gruppen",
"sshUnixGroupsDescription": "Unix-Gruppen, zu denen der Benutzer auf dem Ziel-Host hinzugefügt wird.",
"retryAttempts": "Wiederholungsversuche",
"expectedResponseCodes": "Erwartete Antwortcodes",
"expectedResponseCodesDescription": "HTTP-Statuscode, der einen gesunden Zustand anzeigt. Wenn leer gelassen, wird 200-300 als gesund angesehen.",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "Zugriffskontrolle",
"editInternalResourceDialogAccessControlDescription": "Kontrollieren Sie, welche Rollen, Benutzer und Maschinen-Clients Zugriff auf diese Ressource haben, wenn sie verbunden sind. Admins haben immer Zugriff.",
"editInternalResourceDialogPortRangeValidationError": "Der Port-Bereich muss \"*\" für alle Ports sein, oder eine kommaseparierte Liste von Ports und Bereichen (z.B. \"80,443.8000-9000\"). Ports müssen zwischen 1 und 65535 liegen.",
"internalResourceAuthDaemonStrategy": "SSH Auth-Daemon Standort",
"internalResourceAuthDaemonStrategyDescription": "Wählen Sie aus, wo der SSH-Authentifizierungs-Daemon läuft: auf der Site (Newt) oder auf einem entfernten Host.",
"internalResourceAuthDaemonDescription": "Der SSH-Authentifizierungs-Daemon verarbeitet SSH-Schlüsselsignaturen und PAM-Authentifizierung für diese Ressource. Wählen Sie, ob sie auf der Website (Newt) oder auf einem separaten entfernten Host ausgeführt wird. Siehe <docsLink>die Dokumentation</docsLink> für mehr.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "Strategie auswählen",
"internalResourceAuthDaemonStrategyLabel": "Standort",
"internalResourceAuthDaemonSite": "Vor Ort",
"internalResourceAuthDaemonSiteDescription": "Der Auth Daemon läuft auf der Seite (Newt).",
"internalResourceAuthDaemonRemote": "Entfernter Host",
"internalResourceAuthDaemonRemoteDescription": "Der Auth Daemon läuft auf einem Host, der nicht die Site ist.",
"internalResourceAuthDaemonPort": "Daemon-Port (optional)",
"orgAuthWhatsThis": "Wo finde ich meine Organisations-ID?",
"learnMore": "Mehr erfahren",
"backToHome": "Zurück zur Startseite",

View File

@@ -649,7 +649,7 @@
"resourcesUsersRolesAccess": "User and role-based access control",
"resourcesErrorUpdate": "Failed to toggle resource",
"resourcesErrorUpdateDescription": "An error occurred while updating the resource",
"access": "Access",
"access": "Access Control",
"shareLink": "{resource} Share Link",
"resourceSelect": "Select resource",
"shareLinks": "Share Links",
@@ -790,6 +790,7 @@
"accessRoleRemoved": "Role removed",
"accessRoleRemovedDescription": "The role has been successfully removed.",
"accessRoleRequiredRemove": "Before deleting this role, please select a new role to transfer existing members to.",
"network": "Network",
"manage": "Manage",
"sitesNotFound": "No sites found.",
"pangolinServerAdmin": "Server Admin - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Private",
"sidebarAccessControl": "Access Control",
"sidebarLogsAndAnalytics": "Logs & Analytics",
"sidebarTeam": "Team",
"sidebarUsers": "Users",
"sidebarAdmin": "Admin",
"sidebarInvitations": "Invitations",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Blueprints",
"sidebarOrganization": "Organization",
"sidebarManagement": "Management",
"sidebarBillingAndLicenses": "Billing & Licenses",
"sidebarLogsAnalytics": "Analytics",
"blueprints": "Blueprints",
@@ -1288,8 +1291,7 @@
"contents": "Contents",
"parsedContents": "Parsed Contents (Read Only)",
"enableDockerSocket": "Enable Docker Blueprint",
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.",
"enableDockerSocketLink": "Learn More",
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt. Read about how this works in <docsLink>the documentation</docsLink>.",
"viewDockerContainers": "View Docker Containers",
"containersIn": "Containers in {siteName}",
"selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "Time is in seconds",
"requireDeviceApproval": "Require Device Approvals",
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
"sshAccess": "SSH Access",
"roleAllowSsh": "Allow SSH",
"roleAllowSshAllow": "Allow",
"roleAllowSshDisallow": "Disallow",
"roleAllowSshDescription": "Allow users with this role to connect to resources via SSH. When disabled, the role cannot use SSH access.",
"sshSudoMode": "Sudo Access",
"sshSudoModeNone": "None",
"sshSudoModeNoneDescription": "User cannot run commands with sudo.",
"sshSudoModeFull": "Full Sudo",
"sshSudoModeFullDescription": "User can run any command with sudo.",
"sshSudoModeCommands": "Commands",
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo.",
"sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host.",
"retryAttempts": "Retry Attempts",
"expectedResponseCodes": "Expected Response Codes",
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
@@ -1988,8 +2008,8 @@
"orgAuthNoAccount": "Don't have an account?",
"subscriptionRequiredToUse": "A subscription is required to use this feature.",
"mustUpgradeToUse": "You must upgrade your subscription to use this feature.",
"subscriptionRequiredTierToUse": "This feature requires <tierLink>{tier}</tierLink> or higher.",
"upgradeToTierToUse": "Upgrade to <tierLink>{tier}</tierLink> or higher to use this feature.",
"subscriptionRequiredTierToUse": "This feature requires <tierLink>{tier}</tierLink>.",
"upgradeToTierToUse": "Upgrade to <tierLink>{tier}</tierLink> to use this feature.",
"subscriptionTierTier1": "Home",
"subscriptionTierTier2": "Team",
"subscriptionTierTier3": "Business",
@@ -2079,7 +2099,7 @@
"manageMachineClients": "Manage Machine Clients",
"manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources",
"machineClientsBannerTitle": "Servers & Automated Systems",
"machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can run with Pangolin CLI, Olm CLI, or Olm as a container.",
"machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can be deployed as a CLI or a container.",
"machineClientsBannerPangolinCLI": "Pangolin CLI",
"machineClientsBannerOlmCLI": "Olm CLI",
"machineClientsBannerOlmContainer": "Container",
@@ -2305,7 +2325,7 @@
"logRetentionEndOfFollowingYear": "End of following year",
"actionLogsDescription": "View a history of actions performed in this organization",
"accessLogsDescription": "View access auth requests for resources in this organization",
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature.",
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.",
"certResolver": "Certificate Resolver",
"certResolverDescription": "Select the certificate resolver to use for this resource.",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "Access Control",
"editInternalResourceDialogAccessControlDescription": "Control which roles, users, and machine clients have access to this resource when connected. Admins always have access.",
"editInternalResourceDialogPortRangeValidationError": "Port range must be \"*\" for all ports, or a comma-separated list of ports and ranges (e.g., \"80,443,8000-9000\"). Ports must be between 1 and 65535.",
"internalResourceAuthDaemonStrategy": "SSH Auth Daemon Location",
"internalResourceAuthDaemonStrategyDescription": "Choose where the SSH authentication daemon runs: on the site (Newt) or on a remote host.",
"internalResourceAuthDaemonDescription": "The SSH authentication daemon handles SSH key signing and PAM authentication for this resource. Choose whether it runs on the site (Newt) or on a separate remote host. See <docsLink>the documentation</docsLink> for more.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "Select Strategy",
"internalResourceAuthDaemonStrategyLabel": "Location",
"internalResourceAuthDaemonSite": "On Site",
"internalResourceAuthDaemonSiteDescription": "Auth daemon runs on the site (Newt).",
"internalResourceAuthDaemonRemote": "Remote Host",
"internalResourceAuthDaemonRemoteDescription": "Auth daemon runs on a host that is not the site.",
"internalResourceAuthDaemonPort": "Daemon Port (optional)",
"orgAuthWhatsThis": "Where can I find my organization ID?",
"learnMore": "Learn more",
"backToHome": "Go back to home",

View File

@@ -790,6 +790,7 @@
"accessRoleRemoved": "Rol eliminado",
"accessRoleRemovedDescription": "El rol se ha eliminado correctamente.",
"accessRoleRequiredRemove": "Antes de eliminar este rol, seleccione un nuevo rol al que transferir miembros existentes.",
"network": "Red",
"manage": "Gestionar",
"sitesNotFound": "Sitios no encontrados.",
"pangolinServerAdmin": "Admin Servidor - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Privado",
"sidebarAccessControl": "Control de acceso",
"sidebarLogsAndAnalytics": "Registros y análisis",
"sidebarTeam": "Equipo",
"sidebarUsers": "Usuarios",
"sidebarAdmin": "Admin",
"sidebarInvitations": "Invitaciones",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Registro y análisis",
"sidebarBluePrints": "Planos",
"sidebarOrganization": "Organización",
"sidebarManagement": "Gestión",
"sidebarBillingAndLicenses": "Facturación y licencias",
"sidebarLogsAnalytics": "Analíticas",
"blueprints": "Planos",
@@ -1289,7 +1292,6 @@
"parsedContents": "Contenido analizado (Sólo lectura)",
"enableDockerSocket": "Habilitar Plano Docker",
"enableDockerSocketDescription": "Activar el raspado de etiquetas de Socket Docker para etiquetas de planos. La ruta del Socket debe proporcionarse a Newt.",
"enableDockerSocketLink": "Saber más",
"viewDockerContainers": "Ver contenedores Docker",
"containersIn": "Contenedores en {siteName}",
"selectContainerDescription": "Seleccione cualquier contenedor para usar como nombre de host para este objetivo. Haga clic en un puerto para usar un puerto.",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "El tiempo está en segundos",
"requireDeviceApproval": "Requiere aprobaciones del dispositivo",
"requireDeviceApprovalDescription": "Los usuarios con este rol necesitan nuevos dispositivos aprobados por un administrador antes de poder conectarse y acceder a los recursos.",
"sshAccess": "Acceso a SSH",
"roleAllowSsh": "Permitir SSH",
"roleAllowSshAllow": "Permitir",
"roleAllowSshDisallow": "Rechazar",
"roleAllowSshDescription": "Permitir a los usuarios con este rol conectarse a recursos a través de SSH. Cuando está desactivado, el rol no puede usar acceso SSH.",
"sshSudoMode": "Acceso Sudo",
"sshSudoModeNone": "Ninguna",
"sshSudoModeNoneDescription": "El usuario no puede ejecutar comandos con sudo.",
"sshSudoModeFull": "Sudo completo",
"sshSudoModeFullDescription": "El usuario puede ejecutar cualquier comando con sudo.",
"sshSudoModeCommands": "Comandos",
"sshSudoModeCommandsDescription": "El usuario sólo puede ejecutar los comandos especificados con sudo.",
"sshSudo": "Permitir sudo",
"sshSudoCommands": "Comandos Sudo",
"sshSudoCommandsDescription": "Lista de comandos que el usuario puede ejecutar con sudo.",
"sshCreateHomeDir": "Crear directorio principal",
"sshUnixGroups": "Grupos Unix",
"sshUnixGroupsDescription": "Grupos Unix para agregar el usuario en el host de destino.",
"retryAttempts": "Intentos de Reintento",
"expectedResponseCodes": "Códigos de respuesta esperados",
"expectedResponseCodesDescription": "Código de estado HTTP que indica un estado saludable. Si se deja en blanco, se considera saludable de 200 a 300.",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "Control de acceso",
"editInternalResourceDialogAccessControlDescription": "Controla qué roles, usuarios y clientes de máquinas tienen acceso a este recurso cuando están conectados. Los administradores siempre tienen acceso.",
"editInternalResourceDialogPortRangeValidationError": "El rango de puertos debe ser \"*\" para todos los puertos, o una lista separada por comas de puertos y rangos (por ejemplo, \"80,443,8000-9000\"). Los puertos deben estar entre 1 y 65535.",
"internalResourceAuthDaemonStrategy": "Ubicación del demonio de autenticación SSSH",
"internalResourceAuthDaemonStrategyDescription": "Elija dónde se ejecuta el daemon de autenticación SSH: en el sitio (Newt) o en un host remoto.",
"internalResourceAuthDaemonDescription": "El daemon de autenticación SSSH maneja la firma de claves SSH y autenticación PAM para este recurso. Elija si se ejecuta en el sitio (Newt) o en un host remoto separado. Vea <docsLink>la documentación</docsLink> para más.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "Seleccionar estrategia",
"internalResourceAuthDaemonStrategyLabel": "Ubicación",
"internalResourceAuthDaemonSite": "En el sitio",
"internalResourceAuthDaemonSiteDescription": "Auth daemon corre en el sitio (Newt).",
"internalResourceAuthDaemonRemote": "Host remoto",
"internalResourceAuthDaemonRemoteDescription": "El daemon Auth corre en un host que no es el sitio.",
"internalResourceAuthDaemonPort": "Puerto de demonio (opcional)",
"orgAuthWhatsThis": "¿Dónde puedo encontrar el ID de mi organización?",
"learnMore": "Más información",
"backToHome": "Volver a inicio",

View File

@@ -790,6 +790,7 @@
"accessRoleRemoved": "Rôle supprimé",
"accessRoleRemovedDescription": "Le rôle a été supprimé avec succès.",
"accessRoleRequiredRemove": "Avant de supprimer ce rôle, veuillez sélectionner un nouveau rôle pour transférer les membres existants.",
"network": "Réseau",
"manage": "Gérer",
"sitesNotFound": "Aucun site trouvé.",
"pangolinServerAdmin": "Admin Serveur - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Privé",
"sidebarAccessControl": "Contrôle d'accès",
"sidebarLogsAndAnalytics": "Journaux & Analytiques",
"sidebarTeam": "Equipe",
"sidebarUsers": "Utilisateurs",
"sidebarAdmin": "Administrateur",
"sidebarInvitations": "Invitations",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Journaux & Analytiques",
"sidebarBluePrints": "Configs",
"sidebarOrganization": "Organisation",
"sidebarManagement": "Gestion",
"sidebarBillingAndLicenses": "Facturation & Licences",
"sidebarLogsAnalytics": "Analyses",
"blueprints": "Configs",
@@ -1289,7 +1292,6 @@
"parsedContents": "Contenu analysé (lecture seule)",
"enableDockerSocket": "Activer la Config Docker",
"enableDockerSocketDescription": "Activer le ramassage d'étiquettes de socket Docker pour les étiquettes de plan. Le chemin de socket doit être fourni à Newt.",
"enableDockerSocketLink": "En savoir plus",
"viewDockerContainers": "Voir les conteneurs Docker",
"containersIn": "Conteneurs en {siteName}",
"selectContainerDescription": "Sélectionnez n'importe quel conteneur à utiliser comme nom d'hôte pour cette cible. Cliquez sur un port pour utiliser un port.",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "Le temps est exprimé en secondes",
"requireDeviceApproval": "Exiger les autorisations de l'appareil",
"requireDeviceApprovalDescription": "Les utilisateurs ayant ce rôle ont besoin de nouveaux périphériques approuvés par un administrateur avant de pouvoir se connecter et accéder aux ressources.",
"sshAccess": "Accès SSH",
"roleAllowSsh": "Autoriser SSH",
"roleAllowSshAllow": "Autoriser",
"roleAllowSshDisallow": "Interdire",
"roleAllowSshDescription": "Autoriser les utilisateurs avec ce rôle à se connecter aux ressources via SSH. Lorsque désactivé, le rôle ne peut pas utiliser les accès SSH.",
"sshSudoMode": "Accès Sudo",
"sshSudoModeNone": "Aucun",
"sshSudoModeNoneDescription": "L'utilisateur ne peut pas exécuter de commandes avec sudo.",
"sshSudoModeFull": "Sudo complet",
"sshSudoModeFullDescription": "L'utilisateur peut exécuter n'importe quelle commande avec sudo.",
"sshSudoModeCommands": "Commandes",
"sshSudoModeCommandsDescription": "L'utilisateur ne peut exécuter que les commandes spécifiées avec sudo.",
"sshSudo": "Autoriser sudo",
"sshSudoCommands": "Commandes Sudo",
"sshSudoCommandsDescription": "Liste des commandes que l'utilisateur est autorisé à exécuter avec sudo.",
"sshCreateHomeDir": "Créer un répertoire personnel",
"sshUnixGroups": "Groupes Unix",
"sshUnixGroupsDescription": "Groupes Unix à ajouter à l'utilisateur sur l'hôte cible.",
"retryAttempts": "Tentatives de réessai",
"expectedResponseCodes": "Codes de réponse attendus",
"expectedResponseCodesDescription": "Code de statut HTTP indiquant un état de santé satisfaisant. Si non renseigné, 200-300 est considéré comme satisfaisant.",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "Contrôle d'accès",
"editInternalResourceDialogAccessControlDescription": "Contrôlez quels rôles, utilisateurs et clients de machine ont accès à cette ressource lorsqu'ils sont connectés. Les administrateurs ont toujours accès.",
"editInternalResourceDialogPortRangeValidationError": "La plage de ports doit être \"*\" pour tous les ports, ou une liste de ports et de plages séparés par des virgules (par exemple, \"80,443,8000-9000\"). Les ports doivent être compris entre 1 et 65535.",
"internalResourceAuthDaemonStrategy": "Emplacement du démon d'authentification SSH",
"internalResourceAuthDaemonStrategyDescription": "Choisissez où le démon d'authentification SSH s'exécute : sur le site (Newt) ou sur un hôte distant.",
"internalResourceAuthDaemonDescription": "Le démon d'authentification SSH gère la signature des clés SSH et l'authentification PAM pour cette ressource. Choisissez s'il fonctionne sur le site (Newt) ou sur un hôte distant séparé. Voir <docsLink>la documentation</docsLink> pour plus d'informations.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "Choisir une stratégie",
"internalResourceAuthDaemonStrategyLabel": "Localisation",
"internalResourceAuthDaemonSite": "Sur le site",
"internalResourceAuthDaemonSiteDescription": "Le démon Auth fonctionne sur le site (Newt).",
"internalResourceAuthDaemonRemote": "Hôte distant",
"internalResourceAuthDaemonRemoteDescription": "Le démon Auth fonctionne sur un hôte qui n'est pas le site.",
"internalResourceAuthDaemonPort": "Port du démon (optionnel)",
"orgAuthWhatsThis": "Où puis-je trouver mon identifiant d'organisation ?",
"learnMore": "En savoir plus",
"backToHome": "Retour à l'accueil",

View File

@@ -790,6 +790,7 @@
"accessRoleRemoved": "Ruolo rimosso",
"accessRoleRemovedDescription": "Il ruolo è stato rimosso con successo.",
"accessRoleRequiredRemove": "Prima di eliminare questo ruolo, seleziona un nuovo ruolo a cui trasferire i membri esistenti.",
"network": "Rete",
"manage": "Gestisci",
"sitesNotFound": "Nessun sito trovato.",
"pangolinServerAdmin": "Server Admin - Pangolina",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Privato",
"sidebarAccessControl": "Controllo Accesso",
"sidebarLogsAndAnalytics": "Registri E Analisi",
"sidebarTeam": "Squadra",
"sidebarUsers": "Utenti",
"sidebarAdmin": "Amministratore",
"sidebarInvitations": "Inviti",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Progetti",
"sidebarOrganization": "Organizzazione",
"sidebarManagement": "Gestione",
"sidebarBillingAndLicenses": "Fatturazione E Licenze",
"sidebarLogsAnalytics": "Analisi",
"blueprints": "Progetti",
@@ -1289,7 +1292,6 @@
"parsedContents": "Sommario Analizzato (Solo Lettura)",
"enableDockerSocket": "Abilita Progetto Docker",
"enableDockerSocketDescription": "Abilita la raschiatura dell'etichetta Docker Socket per le etichette dei progetti. Il percorso del socket deve essere fornito a Newt.",
"enableDockerSocketLink": "Scopri di più",
"viewDockerContainers": "Visualizza Contenitori Docker",
"containersIn": "Contenitori in {siteName}",
"selectContainerDescription": "Seleziona qualsiasi contenitore da usare come hostname per questo obiettivo. Fai clic su una porta per usare una porta.",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "Il tempo è in secondi",
"requireDeviceApproval": "Richiede Approvazioni Dispositivo",
"requireDeviceApprovalDescription": "Gli utenti con questo ruolo hanno bisogno di nuovi dispositivi approvati da un amministratore prima di poter connettersi e accedere alle risorse.",
"sshAccess": "Accesso SSH",
"roleAllowSsh": "Consenti SSH",
"roleAllowSshAllow": "Consenti",
"roleAllowSshDisallow": "Non Consentire",
"roleAllowSshDescription": "Consenti agli utenti con questo ruolo di connettersi alle risorse tramite SSH. Quando disabilitato, il ruolo non può utilizzare l'accesso SSH.",
"sshSudoMode": "Accesso Sudo",
"sshSudoModeNone": "Nessuno",
"sshSudoModeNoneDescription": "L'utente non può eseguire comandi con sudo.",
"sshSudoModeFull": "Sudo Completo",
"sshSudoModeFullDescription": "L'utente può eseguire qualsiasi comando con sudo.",
"sshSudoModeCommands": "Comandi",
"sshSudoModeCommandsDescription": "L'utente può eseguire solo i comandi specificati con sudo.",
"sshSudo": "Consenti sudo",
"sshSudoCommands": "Comandi Sudo",
"sshSudoCommandsDescription": "Elenco di comandi che l'utente può eseguire con sudo.",
"sshCreateHomeDir": "Crea Cartella Home",
"sshUnixGroups": "Gruppi Unix",
"sshUnixGroupsDescription": "Gruppi Unix su cui aggiungere l'utente sull'host di destinazione.",
"retryAttempts": "Tentativi di Riprova",
"expectedResponseCodes": "Codici di Risposta Attesi",
"expectedResponseCodesDescription": "Codice di stato HTTP che indica lo stato di salute. Se lasciato vuoto, considerato sano è compreso tra 200-300.",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "Controllo Accesso",
"editInternalResourceDialogAccessControlDescription": "Controlla quali ruoli, utenti e client macchina hanno accesso a questa risorsa quando connessi. Gli amministratori hanno sempre accesso.",
"editInternalResourceDialogPortRangeValidationError": "Il range delle porte deve essere \"*\" per tutte le porte, o un elenco di porte e intervalli separato da virgole (ad es. \"80,443,8000-9000\"). Le porte devono essere tra 1 e 65535.",
"internalResourceAuthDaemonStrategy": "Posizione Demone Autenticazione SSH",
"internalResourceAuthDaemonStrategyDescription": "Scegli dove funziona il demone di autenticazione SSH: sul sito (Newt) o su un host remoto.",
"internalResourceAuthDaemonDescription": "Il demone di autenticazione SSH gestisce la firma della chiave SSH e l'autenticazione PAM per questa risorsa. Scegli se viene eseguito sul sito (Newt) o su un host remoto separato. Vedi <docsLink>la documentazione</docsLink> per ulteriori informazioni.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "Seleziona Strategia",
"internalResourceAuthDaemonStrategyLabel": "Posizione",
"internalResourceAuthDaemonSite": "Sul Sito",
"internalResourceAuthDaemonSiteDescription": "Il demone Auth viene eseguito sul sito (Nuovo).",
"internalResourceAuthDaemonRemote": "Host Remoto",
"internalResourceAuthDaemonRemoteDescription": "Il demone di autenticazione viene eseguito su un host che non è il sito.",
"internalResourceAuthDaemonPort": "Porta Demone (facoltativa)",
"orgAuthWhatsThis": "Dove posso trovare l'ID della mia organizzazione?",
"learnMore": "Scopri di più",
"backToHome": "Torna alla home",

View File

@@ -790,6 +790,7 @@
"accessRoleRemoved": "역할이 제거되었습니다",
"accessRoleRemovedDescription": "역할이 성공적으로 제거되었습니다.",
"accessRoleRequiredRemove": "이 역할을 삭제하기 전에 기존 구성원을 전송할 새 역할을 선택하세요.",
"network": "네트워크",
"manage": "관리",
"sitesNotFound": "사이트를 찾을 수 없습니다.",
"pangolinServerAdmin": "서버 관리자 - 판골린",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "비공개",
"sidebarAccessControl": "액세스 제어",
"sidebarLogsAndAnalytics": "로그 및 분석",
"sidebarTeam": "팀",
"sidebarUsers": "사용자",
"sidebarAdmin": "관리자",
"sidebarInvitations": "초대",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "로그 & 통계",
"sidebarBluePrints": "청사진",
"sidebarOrganization": "조직",
"sidebarManagement": "관리",
"sidebarBillingAndLicenses": "결제 및 라이선스",
"sidebarLogsAnalytics": "분석",
"blueprints": "청사진",
@@ -1289,7 +1292,6 @@
"parsedContents": "구문 분석된 콘텐츠 (읽기 전용)",
"enableDockerSocket": "Docker 청사진 활성화",
"enableDockerSocketDescription": "블루프린트 레이블을 위한 Docker 소켓 레이블 수집을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.",
"enableDockerSocketLink": "자세히 알아보기",
"viewDockerContainers": "도커 컨테이너 보기",
"containersIn": "{siteName}의 컨테이너",
"selectContainerDescription": "이 대상을 위한 호스트 이름으로 사용할 컨테이너를 선택하세요. 포트를 사용하려면 포트를 클릭하세요.",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "시간은 초 단위입니다",
"requireDeviceApproval": "장치 승인 요구",
"requireDeviceApprovalDescription": "이 역할을 가진 사용자는 장치가 연결되기 전에 관리자의 승인이 필요합니다.",
"sshAccess": "SSH 접속",
"roleAllowSsh": "SSH 허용",
"roleAllowSshAllow": "허용",
"roleAllowSshDisallow": "허용 안 함",
"roleAllowSshDescription": "이 역할을 가진 사용자가 SSH를 통해 리소스에 연결할 수 있도록 허용합니다. 비활성화되면 역할은 SSH 접속을 사용할 수 없습니다.",
"sshSudoMode": "Sudo 접속",
"sshSudoModeNone": "없음",
"sshSudoModeNoneDescription": "사용자는 sudo로 명령을 실행할 수 없습니다.",
"sshSudoModeFull": "전체 Sudo",
"sshSudoModeFullDescription": "사용자는 모든 명령을 sudo로 실행할 수 있습니다.",
"sshSudoModeCommands": "명령",
"sshSudoModeCommandsDescription": "사용자는 sudo로 지정된 명령만 실행할 수 있습니다.",
"sshSudo": "Sudo 허용",
"sshSudoCommands": "Sudo 명령",
"sshSudoCommandsDescription": "사용자가 sudo로 실행할 수 있도록 허용된 명령 목록입니다.",
"sshCreateHomeDir": "홈 디렉터리 생성",
"sshUnixGroups": "유닉스 그룹",
"sshUnixGroupsDescription": "대상 호스트에서 사용자를 추가할 유닉스 그룹입니다.",
"retryAttempts": "재시도 횟수",
"expectedResponseCodes": "예상 응답 코드",
"expectedResponseCodesDescription": "정상 상태를 나타내는 HTTP 상태 코드입니다. 비워 두면 200-300이 정상으로 간주됩니다.",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "액세스 제어",
"editInternalResourceDialogAccessControlDescription": "연결 시 이 리소스에 대한 액세스 권한을 가지는 역할, 사용자, 그리고 머신 클라이언트를 제어합니다. 관리자는 항상 접근할 수 있습니다.",
"editInternalResourceDialogPortRangeValidationError": "모든 포트에 대해서는 \"*\"로, 아니면 쉼표로 구분된 포트 및 범위 목록(예: \"80,443,8000-9000\")을 설정해야 합니다. 포트는 1에서 65535 사이여야 합니다.",
"internalResourceAuthDaemonStrategy": "SSH 인증 데몬 위치",
"internalResourceAuthDaemonStrategyDescription": "SSH 인증 데몬이 작동하는 위치를 선택하세요: 사이트(Newt)에서 또는 원격 호스트에서.",
"internalResourceAuthDaemonDescription": "SSH 인증 데몬은 이 리소스를 위한 SSH 키 서명과 PAM 인증을 처리합니다. 사이트(Newt)에서 나 별도의 원격 호스트에서 실행할 것인지를 선택하세요. 자세한 내용은 <docsLink>문서</docsLink>를 참조하세요.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "전략 선택",
"internalResourceAuthDaemonStrategyLabel": "위치",
"internalResourceAuthDaemonSite": "사이트에서 인증 데몬이 실행됩니다(Newt).",
"internalResourceAuthDaemonSiteDescription": "인증 데몬이 사이트(Newt)에서 실행됩니다.",
"internalResourceAuthDaemonRemote": "원격 호스트",
"internalResourceAuthDaemonRemoteDescription": "인증 데몬이 사이트가 아닌 다른 호스트에서 실행됩니다.",
"internalResourceAuthDaemonPort": "데몬 포트 (선택 사항)",
"orgAuthWhatsThis": "조직 ID를 어디에서 찾을 수 있습니까?",
"learnMore": "자세히 알아보기",
"backToHome": "홈으로 돌아가기",

View File

@@ -790,6 +790,7 @@
"accessRoleRemoved": "Rolle fjernet",
"accessRoleRemovedDescription": "Rollen er vellykket fjernet.",
"accessRoleRequiredRemove": "Før du sletter denne rollen, vennligst velg en ny rolle å overføre eksisterende medlemmer til.",
"network": "Nettverk",
"manage": "Administrer",
"sitesNotFound": "Ingen områder funnet.",
"pangolinServerAdmin": "Server Admin - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Privat",
"sidebarAccessControl": "Tilgangskontroll",
"sidebarLogsAndAnalytics": "Logger og analyser",
"sidebarTeam": "Lag",
"sidebarUsers": "Brukere",
"sidebarAdmin": "Administrator",
"sidebarInvitations": "Invitasjoner",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Logg og analyser",
"sidebarBluePrints": "Tegninger",
"sidebarOrganization": "Organisasjon",
"sidebarManagement": "Administrasjon",
"sidebarBillingAndLicenses": "Fakturering & lisenser",
"sidebarLogsAnalytics": "Analyser",
"blueprints": "Tegninger",
@@ -1289,7 +1292,6 @@
"parsedContents": "Parastinnhold (kun lese)",
"enableDockerSocket": "Aktiver Docker blåkopi",
"enableDockerSocketDescription": "Aktiver skraping av Docker Socket for blueprint Etiketter. Socket bane må brukes for nye.",
"enableDockerSocketLink": "Lær mer",
"viewDockerContainers": "Vis Docker-containere",
"containersIn": "Containere i {siteName}",
"selectContainerDescription": "Velg en hvilken som helst container for å bruke som vertsnavn for dette målet. Klikk på en port for å bruke en port.",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "Tid er i sekunder",
"requireDeviceApproval": "Krev enhetsgodkjenning",
"requireDeviceApprovalDescription": "Brukere med denne rollen trenger nye enheter godkjent av en admin før de kan koble seg og få tilgang til ressurser.",
"sshAccess": "SSH tilgang",
"roleAllowSsh": "Tillat SSH",
"roleAllowSshAllow": "Tillat",
"roleAllowSshDisallow": "Forby",
"roleAllowSshDescription": "Tillat brukere med denne rollen å koble til ressurser via SSH. Når deaktivert får rollen ikke tilgang til SSH.",
"sshSudoMode": "Sudo tilgang",
"sshSudoModeNone": "Ingen",
"sshSudoModeNoneDescription": "Brukeren kan ikke kjøre kommandoer med sudo.",
"sshSudoModeFull": "Full Sudo",
"sshSudoModeFullDescription": "Brukeren kan kjøre hvilken som helst kommando med sudo.",
"sshSudoModeCommands": "Kommandoer",
"sshSudoModeCommandsDescription": "Brukeren kan bare kjøre de angitte kommandoene med sudo.",
"sshSudo": "Tillat sudo",
"sshSudoCommands": "Sudo kommandoer",
"sshSudoCommandsDescription": "Liste av kommandoer brukeren har lov til å kjøre med sudo.",
"sshCreateHomeDir": "Opprett hjemmappe",
"sshUnixGroups": "Unix grupper",
"sshUnixGroupsDescription": "Unix grupper for å legge til brukeren til målverten.",
"retryAttempts": "Forsøk på nytt",
"expectedResponseCodes": "Forventede svarkoder",
"expectedResponseCodesDescription": "HTTP-statuskode som indikerer sunn status. Hvis den blir stående tom, regnes 200-300 som sunn.",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "Tilgangskontroll",
"editInternalResourceDialogAccessControlDescription": "Kontroller hvilke roller, brukere og maskinklienter som har tilgang til denne ressursen når den er koblet til. Administratorer har alltid tilgang.",
"editInternalResourceDialogPortRangeValidationError": "Portsjiktet må være \"*\" for alle porter, eller en kommaseparert liste med porter og sjikt (f.eks. \"80,443,8000-9000\"). Porter må være mellom 1 og 65535.",
"internalResourceAuthDaemonStrategy": "SSH Auth Daemon Sted",
"internalResourceAuthDaemonStrategyDescription": "Velg hvor SSH-autentisering daemon kjører: på nettstedet (Newt) eller på en ekstern vert.",
"internalResourceAuthDaemonDescription": "SSH-godkjenning daemon håndterer SSH-nøkkel signering og PAM autentisering for denne ressursen. Velg om den kjører på nettstedet (Newt) eller på en separat ekstern vert. Se <docsLink>dokumentasjonen</docsLink> for mer.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "Velg strategi",
"internalResourceAuthDaemonStrategyLabel": "Sted",
"internalResourceAuthDaemonSite": "På nettsted",
"internalResourceAuthDaemonSiteDescription": "Autentiser daemon kjører på nettstedet (Newt).",
"internalResourceAuthDaemonRemote": "Ekstern vert",
"internalResourceAuthDaemonRemoteDescription": "Autentiser daemon kjører på en vert som ikke er nettstedet.",
"internalResourceAuthDaemonPort": "Daemon Port (valgfritt)",
"orgAuthWhatsThis": "Hvor kan jeg finne min organisasjons-ID?",
"learnMore": "Lær mer",
"backToHome": "Gå tilbake til start",

View File

@@ -790,6 +790,7 @@
"accessRoleRemoved": "Rol verwijderd",
"accessRoleRemovedDescription": "De rol is succesvol verwijderd.",
"accessRoleRequiredRemove": "Voordat u deze rol verwijdert, selecteer een nieuwe rol om bestaande leden aan te dragen.",
"network": "Netwerk",
"manage": "Beheren",
"sitesNotFound": "Geen sites gevonden.",
"pangolinServerAdmin": "Serverbeheer - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Privé",
"sidebarAccessControl": "Toegangs controle",
"sidebarLogsAndAnalytics": "Logs & Analytics",
"sidebarTeam": "Team",
"sidebarUsers": "Gebruikers",
"sidebarAdmin": "Beheerder",
"sidebarInvitations": "Uitnodigingen",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Log & Analytics",
"sidebarBluePrints": "Blauwdrukken",
"sidebarOrganization": "Organisatie",
"sidebarManagement": "Beheer",
"sidebarBillingAndLicenses": "Facturatie & Licenties",
"sidebarLogsAnalytics": "Analyses",
"blueprints": "Blauwdrukken",
@@ -1289,7 +1292,6 @@
"parsedContents": "Geparseerde inhoud (alleen lezen)",
"enableDockerSocket": "Schakel Docker Blauwdruk in",
"enableDockerSocketDescription": "Schakel Docker Socket label in voor blauwdruk labels. Pad naar Nieuw.",
"enableDockerSocketLink": "Meer informatie",
"viewDockerContainers": "Bekijk Docker containers",
"containersIn": "Containers in {siteName}",
"selectContainerDescription": "Selecteer een container om als hostnaam voor dit doel te gebruiken. Klik op een poort om een poort te gebruiken.",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "Tijd is in seconden",
"requireDeviceApproval": "Vereist goedkeuring van apparaat",
"requireDeviceApprovalDescription": "Gebruikers met deze rol hebben nieuwe apparaten nodig die door een beheerder zijn goedgekeurd voordat ze verbinding kunnen maken met bronnen en deze kunnen gebruiken.",
"sshAccess": "SSH toegang",
"roleAllowSsh": "SSH toestaan",
"roleAllowSshAllow": "Toestaan",
"roleAllowSshDisallow": "Weigeren",
"roleAllowSshDescription": "Sta gebruikers met deze rol toe om verbinding te maken met bronnen via SSH. Indien uitgeschakeld kan de rol geen gebruik maken van SSH toegang.",
"sshSudoMode": "Sudo toegang",
"sshSudoModeNone": "geen",
"sshSudoModeNoneDescription": "Gebruiker kan geen commando's uitvoeren met sudo.",
"sshSudoModeFull": "Volledige Sudo",
"sshSudoModeFullDescription": "Gebruiker kan elk commando uitvoeren met een sudo.",
"sshSudoModeCommands": "Opdrachten",
"sshSudoModeCommandsDescription": "Gebruiker kan alleen de opgegeven commando's uitvoeren met de sudo.",
"sshSudo": "sudo toestaan",
"sshSudoCommands": "Sudo Commando's",
"sshSudoCommandsDescription": "Lijst van commando's die de gebruiker mag uitvoeren met een sudo.",
"sshCreateHomeDir": "Maak Home Directory",
"sshUnixGroups": "Unix groepen",
"sshUnixGroupsDescription": "Unix groepen om de gebruiker toe te voegen aan de doel host.",
"retryAttempts": "Herhaal Pogingen",
"expectedResponseCodes": "Verwachte Reactiecodes",
"expectedResponseCodesDescription": "HTTP-statuscode die gezonde status aangeeft. Indien leeg wordt 200-300 als gezond beschouwd.",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "Toegangs controle",
"editInternalResourceDialogAccessControlDescription": "Beheer welke rollen, gebruikers en machineclients toegang hebben tot deze bron wanneer ze zijn verbonden. Beheerders hebben altijd toegang.",
"editInternalResourceDialogPortRangeValidationError": "Poortbereik moet \"*\" zijn voor alle poorten, of een komma-gescheiden lijst van poorten en bereiken (bijv. \"80,443,8000-9000\"). Poorten moeten tussen 1 en 65535 zijn.",
"internalResourceAuthDaemonStrategy": "SSH Auth Daemon locatie",
"internalResourceAuthDaemonStrategyDescription": "Kies waar de SSH authenticatie daemon wordt uitgevoerd: op de website (Newt) of op een externe host.",
"internalResourceAuthDaemonDescription": "De SSH authenticatie daemon zorgt voor SSH sleutelondertekening en PAM authenticatie voor deze resource. Kies of het wordt uitgevoerd op de website (Nieuw) of op een afzonderlijke externe host. Zie <docsLink>de documentatie</docsLink> voor meer.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "Selecteer strategie",
"internalResourceAuthDaemonStrategyLabel": "Locatie",
"internalResourceAuthDaemonSite": "In de site",
"internalResourceAuthDaemonSiteDescription": "Auth daemon draait op de site (Newt).",
"internalResourceAuthDaemonRemote": "Externe host",
"internalResourceAuthDaemonRemoteDescription": "Authenticatiedaemon draait op een host die niet de site is.",
"internalResourceAuthDaemonPort": "Daemon poort (optioneel)",
"orgAuthWhatsThis": "Waar kan ik mijn organisatie-ID vinden?",
"learnMore": "Meer informatie",
"backToHome": "Ga terug naar startpagina",

View File

@@ -790,6 +790,7 @@
"accessRoleRemoved": "Rola usunięta",
"accessRoleRemovedDescription": "Rola została pomyślnie usunięta.",
"accessRoleRequiredRemove": "Przed usunięciem tej roli, wybierz nową rolę do której zostaną przeniesieni obecni członkowie.",
"network": "Sieć",
"manage": "Zarządzaj",
"sitesNotFound": "Nie znaleziono witryn.",
"pangolinServerAdmin": "Administrator serwera - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Prywatny",
"sidebarAccessControl": "Kontrola dostępu",
"sidebarLogsAndAnalytics": "Logi i Analityki",
"sidebarTeam": "Drużyna",
"sidebarUsers": "Użytkownicy",
"sidebarAdmin": "Administrator",
"sidebarInvitations": "Zaproszenia",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Dziennik & Analityka",
"sidebarBluePrints": "Schematy",
"sidebarOrganization": "Organizacja",
"sidebarManagement": "Zarządzanie",
"sidebarBillingAndLicenses": "Płatność i licencje",
"sidebarLogsAnalytics": "Analityka",
"blueprints": "Schematy",
@@ -1289,7 +1292,6 @@
"parsedContents": "Przetworzona zawartość (tylko do odczytu)",
"enableDockerSocket": "Włącz schemat dokera",
"enableDockerSocketDescription": "Włącz etykietowanie kieszeni dokującej dla etykiet schematów. Ścieżka do gniazda musi być dostarczona do Newt.",
"enableDockerSocketLink": "Dowiedz się więcej",
"viewDockerContainers": "Zobacz kontenery dokujące",
"containersIn": "Pojemniki w {siteName}",
"selectContainerDescription": "Wybierz dowolny kontener do użycia jako nazwa hosta dla tego celu. Kliknij port, aby użyć portu.",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "Czas w sekundach",
"requireDeviceApproval": "Wymagaj zatwierdzenia urządzenia",
"requireDeviceApprovalDescription": "Użytkownicy o tej roli potrzebują nowych urządzeń zatwierdzonych przez administratora, zanim będą mogli połączyć się i uzyskać dostęp do zasobów.",
"sshAccess": "Dostęp SSH",
"roleAllowSsh": "Zezwalaj na SSH",
"roleAllowSshAllow": "Zezwól",
"roleAllowSshDisallow": "Nie zezwalaj",
"roleAllowSshDescription": "Zezwalaj użytkownikom z tej roli na łączenie się z zasobami za pomocą SSH. Gdy wyłączone, rola nie może korzystać z dostępu SSH.",
"sshSudoMode": "Dostęp Sudo",
"sshSudoModeNone": "Brak",
"sshSudoModeNoneDescription": "Użytkownik nie może uruchamiać poleceń z sudo.",
"sshSudoModeFull": "Pełne Sudo",
"sshSudoModeFullDescription": "Użytkownik może uruchomić dowolne polecenie z sudo.",
"sshSudoModeCommands": "Polecenia",
"sshSudoModeCommandsDescription": "Użytkownik może uruchamiać tylko określone polecenia z sudo.",
"sshSudo": "Zezwól na sudo",
"sshSudoCommands": "Komendy Sudo",
"sshSudoCommandsDescription": "Lista poleceń, które użytkownik może uruchamiać z sudo.",
"sshCreateHomeDir": "Utwórz katalog domowy",
"sshUnixGroups": "Grupy Unix",
"sshUnixGroupsDescription": "Grupy Unix do dodania użytkownika do docelowego hosta.",
"retryAttempts": "Próby Ponowienia",
"expectedResponseCodes": "Oczekiwane Kody Odpowiedzi",
"expectedResponseCodesDescription": "Kod statusu HTTP, który wskazuje zdrowy status. Jeśli pozostanie pusty, uznaje się 200-300 za zdrowy.",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "Kontrola dostępu",
"editInternalResourceDialogAccessControlDescription": "Kontroluj, które role, użytkownicy i klienci maszyn mają dostęp do tego zasobu po połączeniu. Administratorzy zawsze mają dostęp.",
"editInternalResourceDialogPortRangeValidationError": "Zakres portów musi być \"*\" dla wszystkich portów lub listą portów i zakresów oddzielonych przecinkami (np. \"80,443,8000-9000\"). Porty muszą znajdować się w przedziale od 1 do 65535.",
"internalResourceAuthDaemonStrategy": "SSH Auth Daemon Lokalizacja",
"internalResourceAuthDaemonStrategyDescription": "Wybierz, gdzie działa demon uwierzytelniania SSH: na stronie (Newt) lub na zdalnym serwerze.",
"internalResourceAuthDaemonDescription": "Uwierzytelnianie SSH obsługuje podpisywanie klucza SSH i uwierzytelnianie PAM dla tego zasobu. Wybierz, czy działa na stronie (Newt), czy na oddzielnym serwerze zdalnym. Zobacz <docsLink>dokumentację</docsLink> dla więcej.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "Wybierz strategię",
"internalResourceAuthDaemonStrategyLabel": "Lokalizacja",
"internalResourceAuthDaemonSite": "Na stronie",
"internalResourceAuthDaemonSiteDescription": "Demon Auth działa na stronie (nowy).",
"internalResourceAuthDaemonRemote": "Zdalny host",
"internalResourceAuthDaemonRemoteDescription": "Demon Auth działa na serwerze, który nie jest stroną.",
"internalResourceAuthDaemonPort": "Port Daemon (opcjonalnie)",
"orgAuthWhatsThis": "Gdzie mogę znaleźć swój identyfikator organizacji?",
"learnMore": "Dowiedz się więcej",
"backToHome": "Wróć do strony głównej",

View File

@@ -790,6 +790,7 @@
"accessRoleRemoved": "Função removida",
"accessRoleRemovedDescription": "A função foi removida com sucesso.",
"accessRoleRequiredRemove": "Antes de apagar esta função, selecione uma nova função para transferir os membros existentes.",
"network": "Rede",
"manage": "Gerir",
"sitesNotFound": "Nenhum site encontrado.",
"pangolinServerAdmin": "Administrador do Servidor - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Privado",
"sidebarAccessControl": "Controle de Acesso",
"sidebarLogsAndAnalytics": "Registros e Análises",
"sidebarTeam": "Equipe",
"sidebarUsers": "Utilizadores",
"sidebarAdmin": "Administrador",
"sidebarInvitations": "Convites",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Registo & Análise",
"sidebarBluePrints": "Diagramas",
"sidebarOrganization": "Organização",
"sidebarManagement": "Gestão",
"sidebarBillingAndLicenses": "Faturamento e Licenças",
"sidebarLogsAnalytics": "Análises",
"blueprints": "Diagramas",
@@ -1289,7 +1292,6 @@
"parsedContents": "Conteúdo analisado (Somente Leitura)",
"enableDockerSocket": "Habilitar o Diagrama Docker",
"enableDockerSocketDescription": "Ativar a scraping de rótulo Docker para rótulos de diagramas. Caminho de Socket deve ser fornecido para Newt.",
"enableDockerSocketLink": "Saiba mais",
"viewDockerContainers": "Ver contêineres Docker",
"containersIn": "Contêineres em {siteName}",
"selectContainerDescription": "Selecione qualquer contêiner para usar como hostname para este alvo. Clique em uma porta para usar uma porta.",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "O tempo está em segundos",
"requireDeviceApproval": "Exigir aprovação do dispositivo",
"requireDeviceApprovalDescription": "Usuários com esta função precisam de novos dispositivos aprovados por um administrador antes que eles possam se conectar e acessar recursos.",
"sshAccess": "Acesso SSH",
"roleAllowSsh": "Permitir SSH",
"roleAllowSshAllow": "Autorizar",
"roleAllowSshDisallow": "Anular",
"roleAllowSshDescription": "Permitir que usuários com esta função se conectem a recursos via SSH. Quando desativado, a função não pode usar o acesso SSH.",
"sshSudoMode": "Acesso Sudo",
"sshSudoModeNone": "Nenhuma",
"sshSudoModeNoneDescription": "O usuário não pode executar comandos com o sudo.",
"sshSudoModeFull": "Sudo Completo",
"sshSudoModeFullDescription": "O usuário pode executar qualquer comando com sudo.",
"sshSudoModeCommands": "Comandos",
"sshSudoModeCommandsDescription": "Usuário só pode executar os comandos especificados com sudo.",
"sshSudo": "Permitir sudo",
"sshSudoCommands": "Comandos Sudo",
"sshSudoCommandsDescription": "Lista de comandos com permissão de executar com o sudo.",
"sshCreateHomeDir": "Criar Diretório Inicial",
"sshUnixGroups": "Grupos Unix",
"sshUnixGroupsDescription": "Grupos Unix para adicionar o usuário no host de destino.",
"retryAttempts": "Tentativas de Repetição",
"expectedResponseCodes": "Códigos de Resposta Esperados",
"expectedResponseCodesDescription": "Código de status HTTP que indica estado saudável. Se deixado em branco, 200-300 é considerado saudável.",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "Controle de Acesso",
"editInternalResourceDialogAccessControlDescription": "Controle quais funções, usuários e clientes de máquina podem acessar este recurso quando conectados. Os administradores sempre têm acesso.",
"editInternalResourceDialogPortRangeValidationError": "O intervalo de portas deve ser \"*\" para todas as portas, ou uma lista de portas e intervalos separados por vírgulas (por exemplo, \"80,443,8000-9000\"). As portas devem estar entre 1 e 65535.",
"internalResourceAuthDaemonStrategy": "Local do Daemon de autenticação SSH",
"internalResourceAuthDaemonStrategyDescription": "Escolha onde o daemon de autenticação SSH funciona: no site (Newt) ou em um host remoto.",
"internalResourceAuthDaemonDescription": "A autenticação SSH daemon lida com assinatura de chave SSH e autenticação PAM para este recurso. Escolha se ele é executado no site (Newt) ou em um host remoto separado. Veja <docsLink>a documentação</docsLink> para mais informações.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "Selecione a estratégia",
"internalResourceAuthDaemonStrategyLabel": "Local:",
"internalResourceAuthDaemonSite": "No Site",
"internalResourceAuthDaemonSiteDescription": "O serviço de autenticação é executado no site (Newt).",
"internalResourceAuthDaemonRemote": "Host Remoto",
"internalResourceAuthDaemonRemoteDescription": "O serviço de autenticação é executado em um host que não é o site.",
"internalResourceAuthDaemonPort": "Porta do Daemon (opcional)",
"orgAuthWhatsThis": "Onde posso encontrar meu ID da organização?",
"learnMore": "Saiba mais",
"backToHome": "Voltar para a página inicial",

View File

@@ -790,6 +790,7 @@
"accessRoleRemoved": "Роль удалена",
"accessRoleRemovedDescription": "Роль была успешно удалена.",
"accessRoleRequiredRemove": "Перед удалением этой роли выберите новую роль для переноса существующих участников.",
"network": "Сеть",
"manage": "Управление",
"sitesNotFound": "Сайты не найдены.",
"pangolinServerAdmin": "Администратор сервера - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Приватный",
"sidebarAccessControl": "Контроль доступа",
"sidebarLogsAndAnalytics": "Журналы и аналитика",
"sidebarTeam": "Команда",
"sidebarUsers": "Пользователи",
"sidebarAdmin": "Админ",
"sidebarInvitations": "Приглашения",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Журнал и аналитика",
"sidebarBluePrints": "Чертежи",
"sidebarOrganization": "Организация",
"sidebarManagement": "Управление",
"sidebarBillingAndLicenses": "Биллинг и лицензии",
"sidebarLogsAnalytics": "Статистика",
"blueprints": "Чертежи",
@@ -1289,7 +1292,6 @@
"parsedContents": "Переработанное содержимое (только для чтения)",
"enableDockerSocket": "Включить чертёж Docker",
"enableDockerSocketDescription": "Включить scraping ярлыка Docker Socket для ярлыков чертежей. Путь к сокету должен быть предоставлен в Newt.",
"enableDockerSocketLink": "Узнать больше",
"viewDockerContainers": "Просмотр контейнеров Docker",
"containersIn": "Контейнеры в {siteName}",
"selectContainerDescription": "Выберите любой контейнер для использования в качестве имени хоста для этой цели. Нажмите на порт, чтобы использовать порт.",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "Время указано в секундах",
"requireDeviceApproval": "Требовать подтверждения устройства",
"requireDeviceApprovalDescription": "Пользователям с этой ролью нужны новые устройства, одобренные администратором, прежде чем они смогут подключаться и получать доступ к ресурсам.",
"sshAccess": "SSH доступ",
"roleAllowSsh": "Разрешить SSH",
"roleAllowSshAllow": "Разрешить",
"roleAllowSshDisallow": "Запретить",
"roleAllowSshDescription": "Разрешить пользователям с этой ролью подключаться к ресурсам через SSH. Если отключено, роль не может использовать доступ SSH.",
"sshSudoMode": "Sudo доступ",
"sshSudoModeNone": "Нет",
"sshSudoModeNoneDescription": "Пользователь не может запускать команды с sudo.",
"sshSudoModeFull": "Полная судо",
"sshSudoModeFullDescription": "Пользователь может запускать любую команду с помощью sudo.",
"sshSudoModeCommands": "Команды",
"sshSudoModeCommandsDescription": "Пользователь может запускать только указанные команды с помощью sudo.",
"sshSudo": "Разрешить sudo",
"sshSudoCommands": "Sudo Команды",
"sshSudoCommandsDescription": "Список команд, которые пользователю разрешено запускать с помощью sudo.",
"sshCreateHomeDir": "Создать домашний каталог",
"sshUnixGroups": "Unix группы",
"sshUnixGroupsDescription": "Unix группы для добавления пользователя на целевой хост.",
"retryAttempts": "Количество попыток повторного запроса",
"expectedResponseCodes": "Ожидаемые коды ответов",
"expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "Контроль доступа",
"editInternalResourceDialogAccessControlDescription": "Контролируйте, какие роли, пользователи и машинные клиенты имеют доступ к этому ресурсу при подключении. Администраторы всегда имеют доступ.",
"editInternalResourceDialogPortRangeValidationError": "Диапазон портов должен быть \"*\" для всех портов или списком портов и диапазонов через запятую (например, \"80,443,8000-9000\"). Порты должны находиться в диапазоне от 1 до 65535.",
"internalResourceAuthDaemonStrategy": "Местоположение демона по SSH",
"internalResourceAuthDaemonStrategyDescription": "Выберите, где работает демон аутентификации SSH: на сайте (Newt) или на удаленном узле.",
"internalResourceAuthDaemonDescription": "Демон аутентификации SSH обрабатывает подписание ключей SSH и аутентификацию PAM для этого ресурса. Выберите, запускать ли его на сайте (Newt) или на отдельном удаленном хосте. Подробности смотрите в <docsLink>документации</docsLink>.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "Выберите стратегию",
"internalResourceAuthDaemonStrategyLabel": "Местоположение",
"internalResourceAuthDaemonSite": "На сайте",
"internalResourceAuthDaemonSiteDescription": "На сайте работает демон Auth (Newt).",
"internalResourceAuthDaemonRemote": "Удаленный хост",
"internalResourceAuthDaemonRemoteDescription": "Демон Auth запускается на хост, который не является сайтом.",
"internalResourceAuthDaemonPort": "Порт демона (опционально)",
"orgAuthWhatsThis": "Где я могу найти ID моей организации?",
"learnMore": "Узнать больше",
"backToHome": "Вернуться домой",

View File

@@ -790,6 +790,7 @@
"accessRoleRemoved": "Rol kaldırıldı",
"accessRoleRemovedDescription": "Rol başarıyla kaldırıldı.",
"accessRoleRequiredRemove": "Bu rolü silmeden önce, mevcut üyeleri aktarmak için yeni bir rol seçin.",
"network": "Ağ",
"manage": "Yönet",
"sitesNotFound": "Site bulunamadı.",
"pangolinServerAdmin": "Sunucu Yöneticisi - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "Özel",
"sidebarAccessControl": "Erişim Kontrolü",
"sidebarLogsAndAnalytics": "Kayıtlar & Analitik",
"sidebarTeam": "Ekip",
"sidebarUsers": "Kullanıcılar",
"sidebarAdmin": "Yönetici",
"sidebarInvitations": "Davetiye",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "Kayıt & Analiz",
"sidebarBluePrints": "Planlar",
"sidebarOrganization": "Organizasyon",
"sidebarManagement": "Yönetim",
"sidebarBillingAndLicenses": "Faturalandırma & Lisanslar",
"sidebarLogsAnalytics": "Analitik",
"blueprints": "Planlar",
@@ -1289,7 +1292,6 @@
"parsedContents": "Verilerin Ayrıştırılmış İçeriği (Salt Okunur)",
"enableDockerSocket": "Docker Soketini Etkinleştir",
"enableDockerSocketDescription": "Plan etiketleri için Docker Socket etiket toplamasını etkinleştirin. Newt'e soket yolu sağlanmalıdır.",
"enableDockerSocketLink": "Daha fazla bilgi",
"viewDockerContainers": "Docker Konteynerlerini Görüntüle",
"containersIn": "{siteName} içindeki konteynerler",
"selectContainerDescription": "Bu hedef için bir ana bilgisayar adı olarak kullanmak üzere herhangi bir konteyner seçin. Bir bağlantı noktası kullanmak için bir bağlantı noktasına tıklayın.",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "Zaman saniye cinsindendir",
"requireDeviceApproval": "Cihaz Onaylarını Gerektir",
"requireDeviceApprovalDescription": "Bu role sahip kullanıcıların yeni cihazlarının bağlanabilmesi ve kaynaklara erişebilmesi için bir yönetici tarafından onaylanması gerekiyor.",
"sshAccess": "SSH Erişimi",
"roleAllowSsh": "SSH'a İzin Ver",
"roleAllowSshAllow": "İzin Ver",
"roleAllowSshDisallow": "İzin Verme",
"roleAllowSshDescription": "Bu role sahip kullanıcıların SSH aracılığıyla kaynaklara bağlanmasına izin verin. Devre dışı bırakıldığında, rol SSH erişimini kullanamaz.",
"sshSudoMode": "Sudo Erişimi",
"sshSudoModeNone": "Hiçbiri",
"sshSudoModeNoneDescription": "Kullanıcı, sudo komutunu kullanarak komut çalıştıramaz.",
"sshSudoModeFull": "Tam Sudo",
"sshSudoModeFullDescription": "Kullanıcı, sudo komutuyla her türlü komutu çalıştırabilir.",
"sshSudoModeCommands": "Komutlar",
"sshSudoModeCommandsDescription": "Kullanıcı sadece belirtilen komutları sudo ile çalıştırabilir.",
"sshSudo": "Sudo'ya izin ver",
"sshSudoCommands": "Sudo Komutları",
"sshSudoCommandsDescription": "Kullanıcının sudo ile çalıştırmasına izin verilen komutların listesi.",
"sshCreateHomeDir": "Ev Dizini Oluştur",
"sshUnixGroups": "Unix Grupları",
"sshUnixGroupsDescription": "Hedef ana bilgisayarda kullanıcıya eklemek için Unix grupları.",
"retryAttempts": "Tekrar Deneme Girişimleri",
"expectedResponseCodes": "Beklenen Yanıt Kodları",
"expectedResponseCodesDescription": "Sağlıklı durumu gösteren HTTP durum kodu. Boş bırakılırsa, 200-300 arası sağlıklı kabul edilir.",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "Erişim Kontrolü",
"editInternalResourceDialogAccessControlDescription": "Bağlandığında bu kaynağa erişimi olan roller, kullanıcılar ve makine müşterilerini kontrol edin. Yöneticiler her zaman erişime sahiptir.",
"editInternalResourceDialogPortRangeValidationError": "Port aralığı, tüm portlar için \"*\" veya virgülle ayrılmış bir port ve aralık listesi olmalıdır (ör. \"80,443,8000-9000\"). Portlar 1 ile 65535 arasında olmalıdır.",
"internalResourceAuthDaemonStrategy": "SSH Kimlik Doğrulama Daemon Yeri",
"internalResourceAuthDaemonStrategyDescription": "SSH kimlik doğrulama sunucusunun nerede çalışacağını seçin: sitede (Newt) veya uzak bir ana bilgisayarda.",
"internalResourceAuthDaemonDescription": "SSH kimlik doğrulama sunucusu, bu kaynak için SSH anahtar imzalama ve PAM kimlik doğrulamasını yapar. Sitede (Newt) veya ayrı bir uzak ana bilgisayarda çalışıp çalışmayacağını seçin. Daha fazla bilgi için <docsLink> belgeleri</docsLink> görün.",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "Strateji Seçin",
"internalResourceAuthDaemonStrategyLabel": "Konum",
"internalResourceAuthDaemonSite": "Sitede",
"internalResourceAuthDaemonSiteDescription": "Kimlik doğrulama sunucusu sitede (Newt) çalışır.",
"internalResourceAuthDaemonRemote": "Uzak Ana Bilgisayar",
"internalResourceAuthDaemonRemoteDescription": "Kimlik doğrulama sunucusu, site olmayan bir ana bilgisayarda çalışır.",
"internalResourceAuthDaemonPort": "Daemon Portu (isteğe bağlı)",
"orgAuthWhatsThis": "Kuruluş kimliğimi nerede bulabilirim?",
"learnMore": "Daha fazla bilgi",
"backToHome": "Ana sayfaya geri dön",

View File

@@ -790,6 +790,7 @@
"accessRoleRemoved": "角色已删除",
"accessRoleRemovedDescription": "角色已成功删除。",
"accessRoleRequiredRemove": "删除此角色之前,请选择一个新角色来转移现有成员。",
"network": "网络",
"manage": "管理",
"sitesNotFound": "未找到站点。",
"pangolinServerAdmin": "服务器管理员 - Pangolin",
@@ -1249,6 +1250,7 @@
"sidebarClientResources": "非公开的",
"sidebarAccessControl": "访问控制",
"sidebarLogsAndAnalytics": "日志与分析",
"sidebarTeam": "团队",
"sidebarUsers": "用户",
"sidebarAdmin": "管理员",
"sidebarInvitations": "邀请",
@@ -1267,6 +1269,7 @@
"sidebarLogAndAnalytics": "日志与分析",
"sidebarBluePrints": "蓝图",
"sidebarOrganization": "组织",
"sidebarManagement": "管理",
"sidebarBillingAndLicenses": "帐单和许可证",
"sidebarLogsAnalytics": "分析",
"blueprints": "蓝图",
@@ -1289,7 +1292,6 @@
"parsedContents": "解析内容 (只读)",
"enableDockerSocket": "启用 Docker 蓝图",
"enableDockerSocketDescription": "启用 Docker Socket 标签擦除蓝图标签。套接字路径必须提供给新的。",
"enableDockerSocketLink": "了解更多",
"viewDockerContainers": "查看停靠容器",
"containersIn": "{siteName} 中的容器",
"selectContainerDescription": "选择任何容器作为目标的主机名。点击端口使用端口。",
@@ -1643,6 +1645,24 @@
"timeIsInSeconds": "时间以秒为单位",
"requireDeviceApproval": "需要设备批准",
"requireDeviceApprovalDescription": "具有此角色的用户需要管理员批准的新设备才能连接和访问资源。",
"sshAccess": "SSH 访问",
"roleAllowSsh": "允许 SSH",
"roleAllowSshAllow": "允许",
"roleAllowSshDisallow": "不允许",
"roleAllowSshDescription": "允许具有此角色的用户通过 SSH 连接到资源。禁用时,角色不能使用 SSH 访问。",
"sshSudoMode": "Sudo 访问",
"sshSudoModeNone": "无",
"sshSudoModeNoneDescription": "用户不能用sudo运行命令。",
"sshSudoModeFull": "全苏多",
"sshSudoModeFullDescription": "用户可以用 sudo 运行任何命令。",
"sshSudoModeCommands": "命令",
"sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。",
"sshSudo": "允许Sudo",
"sshSudoCommands": "Sudo 命令",
"sshSudoCommandsDescription": "允许用户使用 sudo 运行的命令列表。",
"sshCreateHomeDir": "创建主目录",
"sshUnixGroups": "Unix 组",
"sshUnixGroupsDescription": "将用户添加到目标主机的Unix组。",
"retryAttempts": "重试次数",
"expectedResponseCodes": "期望响应代码",
"expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空200-300 被视为健康。",
@@ -2503,6 +2523,17 @@
"editInternalResourceDialogAccessControl": "访问控制",
"editInternalResourceDialogAccessControlDescription": "控制当连接到此资源时,哪些角色、用户和机器客户端可以访问。管理员始终具有访问权。",
"editInternalResourceDialogPortRangeValidationError": "端口范围必须为\"*\"表示所有端口,或一个用逗号分隔的端口和范围列表(例如:\"80,443,8000-9000\"。端口必须在1到65535之间。",
"internalResourceAuthDaemonStrategy": "SSH 认证守护进程位置",
"internalResourceAuthDaemonStrategyDescription": "选择 SSH 身份验证守护进程在哪里运行:站点(新建) 或远程主机。",
"internalResourceAuthDaemonDescription": "SSH 身份验证守护程序处理此资源的 SSH 密钥签名和PAM 身份验证。 选择它是在站点(新建)还是在单独的远程主机上运行。请参阅 <docsLink>文档</docsLink>。",
"internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net",
"internalResourceAuthDaemonStrategyPlaceholder": "选择策略",
"internalResourceAuthDaemonStrategyLabel": "地点",
"internalResourceAuthDaemonSite": "在站点",
"internalResourceAuthDaemonSiteDescription": "认证守护进程在站点上运行(新建)。",
"internalResourceAuthDaemonRemote": "远程主机",
"internalResourceAuthDaemonRemoteDescription": "认证守护进程运行在不是站点的主机上。",
"internalResourceAuthDaemonPort": "守护进程端口(可选)",
"orgAuthWhatsThis": "我的组织ID在哪里可以找到",
"learnMore": "了解更多",
"backToHome": "返回首页",

67
package-lock.json generated
View File

@@ -124,7 +124,7 @@
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "25.2.3",
"@types/nodemailer": "7.0.10",
"@types/nodemailer": "7.0.9",
"@types/nprogress": "0.2.3",
"@types/pg": "8.16.0",
"@types/react": "19.2.14",
@@ -1086,6 +1086,7 @@
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.26.2",
@@ -2817,6 +2818,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2839,6 +2841,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -2861,6 +2864,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2877,6 +2881,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2893,6 +2898,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2909,6 +2915,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2925,6 +2932,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2941,6 +2949,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2957,6 +2966,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2973,6 +2983,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -2989,6 +3000,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3005,6 +3017,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3027,6 +3040,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3049,6 +3063,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3071,6 +3086,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3093,6 +3109,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3115,6 +3132,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3137,6 +3155,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3159,6 +3178,7 @@
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -3178,6 +3198,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3197,6 +3218,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3216,6 +3238,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -3497,6 +3520,7 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -7926,6 +7950,7 @@
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.0.0"
},
@@ -9344,6 +9369,7 @@
"version": "5.90.21",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.20"
},
@@ -9459,6 +9485,7 @@
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*"
}
@@ -9799,6 +9826,7 @@
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
@@ -9893,14 +9921,15 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"devOptional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/nodemailer": {
"version": "7.0.10",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.10.tgz",
"integrity": "sha512-tP+9WggTFN22Zxh0XFyst7239H0qwiRCogsk7v9aQS79sYAJY+WEbTHbNYcxUMaalHKmsNpxmoTe35hBEMMd6g==",
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.9.tgz",
"integrity": "sha512-vI8oF1M+8JvQhsId0Pc38BdUP2evenIIys7c7p+9OZXSPOH5c1dyINP1jT8xQ2xPuBUXmIC87s+91IZMDjH8Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9920,6 +9949,7 @@
"integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
@@ -9945,6 +9975,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -9955,6 +9986,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -10041,8 +10073,7 @@
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/@types/ws": {
"version": "8.18.1",
@@ -10113,6 +10144,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz",
"integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.55.0",
"@typescript-eslint/types": "8.55.0",
@@ -10602,6 +10634,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -11066,6 +11099,7 @@
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/types": "^7.26.0"
}
@@ -11132,6 +11166,7 @@
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@@ -11258,6 +11293,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -12211,6 +12247,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -12651,7 +12688,6 @@
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
@@ -13765,6 +13801,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -13863,6 +13900,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -14048,6 +14086,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -14367,6 +14406,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -16876,7 +16916,6 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -16887,7 +16926,6 @@
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
@@ -16975,6 +17013,7 @@
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
"integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==",
"peer": true,
"dependencies": {
"@next/env": "15.5.12",
"@swc/helpers": "0.5.15",
@@ -17909,6 +17948,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz",
"integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.11.0",
"pg-pool": "^3.11.0",
@@ -18403,6 +18443,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -18432,6 +18473,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -19248,6 +19290,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -20717,7 +20760,8 @@
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tapable": {
"version": "2.3.0",
@@ -21191,6 +21235,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -21617,6 +21662,7 @@
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.8",
@@ -21823,6 +21869,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -147,7 +147,7 @@
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "25.2.3",
"@types/nodemailer": "7.0.10",
"@types/nodemailer": "7.0.9",
"@types/nprogress": "0.2.3",
"@types/pg": "8.16.0",
"@types/react": "19.2.14",

View File

@@ -232,7 +232,11 @@ export const siteResources = pgTable("siteResources", {
aliasAddress: varchar("aliasAddress"),
tcpPortRangeString: varchar("tcpPortRangeString").notNull().default("*"),
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
disableIcmp: boolean("disableIcmp").notNull().default(false)
disableIcmp: boolean("disableIcmp").notNull().default(false),
authDaemonPort: integer("authDaemonPort").default(22123),
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote">()
.default("site")
});
export const clientSiteResources = pgTable("clientSiteResources", {
@@ -372,7 +376,11 @@ export const roles = pgTable("roles", {
isAdmin: boolean("isAdmin"),
name: varchar("name").notNull(),
description: varchar("description"),
requireDeviceApproval: boolean("requireDeviceApproval").default(false)
requireDeviceApproval: boolean("requireDeviceApproval").default(false),
sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("none"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
sshUnixGroups: text("sshUnixGroups").default("[]")
});
export const roleActions = pgTable("roleActions", {
@@ -1059,4 +1067,6 @@ export type SecurityKey = InferSelectModel<typeof securityKeys>;
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
export type RoundTripMessageTracker = InferSelectModel<typeof roundTripMessageTracker>;
export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker
>;

View File

@@ -257,7 +257,11 @@ export const siteResources = sqliteTable("siteResources", {
udpPortRangeString: text("udpPortRangeString").notNull().default("*"),
disableIcmp: integer("disableIcmp", { mode: "boolean" })
.notNull()
.default(false)
.default(false),
authDaemonPort: integer("authDaemonPort").default(22123),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote">()
.default("site")
});
export const clientSiteResources = sqliteTable("clientSiteResources", {
@@ -679,7 +683,13 @@ export const roles = sqliteTable("roles", {
description: text("description"),
requireDeviceApproval: integer("requireDeviceApproval", {
mode: "boolean"
}).default(false)
}).default(false),
sshSudoMode: text("sshSudoMode").default("none"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
true
),
sshUnixGroups: text("sshUnixGroups").default("[]")
});
export const roleActions = sqliteTable("roleActions", {

View File

@@ -23,9 +23,14 @@ export async function verifyApiKeyRoleAccess(
);
}
const { roleIds } = req.body;
const allRoleIds =
roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
let allRoleIds: number[] = [];
if (!isNaN(singleRoleId)) {
// If roleId is provided in URL params, query params, or body (single), use it exclusively
allRoleIds = [singleRoleId];
} else if (req.body?.roleIds) {
// Only use body.roleIds if no single roleId was provided
allRoleIds = req.body.roleIds;
}
if (allRoleIds.length === 0) {
return next();

View File

@@ -23,8 +23,14 @@ export async function verifyRoleAccess(
);
}
const roleIds = req.body?.roleIds;
const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]);
let allRoleIds: number[] = [];
if (!isNaN(singleRoleId)) {
// If roleId is provided in URL params, query params, or body (single), use it exclusively
allRoleIds = [singleRoleId];
} else if (req.body?.roleIds) {
// Only use body.roleIds if no single roleId was provided
allRoleIds = req.body.roleIds;
}
if (allRoleIds.length === 0) {
return next();

View File

@@ -61,7 +61,10 @@ function encodeUInt64(value: bigint): Buffer {
* Decode a string from SSH wire format at the given offset
* Returns the string buffer and the new offset
*/
function decodeString(data: Buffer, offset: number): { value: Buffer; newOffset: number } {
function decodeString(
data: Buffer,
offset: number
): { value: Buffer; newOffset: number } {
const len = data.readUInt32BE(offset);
const value = data.subarray(offset + 4, offset + 4 + len);
return { value, newOffset: offset + 4 + len };
@@ -91,7 +94,9 @@ function parseOpenSSHPublicKey(pubKeyLine: string): {
// Verify the key type in the blob matches
const { value: blobKeyType } = decodeString(keyData, 0);
if (blobKeyType.toString("utf8") !== keyType) {
throw new Error(`Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`);
throw new Error(
`Key type mismatch: ${blobKeyType.toString("utf8")} vs ${keyType}`
);
}
return { keyType, keyData, comment };
@@ -238,7 +243,7 @@ export interface SignedCertificate {
* @param comment - Optional comment for the CA public key
* @returns CA key pair and configuration info
*/
export function generateCA(comment: string = "ssh-ca"): CAKeyPair {
export function generateCA(comment: string = "pangolin-ssh-ca"): CAKeyPair {
// Generate Ed25519 key pair
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519", {
publicKeyEncoding: { type: "spki", format: "pem" },
@@ -269,7 +274,7 @@ export function generateCA(comment: string = "ssh-ca"): CAKeyPair {
/**
* Get and decrypt the SSH CA keys for an organization.
*
*
* @param orgId - Organization ID
* @param decryptionKey - Key to decrypt the CA private key (typically server.secret from config)
* @returns CA key pair or null if not found
@@ -307,7 +312,10 @@ export async function getOrgCAKeys(
key: privateKeyPem,
format: "pem"
});
const publicKeyPem = pubKeyObj.export({ type: "spki", format: "pem" }) as string;
const publicKeyPem = pubKeyObj.export({
type: "spki",
format: "pem"
}) as string;
return {
privateKeyPem,
@@ -365,8 +373,8 @@ export function signPublicKey(
const serial = options.serial ?? BigInt(Date.now());
const certType = options.certType ?? 1; // 1 = user cert
const now = BigInt(Math.floor(Date.now() / 1000));
const validAfter = options.validAfter ?? (now - 60n); // 1 minute ago
const validBefore = options.validBefore ?? (now + 86400n * 365n); // 1 year from now
const validAfter = options.validAfter ?? now - 60n; // 1 minute ago
const validBefore = options.validBefore ?? now + 86400n * 365n; // 1 year from now
// Default extensions for user certificates
const defaultExtensions = [
@@ -422,10 +430,7 @@ export function signPublicKey(
]);
// Build complete certificate
const certificate = Buffer.concat([
certBody,
encodeString(signatureBlob)
]);
const certificate = Buffer.concat([certBody, encodeString(signatureBlob)]);
// Format as OpenSSH certificate line
const certLine = `${certTypeString} ${certificate.toString("base64")} ${options.keyId}`;

View File

@@ -25,7 +25,8 @@ import {
loginPageOrg,
orgs,
resources,
roles
roles,
siteResources
} from "@server/db";
import { eq } from "drizzle-orm";
@@ -286,6 +287,10 @@ async function disableFeature(
await disableAutoProvisioning(orgId);
break;
case TierFeature.SshPam:
await disableSshPam(orgId);
break;
default:
logger.warn(
`Unknown feature ${feature} for org ${orgId}, skipping`
@@ -315,6 +320,12 @@ async function disableDeviceApprovals(orgId: string): Promise<void> {
logger.info(`Disabled device approvals on all roles for org ${orgId}`);
}
async function disableSshPam(orgId: string): Promise<void> {
logger.info(
`Disabled SSH PAM options on all roles and site resources for org ${orgId}`
);
}
async function disableLoginPageBranding(orgId: string): Promise<void> {
const [existingBranding] = await db
.select()

View File

@@ -514,7 +514,7 @@ authenticated.post(
verifyValidSubscription(tierMatrix.sshPam),
verifyOrgAccess,
verifyLimits,
// verifyUserHasAction(ActionsEnum.signSshKey),
verifyUserHasAction(ActionsEnum.signSshKey),
logActionAudit(ActionsEnum.signSshKey),
ssh.signSshKey
);

View File

@@ -13,7 +13,17 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, newts, orgs, roundTripMessageTracker, siteResources, sites, userOrgs } from "@server/db";
import {
db,
newts,
roles,
roundTripMessageTracker,
siteResources,
sites,
userOrgs
} from "@server/db";
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -135,11 +145,26 @@ export async function signSshKey(
);
}
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.sshPam
);
if (!isLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"SSH key signing requires a paid plan"
)
);
}
let usernameToUse;
if (!userOrg.pamUsername) {
if (req.user?.email) {
// Extract username from email (first part before @)
usernameToUse = req.user?.email.split("@")[0];
usernameToUse = req.user?.email
.split("@")[0]
.replace(/[^a-zA-Z0-9_-]/g, "");
if (!usernameToUse) {
return next(
createHttpError(
@@ -301,6 +326,29 @@ export async function signSshKey(
);
}
const [roleRow] = await db
.select()
.from(roles)
.where(eq(roles.roleId, roleId))
.limit(1);
let parsedSudoCommands: string[] = [];
let parsedGroups: string[] = [];
try {
parsedSudoCommands = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
if (!Array.isArray(parsedSudoCommands)) parsedSudoCommands = [];
} catch {
parsedSudoCommands = [];
}
try {
parsedGroups = JSON.parse(roleRow?.sshUnixGroups ?? "[]");
if (!Array.isArray(parsedGroups)) parsedGroups = [];
} catch {
parsedGroups = [];
}
const homedir = roleRow?.sshCreateHomeDir ?? null;
const sudoMode = roleRow?.sshSudoMode ?? "none";
// get the site
const [newt] = await db
.select()
@@ -334,7 +382,7 @@ export async function signSshKey(
.values({
wsClientId: newt.newtId,
messageType: `newt/pam/connection`,
sentAt: Math.floor(Date.now() / 1000),
sentAt: Math.floor(Date.now() / 1000)
})
.returning();
@@ -352,14 +400,17 @@ export async function signSshKey(
data: {
messageId: message.messageId,
orgId: orgId,
agentPort: 22123,
agentPort: resource.authDaemonPort ?? 22123,
externalAuthDaemon: resource.authDaemonMode === "remote",
agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse,
niceId: resource.niceId,
metadata: {
sudo: true, // we are hardcoding these for now but should make configurable from the role or something
homedir: true
sudoMode: sudoMode,
sudoCommands: parsedSudoCommands,
homedir: homedir,
groups: parsedGroups
}
}
});

View File

@@ -6,7 +6,7 @@ import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { eq, and } from "drizzle-orm";
import { eq, and, ne } from "drizzle-orm";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
@@ -93,7 +93,8 @@ export async function updateClient(
.where(
and(
eq(clients.niceId, niceId),
eq(clients.orgId, clients.orgId)
eq(clients.orgId, clients.orgId),
ne(clients.clientId, clientId)
)
)
.limit(1);

View File

@@ -181,7 +181,10 @@ export async function createOrg(
}
if (build == "saas" && billingOrgIdForNewOrg) {
const usage = await usageService.getUsage(billingOrgIdForNewOrg, FeatureId.ORGINIZATIONS);
const usage = await usageService.getUsage(
billingOrgIdForNewOrg,
FeatureId.ORGINIZATIONS
);
if (!usage) {
return next(
createHttpError(
@@ -218,11 +221,6 @@ export async function createOrg(
.from(domains)
.where(eq(domains.configManaged, true));
// Generate SSH CA keys for the org
// const ca = generateCA(`${orgId}-ca`);
// const encryptionKey = config.getRawConfig().server.secret!;
// const encryptedCaPrivateKey = encrypt(ca.privateKeyPem, encryptionKey);
const saasBillingFields =
build === "saas" && req.user && isFirstOrg !== null
? isFirstOrg
@@ -233,6 +231,19 @@ export async function createOrg(
}
: {};
const encryptionKey = config.getRawConfig().server.secret;
let sshCaFields: {
sshCaPrivateKey?: string;
sshCaPublicKey?: string;
} = {};
if (encryptionKey) {
const ca = generateCA(`pangolin-ssh-ca-${orgId}`);
sshCaFields = {
sshCaPrivateKey: encrypt(ca.privateKeyPem, encryptionKey),
sshCaPublicKey: ca.publicKeyOpenSSH
};
}
const newOrg = await trx
.insert(orgs)
.values({
@@ -241,8 +252,7 @@ export async function createOrg(
subnet,
utilitySubnet,
createdAt: new Date().toISOString(),
// sshCaPrivateKey: encryptedCaPrivateKey,
// sshCaPublicKey: ca.publicKeyOpenSSH,
...sshCaFields,
...saasBillingFields
})
.returning();
@@ -262,7 +272,8 @@ export async function createOrg(
orgId: newOrg[0].orgId,
isAdmin: true,
name: "Admin",
description: "Admin role with the most permissions"
description: "Admin role with the most permissions",
sshSudoMode: "full"
})
.returning({ roleId: roles.roleId });

View File

@@ -9,7 +9,7 @@ import {
Resource,
resources
} from "@server/db";
import { eq, and } from "drizzle-orm";
import { eq, and, ne } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -33,7 +33,15 @@ const updateResourceParamsSchema = z.strictObject({
const updateHttpResourceBodySchema = z
.strictObject({
name: z.string().min(1).max(255).optional(),
niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
niceId: z
.string()
.min(1)
.max(255)
.regex(
/^[a-zA-Z0-9-]+$/,
"niceId can only contain letters, numbers, and dashes"
)
.optional(),
subdomain: subdomainSchema.nullable().optional(),
ssl: z.boolean().optional(),
sso: z.boolean().optional(),
@@ -248,14 +256,13 @@ async function updateHttpResource(
.where(
and(
eq(resources.niceId, updateData.niceId),
eq(resources.orgId, resource.orgId)
eq(resources.orgId, resource.orgId),
ne(resources.resourceId, resource.resourceId) // exclude the current resource from the search
)
);
)
.limit(1);
if (
existingResource &&
existingResource.resourceId !== resource.resourceId
) {
if (existingResource) {
return next(
createHttpError(
HttpCode.CONFLICT,
@@ -343,7 +350,10 @@ async function updateHttpResource(
headers = null;
}
const isLicensed = await isLicensedOrSubscribed(resource.orgId, tierMatrix.maintencePage);
const isLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.maintencePage
);
if (!isLicensed) {
updateData.maintenanceModeEnabled = undefined;
updateData.maintenanceModeType = undefined;

View File

@@ -18,10 +18,17 @@ const createRoleParamsSchema = z.strictObject({
orgId: z.string()
});
const sshSudoModeSchema = z.enum(["none", "full", "commands"]);
const createRoleSchema = z.strictObject({
name: z.string().min(1).max(255),
description: z.string().optional(),
requireDeviceApproval: z.boolean().optional()
requireDeviceApproval: z.boolean().optional(),
allowSsh: z.boolean().optional(),
sshSudoMode: sshSudoModeSchema.optional(),
sshSudoCommands: z.array(z.string()).optional(),
sshCreateHomeDir: z.boolean().optional(),
sshUnixGroups: z.array(z.string()).optional()
});
export const defaultRoleAllowedActions: ActionsEnum[] = [
@@ -101,24 +108,40 @@ export async function createRole(
);
}
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
if (!isLicensed) {
const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
if (!isLicensedDeviceApprovals) {
roleData.requireDeviceApproval = undefined;
}
const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam);
const roleInsertValues: Record<string, unknown> = {
name: roleData.name,
orgId
};
if (roleData.description !== undefined) roleInsertValues.description = roleData.description;
if (roleData.requireDeviceApproval !== undefined) roleInsertValues.requireDeviceApproval = roleData.requireDeviceApproval;
if (isLicensedSshPam) {
if (roleData.sshSudoMode !== undefined) roleInsertValues.sshSudoMode = roleData.sshSudoMode;
if (roleData.sshSudoCommands !== undefined) roleInsertValues.sshSudoCommands = JSON.stringify(roleData.sshSudoCommands);
if (roleData.sshCreateHomeDir !== undefined) roleInsertValues.sshCreateHomeDir = roleData.sshCreateHomeDir;
if (roleData.sshUnixGroups !== undefined) roleInsertValues.sshUnixGroups = JSON.stringify(roleData.sshUnixGroups);
}
await db.transaction(async (trx) => {
const newRole = await trx
.insert(roles)
.values({
...roleData,
orgId
})
.values(roleInsertValues as typeof roles.$inferInsert)
.returning();
const actionsToInsert = [...defaultRoleAllowedActions];
if (roleData.allowSsh) {
actionsToInsert.push(ActionsEnum.signSshKey);
}
await trx
.insert(roleActions)
.values(
defaultRoleAllowedActions.map((action) => ({
actionsToInsert.map((action) => ({
roleId: newRole[0].roleId,
actionId: action,
orgId

View File

@@ -1,9 +1,10 @@
import { db, orgs, roles } from "@server/db";
import { db, orgs, roleActions, roles } from "@server/db";
import response from "@server/lib/response";
import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import { eq, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { ActionsEnum } from "@server/auth/actions";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -37,7 +38,11 @@ async function queryRoles(orgId: string, limit: number, offset: number) {
name: roles.name,
description: roles.description,
orgName: orgs.name,
requireDeviceApproval: roles.requireDeviceApproval
requireDeviceApproval: roles.requireDeviceApproval,
sshSudoMode: roles.sshSudoMode,
sshSudoCommands: roles.sshSudoCommands,
sshCreateHomeDir: roles.sshCreateHomeDir,
sshUnixGroups: roles.sshUnixGroups
})
.from(roles)
.leftJoin(orgs, eq(roles.orgId, orgs.orgId))
@@ -106,9 +111,28 @@ export async function listRoles(
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count;
let rolesWithAllowSsh = rolesList;
if (rolesList.length > 0) {
const roleIds = rolesList.map((r) => r.roleId);
const signSshKeyRows = await db
.select({ roleId: roleActions.roleId })
.from(roleActions)
.where(
and(
inArray(roleActions.roleId, roleIds),
eq(roleActions.actionId, ActionsEnum.signSshKey)
)
);
const roleIdsWithSsh = new Set(signSshKeyRows.map((r) => r.roleId));
rolesWithAllowSsh = rolesList.map((r) => ({
...r,
allowSsh: roleIdsWithSsh.has(r.roleId)
}));
}
return response(res, {
data: {
roles: rolesList,
roles: rolesWithAllowSsh,
pagination: {
total: totalCount,
limit,

View File

@@ -1,8 +1,9 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, type Role } from "@server/db";
import { roles } from "@server/db";
import { eq } from "drizzle-orm";
import { roleActions, roles } from "@server/db";
import { and, eq } from "drizzle-orm";
import { ActionsEnum } from "@server/auth/actions";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -16,11 +17,18 @@ const updateRoleParamsSchema = z.strictObject({
roleId: z.string().transform(Number).pipe(z.int().positive())
});
const sshSudoModeSchema = z.enum(["none", "full", "commands"]);
const updateRoleBodySchema = z
.strictObject({
name: z.string().min(1).max(255).optional(),
description: z.string().optional(),
requireDeviceApproval: z.boolean().optional()
requireDeviceApproval: z.boolean().optional(),
allowSsh: z.boolean().optional(),
sshSudoMode: sshSudoModeSchema.optional(),
sshSudoCommands: z.array(z.string()).optional(),
sshCreateHomeDir: z.boolean().optional(),
sshUnixGroups: z.array(z.string()).optional()
})
.refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update"
@@ -75,7 +83,9 @@ export async function updateRole(
}
const { roleId } = parsedParams.data;
const updateData = parsedBody.data;
const body = parsedBody.data;
const { allowSsh, ...restBody } = body;
const updateData: Record<string, unknown> = { ...restBody };
const role = await db
.select()
@@ -92,16 +102,14 @@ export async function updateRole(
);
}
if (role[0].isAdmin) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
`Cannot update a Admin role`
)
);
const orgId = role[0].orgId;
const isAdminRole = role[0].isAdmin;
if (isAdminRole) {
delete updateData.name;
delete updateData.description;
}
const orgId = role[0].orgId;
if (!orgId) {
return next(
createHttpError(
@@ -111,18 +119,70 @@ export async function updateRole(
);
}
const isLicensed = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
if (!isLicensed) {
const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals);
if (!isLicensedDeviceApprovals) {
updateData.requireDeviceApproval = undefined;
}
const updatedRole = await db
.update(roles)
.set(updateData)
.where(eq(roles.roleId, roleId))
.returning();
const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam);
if (!isLicensedSshPam) {
delete updateData.sshSudoMode;
delete updateData.sshSudoCommands;
delete updateData.sshCreateHomeDir;
delete updateData.sshUnixGroups;
} else {
if (Array.isArray(updateData.sshSudoCommands)) {
updateData.sshSudoCommands = JSON.stringify(updateData.sshSudoCommands);
}
if (Array.isArray(updateData.sshUnixGroups)) {
updateData.sshUnixGroups = JSON.stringify(updateData.sshUnixGroups);
}
}
if (updatedRole.length === 0) {
const updatedRole = await db.transaction(async (trx) => {
const result = await trx
.update(roles)
.set(updateData as typeof roles.$inferInsert)
.where(eq(roles.roleId, roleId))
.returning();
if (result.length === 0) {
return null;
}
if (allowSsh === true) {
const existing = await trx
.select()
.from(roleActions)
.where(
and(
eq(roleActions.roleId, roleId),
eq(roleActions.actionId, ActionsEnum.signSshKey)
)
)
.limit(1);
if (existing.length === 0) {
await trx.insert(roleActions).values({
roleId,
actionId: ActionsEnum.signSshKey,
orgId: orgId!
});
}
} else if (allowSsh === false) {
await trx
.delete(roleActions)
.where(
and(
eq(roleActions.roleId, roleId),
eq(roleActions.actionId, ActionsEnum.signSshKey)
)
);
}
return result[0];
});
if (!updatedRole) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@@ -132,7 +192,7 @@ export async function updateRole(
}
return response(res, {
data: updatedRole[0],
data: updatedRole,
success: true,
error: false,
message: "Role updated successfully",

View File

@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { sites } from "@server/db";
import { eq, and } from "drizzle-orm";
import { eq, and, ne } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -19,8 +19,8 @@ const updateSiteBodySchema = z
.strictObject({
name: z.string().min(1).max(255).optional(),
niceId: z.string().min(1).max(255).optional(),
dockerSocketEnabled: z.boolean().optional(),
remoteSubnets: z.string().optional()
dockerSocketEnabled: z.boolean().optional()
// remoteSubnets: z.string().optional()
// subdomain: z
// .string()
// .min(1)
@@ -86,18 +86,19 @@ export async function updateSite(
// if niceId is provided, check if it's already in use by another site
if (updateData.niceId) {
const existingSite = await db
const [existingSite] = await db
.select()
.from(sites)
.where(
and(
eq(sites.niceId, updateData.niceId),
eq(sites.orgId, sites.orgId)
eq(sites.orgId, sites.orgId),
ne(sites.siteId, siteId)
)
)
.limit(1);
if (existingSite.length > 0 && existingSite[0].siteId !== siteId) {
if (existingSite) {
return next(
createHttpError(
HttpCode.CONFLICT,
@@ -107,22 +108,22 @@ export async function updateSite(
}
}
// if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs
if (updateData.remoteSubnets) {
const subnets = updateData.remoteSubnets
.split(",")
.map((s) => s.trim());
for (const subnet of subnets) {
if (!isValidCIDR(subnet)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Invalid CIDR format: ${subnet}`
)
);
}
}
}
// // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs
// if (updateData.remoteSubnets) {
// const subnets = updateData.remoteSubnets
// .split(",")
// .map((s) => s.trim());
// for (const subnet of subnets) {
// if (!isValidCIDR(subnet)) {
// return next(
// createHttpError(
// HttpCode.BAD_REQUEST,
// `Invalid CIDR format: ${subnet}`
// )
// );
// }
// }
// }
const updatedSite = await db
.update(sites)

View File

@@ -16,6 +16,8 @@ import {
isIpInCidr,
portRangeStringSchema
} from "@server/lib/ip";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response";
import logger from "@server/logger";
@@ -53,7 +55,9 @@ const createSiteResourceSchema = z
clientIds: z.array(z.int()),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional()
disableIcmp: z.boolean().optional(),
authDaemonPort: z.int().positive().optional(),
authDaemonMode: z.enum(["site", "remote"]).optional()
})
.strict()
.refine(
@@ -168,7 +172,9 @@ export async function createSiteResource(
clientIds,
tcpPortRangeString,
udpPortRangeString,
disableIcmp
disableIcmp,
authDaemonPort,
authDaemonMode
} = parsedBody.data;
// Verify the site exists and belongs to the org
@@ -267,6 +273,11 @@ export async function createSiteResource(
}
}
const isLicensedSshPam = await isLicensedOrSubscribed(
orgId,
tierMatrix.sshPam
);
const niceId = await getUniqueSiteResourceName(orgId);
let aliasAddress: string | null = null;
if (mode == "host") {
@@ -277,25 +288,29 @@ export async function createSiteResource(
let newSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => {
// Create the site resource
const insertValues: typeof siteResources.$inferInsert = {
siteId,
niceId,
orgId,
name,
mode: mode as "host" | "cidr",
destination,
enabled,
alias,
aliasAddress,
tcpPortRangeString,
udpPortRangeString,
disableIcmp
};
if (isLicensedSshPam) {
if (authDaemonPort !== undefined)
insertValues.authDaemonPort = authDaemonPort;
if (authDaemonMode !== undefined)
insertValues.authDaemonMode = authDaemonMode;
}
[newSiteResource] = await trx
.insert(siteResources)
.values({
siteId,
niceId,
orgId,
name,
mode: mode as "host" | "cidr",
// protocol: mode === "port" ? protocol : null,
// proxyPort: mode === "port" ? proxyPort : null,
// destinationPort: mode === "port" ? destinationPort : null,
destination,
enabled,
alias,
aliasAddress,
tcpPortRangeString,
udpPortRangeString,
disableIcmp
})
.values(insertValues)
.returning();
const siteResourceId = newSiteResource.siteResourceId;

View File

@@ -78,6 +78,8 @@ function querySiteResourcesBase() {
tcpPortRangeString: siteResources.tcpPortRangeString,
udpPortRangeString: siteResources.udpPortRangeString,
disableIcmp: siteResources.disableIcmp,
authDaemonMode: siteResources.authDaemonMode,
authDaemonPort: siteResources.authDaemonPort,
siteName: sites.name,
siteNiceId: sites.niceId,
siteAddress: sites.address

View File

@@ -32,6 +32,8 @@ import {
getClientSiteResourceAccess,
rebuildClientAssociationsFromSiteResource
} from "@server/lib/rebuildClientAssociations";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const updateSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.string().transform(Number).pipe(z.int().positive())
@@ -61,7 +63,9 @@ const updateSiteResourceSchema = z
clientIds: z.array(z.int()),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional()
disableIcmp: z.boolean().optional(),
authDaemonPort: z.int().positive().nullish(),
authDaemonMode: z.enum(["site", "remote"]).optional()
})
.strict()
.refine(
@@ -172,7 +176,9 @@ export async function updateSiteResource(
clientIds,
tcpPortRangeString,
udpPortRangeString,
disableIcmp
disableIcmp,
authDaemonPort,
authDaemonMode
} = parsedBody.data;
const [site] = await db
@@ -198,6 +204,11 @@ export async function updateSiteResource(
);
}
const isLicensedSshPam = await isLicensedOrSubscribed(
existingSiteResource.orgId,
tierMatrix.sshPam
);
const [org] = await db
.select()
.from(orgs)
@@ -308,6 +319,18 @@ export async function updateSiteResource(
// wait some time to allow for messages to be handled
await new Promise((resolve) => setTimeout(resolve, 750));
const sshPamSet =
isLicensedSshPam &&
(authDaemonPort !== undefined || authDaemonMode !== undefined)
? {
...(authDaemonPort !== undefined && {
authDaemonPort
}),
...(authDaemonMode !== undefined && {
authDaemonMode
})
}
: {};
[updatedSiteResource] = await trx
.update(siteResources)
.set({
@@ -319,7 +342,8 @@ export async function updateSiteResource(
alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp
disableIcmp: disableIcmp,
...sshPamSet
})
.where(
and(
@@ -397,6 +421,18 @@ export async function updateSiteResource(
);
} else {
// Update the site resource
const sshPamSet =
isLicensedSshPam &&
(authDaemonPort !== undefined || authDaemonMode !== undefined)
? {
...(authDaemonPort !== undefined && {
authDaemonPort
}),
...(authDaemonMode !== undefined && {
authDaemonMode
})
}
: {};
[updatedSiteResource] = await trx
.update(siteResources)
.set({
@@ -408,7 +444,8 @@ export async function updateSiteResource(
alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp
disableIcmp: disableIcmp,
...sshPamSet
})
.where(
and(eq(siteResources.siteResourceId, siteResourceId))

View File

@@ -0,0 +1,29 @@
import { __DIRNAME, APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.16.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
// set all admin role sudo to "full"; all other roles to "none"
// all roles set hoemdir to true
// generate ca certs for all orgs?
// set authDaemonMode to "site" for all orgs
try {
db.transaction(() => {})();
console.log(`Migrated database`);
} catch (e) {
console.log("Failed to migrate db:", e);
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -47,7 +47,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
/>
<ClientProvider client={client}>
<div className="space-y-6">
<div className="space-y-4">
<ClientInfoCard />
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>

View File

@@ -78,7 +78,7 @@ export default async function GeneralSettingsPage({
description={t("orgSettingsDescription")}
/>
<div className="space-y-6">
<div className="space-y-4">
<OrgInfoCard />
<HorizontalTabs items={navItems}>
{children}

View File

@@ -74,7 +74,9 @@ export default async function ClientResourcesPage(
niceId: siteResource.niceId,
tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null,
disableIcmp: siteResource.disableIcmp || false
disableIcmp: siteResource.disableIcmp || false,
authDaemonMode: siteResource.authDaemonMode ?? null,
authDaemonPort: siteResource.authDaemonPort ?? null
};
}
);

View File

@@ -32,8 +32,8 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useState } from "react";
import { SwitchInput } from "@app/components/SwitchInput";
import { ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
const GeneralFormSchema = z.object({
name: z.string().nonempty("Name is required"),
@@ -187,21 +187,22 @@ export default function GeneralPage() {
</FormControl>
<FormMessage />
<FormDescription>
{t(
"enableDockerSocketDescription"
)}{" "}
<Link
href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
<span>
{t(
"enableDockerSocketLink"
)}
</span>
</Link>
{t.rich(
"enableDockerSocketDescription",
{
docsLink: (chunks) => (
<a
href="https://docs.pangolin.net/manage/sites/configure-site#docker-socket-integration"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{chunks}
<ExternalLink className="size-3.5 shrink-0" />
</a>
)
}
)}
</FormDescription>
</FormItem>
)}

View File

@@ -56,7 +56,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
/>
<SiteProvider site={site}>
<div className="space-y-6">
<div className="space-y-4">
<SiteInfoCard />
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>

View File

@@ -82,13 +82,13 @@ export default async function RootLayout({
<body className={`${font.className} h-screen-safe overflow-hidden`}>
<StoreInternalRedirect />
<TopLoader />
{build === "saas" && (
{/* build === "saas" && (
<Script
src="https://rybbit.fossorial.io/api/script.js"
data-site-id="fe1ff2a33287"
strategy="afterInteractive"
/>
)}
)*/}
<ViewportHeightFix />
<NextIntlClientProvider>
<ThemeProvider
@@ -125,9 +125,9 @@ export default async function RootLayout({
</ThemeProvider>
</NextIntlClientProvider>
{process.env.NODE_ENV === "development" && (
{/*process.env.NODE_ENV === "development" && (
<TailwindIndicator />
)}
)*/}
</body>
</html>
);

View File

@@ -2,6 +2,7 @@ import { SidebarNavItem } from "@app/components/SidebarNav";
import { Env } from "@app/lib/types/env";
import { build } from "@server/build";
import {
Building2,
ChartLine,
Combine,
CreditCard,
@@ -11,10 +12,11 @@ import {
KeyRound,
Laptop,
Link as LinkIcon,
Logs, // Added from 'dev' branch
Logs,
MonitorUp,
Plug,
ReceiptText,
ScanEye, // Added from 'dev' branch
ScanEye,
Server,
Settings,
SquareMousePointer,
@@ -49,12 +51,12 @@ export const orgNavSections = (
options?: OrgNavSectionsOptions
): SidebarNavSection[] => [
{
heading: "sidebarGeneral",
heading: "network",
items: [
{
title: "sidebarSites",
href: "/{orgId}/settings/sites",
icon: <Combine className="size-4 flex-none" />
icon: <Plug className="size-4 flex-none" />
},
{
title: "sidebarResources",
@@ -108,14 +110,19 @@ export const orgNavSections = (
heading: "access",
items: [
{
title: "sidebarUsers",
icon: <User className="size-4 flex-none" />,
title: "sidebarTeam",
icon: <Users className="size-4 flex-none" />,
items: [
{
title: "sidebarUsers",
href: "/{orgId}/settings/access/users",
icon: <User className="size-4 flex-none" />
},
{
title: "sidebarRoles",
href: "/{orgId}/settings/access/roles",
icon: <Users className="size-4 flex-none" />
},
{
title: "sidebarInvitations",
href: "/{orgId}/settings/access/invitations",
@@ -123,11 +130,6 @@ export const orgNavSections = (
}
]
},
{
title: "sidebarRoles",
href: "/{orgId}/settings/access/roles",
icon: <Users className="size-4 flex-none" />
},
// PaidFeaturesAlert
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
build === "saas" ||
@@ -157,92 +159,88 @@ export const orgNavSections = (
}
]
},
{
heading: "sidebarLogsAndAnalytics",
items: (() => {
const logItems: SidebarNavItem[] = [
{
title: "sidebarLogsRequest",
href: "/{orgId}/settings/logs/request",
icon: <SquareMousePointer className="size-4 flex-none" />
},
...(!env?.flags.disableEnterpriseFeatures
? [
{
title: "sidebarLogsAccess",
href: "/{orgId}/settings/logs/access",
icon: <ScanEye className="size-4 flex-none" />
},
{
title: "sidebarLogsAction",
href: "/{orgId}/settings/logs/action",
icon: <Logs className="size-4 flex-none" />
}
]
: [])
];
const analytics = {
title: "sidebarLogsAnalytics",
href: "/{orgId}/settings/logs/analytics",
icon: <ChartLine className="h-4 w-4" />
};
// If only one log item, return it directly without grouping
if (logItems.length === 1) {
return [analytics, ...logItems];
}
// If multiple log items, create a group
return [
analytics,
{
title: "sidebarLogs",
icon: <Logs className="size-4 flex-none" />,
items: logItems
}
];
})()
},
{
heading: "sidebarOrganization",
items: [
{
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
title: "sidebarLogsAndAnalytics",
icon: <ChartLine className="size-4 flex-none" />,
items: [
{
title: "sidebarLogsAnalytics",
href: "/{orgId}/settings/logs/analytics",
icon: <ChartLine className="size-4 flex-none" />
},
{
title: "sidebarLogsRequest",
href: "/{orgId}/settings/logs/request",
icon: (
<SquareMousePointer className="size-4 flex-none" />
)
},
...(!env?.flags.disableEnterpriseFeatures
? [
{
title: "sidebarLogsAccess",
href: "/{orgId}/settings/logs/access",
icon: <ScanEye className="size-4 flex-none" />
},
{
title: "sidebarLogsAction",
href: "/{orgId}/settings/logs/action",
icon: <Logs className="size-4 flex-none" />
}
]
: [])
]
},
{
title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints",
icon: <ReceiptText className="size-4 flex-none" />
title: "sidebarManagement",
icon: <Building2 className="size-4 flex-none" />,
items: [
{
title: "sidebarApiKeys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" />
},
{
title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints",
icon: <ReceiptText className="size-4 flex-none" />
}
]
},
...(build == "saas" && options?.isPrimaryOrg
? [
{
title: "sidebarBillingAndLicenses",
icon: <CreditCard className="size-4 flex-none" />,
items: [
{
title: "sidebarBilling",
href: "/{orgId}/settings/billing",
icon: (
<CreditCard className="size-4 flex-none" />
)
},
{
title: "sidebarEnterpriseLicenses",
href: "/{orgId}/settings/license",
icon: (
<TicketCheck className="size-4 flex-none" />
)
}
]
}
]
: []),
{
title: "sidebarSettings",
href: "/{orgId}/settings/general",
icon: <Settings className="size-4 flex-none" />
}
]
},
...(build == "saas" && options?.isPrimaryOrg
? [
{
heading: "sidebarBillingAndLicenses",
items: [
{
title: "sidebarBilling",
href: "/{orgId}/settings/billing",
icon: <CreditCard className="size-4 flex-none" />
},
{
title: "sidebarEnterpriseLicenses",
href: "/{orgId}/settings/license",
icon: <TicketCheck className="size-4 flex-none" />
}
]
}
]
: [])
}
];
export const adminNavSections = (env?: Env): SidebarNavSection[] => [

View File

@@ -51,6 +51,8 @@ export type InternalResourceRow = {
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean;
authDaemonMode?: "site" | "remote" | null;
authDaemonPort?: number | null;
};
type ClientResourcesTableProps = {

File diff suppressed because it is too large Load Diff

View File

@@ -11,31 +11,19 @@ import {
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import type { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import type {
CreateRoleBody,
CreateRoleResponse
} from "@server/routers/role";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { CheckboxWithLabel } from "./ui/checkbox";
import { RoleForm, type RoleFormValues } from "./RoleForm";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type CreateRoleFormProps = {
@@ -52,35 +40,39 @@ export default function CreateRoleForm({
const { org } = useOrgContext();
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const { env } = useEnvContext();
const formSchema = z.object({
name: z
.string({ message: t("nameRequired") })
.min(1)
.max(32),
description: z.string().max(255).optional(),
requireDeviceApproval: z.boolean().optional()
});
const api = createApiClient(useEnvContext());
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
description: "",
requireDeviceApproval: false
}
});
const [loading, startTransition] = useTransition();
async function onSubmit(values: z.infer<typeof formSchema>) {
async function onSubmit(values: RoleFormValues) {
const payload: CreateRoleBody = {
name: values.name,
description: values.description || undefined,
requireDeviceApproval: values.requireDeviceApproval,
allowSsh: values.allowSsh
};
if (isPaidUser(tierMatrix.sshPam)) {
payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands =
values.sshSudoMode === "commands" &&
values.sshSudoCommands?.trim()
? values.sshSudoCommands
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [];
if (values.sshUnixGroups?.trim()) {
payload.sshUnixGroups = values.sshUnixGroups
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
}
const res = await api
.put<
AxiosResponse<CreateRoleResponse>
>(`/org/${org?.org.orgId}/role`, values satisfies CreateRoleBody)
.put<AxiosResponse<CreateRoleResponse>>(
`/org/${org?.org.orgId}/role`,
payload
)
.catch((e) => {
toast({
variant: "destructive",
@@ -98,143 +90,42 @@ export default function CreateRoleForm({
title: t("accessRoleCreated"),
description: t("accessRoleCreatedDescription")
});
if (open) {
setOpen(false);
}
if (open) setOpen(false);
afterCreate?.(res.data.data);
}
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("accessRoleCreate")}</CredenzaTitle>
<CredenzaDescription>
{t("accessRoleCreateDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) =>
startTransition(() => onSubmit(values))
)}
className="space-y-4"
id="create-role-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("accessRoleName")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("description")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!env.flags.disableEnterpriseFeatures && (
<>
<PaidFeaturesAlert
tiers={tierMatrix.deviceApprovals}
/>
<FormField
control={form.control}
name="requireDeviceApproval"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
disabled={
!isPaidUser(
tierMatrix.deviceApprovals
)
}
value="on"
checked={form.watch(
"requireDeviceApproval"
)}
onCheckedChange={(
checked
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"requireDeviceApproval",
checked
);
}
}}
label={t(
"requireDeviceApproval"
)}
/>
</FormControl>
<FormDescription>
{t(
"requireDeviceApprovalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
form="create-role-form"
loading={loading}
disabled={loading}
>
{t("accessRoleCreateSubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("accessRoleCreate")}</CredenzaTitle>
<CredenzaDescription>
{t("accessRoleCreateDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<RoleForm
variant="create"
onSubmit={(values) =>
startTransition(() => onSubmit(values))
}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
form="create-role-form"
loading={loading}
disabled={loading}
>
{t("accessRoleCreateSubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
return (
<CredenzaContent
className={cn(
"overflow-y-auto max-h-[100dvh] md:max-h-screen",
"overflow-y-auto max-h-[100dvh] md:max-h-screen md:top-[clamp(1.5rem,12vh,200px)] md:translate-y-0",
className
)}
{...props}

File diff suppressed because it is too large Load Diff

View File

@@ -11,44 +11,26 @@ import {
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { build } from "@server/build";
import type { Role } from "@server/db";
import type {
CreateRoleBody,
CreateRoleResponse,
UpdateRoleBody,
UpdateRoleResponse
} from "@server/routers/role";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import { useTransition } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { CheckboxWithLabel } from "./ui/checkbox";
import { RoleForm, type RoleFormValues } from "./RoleForm";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
type CreateRoleFormProps = {
type EditRoleFormProps = {
role: Role;
open: boolean;
setOpen: (open: boolean) => void;
onSuccess?: (res: CreateRoleResponse) => void;
onSuccess?: (res: UpdateRoleResponse) => void;
};
export default function EditRoleForm({
@@ -56,39 +38,44 @@ export default function EditRoleForm({
role,
setOpen,
onSuccess
}: CreateRoleFormProps) {
const { org } = useOrgContext();
}: EditRoleFormProps) {
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const { env } = useEnvContext();
const formSchema = z.object({
name: z
.string({ message: t("nameRequired") })
.min(1)
.max(32),
description: z.string().max(255).optional(),
requireDeviceApproval: z.boolean().optional()
});
const api = createApiClient(useEnvContext());
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: role.name,
description: role.description ?? "",
requireDeviceApproval: role.requireDeviceApproval ?? false
}
});
const [loading, startTransition] = useTransition();
async function onSubmit(values: z.infer<typeof formSchema>) {
async function onSubmit(values: RoleFormValues) {
const payload: UpdateRoleBody = {
requireDeviceApproval: values.requireDeviceApproval,
allowSsh: values.allowSsh
};
if (!role.isAdmin) {
payload.name = values.name;
payload.description = values.description || undefined;
}
if (isPaidUser(tierMatrix.sshPam)) {
payload.sshSudoMode = values.sshSudoMode;
payload.sshCreateHomeDir = values.sshCreateHomeDir;
payload.sshSudoCommands =
values.sshSudoMode === "commands" &&
values.sshSudoCommands?.trim()
? values.sshSudoCommands
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [];
if (values.sshUnixGroups !== undefined) {
payload.sshUnixGroups = values.sshUnixGroups
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
}
const res = await api
.post<
AxiosResponse<UpdateRoleResponse>
>(`/role/${role.roleId}`, values satisfies UpdateRoleBody)
.post<AxiosResponse<UpdateRoleResponse>>(
`/role/${role.roleId}`,
payload
)
.catch((e) => {
toast({
variant: "destructive",
@@ -106,143 +93,43 @@ export default function EditRoleForm({
title: t("accessRoleUpdated"),
description: t("accessRoleUpdatedDescription")
});
if (open) {
setOpen(false);
}
if (open) setOpen(false);
onSuccess?.(res.data.data);
}
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("accessRoleEdit")}</CredenzaTitle>
<CredenzaDescription>
{t("accessRoleEditDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) =>
startTransition(() => onSubmit(values))
)}
className="space-y-4"
id="create-role-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("accessRoleName")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("description")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!env.flags.disableEnterpriseFeatures && (
<>
<PaidFeaturesAlert
tiers={tierMatrix.deviceApprovals}
/>
<FormField
control={form.control}
name="requireDeviceApproval"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
disabled={
!isPaidUser(
tierMatrix.deviceApprovals
)
}
value="on"
checked={form.watch(
"requireDeviceApproval"
)}
onCheckedChange={(
checked
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"requireDeviceApproval",
checked
);
}
}}
label={t(
"requireDeviceApproval"
)}
/>
</FormControl>
<FormDescription>
{t(
"requireDeviceApprovalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
form="create-role-form"
loading={loading}
disabled={loading}
>
{t("accessRoleUpdateSubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("accessRoleEdit")}</CredenzaTitle>
<CredenzaDescription>
{t("accessRoleEditDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<RoleForm
variant="edit"
role={role}
onSubmit={(values) =>
startTransition(() => onSubmit(values))
}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
form="create-role-form"
loading={loading}
disabled={loading}
>
{t("accessRoleUpdateSubmit")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -75,7 +75,7 @@ export async function Layout({
<div
className={cn(
"container mx-auto max-w-12xl mb-12",
showHeader && "md:pt-16" // Add top padding only on desktop to account for fixed header
showHeader && "md:pt-14" // Add top padding only on desktop to account for fixed header
)}
>
{children}

View File

@@ -73,14 +73,14 @@ export function LayoutMobileMenu({
{t("navbarDescription")}
</SheetDescription>
<div className="flex-1 overflow-y-auto relative">
<div className="px-3">
<div className="px-1">
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
</div>
<div className="w-full border-b border-border" />
<div className="px-3">
<div className="px-3 pt-3">
{!isAdminPage &&
user.serverAdmin && (
<div className="py-2">

View File

@@ -18,7 +18,7 @@ import { approvalQueries } from "@app/lib/queries";
import { build } from "@server/build";
import { useQuery } from "@tanstack/react-query";
import { ListUserOrgsResponse } from "@server/routers/org";
import { ExternalLink, Server } from "lucide-react";
import { ArrowRight, ExternalLink, PanelRightOpen, Server } from "lucide-react";
import { useTranslations } from "next-intl";
import dynamic from "next/dynamic";
import Link from "next/link";
@@ -145,37 +145,7 @@ export function LayoutSidebar({
)}
/>
<div className="flex-1 overflow-y-auto relative">
<div className="px-2 pt-1">
{!isAdminPage && user.serverAdmin && (
<div className="py-2">
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed
? t("serverAdmin")
: undefined
}
>
<span
className={cn(
"shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<Server className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<span>{t("serverAdmin")}</span>
)}
</Link>
</div>
)}
<div className="px-2 pt-3">
<SidebarNav
sections={navItems}
isCollapsed={isSidebarCollapsed}
@@ -186,31 +156,89 @@ export function LayoutSidebar({
<div className="sticky bottom-0 left-0 right-0 h-8 pointer-events-none bg-gradient-to-t from-card to-transparent" />
</div>
<div className="w-full border-t border-border" />
{!isAdminPage && user.serverAdmin && (
<div className="shrink-0 px-2 pb-2">
<Link
href="/admin"
className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
isSidebarCollapsed
? "px-2 py-2 justify-center"
: "px-3 py-1.5"
)}
title={
isSidebarCollapsed ? t("serverAdmin") : undefined
}
>
<span
className={cn(
"shrink-0",
!isSidebarCollapsed && "mr-2"
)}
>
<Server className="h-4 w-4" />
</span>
{!isSidebarCollapsed && (
<>
<span className="flex-1">
{t("serverAdmin")}
</span>
<ArrowRight className="h-4 w-4 shrink-0 ml-auto opacity-70" />
</>
)}
</Link>
</div>
)}
<div className="p-4 pt-1 flex flex-col shrink-0">
{canShowProductUpdates ? (
<div className="mb-3">
{isSidebarCollapsed && (
<div className="shrink-0 flex justify-center py-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
setIsSidebarCollapsed(false);
setHasManualToggle(true);
setSidebarStateCookie(false);
}}
className="rounded-md p-2 text-muted-foreground hover:text-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 transition-colors"
aria-label={t("sidebarExpand")}
>
<PanelRightOpen className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{t("sidebarExpand")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
<div className="w-full border-t border-border mb-3" />
<div className="p-4 pt-0 mt-0 flex flex-col shrink-0">
{canShowProductUpdates && (
<div className="mb-3 empty:mb-0">
<ProductUpdates isCollapsed={isSidebarCollapsed} />
</div>
) : (
<div className="mb-3"></div>
)}
{build === "enterprise" && (
<div className="mb-3">
<div className="mb-3 empty:mb-0">
<SidebarLicenseButton
isCollapsed={isSidebarCollapsed}
/>
</div>
)}
{build === "oss" && (
<div className="mb-3">
<div className="mb-3 empty:mb-0">
<SupporterStatus isCollapsed={isSidebarCollapsed} />
</div>
)}
{build === "saas" && (
<div className="mb-3">
<div className="mb-3 empty:mb-0">
<SidebarSupportButton
isCollapsed={isSidebarCollapsed}
/>
@@ -226,19 +254,19 @@ export function LayoutSidebar({
className="whitespace-nowrap"
>
{link.href ? (
<div className="text-xs text-muted-foreground text-center">
<div className="text-xs text-muted-foreground text-left">
<Link
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
className="flex items-center justify-start gap-1"
>
{link.text}
<ExternalLink size={12} />
</Link>
</div>
) : (
<div className="text-xs text-muted-foreground text-center">
<div className="text-xs text-muted-foreground text-left">
{link.text}
</div>
)}
@@ -247,12 +275,12 @@ export function LayoutSidebar({
</>
) : (
<>
<div className="text-xs text-muted-foreground text-center">
<div className="text-xs text-muted-foreground text-left">
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
className="flex items-center justify-start gap-1"
>
{build === "oss"
? t("communityEdition")
@@ -265,22 +293,22 @@ export function LayoutSidebar({
{build === "enterprise" &&
isUnlocked() &&
licenseStatus?.tier === "personal" ? (
<div className="text-xs text-muted-foreground text-center">
<div className="text-xs text-muted-foreground text-left">
{t("personalUseOnly")}
</div>
) : null}
{build === "enterprise" && !isUnlocked() ? (
<div className="text-xs text-muted-foreground text-center">
<div className="text-xs text-muted-foreground text-left">
{t("unlicensed")}
</div>
) : null}
{env?.app?.version && (
<div className="text-xs text-muted-foreground text-center">
<div className="text-xs text-muted-foreground text-left">
<Link
href={`https://github.com/fosrl/pangolin/releases/tag/${env.app.version}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
className="flex items-center justify-start gap-1"
>
v{env.app.version}
<ExternalLink size={12} />

View File

@@ -0,0 +1,70 @@
"use client";
import { Button } from "@app/components/ui/button";
import { cn } from "@app/lib/cn";
import type { ReactNode } from "react";
export type OptionSelectOption<TValue extends string> = {
value: TValue;
label: string;
icon?: ReactNode;
};
type OptionSelectProps<TValue extends string> = {
options: ReadonlyArray<OptionSelectOption<TValue>>;
value: TValue;
onChange: (value: TValue) => void;
label?: string;
/** Grid columns: 2, 3, 4, 5, etc. Default 5 on md+. */
cols?: number;
className?: string;
disabled?: boolean;
};
export function OptionSelect<TValue extends string>({
options,
value,
onChange,
label,
cols = 5,
className,
disabled = false
}: OptionSelectProps<TValue>) {
return (
<div className={className}>
{label && (
<p className="font-bold mb-3">{label}</p>
)}
<div
className={cn(
"grid gap-2",
cols === 2 && "grid-cols-2",
cols === 3 && "grid-cols-2 md:grid-cols-3",
cols === 4 && "grid-cols-2 md:grid-cols-4",
cols === 5 && "grid-cols-2 md:grid-cols-5",
cols === 6 && "grid-cols-2 md:grid-cols-3 lg:grid-cols-6"
)}
>
{options.map((option) => {
const isSelected = value === option.value;
return (
<Button
key={option.value}
type="button"
variant={isSelected ? "squareOutlinePrimary" : "squareOutline"}
className={cn(
"flex-1 min-w-30 shadow-none",
isSelected && "bg-primary/10"
)}
onClick={() => onChange(option.value)}
disabled={disabled}
>
{option.icon}
{option.label}
</Button>
);
})}
</div>
</div>
);
}

View File

@@ -6,8 +6,7 @@ import {
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
CommandList
} from "@app/components/ui/command";
import {
Popover,
@@ -25,6 +24,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
import { cn } from "@app/lib/cn";
import { ListUserOrgsResponse } from "@server/routers/org";
import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react";
import { Button } from "@app/components/ui/button";
import { usePathname, useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useUserContext } from "@app/hooks/useUserContext";
@@ -71,7 +71,7 @@ export function OrgSelector({
"cursor-pointer transition-colors",
isCollapsed
? "w-full h-16 flex items-center justify-center hover:bg-muted"
: "w-full px-4 py-4 hover:bg-muted"
: "w-full px-5 py-4 hover:bg-muted"
)}
>
{isCollapsed ? (
@@ -93,68 +93,45 @@ export function OrgSelector({
)}
</div>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command className="rounded-lg">
<PopoverContent
className="w-[320px] p-0 ml-4 flex flex-col relative overflow-visible"
align="start"
sideOffset={12}
>
<Command className="rounded-lg border-0 flex-1 min-h-0">
<CommandInput
placeholder={t("searchPlaceholder")}
className="border-0 focus:ring-0"
className="border-0 focus:ring-0 h-9 rounded-b-none"
/>
<CommandEmpty className="py-6 text-center">
<div className="text-muted-foreground text-sm">
{t("orgNotFound2")}
</div>
</CommandEmpty>
{(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
<>
<CommandGroup
heading={t("create")}
className="py-2"
>
<CommandList>
<CommandItem
onSelect={() => {
setOpen(false);
router.push("/setup");
}}
className="mx-2 rounded-md"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 mr-3">
<Plus className="h-4 w-4 text-primary" />
</div>
<div className="flex flex-col">
<span className="font-medium">
{t("setupNewOrg")}
</span>
<span className="text-xs text-muted-foreground">
{t("createNewOrgDescription")}
</span>
</div>
</CommandItem>
</CommandList>
</CommandGroup>
<CommandSeparator className="my-2" />
</>
)}
<CommandGroup heading={t("orgs")} className="py-2">
<CommandList>
<CommandList className="max-h-[280px]">
<CommandEmpty className="py-4 text-center">
<div className="text-muted-foreground text-sm">
{t("orgNotFound2")}
</div>
</CommandEmpty>
<CommandGroup className="p-1" heading={t("orgs")}>
{sortedOrgs.map((org) => (
<CommandItem
key={org.orgId}
onSelect={() => {
setOpen(false);
const newPath = pathname.replace(
/^\/[^/]+/,
`/${org.orgId}`
);
const newPath = pathname.includes(
"/settings/"
)
? pathname.replace(
/^\/[^/]+/,
`/${org.orgId}`
)
: `/${org.orgId}`;
router.push(newPath);
}}
className="mx-2 rounded-md"
className="mx-1 rounded-md py-1.5 h-auto min-h-0"
>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted mr-3">
<Users className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center justify-center w-6 h-6 rounded-md bg-muted mr-2.5 flex-shrink-0">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="font-medium truncate">
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
<span className="font-medium truncate text-sm">
{org.name}
</span>
<div className="flex items-center gap-2 min-w-0">
@@ -173,7 +150,7 @@ export function OrgSelector({
</div>
<Check
className={cn(
"h-4 w-4 text-primary",
"h-4 w-4 text-primary flex-shrink-0",
orgId === org.orgId
? "opacity-100"
: "opacity-0"
@@ -181,9 +158,25 @@ export function OrgSelector({
/>
</CommandItem>
))}
</CommandList>
</CommandGroup>
</CommandGroup>
</CommandList>
</Command>
{(!env.flags.disableUserCreateOrg || user.serverAdmin) && (
<div className="p-2 border-t border-border">
<Button
variant="ghost"
size="sm"
className="w-full justify-start h-8 font-normal text-muted-foreground hover:text-foreground"
onClick={() => {
setOpen(false);
router.push("/setup");
}}
>
<Plus className="h-3.5 w-3.5 mr-2" />
{t("setupNewOrg")}
</Button>
</div>
)}
</PopoverContent>
</Popover>
);

View File

@@ -12,34 +12,42 @@ import { useParams } from "next/navigation";
const TIER_ORDER: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
const TIER_TRANSLATION_KEYS: Record<Tier, "subscriptionTierTier1" | "subscriptionTierTier2" | "subscriptionTierTier3" | "subscriptionTierEnterprise"> = {
const TIER_TRANSLATION_KEYS: Record<
Tier,
| "subscriptionTierTier1"
| "subscriptionTierTier2"
| "subscriptionTierTier3"
| "subscriptionTierEnterprise"
> = {
tier1: "subscriptionTierTier1",
tier2: "subscriptionTierTier2",
tier3: "subscriptionTierTier3",
enterprise: "subscriptionTierEnterprise"
};
function getRequiredTier(tiers: Tier[]): Tier | null {
function formatRequiredTiersList(
tiers: Tier[],
t: (key: (typeof TIER_TRANSLATION_KEYS)[Tier]) => string
): string | null {
if (tiers.length === 0) return null;
let min: Tier | null = null;
for (const tier of tiers) {
const idx = TIER_ORDER.indexOf(tier);
if (idx === -1) continue;
if (min === null || TIER_ORDER.indexOf(min) > idx) {
min = tier;
}
}
return min;
const sorted = [...tiers]
.filter((tier) => TIER_ORDER.includes(tier))
.sort((a, b) => TIER_ORDER.indexOf(a) - TIER_ORDER.indexOf(b));
if (sorted.length === 0) return null;
const names = sorted.map((tier) => t(TIER_TRANSLATION_KEYS[tier]));
if (names.length === 1) return names[0];
if (names.length === 2) return `${names[0]} or ${names[1]}`;
return `${names.slice(0, -1).join(", ")}, or ${names.at(-1)}`;
}
const bannerClassName =
"mb-6 border-purple-500/30 bg-linear-to-br from-purple-500/10 via-background to-background overflow-hidden";
"mb-6 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden";
const bannerContentClassName = "py-3 px-4";
const bannerRowClassName =
"flex items-center gap-2.5 text-sm text-muted-foreground";
const bannerIconClassName = "size-4 shrink-0 text-purple-500";
const bannerIconClassName = "size-4 shrink-0 text-black-500";
const docsLinkClassName =
"inline-flex items-center gap-1 font-medium text-purple-600 underline";
"inline-flex items-center gap-1 font-medium text-black-600 underline";
const PANGOLIN_CLOUD_SIGNUP_URL = "https://app.pangolin.net/auth/signup/";
const ENTERPRISE_DOCS_URL =
"https://docs.pangolin.net/self-host/enterprise-edition";
@@ -94,11 +102,17 @@ export function PaidFeaturesAlert({ tiers }: Props) {
const t = useTranslations();
const params = useParams();
const orgId = params?.orgId as string | undefined;
const { hasSaasSubscription, hasEnterpriseLicense, isActive, subscriptionTier } = usePaidStatus();
const {
hasSaasSubscription,
hasEnterpriseLicense,
isActive,
subscriptionTier
} = usePaidStatus();
const { env } = useEnvContext();
const requiredTier = getRequiredTier(tiers);
const requiredTierName = requiredTier ? t(TIER_TRANSLATION_KEYS[requiredTier]) : null;
const billingHref = orgId ? `/${orgId}/settings/billing` : "https://pangolin.net/pricing";
const requiredTiersLabel = formatRequiredTiersList(tiers, t);
const billingHref = orgId
? `/${orgId}/settings/billing`
: "https://pangolin.net/pricing";
const tierLinkRenderer = getTierLinkRenderer(billingHref);
const pangolinCloudLinkRenderer = getPangolinCloudLinkRenderer();
const enterpriseDocsLinkRenderer = getDocsLinkRenderer(ENTERPRISE_DOCS_URL);
@@ -115,16 +129,16 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<div className={bannerRowClassName}>
<KeyRound className={bannerIconClassName} />
<span>
{requiredTierName
{requiredTiersLabel
? isActive
? t.rich("upgradeToTierToUse", {
tier: requiredTierName,
tierLink: tierLinkRenderer
})
: t.rich("subscriptionRequiredTierToUse", {
tier: requiredTierName,
tierLink: tierLinkRenderer
})
tier: requiredTiersLabel,
tierLink: tierLinkRenderer
})
: t.rich("upgradeToTierToUse", {
tier: requiredTiersLabel,
tierLink: tierLinkRenderer
})
: isActive
? t("mustUpgradeToUse")
: t("subscriptionRequiredToUse")}
@@ -141,7 +155,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<KeyRound className={bannerIconClassName} />
<span>
{t.rich("licenseRequiredToUse", {
enterpriseLicenseLink: enterpriseDocsLinkRenderer,
enterpriseLicenseLink:
enterpriseDocsLinkRenderer,
pangolinCloudLink: pangolinCloudLinkRenderer
})}
</span>
@@ -157,7 +172,8 @@ export function PaidFeaturesAlert({ tiers }: Props) {
<KeyRound className={bannerIconClassName} />
<span>
{t.rich("ossEnterpriseEditionRequired", {
enterpriseEditionLink: enterpriseDocsLinkRenderer,
enterpriseEditionLink:
enterpriseDocsLinkRenderer,
pangolinCloudLink: pangolinCloudLinkRenderer
})}
</span>

View File

@@ -105,7 +105,7 @@ export default function ProductUpdates({
<div className="flex flex-col gap-1">
<small
className={cn(
"text-xs text-muted-foreground flex items-center gap-1 mt-2",
"text-xs text-muted-foreground flex items-center gap-1 mt-2 empty:mt-0",
showMoreUpdatesText
? "animate-in fade-in duration-300"
: "opacity-0"

468
src/components/RoleForm.tsx Normal file
View File

@@ -0,0 +1,468 @@
"use client";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
OptionSelect,
type OptionSelectOption
} from "@app/components/OptionSelect";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
import { CheckboxWithLabel } from "./ui/checkbox";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Role } from "@server/db";
export const SSH_SUDO_MODE_VALUES = ["none", "full", "commands"] as const;
export type SshSudoMode = (typeof SSH_SUDO_MODE_VALUES)[number];
function parseRoleJsonArray(value: string | null | undefined): string[] {
if (value == null || value === "") return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function toSshSudoMode(value: string | null | undefined): SshSudoMode {
if (value === "none" || value === "full" || value === "commands")
return value;
return "none";
}
export type RoleFormValues = {
name: string;
description?: string;
requireDeviceApproval?: boolean;
allowSsh?: boolean;
sshSudoMode: SshSudoMode;
sshSudoCommands?: string;
sshCreateHomeDir?: boolean;
sshUnixGroups?: string;
};
type RoleFormProps = {
variant: "create" | "edit";
role?: Role;
onSubmit: (values: RoleFormValues) => void | Promise<void>;
formId?: string;
};
export function RoleForm({
variant,
role,
onSubmit,
formId = "create-role-form"
}: RoleFormProps) {
const t = useTranslations();
const { isPaidUser } = usePaidStatus();
const { env } = useEnvContext();
const formSchema = z.object({
name: z
.string({ message: t("nameRequired") })
.min(1)
.max(32),
description: z.string().max(255).optional(),
requireDeviceApproval: z.boolean().optional(),
allowSsh: z.boolean().optional(),
sshSudoMode: z.enum(SSH_SUDO_MODE_VALUES),
sshSudoCommands: z.string().optional(),
sshCreateHomeDir: z.boolean().optional(),
sshUnixGroups: z.string().optional()
});
const defaultValues: RoleFormValues = role
? {
name: role.name,
description: role.description ?? "",
requireDeviceApproval: role.requireDeviceApproval ?? false,
allowSsh:
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
sshSudoMode: toSshSudoMode(role.sshSudoMode),
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
", "
),
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
}
: {
name: "",
description: "",
requireDeviceApproval: false,
allowSsh: false,
sshSudoMode: "none",
sshSudoCommands: "",
sshCreateHomeDir: true,
sshUnixGroups: ""
};
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues
});
useEffect(() => {
if (variant === "edit" && role) {
form.reset({
name: role.name,
description: role.description ?? "",
requireDeviceApproval: role.requireDeviceApproval ?? false,
allowSsh:
(role as Role & { allowSsh?: boolean }).allowSsh ?? false,
sshSudoMode: toSshSudoMode(role.sshSudoMode),
sshSudoCommands: parseRoleJsonArray(role.sshSudoCommands).join(
", "
),
sshCreateHomeDir: role.sshCreateHomeDir ?? false,
sshUnixGroups: parseRoleJsonArray(role.sshUnixGroups).join(", ")
});
}
}, [variant, role, form]);
const sshDisabled = !isPaidUser(tierMatrix.sshPam);
const sshSudoMode = form.watch("sshSudoMode");
const isAdminRole = variant === "edit" && role?.isAdmin === true;
useEffect(() => {
if (sshDisabled) {
form.setValue("allowSsh", false);
}
}, [sshDisabled, form]);
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((values) => onSubmit(values))}
className="space-y-4"
id={formId}
>
{env.flags.disableEnterpriseFeatures ? (
<div className="space-y-4 mt-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("accessRoleName")}</FormLabel>
<FormControl>
<Input
{...field}
disabled={isAdminRole}
readOnly={isAdminRole}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t("description")}</FormLabel>
<FormControl>
<Input
{...field}
disabled={isAdminRole}
readOnly={isAdminRole}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
) : (
<HorizontalTabs
clientSide={true}
defaultTab={0}
items={[
{ title: t("general"), href: "#" },
...(env.flags.disableEnterpriseFeatures
? []
: [{ title: t("sshAccess"), href: "#" }])
]}
>
{/* General tab */}
<div className="space-y-4 mt-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("accessRoleName")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled={isAdminRole}
readOnly={isAdminRole}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("description")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled={isAdminRole}
readOnly={isAdminRole}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<PaidFeaturesAlert
tiers={tierMatrix.deviceApprovals}
/>
<FormField
control={form.control}
name="requireDeviceApproval"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
disabled={
!isPaidUser(
tierMatrix.deviceApprovals
)
}
value="on"
checked={form.watch(
"requireDeviceApproval"
)}
onCheckedChange={(checked) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"requireDeviceApproval",
checked
);
}
}}
label={t(
"requireDeviceApproval"
)}
/>
</FormControl>
<FormDescription>
{t(
"requireDeviceApprovalDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* SSH tab - hidden when enterprise features are disabled */}
{!env.flags.disableEnterpriseFeatures && (
<div className="space-y-4 mt-4">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<FormField
control={form.control}
name="allowSsh"
render={({ field }) => {
const allowSshOptions: OptionSelectOption<"allow" | "disallow">[] = [
{
value: "allow",
label: t("roleAllowSshAllow")
},
{
value: "disallow",
label: t("roleAllowSshDisallow")
}
];
return (
<FormItem>
<FormLabel>
{t("roleAllowSsh")}
</FormLabel>
<OptionSelect<"allow" | "disallow">
options={allowSshOptions}
value={
sshDisabled
? "disallow"
: field.value
? "allow"
: "disallow"
}
onChange={(v) => {
if (sshDisabled) return;
field.onChange(v === "allow");
}}
cols={2}
disabled={sshDisabled}
/>
<FormDescription>
{t(
"roleAllowSshDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="sshSudoMode"
render={({ field }) => {
const sudoOptions: OptionSelectOption<SshSudoMode>[] =
[
{
value: "none",
label: t("sshSudoModeNone")
},
{
value: "full",
label: t("sshSudoModeFull")
},
{
value: "commands",
label: t(
"sshSudoModeCommands"
)
}
];
return (
<FormItem>
<FormLabel>
{t("sshSudoMode")}
</FormLabel>
<OptionSelect<SshSudoMode>
options={sudoOptions}
value={field.value}
onChange={field.onChange}
cols={3}
disabled={sshDisabled}
/>
<FormMessage />
</FormItem>
);
}}
/>
{sshSudoMode === "commands" && (
<FormField
control={form.control}
name="sshSudoCommands"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshSudoCommands")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled={sshDisabled}
/>
</FormControl>
<FormDescription>
{t(
"sshSudoCommandsDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="sshUnixGroups"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("sshUnixGroups")}
</FormLabel>
<FormControl>
<Input
{...field}
disabled={sshDisabled}
/>
</FormControl>
<FormDescription>
{t("sshUnixGroupsDescription")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sshCreateHomeDir"
render={({ field }) => (
<FormItem className="my-2">
<FormControl>
<CheckboxWithLabel
{...field}
value="on"
checked={form.watch(
"sshCreateHomeDir"
)}
onCheckedChange={(
checked
) => {
if (
checked !==
"indeterminate"
) {
form.setValue(
"sshCreateHomeDir",
checked
);
}
}}
label={t(
"sshCreateHomeDir"
)}
disabled={sshDisabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</HorizontalTabs>
)}
</form>
</Form>
);
}

View File

@@ -103,45 +103,46 @@ export default function UsersTable({ roles }: RolesTableProps) {
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const roleRow = row.original;
const isAdmin = roleRow.isAdmin;
return (
!roleRow.isAdmin && (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setRoleToRemove(roleRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingRole(roleRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
)
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
disabled={isAdmin || false}
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={isAdmin || false}
onClick={() => {
setRoleToRemove(roleRow);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"outline"}
onClick={() => {
setEditingRole(roleRow);
setIsEditDialogOpen(true);
}}
>
{t("edit")}
</Button>
</div>
);
}
}

View File

@@ -119,7 +119,7 @@ function CollapsibleNavItem({
<button
className={cn(
"flex items-center w-full rounded-md transition-colors",
level === 0 ? "px-3 py-1.5" : "px-3 py-1",
"px-3 py-1.5",
isActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
@@ -128,7 +128,7 @@ function CollapsibleNavItem({
disabled={isDisabled}
>
{item.icon && (
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
{item.icon}
</span>
)}
@@ -167,22 +167,192 @@ function CollapsibleNavItem({
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<CollapsibleContent forceMount>
<div
className={cn(
"border-l ml-3 pl-3 mt-0 space-y-0",
"border-border"
"grid overflow-hidden transition-[grid-template-rows] duration-200 ease-in-out",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
{item.items!.map((childItem) =>
renderNavItem(childItem, level + 1)
)}
<div className="min-h-0">
<div
className={cn(
"border-l ml-[22px] pl-[9px] mt-0 space-y-0",
"border-border"
)}
>
{item.items!.map((childItem) =>
renderNavItem(childItem, level + 1)
)}
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
}
type CollapsedNavItemWithPopoverProps = {
item: SidebarNavItem;
tooltipText: string;
isActive: boolean;
isChildActive: boolean;
isDisabled: boolean;
hydrateHref: (val?: string) => string | undefined;
pathname: string;
build: string;
isUnlocked: () => boolean;
disabled: boolean;
t: (key: string) => string;
onItemClick?: () => void;
};
const TOOLTIP_SUPPRESS_MS = 400;
function CollapsedNavItemWithPopover({
item,
tooltipText,
isActive,
isChildActive,
isDisabled,
hydrateHref,
pathname,
build,
isUnlocked,
disabled,
t,
onItemClick
}: CollapsedNavItemWithPopoverProps) {
const [popoverOpen, setPopoverOpen] = React.useState(false);
const [tooltipOpen, setTooltipOpen] = React.useState(false);
const suppressTooltipRef = React.useRef(false);
const handlePopoverOpenChange = React.useCallback((open: boolean) => {
setPopoverOpen(open);
if (!open) {
setTooltipOpen(false);
suppressTooltipRef.current = true;
window.setTimeout(() => {
suppressTooltipRef.current = false;
}, TOOLTIP_SUPPRESS_MS);
}
}, []);
const handleTooltipOpenChange = React.useCallback((open: boolean) => {
if (open && suppressTooltipRef.current) return;
setTooltipOpen(open);
}, []);
return (
<TooltipProvider>
<Tooltip open={tooltipOpen} onOpenChange={handleTooltipOpenChange}>
<Popover
open={popoverOpen}
onOpenChange={handlePopoverOpenChange}
>
<PopoverTrigger asChild>
<TooltipTrigger asChild>
<button
className={cn(
"flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full",
isActive || isChildActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
isDisabled &&
"cursor-not-allowed opacity-60"
)}
disabled={isDisabled}
>
{item.icon && (
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center text-muted-foreground">
{item.icon}
</span>
)}
</button>
</TooltipTrigger>
</PopoverTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{tooltipText}</p>
</TooltipContent>
<PopoverContent
side="right"
align="start"
className="w-56 p-1"
>
<div className="space-y-1">
{item.items!.map((childItem) => {
const childHydratedHref = hydrateHref(
childItem.href
);
const childIsActive = childHydratedHref
? pathname.startsWith(childHydratedHref)
: false;
const childIsEE =
build === "enterprise" &&
childItem.showEE &&
!isUnlocked();
const childIsDisabled = disabled || childIsEE;
if (!childHydratedHref) {
return null;
}
return (
<Link
key={childItem.title}
href={
childIsDisabled
? "#"
: childHydratedHref
}
className={cn(
"flex items-center rounded-md transition-colors px-3 py-1.5 text-sm",
childIsActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground",
childIsDisabled &&
"cursor-not-allowed opacity-60"
)}
onClick={(e) => {
if (childIsDisabled) {
e.preventDefault();
} else {
handlePopoverOpenChange(false);
onItemClick?.();
}
}}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate">
{t(childItem.title)}
</span>
{childItem.isBeta && (
<span className="uppercase font-mono text-yellow-600 dark:text-yellow-800 font-black text-xs">
{t("beta")}
</span>
)}
</div>
{build === "enterprise" &&
childItem.showEE &&
!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="flex-shrink-0 ml-2"
>
{t("licenseBadge")}
</Badge>
)}
</Link>
);
})}
</div>
</PopoverContent>
</Popover>
</Tooltip>
</TooltipProvider>
);
}
export function SidebarNav({
className,
sections,
@@ -278,11 +448,7 @@ export function SidebarNav({
href={isDisabled ? "#" : hydratedHref}
className={cn(
"flex items-center rounded-md transition-colors relative",
isCollapsed
? "px-2 py-2 justify-center"
: level === 0
? "px-3 py-1.5"
: "px-3 py-1",
isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5",
isActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
@@ -298,10 +464,13 @@ export function SidebarNav({
tabIndex={isDisabled ? -1 : undefined}
aria-disabled={isDisabled}
>
{item.icon && (
{item.icon && level === 0 && (
<span
className={cn(
"flex-shrink-0 w-5 h-5 flex items-center justify-center",
isCollapsed
? "text-muted-foreground"
: "text-muted-foreground",
!isCollapsed && "mr-3"
)}
>
@@ -355,13 +524,13 @@ export function SidebarNav({
<div
className={cn(
"flex items-center rounded-md transition-colors",
level === 0 ? "px-3 py-1.5" : "px-3 py-1",
"px-3 py-1.5",
"text-muted-foreground",
isDisabled && "cursor-not-allowed opacity-60"
)}
>
{item.icon && (
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
{item.icon && level === 0 && (
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center text-muted-foreground">
{item.icon}
</span>
)}
@@ -401,120 +570,21 @@ export function SidebarNav({
// If item has nested items, show both tooltip and popover
if (hasNestedItems) {
return (
<TooltipProvider key={item.title}>
<Tooltip>
<Popover>
<PopoverTrigger asChild>
<TooltipTrigger asChild>
<button
className={cn(
"flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full",
isActive || isChildActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/80 dark:hover:bg-secondary/50 hover:text-foreground",
isDisabled &&
"cursor-not-allowed opacity-60"
)}
disabled={isDisabled}
>
{item.icon && (
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center">
{item.icon}
</span>
)}
</button>
</TooltipTrigger>
</PopoverTrigger>
<TooltipContent side="right" sideOffset={8}>
<p>{tooltipText}</p>
</TooltipContent>
<PopoverContent
side="right"
align="start"
className="w-56 p-1"
>
<div className="space-y-1">
{item.items!.map((childItem) => {
const childHydratedHref =
hydrateHref(childItem.href);
const childIsActive =
childHydratedHref
? pathname.startsWith(
childHydratedHref
)
: false;
const childIsEE =
build === "enterprise" &&
childItem.showEE &&
!isUnlocked();
const childIsDisabled =
disabled || childIsEE;
if (!childHydratedHref) {
return null;
}
return (
<Link
key={childItem.title}
href={
childIsDisabled
? "#"
: childHydratedHref
}
className={cn(
"flex items-center rounded-md transition-colors px-3 py-1.5 text-sm",
childIsActive
? "bg-secondary font-medium"
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground",
childIsDisabled &&
"cursor-not-allowed opacity-60"
)}
onClick={(e) => {
if (childIsDisabled) {
e.preventDefault();
} else if (
onItemClick
) {
onItemClick();
}
}}
>
{childItem.icon && (
<span className="flex-shrink-0 mr-3 w-5 h-5 flex items-center justify-center">
{childItem.icon}
</span>
)}
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="truncate">
{t(childItem.title)}
</span>
{childItem.isBeta && (
<span className="uppercase font-mono text-yellow-600 dark:text-yellow-800 font-black text-xs">
{t("beta")}
</span>
)}
</div>
{build === "enterprise" &&
childItem.showEE &&
!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="flex-shrink-0 ml-2"
>
{t(
"licenseBadge"
)}
</Badge>
)}
</Link>
);
})}
</div>
</PopoverContent>
</Popover>
</Tooltip>
</TooltipProvider>
<CollapsedNavItemWithPopover
key={item.title}
item={item}
tooltipText={tooltipText}
isActive={isActive}
isChildActive={isChildActive}
isDisabled={!!isDisabled}
hydrateHref={hydrateHref}
pathname={pathname}
build={build}
isUnlocked={isUnlocked}
disabled={disabled ?? false}
t={t}
onItemClick={onItemClick}
/>
);
}
@@ -549,7 +619,7 @@ export function SidebarNav({
className={cn(sectionIndex > 0 && "mt-4")}
>
{!isCollapsed && (
<div className="px-3 py-2 text-xs font-medium text-muted-foreground/80 uppercase tracking-wider">
<div className="px-3 py-2 text-xs font-medium text-foreground uppercase tracking-wider">
{t(`${section.heading}`)}
</div>
)}

View File

@@ -14,6 +14,7 @@ export interface StrategyOption<TValue extends string> {
interface StrategySelectProps<TValue extends string> {
options: ReadonlyArray<StrategyOption<TValue>>;
value?: TValue | null;
defaultValue?: TValue;
onChange?: (value: TValue) => void;
cols?: number;
@@ -21,18 +22,21 @@ interface StrategySelectProps<TValue extends string> {
export function StrategySelect<TValue extends string>({
options,
value: controlledValue,
defaultValue,
onChange,
cols
}: StrategySelectProps<TValue>) {
const [selected, setSelected] = useState<TValue | undefined>(defaultValue);
const [uncontrolledSelected, setUncontrolledSelected] = useState<TValue | undefined>(defaultValue);
const isControlled = controlledValue !== undefined;
const selected = isControlled ? (controlledValue ?? undefined) : uncontrolledSelected;
return (
<RadioGroup
defaultValue={defaultValue}
value={selected ?? ""}
onValueChange={(value: string) => {
const typedValue = value as TValue;
setSelected(typedValue);
if (!isControlled) setUncontrolledSelected(typedValue);
onChange?.(typedValue);
}}
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}

View File

@@ -155,62 +155,72 @@ export default function UsersTable({ users: u }: UsersTableProps) {
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const userRow = row.original;
const isCurrentUser =
`${userRow.username}-${userRow.idpId}` ===
`${user?.username}-${user?.idpId}`;
const isDisabled = userRow.isOwner || isCurrentUser;
return (
<div className="flex items-center justify-end">
<div>
{!userRow.isOwner && (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full"
>
<DropdownMenuItem>
{t("accessUsersManage")}
</DropdownMenuItem>
</Link>
{`${userRow.username}-${userRow.idpId}` !==
`${user?.username}-${user?.idpId}` && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(
true
);
setSelectedUser(
userRow
);
}}
>
<span className="text-red-500">
{t("accessUserRemove")}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
disabled={isDisabled}
>
<span className="sr-only">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="block w-full"
aria-disabled={isDisabled}
onClick={(e) =>
isDisabled && e.preventDefault()
}
>
<DropdownMenuItem
disabled={isDisabled}
>
{t("accessUsersManage")}
</DropdownMenuItem>
</Link>
{!isDisabled && (
<DropdownMenuItem
onClick={() => {
setIsDeleteModalOpen(true);
setSelectedUser(userRow);
}}
>
<span className="text-red-500">
{t("accessUserRemove")}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
{!userRow.isOwner && (
{isDisabled ? (
<Button
variant={"outline"}
className="ml-2"
disabled
>
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
) : (
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button
variant={"outline"}
className="ml-2"
disabled={userRow.isOwner}
>
{t("manage")}
<ArrowRight className="ml-2 w-4 h-4" />

View File

@@ -8,7 +8,7 @@ import {
SettingsSectionTitle
} from "./Settings";
import { CheckboxWithLabel } from "./ui/checkbox";
import { Button } from "./ui/button";
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
import { useState } from "react";
import { FaCubes, FaDocker, FaWindows } from "react-icons/fa";
import { Terminal } from "lucide-react";
@@ -138,6 +138,14 @@ WantedBy=default.target`
const commands = commandList[platform][architecture];
const platformOptions: OptionSelectOption<Platform>[] = PLATFORMS.map(
(os) => ({
value: os,
label: getPlatformName(os),
icon: getPlatformIcon(os)
})
);
return (
<SettingsSection>
<SettingsSectionHeader>
@@ -149,53 +157,33 @@ WantedBy=default.target`
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div>
<p className="font-bold mb-3">{t("operatingSystem")}</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{PLATFORMS.map((os) => (
<Button
key={os}
variant={
platform === os
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-30 ${platform === os ? "bg-primary/10" : ""} shadow-none`}
onClick={() => {
setPlatform(os);
const architectures = getArchitectures(os);
setArchitecture(architectures[0]);
}}
>
{getPlatformIcon(os)}
{getPlatformName(os)}
</Button>
))}
</div>
</div>
<OptionSelect<Platform>
label={t("operatingSystem")}
options={platformOptions}
value={platform}
onChange={(os) => {
setPlatform(os);
const architectures = getArchitectures(os);
setArchitecture(architectures[0]);
}}
cols={5}
/>
<div>
<p className="font-bold mb-3">
{["docker", "podman"].includes(platform)
<OptionSelect<string>
label={
["docker", "podman"].includes(platform)
? t("method")
: t("architecture")}
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{getArchitectures(platform).map((arch) => (
<Button
key={arch}
variant={
architecture === arch
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-30 ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
onClick={() => setArchitecture(arch)}
>
{arch}
</Button>
))}
</div>
: t("architecture")
}
options={getArchitectures(platform).map((arch) => ({
value: arch,
label: arch
}))}
value={architecture}
onChange={setArchitecture}
cols={5}
className="mt-4"
/>
<div className="pt-4">
<p className="font-bold mb-3">
@@ -250,7 +238,6 @@ WantedBy=default.target`
})}
</div>
</div>
</div>
</SettingsSectionBody>
</SettingsSection>
);

View File

@@ -10,7 +10,7 @@ import {
SettingsSectionHeader,
SettingsSectionTitle
} from "./Settings";
import { Button } from "./ui/button";
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
export type CommandItem = string | { title: string; command: string };
@@ -88,6 +88,15 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
};
const commands = commandList[platform][architecture];
const platformOptions: OptionSelectOption<Platform>[] = PLATFORMS.map(
(os) => ({
value: os,
label: getPlatformName(os),
icon: getPlatformIcon(os)
})
);
return (
<SettingsSection>
<SettingsSectionHeader>
@@ -99,54 +108,35 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<div>
<p className="font-bold mb-3">{t("operatingSystem")}</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{PLATFORMS.map((os) => (
<Button
key={os}
variant={
platform === os
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-30 ${platform === os ? "bg-primary/10" : ""} shadow-none`}
onClick={() => {
setPlatform(os);
const architectures = getArchitectures(os);
setArchitecture(architectures[0]);
}}
>
{getPlatformIcon(os)}
{getPlatformName(os)}
</Button>
))}
</div>
</div>
<OptionSelect<Platform>
label={t("operatingSystem")}
options={platformOptions}
value={platform}
onChange={(os) => {
setPlatform(os);
const architectures = getArchitectures(os);
setArchitecture(architectures[0]);
}}
cols={5}
/>
<div>
<p className="font-bold mb-3">
{["docker", "podman"].includes(platform)
<OptionSelect<string>
label={
platform === "docker"
? t("method")
: t("architecture")}
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{getArchitectures(platform).map((arch) => (
<Button
key={arch}
variant={
architecture === arch
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-30 ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
onClick={() => setArchitecture(arch)}
>
{arch}
</Button>
))}
</div>
<div className="pt-4">
: t("architecture")
}
options={getArchitectures(platform).map((arch) => ({
value: arch,
label: arch
}))}
value={architecture}
onChange={setArchitecture}
cols={5}
className="mt-4"
/>
<div className="pt-4">
<p className="font-bold mb-3">{t("commands")}</p>
<div className="mt-2 space-y-3">
{commands.map((item, index) => {
@@ -174,7 +164,6 @@ curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/ol
);
})}
</div>
</div>
</div>
</SettingsSectionBody>
</SettingsSection>