mirror of
https://github.com/fosrl/pangolin.git
synced 2025-07-31 08:04:54 +02:00
Merge branch 'dev' into clients-pops
This commit is contained in:
commit
459bc32f9d
149 changed files with 13888 additions and 5083 deletions
3
crowdin.yml
Normal file
3
crowdin.yml
Normal file
|
@ -0,0 +1,3 @@
|
|||
files:
|
||||
- source: /messages/en-US.json
|
||||
translation: /messages/%locale%.json
|
|
@ -1,287 +0,0 @@
|
|||
## Authentication Site
|
||||
|
||||
| EN | DE | Notes |
|
||||
| -------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------- |
|
||||
| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Bereitgestellt von [Pangolin](https://github.com/fosrl/pangolin) | |
|
||||
| Authentication Required | Authentifizierung erforderlich | |
|
||||
| Choose your preferred method to access {resource} | Wählen Sie Ihre bevorzugte Methode, um auf {resource} zuzugreifen | |
|
||||
| PIN | PIN | |
|
||||
| User | Benutzer | |
|
||||
| 6-digit PIN Code | 6-stelliger PIN-Code | pin login |
|
||||
| Login in with PIN | Mit PIN anmelden | pin login |
|
||||
| Email | E-Mail | user login |
|
||||
| Enter your email | Geben Sie Ihre E-Mail-Adresse ein | user login |
|
||||
| Password | Passwort | user login |
|
||||
| Enter your password | Geben Sie Ihr Passwort ein | user login |
|
||||
| Forgot your password? | Passwort vergessen? | user login |
|
||||
| Log in | Anmelden | user login |
|
||||
|
||||
---
|
||||
|
||||
## Login site
|
||||
|
||||
| EN | DE | Notes |
|
||||
| --------------------- | ---------------------------------- | ----------- |
|
||||
| Welcome to Pangolin | Willkommen bei Pangolin | |
|
||||
| Log in to get started | Melden Sie sich an, um zu beginnen | |
|
||||
| Email | E-Mail | |
|
||||
| Enter your email | Geben Sie Ihre E-Mail-Adresse ein | placeholder |
|
||||
| Password | Passwort | |
|
||||
| Enter your password | Geben Sie Ihr Passwort ein | placeholder |
|
||||
| Forgot your password? | Passwort vergessen? | |
|
||||
| Log in | Anmelden | |
|
||||
|
||||
# Ogranization site after successful login
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ----------------------------------------- | -------------------------------------------- | ----- |
|
||||
| Welcome to Pangolin | Willkommen bei Pangolin | |
|
||||
| You're a member of {number} organization. | Sie sind Mitglied von {number} Organisation. | |
|
||||
|
||||
## Shared Header, Navbar and Footer
|
||||
##### Header
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------- | ------------------- | ----- |
|
||||
| Documentation | Dokumentation | |
|
||||
| Support | Support | |
|
||||
| Organization {name} | Organisation {name} | |
|
||||
##### Organization selector
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ---------------- | ----------------- | ----- |
|
||||
| Search… | Suchen… | |
|
||||
| Create | Erstellen | |
|
||||
| New Organization | Neue Organisation | |
|
||||
| Organizations | Organisationen | |
|
||||
|
||||
##### Navbar
|
||||
|
||||
| EN | DE | Notes |
|
||||
| --------------- | ----------------- | ----- |
|
||||
| Sites | Websites | |
|
||||
| Resources | Ressourcen | |
|
||||
| User & Roles | Benutzer & Rollen | |
|
||||
| Shareable Links | Teilbare Links | |
|
||||
| General | Allgemein | |
|
||||
##### Footer
|
||||
| EN | DE | |
|
||||
| ------------------------- | --------------------------- | ------------------- |
|
||||
| Page {number} of {number} | Seite {number} von {number} | |
|
||||
| Rows per page | Zeilen pro Seite | |
|
||||
| Pangolin | Pangolin | unten auf der Seite |
|
||||
| Built by Fossorial | Erstellt von Fossorial | unten auf der Seite |
|
||||
| Open Source | Open Source | unten auf der Seite |
|
||||
| Documentation | Dokumentation | unten auf der Seite |
|
||||
| {version} | {version} | unten auf der Seite |
|
||||
|
||||
## Main “Sites”
|
||||
##### “Hero” section
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Newt (Recommended) | Newt (empfohlen) | |
|
||||
| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | Für das beste Benutzererlebnis verwenden Sie Newt. Es nutzt WireGuard im Hintergrund und ermöglicht es Ihnen, auf Ihre privaten Ressourcen über ihre LAN-Adresse in Ihrem privaten Netzwerk direkt aus dem Pangolin-Dashboard zuzugreifen. | |
|
||||
| Runs in Docker | Läuft in Docker | |
|
||||
| Runs in shell on macOS, Linux, and Windows | Läuft in der Shell auf macOS, Linux und Windows | |
|
||||
| Install Newt | Newt installieren | |
|
||||
| Basic WireGuard<br> | Verwenden Sie einen beliebigen WireGuard-Client, um eine Verbindung herzustellen. Sie müssen auf Ihre internen Ressourcen über die Peer-IP-Adresse zugreifen. | |
|
||||
| Compatible with all WireGuard clients<br> | Kompatibel mit allen WireGuard-Clients<br> | |
|
||||
| Manual configuration required | Manuelle Konfiguration erforderlich<br> | |
|
||||
##### Content
|
||||
|
||||
| EN | DE | Notes |
|
||||
| --------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
|
||||
| Manage Sites | Seiten verwalten | |
|
||||
| Allow connectivity to your network through secure tunnels | Ermöglichen Sie die Verbindung zu Ihrem Netzwerk über ein sicheren Tunnel | |
|
||||
| Search sites | Seiten suchen | placeholder |
|
||||
| Add Site | Seite hinzufügen | |
|
||||
| Name | Name | table header |
|
||||
| Online | Status | table header |
|
||||
| Site | Seite | table header |
|
||||
| Data In | Eingehende Daten | table header |
|
||||
| Data Out | Ausgehende Daten | table header |
|
||||
| Connection Type | Verbindungstyp | table header |
|
||||
| Online | Online | site state |
|
||||
| Offline | Offline | site state |
|
||||
| Edit → | Bearbeiten → | |
|
||||
| View settings | Einstellungen anzeigen | Popup after clicking “…” on site |
|
||||
| Delete | Löschen | Popup after clicking “…” on site |
|
||||
##### Add Site Popup
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------ | ----------------------------------------------------------- | ----------- |
|
||||
| Create Site | Seite erstellen | |
|
||||
| Create a new site to start connection for this site | Erstellen Sie eine neue Seite, um die Verbindung zu starten | |
|
||||
| Name | Name | |
|
||||
| Site name | Seiten-Name | placeholder |
|
||||
| This is the name that will be displayed for this site. | So wird Ihre Seite angezeigt | desc |
|
||||
| Method | Methode | |
|
||||
| Local | Lokal | |
|
||||
| Newt | Newt | |
|
||||
| WireGuard | WireGuard | |
|
||||
| This is how you will expose connections. | So werden Verbindungen freigegeben. | |
|
||||
| You will only be able to see the configuration once. | Diese Konfiguration können Sie nur einmal sehen. | |
|
||||
| Learn how to install Newt on your system | Erfahren Sie, wie Sie Newt auf Ihrem System installieren | |
|
||||
| I have copied the config | Ich habe die Konfiguration kopiert | |
|
||||
| Create Site | Website erstellen | |
|
||||
| Close | Schließen | |
|
||||
|
||||
## Main “Resources”
|
||||
|
||||
##### “Hero” section
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Resources | Ressourcen | |
|
||||
| Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | |
|
||||
| Secure connectivity with WireGuard encryption | Sichere Verbindung mit WireGuard-Verschlüsselung | |
|
||||
| Configure multiple authentication methods | Konfigurieren Sie mehrere Authentifizierungsmethoden | |
|
||||
| User and role-based access control | Benutzer- und rollenbasierte Zugriffskontrolle | |
|
||||
##### Content
|
||||
|
||||
| EN | DE | Notes |
|
||||
| -------------------------------------------------- | ---------------------------------------------------------- | -------------------- |
|
||||
| Manage Resources | Ressourcen verwalten | |
|
||||
| Create secure proxies to your private applications | Erstellen Sie sichere Proxys für Ihre privaten Anwendungen | |
|
||||
| Search resources | Ressourcen durchsuchen | placeholder |
|
||||
| Name | Name | |
|
||||
| Site | Website | |
|
||||
| Full URL | Vollständige URL | |
|
||||
| Authentication | Authentifizierung | |
|
||||
| Not Protected | Nicht geschützt | authentication state |
|
||||
| Protected | Geschützt | authentication state |
|
||||
| Edit → | Bearbeiten → | |
|
||||
| Add Resource | Ressource hinzufügen | |
|
||||
##### Add Resource Popup
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------- |
|
||||
| Create Resource | Ressource erstellen | |
|
||||
| Create a new resource to proxy request to your app | Erstellen Sie eine neue Ressource, um Anfragen an Ihre App zu proxen | |
|
||||
| Name | Name | |
|
||||
| My Resource | Neue Ressource | name placeholder |
|
||||
| This is the name that will be displayed for this resource. | Dies ist der Name, der für diese Ressource angezeigt wird | |
|
||||
| Subdomain | Subdomain | |
|
||||
| Enter subdomain | Subdomain eingeben | |
|
||||
| This is the fully qualified domain name that will be used to access the resource. | Dies ist der vollständige Domainname, der für den Zugriff auf die Ressource verwendet wird. | |
|
||||
| Site | Website | |
|
||||
| Search site… | Website suchen… | Site selector popup |
|
||||
| This is the site that will be used in the dashboard. | Dies ist die Website, die im Dashboard verwendet wird. | |
|
||||
| Create Resource | Ressource erstellen | |
|
||||
| Close | Schließen | |
|
||||
|
||||
|
||||
## Main “User & Roles”
|
||||
##### Content
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------- |
|
||||
| Manage User & Roles | Benutzer & Rollen verwalten | |
|
||||
| Invite users and add them to roles to manage access to your organization | Laden Sie Benutzer ein und weisen Sie ihnen Rollen zu, um den Zugriff auf Ihre Organisation zu verwalten | |
|
||||
| Users | Benutzer | sidebar item |
|
||||
| Roles | Rollen | sidebar item |
|
||||
| **User tab** | | |
|
||||
| Search users | Benutzer suchen | placeholder |
|
||||
| Invite User | Benutzer einladen | addbutton |
|
||||
| Email | E-Mail | table header |
|
||||
| Status | Status | table header |
|
||||
| Role | Rolle | table header |
|
||||
| Confirmed | Bestätigt | account status |
|
||||
| Not confirmed (?) | Nicht bestätigt (?) | unknown for me account status |
|
||||
| Owner | Besitzer | role |
|
||||
| Admin | Administrator | role |
|
||||
| Member | Mitglied | role |
|
||||
| **Roles Tab** | | |
|
||||
| Search roles | Rollen suchen | placeholder |
|
||||
| Add Role | Rolle hinzufügen | addbutton |
|
||||
| Name | Name | table header |
|
||||
| Description | Beschreibung | table header |
|
||||
| Admin | Administrator | role |
|
||||
| Member | Mitglied | role |
|
||||
| Admin role with the most permissions | Administratorrolle mit den meisten Berechtigungen | admin role desc |
|
||||
| Members can only view resources | Mitglieder können nur Ressourcen anzeigen | member role desc |
|
||||
|
||||
##### Invite User popup
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ----------------- | ------------------------------------------------------- | ----------- |
|
||||
| Invite User | Geben Sie neuen Benutzern Zugriff auf Ihre Organisation | |
|
||||
| Email | E-Mail | |
|
||||
| Enter an email | E-Mail eingeben | placeholder |
|
||||
| Role | Rolle | |
|
||||
| Select role | Rolle auswählen | placeholder |
|
||||
| Gültig für | Gültig bis | |
|
||||
| 1 day | Tag | |
|
||||
| 2 days | 2 Tage | |
|
||||
| 3 days | 3 Tage | |
|
||||
| 4 days | 4 Tage | |
|
||||
| 5 days | 5 Tage | |
|
||||
| 6 days | 6 Tage | |
|
||||
| 7 days | 7 Tage | |
|
||||
| Create Invitation | Einladung erstellen | |
|
||||
| Close | Schließen | |
|
||||
|
||||
|
||||
## Main “Shareable Links”
|
||||
##### “Hero” section
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Shareable Links | Teilbare Links | |
|
||||
| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Erstellen Sie teilbare Links zu Ihren Ressourcen. Links bieten temporären oder unbegrenzten Zugriff auf Ihre Ressource. Sie können die Gültigkeitsdauer des Links beim Erstellen konfigurieren. | |
|
||||
| Easy to create and share | Einfach zu erstellen und zu teilen | |
|
||||
| Configurable expiration duration | Konfigurierbare Gültigkeitsdauer | |
|
||||
| Secure and revocable | Sicher und widerrufbar | |
|
||||
##### Content
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------- |
|
||||
| Manage Shareable Links | Teilbare Links verwalten | |
|
||||
| Create shareable links to grant temporary or permanent access to your resources | Erstellen Sie teilbare Links, um temporären oder permanenten Zugriff auf Ihre Ressourcen zu gewähren | |
|
||||
| Search links | Links suchen | placeholder |
|
||||
| Create Share Link | Neuen Link erstellen | addbutton |
|
||||
| Resource | Ressource | table header |
|
||||
| Title | Titel | table header |
|
||||
| Created | Erstellt | table header |
|
||||
| Expires | Gültig bis | table header |
|
||||
| No links. Create one to get started. | Keine Links. Erstellen Sie einen, um zu beginnen. | table placeholder |
|
||||
|
||||
##### Create Shareable Link popup
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------- |
|
||||
| Create Shareable Link | Teilbaren Link erstellen | |
|
||||
| Anyone with this link can access the resource | Jeder mit diesem Link kann auf die Ressource zugreifen | |
|
||||
| Resource | Ressource | |
|
||||
| Select resource | Ressource auswählen | |
|
||||
| Search resources… | Ressourcen suchen… | resource selector popup |
|
||||
| Title (optional) | Titel (optional) | |
|
||||
| Enter title | Titel eingeben | placeholder |
|
||||
| Expire in | Gültig bis | |
|
||||
| Minutes | Minuten | |
|
||||
| Hours | Stunden | |
|
||||
| Days | Tage | |
|
||||
| Months | Monate | |
|
||||
| Years | Jahre | |
|
||||
| Never expire | Nie ablaufen | |
|
||||
| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | Die Gültigkeitsdauer bestimmt, wie lange der Link nutzbar ist und Zugriff auf die Ressource bietet. Nach Ablauf dieser Zeit funktioniert der Link nicht mehr, und Benutzer, die diesen Link verwendet haben, verlieren den Zugriff auf die Ressource. | |
|
||||
| Create Link | Link erstellen | |
|
||||
| Close | Schließen | |
|
||||
|
||||
|
||||
## Main “General”
|
||||
|
||||
| EN | DE | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------ |
|
||||
| General | Allgemein | |
|
||||
| Configure your organization’s general settings | Konfigurieren Sie die allgemeinen Einstellungen Ihrer Organisation | |
|
||||
| General | Allgemein | sidebar item |
|
||||
| Organization Settings | Organisationseinstellungen | |
|
||||
| Manage your organization details and configuration | Verwalten Sie die Details und Konfiguration Ihrer Organisation | |
|
||||
| Name | Name | |
|
||||
| This is the display name of the org | Dies ist der Anzeigename Ihrer Organisation | |
|
||||
| Save Settings | Einstellungen speichern | |
|
||||
| Danger Zone | Gefahrenzone | |
|
||||
| Once you delete this org, there is no going back. Please be certain. | Wenn Sie diese Organisation löschen, gibt es kein Zurück. Bitte seien Sie sicher. | |
|
||||
| Delete Organization Data | Organisationsdaten löschen | |
|
|
@ -1,291 +0,0 @@
|
|||
## Authentication Site
|
||||
|
||||
|
||||
| EN | ES | Notes |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------- |
|
||||
| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Desarrollado por [Pangolin](https://github.com/fosrl/pangolin) | |
|
||||
| Authentication Required | Se requiere autenticación | |
|
||||
| Choose your preferred method to access {resource} | Elije tu método requerido para acceder a {resource} | |
|
||||
| PIN | PIN | |
|
||||
| User | Usuario | |
|
||||
| 6-digit PIN Code | Código PIN de 6 dígitos | pin login |
|
||||
| Login in with PIN | Registrate con PIN | pin login |
|
||||
| Email | Email | user login |
|
||||
| Enter your email | Introduce tu email | user login |
|
||||
| Password | Contraseña | user login |
|
||||
| Enter your password | Introduce tu contraseña | user login |
|
||||
| Forgot your password? | ¿Olvidaste tu contraseña? | user login |
|
||||
| Log in | Iniciar sesión | user login |
|
||||
|
||||
|
||||
## Login site
|
||||
|
||||
| EN | ES | Notes |
|
||||
| --------------------- | ---------------------------------- | ----------- |
|
||||
| Welcome to Pangolin | Binvenido a Pangolin | |
|
||||
| Log in to get started | Registrate para comenzar | |
|
||||
| Email | Email | |
|
||||
| Enter your email | Introduce tu email | placeholder |
|
||||
| Password | Contraseña | |
|
||||
| Enter your password | Introduce tu contraseña | placeholder |
|
||||
| Forgot your password? | ¿Olvidaste tu contraseña? | |
|
||||
| Log in | Iniciar sesión | |
|
||||
|
||||
# Ogranization site after successful login
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ----------------------------------------- | -------------------------------------------- | ----- |
|
||||
| Welcome to Pangolin | Binvenido a Pangolin | |
|
||||
| You're a member of {number} organization. | Eres miembro de la organización {number}. | |
|
||||
|
||||
## Shared Header, Navbar and Footer
|
||||
##### Header
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------- | ------------------- | ----- |
|
||||
| Documentation | Documentación | |
|
||||
| Support | Soporte | |
|
||||
| Organization {name} | Organización {name} | |
|
||||
##### Organization selector
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ---------------- | ----------------- | ----- |
|
||||
| Search… | Buscar… | |
|
||||
| Create | Crear | |
|
||||
| New Organization | Nueva Organización| |
|
||||
| Organizations | Organizaciones | |
|
||||
|
||||
##### Navbar
|
||||
|
||||
| EN | ES | Notes |
|
||||
| --------------- | -----------------------| ----- |
|
||||
| Sites | Sitios | |
|
||||
| Resources | Recursos | |
|
||||
| User & Roles | Usuarios y roles | |
|
||||
| Shareable Links | Enlaces para compartir | |
|
||||
| General | General | |
|
||||
|
||||
##### Footer
|
||||
| EN | ES | |
|
||||
| ------------------------- | --------------------------- | -------|
|
||||
| Page {number} of {number} | Página {number} de {number} | footer |
|
||||
| Rows per page | Filas por página | footer |
|
||||
| Pangolin | Pangolin | footer |
|
||||
| Built by Fossorial | Construido por Fossorial | footer |
|
||||
| Open Source | Código abierto | footer |
|
||||
| Documentation | Documentación | footer |
|
||||
| {version} | {version} | footer |
|
||||
|
||||
## Main “Sites”
|
||||
##### “Hero” section
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Newt (Recommended) | Newt (Recomendado) | |
|
||||
| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | Para obtener la mejor experiencia de usuario, utiliza Newt. Utiliza WireGuard internamente y te permite abordar tus recursos privados mediante tu dirección LAN en tu red privada desde el panel de Pangolin. | |
|
||||
| Runs in Docker | Se ejecuta en Docker | |
|
||||
| Runs in shell on macOS, Linux, and Windows | Se ejecuta en shell en macOS, Linux y Windows | |
|
||||
| Install Newt | Instalar Newt | |
|
||||
| Basic WireGuard<br> | WireGuard básico<br> | |
|
||||
| Compatible with all WireGuard clients<br> | Compatible con todos los clientes WireGuard<br> | |
|
||||
| Manual configuration required | Se requiere configuración manual | |
|
||||
|
||||
##### Content
|
||||
|
||||
| EN | ES | Notes |
|
||||
| --------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
|
||||
| Manage Sites | Administrar sitios | |
|
||||
| Allow connectivity to your network through secure tunnels | Permitir la conectividad a tu red a través de túneles seguros| |
|
||||
| Search sites | Buscar sitios | placeholder |
|
||||
| Add Site | Agregar sitio | |
|
||||
| Name | Nombre | table header |
|
||||
| Online | Conectado | table header |
|
||||
| Site | Sitio | table header |
|
||||
| Data In | Datos en | table header |
|
||||
| Data Out | Datos de salida | table header |
|
||||
| Connection Type | Tipo de conexión | table header |
|
||||
| Online | Conectado | site state |
|
||||
| Offline | Desconectado | site state |
|
||||
| Edit → | Editar → | |
|
||||
| View settings | Ver configuración | Popup after clicking “…” on site |
|
||||
| Delete | Borrar | Popup after clicking “…” on site |
|
||||
|
||||
##### Add Site Popup
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------ | ----------------------------------------------------------- | ----------- |
|
||||
| Create Site | Crear sitio | |
|
||||
| Create a new site to start connection for this site | Crear un nuevo sitio para iniciar la conexión para este sitio | |
|
||||
| Name | Nombre | |
|
||||
| Site name | Nombre del sitio | placeholder |
|
||||
| This is the name that will be displayed for this site. | Este es el nombre que se mostrará para este sitio. | desc |
|
||||
| Method | Método | |
|
||||
| Local | Local | |
|
||||
| Newt | Newt | |
|
||||
| WireGuard | WireGuard | |
|
||||
| This is how you will expose connections. | Así es como expondrás las conexiones. | |
|
||||
| You will only be able to see the configuration once. | Solo podrás ver la configuración una vez. | |
|
||||
| Learn how to install Newt on your system | Aprende a instalar Newt en tu sistema | |
|
||||
| I have copied the config | He copiado la configuración | |
|
||||
| Create Site | Crear sitio | |
|
||||
| Close | Cerrar | |
|
||||
|
||||
## Main “Resources”
|
||||
|
||||
##### “Hero” section
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Resources | Recursos | |
|
||||
| Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. |Los recursos son servidores proxy para aplicaciones que se ejecutan en su red privada. Cree un recurso para cada aplicación HTTP o HTTPS en su red privada. Cada recurso debe estar conectado a un sitio web para proporcionar una conexión privada y segura a través del túnel cifrado WireGuard. | |
|
||||
| Secure connectivity with WireGuard encryption | Conectividad segura con encriptación WireGuard | |
|
||||
| Configure multiple authentication methods | Configura múltiples métodos de autenticación | |
|
||||
| User and role-based access control | Control de acceso basado en usuarios y roles | |
|
||||
|
||||
##### Content
|
||||
|
||||
| EN | ES | Notes |
|
||||
| -------------------------------------------------- | ---------------------------------------------------------- | -------------------- |
|
||||
| Manage Resources | Administrar recursos | |
|
||||
| Create secure proxies to your private applications | Crea servidores proxy seguros para tus aplicaciones privadas | |
|
||||
| Search resources | Buscar recursos | placeholder |
|
||||
| Name | Nombre | |
|
||||
| Site | Sitio | |
|
||||
| Full URL | URL completa | |
|
||||
| Authentication | Autenticación | |
|
||||
| Not Protected | No protegido | authentication state |
|
||||
| Protected | Protegido | authentication state |
|
||||
| Edit → | Editar → | |
|
||||
| Add Resource | Agregar recurso | |
|
||||
|
||||
##### Add Resource Popup
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------- |
|
||||
| Create Resource | Crear recurso | |
|
||||
| Create a new resource to proxy request to your app | Crea un nuevo recurso para enviar solicitudes a tu aplicación | |
|
||||
| Name | Nombre | |
|
||||
| My Resource | Mi recurso | name placeholder |
|
||||
| This is the name that will be displayed for this resource. | Este es el nombre que se mostrará para este recurso. | |
|
||||
| Subdomain | Subdominio | |
|
||||
| Enter subdomain | Ingresar subdominio | |
|
||||
| This is the fully qualified domain name that will be used to access the resource. | Este es el nombre de dominio completo que se utilizará para acceder al recurso. | |
|
||||
| Site | Sitio | |
|
||||
| Search site… | Buscar sitio… | Site selector popup |
|
||||
| This is the site that will be used in the dashboard. | Este es el sitio que se utilizará en el panel de control. | |
|
||||
| Create Resource | Crear recurso | |
|
||||
| Close | Cerrar | |
|
||||
|
||||
## Main “User & Roles”
|
||||
##### Content
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------- |
|
||||
| Manage User & Roles | Administrar usuarios y roles | |
|
||||
| Invite users and add them to roles to manage access to your organization | Invita a usuarios y agrégalos a roles para administrar el acceso a tu organización | |
|
||||
| Users | Usuarios | sidebar item |
|
||||
| Roles | Roles | sidebar item |
|
||||
| **User tab** | **Pestaña de usuario** | |
|
||||
| Search users | Buscar usuarios | placeholder |
|
||||
| Invite User | Invitar usuario | addbutton |
|
||||
| Email | Email | table header |
|
||||
| Status | Estado | table header |
|
||||
| Role | Role | table header |
|
||||
| Confirmed | Confirmado | account status |
|
||||
| Not confirmed (?) | No confirmado (?) | unknown for me account status |
|
||||
| Owner | Dueño | role |
|
||||
| Admin | Administrador | role |
|
||||
| Member | Miembro | role |
|
||||
| **Roles Tab** | **Pestaña Roles** | |
|
||||
| Search roles | Buscar roles | placeholder |
|
||||
| Add Role | Agregar rol | addbutton |
|
||||
| Name | Nombre | table header |
|
||||
| Description | Descripción | table header |
|
||||
| Admin | Administrador | role |
|
||||
| Member | Miembro | role |
|
||||
| Admin role with the most permissions | Rol de administrador con más permisos | admin role desc |
|
||||
| Members can only view resources | Los miembros sólo pueden ver los recursos | member role desc |
|
||||
|
||||
##### Invite User popup
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ----------------- | ------------------------------------------------------- | ----------- |
|
||||
| Invite User | Invitar usuario | |
|
||||
| Email | Email | |
|
||||
| Enter an email | Introduzca un email | placeholder |
|
||||
| Role | Rol | |
|
||||
| Select role | Seleccionar rol | placeholder |
|
||||
| Gültig für | Válido para | |
|
||||
| 1 day | 1 día | |
|
||||
| 2 days | 2 días | |
|
||||
| 3 days | 3 días | |
|
||||
| 4 days | 4 días | |
|
||||
| 5 days | 5 días | |
|
||||
| 6 days | 6 días | |
|
||||
| 7 days | 7 días | |
|
||||
| Create Invitation | Crear invitación | |
|
||||
| Close | Cerrar | |
|
||||
|
||||
|
||||
## Main “Shareable Links”
|
||||
##### “Hero” section
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Shareable Links | Enlaces para compartir | |
|
||||
| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Crear enlaces que se puedan compartir a tus recursos. Los enlaces proporcionan acceso temporal o ilimitado a tu recurso. Puedes configurar la duración de caducidad del enlace cuando lo creas. | |
|
||||
| Easy to create and share | Fácil de crear y compartir | |
|
||||
| Configurable expiration duration | Duración de expiración configurable | |
|
||||
| Secure and revocable | Seguro y revocable | |
|
||||
##### Content
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------- |
|
||||
| Manage Shareable Links | Administrar enlaces compartibles | |
|
||||
| Create shareable links to grant temporary or permanent access to your resources | Crear enlaces compartibles para otorgar acceso temporal o permanente a tus recursos | |
|
||||
| Search links | Buscar enlaces | placeholder |
|
||||
| Create Share Link | Crear enlace para compartir | addbutton |
|
||||
| Resource | Recurso | table header |
|
||||
| Title | Título | table header |
|
||||
| Created | Creado | table header |
|
||||
| Expires | Caduca | table header |
|
||||
| No links. Create one to get started. | No hay enlaces. Crea uno para comenzar. | table placeholder |
|
||||
|
||||
##### Create Shareable Link popup
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------- |
|
||||
| Create Shareable Link | Crear un enlace para compartir | |
|
||||
| Anyone with this link can access the resource | Cualquier persona con este enlace puede acceder al recurso. | |
|
||||
| Resource | Recurso | |
|
||||
| Select resource | Seleccionar recurso | |
|
||||
| Search resources… | Buscar recursos… | resource selector popup |
|
||||
| Title (optional) | Título (opcional) | |
|
||||
| Enter title | Introducir título | placeholder |
|
||||
| Expire in | Caduca en | |
|
||||
| Minutes | Minutos | |
|
||||
| Hours | Horas | |
|
||||
| Days | Días | |
|
||||
| Months | Meses | |
|
||||
| Years | Años | |
|
||||
| Never expire | Nunca caduca | |
|
||||
| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | El tiempo de expiración es el tiempo durante el cual el enlace se podrá utilizar y brindará acceso al recurso. Después de este tiempo, el enlace dejará de funcionar y los usuarios que lo hayan utilizado perderán el acceso al recurso. | |
|
||||
| Create Link | Crear enlace | |
|
||||
| Close | Cerrar | |
|
||||
|
||||
|
||||
## Main “General”
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------ |
|
||||
| General | General | |
|
||||
| Configure your organization’s general settings | Configura los ajustes generales de tu organización | |
|
||||
| General | General | sidebar item |
|
||||
| Organization Settings | Configuración de la organización | |
|
||||
| Manage your organization details and configuration | Administra los detalles y la configuración de tu organización| |
|
||||
| Name | Nombre | |
|
||||
| This is the display name of the org | Este es el nombre para mostrar de la organización. | |
|
||||
| Save Settings | Guardar configuración | |
|
||||
| Danger Zone | Zona de peligro | |
|
||||
| Once you delete this org, there is no going back. Please be certain. | Una vez que elimines esta organización, no habrá vuelta atrás. Asegúrate de hacerlo. | |
|
||||
| Delete Organization Data | Eliminar datos de la organización | |
|
|
@ -1,287 +0,0 @@
|
|||
## Authentication Site
|
||||
|
||||
|
||||
| EN | PL | Notes |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------- |
|
||||
| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Zasilane przez [Pangolin](https://github.com/fosrl/pangolin) | |
|
||||
| Authentication Required | Wymagane uwierzytelnienie | |
|
||||
| Choose your preferred method to access {resource} | Wybierz preferowaną metodę dostępu do {resource} | |
|
||||
| PIN | PIN | |
|
||||
| User | Zaloguj | |
|
||||
| 6-digit PIN Code | 6-cyfrowy kod PIN | pin login |
|
||||
| Login in with PIN | Zaloguj się PIN’em | pin login |
|
||||
| Email | Email | user login |
|
||||
| Enter your email | Wprowadź swój email | user login |
|
||||
| Password | Hasło | user login |
|
||||
| Enter your password | Wprowadź swoje hasło | user login |
|
||||
| Forgot your password? | Zapomniałeś hasła? | user login |
|
||||
| Log in | Zaloguj | user login |
|
||||
|
||||
|
||||
## Login site
|
||||
|
||||
| EN | PL | Notes |
|
||||
| --------------------- | ------------------------------ | ----------- |
|
||||
| Welcome to Pangolin | Witaj w Pangolin | |
|
||||
| Log in to get started | Zaloguj się, aby rozpocząć<br> | |
|
||||
| Email | Email | |
|
||||
| Enter your email | Wprowadź swój adres e-mail<br> | placeholder |
|
||||
| Password | Hasło | |
|
||||
| Enter your password | Wprowadź swoje hasło | placeholder |
|
||||
| Forgot your password? | Nie pamiętasz hasła? | |
|
||||
| Log in | Zaloguj | |
|
||||
|
||||
# Ogranization site after successful login
|
||||
|
||||
|
||||
| EN | PL | Notes |
|
||||
| ----------------------------------------- | ------------------------------------------ | ----- |
|
||||
| Welcome to Pangolin | Witaj w Pangolin | |
|
||||
| You're a member of {number} organization. | Jesteś użytkownikiem {number} organizacji. | |
|
||||
|
||||
## Shared Header, Navbar and Footer
|
||||
##### Header
|
||||
|
||||
| EN | PL | Notes |
|
||||
| ------------------- | ------------------ | ----- |
|
||||
| Documentation | Dokumentacja | |
|
||||
| Support | Wsparcie | |
|
||||
| Organization {name} | Organizacja {name} | |
|
||||
##### Organization selector
|
||||
|
||||
| EN | PL | Notes |
|
||||
| ---------------- | ---------------- | ----- |
|
||||
| Search… | Szukaj… | |
|
||||
| Create | Utwórz | |
|
||||
| New Organization | Nowa organizacja | |
|
||||
| Organizations | Organizacje | |
|
||||
|
||||
##### Navbar
|
||||
|
||||
| EN | PL | Notes |
|
||||
| --------------- | ---------------------- | ----- |
|
||||
| Sites | Witryny | |
|
||||
| Resources | Zasoby | |
|
||||
| User & Roles | Użytkownicy i Role | |
|
||||
| Shareable Links | Łącza do udostępniania | |
|
||||
| General | Ogólne | |
|
||||
##### Footer
|
||||
| EN | PL | |
|
||||
| ------------------------- | -------------------------- | -------------- |
|
||||
| Page {number} of {number} | Strona {number} z {number} | |
|
||||
| Rows per page | Wierszy na stronę | |
|
||||
| Pangolin | Pangolin | bottom of site |
|
||||
| Built by Fossorial | Stworzone przez Fossorial | bottom of site |
|
||||
| Open Source | Open source | bottom of site |
|
||||
| Documentation | Dokumentacja | bottom of site |
|
||||
| {version} | {version} | bottom of site |
|
||||
## Main “Sites”
|
||||
##### “Hero” section
|
||||
|
||||
| EN | PL | Notes |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
|
||||
| Newt (Recommended) | Newt (zalecane) | |
|
||||
| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | Aby zapewnić najlepsze doświadczenie użytkownika, korzystaj z Newt. Wykorzystuje on technologię WireGuard w tle i pozwala na dostęp do Twoich prywatnych zasobów za pomocą ich adresu LAN w prywatnej sieci bezpośrednio z poziomu pulpitu nawigacyjnego Pangolin. | |
|
||||
| Runs in Docker | Działa w Dockerze | |
|
||||
| Runs in shell on macOS, Linux, and Windows | Działa w powłoce na systemach macOS, Linux i Windows | |
|
||||
| Install Newt | Zainstaluj Newt | |
|
||||
| Podstawowy WireGuard<br> | Użyj dowolnego klienta WireGuard, aby się połączyć. Będziesz musiał uzyskiwać dostęp do swoich wewnętrznych zasobów za pomocą adresu IP równorzędnego | |
|
||||
| Compatible with all WireGuard clients<br> | Kompatybilny ze wszystkimi klientami WireGuard<br> | |
|
||||
| Manual configuration required | Wymagana ręczna konfiguracja<br> | |
|
||||
##### Content
|
||||
|
||||
| EN | PL | Notes |
|
||||
| --------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------- |
|
||||
| Manage Sites | Zarządzanie witrynami | |
|
||||
| Allow connectivity to your network through secure tunnels | Zezwalaj na łączność z Twoją siecią za pośrednictwem bezpiecznych tuneli | |
|
||||
| Search sites | Szukaj witryny | placeholder |
|
||||
| Add Site | Dodaj witrynę | |
|
||||
| Name | Nazwa | table header |
|
||||
| Online | Status | table header |
|
||||
| Site | Witryna | table header |
|
||||
| Data In | Dane wchodzące | table header |
|
||||
| Data Out | Dane wychodzące | table header |
|
||||
| Connection Type | Typ połączenia | table header |
|
||||
| Online | Online | site state |
|
||||
| Offline | Poza siecią | site state |
|
||||
| Edit → | Edytuj → | |
|
||||
| View settings | Pokaż ustawienia | Popup after clicking “…” on site |
|
||||
| Delete | Usuń | Popup after clicking “…” on site |
|
||||
##### Add Site Popup
|
||||
|
||||
| EN | PL | Notes |
|
||||
| ------------------------------------------------------ | --------------------------------------------------- | ----------- |
|
||||
| Create Site | Utwórz witrynę | |
|
||||
| Create a new site to start connection for this site | Utwórz nową witrynę aby rozpocząć połączenie | |
|
||||
| Name | Nazwa | |
|
||||
| Site name | Nazwa witryny | placeholder |
|
||||
| This is the name that will be displayed for this site. | Tak będzie wyświetlana twoja witryna | desc |
|
||||
| Method | Metoda | |
|
||||
| Local | Lokalna | |
|
||||
| Newt | Newt | |
|
||||
| WireGuard | WireGuard | |
|
||||
| This is how you will expose connections. | Tak będą eksponowane połączenie. | |
|
||||
| You will only be able to see the configuration once. | Tą konfigurację możesz zobaczyć tylko raz. | |
|
||||
| Learn how to install Newt on your system | Dowiedz się jak zainstalować Newt na twoim systemie | |
|
||||
| I have copied the config | Skopiowałem konfigurację | |
|
||||
| Create Site | Utwórz witrynę | |
|
||||
| Close | Zamknij | |
|
||||
|
||||
## Main “Resources”
|
||||
|
||||
##### “Hero” section
|
||||
|
||||
| EN | PL | Notes |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
|
||||
| Resources | Zasoby | |
|
||||
| Zasoby to serwery proxy dla aplikacji działających w Twojej prywatnej sieci. Utwórz zasób dla dowolnej aplikacji HTTP lub HTTPS w swojej prywatnej sieci. Każdy zasób musi być połączony z witryną, aby umożliwić prywatne i bezpieczne połączenie przez szyfrowany tunel WireGuard. | Zasoby to serwery proxy dla aplikacji działających w Twojej prywatnej sieci. Utwórz zasób dla dowolnej aplikacji HTTP lub HTTPS w swojej prywatnej sieci. Każdy zasób musi być połączony z witryną, aby umożliwić prywatne i bezpieczne połączenie przez szyfrowany tunel WireGuard. | |
|
||||
| Secure connectivity with WireGuard encryption | Bezpieczna łączność z szyfrowaniem WireGuard | |
|
||||
| Configure multiple authentication methods | Konfigurowanie wielu metod uwierzytelniania | |
|
||||
| User and role-based access control | Kontrola dostępu oparta na użytkownikach i rolach | |
|
||||
##### Content
|
||||
|
||||
| EN | PL | Notes |
|
||||
| -------------------------------------------------- | -------------------------------------------------------------- | -------------------- |
|
||||
| Manage Resources | Zarządzaj zasobami | |
|
||||
| Create secure proxies to your private applications | Twórz bezpieczne serwery proxy dla swoich prywatnych aplikacji | |
|
||||
| Search resources | Szukaj w zasobach | placeholder |
|
||||
| Name | Nazwa | |
|
||||
| Site | Witryna | |
|
||||
| Full URL | Pełny URL | |
|
||||
| Authentication | Uwierzytelnianie | |
|
||||
| Not Protected | Niezabezpieczony | authentication state |
|
||||
| Protected | Zabezpieczony | authentication state |
|
||||
| Edit → | Edytuj → | |
|
||||
| Add Resource | Dodaj zasób | |
|
||||
##### Add Resource Popup
|
||||
|
||||
| EN | PL | Notes |
|
||||
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------- |
|
||||
| Create Resource | Utwórz zasób | |
|
||||
| Create a new resource to proxy request to your app | Utwórz nowy zasób, aby przekazywać żądania do swojej aplikacji | |
|
||||
| Name | Nazwa | |
|
||||
| My Resource | Nowy zasób | name placeholder |
|
||||
| This is the name that will be displayed for this resource. | To jest nazwa, która będzie wyświetlana dla tego zasobu | |
|
||||
| Subdomain | Subdomena | |
|
||||
| Enter subdomain | Wprowadź subdomenę | |
|
||||
| This is the fully qualified domain name that will be used to access the resource. | To jest pełna nazwa domeny, która będzie używana do dostępu do zasobu. | |
|
||||
| Site | Witryna | |
|
||||
| Search site… | Szukaj witryny… | Site selector popup |
|
||||
| This is the site that will be used in the dashboard. | To jest witryna, która będzie używana w pulpicie nawigacyjnym. | |
|
||||
| Create Resource | Utwórz zasób | |
|
||||
| Close | Zamknij | |
|
||||
|
||||
|
||||
## Main “User & Roles”
|
||||
##### Content
|
||||
|
||||
| EN | PL | Notes |
|
||||
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ----------------------------- |
|
||||
| Manage User & Roles | Zarządzanie użytkownikami i rolami | |
|
||||
| Invite users and add them to roles to manage access to your organization | Zaproś użytkowników i przypisz im role, aby zarządzać dostępem do Twojej organizacji | |
|
||||
| Users | Użytkownicy | sidebar item |
|
||||
| Roles | Role | sidebar item |
|
||||
| **User tab** | | |
|
||||
| Search users | Wyszukaj użytkownika | placeholder |
|
||||
| Invite User | Zaproś użytkownika | addbutton |
|
||||
| Email | Email | table header |
|
||||
| Status | Status | table header |
|
||||
| Role | Rola | table header |
|
||||
| Confirmed | Zatwierdzony | account status |
|
||||
| Not confirmed (?) | Niezatwierdzony (?) | unknown for me account status |
|
||||
| Owner | Właściciel | role |
|
||||
| Admin | Administrator | role |
|
||||
| Member | Użytkownik | role |
|
||||
| **Roles Tab** | | |
|
||||
| Search roles | Wyszukaj role | placeholder |
|
||||
| Add Role | Dodaj role | addbutton |
|
||||
| Name | Nazwa | table header |
|
||||
| Description | Opis | table header |
|
||||
| Admin | Administrator | role |
|
||||
| Member | Użytkownik | role |
|
||||
| Admin role with the most permissions | Rola administratora z najszerszymi uprawnieniami | admin role desc |
|
||||
| Members can only view resources | Członkowie mogą jedynie przeglądać zasoby | member role desc |
|
||||
|
||||
##### Invite User popup
|
||||
|
||||
| EN | PL | Notes |
|
||||
| ----------------- | ------------------------------------------ | ----------- |
|
||||
| Invite User | Give new users access to your organization | |
|
||||
| Email | Email | |
|
||||
| Enter an email | Wprowadź email | placeholder |
|
||||
| Role | Rola | |
|
||||
| Select role | Wybierz role | placeholder |
|
||||
| Vaild for | Ważne do | |
|
||||
| 1 day | Dzień | |
|
||||
| 2 days | 2 dni | |
|
||||
| 3 days | 3 dni | |
|
||||
| 4 days | 4 dni | |
|
||||
| 5 days | 5 dni | |
|
||||
| 6 days | 6 dni | |
|
||||
| 7 days | 7 dni | |
|
||||
| Create Invitation | Utwórz zaproszenie | |
|
||||
| Close | Zamknij | |
|
||||
|
||||
|
||||
## Main “Shareable Links”
|
||||
##### “Hero” section
|
||||
|
||||
| EN | PL | Notes |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| Shareable Links | Łącza do udostępniania | |
|
||||
| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Twórz linki do udostępniania swoich zasobów. Linki zapewniają tymczasowy lub nieograniczony dostęp do zasobu. Możesz skonfigurować czas wygaśnięcia linku podczas jego tworzenia. | |
|
||||
| Easy to create and share | Łatwe tworzenie i udostępnianie | |
|
||||
| Configurable expiration duration | Konfigurowalny czas wygaśnięcia | |
|
||||
| Secure and revocable | Bezpieczne i odwołalne | |
|
||||
##### Content
|
||||
|
||||
| EN | PL | Notes |
|
||||
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------- |
|
||||
| Manage Shareable Links | Zarządzaj łączami do udostępniania | |
|
||||
| Create shareable links to grant temporary or permament access to your resources | Utwórz łącze do udostępniania w celu przyznania tymczasowego lub stałego dostępu do zasobów | |
|
||||
| Search links | Szukaj łączy | placeholder |
|
||||
| Create Share Link | Utwórz nowe łącze | addbutton |
|
||||
| Resource | Zasób | table header |
|
||||
| Title | Tytuł | table header |
|
||||
| Created | Utworzone | table header |
|
||||
| Expires | Wygasa | table header |
|
||||
| No links. Create one to get started. | Brak łączy. Utwórz, aby rozpocząć. | table placeholder |
|
||||
|
||||
##### Create Shareable Link popup
|
||||
|
||||
| EN | PL | Notes |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
|
||||
| Create Shareable Link | Utwórz łącze do udostępnienia | |
|
||||
| Anyone with this link can access the resource | Każdy kto ma ten link może korzystać z zasobu | |
|
||||
| Resource | Zasób | |
|
||||
| Select resource | Wybierz zasób | |
|
||||
| Search resources… | Szukaj zasobów… | resource selector popup |
|
||||
| Title (optional) | Tytuł (opcjonalny) | |
|
||||
| Enter title | Wprowadź tytuł | placeholder |
|
||||
| Expire in | Wygasa za | |
|
||||
| Minutes | Minut | |
|
||||
| Hours | Godzin | |
|
||||
| Days | Dni | |
|
||||
| Months | Miesięcy | |
|
||||
| Years | Lat | |
|
||||
| Never expire | Nie wygasa | |
|
||||
| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | Czas wygaśnięcia to okres, przez który link będzie aktywny i zapewni dostęp do zasobu. Po upływie tego czasu link przestanie działać, a użytkownicy, którzy go użyli, stracą dostęp do zasobu. | |
|
||||
| Create Link | Utwórz łącze | |
|
||||
| Close | Zamknij | |
|
||||
|
||||
|
||||
## Main “General”
|
||||
|
||||
| EN | PL | Notes |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------ |
|
||||
| General | Ogólne | |
|
||||
| Configure your organization’s general settings | Zarządzaj ogólnymi ustawieniami twoich organizacji | |
|
||||
| General | Ogólne | sidebar item |
|
||||
| Organization Settings | Ustawienia organizacji | |
|
||||
| Manage your organization details and configuration | Zarządzaj szczegółami i konfiguracją organizacji | |
|
||||
| Name | Nazwa | |
|
||||
| This is the display name of the org | To jest wyświetlana nazwa Twojej organizacji | |
|
||||
| Save Settings | Zapisz ustawienia | |
|
||||
| Danger Zone | Niebezpieczna strefa | |
|
||||
| Once you delete this org, there is no going back. Please be certain. | Jeśli usuniesz swoją tą organizację, nie ma odwrotu. Bądź ostrożny! | |
|
||||
| Delete Organization Data | Usuń dane organizacji | |
|
|
@ -1,310 +0,0 @@
|
|||
## Authentication Site
|
||||
|
||||
| EN | TR | Notes |
|
||||
| -------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------- |
|
||||
| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Pangolin Tarafından Destekleniyor | |
|
||||
| Authentication Required | Kimlik Doğrulaması Gerekli | |
|
||||
| Choose your preferred method to access {resource} | {resource}'a erişmek için tercih ettiğiniz yöntemi seçin | |
|
||||
| PIN | PIN | |
|
||||
| User | Kullanıcı | |
|
||||
| 6-digit PIN Code | 6 haneli PIN Kodu | pin login |
|
||||
| Login in with PIN | PIN ile Giriş Yap | pin login |
|
||||
| Email | E-posta | user login |
|
||||
| Enter your email | E-postanızı girin | user login |
|
||||
| Password | Şifre | user login |
|
||||
| Enter your password | Şifrenizi girin | user login |
|
||||
| Forgot your password? | Şifrenizi mi unuttunuz? | user login |
|
||||
| Log in | Giriş Yap | user login |
|
||||
|
||||
---
|
||||
|
||||
## Login site
|
||||
|
||||
| EN | TR | Notes |
|
||||
| --------------------- | ------------------------------------------------------ | ----------- |
|
||||
| Welcome to Pangolin | Pangolin'e Hoşgeldiniz | |
|
||||
| Log in to get started | Başlamak için giriş yapın | |
|
||||
| Email | E-posta | |
|
||||
| Enter your email | E-posta adresinizi girin | placeholder |
|
||||
| Password | Şifre | |
|
||||
| Enter your password | Şifrenizi girin | placeholder |
|
||||
| Forgot your password? | Şifrenizi mi unuttunuz? | |
|
||||
| Log in | Giriş Yap | |
|
||||
|
||||
---
|
||||
|
||||
# Organization site after successful login
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------- | ----- |
|
||||
| Welcome to Pangolin | Pangolin'e Hoşgeldiniz | |
|
||||
| You're a member of {number} organization. | {number} organizasyonunun üyesiniz. | |
|
||||
|
||||
---
|
||||
|
||||
## Shared Header, Navbar and Footer
|
||||
|
||||
##### Header
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------- | -------------------------- | ----- |
|
||||
| Documentation | Dokümantasyon | |
|
||||
| Support | Destek | |
|
||||
| Organization {name} | Organizasyon {name} | |
|
||||
|
||||
##### Organization selector
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ---------------- | ---------------------- | ----- |
|
||||
| Search… | Ara… | |
|
||||
| Create | Oluştur | |
|
||||
| New Organization | Yeni Organizasyon | |
|
||||
| Organizations | Organizasyonlar | |
|
||||
|
||||
##### Navbar
|
||||
|
||||
| EN | TR | Notes |
|
||||
| --------------- | ------------------------------- | ----- |
|
||||
| Sites | Siteler | |
|
||||
| Resources | Kaynaklar | |
|
||||
| User & Roles | Kullanıcılar ve Roller | |
|
||||
| Shareable Links | Paylaşılabilir Linkler | |
|
||||
| General | Genel | |
|
||||
|
||||
##### Footer
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------- | ------------------------------------------------ | -------------------- |
|
||||
| Page {number} of {number} | Sayfa {number} / {number} | |
|
||||
| Rows per page | Sayfa başına satırlar | |
|
||||
| Pangolin | Pangolin | Footer'da yer alır |
|
||||
| Built by Fossorial | Fossorial tarafından oluşturuldu | Footer'da yer alır |
|
||||
| Open Source | Açık Kaynak | Footer'da yer alır |
|
||||
| Documentation | Dokümantasyon | Footer'da yer alır |
|
||||
| {version} | {version} | Footer'da yer alır |
|
||||
|
||||
---
|
||||
|
||||
## Main “Sites”
|
||||
|
||||
##### “Hero” section
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| Newt (Recommended) | Newt (Tavsiye Edilen) | |
|
||||
| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | En iyi kullanıcı deneyimi için Newt'i kullanın. Newt, arka planda WireGuard kullanır ve Pangolin kontrol paneli üzerinden özel ağınızdaki kaynaklarınıza LAN adresleriyle erişmenizi sağlar. | |
|
||||
| Runs in Docker | Docker üzerinde çalışır | |
|
||||
| Runs in shell on macOS, Linux, and Windows | macOS, Linux ve Windows’ta komut satırında çalışır | |
|
||||
| Install Newt | Newt'i Yükle | |
|
||||
| Basic WireGuard<br> | Temel WireGuard<br> | |
|
||||
| Compatible with all WireGuard clients<br> | Tüm WireGuard istemcileriyle uyumlu<br> | |
|
||||
| Manual configuration required | Manuel yapılandırma gereklidir | |
|
||||
|
||||
##### Content
|
||||
|
||||
| EN | TR | Notes |
|
||||
| --------------------------------------------------------- | --------------------------------------------------------------------------- | ------------ |
|
||||
| Manage Sites | Siteleri Yönet | |
|
||||
| Allow connectivity to your network through secure tunnels | Güvenli tüneller aracılığıyla ağınıza bağlantı sağlayın | |
|
||||
| Search sites | Siteleri ara | placeholder |
|
||||
| Add Site | Site Ekle | |
|
||||
| Name | Ad | Table Header |
|
||||
| Online | Çevrimiçi | Table Header |
|
||||
| Site | Site | Table Header |
|
||||
| Data In | Gelen Veri | Table Header |
|
||||
| Data Out | Giden Veri | Table Header |
|
||||
| Connection Type | Bağlantı Türü | Table Header |
|
||||
| Online | Çevrimiçi | Site state |
|
||||
| Offline | Çevrimdışı | Site state |
|
||||
| Edit → | Düzenle → | |
|
||||
| View settings | Ayarları Görüntüle | Popup |
|
||||
| Delete | Sil | Popup |
|
||||
|
||||
##### Add Site Popup
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------- | ----------- |
|
||||
| Create Site | Site Oluştur | |
|
||||
| Create a new site to start connection for this site | Bu site için bağlantıyı başlatmak amacıyla yeni bir site oluşturun | |
|
||||
| Name | Ad | |
|
||||
| Site name | Site adı | placeholder |
|
||||
| This is the name that will be displayed for this site. | Bu, site için görüntülenecek addır. | desc |
|
||||
| Method | Yöntem | |
|
||||
| Local | Yerel | |
|
||||
| Newt | Newt | |
|
||||
| WireGuard | WireGuard | |
|
||||
| This is how you will expose connections. | Bağlantılarınızı bu şekilde açığa çıkaracaksınız. | |
|
||||
| You will only be able to see the configuration once. | Yapılandırmayı yalnızca bir kez görüntüleyebilirsiniz. | |
|
||||
| Learn how to install Newt on your system | Sisteminizde Newt'in nasıl kurulacağını öğrenin | |
|
||||
| I have copied the config | Yapılandırmayı kopyaladım | |
|
||||
| Create Site | Site Oluştur | |
|
||||
| Close | Kapat | |
|
||||
|
||||
---
|
||||
|
||||
## Main “Resources”
|
||||
|
||||
##### “Hero” section
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ----- |
|
||||
| Resources | Kaynaklar | |
|
||||
| Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | Kaynaklar, özel ağınızda çalışan uygulamalar için proxy sunucularıdır. Özel ağınızdaki her HTTP veya HTTPS uygulaması için bir kaynak oluşturun. Her kaynağın, şifrelenmiş WireGuard tüneli üzerinden özel ve güvenli bağlantı sağlamak üzere bir siteyle ilişkili olması gerekir. | |
|
||||
| Secure connectivity with WireGuard encryption | WireGuard şifrelemesiyle güvenli bağlantı | |
|
||||
| Configure multiple authentication methods | Birden çok kimlik doğrulama yöntemini yapılandırın | |
|
||||
| User and role-based access control | Kullanıcı ve role dayalı erişim kontrolü | |
|
||||
|
||||
##### Content
|
||||
|
||||
| EN | TR | Notes |
|
||||
| -------------------------------------------------- | ------------------------------------------------------------- | -------------------- |
|
||||
| Manage Resources | Kaynakları Yönet | |
|
||||
| Create secure proxies to your private applications | Özel uygulamalarınız için güvenli proxy’ler oluşturun | |
|
||||
| Search resources | Kaynakları ara | placeholder |
|
||||
| Name | Ad | |
|
||||
| Site | Site | |
|
||||
| Full URL | Tam URL | |
|
||||
| Authentication | Kimlik Doğrulama | |
|
||||
| Not Protected | Korunmayan | authentication state |
|
||||
| Protected | Korunan | authentication state |
|
||||
| Edit → | Düzenle → | |
|
||||
| Add Resource | Kaynak Ekle | |
|
||||
|
||||
##### Add Resource Popup
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | ------------- |
|
||||
| Create Resource | Kaynak Oluştur | |
|
||||
| Create a new resource to proxy request to your app | Uygulamanıza gelen istekleri yönlendirmek için yeni bir kaynak oluşturun | |
|
||||
| Name | Ad | |
|
||||
| My Resource | Kaynağım | name placeholder |
|
||||
| This is the name that will be displayed for this resource. | Bu, kaynağın görüntülenecek adıdır. | |
|
||||
| Subdomain | Alt alan adı | |
|
||||
| Enter subdomain | Alt alan adını girin | |
|
||||
| This is the fully qualified domain name that will be used to access the resource. | Kaynağa erişmek için kullanılacak tam nitelikli alan adıdır. | |
|
||||
| Site | Site | |
|
||||
| Search site… | Site ara… | Site selector popup |
|
||||
| This is the site that will be used in the dashboard. | Kontrol panelinde kullanılacak sitedir. | |
|
||||
| Create Resource | Kaynak Oluştur | |
|
||||
| Close | Kapat | |
|
||||
|
||||
---
|
||||
|
||||
## Main “User & Roles”
|
||||
|
||||
##### Content
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ----------------------------- |
|
||||
| Manage User & Roles | Kullanıcılar ve Rolleri Yönet | |
|
||||
| Invite users and add them to roles to manage access to your organization | Organizasyonunuza erişimi yönetmek için kullanıcıları davet edin ve rollere atayın | |
|
||||
| Users | Kullanıcılar | sidebar item |
|
||||
| Roles | Roller | sidebar item |
|
||||
| **User tab** | **Kullanıcı Sekmesi** | |
|
||||
| Search users | Kullanıcıları ara | placeholder |
|
||||
| Invite User | Kullanıcı Davet Et | addbutton |
|
||||
| Email | E-posta | table header |
|
||||
| Status | Durum | table header |
|
||||
| Role | Rol | table header |
|
||||
| Confirmed | Onaylandı | account status |
|
||||
| Not confirmed (?) | Onaylanmadı (?) | account status |
|
||||
| Owner | Sahip | role |
|
||||
| Admin | Yönetici | role |
|
||||
| Member | Üye | role |
|
||||
| **Roles Tab** | **Roller Sekmesi** | |
|
||||
| Search roles | Rolleri ara | placeholder |
|
||||
| Add Role | Rol Ekle | addbutton |
|
||||
| Name | Ad | table header |
|
||||
| Description | Açıklama | table header |
|
||||
| Admin | Yönetici | role |
|
||||
| Member | Üye | role |
|
||||
| Admin role with the most permissions | En fazla yetkiye sahip yönetici rolü | admin role desc |
|
||||
| Members can only view resources | Üyeler yalnızca kaynakları görüntüleyebilir | member role desc |
|
||||
|
||||
##### Invite User popup
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ----------------- | ----------------------------------------------------------------------- | ----------- |
|
||||
| Invite User | Kullanıcı Davet Et | |
|
||||
| Email | E-posta | |
|
||||
| Enter an email | Bir e-posta adresi girin | placeholder |
|
||||
| Role | Rol | |
|
||||
| Select role | Rol seçin | placeholder |
|
||||
| Gültig für | Geçerlilik Süresi | |
|
||||
| 1 day | 1 gün | |
|
||||
| 2 days | 2 gün | |
|
||||
| 3 days | 3 gün | |
|
||||
| 4 days | 4 gün | |
|
||||
| 5 days | 5 gün | |
|
||||
| 6 days | 6 gün | |
|
||||
| 7 days | 7 gün | |
|
||||
| Create Invitation | Davetiye Oluştur | |
|
||||
| Close | Kapat | |
|
||||
|
||||
---
|
||||
|
||||
## Main “Shareable Links”
|
||||
|
||||
##### “Hero” section
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| Shareable Links | Paylaşılabilir Bağlantılar | |
|
||||
| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Kaynaklarınıza paylaşılabilir bağlantılar oluşturun. Bağlantılar, kaynağınıza geçici veya sınırsız erişim sağlar. Oluştururken bağlantının geçerlilik süresini ayarlayabilirsiniz. | |
|
||||
| Easy to create and share | Oluşturması ve paylaşması kolay | |
|
||||
| Configurable expiration duration | Yapılandırılabilir geçerlilik süresi | |
|
||||
| Secure and revocable | Güvenli ve iptal edilebilir | |
|
||||
|
||||
##### Content
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | -------------- |
|
||||
| Manage Shareable Links | Paylaşılabilir Bağlantıları Yönet | |
|
||||
| Create shareable links to grant temporary or permanent access to your resources | Kaynaklarınıza geçici veya kalıcı erişim sağlamak için paylaşılabilir bağlantılar oluşturun | |
|
||||
| Search links | Bağlantıları ara | placeholder |
|
||||
| Create Share Link | Bağlantı Oluştur | addbutton |
|
||||
| Resource | Kaynak | table header |
|
||||
| Title | Başlık | table header |
|
||||
| Created | Oluşturulma Tarihi | table header |
|
||||
| Expires | Son Kullanma Tarihi | table header |
|
||||
| No links. Create one to get started. | Bağlantı yok. Başlamak için bir tane oluşturun. | table placeholder |
|
||||
|
||||
##### Create Shareable Link popup
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | ----------------------- |
|
||||
| Create Shareable Link | Paylaşılabilir Bağlantı Oluştur | |
|
||||
| Anyone with this link can access the resource | Bu bağlantıya sahip olan herkes kaynağa erişebilir | |
|
||||
| Resource | Kaynak | |
|
||||
| Select resource | Kaynak seçin | |
|
||||
| Search resources… | Kaynak ara… | resource selector popup |
|
||||
| Title (optional) | Başlık (isteğe bağlı) | |
|
||||
| Enter title | Başlık girin | placeholder |
|
||||
| Expire in | Sona Erme Süresi | |
|
||||
| Minutes | Dakika | |
|
||||
| Hours | Saat | |
|
||||
| Days | Gün | |
|
||||
| Months | Ay | |
|
||||
| Years | Yıl | |
|
||||
| Never expire | Asla sona erme | |
|
||||
| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | Bağlantının geçerlilik süresi, bağlantının ne kadar süreyle kullanılabilir olacağını ve kaynağa erişim sağlayacağını belirler. Bu sürenin sonunda bağlantı çalışmaz hale gelir ve bağlantıyı kullananlar kaynağa erişimini kaybeder. | |
|
||||
| Create Link | Bağlantı Oluştur | |
|
||||
| Close | Kapat | |
|
||||
|
||||
---
|
||||
|
||||
## Main “General”
|
||||
|
||||
| EN | TR | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | ------------ |
|
||||
| General | Genel | |
|
||||
| Configure your organization’s general settings | Organizasyonunuzun genel ayarlarını yapılandırın | |
|
||||
| General | Genel | sidebar item |
|
||||
| Organization Settings | Organizasyon Ayarları | |
|
||||
| Manage your organization details and configuration | Organizasyonunuzun detaylarını ve yapılandırmasını yönetin | |
|
||||
| Name | Ad | |
|
||||
| This is the display name of the org | Bu, organizasyonunuzun görüntülenecek adıdır. | |
|
||||
| Save Settings | Ayarları Kaydet | |
|
||||
| Danger Zone | Tehlikeli Bölge | |
|
||||
| Once you delete this org, there is no going back. Please be certain. | Bu organizasyonu sildikten sonra geri dönüş yoktur. Lütfen emin olun. | |
|
||||
| Delete Organization Data | Organizasyon Verilerini Sil | |
|
1132
messages/de-DE.json
Normal file
1132
messages/de-DE.json
Normal file
File diff suppressed because it is too large
Load diff
1134
messages/en-US.json
Normal file
1134
messages/en-US.json
Normal file
File diff suppressed because it is too large
Load diff
1132
messages/es-ES.json
Normal file
1132
messages/es-ES.json
Normal file
File diff suppressed because it is too large
Load diff
1132
messages/fr-FR.json
Normal file
1132
messages/fr-FR.json
Normal file
File diff suppressed because it is too large
Load diff
1132
messages/it-IT.json
Normal file
1132
messages/it-IT.json
Normal file
File diff suppressed because it is too large
Load diff
1132
messages/nl-NL.json
Normal file
1132
messages/nl-NL.json
Normal file
File diff suppressed because it is too large
Load diff
1132
messages/pl-PL.json
Normal file
1132
messages/pl-PL.json
Normal file
File diff suppressed because it is too large
Load diff
1132
messages/pt-PT.json
Normal file
1132
messages/pt-PT.json
Normal file
File diff suppressed because it is too large
Load diff
1132
messages/tr-TR.json
Normal file
1132
messages/tr-TR.json
Normal file
File diff suppressed because it is too large
Load diff
1132
messages/zh-CN.json
Normal file
1132
messages/zh-CN.json
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,4 +1,8 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin();
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true
|
||||
|
@ -6,4 +10,4 @@ const nextConfig = {
|
|||
output: "standalone"
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default withNextIntl(nextConfig);
|
||||
|
|
1832
package-lock.json
generated
1832
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -81,6 +81,7 @@
|
|||
"lucide-react": "0.511.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "15.3.3",
|
||||
"next-intl": "^4.1.0",
|
||||
"next-themes": "0.4.6",
|
||||
"node-cache": "5.1.2",
|
||||
"node-fetch": "3.3.2",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Users, Globe, Database, Cog, Settings, Waypoints, Combine } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface OrgStat {
|
||||
label: string;
|
||||
|
@ -38,19 +39,21 @@ export default function OrganizationLandingCard(
|
|||
) {
|
||||
const [orgData] = useState(props);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const orgStats: OrgStat[] = [
|
||||
{
|
||||
label: "Sites",
|
||||
label: t('sites'),
|
||||
value: orgData.overview.stats.sites,
|
||||
icon: <Combine className="h-6 w-6" />
|
||||
},
|
||||
{
|
||||
label: "Resources",
|
||||
label: t('resources'),
|
||||
value: orgData.overview.stats.resources,
|
||||
icon: <Waypoints className="h-6 w-6" />
|
||||
},
|
||||
{
|
||||
label: "Users",
|
||||
label: t('users'),
|
||||
value: orgData.overview.stats.users,
|
||||
icon: <Users className="h-6 w-6" />
|
||||
}
|
||||
|
@ -81,9 +84,9 @@ export default function OrganizationLandingCard(
|
|||
))}
|
||||
</div>
|
||||
<div className="text-center text-lg">
|
||||
Your role:{" "}
|
||||
{t('accessRoleYour')}{" "}
|
||||
<span className="font-semibold">
|
||||
{orgData.overview.isOwner ? "Owner" : orgData.overview.userRole}
|
||||
{orgData.overview.isOwner ? t('accessRoleOwner') : orgData.overview.userRole}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
@ -92,7 +95,7 @@ export default function OrganizationLandingCard(
|
|||
<Link href={`/${orgData.overview.orgId}/settings`}>
|
||||
<Button size="lg" className="w-full md:w-auto">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Organization Settings
|
||||
{t('orgGeneralSettings')}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardFooter>
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AccessPageHeaderAndNavProps {
|
||||
children: React.ReactNode;
|
||||
|
@ -12,20 +13,22 @@ export default function AccessPageHeaderAndNav({
|
|||
children,
|
||||
hasInvitations
|
||||
}: AccessPageHeaderAndNavProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: "Users",
|
||||
title: t('users'),
|
||||
href: `/{orgId}/settings/access/users`
|
||||
},
|
||||
{
|
||||
title: "Roles",
|
||||
title: t('roles'),
|
||||
href: `/{orgId}/settings/access/roles`
|
||||
}
|
||||
];
|
||||
|
||||
if (hasInvitations) {
|
||||
navItems.push({
|
||||
title: "Invitations",
|
||||
title: t('invite'),
|
||||
href: `/{orgId}/settings/access/invitations`
|
||||
});
|
||||
}
|
||||
|
@ -33,8 +36,8 @@ export default function AccessPageHeaderAndNav({
|
|||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage Users & Roles"
|
||||
description="Invite users and add them to roles to manage access to your organization"
|
||||
title={t('accessUsersRoles')}
|
||||
description={t('accessUsersRolesDescription')}
|
||||
/>
|
||||
|
||||
<HorizontalTabs items={navItems}>
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
@ -14,12 +15,15 @@ export function InvitationsDataTable<TData, TValue>({
|
|||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Invitations"
|
||||
searchPlaceholder="Search invitations..."
|
||||
title={t('invite')}
|
||||
searchPlaceholder={t('inviteSearch')}
|
||||
searchColumn="email"
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -17,6 +17,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
|
|||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type InvitationRow = {
|
||||
id: string;
|
||||
|
@ -39,6 +40,8 @@ export default function InvitationsTable({
|
|||
const [selectedInvitation, setSelectedInvitation] =
|
||||
useState<InvitationRow | null>(null);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { org } = useOrgContext();
|
||||
|
||||
|
@ -51,7 +54,7 @@ export default function InvitationsTable({
|
|||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="sr-only">{t('openMenu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
@ -62,7 +65,7 @@ export default function InvitationsTable({
|
|||
setSelectedInvitation(invitation);
|
||||
}}
|
||||
>
|
||||
<span>Regenerate Invitation</span>
|
||||
<span>{t('inviteRegenerate')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
|
@ -71,7 +74,7 @@ export default function InvitationsTable({
|
|||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
Remove Invitation
|
||||
{t('inviteRemove')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
@ -81,11 +84,11 @@ export default function InvitationsTable({
|
|||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Email"
|
||||
header: t('email')
|
||||
},
|
||||
{
|
||||
accessorKey: "expiresAt",
|
||||
header: "Expires At",
|
||||
header: t('expiresAt'),
|
||||
cell: ({ row }) => {
|
||||
const expiresAt = new Date(row.original.expiresAt);
|
||||
const isExpired = expiresAt < new Date();
|
||||
|
@ -99,7 +102,7 @@ export default function InvitationsTable({
|
|||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Role"
|
||||
header: t('role')
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -112,17 +115,16 @@ export default function InvitationsTable({
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to remove invitation",
|
||||
description:
|
||||
"An error occurred while removing the invitation."
|
||||
title: t('inviteRemoveError'),
|
||||
description: t('inviteRemoveErrorDescription')
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "Invitation removed",
|
||||
description: `The invitation for ${selectedInvitation.email} has been removed.`
|
||||
title: t('inviteRemoved'),
|
||||
description: t('inviteRemovedDescription', {email: selectedInvitation.email})
|
||||
});
|
||||
|
||||
setInvitations((prev) =>
|
||||
|
@ -146,23 +148,20 @@ export default function InvitationsTable({
|
|||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to remove the invitation for{" "}
|
||||
<b>{selectedInvitation?.email}</b>?
|
||||
{t('inviteQuestionRemove', {email: selectedInvitation?.email || ""})}
|
||||
</p>
|
||||
<p>
|
||||
Once removed, this invitation will no longer be
|
||||
valid. You can always re-invite the user later.
|
||||
{t('inviteMessageRemove')}
|
||||
</p>
|
||||
<p>
|
||||
To confirm, please type the email address of the
|
||||
invitation below.
|
||||
{t('inviteMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Remove Invitation"
|
||||
buttonText={t('inviteRemoveConfirm')}
|
||||
onConfirm={removeInvitation}
|
||||
string={selectedInvitation?.email ?? ""}
|
||||
title="Remove Invitation"
|
||||
title={t('inviteRemove')}
|
||||
/>
|
||||
<RegenerateInvitationForm
|
||||
open={isRegenerateModalOpen}
|
||||
|
|
|
@ -24,6 +24,7 @@ import {
|
|||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Label } from "@app/components/ui/label";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type RegenerateInvitationFormProps = {
|
||||
open: boolean;
|
||||
|
@ -56,14 +57,16 @@ export default function RegenerateInvitationForm({
|
|||
const api = createApiClient(useEnvContext());
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const validForOptions = [
|
||||
{ hours: 24, name: "1 day" },
|
||||
{ hours: 48, name: "2 days" },
|
||||
{ hours: 72, name: "3 days" },
|
||||
{ hours: 96, name: "4 days" },
|
||||
{ hours: 120, name: "5 days" },
|
||||
{ hours: 144, name: "6 days" },
|
||||
{ hours: 168, name: "7 days" }
|
||||
{ hours: 24, name: t('day', {count: 1}) },
|
||||
{ hours: 48, name: t('day', {count: 2}) },
|
||||
{ hours: 72, name: t('day', {count: 3}) },
|
||||
{ hours: 96, name: t('day', {count: 4}) },
|
||||
{ hours: 120, name: t('day', {count: 5}) },
|
||||
{ hours: 144, name: t('day', {count: 6}) },
|
||||
{ hours: 168, name: t('day', {count: 7}) }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -79,9 +82,8 @@ export default function RegenerateInvitationForm({
|
|||
if (!org?.org.orgId) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Organization ID Missing",
|
||||
description:
|
||||
"Unable to regenerate invitation without an organization ID.",
|
||||
title: t('orgMissing'),
|
||||
description: t('orgMissingMessage'),
|
||||
duration: 5000
|
||||
});
|
||||
return;
|
||||
|
@ -105,15 +107,15 @@ export default function RegenerateInvitationForm({
|
|||
if (sendEmail) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "Invitation Regenerated",
|
||||
description: `A new invitation has been sent to ${invitation.email}.`,
|
||||
title: t('inviteRegenerated'),
|
||||
description: t('inviteSent', {email: invitation.email}),
|
||||
duration: 5000
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "Invitation Regenerated",
|
||||
description: `A new invitation has been generated for ${invitation.email}.`,
|
||||
title: t('inviteRegenerated'),
|
||||
description: t('inviteGenerate', {email: invitation.email}),
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
|
@ -130,24 +132,22 @@ export default function RegenerateInvitationForm({
|
|||
if (error.response?.status === 409) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Duplicate Invite",
|
||||
description: "An invitation for this user already exists.",
|
||||
title: t('inviteDuplicateError'),
|
||||
description: t('inviteDuplicateErrorDescription'),
|
||||
duration: 5000
|
||||
});
|
||||
} else if (error.response?.status === 429) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Rate Limit Exceeded",
|
||||
description:
|
||||
"You have exceeded the limit of 3 regenerations per hour. Please try again later.",
|
||||
title: t('inviteRateLimitError'),
|
||||
description: t('inviteRateLimitErrorDescription'),
|
||||
duration: 5000
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to Regenerate Invitation",
|
||||
description:
|
||||
"An error occurred while regenerating the invitation.",
|
||||
title: t('inviteRegenerateError'),
|
||||
description: t('inviteRegenerateErrorDescription'),
|
||||
duration: 5000
|
||||
});
|
||||
}
|
||||
|
@ -168,18 +168,16 @@ export default function RegenerateInvitationForm({
|
|||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Regenerate Invitation</CredenzaTitle>
|
||||
<CredenzaTitle>{t('inviteRegenerate')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Revoke previous invitation and create a new one
|
||||
{t('inviteRegenerateDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
{!inviteLink ? (
|
||||
<div>
|
||||
<p>
|
||||
Are you sure you want to regenerate the
|
||||
invitation for <b>{invitation?.email}</b>? This
|
||||
will revoke the previous invitation.
|
||||
{t('inviteQuestionRegenerate', {email: invitation?.email || ""})}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<Checkbox
|
||||
|
@ -190,12 +188,12 @@ export default function RegenerateInvitationForm({
|
|||
}
|
||||
/>
|
||||
<label htmlFor="send-email">
|
||||
Send email notification to the user
|
||||
{t('inviteSentEmail')}
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label>
|
||||
Validity Period
|
||||
{t('inviteValidityPeriod')}
|
||||
</Label>
|
||||
<Select
|
||||
value={validHours.toString()}
|
||||
|
@ -204,7 +202,7 @@ export default function RegenerateInvitationForm({
|
|||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select validity period" />
|
||||
<SelectValue placeholder={t('inviteValidityPeriodSelect')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{validForOptions.map((option) => (
|
||||
|
@ -222,9 +220,7 @@ export default function RegenerateInvitationForm({
|
|||
) : (
|
||||
<div className="space-y-4 max-w-md">
|
||||
<p>
|
||||
The invitation has been regenerated. The user
|
||||
must access the link below to accept the
|
||||
invitation.
|
||||
{t('inviteRegenerateMessage')}
|
||||
</p>
|
||||
<CopyTextBox text={inviteLink} wrapText={false} />
|
||||
</div>
|
||||
|
@ -234,18 +230,18 @@ export default function RegenerateInvitationForm({
|
|||
{!inviteLink ? (
|
||||
<>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button variant="outline">{t('cancel')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
onClick={handleRegenerate}
|
||||
loading={loading}
|
||||
>
|
||||
Regenerate
|
||||
{t('inviteRegenerateButton')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
)}
|
||||
</CredenzaFooter>
|
||||
|
|
|
@ -9,6 +9,7 @@ import UserProvider from "@app/providers/UserProvider";
|
|||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
type InvitationsPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -18,6 +19,7 @@ export const dynamic = "force-dynamic";
|
|||
|
||||
export default async function InvitationsPage(props: InvitationsPageProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
@ -66,7 +68,7 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
|
|||
id: invite.inviteId,
|
||||
email: invite.email,
|
||||
expiresAt: new Date(Number(invite.expiresAt)).toISOString(),
|
||||
role: invite.roleName || "Unknown Role",
|
||||
role: invite.roleName || t('accessRoleUnknown'),
|
||||
roleId: invite.roleId
|
||||
};
|
||||
});
|
||||
|
@ -74,8 +76,8 @@ export default async function InvitationsPage(props: InvitationsPageProps) {
|
|||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Open Invitations"
|
||||
description="Manage your invitations to other users"
|
||||
title={t('inviteTitle')}
|
||||
description={t('inviteDescription')}
|
||||
/>
|
||||
<UserProvider user={user!}>
|
||||
<OrgProvider org={org}>
|
||||
|
|
|
@ -31,6 +31,7 @@ import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
|
|||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type CreateRoleFormProps = {
|
||||
open: boolean;
|
||||
|
@ -38,17 +39,18 @@ type CreateRoleFormProps = {
|
|||
afterCreate?: (res: CreateRoleResponse) => Promise<void>;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string({ message: "Name is required" }).max(32),
|
||||
description: z.string().max(255).optional()
|
||||
});
|
||||
|
||||
export default function CreateRoleForm({
|
||||
open,
|
||||
setOpen,
|
||||
afterCreate
|
||||
}: CreateRoleFormProps) {
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string({ message: t('nameRequired') }).max(32),
|
||||
description: z.string().max(255).optional()
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
@ -76,10 +78,10 @@ export default function CreateRoleForm({
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to create role",
|
||||
title: t('accessRoleErrorCreate'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while creating the role."
|
||||
t('accessRoleErrorCreateDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -87,8 +89,8 @@ export default function CreateRoleForm({
|
|||
if (res && res.status === 201) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "Role created",
|
||||
description: "The role has been successfully created."
|
||||
title: t('accessRoleCreated'),
|
||||
description: t('accessRoleCreatedDescription')
|
||||
});
|
||||
|
||||
if (open) {
|
||||
|
@ -115,10 +117,9 @@ export default function CreateRoleForm({
|
|||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Create Role</CredenzaTitle>
|
||||
<CredenzaTitle>{t('accessRoleCreate')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Create a new role to group users and manage their
|
||||
permissions.
|
||||
{t('accessRoleCreateDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
|
@ -133,7 +134,7 @@ export default function CreateRoleForm({
|
|||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role Name</FormLabel>
|
||||
<FormLabel>{t('accessRoleName')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
@ -146,7 +147,7 @@ export default function CreateRoleForm({
|
|||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormLabel>{t('description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
@ -159,7 +160,7 @@ export default function CreateRoleForm({
|
|||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
|
@ -167,7 +168,7 @@ export default function CreateRoleForm({
|
|||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Create Role
|
||||
{t('accessRoleCreateSubmit')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
|
|
@ -38,6 +38,7 @@ import { RoleRow } from "./RolesTable";
|
|||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type CreateRoleFormProps = {
|
||||
open: boolean;
|
||||
|
@ -46,10 +47,6 @@ type CreateRoleFormProps = {
|
|||
afterDelete?: () => void;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
newRoleId: z.string({ message: "New role is required" })
|
||||
});
|
||||
|
||||
export default function DeleteRoleForm({
|
||||
open,
|
||||
roleToDelete,
|
||||
|
@ -57,12 +54,17 @@ export default function DeleteRoleForm({
|
|||
afterDelete
|
||||
}: CreateRoleFormProps) {
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [roles, setRoles] = useState<ListRolesResponse["roles"]>([]);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const formSchema = z.object({
|
||||
newRoleId: z.string({ message: t('accessRoleErrorNewRequired') })
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchRoles() {
|
||||
const res = await api
|
||||
|
@ -73,10 +75,10 @@ export default function DeleteRoleForm({
|
|||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch roles",
|
||||
title: t('accessRoleErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching the roles"
|
||||
t('accessRoleErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -112,10 +114,10 @@ export default function DeleteRoleForm({
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to remove role",
|
||||
title: t('accessRoleErrorRemove'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while removing the role."
|
||||
t('accessRoleErrorRemoveDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -123,8 +125,8 @@ export default function DeleteRoleForm({
|
|||
if (res && res.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "Role removed",
|
||||
description: "The role has been successfully removed."
|
||||
title: t('accessRoleRemoved'),
|
||||
description: t('accessRoleRemovedDescription')
|
||||
});
|
||||
|
||||
if (open) {
|
||||
|
@ -151,22 +153,19 @@ export default function DeleteRoleForm({
|
|||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Remove Role</CredenzaTitle>
|
||||
<CredenzaTitle>{t('accessRoleRemove')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Remove a role from the organization
|
||||
{t('accessRoleRemoveDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
You're about to delete the{" "}
|
||||
<b>{roleToDelete.name}</b> role. You cannot
|
||||
undo this action.
|
||||
{t('accessRoleQuestionRemove', {name: roleToDelete.name})}
|
||||
</p>
|
||||
<p>
|
||||
Before deleting this role, please select a
|
||||
new role to transfer existing members to.
|
||||
{t('accessRoleRequiredRemove')}
|
||||
</p>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
|
@ -180,7 +179,7 @@ export default function DeleteRoleForm({
|
|||
name="newRoleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<FormLabel>{t('role')}</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
|
@ -189,7 +188,7 @@ export default function DeleteRoleForm({
|
|||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
<SelectValue placeholder={t('accessRoleSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
|
@ -215,7 +214,7 @@ export default function DeleteRoleForm({
|
|||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
|
@ -223,7 +222,7 @@ export default function DeleteRoleForm({
|
|||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Remove Role
|
||||
{t('accessRoleRemoveSubmit')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
@ -16,15 +17,18 @@ export function RolesDataTable<TData, TValue>({
|
|||
data,
|
||||
createRole
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Roles"
|
||||
searchPlaceholder="Search roles..."
|
||||
title={t('roles')}
|
||||
searchPlaceholder={t('accessRolesSearch')}
|
||||
searchColumn="name"
|
||||
onAdd={createRole}
|
||||
addButtonText="Add Role"
|
||||
addButtonText={t('accessRolesAdd')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import CreateRoleForm from "./CreateRoleForm";
|
|||
import DeleteRoleForm from "./DeleteRoleForm";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export type RoleRow = Role;
|
||||
|
||||
|
@ -38,6 +39,8 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||
|
||||
const { org } = useOrgContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const columns: ColumnDef<RoleRow>[] = [
|
||||
{
|
||||
id: "actions",
|
||||
|
@ -58,7 +61,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open menu
|
||||
{t('openMenu')}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
@ -71,7 +74,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
Delete Role
|
||||
{t('accessRoleDelete')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
@ -92,7 +95,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -100,7 +103,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
|
|||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description"
|
||||
header: t('description')
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import RolesTable, { RoleRow } from "./RolesTable";
|
|||
import { SidebarSettings } from "@app/components/SidebarSettings";
|
||||
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
type RolesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -62,12 +63,13 @@ export default async function RolesPage(props: RolesPageProps) {
|
|||
}
|
||||
|
||||
const roleRows: RoleRow[] = roles;
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage Roles"
|
||||
description="Configure roles to manage access to your organization"
|
||||
title={t('accessRolesManage')}
|
||||
description={t('accessRolesDescription')}
|
||||
/>
|
||||
<OrgProvider org={org}>
|
||||
<RolesTable roles={roleRows} />
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
@ -16,15 +17,18 @@ export function UsersDataTable<TData, TValue>({
|
|||
data,
|
||||
inviteUser
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Users"
|
||||
searchPlaceholder="Search users..."
|
||||
title={t('users')}
|
||||
searchPlaceholder={t('accessUsersSearch')}
|
||||
searchColumn="email"
|
||||
onAdd={inviteUser}
|
||||
addButtonText="Create User"
|
||||
addButtonText={t('accessUserCreate')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -20,6 +20,7 @@ import { formatAxiosError } from "@app/lib/api";
|
|||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export type UserRow = {
|
||||
id: string;
|
||||
|
@ -47,6 +48,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
const api = createApiClient(useEnvContext());
|
||||
const { user, updateUser } = useUserContext();
|
||||
const { org } = useOrgContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const columns: ColumnDef<UserRow>[] = [
|
||||
{
|
||||
|
@ -68,7 +70,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open menu
|
||||
{t('openMenu')}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
@ -79,7 +81,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
className="block w-full"
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
Manage User
|
||||
{t('accessUsersManage')}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
{`${userRow.username}-${userRow.idpId}` !==
|
||||
|
@ -95,7 +97,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
Remove User
|
||||
{t('accessUserRemove')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
@ -118,7 +120,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Username
|
||||
{t('username')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -134,7 +136,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Identity Provider
|
||||
{t('identityProvider')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -150,7 +152,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Role
|
||||
{t('role')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -179,7 +181,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
variant="ghost"
|
||||
className="opacity-0 cursor-default"
|
||||
>
|
||||
Placeholder
|
||||
{t('placeholder')}
|
||||
</Button>
|
||||
)}
|
||||
{!userRow.isOwner && (
|
||||
|
@ -190,7 +192,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
variant={"outlinePrimary"}
|
||||
className="ml-2"
|
||||
>
|
||||
Manage
|
||||
{t('manage')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
@ -208,10 +210,10 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to remove user",
|
||||
title: t('userErrorOrgRemove'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while removing the user."
|
||||
t('userErrorOrgRemoveDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -219,8 +221,8 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
if (res && res.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "User removed",
|
||||
description: `The user ${selectedUser.email} has been removed from the organization.`
|
||||
title: t('userOrgRemoved'),
|
||||
description: t('userOrgRemovedDescription', {email: selectedUser.email || ""})
|
||||
});
|
||||
|
||||
setUsers((prev) =>
|
||||
|
@ -242,29 +244,19 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to remove{" "}
|
||||
<b>
|
||||
{selectedUser?.email ||
|
||||
selectedUser?.name ||
|
||||
selectedUser?.username}
|
||||
</b>{" "}
|
||||
from the organization?
|
||||
{t('userQuestionOrgRemove', {email: selectedUser?.email || selectedUser?.name || selectedUser?.username || ""})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Once removed, this user will no longer have access
|
||||
to the organization. You can always re-invite them
|
||||
later, but they will need to accept the invitation
|
||||
again.
|
||||
{t('userMessageOrgRemove')}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the name of the of the user
|
||||
below.
|
||||
{t('userMessageOrgConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Remove User"
|
||||
buttonText={t('userRemoveOrgConfirm')}
|
||||
onConfirm={removeUser}
|
||||
string={
|
||||
selectedUser?.email ||
|
||||
|
@ -272,7 +264,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
selectedUser?.username ||
|
||||
""
|
||||
}
|
||||
title="Remove User from Organization"
|
||||
title={t('userRemoveOrg')}
|
||||
/>
|
||||
|
||||
<UsersDataTable
|
||||
|
|
|
@ -40,11 +40,7 @@ import {
|
|||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z.string(),
|
||||
roleId: z.string().min(1, { message: "Please select a role" })
|
||||
});
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function AccessControlsPage() {
|
||||
const { orgUser: user } = userOrgUserContext();
|
||||
|
@ -56,6 +52,13 @@ export default function AccessControlsPage() {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z.string(),
|
||||
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') })
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
|
@ -72,10 +75,10 @@ export default function AccessControlsPage() {
|
|||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch roles",
|
||||
title: t('accessRoleErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching the roles"
|
||||
t('accessRoleErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -100,10 +103,10 @@ export default function AccessControlsPage() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to add user to role",
|
||||
title: t('accessRoleErrorAdd'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while adding user to the role."
|
||||
t('accessRoleErrorAddDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -111,8 +114,8 @@ export default function AccessControlsPage() {
|
|||
if (res && res.status === 200) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "User saved",
|
||||
description: "The user has been updated."
|
||||
title: t('userSaved'),
|
||||
description: t('userSavedDescription')
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -123,10 +126,9 @@ export default function AccessControlsPage() {
|
|||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>Access Controls</SettingsSectionTitle>
|
||||
<SettingsSectionTitle>{t('accessControls')}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Manage what this user can access and do in the
|
||||
organization
|
||||
{t('accessControlsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
|
@ -143,14 +145,14 @@ export default function AccessControlsPage() {
|
|||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<FormLabel>{t('role')}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
<SelectValue placeholder={t('accessRoleSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
|
@ -180,7 +182,7 @@ export default function AccessControlsPage() {
|
|||
disabled={loading}
|
||||
form="access-controls-form"
|
||||
>
|
||||
Save Access Controls
|
||||
{t('accessControlsSubmit')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
import Link from "next/link";
|
||||
import { cache } from "react";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
interface UserLayoutProps {
|
||||
children: React.ReactNode;
|
||||
|
@ -26,6 +27,8 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
|||
|
||||
const { children } = props;
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
let user = null;
|
||||
try {
|
||||
const getOrgUser = cache(async () =>
|
||||
|
@ -42,7 +45,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
|||
|
||||
const navItems = [
|
||||
{
|
||||
title: "Access Controls",
|
||||
title: t('accessControls'),
|
||||
href: "/{orgId}/settings/access/users/{userId}/access-controls"
|
||||
}
|
||||
];
|
||||
|
@ -51,7 +54,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
|||
<>
|
||||
<SettingsSectionTitle
|
||||
title={`${user?.email}`}
|
||||
description="Manage the settings on this user"
|
||||
description={t('userDescription2')}
|
||||
/>
|
||||
<OrgUserProvider orgUser={user}>
|
||||
<HorizontalTabs items={navItems}>
|
||||
|
|
|
@ -44,6 +44,7 @@ import { formatAxiosError } from "@app/lib/api";
|
|||
import { createApiClient } from "@app/lib/api";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { ListIdpsResponse } from "@server/routers/idp";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type UserType = "internal" | "oidc";
|
||||
|
||||
|
@ -59,38 +60,12 @@ interface IdpOption {
|
|||
type: string;
|
||||
}
|
||||
|
||||
const internalFormSchema = z.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
validForHours: z.string().min(1, { message: "Please select a duration" }),
|
||||
roleId: z.string().min(1, { message: "Please select a role" })
|
||||
});
|
||||
|
||||
const externalFormSchema = z.object({
|
||||
username: z.string().min(1, { message: "Username is required" }),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: "Invalid email address" })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
name: z.string().optional(),
|
||||
roleId: z.string().min(1, { message: "Please select a role" }),
|
||||
idpId: z.string().min(1, { message: "Please select an identity provider" })
|
||||
});
|
||||
|
||||
const formatIdpType = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case "oidc":
|
||||
return "Generic OAuth2/OIDC provider.";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { orgId } = useParams();
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
|
||||
const [userType, setUserType] = useState<UserType | null>("internal");
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
|
@ -102,14 +77,41 @@ export default function Page() {
|
|||
const [selectedIdp, setSelectedIdp] = useState<IdpOption | null>(null);
|
||||
const [dataLoaded, setDataLoaded] = useState(false);
|
||||
|
||||
const internalFormSchema = z.object({
|
||||
email: z.string().email({ message: t('emailInvalid') }),
|
||||
validForHours: z.string().min(1, { message: t('inviteValidityDuration') }),
|
||||
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') })
|
||||
});
|
||||
|
||||
const externalFormSchema = z.object({
|
||||
username: z.string().min(1, { message: t('usernameRequired') }),
|
||||
email: z
|
||||
.string()
|
||||
.email({ message: t('emailInvalid') })
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
name: z.string().optional(),
|
||||
roleId: z.string().min(1, { message: t('accessRoleSelectPlease') }),
|
||||
idpId: z.string().min(1, { message: t('idpSelectPlease') })
|
||||
});
|
||||
|
||||
const formatIdpType = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case "oidc":
|
||||
return t('idpGenericOidc');
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
const validFor = [
|
||||
{ hours: 24, name: "1 day" },
|
||||
{ hours: 48, name: "2 days" },
|
||||
{ hours: 72, name: "3 days" },
|
||||
{ hours: 96, name: "4 days" },
|
||||
{ hours: 120, name: "5 days" },
|
||||
{ hours: 144, name: "6 days" },
|
||||
{ hours: 168, name: "7 days" }
|
||||
{ hours: 24, name: t('day', {count: 1}) },
|
||||
{ hours: 48, name: t('day', {count: 2}) },
|
||||
{ hours: 72, name: t('day', {count: 3}) },
|
||||
{ hours: 96, name: t('day', {count: 4}) },
|
||||
{ hours: 120, name: t('day', {count: 5}) },
|
||||
{ hours: 144, name: t('day', {count: 6}) },
|
||||
{ hours: 168, name: t('day', {count: 7}) }
|
||||
];
|
||||
|
||||
const internalForm = useForm<z.infer<typeof internalFormSchema>>({
|
||||
|
@ -155,10 +157,10 @@ export default function Page() {
|
|||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch roles",
|
||||
title: t('accessRoleErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching the roles"
|
||||
t('accessRoleErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -178,10 +180,10 @@ export default function Page() {
|
|||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch identity providers",
|
||||
title: t('idpErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching identity providers"
|
||||
t('idpErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -218,17 +220,16 @@ export default function Page() {
|
|||
if (e.response?.status === 409) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "User Already Exists",
|
||||
description:
|
||||
"This user is already a member of the organization."
|
||||
title: t('userErrorExists'),
|
||||
description: t('userErrorExistsDescription')
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to invite user",
|
||||
title: t('inviteError'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while inviting the user"
|
||||
t('inviteErrorDescription')
|
||||
)
|
||||
});
|
||||
}
|
||||
|
@ -238,8 +239,8 @@ export default function Page() {
|
|||
setInviteLink(res.data.data.inviteLink);
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "User invited",
|
||||
description: "The user has been successfully invited."
|
||||
title: t('userInvited'),
|
||||
description: t('userInvitedDescription')
|
||||
});
|
||||
|
||||
setExpiresInDays(parseInt(values.validForHours) / 24);
|
||||
|
@ -265,10 +266,10 @@ export default function Page() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to create user",
|
||||
title: t('userErrorCreate'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while creating the user"
|
||||
t('userErrorCreateDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -276,8 +277,8 @@ export default function Page() {
|
|||
if (res && res.status === 201) {
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "User created",
|
||||
description: "The user has been successfully created."
|
||||
title: t('userCreated'),
|
||||
description: t('userCreatedDescription')
|
||||
});
|
||||
router.push(`/${orgId}/settings/access/users`);
|
||||
}
|
||||
|
@ -288,13 +289,13 @@ export default function Page() {
|
|||
const userTypes: ReadonlyArray<UserTypeOption> = [
|
||||
{
|
||||
id: "internal",
|
||||
title: "Internal User",
|
||||
description: "Invite a user to join your organization directly."
|
||||
title: t('userTypeInternal'),
|
||||
description: t('userTypeInternalDescription')
|
||||
},
|
||||
{
|
||||
id: "oidc",
|
||||
title: "External User",
|
||||
description: "Create a user with an external identity provider."
|
||||
title: t('userTypeExternal'),
|
||||
description: t('userTypeExternalDescription')
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -302,8 +303,8 @@ export default function Page() {
|
|||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Create User"
|
||||
description="Follow the steps below to create a new user"
|
||||
title={t('accessUserCreate')}
|
||||
description={t('accessUserCreateDescription')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
@ -311,7 +312,7 @@ export default function Page() {
|
|||
router.push(`/${orgId}/settings/access/users`);
|
||||
}}
|
||||
>
|
||||
See All Users
|
||||
{t('userSeeAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
@ -320,10 +321,10 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
User Type
|
||||
{t('userTypeTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine how you want to create the user
|
||||
{t('userTypeDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -349,10 +350,10 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
User Information
|
||||
{t('userSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Enter the details for the new user
|
||||
{t('userSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -373,7 +374,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email
|
||||
{t('email')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -402,8 +403,7 @@ export default function Page() {
|
|||
htmlFor="send-email"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Send invite email to
|
||||
user
|
||||
{t('inviteEmailSent')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
@ -416,7 +416,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Valid For
|
||||
{t('inviteValid')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
|
@ -428,7 +428,7 @@ export default function Page() {
|
|||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select duration" />
|
||||
<SelectValue placeholder={t('selectDuration')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
|
@ -463,7 +463,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Role
|
||||
{t('role')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
|
@ -472,7 +472,7 @@ export default function Page() {
|
|||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
<SelectValue placeholder={t('accessRoleSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
|
@ -503,37 +503,16 @@ export default function Page() {
|
|||
<div className="max-w-md space-y-4">
|
||||
{sendEmail && (
|
||||
<p>
|
||||
An email has
|
||||
been sent to the
|
||||
user with the
|
||||
access link
|
||||
below. They must
|
||||
access the link
|
||||
to accept the
|
||||
invitation.
|
||||
{t('inviteEmailSentDescription')}
|
||||
</p>
|
||||
)}
|
||||
{!sendEmail && (
|
||||
<p>
|
||||
The user has
|
||||
been invited.
|
||||
They must access
|
||||
the link below
|
||||
to accept the
|
||||
invitation.
|
||||
{t('inviteSentDescription')}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
The invite will
|
||||
expire in{" "}
|
||||
<b>
|
||||
{expiresInDays}{" "}
|
||||
{expiresInDays ===
|
||||
1
|
||||
? "day"
|
||||
: "days"}
|
||||
</b>
|
||||
.
|
||||
{t('inviteExpiresIn', {days: expiresInDays})}
|
||||
</p>
|
||||
<CopyTextBox
|
||||
text={inviteLink}
|
||||
|
@ -554,20 +533,16 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Identity Provider
|
||||
{t('idpTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Select the identity provider for the
|
||||
external user
|
||||
{t('idpSelect')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{idps.length === 0 ? (
|
||||
<p className="text-muted-foreground">
|
||||
No identity providers are
|
||||
configured. Please configure an
|
||||
identity provider before creating
|
||||
external users.
|
||||
{t('idpNotConfigured')}
|
||||
</p>
|
||||
) : (
|
||||
<Form {...externalForm}>
|
||||
|
@ -621,10 +596,10 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
User Information
|
||||
{t('userSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Enter the details for the new user
|
||||
{t('userSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -645,7 +620,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Username
|
||||
{t('username')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -653,15 +628,7 @@ export default function Page() {
|
|||
/>
|
||||
</FormControl>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This must
|
||||
match the
|
||||
unique
|
||||
username
|
||||
that exists
|
||||
in the
|
||||
selected
|
||||
identity
|
||||
provider.
|
||||
{t('usernameUniq')}
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -676,8 +643,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email
|
||||
(Optional)
|
||||
{t('emailOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -697,8 +663,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
(Optional)
|
||||
{t('nameOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -718,7 +683,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Role
|
||||
{t('role')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
|
@ -727,7 +692,7 @@ export default function Page() {
|
|||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
<SelectValue placeholder={t('accessRoleSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
|
@ -771,7 +736,7 @@ export default function Page() {
|
|||
router.push(`/${orgId}/settings/access/users`);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
{userType && dataLoaded && (
|
||||
<Button
|
||||
|
@ -783,7 +748,7 @@ export default function Page() {
|
|||
(userType === "internal" && inviteLink !== null)
|
||||
}
|
||||
>
|
||||
Create User
|
||||
{t('accessUserCreate')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -10,6 +10,7 @@ import UserProvider from "@app/providers/UserProvider";
|
|||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
type UsersPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -22,6 +23,7 @@ export default async function UsersPage(props: UsersPageProps) {
|
|||
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
const t = await getTranslations();
|
||||
|
||||
let users: ListUsersResponse["users"] = [];
|
||||
let hasInvitations = false;
|
||||
|
@ -76,9 +78,9 @@ export default async function UsersPage(props: UsersPageProps) {
|
|||
email: user.email,
|
||||
type: user.type,
|
||||
idpId: user.idpId,
|
||||
idpName: user.idpName || "Internal",
|
||||
status: "Confirmed",
|
||||
role: user.isOwner ? "Owner" : user.roleName || "Member",
|
||||
idpName: user.idpName || t('idpNameInternal'),
|
||||
status: t('userConfirmed'),
|
||||
role: user.isOwner ? t('accessRoleOwner') : user.roleName || t('accessRoleMember'),
|
||||
isOwner: user.isOwner || false
|
||||
};
|
||||
});
|
||||
|
@ -86,8 +88,8 @@ export default async function UsersPage(props: UsersPageProps) {
|
|||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage Users"
|
||||
description="Invite users and add them to roles to manage access to your organization"
|
||||
title={t('accessUsersManage')}
|
||||
description={t('accessUsersDescription')}
|
||||
/>
|
||||
<UserProvider user={user!}>
|
||||
<OrgProvider org={org}>
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
@ -14,15 +15,18 @@ export function OrgApiKeysDataTable<TData, TValue>({
|
|||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="API Keys"
|
||||
searchPlaceholder="Search API keys..."
|
||||
title={t('apiKeys')}
|
||||
searchPlaceholder={t('searchApiKeys')}
|
||||
searchColumn="name"
|
||||
onAdd={addApiKey}
|
||||
addButtonText="Generate API Key"
|
||||
addButtonText={t('apiKeysAdd')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import { formatAxiosError } from "@app/lib/api";
|
|||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import moment from "moment";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type OrgApiKeyRow = {
|
||||
id: string;
|
||||
|
@ -44,14 +45,16 @@ export default function OrgApiKeysTable({
|
|||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const deleteSite = (apiKeyId: string) => {
|
||||
api.delete(`/org/${orgId}/api-key/${apiKeyId}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting API key", e);
|
||||
console.error(t('apiKeysErrorDelete'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting API key",
|
||||
description: formatAxiosError(e, "Error deleting API key")
|
||||
title: t('apiKeysErrorDelete'),
|
||||
description: formatAxiosError(e, t('apiKeysErrorDeleteMessage'))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
|
@ -75,7 +78,7 @@ export default function OrgApiKeysTable({
|
|||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="sr-only">{t('openMenu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
@ -85,7 +88,7 @@ export default function OrgApiKeysTable({
|
|||
setSelected(apiKeyROw);
|
||||
}}
|
||||
>
|
||||
<span>View settings</span>
|
||||
<span>{t('viewSettings')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
|
@ -93,7 +96,7 @@ export default function OrgApiKeysTable({
|
|||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span className="text-red-500">{t('delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
@ -110,7 +113,7 @@ export default function OrgApiKeysTable({
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -118,7 +121,7 @@ export default function OrgApiKeysTable({
|
|||
},
|
||||
{
|
||||
accessorKey: "key",
|
||||
header: "Key",
|
||||
header: t('key'),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span className="font-mono">{r.key}</span>;
|
||||
|
@ -126,7 +129,7 @@ export default function OrgApiKeysTable({
|
|||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
header: t('createdAt'),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span>{moment(r.createdAt).format("lll")} </span>;
|
||||
|
@ -140,7 +143,7 @@ export default function OrgApiKeysTable({
|
|||
<div className="flex items-center justify-end">
|
||||
<Link href={`/${orgId}/settings/api-keys/${r.id}`}>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
Edit
|
||||
{t('edit')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
@ -162,28 +165,24 @@ export default function OrgApiKeysTable({
|
|||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to remove the API key{" "}
|
||||
<b>{selected?.name || selected?.id}</b> from the
|
||||
organization?
|
||||
{t('apiKeysQuestionRemove', {selectedApiKey: selected?.name || selected?.id})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>
|
||||
Once removed, the API key will no longer be
|
||||
able to be used.
|
||||
{t('apiKeysMessageRemove')}
|
||||
</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the name of the API key
|
||||
below.
|
||||
{t('apiKeysMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete API Key"
|
||||
buttonText={t('apiKeysDeleteConfirm')}
|
||||
onConfirm={async () => deleteSite(selected!.id)}
|
||||
string={selected.name}
|
||||
title="Delete API Key"
|
||||
title={t('apiKeysDelete')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
import { GetApiKeyResponse } from "@server/routers/apiKeys";
|
||||
import ApiKeyProvider from "@app/providers/ApiKeyProvider";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
|
@ -23,6 +24,7 @@ interface SettingsLayoutProps {
|
|||
|
||||
export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
const { children } = props;
|
||||
|
||||
|
@ -40,14 +42,14 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
|
||||
const navItems = [
|
||||
{
|
||||
title: "Permissions",
|
||||
title: t('apiKeysPermissionsTitle'),
|
||||
href: "/{orgId}/settings/api-keys/{apiKeyId}/permissions"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle title={`${apiKey?.name} Settings`} />
|
||||
<SettingsSectionTitle title={t('apiKeysSettings', {apiKeyName: apiKey?.name})} />
|
||||
|
||||
<ApiKeyProvider apiKey={apiKey}>
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
|
|
|
@ -18,12 +18,15 @@ import { ListApiKeyActionsResponse } from "@server/routers/apiKeys";
|
|||
import { AxiosResponse } from "axios";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { orgId, apiKeyId } = useParams();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState<boolean>(true);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||
Record<string, boolean>
|
||||
|
@ -42,10 +45,10 @@ export default function Page() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error loading API key actions",
|
||||
title: t('apiKeysPermissionsErrorLoadingActions'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"Error loading API key actions"
|
||||
t('apiKeysPermissionsErrorLoadingActions')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -76,18 +79,18 @@ export default function Page() {
|
|||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Error setting permissions", e);
|
||||
console.error(t('apiKeysErrorSetPermission'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error setting permissions",
|
||||
title: t('apiKeysErrorSetPermission'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
|
||||
if (actionsRes && actionsRes.status === 200) {
|
||||
toast({
|
||||
title: "Permissions updated",
|
||||
description: "The permissions have been updated."
|
||||
title: t('apiKeysPermissionsUpdated'),
|
||||
description: t('apiKeysPermissionsUpdatedDescription')
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -101,10 +104,10 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Permissions
|
||||
{t('apiKeysPermissionsGeneralSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine what this API key can do
|
||||
{t('apiKeysPermissionsGeneralSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -121,7 +124,7 @@ export default function Page() {
|
|||
loading={loadingSavePermissions}
|
||||
disabled={loadingSavePermissions}
|
||||
>
|
||||
Save Permissions
|
||||
{t('apiKeysPermissionsSave')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSectionBody>
|
||||
|
|
|
@ -55,41 +55,14 @@ import moment from "moment";
|
|||
import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
||||
|
||||
const createFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Name must be at least 2 characters."
|
||||
})
|
||||
.max(255, {
|
||||
message: "Name must not be longer than 255 characters."
|
||||
})
|
||||
});
|
||||
|
||||
type CreateFormValues = z.infer<typeof createFormSchema>;
|
||||
|
||||
const copiedFormSchema = z
|
||||
.object({
|
||||
copied: z.boolean()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
return data.copied;
|
||||
},
|
||||
{
|
||||
message: "You must confirm that you have copied the API key.",
|
||||
path: ["copied"]
|
||||
}
|
||||
);
|
||||
|
||||
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { orgId } = useParams();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
|
@ -98,6 +71,35 @@ export default function Page() {
|
|||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const createFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: t('nameMin', {len: 2})
|
||||
})
|
||||
.max(255, {
|
||||
message: t('nameMax', {len: 255})
|
||||
})
|
||||
});
|
||||
|
||||
type CreateFormValues = z.infer<typeof createFormSchema>;
|
||||
|
||||
const copiedFormSchema = z
|
||||
.object({
|
||||
copied: z.boolean()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
return data.copied;
|
||||
},
|
||||
{
|
||||
message: t('apiKeysConfirmCopy2'),
|
||||
path: ["copied"]
|
||||
}
|
||||
);
|
||||
|
||||
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
|
||||
|
||||
const form = useForm<CreateFormValues>({
|
||||
resolver: zodResolver(createFormSchema),
|
||||
defaultValues: {
|
||||
|
@ -126,7 +128,7 @@ export default function Page() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating API key",
|
||||
title: t('apiKeysErrorCreate'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
|
@ -147,10 +149,10 @@ export default function Page() {
|
|||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Error setting permissions", e);
|
||||
console.error(t('apiKeysErrorSetPermission'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error setting permissions",
|
||||
title: t('apiKeysErrorSetPermission'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
|
@ -189,8 +191,8 @@ export default function Page() {
|
|||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Generate API Key"
|
||||
description="Generate a new API key for your organization"
|
||||
title={t('apiKeysCreate')}
|
||||
description={t('apiKeysCreateDescription')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
@ -198,7 +200,7 @@ export default function Page() {
|
|||
router.push(`/${orgId}/settings/api-keys`);
|
||||
}}
|
||||
>
|
||||
See All API Keys
|
||||
{t('apiKeysSeeAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
@ -210,7 +212,7 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
API Key Information
|
||||
{t('apiKeysTitle')}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -226,7 +228,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
{t('name')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -247,10 +249,10 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Permissions
|
||||
{t('apiKeysGeneralSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine what this API key can do
|
||||
{t('apiKeysGeneralSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -269,14 +271,14 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Your API Key
|
||||
{t('apiKeysList')}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={2}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Name
|
||||
{t('name')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
|
@ -286,7 +288,7 @@ export default function Page() {
|
|||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Created
|
||||
{t('created')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{moment(
|
||||
|
@ -299,17 +301,15 @@ export default function Page() {
|
|||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Save Your API Key
|
||||
{t('apiKeysSave')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will only be able to see this
|
||||
once. Make sure to copy it to a
|
||||
secure place.
|
||||
{t('apiKeysSaveDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<h4 className="font-semibold">
|
||||
Your API key is:
|
||||
{t('apiKeysInfo')}
|
||||
</h4>
|
||||
|
||||
<CopyTextBox
|
||||
|
@ -347,8 +347,7 @@ export default function Page() {
|
|||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have copied
|
||||
the API key
|
||||
{t('apiKeysConfirmCopy')}
|
||||
</label>
|
||||
</div>
|
||||
<FormMessage />
|
||||
|
@ -372,7 +371,7 @@ export default function Page() {
|
|||
router.push(`/${orgId}/settings/api-keys`);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{!apiKey && (
|
||||
|
@ -384,7 +383,7 @@ export default function Page() {
|
|||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
Generate
|
||||
{t('generate')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
@ -395,7 +394,7 @@ export default function Page() {
|
|||
copiedForm.handleSubmit(onCopiedSubmit)();
|
||||
}}
|
||||
>
|
||||
Done
|
||||
{t('done')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -4,6 +4,7 @@ import { AxiosResponse } from "axios";
|
|||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import OrgApiKeysTable, { OrgApiKeyRow } from "./OrgApiKeysTable";
|
||||
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
type ApiKeyPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -13,6 +14,8 @@ export const dynamic = "force-dynamic";
|
|||
|
||||
export default async function ApiKeysPage(props: ApiKeyPageProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
let apiKeys: ListOrgApiKeysResponse["apiKeys"] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListOrgApiKeysResponse>>(
|
||||
|
@ -34,8 +37,8 @@ export default async function ApiKeysPage(props: ApiKeyPageProps) {
|
|||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage API Keys"
|
||||
description="API keys are used to authenticate with the integration API"
|
||||
title={t('apiKeysManage')}
|
||||
description={t('apiKeysDescription')}
|
||||
/>
|
||||
|
||||
<OrgApiKeysTable apiKeys={rows} orgId={params.orgId} />
|
||||
|
|
|
@ -10,6 +10,7 @@ import { GetOrgUserResponse } from "@server/routers/user";
|
|||
import { AxiosResponse } from "axios";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
type GeneralSettingsProps = {
|
||||
children: React.ReactNode;
|
||||
|
@ -57,9 +58,11 @@ export default async function GeneralSettingsPage({
|
|||
redirect(`/${orgId}`);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: "General",
|
||||
title: t('general'),
|
||||
href: `/{orgId}/settings/general`,
|
||||
},
|
||||
];
|
||||
|
@ -69,8 +72,8 @@ export default async function GeneralSettingsPage({
|
|||
<OrgProvider org={org}>
|
||||
<OrgUserProvider orgUser={orgUser}>
|
||||
<SettingsSectionTitle
|
||||
title="General"
|
||||
description="Configure your organization's general settings"
|
||||
title={t('general')}
|
||||
description={t('orgSettingsDescription')}
|
||||
/>
|
||||
|
||||
<HorizontalTabs items={navItems}>
|
||||
|
|
|
@ -35,6 +35,7 @@ import {
|
|||
SettingsSectionFooter
|
||||
} from "@app/components/Settings";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
// Updated schema to include subnet field
|
||||
const GeneralFormSchema = z.object({
|
||||
|
@ -51,6 +52,7 @@ export default function GeneralPage() {
|
|||
const { org } = useOrgContext();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { user } = useUserContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loadingDelete, setLoadingDelete] = useState(false);
|
||||
const [loadingSave, setLoadingSave] = useState(false);
|
||||
|
@ -71,8 +73,8 @@ export default function GeneralPage() {
|
|||
`/org/${org?.org.orgId}`
|
||||
);
|
||||
toast({
|
||||
title: "Organization deleted",
|
||||
description: "The organization and its data has been deleted."
|
||||
title: t('orgDeleted'),
|
||||
description: t('orgDeletedMessage')
|
||||
});
|
||||
if (res.status === 200) {
|
||||
pickNewOrgAndNavigate();
|
||||
|
@ -81,11 +83,8 @@ export default function GeneralPage() {
|
|||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to delete org",
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while deleting the org."
|
||||
)
|
||||
title: t('orgErrorDelete'),
|
||||
description: formatAxiosError(err, t('orgErrorDeleteMessage'))
|
||||
});
|
||||
} finally {
|
||||
setLoadingDelete(false);
|
||||
|
@ -112,11 +111,8 @@ export default function GeneralPage() {
|
|||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch orgs",
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while listing your orgs"
|
||||
)
|
||||
title: t('orgErrorFetch'),
|
||||
description: formatAxiosError(err, t('orgErrorFetchMessage'))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -130,19 +126,16 @@ export default function GeneralPage() {
|
|||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Organization updated",
|
||||
description: "The organization has been updated."
|
||||
title: t('orgUpdated'),
|
||||
description: t('orgUpdatedDescription')
|
||||
});
|
||||
router.refresh();
|
||||
})
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update org",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the org."
|
||||
)
|
||||
title: t('orgErrorUpdate'),
|
||||
description: formatAxiosError(e, t('orgErrorUpdateMessage'))
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
|
@ -160,30 +153,28 @@ export default function GeneralPage() {
|
|||
dialog={
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
Are you sure you want to delete the organization{" "}
|
||||
<b>{org?.org.name}?</b>
|
||||
{t('orgQuestionRemove', {selectedOrg: org?.org.name})}
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
This action is irreversible and will delete all
|
||||
associated data.
|
||||
{t('orgMessageRemove')}
|
||||
</p>
|
||||
<p>
|
||||
To confirm, type the name of the organization below.
|
||||
{t('orgMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete Organization"
|
||||
buttonText={t('orgDeleteConfirm')}
|
||||
onConfirm={deleteOrg}
|
||||
string={org?.org.name || ""}
|
||||
title="Delete Organization"
|
||||
title={t('orgDelete')}
|
||||
/>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Organization Settings
|
||||
{t('orgGeneralSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Manage your organization details and configuration
|
||||
{t('orgGeneralSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -199,14 +190,13 @@ export default function GeneralPage() {
|
|||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t('name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display name of the
|
||||
organization.
|
||||
{t('orgDisplayName')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -242,16 +232,15 @@ export default function GeneralPage() {
|
|||
loading={loadingSave}
|
||||
disabled={loadingSave}
|
||||
>
|
||||
Save General Settings
|
||||
{t('saveGeneralSettings')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>Danger Zone</SettingsSectionTitle>
|
||||
<SettingsSectionTitle>{t('orgDangerZone')}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Once you delete this org, there is no going back. Please
|
||||
be certain.
|
||||
{t('orgDangerZoneDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionFooter>
|
||||
|
@ -262,7 +251,7 @@ export default function GeneralPage() {
|
|||
loading={loadingDelete}
|
||||
disabled={loadingDelete}
|
||||
>
|
||||
Delete Organization Data
|
||||
{t('orgDelete')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
|
|
@ -19,6 +19,7 @@ import UserProvider from "@app/providers/UserProvider";
|
|||
import { Layout } from "@app/components/Layout";
|
||||
import { SidebarNavItem, SidebarNavProps } from "@app/components/SidebarNav";
|
||||
import { orgNavItems } from "@app/app/navigation";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
@ -79,6 +80,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
|
||||
const cookie = await authCookieHeader();
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
try {
|
||||
const getOrgUser = cache(() =>
|
||||
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
||||
|
@ -89,7 +92,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
const orgUser = await getOrgUser();
|
||||
|
||||
if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) {
|
||||
throw new Error("User is not an admin or owner");
|
||||
throw new Error(t('userErrorNotAdminOrOwner'));
|
||||
}
|
||||
} catch {
|
||||
redirect(`/${params.orgId}`);
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
@ -14,15 +15,18 @@ export function ResourcesDataTable<TData, TValue>({
|
|||
data,
|
||||
createResource
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Resources"
|
||||
searchPlaceholder="Search resources..."
|
||||
title={t('resources')}
|
||||
searchPlaceholder={t('resourcesSearch')}
|
||||
searchColumn="name"
|
||||
onAdd={createResource}
|
||||
addButtonText="Add Resource"
|
||||
addButtonText={t('resourceAdd')}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
|
|
|
@ -4,6 +4,7 @@ import React, { useState, useEffect } from "react";
|
|||
import { Server, Lock, Key, Users, X, ArrowRight } from "lucide-react"; // Replace with actual imports
|
||||
import { Card, CardContent } from "@app/components/ui/card";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const ResourcesSplashCard = () => {
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
@ -22,6 +23,8 @@ export const ResourcesSplashCard = () => {
|
|||
localStorage.setItem(key, "true");
|
||||
};
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
@ -31,7 +34,7 @@ export const ResourcesSplashCard = () => {
|
|||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 p-2"
|
||||
aria-label="Dismiss"
|
||||
aria-label={t('dismiss')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
@ -39,24 +42,23 @@ export const ResourcesSplashCard = () => {
|
|||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Server className="text-blue-500" />
|
||||
Resources
|
||||
{t('resources')}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
Resources are proxies to applications running on your private network. Create a resource for any HTTP/HTTPS or raw TCP/UDP service on your private network.
|
||||
Each resource must be connected to a site to enable private, secure connectivity through an encrypted WireGuard tunnel.
|
||||
{t('resourcesDescription')}
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<Lock className="text-green-500 w-4 h-4" />
|
||||
Secure connectivity with WireGuard encryption
|
||||
{t('resourcesWireGuardConnect')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Key className="text-yellow-500 w-4 h-4" />
|
||||
Configure multiple authentication methods
|
||||
{t('resourcesMultipleAuthenticationMethods')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Users className="text-purple-500 w-4 h-4" />
|
||||
User and role-based access control
|
||||
{t('resourcesUsersRolesAccess')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -31,6 +31,7 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
|
|||
import { Switch } from "@app/components/ui/switch";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export type ResourceRow = {
|
||||
id: number;
|
||||
|
@ -53,6 +54,7 @@ type ResourcesTableProps = {
|
|||
|
||||
export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
|
@ -63,11 +65,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
const deleteResource = (resourceId: number) => {
|
||||
api.delete(`/resource/${resourceId}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting resource", e);
|
||||
console.error(t('resourceErrorDelte'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting resource",
|
||||
description: formatAxiosError(e, "Error deleting resource")
|
||||
title: t('resourceErrorDelte'),
|
||||
description: formatAxiosError(e, t('resourceErrorDelte'))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
|
@ -87,11 +89,8 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to toggle resource",
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the resource"
|
||||
)
|
||||
title: t('resourcesErrorUpdate'),
|
||||
description: formatAxiosError(e, t('resourcesErrorUpdateDescription'))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -108,7 +107,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="sr-only">{t('openMenu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
@ -118,7 +117,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
View settings
|
||||
{t('viewSettings')}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
|
@ -127,7 +126,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span className="text-red-500">{t('delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
@ -144,7 +143,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -160,7 +159,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Site
|
||||
{t('site')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -181,7 +180,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
},
|
||||
{
|
||||
accessorKey: "protocol",
|
||||
header: "Protocol",
|
||||
header: t('protocol'),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return <span>{resourceRow.protocol.toUpperCase()}</span>;
|
||||
|
@ -189,7 +188,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
},
|
||||
{
|
||||
accessorKey: "domain",
|
||||
header: "Access",
|
||||
header: t('access'),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
|
@ -219,7 +218,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Authentication
|
||||
{t('authentication')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -231,12 +230,12 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
{resourceRow.authState === "protected" ? (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>Protected</span>
|
||||
<span>{t('protected')}</span>
|
||||
</span>
|
||||
) : resourceRow.authState === "not_protected" ? (
|
||||
<span className="text-yellow-500 flex items-center space-x-2">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<span>Not Protected</span>
|
||||
<span>{t('notProtected')}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>-</span>
|
||||
|
@ -247,7 +246,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: "Enabled",
|
||||
header: t('enabled'),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
|
@ -267,7 +266,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
|
||||
>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
Edit
|
||||
{t('edit')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
@ -289,30 +288,22 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
dialog={
|
||||
<div>
|
||||
<p className="mb-2">
|
||||
Are you sure you want to remove the resource{" "}
|
||||
<b>
|
||||
{selectedResource?.name ||
|
||||
selectedResource?.id}
|
||||
</b>{" "}
|
||||
from the organization?
|
||||
{t('resourceQuestionRemove', {selectedResource: selectedResource?.name || selectedResource?.id})}
|
||||
</p>
|
||||
|
||||
<p className="mb-2">
|
||||
Once removed, the resource will no longer be
|
||||
accessible. All targets attached to the resource
|
||||
will be removed.
|
||||
{t('resourceMessageRemove')}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the name of the resource
|
||||
below.
|
||||
{t('resourceMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete Resource"
|
||||
buttonText={t('resourceDeleteConfirm')}
|
||||
onConfirm={async () => deleteResource(selectedResource!.id)}
|
||||
string={selectedResource.name}
|
||||
title="Delete Resource"
|
||||
title={t('resourceDelete')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useDockerSocket } from "@app/hooks/useDockerSocket";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
|
@ -21,6 +22,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const { isEnabled, isAvailable } = useDockerSocket(site!);
|
||||
const t = useTranslations();
|
||||
|
||||
let fullUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
|
||||
|
@ -28,7 +30,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Resource Information
|
||||
{t('resourceInfo')}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections cols={4}>
|
||||
|
@ -36,7 +38,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Authentication
|
||||
{t('authentication')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{authInfo.password ||
|
||||
|
@ -45,12 +47,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
authInfo.whitelist ? (
|
||||
<div className="flex items-start space-x-2 text-green-500">
|
||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||
<span>Protected</span>
|
||||
<span>{t('protected')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 text-yellow-500">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<span>Not Protected</span>
|
||||
<span>{t('notProtected')}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
|
@ -65,7 +67,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Site</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t('site')}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.siteName}
|
||||
</InfoSectionContent>
|
||||
|
@ -92,7 +94,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
) : (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Protocol</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t('protocol')}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.protocol.toUpperCase()}
|
||||
|
@ -100,7 +102,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Port</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t('port')}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={resource.proxyPort!.toString()}
|
||||
|
@ -111,10 +113,10 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
|||
</>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Visibility</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t('visibility')}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<span>
|
||||
{resource.enabled ? "Enabled" : "Disabled"}
|
||||
{resource.enabled ? t('enabled') : t('disabled')}
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
|
|
@ -31,6 +31,7 @@ import { AxiosResponse } from "axios";
|
|||
import { Resource } from "@server/db";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const setPasswordFormSchema = z.object({
|
||||
password: z.string().min(4).max(100)
|
||||
|
@ -56,6 +57,7 @@ export default function SetResourcePasswordForm({
|
|||
onSetPassword
|
||||
}: SetPasswordFormProps) {
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
@ -81,18 +83,17 @@ export default function SetResourcePasswordForm({
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error setting resource password",
|
||||
title: t('resourceErrorPasswordSetup'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while setting the resource password"
|
||||
t('resourceErrorPasswordSetupDescription')
|
||||
)
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Resource password set",
|
||||
description:
|
||||
"The resource password has been set successfully"
|
||||
title: t('resourcePasswordSetup'),
|
||||
description: t('resourcePasswordSetupDescription')
|
||||
});
|
||||
|
||||
if (onSetPassword) {
|
||||
|
@ -114,9 +115,9 @@ export default function SetResourcePasswordForm({
|
|||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Set Password</CredenzaTitle>
|
||||
<CredenzaTitle>{t('resourcePasswordSetupTitle')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Set a password to protect this resource
|
||||
{t('resourcePasswordSetupTitleDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
|
@ -131,7 +132,7 @@ export default function SetResourcePasswordForm({
|
|||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel>{t('password')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
|
@ -148,7 +149,7 @@ export default function SetResourcePasswordForm({
|
|||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
|
@ -156,7 +157,7 @@ export default function SetResourcePasswordForm({
|
|||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Enable Password Protection
|
||||
{t('resourcePasswordSubmit')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
|
|
@ -36,6 +36,7 @@ import {
|
|||
} from "@app/components/ui/input-otp";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const setPincodeFormSchema = z.object({
|
||||
pincode: z.string().length(6)
|
||||
|
@ -69,6 +70,8 @@ export default function SetResourcePincodeForm({
|
|||
defaultValues
|
||||
});
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
|
@ -86,18 +89,17 @@ export default function SetResourcePincodeForm({
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error setting resource PIN code",
|
||||
title: t('resourceErrorPincodeSetup'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while setting the resource PIN code"
|
||||
t('resourceErrorPincodeSetupDescription')
|
||||
)
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Resource PIN code set",
|
||||
description:
|
||||
"The resource pincode has been set successfully"
|
||||
title: t('resourcePincodeSetup'),
|
||||
description: t('resourcePincodeSetupDescription')
|
||||
});
|
||||
|
||||
if (onSetPincode) {
|
||||
|
@ -119,9 +121,9 @@ export default function SetResourcePincodeForm({
|
|||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Set Pincode</CredenzaTitle>
|
||||
<CredenzaTitle>{t('resourcePincodeSetupTitle')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Set a pincode to protect this resource
|
||||
{t('resourcePincodeSetupTitleDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
|
@ -136,7 +138,7 @@ export default function SetResourcePincodeForm({
|
|||
name="pincode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>PIN Code</FormLabel>
|
||||
<FormLabel>{t('resourcePincode')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
|
@ -182,7 +184,7 @@ export default function SetResourcePincodeForm({
|
|||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
|
@ -190,7 +192,7 @@ export default function SetResourcePincodeForm({
|
|||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Enable PIN Code Protection
|
||||
{t('resourcePincodeSubmit')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
|
|
@ -48,6 +48,7 @@ import { useRouter } from "next/navigation";
|
|||
import { UserType } from "@server/types/UserTypes";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const UsersRolesFormSchema = z.object({
|
||||
roles: z.array(
|
||||
|
@ -82,6 +83,7 @@ export default function ResourceAuthenticationPage() {
|
|||
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
|
||||
|
@ -203,10 +205,10 @@ export default function ResourceAuthenticationPage() {
|
|||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch data",
|
||||
title: t('resourceErrorAuthFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching the data"
|
||||
t('resourceErrorAuthFetchDescription')
|
||||
)
|
||||
});
|
||||
}
|
||||
|
@ -233,18 +235,18 @@ export default function ResourceAuthenticationPage() {
|
|||
});
|
||||
|
||||
toast({
|
||||
title: "Saved successfully",
|
||||
description: "Whitelist settings have been saved"
|
||||
title: t('resourceWhitelistSave'),
|
||||
description: t('resourceWhitelistSaveDescription')
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to save whitelist",
|
||||
title: t('resourceErrorWhitelistSave'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while saving the whitelist"
|
||||
t('resourceErrorWhitelistSaveDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
|
@ -281,18 +283,18 @@ export default function ResourceAuthenticationPage() {
|
|||
});
|
||||
|
||||
toast({
|
||||
title: "Saved successfully",
|
||||
description: "Authentication settings have been saved"
|
||||
title: t('resourceAuthSettingsSave'),
|
||||
description: t('resourceAuthSettingsSaveDescription')
|
||||
});
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to set roles",
|
||||
title: t('resourceErrorUsersRolesSave'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while setting the roles"
|
||||
t('resourceErrorUsersRolesSaveDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
|
@ -308,9 +310,8 @@ export default function ResourceAuthenticationPage() {
|
|||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Resource password removed",
|
||||
description:
|
||||
"The resource password has been removed successfully"
|
||||
title: t('resourcePasswordRemove'),
|
||||
description: t('resourcePasswordRemoveDescription')
|
||||
});
|
||||
|
||||
updateAuthInfo({
|
||||
|
@ -321,10 +322,10 @@ export default function ResourceAuthenticationPage() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error removing resource password",
|
||||
title: t('resourceErrorPasswordRemove'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while removing the resource password"
|
||||
t('resourceErrorPasswordRemoveDescription')
|
||||
)
|
||||
});
|
||||
})
|
||||
|
@ -339,9 +340,8 @@ export default function ResourceAuthenticationPage() {
|
|||
})
|
||||
.then(() => {
|
||||
toast({
|
||||
title: "Resource pincode removed",
|
||||
description:
|
||||
"The resource password has been removed successfully"
|
||||
title: t('resourcePincodeRemove'),
|
||||
description: t('resourcePincodeRemoveDescription')
|
||||
});
|
||||
|
||||
updateAuthInfo({
|
||||
|
@ -352,10 +352,10 @@ export default function ResourceAuthenticationPage() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error removing resource pincode",
|
||||
title: t('resourceErrorPincodeRemove'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while removing the resource pincode"
|
||||
t('resourceErrorPincodeRemoveDescription')
|
||||
)
|
||||
});
|
||||
})
|
||||
|
@ -400,18 +400,17 @@ export default function ResourceAuthenticationPage() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Users & Roles
|
||||
{t('resourceUsersRoles')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure which users and roles can visit this
|
||||
resource
|
||||
{t('resourceUsersRolesDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SwitchInput
|
||||
id="sso-toggle"
|
||||
label="Use Platform SSO"
|
||||
description="Existing users will only have to log in once for all resources that have this enabled."
|
||||
label={t('ssoUse')}
|
||||
description={t('ssoUseDescription')}
|
||||
defaultChecked={resource.sso}
|
||||
onCheckedChange={(val) => setSsoEnabled(val)}
|
||||
/>
|
||||
|
@ -431,7 +430,7 @@ export default function ResourceAuthenticationPage() {
|
|||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>Roles</FormLabel>
|
||||
<FormLabel>{t('roles')}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
|
@ -441,7 +440,7 @@ export default function ResourceAuthenticationPage() {
|
|||
setActiveTagIndex={
|
||||
setActiveRolesTagIndex
|
||||
}
|
||||
placeholder="Select a role"
|
||||
placeholder={t('accessRoleSelect2')}
|
||||
size="sm"
|
||||
tags={
|
||||
usersRolesForm.getValues()
|
||||
|
@ -475,8 +474,7 @@ export default function ResourceAuthenticationPage() {
|
|||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Admins can always access
|
||||
this resource.
|
||||
{t('resourceRoleDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -486,7 +484,7 @@ export default function ResourceAuthenticationPage() {
|
|||
name="users"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col items-start">
|
||||
<FormLabel>Users</FormLabel>
|
||||
<FormLabel>{t('users')}</FormLabel>
|
||||
<FormControl>
|
||||
<TagInput
|
||||
{...field}
|
||||
|
@ -496,7 +494,7 @@ export default function ResourceAuthenticationPage() {
|
|||
setActiveTagIndex={
|
||||
setActiveUsersTagIndex
|
||||
}
|
||||
placeholder="Select a user"
|
||||
placeholder={t('accessUserSelect')}
|
||||
tags={
|
||||
usersRolesForm.getValues()
|
||||
.users
|
||||
|
@ -544,7 +542,7 @@ export default function ResourceAuthenticationPage() {
|
|||
disabled={loadingSaveUsersRoles}
|
||||
form="users-roles-form"
|
||||
>
|
||||
Save Users & Roles
|
||||
{t('resourceUsersRolesSubmit')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
@ -552,11 +550,10 @@ export default function ResourceAuthenticationPage() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Authentication Methods
|
||||
{t('resourceAuthMethods')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Allow access to the resource via additional auth
|
||||
methods
|
||||
{t('resourceAuthMethodsDescriptions')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -567,8 +564,7 @@ export default function ResourceAuthenticationPage() {
|
|||
>
|
||||
<Key />
|
||||
<span>
|
||||
Password Protection{" "}
|
||||
{authInfo.password ? "Enabled" : "Disabled"}
|
||||
{t('resourcePasswordProtection', {status: authInfo.password? t('enabled') : t('disabled')})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
|
@ -581,8 +577,8 @@ export default function ResourceAuthenticationPage() {
|
|||
loading={loadingRemoveResourcePassword}
|
||||
>
|
||||
{authInfo.password
|
||||
? "Remove Password"
|
||||
: "Add Password"}
|
||||
? t('passwordRemove')
|
||||
: t('passwordAdd')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
@ -593,8 +589,7 @@ export default function ResourceAuthenticationPage() {
|
|||
>
|
||||
<Binary />
|
||||
<span>
|
||||
PIN Code Protection{" "}
|
||||
{authInfo.pincode ? "Enabled" : "Disabled"}
|
||||
{t('resourcePincodeProtection', {status: authInfo.pincode ? t('enabled') : t('disabled')})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
|
@ -607,8 +602,8 @@ export default function ResourceAuthenticationPage() {
|
|||
loading={loadingRemoveResourcePincode}
|
||||
>
|
||||
{authInfo.pincode
|
||||
? "Remove PIN Code"
|
||||
: "Add PIN Code"}
|
||||
? t('pincodeRemove')
|
||||
: t('pincodeAdd')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
|
@ -617,11 +612,10 @@ export default function ResourceAuthenticationPage() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
One-time Passwords
|
||||
{t('otpEmailTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Require email-based authentication for resource
|
||||
access
|
||||
{t('otpEmailTitleDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -629,16 +623,16 @@ export default function ResourceAuthenticationPage() {
|
|||
<Alert variant="neutral" className="mb-4">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
SMTP Required
|
||||
{t('otpEmailSmtpRequired')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
SMTP must be enabled on the server to use one-time password authentication.
|
||||
{t('otpEmailSmtpRequiredDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<SwitchInput
|
||||
id="whitelist-toggle"
|
||||
label="Email Whitelist"
|
||||
label={t('otpEmailWhitelist')}
|
||||
defaultChecked={resource.emailWhitelistEnabled}
|
||||
onCheckedChange={setWhitelistEnabled}
|
||||
disabled={!env.email.emailEnabled}
|
||||
|
@ -654,8 +648,8 @@ export default function ResourceAuthenticationPage() {
|
|||
<FormItem>
|
||||
<FormLabel>
|
||||
<InfoPopup
|
||||
text="Whitelisted Emails"
|
||||
info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
|
||||
text={t('otpEmailWhitelistList')}
|
||||
info={t('otpEmailWhitelistListDescription')}
|
||||
/>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
|
@ -678,8 +672,7 @@ export default function ResourceAuthenticationPage() {
|
|||
.regex(
|
||||
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
|
||||
{
|
||||
message:
|
||||
"Invalid email address. Wildcard (*) must be the entire local part."
|
||||
message: t('otpEmailErrorInvalid')
|
||||
}
|
||||
)
|
||||
)
|
||||
|
@ -690,7 +683,7 @@ export default function ResourceAuthenticationPage() {
|
|||
setActiveTagIndex={
|
||||
setActiveEmailTagIndex
|
||||
}
|
||||
placeholder="Enter an email"
|
||||
placeholder={t('otpEmailEnter')}
|
||||
tags={
|
||||
whitelistForm.getValues()
|
||||
.emails
|
||||
|
@ -713,9 +706,7 @@ export default function ResourceAuthenticationPage() {
|
|||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Press enter to add an
|
||||
email after typing it in
|
||||
the input field.
|
||||
{t('otpEmailEnterDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -731,7 +722,7 @@ export default function ResourceAuthenticationPage() {
|
|||
loading={loadingSaveWhitelist}
|
||||
disabled={loadingSaveWhitelist}
|
||||
>
|
||||
Save Whitelist
|
||||
{t('otpEmailWhitelistSave')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
|
|
@ -65,51 +65,12 @@ import {
|
|||
updateResourceRule
|
||||
} from "@server/routers/resource";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
|
||||
const GeneralFormSchema = z
|
||||
.object({
|
||||
subdomain: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
proxyPort: z.number().optional(),
|
||||
http: z.boolean(),
|
||||
isBaseDomain: z.boolean().optional(),
|
||||
domainId: z.string().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.http) {
|
||||
return z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.safeParse(data.proxyPort).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid port number",
|
||||
path: ["proxyPort"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.http && !data.isBaseDomain) {
|
||||
return subdomainSchema.safeParse(data.subdomain).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Invalid subdomain",
|
||||
path: ["subdomain"]
|
||||
}
|
||||
);
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const TransferFormSchema = z.object({
|
||||
siteId: z.number()
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
type TransferFormValues = z.infer<typeof TransferFormSchema>;
|
||||
|
||||
export default function GeneralForm() {
|
||||
|
@ -118,6 +79,7 @@ export default function GeneralForm() {
|
|||
const { resource, updateResource } = useResourceContext();
|
||||
const { org } = useOrgContext();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
|
@ -138,6 +100,47 @@ export default function GeneralForm() {
|
|||
resource.isBaseDomain ? "basedomain" : "subdomain"
|
||||
);
|
||||
|
||||
const GeneralFormSchema = z
|
||||
.object({
|
||||
subdomain: z.string().optional(),
|
||||
name: z.string().min(1).max(255),
|
||||
proxyPort: z.number().optional(),
|
||||
http: z.boolean(),
|
||||
isBaseDomain: z.boolean().optional(),
|
||||
domainId: z.string().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.http) {
|
||||
return z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(65535)
|
||||
.safeParse(data.proxyPort).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t("proxyErrorInvalidPort"),
|
||||
path: ["proxyPort"]
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.http && !data.isBaseDomain) {
|
||||
return subdomainSchema.safeParse(data.subdomain).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t("subdomainErrorInvalid"),
|
||||
path: ["subdomain"]
|
||||
}
|
||||
);
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
||||
const form = useForm<GeneralFormValues>({
|
||||
resolver: zodResolver(GeneralFormSchema),
|
||||
defaultValues: {
|
||||
|
@ -174,10 +177,10 @@ export default function GeneralForm() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error fetching domains",
|
||||
title: t("domainErrorFetch"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred when fetching the domains"
|
||||
t("domainErrorFetchDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -216,18 +219,18 @@ export default function GeneralForm() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update resource",
|
||||
title: t("resourceErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the resource"
|
||||
t("resourceErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
title: "Resource updated",
|
||||
description: "The resource has been updated successfully"
|
||||
title: t("resourceUpdated"),
|
||||
description: t("resourceUpdatedDescription")
|
||||
});
|
||||
|
||||
const resource = res.data.data;
|
||||
|
@ -255,18 +258,18 @@ export default function GeneralForm() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to transfer resource",
|
||||
title: t("resourceErrorTransfer"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while transferring the resource"
|
||||
t("resourceErrorTransferDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
toast({
|
||||
title: "Resource transferred",
|
||||
description: "The resource has been transferred successfully"
|
||||
title: t("resourceTransferred"),
|
||||
description: t("resourceTransferredDescription")
|
||||
});
|
||||
router.refresh();
|
||||
|
||||
|
@ -290,10 +293,10 @@ export default function GeneralForm() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to toggle resource",
|
||||
title: t("resourceErrorToggle"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the resource"
|
||||
t("resourceErrorToggleDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -308,15 +311,17 @@ export default function GeneralForm() {
|
|||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>Visibility</SettingsSectionTitle>
|
||||
<SettingsSectionTitle>
|
||||
{t("resourceVisibilityTitle")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Completely enable or disable resource visibility
|
||||
{t("resourceVisibilityTitleDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SwitchInput
|
||||
id="enable-resource"
|
||||
label="Enable Resource"
|
||||
label={t("resourceEnable")}
|
||||
defaultChecked={resource.enabled}
|
||||
onCheckedChange={async (val) => {
|
||||
await toggleResourceEnabled(val);
|
||||
|
@ -328,10 +333,10 @@ export default function GeneralForm() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Settings
|
||||
{t("resourceGeneral")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the general settings for this resource
|
||||
{t("resourceGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
|
@ -348,7 +353,9 @@ export default function GeneralForm() {
|
|||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
@ -367,7 +374,9 @@ export default function GeneralForm() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Domain Type
|
||||
{t(
|
||||
"domainType"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={
|
||||
|
@ -398,11 +407,14 @@ export default function GeneralForm() {
|
|||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="subdomain">
|
||||
Subdomain
|
||||
{t(
|
||||
"subdomain"
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value="basedomain">
|
||||
Base
|
||||
Domain
|
||||
{t(
|
||||
"baseDomain"
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
@ -416,7 +428,7 @@ export default function GeneralForm() {
|
|||
{domainType === "subdomain" ? (
|
||||
<div className="w-fill space-y-2">
|
||||
<FormLabel>
|
||||
Subdomain
|
||||
{t("subdomain")}
|
||||
</FormLabel>
|
||||
<div className="flex">
|
||||
<div className="w-1/2">
|
||||
|
@ -502,7 +514,9 @@ export default function GeneralForm() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Base Domain
|
||||
{t(
|
||||
"baseDomain"
|
||||
)}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
|
@ -556,7 +570,9 @@ export default function GeneralForm() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Port Number
|
||||
{t(
|
||||
"resourcePortNumber"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -596,7 +612,7 @@ export default function GeneralForm() {
|
|||
disabled={saveLoading}
|
||||
form="general-settings-form"
|
||||
>
|
||||
Save General Settings
|
||||
{t("saveGeneralSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
@ -604,10 +620,10 @@ export default function GeneralForm() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Transfer Resource
|
||||
{t("resourceTransfer")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Transfer this resource to a different site
|
||||
{t("resourceTransferDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
|
@ -627,7 +643,7 @@ export default function GeneralForm() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Destination Site
|
||||
{t("siteDestination")}
|
||||
</FormLabel>
|
||||
<Popover
|
||||
open={open}
|
||||
|
@ -652,16 +668,24 @@ export default function GeneralForm() {
|
|||
site.siteId ===
|
||||
field.value
|
||||
)?.name
|
||||
: "Select site"}
|
||||
: t(
|
||||
"siteSelect"
|
||||
)}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search sites" />
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
"searchSites"
|
||||
)}
|
||||
/>
|
||||
<CommandEmpty>
|
||||
No sites found.
|
||||
{t(
|
||||
"sitesNotFound"
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map(
|
||||
|
@ -716,7 +740,7 @@ export default function GeneralForm() {
|
|||
disabled={transferLoading}
|
||||
form="transfer-form"
|
||||
>
|
||||
Transfer Resource
|
||||
{t("resourceTransferSubmit")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
|
|
@ -14,6 +14,7 @@ import OrgProvider from "@app/providers/OrgProvider";
|
|||
import { cache } from "react";
|
||||
import ResourceInfoBox from "./ResourceInfoBox";
|
||||
import { GetSiteResponse } from "@server/routers/site";
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
interface ResourceLayoutProps {
|
||||
children: React.ReactNode;
|
||||
|
@ -22,6 +23,7 @@ interface ResourceLayoutProps {
|
|||
|
||||
export default async function ResourceLayout(props: ResourceLayoutProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
const { children } = props;
|
||||
|
||||
|
@ -88,22 +90,22 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||
|
||||
const navItems = [
|
||||
{
|
||||
title: "General",
|
||||
title: t('general'),
|
||||
href: `/{orgId}/settings/resources/{resourceId}/general`
|
||||
},
|
||||
{
|
||||
title: "Proxy",
|
||||
title: t('proxy'),
|
||||
href: `/{orgId}/settings/resources/{resourceId}/proxy`
|
||||
}
|
||||
];
|
||||
|
||||
if (resource.http) {
|
||||
navItems.push({
|
||||
title: "Authentication",
|
||||
title: t('authentication'),
|
||||
href: `/{orgId}/settings/resources/{resourceId}/authentication`
|
||||
});
|
||||
navItems.push({
|
||||
title: "Rules",
|
||||
title: t('rules'),
|
||||
href: `/{orgId}/settings/resources/{resourceId}/rules`
|
||||
});
|
||||
}
|
||||
|
@ -111,8 +113,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
|
|||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={`${resource?.name} Settings`}
|
||||
description="Configure the settings on your resource"
|
||||
title={t('resourceSetting', {resourceName: resource?.name})}
|
||||
description={t('resourceSettingDescription')}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
|
|
|
@ -74,7 +74,7 @@ import {
|
|||
CollapsibleTrigger
|
||||
} from "@app/components/ui/collapsible";
|
||||
import { ContainersSelector } from "@app/components/ContainersSelector";
|
||||
import { FaDocker } from "react-icons/fa";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const addTargetSchema = z.object({
|
||||
ip: z.string().refine(isTargetValid),
|
||||
|
@ -94,51 +94,11 @@ type LocalTarget = Omit<
|
|||
"protocol"
|
||||
>;
|
||||
|
||||
const proxySettingsSchema = z.object({
|
||||
setHostHeader: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data) {
|
||||
return tlsNameSchema.safeParse(data).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
const tlsSettingsSchema = z.object({
|
||||
ssl: z.boolean(),
|
||||
tlsServerName: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data) {
|
||||
return tlsNameSchema.safeParse(data).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name."
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
type ProxySettingsValues = z.infer<typeof proxySettingsSchema>;
|
||||
type TlsSettingsValues = z.infer<typeof tlsSettingsSchema>;
|
||||
type TargetsSettingsValues = z.infer<typeof targetsSettingsSchema>;
|
||||
|
||||
export default function ReverseProxyTargets(props: {
|
||||
params: Promise<{ resourceId: number }>;
|
||||
}) {
|
||||
const params = use(props.params);
|
||||
const t = useTranslations();
|
||||
|
||||
const { resource, updateResource } = useResourceContext();
|
||||
|
||||
|
@ -156,6 +116,45 @@ export default function ReverseProxyTargets(props: {
|
|||
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const proxySettingsSchema = z.object({
|
||||
setHostHeader: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data) {
|
||||
return tlsNameSchema.safeParse(data).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t('proxyErrorInvalidHeader')
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
const tlsSettingsSchema = z.object({
|
||||
ssl: z.boolean(),
|
||||
tlsServerName: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data) {
|
||||
return tlsNameSchema.safeParse(data).success;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t('proxyErrorTls')
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
type ProxySettingsValues = z.infer<typeof proxySettingsSchema>;
|
||||
type TlsSettingsValues = z.infer<typeof tlsSettingsSchema>;
|
||||
type TargetsSettingsValues = z.infer<typeof targetsSettingsSchema>;
|
||||
|
||||
const addTargetForm = useForm({
|
||||
resolver: zodResolver(addTargetSchema),
|
||||
defaultValues: {
|
||||
|
@ -204,10 +203,10 @@ export default function ReverseProxyTargets(props: {
|
|||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch targets",
|
||||
title: t('targetErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while fetching targets"
|
||||
t('targetErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
|
@ -229,10 +228,10 @@ export default function ReverseProxyTargets(props: {
|
|||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch resource",
|
||||
title: t('siteErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while fetching resource"
|
||||
t('siteErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
}
|
||||
|
@ -252,8 +251,8 @@ export default function ReverseProxyTargets(props: {
|
|||
if (isDuplicate) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Duplicate target",
|
||||
description: "A target with these settings already exists"
|
||||
title: t('targetErrorDuplicate'),
|
||||
description: t('targetErrorDuplicateDescription')
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -265,8 +264,8 @@ export default function ReverseProxyTargets(props: {
|
|||
if (!isIPInSubnet(targetIp, subnet)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid target IP",
|
||||
description: "Target IP must be within the site subnet"
|
||||
title: t('targetWireGuardErrorInvalidIp'),
|
||||
description: t('targetWireGuardErrorInvalidIpDescription')
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -344,8 +343,8 @@ export default function ReverseProxyTargets(props: {
|
|||
updateResource({ stickySession: stickySessionData.stickySession });
|
||||
|
||||
toast({
|
||||
title: "Targets updated",
|
||||
description: "Targets and settings updated successfully"
|
||||
title: t('targetsUpdated'),
|
||||
description: t('targetsUpdatedDescription')
|
||||
});
|
||||
|
||||
setTargetsToRemove([]);
|
||||
|
@ -354,10 +353,10 @@ export default function ReverseProxyTargets(props: {
|
|||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update targets",
|
||||
title: t('targetsErrorUpdate'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while updating targets"
|
||||
t('targetsErrorUpdateDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
|
@ -378,17 +377,17 @@ export default function ReverseProxyTargets(props: {
|
|||
tlsServerName: data.tlsServerName || null
|
||||
});
|
||||
toast({
|
||||
title: "TLS settings updated",
|
||||
description: "Your TLS settings have been updated successfully"
|
||||
title: t('targetTlsUpdate'),
|
||||
description: t('targetTlsUpdateDescription')
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update TLS settings",
|
||||
title: t('targetErrorTlsUpdate'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while updating TLS settings"
|
||||
t('targetErrorTlsUpdateDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
|
@ -407,18 +406,17 @@ export default function ReverseProxyTargets(props: {
|
|||
setHostHeader: data.setHostHeader || null
|
||||
});
|
||||
toast({
|
||||
title: "Proxy settings updated",
|
||||
description:
|
||||
"Your proxy settings have been updated successfully"
|
||||
title: t('proxyUpdated'),
|
||||
description: t('proxyUpdatedDescription')
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update proxy settings",
|
||||
title: t('proxyErrorUpdate'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while updating proxy settings"
|
||||
t('proxyErrorUpdateDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
|
@ -429,7 +427,7 @@ export default function ReverseProxyTargets(props: {
|
|||
const columns: ColumnDef<LocalTarget>[] = [
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: "IP / Hostname",
|
||||
header: t('targetAddr'),
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
defaultValue={row.original.ip}
|
||||
|
@ -444,7 +442,7 @@ export default function ReverseProxyTargets(props: {
|
|||
},
|
||||
{
|
||||
accessorKey: "port",
|
||||
header: "Port",
|
||||
header: t('targetPort'),
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
type="number"
|
||||
|
@ -460,7 +458,7 @@ export default function ReverseProxyTargets(props: {
|
|||
},
|
||||
// {
|
||||
// accessorKey: "protocol",
|
||||
// header: "Protocol",
|
||||
// header: t('targetProtocol'),
|
||||
// cell: ({ row }) => (
|
||||
// <Select
|
||||
// defaultValue={row.original.protocol!}
|
||||
|
@ -478,7 +476,7 @@ export default function ReverseProxyTargets(props: {
|
|||
// },
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: "Enabled",
|
||||
header: t('enabled'),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
|
@ -505,7 +503,7 @@ export default function ReverseProxyTargets(props: {
|
|||
variant="outline"
|
||||
onClick={() => removeTarget(row.original.targetId)}
|
||||
>
|
||||
Delete
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
@ -516,7 +514,7 @@ export default function ReverseProxyTargets(props: {
|
|||
if (resource.http) {
|
||||
const methodCol: ColumnDef<LocalTarget> = {
|
||||
accessorKey: "method",
|
||||
header: "Method",
|
||||
header: t('method'),
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.method ?? ""}
|
||||
|
@ -564,10 +562,10 @@ export default function ReverseProxyTargets(props: {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Targets Configuration
|
||||
{t('targets')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Set up targets to route traffic to your services
|
||||
{t('targetsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -589,8 +587,8 @@ export default function ReverseProxyTargets(props: {
|
|||
<FormControl>
|
||||
<SwitchInput
|
||||
id="sticky-toggle"
|
||||
label="Enable Sticky Sessions"
|
||||
description="Keep connections on the same backend target for their entire session."
|
||||
label={t('targetStickySessions')}
|
||||
description={t('targetStickySessionsDescription')}
|
||||
defaultChecked={
|
||||
field.value
|
||||
}
|
||||
|
@ -621,7 +619,7 @@ export default function ReverseProxyTargets(props: {
|
|||
name="method"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Method</FormLabel>
|
||||
<FormLabel>{t('method')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={
|
||||
|
@ -638,7 +636,7 @@ export default function ReverseProxyTargets(props: {
|
|||
}}
|
||||
>
|
||||
<SelectTrigger id="method">
|
||||
<SelectValue placeholder="Select method" />
|
||||
<SelectValue placeholder={t('methodSelect')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">
|
||||
|
@ -664,7 +662,7 @@ export default function ReverseProxyTargets(props: {
|
|||
name="ip"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative">
|
||||
<FormLabel>IP / Hostname</FormLabel>
|
||||
<FormLabel>{t('targetAddr')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input id="ip" {...field} />
|
||||
</FormControl>
|
||||
|
@ -697,7 +695,7 @@ export default function ReverseProxyTargets(props: {
|
|||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormLabel>{t('targetPort')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="port"
|
||||
|
@ -716,7 +714,7 @@ export default function ReverseProxyTargets(props: {
|
|||
className="mt-6"
|
||||
disabled={!(watchedIp && watchedPort)}
|
||||
>
|
||||
Add Target
|
||||
{t('targetSubmit')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -760,14 +758,13 @@ export default function ReverseProxyTargets(props: {
|
|||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No targets. Add a target using the form.
|
||||
{t('targetNoOne')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
<TableCaption>
|
||||
Adding more than one target above will enable load
|
||||
balancing.
|
||||
{t('targetNoOneDescription')}
|
||||
</TableCaption>
|
||||
</Table>
|
||||
</SettingsSectionBody>
|
||||
|
@ -778,7 +775,7 @@ export default function ReverseProxyTargets(props: {
|
|||
disabled={targetsLoading}
|
||||
form="targets-settings-form"
|
||||
>
|
||||
Save Targets
|
||||
{t('targetsSubmit')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
@ -788,10 +785,10 @@ export default function ReverseProxyTargets(props: {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Secure Connection Configuration
|
||||
{t('targetTlsSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure SSL/TLS settings for your resource
|
||||
{t('targetTlsSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -812,7 +809,7 @@ export default function ReverseProxyTargets(props: {
|
|||
<FormControl>
|
||||
<SwitchInput
|
||||
id="ssl-toggle"
|
||||
label="Enable SSL (https)"
|
||||
label={t('proxyEnableSSL')}
|
||||
defaultChecked={
|
||||
field.value
|
||||
}
|
||||
|
@ -841,8 +838,7 @@ export default function ReverseProxyTargets(props: {
|
|||
className="p-0 flex items-center justify-start gap-2 w-full"
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Advanced TLS
|
||||
Settings
|
||||
{t('targetTlsSettingsAdvanced')}
|
||||
</p>
|
||||
<div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
|
@ -862,8 +858,7 @@ export default function ReverseProxyTargets(props: {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
TLS Server Name
|
||||
(SNI)
|
||||
{t('targetTlsSni')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -871,11 +866,7 @@ export default function ReverseProxyTargets(props: {
|
|||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The TLS Server
|
||||
Name to use for
|
||||
SNI. Leave empty
|
||||
to use the
|
||||
default.
|
||||
{t('targetTlsSniDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -893,18 +884,17 @@ export default function ReverseProxyTargets(props: {
|
|||
loading={httpsTlsLoading}
|
||||
form="tls-settings-form"
|
||||
>
|
||||
Save Settings
|
||||
{t('targetTlsSubmit')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Additional Proxy Settings
|
||||
{t('proxyAdditional')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure how your resource handles proxy
|
||||
settings
|
||||
{t('proxyAdditionalDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -923,16 +913,13 @@ export default function ReverseProxyTargets(props: {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Custom Host Header
|
||||
{t('proxyCustomHeader')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The host header to set
|
||||
when proxying requests.
|
||||
Leave empty to use the
|
||||
default.
|
||||
{t('proxyCustomHeaderDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -948,7 +935,7 @@ export default function ReverseProxyTargets(props: {
|
|||
loading={proxySettingsLoading}
|
||||
form="proxy-settings-form"
|
||||
>
|
||||
Save Settings
|
||||
{t('targetTlsSubmit')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
@ -963,8 +950,10 @@ function isIPInSubnet(subnet: string, ip: string): boolean {
|
|||
const [subnetIP, maskBits] = subnet.split("/");
|
||||
const mask = parseInt(maskBits);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
if (mask < 0 || mask > 32) {
|
||||
throw new Error("Invalid subnet mask. Must be between 0 and 32.");
|
||||
throw new Error(t('subnetMaskErrorInvalid'));
|
||||
}
|
||||
|
||||
// Convert IP addresses to binary numbers
|
||||
|
@ -981,15 +970,17 @@ function isIPInSubnet(subnet: string, ip: string): boolean {
|
|||
function ipToNumber(ip: string): number {
|
||||
// Validate IP address format
|
||||
const parts = ip.split(".");
|
||||
const t = useTranslations();
|
||||
|
||||
if (parts.length !== 4) {
|
||||
throw new Error("Invalid IP address format");
|
||||
throw new Error(t('ipAddressErrorInvalidFormat'));
|
||||
}
|
||||
|
||||
// Convert IP octets to 32-bit number
|
||||
return parts.reduce((num, octet) => {
|
||||
const oct = parseInt(octet);
|
||||
if (isNaN(oct) || oct < 0 || oct > 255) {
|
||||
throw new Error("Invalid IP address octet");
|
||||
throw new Error(t('ipAddressErrorInvalidOctet'));
|
||||
}
|
||||
return (num << 8) + oct;
|
||||
}, 0);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, use } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
@ -72,6 +73,7 @@ import {
|
|||
} from "@server/lib/validators";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// Schema for rule validation
|
||||
const addRuleSchema = z.object({
|
||||
|
@ -86,17 +88,6 @@ type LocalRule = ArrayElement<ListResourceRulesResponse["rules"]> & {
|
|||
updated?: boolean;
|
||||
};
|
||||
|
||||
enum RuleAction {
|
||||
ACCEPT = "Always Allow",
|
||||
DROP = "Always Deny"
|
||||
}
|
||||
|
||||
enum RuleMatch {
|
||||
PATH = "Path",
|
||||
IP = "IP",
|
||||
CIDR = "IP Range"
|
||||
}
|
||||
|
||||
export default function ResourceRules(props: {
|
||||
params: Promise<{ resourceId: number }>;
|
||||
}) {
|
||||
|
@ -109,6 +100,19 @@ export default function ResourceRules(props: {
|
|||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [rulesEnabled, setRulesEnabled] = useState(resource.applyRules);
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
|
||||
const RuleAction = {
|
||||
ACCEPT: t('alwaysAllow'),
|
||||
DROP: t('alwaysDeny')
|
||||
} as const;
|
||||
|
||||
const RuleMatch = {
|
||||
PATH: t('path'),
|
||||
IP: "IP",
|
||||
CIDR: t('ipAddressRange')
|
||||
} as const;
|
||||
|
||||
const addRuleForm = useForm({
|
||||
resolver: zodResolver(addRuleSchema),
|
||||
|
@ -132,10 +136,10 @@ export default function ResourceRules(props: {
|
|||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch rules",
|
||||
title: t('rulesErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while fetching rules"
|
||||
t('rulesErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
|
@ -156,8 +160,8 @@ export default function ResourceRules(props: {
|
|||
if (isDuplicate) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Duplicate rule",
|
||||
description: "A rule with these settings already exists"
|
||||
title: t('rulesErrorDuplicate'),
|
||||
description: t('rulesErrorDuplicateDescription')
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
@ -165,8 +169,8 @@ export default function ResourceRules(props: {
|
|||
if (data.match === "CIDR" && !isValidCIDR(data.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid CIDR",
|
||||
description: "Please enter a valid CIDR value"
|
||||
title: t('rulesErrorInvalidIpAddressRange'),
|
||||
description: t('rulesErrorInvalidIpAddressRangeDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
|
@ -174,8 +178,8 @@ export default function ResourceRules(props: {
|
|||
if (data.match === "PATH" && !isValidUrlGlobPattern(data.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid URL path",
|
||||
description: "Please enter a valid URL path value"
|
||||
title: t('rulesErrorInvalidUrl'),
|
||||
description: t('rulesErrorInvalidUrlDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
|
@ -183,8 +187,8 @@ export default function ResourceRules(props: {
|
|||
if (data.match === "IP" && !isValidIP(data.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid IP",
|
||||
description: "Please enter a valid IP address"
|
||||
title: t('rulesErrorInvalidIpAddress'),
|
||||
description: t('rulesErrorInvalidIpAddressDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
|
@ -239,10 +243,10 @@ export default function ResourceRules(props: {
|
|||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update rules",
|
||||
title: t('rulesErrorUpdate'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred while updating rules"
|
||||
t('rulesErrorUpdateDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -252,8 +256,8 @@ export default function ResourceRules(props: {
|
|||
updateResource({ applyRules: val });
|
||||
|
||||
toast({
|
||||
title: "Enable Rules",
|
||||
description: "Rule evaluation has been updated"
|
||||
title: t('rulesUpdated'),
|
||||
description: t('rulesUpdatedDescription')
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
|
@ -262,11 +266,11 @@ export default function ResourceRules(props: {
|
|||
function getValueHelpText(type: string) {
|
||||
switch (type) {
|
||||
case "CIDR":
|
||||
return "Enter an address in CIDR format (e.g., 103.21.244.0/22)";
|
||||
return t('rulesMatchIpAddressRangeDescription');
|
||||
case "IP":
|
||||
return "Enter an IP address (e.g., 103.21.244.12)";
|
||||
return t('rulesMatchIpAddress');
|
||||
case "PATH":
|
||||
return "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)";
|
||||
return t('rulesMatchUrl');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -285,8 +289,8 @@ export default function ResourceRules(props: {
|
|||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid CIDR",
|
||||
description: "Please enter a valid CIDR value"
|
||||
title: t('rulesErrorInvalidIpAddressRange'),
|
||||
description: t('rulesErrorInvalidIpAddressRangeDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
|
@ -297,8 +301,8 @@ export default function ResourceRules(props: {
|
|||
) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid URL path",
|
||||
description: "Please enter a valid URL path value"
|
||||
title: t('rulesErrorInvalidUrl'),
|
||||
description: t('rulesErrorInvalidUrlDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
|
@ -306,8 +310,8 @@ export default function ResourceRules(props: {
|
|||
if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid IP",
|
||||
description: "Please enter a valid IP address"
|
||||
title: t('rulesErrorInvalidIpAddress'),
|
||||
description: t('rulesErrorInvalidIpAddressDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
|
@ -316,8 +320,8 @@ export default function ResourceRules(props: {
|
|||
if (rule.priority === undefined) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid Priority",
|
||||
description: "Please enter a valid priority"
|
||||
title: t('rulesErrorInvalidPriority'),
|
||||
description: t('rulesErrorInvalidPriorityDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
|
@ -328,8 +332,8 @@ export default function ResourceRules(props: {
|
|||
if (priorities.length !== new Set(priorities).size) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Duplicate Priorities",
|
||||
description: "Please enter unique priorities"
|
||||
title: t('rulesErrorDuplicatePriority'),
|
||||
description: t('rulesErrorDuplicatePriorityDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
|
@ -368,8 +372,8 @@ export default function ResourceRules(props: {
|
|||
}
|
||||
|
||||
toast({
|
||||
title: "Rules updated",
|
||||
description: "Rules updated successfully"
|
||||
title: t('ruleUpdated'),
|
||||
description: t('ruleUpdatedDescription')
|
||||
});
|
||||
|
||||
setRulesToRemove([]);
|
||||
|
@ -378,10 +382,10 @@ export default function ResourceRules(props: {
|
|||
console.error(err);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Operation failed",
|
||||
title: t('ruleErrorUpdate'),
|
||||
description: formatAxiosError(
|
||||
err,
|
||||
"An error occurred during the save operation"
|
||||
t('ruleErrorUpdateDescription')
|
||||
)
|
||||
});
|
||||
}
|
||||
|
@ -399,7 +403,7 @@ export default function ResourceRules(props: {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Priority
|
||||
{t('rulesPriority')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -419,8 +423,8 @@ export default function ResourceRules(props: {
|
|||
if (!parsed.data) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid IP",
|
||||
description: "Please enter a valid priority"
|
||||
title: t('rulesErrorInvalidIpAddress'), // correct priority or IP?
|
||||
description: t('rulesErrorInvalidPriorityDescription')
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
|
@ -435,7 +439,7 @@ export default function ResourceRules(props: {
|
|||
},
|
||||
{
|
||||
accessorKey: "action",
|
||||
header: "Action",
|
||||
header: t('rulesAction'),
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.action}
|
||||
|
@ -457,7 +461,7 @@ export default function ResourceRules(props: {
|
|||
},
|
||||
{
|
||||
accessorKey: "match",
|
||||
header: "Match Type",
|
||||
header: t('rulesMatchType'),
|
||||
cell: ({ row }) => (
|
||||
<Select
|
||||
defaultValue={row.original.match}
|
||||
|
@ -478,7 +482,7 @@ export default function ResourceRules(props: {
|
|||
},
|
||||
{
|
||||
accessorKey: "value",
|
||||
header: "Value",
|
||||
header: t('value'),
|
||||
cell: ({ row }) => (
|
||||
<Input
|
||||
defaultValue={row.original.value}
|
||||
|
@ -493,7 +497,7 @@ export default function ResourceRules(props: {
|
|||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
header: "Enabled",
|
||||
header: t('enabled'),
|
||||
cell: ({ row }) => (
|
||||
<Switch
|
||||
defaultChecked={row.original.enabled}
|
||||
|
@ -511,7 +515,7 @@ export default function ResourceRules(props: {
|
|||
variant="outline"
|
||||
onClick={() => removeRule(row.original.ruleId)}
|
||||
>
|
||||
Delete
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
@ -541,46 +545,40 @@ export default function ResourceRules(props: {
|
|||
<SettingsContainer>
|
||||
<Alert className="hidden md:block">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">About Rules</AlertTitle>
|
||||
<AlertTitle className="font-semibold">{t('rulesAbout')}</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<div className="space-y-1 mb-4">
|
||||
<p>
|
||||
Rules allow you to control access to your resource
|
||||
based on a set of criteria. You can create rules to
|
||||
allow or deny access based on IP address or URL
|
||||
path.
|
||||
{t('rulesAboutDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<InfoSections cols={2}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Actions</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t('rulesActions')}</InfoSectionTitle>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="text-green-500 w-4 h-4" />
|
||||
Always Allow: Bypass all authentication
|
||||
methods
|
||||
{t('rulesActionAlwaysAllow')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<X className="text-red-500 w-4 h-4" />
|
||||
Always Deny: Block all requests; no
|
||||
authentication can be attempted
|
||||
{t('rulesActionAlwaysDeny')}
|
||||
</li>
|
||||
</ul>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Matching Criteria
|
||||
{t('rulesMatchCriteria')}
|
||||
</InfoSectionTitle>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li className="flex items-center gap-2">
|
||||
Match a specific IP address
|
||||
{t('rulesMatchCriteriaIpAddress')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
Match a range of IP addresses in CIDR
|
||||
notation
|
||||
{t('rulesMatchCriteriaIpAddressRange')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
Match a URL path or pattern
|
||||
{t('rulesMatchCriteriaUrl')}
|
||||
</li>
|
||||
</ul>
|
||||
</InfoSection>
|
||||
|
@ -590,15 +588,15 @@ export default function ResourceRules(props: {
|
|||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>Enable Rules</SettingsSectionTitle>
|
||||
<SettingsSectionTitle>{t('rulesEnable')}</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Enable or disable rule evaluation for this resource
|
||||
{t('rulesEnableDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SwitchInput
|
||||
id="rules-toggle"
|
||||
label="Enable Rules"
|
||||
label={t('rulesEnable')}
|
||||
defaultChecked={rulesEnabled}
|
||||
onCheckedChange={async (val) => {
|
||||
await saveApplyRules(val);
|
||||
|
@ -610,10 +608,10 @@ export default function ResourceRules(props: {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Resource Rules Configuration
|
||||
{t('rulesResource')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure rules to control access to your resource
|
||||
{t('rulesResourceDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -628,7 +626,7 @@ export default function ResourceRules(props: {
|
|||
name="action"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Action</FormLabel>
|
||||
<FormLabel>{t('rulesAction')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
|
@ -658,7 +656,7 @@ export default function ResourceRules(props: {
|
|||
name="match"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Match Type</FormLabel>
|
||||
<FormLabel>{t('rulesMatchType')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
|
@ -694,7 +692,7 @@ export default function ResourceRules(props: {
|
|||
render={({ field }) => (
|
||||
<FormItem className="space-y-0 mb-2">
|
||||
<InfoPopup
|
||||
text="Value"
|
||||
text={t('value')}
|
||||
info={
|
||||
getValueHelpText(
|
||||
addRuleForm.watch(
|
||||
|
@ -716,7 +714,7 @@ export default function ResourceRules(props: {
|
|||
className="mb-2"
|
||||
disabled={!rulesEnabled}
|
||||
>
|
||||
Add Rule
|
||||
{t('ruleSubmit')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -759,13 +757,13 @@ export default function ResourceRules(props: {
|
|||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No rules. Add a rule using the form.
|
||||
{t('rulesNoOne')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
<TableCaption>
|
||||
Rules are evaluated by priority in ascending order.
|
||||
{t('rulesOrder')}
|
||||
</TableCaption>
|
||||
</Table>
|
||||
</SettingsSectionBody>
|
||||
|
@ -775,7 +773,7 @@ export default function ResourceRules(props: {
|
|||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Save Rules
|
||||
{t('rulesSubmit')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
|
|
@ -62,6 +62,7 @@ import { cn } from "@app/lib/cn";
|
|||
import { SquareArrowOutUpRight } from "lucide-react";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const baseResourceFormSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
|
@ -104,6 +105,7 @@ export default function Page() {
|
|||
const api = createApiClient({ env });
|
||||
const { orgId } = useParams();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
|
||||
|
@ -117,15 +119,13 @@ export default function Page() {
|
|||
const resourceTypes: ReadonlyArray<ResourceTypeOption> = [
|
||||
{
|
||||
id: "http",
|
||||
title: "HTTPS Resource",
|
||||
description:
|
||||
"Proxy requests to your app over HTTPS using a subdomain or base domain."
|
||||
title: t('resourceHTTP'),
|
||||
description: t('resourceHTTPDescription')
|
||||
},
|
||||
{
|
||||
id: "raw",
|
||||
title: "Raw TCP/UDP Resource",
|
||||
description:
|
||||
"Proxy requests to your app over TCP/UDP using a port number.",
|
||||
title: t('resourceRaw'),
|
||||
description: t('resourceRawDescription'),
|
||||
disabled: !env.flags.allowRawResources
|
||||
}
|
||||
];
|
||||
|
@ -199,10 +199,10 @@ export default function Page() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating resource",
|
||||
title: t('resourceErrorCreate'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred when creating the resource"
|
||||
t('resourceErrorCreateDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -219,11 +219,11 @@ export default function Page() {
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error creating resource:", e);
|
||||
console.error(t('resourceErrorCreateMessage'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating resource",
|
||||
description: "An unexpected error occurred"
|
||||
title: t('resourceErrorCreate'),
|
||||
description:t('resourceErrorCreateMessageDescription')
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -242,10 +242,10 @@ export default function Page() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error fetching sites",
|
||||
title: t('sitesErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred when fetching the sites"
|
||||
t('sitesErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -270,10 +270,10 @@ export default function Page() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error fetching domains",
|
||||
title: t('domainsErrorFetch'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred when fetching the domains"
|
||||
t('domainsErrorFetchDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -300,8 +300,8 @@ export default function Page() {
|
|||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Create Resource"
|
||||
description="Follow the steps below to create a new resource"
|
||||
title={t('resourceCreate')}
|
||||
description={t('resourceCreateDescription')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
@ -309,7 +309,7 @@ export default function Page() {
|
|||
router.push(`/${orgId}/settings/resources`);
|
||||
}}
|
||||
>
|
||||
See All Resources
|
||||
{t('resourceSeeAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
@ -320,7 +320,7 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Resource Information
|
||||
{t('resourceInfo')}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -336,7 +336,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
{t('name')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -345,9 +345,7 @@ export default function Page() {
|
|||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the
|
||||
display name for
|
||||
the resource.
|
||||
{t('resourceNameDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -359,7 +357,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
Site
|
||||
{t('site')}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
|
@ -384,19 +382,17 @@ export default function Page() {
|
|||
field.value
|
||||
)
|
||||
?.name
|
||||
: "Select site"}
|
||||
: t('siteSelect')}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search site" />
|
||||
<CommandInput placeholder={t('siteSearch')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No
|
||||
site
|
||||
found.
|
||||
{t('siteNotFound')}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{sites.map(
|
||||
|
@ -437,10 +433,7 @@ export default function Page() {
|
|||
</Popover>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This site will
|
||||
provide
|
||||
connectivity to
|
||||
the resource.
|
||||
{t('siteSelectionDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -454,11 +447,10 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Resource Type
|
||||
{t('resourceType')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine how you want to access your
|
||||
resource
|
||||
{t('resourceTypeDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -480,11 +472,10 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
HTTPS Settings
|
||||
{t('resourceHTTPSSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure how your resource will be
|
||||
accessed over HTTPS
|
||||
{t('resourceHTTPSSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -506,8 +497,7 @@ export default function Page() {
|
|||
}) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Domain
|
||||
Type
|
||||
{t('domainType')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
value={
|
||||
|
@ -531,11 +521,10 @@ export default function Page() {
|
|||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="subdomain">
|
||||
Subdomain
|
||||
{t('subdomain')}
|
||||
</SelectItem>
|
||||
<SelectItem value="basedomain">
|
||||
Base
|
||||
Domain
|
||||
{t('baseDomain')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
@ -550,7 +539,7 @@ export default function Page() {
|
|||
) && (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Subdomain
|
||||
{t('subdomain')}
|
||||
</FormLabel>
|
||||
<div className="flex space-x-0">
|
||||
<div className="w-1/2">
|
||||
|
@ -629,10 +618,7 @@ export default function Page() {
|
|||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
The subdomain
|
||||
where your
|
||||
resource will be
|
||||
accessible.
|
||||
{t('subdomnainDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -650,8 +636,7 @@ export default function Page() {
|
|||
}) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Base
|
||||
Domain
|
||||
{t('baseDomain')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
|
@ -702,11 +687,10 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
TCP/UDP Settings
|
||||
{t('resourceRawSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure how your resource will be
|
||||
accessed over TCP/UDP
|
||||
{t('resourceRawSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -724,7 +708,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Protocol
|
||||
{t('protocol')}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
|
@ -734,7 +718,7 @@ export default function Page() {
|
|||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a protocol" />
|
||||
<SelectValue placeholder={t('protocolSelect')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
|
@ -759,7 +743,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Port Number
|
||||
{t('resourcePortNumber')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -787,10 +771,7 @@ export default function Page() {
|
|||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
The external
|
||||
port number
|
||||
to proxy
|
||||
requests.
|
||||
{t('resourcePortNumberDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -810,7 +791,7 @@ export default function Page() {
|
|||
router.push(`/${orgId}/settings/resources`)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
@ -827,7 +808,7 @@ export default function Page() {
|
|||
}}
|
||||
loading={createLoading}
|
||||
>
|
||||
Create Resource
|
||||
{t('resourceCreate')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
|
@ -836,17 +817,17 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Configuration Snippets
|
||||
{t('resourceConfig')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Copy and paste these configuration snippets to set up your TCP/UDP resource
|
||||
{t('resourceConfigDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Traefik: Add Entrypoints
|
||||
{t('resourceAddEntrypoints')}
|
||||
</h3>
|
||||
<CopyTextBox
|
||||
text={`entryPoints:
|
||||
|
@ -858,7 +839,7 @@ export default function Page() {
|
|||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
Gerbil: Expose Ports in Docker Compose
|
||||
{t('resourceExposePorts')}
|
||||
</h3>
|
||||
<CopyTextBox
|
||||
text={`ports:
|
||||
|
@ -874,7 +855,7 @@ export default function Page() {
|
|||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>
|
||||
Learn how to configure TCP/UDP resources
|
||||
{t('resourceLearnRaw')}
|
||||
</span>
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</Link>
|
||||
|
@ -890,7 +871,7 @@ export default function Page() {
|
|||
router.push(`/${orgId}/settings/resources`)
|
||||
}
|
||||
>
|
||||
Back to Resources
|
||||
{t('resourceBack')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
@ -900,7 +881,7 @@ export default function Page() {
|
|||
)
|
||||
}
|
||||
>
|
||||
Go to Resource
|
||||
{t('resourceGoTo')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsContainer>
|
||||
|
|
|
@ -9,6 +9,7 @@ import { cache } from "react";
|
|||
import { GetOrgResponse } from "@server/routers/org";
|
||||
import OrgProvider from "@app/providers/OrgProvider";
|
||||
import ResourcesSplashCard from "./ResourcesSplashCard";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type ResourcesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -18,6 +19,8 @@ export const dynamic = "force-dynamic";
|
|||
|
||||
export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
|
||||
let resources: ListResourcesResponse["resources"] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
|
||||
|
@ -51,8 +54,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
|||
name: resource.name,
|
||||
orgId: params.orgId,
|
||||
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
|
||||
site: resource.siteName || "None",
|
||||
siteId: resource.siteId || "Unknown",
|
||||
site: resource.siteName || t('none'),
|
||||
siteId: resource.siteId || t('unknown'),
|
||||
protocol: resource.protocol,
|
||||
proxyPort: resource.proxyPort,
|
||||
http: resource.http,
|
||||
|
@ -73,8 +76,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
|
|||
{/* <ResourcesSplashCard /> */}
|
||||
|
||||
<SettingsSectionTitle
|
||||
title="Manage Resources"
|
||||
description="Create secure proxies to your private applications"
|
||||
title={t('resourceTitle')}
|
||||
description={t('resourceDescription')}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
|
|
|
@ -15,6 +15,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface AccessTokenSectionProps {
|
||||
token: string;
|
||||
|
@ -37,37 +38,37 @@ export default function AccessTokenSection({
|
|||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start space-x-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your access token can be passed in two ways: as a query
|
||||
parameter or in the request headers. These must be passed
|
||||
from the client on every request for authenticated access.
|
||||
{t('shareTokenDescription')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="token" className="w-full mt-4">
|
||||
<TabsList className="grid grid-cols-2">
|
||||
<TabsTrigger value="token">Access Token</TabsTrigger>
|
||||
<TabsTrigger value="usage">Usage Examples</TabsTrigger>
|
||||
<TabsTrigger value="token">{t('accessToken')}</TabsTrigger>
|
||||
<TabsTrigger value="usage">{t('usageExamples')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="token" className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold">Token ID</div>
|
||||
<div className="font-bold">{t('tokenId')}</div>
|
||||
<CopyToClipboard text={tokenId} isLink={false} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="font-bold">Token</div>
|
||||
<div className="font-bold">{t('token')}</div>
|
||||
<CopyToClipboard text={token} isLink={false} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="usage" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Request Headers</h3>
|
||||
<h3 className="text-sm font-medium">{t('requestHeades')}</h3>
|
||||
<CopyTextBox
|
||||
text={`${env.server.resourceAccessTokenHeadersId}: ${tokenId}
|
||||
${env.server.resourceAccessTokenHeadersToken}: ${token}`}
|
||||
|
@ -75,7 +76,7 @@ ${env.server.resourceAccessTokenHeadersToken}: ${token}`}
|
|||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Query Parameter</h3>
|
||||
<h3 className="text-sm font-medium">{t('queryParameter')}</h3>
|
||||
<CopyTextBox
|
||||
text={`${resourceUrl}?${env.server.resourceAccessTokenParam}=${tokenId}.${token}`}
|
||||
/>
|
||||
|
@ -84,21 +85,17 @@ ${env.server.resourceAccessTokenHeadersToken}: ${token}`}
|
|||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Important Note
|
||||
{t('importantNote')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
For security reasons, using headers is recommended
|
||||
over query parameters when possible, as query
|
||||
parameters may be logged in server logs or browser
|
||||
history.
|
||||
{t('shareImportantDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="text-sm text-muted-foreground mt-4">
|
||||
Keep your access token secure. Do not share it in publicly
|
||||
accessible areas or client-side code.
|
||||
{t('shareTokenSecurety')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -66,6 +66,7 @@ import {
|
|||
CollapsibleTrigger
|
||||
} from "@app/components/ui/collapsible";
|
||||
import AccessTokenSection from "./AccessTokenUsage";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type FormProps = {
|
||||
open: boolean;
|
||||
|
@ -73,15 +74,6 @@ type FormProps = {
|
|||
onCreated?: (result: ShareLinkRow) => void;
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
resourceId: z.number({ message: "Please select a resource" }),
|
||||
resourceName: z.string(),
|
||||
resourceUrl: z.string(),
|
||||
timeUnit: z.string(),
|
||||
timeValue: z.coerce.number().int().positive().min(1),
|
||||
title: z.string().optional()
|
||||
});
|
||||
|
||||
export default function CreateShareLinkForm({
|
||||
open,
|
||||
setOpen,
|
||||
|
@ -99,6 +91,7 @@ export default function CreateShareLinkForm({
|
|||
const [neverExpire, setNeverExpire] = useState(false);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
const [resources, setResources] = useState<
|
||||
{
|
||||
|
@ -109,13 +102,22 @@ export default function CreateShareLinkForm({
|
|||
}[]
|
||||
>([]);
|
||||
|
||||
const formSchema = z.object({
|
||||
resourceId: z.number({ message: t('shareErrorSelectResource') }),
|
||||
resourceName: z.string(),
|
||||
resourceUrl: z.string(),
|
||||
timeUnit: z.string(),
|
||||
timeValue: z.coerce.number().int().positive().min(1),
|
||||
title: z.string().optional()
|
||||
});
|
||||
|
||||
const timeUnits = [
|
||||
{ unit: "minutes", name: "Minutes" },
|
||||
{ unit: "hours", name: "Hours" },
|
||||
{ unit: "days", name: "Days" },
|
||||
{ unit: "weeks", name: "Weeks" },
|
||||
{ unit: "months", name: "Months" },
|
||||
{ unit: "years", name: "Years" }
|
||||
{ unit: "minutes", name: t('minutes') },
|
||||
{ unit: "hours", name: t('hours') },
|
||||
{ unit: "days", name: t('days') },
|
||||
{ unit: "weeks", name: t('weeks') },
|
||||
{ unit: "months", name: t('months') },
|
||||
{ unit: "years", name: t('years') }
|
||||
];
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
|
@ -141,10 +143,10 @@ export default function CreateShareLinkForm({
|
|||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to fetch resources",
|
||||
title: t('shareErrorFetchResource'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while fetching the resources"
|
||||
t('shareErrorFetchResourceDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -201,17 +203,17 @@ export default function CreateShareLinkForm({
|
|||
validForSeconds: neverExpire ? undefined : timeInSeconds,
|
||||
title:
|
||||
values.title ||
|
||||
`${values.resourceName || "Resource" + values.resourceId} Share Link`
|
||||
t('shareLink', {resource: (values.resourceName || "Resource" + values.resourceId)})
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to create share link",
|
||||
title: t('shareErrorCreate'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while creating the share link"
|
||||
t('shareErrorCreateDescription')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -260,9 +262,9 @@ export default function CreateShareLinkForm({
|
|||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Create Shareable Link</CredenzaTitle>
|
||||
<CredenzaTitle>{t('shareCreate')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Anyone with this link can access the resource
|
||||
{t('shareCreateDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
|
@ -280,7 +282,7 @@ export default function CreateShareLinkForm({
|
|||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>
|
||||
Resource
|
||||
{t('resource')}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
|
@ -298,19 +300,17 @@ export default function CreateShareLinkForm({
|
|||
? getSelectedResourceName(
|
||||
field.value
|
||||
)
|
||||
: "Select resource"}
|
||||
: t('resourceSelect')}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search resources" />
|
||||
<CommandInput placeholder={t('resourceSearch')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No
|
||||
resources
|
||||
found
|
||||
{t('resourcesNotFound')}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{resources.map(
|
||||
|
@ -366,7 +366,7 @@ export default function CreateShareLinkForm({
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Title (optional)
|
||||
{t('shareTitleOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
|
@ -378,7 +378,7 @@ export default function CreateShareLinkForm({
|
|||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Expire In</FormLabel>
|
||||
<FormLabel>{t('expireIn')}</FormLabel>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
@ -393,7 +393,7 @@ export default function CreateShareLinkForm({
|
|||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select duration" />
|
||||
<SelectValue placeholder={t('selectDuration')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
|
@ -455,18 +455,12 @@ export default function CreateShareLinkForm({
|
|||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Never expire
|
||||
{t('neverExpire')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Expiration time is how long the
|
||||
link will be usable and provide
|
||||
access to the resource. After
|
||||
this time, the link will no
|
||||
longer work, and users who used
|
||||
this link will lose access to
|
||||
the resource.
|
||||
{t('shareExpireDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -475,12 +469,10 @@ export default function CreateShareLinkForm({
|
|||
{link && (
|
||||
<div className="max-w-md space-y-4">
|
||||
<p>
|
||||
You will only be able to see this link
|
||||
once. Make sure to copy it.
|
||||
{t('shareSeeOnce')}
|
||||
</p>
|
||||
<p>
|
||||
Anyone with this link can access the
|
||||
resource. Share it with care.
|
||||
{t('shareAccessHint')}
|
||||
</p>
|
||||
|
||||
<div className="h-[250px] w-full mx-auto flex items-center justify-center">
|
||||
|
@ -506,12 +498,12 @@ export default function CreateShareLinkForm({
|
|||
className="p-0 flex items-center justify-between w-full"
|
||||
>
|
||||
<h4 className="text-sm font-semibold">
|
||||
See Access Token Usage
|
||||
{t('shareTokenUsage')}
|
||||
</h4>
|
||||
<div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
Toggle
|
||||
{t('toggle')}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
@ -541,7 +533,7 @@ export default function CreateShareLinkForm({
|
|||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="button"
|
||||
|
@ -549,7 +541,7 @@ export default function CreateShareLinkForm({
|
|||
loading={loading}
|
||||
disabled={link !== null || loading}
|
||||
>
|
||||
Create Link
|
||||
{t('createLink')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
@ -16,15 +17,18 @@ export function ShareLinksDataTable<TData, TValue>({
|
|||
data,
|
||||
createShareLink
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Share Links"
|
||||
searchPlaceholder="Search share links..."
|
||||
title={t('shareLinks')}
|
||||
searchPlaceholder={t('shareSearch')}
|
||||
searchColumn="name"
|
||||
onAdd={createShareLink}
|
||||
addButtonText="Create Share Link"
|
||||
addButtonText={t('shareCreate')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import React, { useState, useEffect } from "react";
|
|||
import { Link, X, Clock, Share, ArrowRight, Lock } from "lucide-react"; // Replace with actual imports
|
||||
import { Card, CardContent } from "@app/components/ui/card";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const ShareableLinksSplash = () => {
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
@ -22,6 +23,8 @@ export const ShareableLinksSplash = () => {
|
|||
localStorage.setItem(key, "true");
|
||||
};
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
if (isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
@ -31,7 +34,7 @@ export const ShareableLinksSplash = () => {
|
|||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 p-2"
|
||||
aria-label="Dismiss"
|
||||
aria-label={t('dismiss')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
@ -39,26 +42,23 @@ export const ShareableLinksSplash = () => {
|
|||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Link className="text-blue-500" />
|
||||
Shareable Links
|
||||
{t('share')}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
Create shareable links to your resources. Links provide
|
||||
temporary or unlimited access to your resource. You can
|
||||
configure the expiration duration of the link when you
|
||||
create one.
|
||||
{t('shareDescription2')}
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<Share className="text-green-500 w-4 h-4" />
|
||||
Easy to create and share
|
||||
{t('shareEasyCreate')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Clock className="text-yellow-500 w-4 h-4" />
|
||||
Configurable expiration duration
|
||||
{t('shareConfigurableExpirationDuration')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Lock className="text-red-500 w-4 h-4" />
|
||||
Secure and revocable
|
||||
{t('shareSecureAndRevocable')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -33,6 +33,7 @@ import { ListAccessTokensResponse } from "@server/routers/accessToken";
|
|||
import moment from "moment";
|
||||
import CreateShareLinkForm from "./CreateShareLinkForm";
|
||||
import { constructShareLink } from "@app/lib/shareLinks";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type ShareLinkRow = {
|
||||
accessTokenId: string;
|
||||
|
@ -54,6 +55,7 @@ export default function ShareLinksTable({
|
|||
orgId
|
||||
}: ShareLinksTableProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
|
@ -67,10 +69,10 @@ export default function ShareLinksTable({
|
|||
async function deleteSharelink(id: string) {
|
||||
await api.delete(`/access-token/${id}`).catch((e) => {
|
||||
toast({
|
||||
title: "Failed to delete link",
|
||||
title: t('shareErrorDelete'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred deleting link"
|
||||
t('shareErrorDeleteMessage')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -79,8 +81,8 @@ export default function ShareLinksTable({
|
|||
setRows(newRows);
|
||||
|
||||
toast({
|
||||
title: "Link deleted",
|
||||
description: "The link has been deleted"
|
||||
title: t('shareDeleted'),
|
||||
description: t('shareDeletedDescription')
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -102,7 +104,7 @@ export default function ShareLinksTable({
|
|||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open menu
|
||||
{t('openMenu')}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
|
@ -116,7 +118,7 @@ export default function ShareLinksTable({
|
|||
}}
|
||||
>
|
||||
<button className="text-red-500">
|
||||
Delete
|
||||
{t('delete')}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
@ -136,7 +138,7 @@ export default function ShareLinksTable({
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Resource
|
||||
{t('resource')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -164,7 +166,7 @@ export default function ShareLinksTable({
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Title
|
||||
{t('title')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -243,7 +245,7 @@ export default function ShareLinksTable({
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Created
|
||||
{t('created')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -263,7 +265,7 @@ export default function ShareLinksTable({
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Expires
|
||||
{t('expires')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -273,7 +275,7 @@ export default function ShareLinksTable({
|
|||
if (r.expiresAt) {
|
||||
return moment(r.expiresAt).format("lll");
|
||||
}
|
||||
return "Never";
|
||||
return t('never');
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -286,7 +288,7 @@ export default function ShareLinksTable({
|
|||
deleteSharelink(row.original.accessTokenId)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -9,6 +9,7 @@ import OrgProvider from "@app/providers/OrgProvider";
|
|||
import { ListAccessTokensResponse } from "@server/routers/accessToken";
|
||||
import ShareLinksTable, { ShareLinkRow } from "./ShareLinksTable";
|
||||
import ShareableLinksSplash from "./ShareLinksSplash";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type ShareLinksPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -51,13 +52,15 @@ export default async function ShareLinksPage(props: ShareLinksPageProps) {
|
|||
(token) => ({ ...token }) as ShareLinkRow
|
||||
);
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* <ShareableLinksSplash /> */}
|
||||
|
||||
<SettingsSectionTitle
|
||||
title="Manage Share Links"
|
||||
description="Create shareable links to grant temporary or permanent access to your resources"
|
||||
title={t('shareTitle')}
|
||||
description={t('shareDescription')}
|
||||
/>
|
||||
|
||||
<OrgProvider org={org}>
|
||||
|
|
|
@ -50,25 +50,7 @@ import {
|
|||
CollapsibleTrigger
|
||||
} from "@app/components/ui/collapsible";
|
||||
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
|
||||
|
||||
const createSiteFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Name must be at least 2 characters."
|
||||
})
|
||||
.max(30, {
|
||||
message: "Name must not be longer than 30 characters."
|
||||
}),
|
||||
method: z.enum(["wireguard", "newt", "local"])
|
||||
});
|
||||
|
||||
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
|
||||
|
||||
const defaultValues: Partial<CreateSiteFormValues> = {
|
||||
name: "",
|
||||
method: "newt"
|
||||
};
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type CreateSiteFormProps = {
|
||||
onCreate?: (site: SiteRow) => void;
|
||||
|
@ -96,6 +78,27 @@ export default function CreateSiteForm({
|
|||
privateKey: string;
|
||||
} | null>(null);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const createSiteFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: t('nameMin', {len: 2})
|
||||
})
|
||||
.max(30, {
|
||||
message: t('nameMax', {len: 30})
|
||||
}),
|
||||
method: z.enum(["wireguard", "newt", "local"])
|
||||
});
|
||||
|
||||
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
|
||||
|
||||
const defaultValues: Partial<CreateSiteFormValues> = {
|
||||
name: "",
|
||||
method: "newt"
|
||||
};
|
||||
|
||||
const [siteDefaults, setSiteDefaults] =
|
||||
useState<PickSiteDefaultsResponse | null>(null);
|
||||
|
||||
|
@ -169,8 +172,8 @@ export default function CreateSiteForm({
|
|||
if (!keypair || !siteDefaults) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
description: "Key pair or site defaults not found"
|
||||
title: t('siteErrorCreate'),
|
||||
description: t('siteErrorCreateKeyPair')
|
||||
});
|
||||
setLoading?.(false);
|
||||
setIsLoading(false);
|
||||
|
@ -188,8 +191,8 @@ export default function CreateSiteForm({
|
|||
if (!siteDefaults) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
description: "Site defaults not found"
|
||||
title: t('siteErrorCreate'),
|
||||
description: t('siteErrorCreateDefaults')
|
||||
});
|
||||
setLoading?.(false);
|
||||
setIsLoading(false);
|
||||
|
@ -212,7 +215,7 @@ export default function CreateSiteForm({
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
title: t('siteErrorCreate'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
|
@ -227,11 +230,11 @@ export default function CreateSiteForm({
|
|||
address: data.address?.split("/")[0],
|
||||
mbIn:
|
||||
data.type == "wireguard" || data.type == "newt"
|
||||
? "0 MB"
|
||||
? t('megabytes', {count: 0})
|
||||
: "-",
|
||||
mbOut:
|
||||
data.type == "wireguard" || data.type == "newt"
|
||||
? "0 MB"
|
||||
? t('megabytes', {count: 0})
|
||||
: "-",
|
||||
orgId: orgId as string,
|
||||
type: data.type as any,
|
||||
|
@ -286,13 +289,13 @@ PersistentKeepalive = 5`
|
|||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t('name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoComplete="off" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display name for the site.
|
||||
{t('siteNameDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -302,18 +305,18 @@ PersistentKeepalive = 5`
|
|||
name="method"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Method</FormLabel>
|
||||
<FormLabel>{t('method')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select method" />
|
||||
<SelectValue placeholder={t('methodSelect')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="local">
|
||||
Local
|
||||
{t('local')}
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="newt"
|
||||
|
@ -332,7 +335,7 @@ PersistentKeepalive = 5`
|
|||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is how you will expose connections.
|
||||
{t('siteMethodDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -346,7 +349,7 @@ PersistentKeepalive = 5`
|
|||
rel="noopener noreferrer"
|
||||
>
|
||||
<span>
|
||||
Learn how to install Newt on your system
|
||||
{t('siteLearnNewt')}
|
||||
</span>
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</Link>
|
||||
|
@ -357,13 +360,12 @@ PersistentKeepalive = 5`
|
|||
<>
|
||||
<CopyTextBox text={wgConfig} />
|
||||
<span className="text-sm text-muted-foreground mt-2">
|
||||
You will only be able to see the
|
||||
configuration once.
|
||||
{t('siteSeeConfigOnce')}
|
||||
</span>
|
||||
</>
|
||||
) : form.watch("method") === "wireguard" &&
|
||||
isLoading ? (
|
||||
<p>Loading WireGuard configuration...</p>
|
||||
<p>{t('siteLoadWGConfig')}</p>
|
||||
) : form.watch("method") === "newt" && siteDefaults ? (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
|
@ -379,8 +381,7 @@ PersistentKeepalive = 5`
|
|||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
You will only be able to see the
|
||||
configuration once.
|
||||
{t('siteSeeConfigOnce')}
|
||||
</span>
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
|
@ -390,13 +391,12 @@ PersistentKeepalive = 5`
|
|||
className="p-0 flex items-center justify-between w-full"
|
||||
>
|
||||
<h4 className="text-sm font-semibold">
|
||||
Expand for Docker
|
||||
Deployment Details
|
||||
{t('siteDocker')}
|
||||
</h4>
|
||||
<div>
|
||||
<ChevronsUpDown className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
Toggle
|
||||
{t('toggle')}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
@ -404,7 +404,7 @@ PersistentKeepalive = 5`
|
|||
</div>
|
||||
<CollapsibleContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<b>Docker Compose</b>
|
||||
<b>{t('dockerCompose')}</b>
|
||||
<CopyTextBox
|
||||
text={
|
||||
newtConfigDockerCompose
|
||||
|
@ -413,7 +413,7 @@ PersistentKeepalive = 5`
|
|||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<b>Docker Run</b>
|
||||
<b>{t('dockerRun')}</b>
|
||||
|
||||
<CopyTextBox
|
||||
text={newtConfigDockerRun}
|
||||
|
@ -434,7 +434,7 @@ PersistentKeepalive = 5`
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<span> Local sites do not tunnel, learn more</span>
|
||||
<span>{t('siteLearnLocal')}</span>
|
||||
<SquareArrowOutUpRight size={14} />
|
||||
</Link>
|
||||
)}
|
||||
|
@ -451,7 +451,7 @@ PersistentKeepalive = 5`
|
|||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have copied the config
|
||||
{t('siteConfirmCopy')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "@app/components/Credenza";
|
||||
import { SiteRow } from "./SitesTable";
|
||||
import CreateSiteForm from "./CreateSiteForm";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type CreateSiteFormProps = {
|
||||
open: boolean;
|
||||
|
@ -30,6 +31,7 @@ export default function CreateSiteFormModal({
|
|||
}: CreateSiteFormProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -42,9 +44,9 @@ export default function CreateSiteFormModal({
|
|||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Create Site</CredenzaTitle>
|
||||
<CredenzaTitle>{t('siteCreate')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Create a new site to start connecting your resources
|
||||
{t('siteCreateDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
|
@ -59,7 +61,7 @@ export default function CreateSiteFormModal({
|
|||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
|
@ -70,7 +72,7 @@ export default function CreateSiteFormModal({
|
|||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Create Site
|
||||
{t('siteCreate')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
@ -14,15 +15,18 @@ export function SitesDataTable<TData, TValue>({
|
|||
data,
|
||||
createSite
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Sites"
|
||||
searchPlaceholder="Search sites..."
|
||||
title={t('sites')}
|
||||
searchPlaceholder={t('searchSitesProgress')}
|
||||
searchColumn="name"
|
||||
onAdd={createSite}
|
||||
addButtonText="Add Site"
|
||||
addButtonText={t('siteAdd')}
|
||||
defaultSort={{
|
||||
id: "name",
|
||||
desc: false
|
||||
|
|
|
@ -5,11 +5,13 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export const SitesSplashCard = () => {
|
||||
const [isDismissed, setIsDismissed] = useState(true);
|
||||
|
||||
const key = "sites-splash-card-dismissed";
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
const dismissed = localStorage.getItem(key);
|
||||
|
@ -34,7 +36,7 @@ export const SitesSplashCard = () => {
|
|||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 p-2"
|
||||
aria-label="Dismiss"
|
||||
aria-label={t('dismiss')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
@ -42,22 +44,19 @@ export const SitesSplashCard = () => {
|
|||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Globe className="text-blue-500" />
|
||||
Newt (Recommended)
|
||||
Newt ({t('recommended')})
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
For the best user experience, use Newt. It uses
|
||||
WireGuard under the hood and allows you to address your
|
||||
private resources by their LAN address on your private
|
||||
network from within the Pangolin dashboard.
|
||||
{t('siteNewtDescription')}
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<Server className="text-green-500 w-4 h-4" />
|
||||
Runs in Docker
|
||||
{t('siteRunsInDocker')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Server className="text-green-500 w-4 h-4" />
|
||||
Runs in shell on macOS, Linux, and Windows
|
||||
{t('siteRunsInShell')}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
|
@ -71,7 +70,7 @@ export const SitesSplashCard = () => {
|
|||
className="w-full flex items-center"
|
||||
variant="secondary"
|
||||
>
|
||||
Install Newt{" "}
|
||||
{t('siteInstallNewt')}{" "}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
@ -79,20 +78,19 @@ export const SitesSplashCard = () => {
|
|||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-semibold flex items-center gap-2">
|
||||
Basic WireGuard
|
||||
{t('siteWg')}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
Use any WireGuard client to connect. You will have to
|
||||
address your internal resources using the peer IP.
|
||||
{t('siteWgAnyClients')}
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li className="flex items-center gap-2">
|
||||
<Docker className="text-purple-500 w-4 h-4" />
|
||||
Compatible with all WireGuard clients
|
||||
{t('siteWgCompatibleAllClients')}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Server className="text-purple-500 w-4 h-4" />
|
||||
Manual configuration required
|
||||
{t('siteWgManualConfigurationRequired')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
@ -27,6 +27,7 @@ import { formatAxiosError } from "@app/lib/api";
|
|||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import CreateSiteFormModal from "./CreateSiteModal";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { parseDataSize } from '@app/lib/dataSize';
|
||||
|
||||
export type SiteRow = {
|
||||
|
@ -54,15 +55,16 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
const [rows, setRows] = useState<SiteRow[]>(sites);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
const deleteSite = (siteId: number) => {
|
||||
api.delete(`/site/${siteId}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting site", e);
|
||||
console.error(t('siteErrorDelete'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting site",
|
||||
description: formatAxiosError(e, "Error deleting site")
|
||||
title: t('siteErrorDelete'),
|
||||
description: formatAxiosError(e, t('siteErrorDelete'))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
|
@ -96,7 +98,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
View settings
|
||||
{t('viewSettings')}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
|
@ -105,7 +107,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span className="text-red-500">{t('delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
@ -122,7 +124,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -138,7 +140,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Online
|
||||
{t('online')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -153,14 +155,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
return (
|
||||
<span className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>Online</span>
|
||||
<span>{t('online')}</span>
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<span className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>Offline</span>
|
||||
<span>{t('offline')}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
@ -179,7 +181,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Site
|
||||
{t('site')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -195,7 +197,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Data In
|
||||
{t('dataIn')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -213,7 +215,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Data Out
|
||||
{t('dataOut')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -231,7 +233,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Connection Type
|
||||
{t('connectionType')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -258,7 +260,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
if (originalRow.type === "local") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Local</span>
|
||||
<span>{t('local')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -290,7 +292,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
|
||||
>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
Edit
|
||||
{t('edit')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
@ -312,30 +314,22 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
|||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to remove the site{" "}
|
||||
<b>{selectedSite?.name || selectedSite?.id}</b>{" "}
|
||||
from the organization?
|
||||
{t('siteQuestionRemove', {selectedSite: selectedSite?.name || selectedSite?.id})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{t('siteMessageRemove')}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Once removed, the site will no longer be
|
||||
accessible.{" "}
|
||||
<b>
|
||||
All resources and targets associated with
|
||||
the site will also be removed.
|
||||
</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the name of the site
|
||||
below.
|
||||
{t('siteMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete Site"
|
||||
buttonText={t('siteConfirmDelete')}
|
||||
onConfirm={async () => deleteSite(selectedSite!.id)}
|
||||
string={selectedSite.name}
|
||||
title="Delete Site"
|
||||
title={t('siteDelete')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -9,11 +9,13 @@ import {
|
|||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type SiteInfoCardProps = {};
|
||||
|
||||
export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
const { site, updateSite } = useSiteContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const getConnectionTypeString = (type: string) => {
|
||||
if (type === "newt") {
|
||||
|
@ -21,32 +23,32 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
|||
} else if (type === "wireguard") {
|
||||
return "WireGuard";
|
||||
} else if (type === "local") {
|
||||
return "Local";
|
||||
return t('local');
|
||||
} else {
|
||||
return "Unknown";
|
||||
return t('unknown');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">Site Information</AlertTitle>
|
||||
<AlertTitle className="font-semibold">{t('siteInfo')}</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections cols={2}>
|
||||
{(site.type == "newt" || site.type == "wireguard") && (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Status</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t('status')}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{site.online ? (
|
||||
<div className="text-green-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||
<span>Online</span>
|
||||
<span>{t('online')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-neutral-500 flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||
<span>Offline</span>
|
||||
<span>{t('offline')}</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
|
@ -54,7 +56,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
|||
</>
|
||||
)}
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Connection Type</InfoSectionTitle>
|
||||
<InfoSectionTitle>{t('connectionType')}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{getConnectionTypeString(site.type)}
|
||||
</InfoSectionContent>
|
||||
|
|
|
@ -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 { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, ExternalLink } from "lucide-react";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().nonempty("Name is required"),
|
||||
|
@ -50,6 +50,7 @@ export default function GeneralPage() {
|
|||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const form = useForm<GeneralFormValues>({
|
||||
resolver: zodResolver(GeneralFormSchema),
|
||||
|
@ -71,10 +72,10 @@ export default function GeneralPage() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to update site",
|
||||
title: t("siteErrorUpdate"),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while updating the site."
|
||||
t("siteErrorUpdateDescription")
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -85,8 +86,8 @@ export default function GeneralPage() {
|
|||
});
|
||||
|
||||
toast({
|
||||
title: "Site updated",
|
||||
description: "The site has been updated."
|
||||
title: t("siteUpdated"),
|
||||
description: t("siteUpdatedDescription")
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
@ -99,10 +100,10 @@ export default function GeneralPage() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Settings
|
||||
{t("generalSettings")}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the general settings for this site
|
||||
{t("siteGeneralDescription")}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
|
||||
|
@ -119,14 +120,13 @@ export default function GeneralPage() {
|
|||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t("name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display name of the
|
||||
site.
|
||||
{t("siteNameDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -140,7 +140,9 @@ export default function GeneralPage() {
|
|||
<FormControl>
|
||||
<SwitchInput
|
||||
id="docker-socket-enabled"
|
||||
label="Enable Docker Socket"
|
||||
label={t(
|
||||
"enableDockerSocket"
|
||||
)}
|
||||
defaultChecked={
|
||||
field.value
|
||||
}
|
||||
|
@ -151,20 +153,21 @@ export default function GeneralPage() {
|
|||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Enable Docker Socket
|
||||
discovery for populating
|
||||
container information.
|
||||
Socket path must be provided
|
||||
to Newt.{" "}
|
||||
<a
|
||||
{t(
|
||||
"enableDockerSocketDescription"
|
||||
)}{" "}
|
||||
<Link
|
||||
href="https://docs.fossorial.io/Newt/overview#docker-socket-integration"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center"
|
||||
>
|
||||
Learn more
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
<span>
|
||||
{t(
|
||||
"enableDockerSocketLink"
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -182,7 +185,7 @@ export default function GeneralPage() {
|
|||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Save General Settings
|
||||
{t("saveGeneralSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
BreadcrumbSeparator
|
||||
} from "@app/components/ui/breadcrumb";
|
||||
import SiteInfoCard from "./SiteInfoCard";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
|
@ -38,9 +39,11 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
redirect(`/${params.orgId}/settings/sites`);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
title: "General",
|
||||
title: t('general'),
|
||||
href: "/{orgId}/settings/sites/{niceId}/general"
|
||||
}
|
||||
];
|
||||
|
@ -48,8 +51,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={`${site?.name} Settings`}
|
||||
description="Configure the settings on your site"
|
||||
title={t('siteSetting', {siteName: site?.name})}
|
||||
description={t('siteSettingDescription')}
|
||||
/>
|
||||
|
||||
<SiteProvider site={site}>
|
||||
|
|
|
@ -65,32 +65,7 @@ import {
|
|||
import Link from "next/link";
|
||||
import { QRCodeCanvas } from "qrcode.react";
|
||||
|
||||
const createSiteFormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, { message: "Name must be at least 2 characters." })
|
||||
.max(30, {
|
||||
message: "Name must not be longer than 30 characters."
|
||||
}),
|
||||
method: z.enum(["newt", "wireguard", "local"]),
|
||||
copied: z.boolean(),
|
||||
clientAddress: z.string().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.method !== "local") {
|
||||
return data.copied;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Please confirm that you have copied the config.",
|
||||
path: ["copied"]
|
||||
}
|
||||
);
|
||||
|
||||
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type SiteType = "newt" | "wireguard" | "local";
|
||||
|
||||
|
@ -125,28 +100,54 @@ export default function Page() {
|
|||
const api = createApiClient({ env });
|
||||
const { orgId } = useParams();
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const createSiteFormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, { message: t('nameMin', {len: 2}) })
|
||||
.max(30, {
|
||||
message: t('nameMax', {len: 30})
|
||||
}),
|
||||
method: z.enum(["newt", "wireguard", "local"]),
|
||||
copied: z.boolean(),
|
||||
clientAddress: z.string().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.method !== "local") {
|
||||
return data.copied;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: t('sitesConfirmCopy'),
|
||||
path: ["copied"]
|
||||
}
|
||||
);
|
||||
|
||||
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
|
||||
|
||||
const [tunnelTypes, setTunnelTypes] = useState<
|
||||
ReadonlyArray<TunnelTypeOption>
|
||||
>([
|
||||
{
|
||||
id: "newt",
|
||||
title: "Newt Tunnel (Recommended)",
|
||||
description:
|
||||
"Easiest way to create an entrypoint into your network. No extra setup.",
|
||||
title: t('siteNewtTunnel'),
|
||||
description: t('siteNewtTunnelDescription'),
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
id: "wireguard",
|
||||
title: "Basic WireGuard",
|
||||
description:
|
||||
"Use any WireGuard client to establish a tunnel. Manual NAT setup required.",
|
||||
title: t('siteWg'),
|
||||
description: t('siteWgDescription'),
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
id: "local",
|
||||
title: "Local",
|
||||
description: "Local resources only. No tunneling."
|
||||
title: t('local'),
|
||||
description: t('siteLocalDescription')
|
||||
}
|
||||
]);
|
||||
|
||||
|
@ -325,7 +326,7 @@ WantedBy=default.target`
|
|||
};
|
||||
|
||||
const getCommand = () => {
|
||||
const placeholder = ["Unknown command"];
|
||||
const placeholder = [t('unknownCommand')];
|
||||
if (!commands) {
|
||||
return placeholder;
|
||||
}
|
||||
|
@ -387,8 +388,8 @@ WantedBy=default.target`
|
|||
if (!siteDefaults || !wgConfig) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
description: "Key pair or site defaults not found"
|
||||
title: t('siteErrorCreate'),
|
||||
description: t('siteErrorCreateKeyPair')
|
||||
});
|
||||
setCreateLoading(false);
|
||||
return;
|
||||
|
@ -405,8 +406,8 @@ WantedBy=default.target`
|
|||
if (!siteDefaults) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
description: "Site defaults not found"
|
||||
title: t('siteErrorCreate'),
|
||||
description: t('siteErrorCreateDefaults')
|
||||
});
|
||||
setCreateLoading(false);
|
||||
return;
|
||||
|
@ -429,7 +430,7 @@ WantedBy=default.target`
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
title: t('siteErrorCreate'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
|
@ -455,14 +456,14 @@ WantedBy=default.target`
|
|||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch release info: ${response.statusText}`
|
||||
t('newtErrorFetchReleases', {err: response.statusText})
|
||||
);
|
||||
}
|
||||
const data = await response.json();
|
||||
const latestVersion = data.tag_name;
|
||||
newtVersion = latestVersion;
|
||||
} catch (error) {
|
||||
console.error("Error fetching latest release:", error);
|
||||
console.error(t('newtErrorFetchLatest', {err: error instanceof Error ? error.message : String(error)}));
|
||||
}
|
||||
|
||||
const generatedKeypair = generateKeypair();
|
||||
|
@ -529,8 +530,8 @@ WantedBy=default.target`
|
|||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Create Site"
|
||||
description="Follow the steps below to create and connect a new site"
|
||||
title={t('siteCreate')}
|
||||
description={t('siteCreateDescription2')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
@ -538,7 +539,7 @@ WantedBy=default.target`
|
|||
router.push(`/${orgId}/settings/sites`);
|
||||
}}
|
||||
>
|
||||
See All Sites
|
||||
{t('siteSeeAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
@ -548,7 +549,7 @@ WantedBy=default.target`
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Site Information
|
||||
{t('siteInfo')}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -564,7 +565,7 @@ WantedBy=default.target`
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
{t('name')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -574,8 +575,7 @@ WantedBy=default.target`
|
|||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the display
|
||||
name for the site.
|
||||
{t('siteNameDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
@ -625,11 +625,10 @@ WantedBy=default.target`
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Tunnel Type
|
||||
{t('tunnelType')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine how you want to connect to your
|
||||
site
|
||||
{t('siteTunnelDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -649,18 +648,17 @@ WantedBy=default.target`
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Newt Credentials
|
||||
{t('siteNewtCredentials')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
This is how Newt will authenticate
|
||||
with the server
|
||||
{t('siteNewtCredentialsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Newt Endpoint
|
||||
{t('newtEndpoint')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
|
@ -672,7 +670,7 @@ WantedBy=default.target`
|
|||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Newt ID
|
||||
{t('newtId')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
|
@ -682,7 +680,7 @@ WantedBy=default.target`
|
|||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Newt Secret Key
|
||||
{t('newtSecretKey')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
|
@ -695,12 +693,10 @@ WantedBy=default.target`
|
|||
<Alert variant="neutral" className="">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Save Your Credentials
|
||||
{t('siteCredentialsSave')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will only be able to see
|
||||
this once. Make sure to copy it
|
||||
to a secure place.
|
||||
{t('siteCredentialsSaveDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
@ -735,9 +731,7 @@ WantedBy=default.target`
|
|||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have
|
||||
copied the
|
||||
config
|
||||
{t('siteConfirmCopy')}
|
||||
</label>
|
||||
</div>
|
||||
<FormMessage />
|
||||
|
@ -751,16 +745,16 @@ WantedBy=default.target`
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Install Newt
|
||||
{t('siteInstallNewt')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Get Newt running on your system
|
||||
{t('siteInstallNewtDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div>
|
||||
<p className="font-bold mb-3">
|
||||
Operating System
|
||||
{t('operatingSystem')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{platforms.map((os) => (
|
||||
|
@ -788,8 +782,8 @@ WantedBy=default.target`
|
|||
{["docker", "podman"].includes(
|
||||
platform
|
||||
)
|
||||
? "Method"
|
||||
: "Architecture"}
|
||||
? t('method')
|
||||
: t('architecture')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{getArchitectures().map(
|
||||
|
@ -816,7 +810,7 @@ WantedBy=default.target`
|
|||
</div>
|
||||
<div className="pt-4">
|
||||
<p className="font-bold mb-3">
|
||||
Commands
|
||||
{t('commands')}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<CopyTextBox
|
||||
|
@ -837,11 +831,10 @@ WantedBy=default.target`
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
WireGuard Configuration
|
||||
{t('WgConfiguration')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Use the following configuration to
|
||||
connect to your network
|
||||
{t('WgConfigurationDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -862,12 +855,10 @@ WantedBy=default.target`
|
|||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Save Your Credentials
|
||||
{t('siteCredentialsSave')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will only be able to see this
|
||||
once. Make sure to copy it to a
|
||||
secure place.
|
||||
{t('siteCredentialsSaveDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
@ -902,8 +893,7 @@ WantedBy=default.target`
|
|||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have copied
|
||||
the config
|
||||
{t('siteConfirmCopy')}
|
||||
</label>
|
||||
</div>
|
||||
<FormMessage />
|
||||
|
@ -925,7 +915,7 @@ WantedBy=default.target`
|
|||
router.push(`/${orgId}/settings/sites`);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
@ -935,7 +925,7 @@ WantedBy=default.target`
|
|||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
Create Site
|
||||
{t('siteCreate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -5,6 +5,7 @@ import { AxiosResponse } from "axios";
|
|||
import SitesTable, { SiteRow } from "./SitesTable";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import SitesSplashCard from "./SitesSplashCard";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type SitesPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -23,16 +24,18 @@ export default async function SitesPage(props: SitesPageProps) {
|
|||
sites = res.data.data.sites;
|
||||
} catch (e) {}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
function formatSize(mb: number, type: string): string {
|
||||
if (type === "local") {
|
||||
return "-"; // because we are not able to track the data use in a local site right now
|
||||
}
|
||||
if (mb >= 1024 * 1024) {
|
||||
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
|
||||
return t('terabytes', {count: (mb / (1024 * 1024)).toFixed(2)});
|
||||
} else if (mb >= 1024) {
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
return t('gigabytes', {count: (mb / 1024).toFixed(2)});
|
||||
} else {
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
return t('megabytes', {count: mb.toFixed(2)});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -55,8 +58,8 @@ export default async function SitesPage(props: SitesPageProps) {
|
|||
{/* <SitesSplashCard /> */}
|
||||
|
||||
<SettingsSectionTitle
|
||||
title="Manage Sites"
|
||||
description="Allow connectivity to your network through secure tunnels"
|
||||
title={t('siteManageSites')}
|
||||
description={t('siteDescription')}
|
||||
/>
|
||||
|
||||
<SitesTable sites={siteRows} orgId={params.orgId} />
|
||||
|
|
|
@ -27,6 +27,7 @@ import { Input } from "@app/components/ui/input";
|
|||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
@ -39,15 +40,18 @@ export function ApiKeysDataTable<TData, TValue>({
|
|||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="API Keys"
|
||||
searchPlaceholder="Search API keys..."
|
||||
title={t('apiKeys')}
|
||||
searchPlaceholder={t('searchApiKeys')}
|
||||
searchColumn="name"
|
||||
onAdd={addApiKey}
|
||||
addButtonText="Generate API Key"
|
||||
addButtonText={t('apiKeysAdd')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ import { createApiClient } from "@app/lib/api";
|
|||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import moment from "moment";
|
||||
import { ApiKeysDataTable } from "./ApiKeysDataTable";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type ApiKeyRow = {
|
||||
id: string;
|
||||
|
@ -40,14 +41,16 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
|||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const deleteSite = (apiKeyId: string) => {
|
||||
api.delete(`/api-key/${apiKeyId}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting API key", e);
|
||||
console.error(t('apiKeysErrorDelete'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting API key",
|
||||
description: formatAxiosError(e, "Error deleting API key")
|
||||
title: t('apiKeysErrorDelete'),
|
||||
description: formatAxiosError(e, t('apiKeysErrorDeleteMessage'))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
|
@ -71,7 +74,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
|||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="sr-only">{t('openMenu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
@ -81,7 +84,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
|||
setSelected(apiKeyROw);
|
||||
}}
|
||||
>
|
||||
<span>View settings</span>
|
||||
<span>{t('viewSettings')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
|
@ -89,7 +92,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
|||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span className="text-red-500">{t('delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
@ -106,7 +109,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -114,7 +117,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
|||
},
|
||||
{
|
||||
accessorKey: "key",
|
||||
header: "Key",
|
||||
header: t('key'),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span className="font-mono">{r.key}</span>;
|
||||
|
@ -122,7 +125,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
|||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
header: t('createdAt'),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span>{moment(r.createdAt).format("lll")} </span>;
|
||||
|
@ -136,7 +139,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
|||
<div className="flex items-center justify-end">
|
||||
<Link href={`/admin/api-keys/${r.id}`}>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
Edit
|
||||
{t('edit')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
@ -158,27 +161,24 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
|||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to remove the API key{" "}
|
||||
<b>{selected?.name || selected?.id}</b>?
|
||||
{t('apiKeysQuestionRemove', {selectedApiKey: selected?.name || selected?.id})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>
|
||||
Once removed, the API key will no longer be
|
||||
able to be used.
|
||||
{t('apiKeysMessageRemove')}
|
||||
</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the name of the API key
|
||||
below.
|
||||
{t('apiKeysMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete API Key"
|
||||
buttonText={t('apiKeysDeleteConfirm')}
|
||||
onConfirm={async () => deleteSite(selected!.id)}
|
||||
string={selected.name}
|
||||
title="Delete API Key"
|
||||
title={t('apiKeysDelete')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
import { GetApiKeyResponse } from "@server/routers/apiKeys";
|
||||
import ApiKeyProvider from "@app/providers/ApiKeyProvider";
|
||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
|
@ -24,6 +25,8 @@ interface SettingsLayoutProps {
|
|||
export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
const params = await props.params;
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
const { children } = props;
|
||||
|
||||
let apiKey = null;
|
||||
|
@ -40,14 +43,14 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
|
||||
const navItems = [
|
||||
{
|
||||
title: "Permissions",
|
||||
title: t('apiKeysPermissionsTitle'),
|
||||
href: "/admin/api-keys/{apiKeyId}/permissions"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle title={`${apiKey?.name} Settings`} />
|
||||
<SettingsSectionTitle title={t('apiKeysSettings', {apiKeyName: apiKey?.name})} />
|
||||
|
||||
<ApiKeyProvider apiKey={apiKey}>
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
|
|
|
@ -18,12 +18,15 @@ import { ListApiKeyActionsResponse } from "@server/routers/apiKeys";
|
|||
import { AxiosResponse } from "axios";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { apiKeyId } = useParams();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState<boolean>(true);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||
Record<string, boolean>
|
||||
|
@ -42,10 +45,10 @@ export default function Page() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error loading API key actions",
|
||||
title: t('apiKeysPermissionsErrorLoadingActions'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"Error loading API key actions"
|
||||
t('apiKeysPermissionsErrorLoadingActions')
|
||||
)
|
||||
});
|
||||
});
|
||||
|
@ -76,18 +79,18 @@ export default function Page() {
|
|||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Error setting permissions", e);
|
||||
console.error(t('apiKeysErrorSetPermission'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error setting permissions",
|
||||
title: t('apiKeysErrorSetPermission'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
|
||||
if (actionsRes && actionsRes.status === 200) {
|
||||
toast({
|
||||
title: "Permissions updated",
|
||||
description: "The permissions have been updated."
|
||||
title: t('apiKeysPermissionsUpdated'),
|
||||
description: t('apiKeysPermissionsUpdatedDescription')
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -101,10 +104,10 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Permissions
|
||||
{t('apiKeysPermissionsTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine what this API key can do
|
||||
{t('apiKeysPermissionsGeneralSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -122,7 +125,7 @@ export default function Page() {
|
|||
loading={loadingSavePermissions}
|
||||
disabled={loadingSavePermissions}
|
||||
>
|
||||
Save Permissions
|
||||
{t('apiKeysPermissionsSave')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSectionBody>
|
||||
|
|
|
@ -54,40 +54,13 @@ import CopyToClipboard from "@app/components/CopyToClipboard";
|
|||
import moment from "moment";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
||||
|
||||
const createFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Name must be at least 2 characters."
|
||||
})
|
||||
.max(255, {
|
||||
message: "Name must not be longer than 255 characters."
|
||||
})
|
||||
});
|
||||
|
||||
type CreateFormValues = z.infer<typeof createFormSchema>;
|
||||
|
||||
const copiedFormSchema = z
|
||||
.object({
|
||||
copied: z.boolean()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
return data.copied;
|
||||
},
|
||||
{
|
||||
message: "You must confirm that you have copied the API key.",
|
||||
path: ["copied"]
|
||||
}
|
||||
);
|
||||
|
||||
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
|
@ -96,6 +69,35 @@ export default function Page() {
|
|||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const createFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: t('nameMin', {len: 2})
|
||||
})
|
||||
.max(255, {
|
||||
message: t('nameMax', {len: 255})
|
||||
})
|
||||
});
|
||||
|
||||
type CreateFormValues = z.infer<typeof createFormSchema>;
|
||||
|
||||
const copiedFormSchema = z
|
||||
.object({
|
||||
copied: z.boolean()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
return data.copied;
|
||||
},
|
||||
{
|
||||
message: t('apiKeysConfirmCopy2'),
|
||||
path: ["copied"]
|
||||
}
|
||||
);
|
||||
|
||||
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
|
||||
|
||||
const form = useForm<CreateFormValues>({
|
||||
resolver: zodResolver(createFormSchema),
|
||||
defaultValues: {
|
||||
|
@ -122,7 +124,7 @@ export default function Page() {
|
|||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating API key",
|
||||
title: t('apiKeysErrorCreate'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
|
@ -143,10 +145,10 @@ export default function Page() {
|
|||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Error setting permissions", e);
|
||||
console.error(t('apiKeysErrorSetPermission'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error setting permissions",
|
||||
title: t('apiKeysErrorSetPermission'),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
|
@ -179,8 +181,8 @@ export default function Page() {
|
|||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Generate API Key"
|
||||
description="Generate a new root access API key"
|
||||
title={t('apiKeysCreate')}
|
||||
description={t('apiKeysCreateDescription')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
@ -188,7 +190,7 @@ export default function Page() {
|
|||
router.push(`/admin/api-keys`);
|
||||
}}
|
||||
>
|
||||
See All API Keys
|
||||
{t('apiKeysSeeAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
@ -200,7 +202,7 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
API Key Information
|
||||
{t('apiKeysTitle')}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -216,7 +218,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
{t('name')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -237,10 +239,10 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Permissions
|
||||
{t('apiKeysGeneralSettings')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine what this API key can do
|
||||
{t('apiKeysGeneralSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -260,14 +262,14 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Your API Key
|
||||
{t('apiKeysList')}
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={2}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Name
|
||||
{t('name')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
|
@ -277,7 +279,7 @@ export default function Page() {
|
|||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Created
|
||||
{t('created')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{moment(
|
||||
|
@ -290,17 +292,15 @@ export default function Page() {
|
|||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Save Your API Key
|
||||
{t('apiKeysSave')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will only be able to see this
|
||||
once. Make sure to copy it to a
|
||||
secure place.
|
||||
{t('apiKeysSaveDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<h4 className="font-semibold">
|
||||
Your API key is:
|
||||
{t('apiKeysInfo')}
|
||||
</h4>
|
||||
|
||||
<CopyTextBox
|
||||
|
@ -338,8 +338,7 @@ export default function Page() {
|
|||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have copied
|
||||
the API key
|
||||
{t('apiKeysConfirmCopy')}
|
||||
</label>
|
||||
</div>
|
||||
<FormMessage />
|
||||
|
@ -363,7 +362,7 @@ export default function Page() {
|
|||
router.push(`/admin/api-keys`);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{!apiKey && (
|
||||
|
@ -375,7 +374,7 @@ export default function Page() {
|
|||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
Generate
|
||||
{t('generate')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
@ -386,7 +385,7 @@ export default function Page() {
|
|||
copiedForm.handleSubmit(onCopiedSubmit)();
|
||||
}}
|
||||
>
|
||||
Done
|
||||
{t('done')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -4,6 +4,7 @@ import { AxiosResponse } from "axios";
|
|||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { ListRootApiKeysResponse } from "@server/routers/apiKeys";
|
||||
import ApiKeysTable, { ApiKeyRow } from "./ApiKeysTable";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type ApiKeyPageProps = {};
|
||||
|
||||
|
@ -28,11 +29,13 @@ export default async function ApiKeysPage(props: ApiKeyPageProps) {
|
|||
};
|
||||
});
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage API Keys"
|
||||
description="API keys are used to authenticate with the integration API"
|
||||
title={t('apiKeysManage')}
|
||||
description={t('apiKeysDescription')}
|
||||
/>
|
||||
|
||||
<ApiKeysTable apiKeys={rows} />
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
@ -14,15 +15,16 @@ export function IdpDataTable<TData, TValue>({
|
|||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Identity Providers"
|
||||
searchPlaceholder="Search identity providers..."
|
||||
title={t('idp')}
|
||||
searchPlaceholder={t('idpSearch')}
|
||||
searchColumn="name"
|
||||
addButtonText="Add Identity Provider"
|
||||
addButtonText={t('idpAdd')}
|
||||
onAdd={() => {
|
||||
router.push("/admin/idp/create");
|
||||
}}
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type IdpRow = {
|
||||
idpId: number;
|
||||
|
@ -36,19 +37,20 @@ export default function IdpTable({ idps }: Props) {
|
|||
const [selectedIdp, setSelectedIdp] = useState<IdpRow | null>(null);
|
||||
const api = createApiClient(useEnvContext());
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const deleteIdp = async (idpId: number) => {
|
||||
try {
|
||||
await api.delete(`/idp/${idpId}`);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Identity provider deleted successfully"
|
||||
title: t('success'),
|
||||
description: t('idpDeletedDescription')
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
|
@ -74,7 +76,7 @@ export default function IdpTable({ idps }: Props) {
|
|||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="sr-only">{t('openMenu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
@ -84,7 +86,7 @@ export default function IdpTable({ idps }: Props) {
|
|||
href={`/admin/idp/${r.idpId}/general`}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
View settings
|
||||
{t('viewSettings')}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
<DropdownMenuItem
|
||||
|
@ -93,7 +95,7 @@ export default function IdpTable({ idps }: Props) {
|
|||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span className="text-red-500">{t('delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
@ -126,7 +128,7 @@ export default function IdpTable({ idps }: Props) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -142,7 +144,7 @@ export default function IdpTable({ idps }: Props) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Type
|
||||
{t('type')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -162,7 +164,7 @@ export default function IdpTable({ idps }: Props) {
|
|||
<div className="flex items-center justify-end">
|
||||
<Link href={`/admin/idp/${siteRow.idpId}/general`}>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
Edit
|
||||
{t('edit')}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
|
@ -184,27 +186,22 @@ export default function IdpTable({ idps }: Props) {
|
|||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to permanently delete the
|
||||
identity provider <b>{selectedIdp.name}</b>?
|
||||
{t('idpQuestionRemove', {name: selectedIdp.name})}
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
This will remove the identity provider and
|
||||
all associated configurations. Users who
|
||||
authenticate through this provider will no
|
||||
longer be able to log in.
|
||||
{t('idpMessageRemove')}
|
||||
</b>
|
||||
</p>
|
||||
<p>
|
||||
To confirm, please type the name of the identity
|
||||
provider below.
|
||||
{t('idpMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete Identity Provider"
|
||||
buttonText={t('idpConfirmDelete')}
|
||||
onConfirm={async () => deleteIdp(selectedIdp.idpId)}
|
||||
string={selectedIdp.name}
|
||||
title="Delete Identity Provider"
|
||||
title={t('idpDelete')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -43,23 +43,7 @@ import {
|
|||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
||||
clientId: z.string().min(1, { message: "Client ID is required." }),
|
||||
clientSecret: z.string().min(1, { message: "Client Secret is required." }),
|
||||
authUrl: z.string().url({ message: "Auth URL must be a valid URL." }),
|
||||
tokenUrl: z.string().url({ message: "Token URL must be a valid URL." }),
|
||||
identifierPath: z
|
||||
.string()
|
||||
.min(1, { message: "Identifier Path is required." }),
|
||||
emailPath: z.string().optional(),
|
||||
namePath: z.string().optional(),
|
||||
scopes: z.string().min(1, { message: "Scopes are required." }),
|
||||
autoProvision: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function GeneralPage() {
|
||||
const { env } = useEnvContext();
|
||||
|
@ -71,6 +55,24 @@ export default function GeneralPage() {
|
|||
const { isUnlocked } = useLicenseStatusContext();
|
||||
|
||||
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
|
||||
const t = useTranslations();
|
||||
|
||||
const GeneralFormSchema = z.object({
|
||||
name: z.string().min(2, { message: t('nameMin', {len: 2}) }),
|
||||
clientId: z.string().min(1, { message: t('idpClientIdRequired') }),
|
||||
clientSecret: z.string().min(1, { message: t('idpClientSecretRequired') }),
|
||||
authUrl: z.string().url({ message: t('idpErrorAuthUrlInvalid') }),
|
||||
tokenUrl: z.string().url({ message: t('idpErrorTokenUrlInvalid') }),
|
||||
identifierPath: z
|
||||
.string()
|
||||
.min(1, { message: t('idpPathRequired') }),
|
||||
emailPath: z.string().optional(),
|
||||
namePath: z.string().optional(),
|
||||
scopes: z.string().min(1, { message: t('idpScopeRequired') }),
|
||||
autoProvision: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
|
||||
|
||||
const form = useForm<GeneralFormValues>({
|
||||
resolver: zodResolver(GeneralFormSchema),
|
||||
|
@ -109,7 +111,7 @@ export default function GeneralPage() {
|
|||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
|
@ -143,14 +145,14 @@ export default function GeneralPage() {
|
|||
|
||||
if (res.status === 200) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Identity provider updated successfully"
|
||||
title: t('success'),
|
||||
description: t('idpUpdatedDescription')
|
||||
});
|
||||
router.refresh();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
|
@ -169,18 +171,17 @@ export default function GeneralPage() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Information
|
||||
{t('idpTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the basic information for your identity
|
||||
provider
|
||||
{t('idpSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Redirect URL
|
||||
{t('redirectUrl')}
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard text={redirectUrl} />
|
||||
|
@ -191,13 +192,10 @@ export default function GeneralPage() {
|
|||
<Alert variant="neutral" className="">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
About Redirect URL
|
||||
{t('redirectUrlAbout')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
This is the URL to which users will be
|
||||
redirected after authentication. You need to
|
||||
configure this URL in your identity provider
|
||||
settings.
|
||||
{t('redirectUrlAboutDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<SettingsSectionForm>
|
||||
|
@ -212,13 +210,12 @@ export default function GeneralPage() {
|
|||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t('name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A display name for this
|
||||
identity provider
|
||||
{t('idpDisplayName')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -228,7 +225,7 @@ export default function GeneralPage() {
|
|||
<div className="flex items-start mb-0">
|
||||
<SwitchInput
|
||||
id="auto-provision-toggle"
|
||||
label="Auto Provision Users"
|
||||
label={t('idpAutoProvisionUsers')}
|
||||
defaultChecked={form.getValues(
|
||||
"autoProvision"
|
||||
)}
|
||||
|
@ -241,10 +238,7 @@ export default function GeneralPage() {
|
|||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
When enabled, users will be
|
||||
automatically created in the system upon
|
||||
first login with the ability to map
|
||||
users to roles and organizations.
|
||||
{t('idpAutoProvisionUsersDescription')}
|
||||
</span>
|
||||
</form>
|
||||
</Form>
|
||||
|
@ -256,11 +250,10 @@ export default function GeneralPage() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
OAuth2/OIDC Configuration
|
||||
{t('idpOidcConfigure')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the OAuth2/OIDC provider endpoints and
|
||||
credentials
|
||||
{t('idpOidcConfigureDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -277,15 +270,13 @@ export default function GeneralPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Client ID
|
||||
{t('idpClientId')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 client ID
|
||||
from your identity
|
||||
provider
|
||||
{t('idpClientIdDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -298,7 +289,7 @@ export default function GeneralPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Client Secret
|
||||
{t('idpClientSecret')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -307,9 +298,7 @@ export default function GeneralPage() {
|
|||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 client secret
|
||||
from your identity
|
||||
provider
|
||||
{t('idpClientSecretDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -322,14 +311,13 @@ export default function GeneralPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Authorization URL
|
||||
{t('idpAuthUrl')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 authorization
|
||||
endpoint URL
|
||||
{t('idpAuthUrlDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -342,14 +330,13 @@ export default function GeneralPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Token URL
|
||||
{t('idpTokenUrl')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 token
|
||||
endpoint URL
|
||||
{t('idpTokenUrlDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -364,11 +351,10 @@ export default function GeneralPage() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Token Configuration
|
||||
{t('idpToken')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure how to extract user information from
|
||||
the ID token
|
||||
{t('idpTokenDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -382,19 +368,17 @@ export default function GeneralPage() {
|
|||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
About JMESPath
|
||||
{t('idpJmespathAbout')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
The paths below use JMESPath
|
||||
syntax to extract values from
|
||||
the ID token.
|
||||
{t('idpJmespathAboutDescription')}
|
||||
<a
|
||||
href="https://jmespath.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center"
|
||||
>
|
||||
Learn more about JMESPath{" "}
|
||||
{t('idpJmespathAboutDescriptionLink')}{" "}
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
</AlertDescription>
|
||||
|
@ -406,15 +390,13 @@ export default function GeneralPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Identifier Path
|
||||
{t('idpJmespathLabel')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The JMESPath to the user
|
||||
identifier in the ID
|
||||
token
|
||||
{t('idpJmespathLabelDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -427,15 +409,13 @@ export default function GeneralPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email Path (Optional)
|
||||
{t('idpJmespathEmailPathOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The JMESPath to the
|
||||
user's email in the ID
|
||||
token
|
||||
{t('idpJmespathEmailPathOptionalDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -448,15 +428,13 @@ export default function GeneralPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name Path (Optional)
|
||||
{t('idpJmespathNamePathOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The JMESPath to the
|
||||
user's name in the ID
|
||||
token
|
||||
{t('idpJmespathNamePathOptionalDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -469,14 +447,13 @@ export default function GeneralPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Scopes
|
||||
{t('idpOidcConfigureScopes')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Space-separated list of
|
||||
OAuth2 scopes to request
|
||||
{t('idpOidcConfigureScopesDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -497,7 +474,7 @@ export default function GeneralPage() {
|
|||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
Save General Settings
|
||||
{t('saveGeneralSettings')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@app/components/ui/breadcrumb";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
|
@ -24,6 +25,7 @@ interface SettingsLayoutProps {
|
|||
export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
const params = await props.params;
|
||||
const { children } = props;
|
||||
const t = await getTranslations();
|
||||
|
||||
let idp = null;
|
||||
try {
|
||||
|
@ -38,11 +40,11 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
|
||||
const navItems: HorizontalTabs = [
|
||||
{
|
||||
title: "General",
|
||||
title: t('general'),
|
||||
href: `/admin/idp/${params.idpId}/general`
|
||||
},
|
||||
{
|
||||
title: "Organization Policies",
|
||||
title: t('orgPolicies'),
|
||||
href: `/admin/idp/${params.idpId}/policies`
|
||||
}
|
||||
];
|
||||
|
@ -50,8 +52,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
|||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title={`${idp.idp.name} Settings`}
|
||||
description="Configure the settings for your identity provider"
|
||||
title={t('idpSettings', { idpName: idp.idp.name })}
|
||||
description={t('idpSettingsDescription')}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
@ -14,14 +15,17 @@ export function PolicyDataTable<TData, TValue>({
|
|||
data,
|
||||
onAdd
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Organization Policies"
|
||||
searchPlaceholder="Search organization policies..."
|
||||
title={t('orgPolicies')}
|
||||
searchPlaceholder={t('orgPoliciesSearch')}
|
||||
searchColumn="orgId"
|
||||
addButtonText="Add Organization Policy"
|
||||
addButtonText={t('orgPoliciesAdd')}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from "@app/components/ui/dropdown-menu";
|
||||
import Link from "next/link";
|
||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export interface PolicyRow {
|
||||
orgId: string;
|
||||
|
@ -34,6 +35,7 @@ interface Props {
|
|||
}
|
||||
|
||||
export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) {
|
||||
const t = useTranslations();
|
||||
const columns: ColumnDef<PolicyRow>[] = [
|
||||
{
|
||||
id: "dots",
|
||||
|
@ -44,7 +46,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
|||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="sr-only">{t('openMenu')}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
@ -54,7 +56,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
|||
onDelete(r.orgId);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
<span className="text-red-500">{t('delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
@ -71,7 +73,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Organization ID
|
||||
{t('orgId')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -87,7 +89,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Role Mapping
|
||||
{t('roleMapping')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -114,7 +116,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Organization Mapping
|
||||
{t('orgMapping')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -142,7 +144,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props
|
|||
className="ml-2"
|
||||
onClick={() => onEdit(policy)}
|
||||
>
|
||||
Edit
|
||||
{t('edit')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -63,31 +63,19 @@ import {
|
|||
SettingsSectionFooter,
|
||||
SettingsSectionForm
|
||||
} from "@app/components/Settings";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type Organization = {
|
||||
orgId: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const policyFormSchema = z.object({
|
||||
orgId: z.string().min(1, { message: "Organization is required" }),
|
||||
roleMapping: z.string().optional(),
|
||||
orgMapping: z.string().optional()
|
||||
});
|
||||
|
||||
const defaultMappingsSchema = z.object({
|
||||
defaultRoleMapping: z.string().optional(),
|
||||
defaultOrgMapping: z.string().optional()
|
||||
});
|
||||
|
||||
type PolicyFormValues = z.infer<typeof policyFormSchema>;
|
||||
type DefaultMappingsValues = z.infer<typeof defaultMappingsSchema>;
|
||||
|
||||
export default function PoliciesPage() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const { idpId } = useParams();
|
||||
const t = useTranslations();
|
||||
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [addPolicyLoading, setAddPolicyLoading] = useState(false);
|
||||
|
@ -100,6 +88,20 @@ export default function PoliciesPage() {
|
|||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [editingPolicy, setEditingPolicy] = useState<PolicyRow | null>(null);
|
||||
|
||||
const policyFormSchema = z.object({
|
||||
orgId: z.string().min(1, { message: t('orgRequired') }),
|
||||
roleMapping: z.string().optional(),
|
||||
orgMapping: z.string().optional()
|
||||
});
|
||||
|
||||
const defaultMappingsSchema = z.object({
|
||||
defaultRoleMapping: z.string().optional(),
|
||||
defaultOrgMapping: z.string().optional()
|
||||
});
|
||||
|
||||
type PolicyFormValues = z.infer<typeof policyFormSchema>;
|
||||
type DefaultMappingsValues = z.infer<typeof defaultMappingsSchema>;
|
||||
|
||||
const form = useForm<PolicyFormValues>({
|
||||
resolver: zodResolver(policyFormSchema),
|
||||
defaultValues: {
|
||||
|
@ -131,7 +133,7 @@ export default function PoliciesPage() {
|
|||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
|
@ -146,7 +148,7 @@ export default function PoliciesPage() {
|
|||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
|
@ -165,7 +167,7 @@ export default function PoliciesPage() {
|
|||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
|
@ -200,15 +202,15 @@ export default function PoliciesPage() {
|
|||
};
|
||||
setPolicies([...policies, newPolicy]);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Policy added successfully"
|
||||
title: t('success'),
|
||||
description: t('orgPolicyAddedDescription')
|
||||
});
|
||||
setShowAddDialog(false);
|
||||
form.reset();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
|
@ -242,8 +244,8 @@ export default function PoliciesPage() {
|
|||
)
|
||||
);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Policy updated successfully"
|
||||
title: t('success'),
|
||||
description: t('orgPolicyUpdatedDescription')
|
||||
});
|
||||
setShowAddDialog(false);
|
||||
setEditingPolicy(null);
|
||||
|
@ -251,7 +253,7 @@ export default function PoliciesPage() {
|
|||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
|
@ -269,13 +271,13 @@ export default function PoliciesPage() {
|
|||
policies.filter((policy) => policy.orgId !== orgId)
|
||||
);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Policy deleted successfully"
|
||||
title: t('success'),
|
||||
description: t('orgPolicyDeletedDescription')
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
|
@ -293,13 +295,13 @@ export default function PoliciesPage() {
|
|||
});
|
||||
if (res.status === 200) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Default mappings updated successfully"
|
||||
title: t('success'),
|
||||
description: t('defaultMappingsUpdatedDescription')
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
|
@ -318,21 +320,18 @@ export default function PoliciesPage() {
|
|||
<Alert variant="neutral" className="mb-6">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
About Organization Policies
|
||||
{t('orgPoliciesAbout')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Organization policies are used to control access to
|
||||
organizations based on the user's ID token. You can
|
||||
specify JMESPath expressions to extract role and
|
||||
organization information from the ID token. For more
|
||||
information, see{" "}
|
||||
{/*TODO(vlalx): Validate replacing */}
|
||||
{t('orgPoliciesAboutDescription')}{" "}
|
||||
<Link
|
||||
href="https://docs.fossorial.io/Pangolin/Identity%20Providers/auto-provision"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
the documentation
|
||||
{t('orgPoliciesAboutDescriptionLink')}
|
||||
<ExternalLink className="ml-1 h-4 w-4 inline" />
|
||||
</Link>
|
||||
</AlertDescription>
|
||||
|
@ -341,13 +340,10 @@ export default function PoliciesPage() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Default Mappings (Optional)
|
||||
{t('defaultMappingsOptional')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
The default mappings are used when when there is not
|
||||
an organization policy defined for an organization.
|
||||
You can specify the default role and organization
|
||||
mappings to fall back to here.
|
||||
{t('defaultMappingsOptionalDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -366,16 +362,13 @@ export default function PoliciesPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Default Role Mapping
|
||||
{t('defaultMappingsRole')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The result of this
|
||||
expression must return the
|
||||
role name as defined in the
|
||||
organization as a string.
|
||||
{t('defaultMappingsRoleDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -388,16 +381,13 @@ export default function PoliciesPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Default Organization Mapping
|
||||
{t('defaultMappingsOrg')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This expression must return
|
||||
the org ID or true for the
|
||||
user to be allowed to access
|
||||
the organization.
|
||||
{t('defaultMappingsOrgDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -412,7 +402,7 @@ export default function PoliciesPage() {
|
|||
form="policy-default-mappings-form"
|
||||
loading={updateDefaultMappingsLoading}
|
||||
>
|
||||
Save Default Mappings
|
||||
{t('defaultMappingsSubmit')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSectionBody>
|
||||
|
@ -455,11 +445,11 @@ export default function PoliciesPage() {
|
|||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{editingPolicy
|
||||
? "Edit Organization Policy"
|
||||
: "Add Organization Policy"}
|
||||
? t('orgPoliciesEdit')
|
||||
: t('orgPoliciesAdd')}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Configure access for an organization
|
||||
{t('orgPolicyConfig')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
|
@ -476,7 +466,7 @@ export default function PoliciesPage() {
|
|||
name="orgId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel>Organization</FormLabel>
|
||||
<FormLabel>{t('org')}</FormLabel>
|
||||
{editingPolicy ? (
|
||||
<Input {...field} disabled />
|
||||
) : (
|
||||
|
@ -500,18 +490,17 @@ export default function PoliciesPage() {
|
|||
org.orgId ===
|
||||
field.value
|
||||
)?.name
|
||||
: "Select organization"}
|
||||
: t('orgSelect')}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search org" />
|
||||
<CommandInput placeholder={t('orgSearch')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No org
|
||||
found.
|
||||
{t('orgNotFound')}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{organizations.map(
|
||||
|
@ -562,16 +551,13 @@ export default function PoliciesPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Role Mapping Path (Optional)
|
||||
{t('roleMappingPathOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The result of this expression
|
||||
must return the role name as
|
||||
defined in the organization as a
|
||||
string.
|
||||
{t('defaultMappingsRoleDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -584,17 +570,13 @@ export default function PoliciesPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Organization Mapping Path
|
||||
(Optional)
|
||||
{t('orgMappingPathOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
This expression must return the
|
||||
org ID or true for the user to
|
||||
be allowed to access the
|
||||
organization.
|
||||
{t('defaultMappingsOrgDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -605,7 +587,7 @@ export default function PoliciesPage() {
|
|||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
|
@ -621,7 +603,7 @@ export default function PoliciesPage() {
|
|||
: addPolicyLoading
|
||||
}
|
||||
>
|
||||
{editingPolicy ? "Update Policy" : "Add Policy"}
|
||||
{editingPolicy ? t('orgPolicyUpdate') : t('orgPolicyAdd')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
|
|
@ -37,38 +37,7 @@ import { StrategySelect } from "@app/components/StrategySelect";
|
|||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
|
||||
const createIdpFormSchema = z.object({
|
||||
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
||||
type: z.enum(["oidc"]),
|
||||
clientId: z.string().min(1, { message: "Client ID is required." }),
|
||||
clientSecret: z.string().min(1, { message: "Client Secret is required." }),
|
||||
authUrl: z.string().url({ message: "Auth URL must be a valid URL." }),
|
||||
tokenUrl: z.string().url({ message: "Token URL must be a valid URL." }),
|
||||
identifierPath: z
|
||||
.string()
|
||||
.min(1, { message: "Identifier Path is required." }),
|
||||
emailPath: z.string().optional(),
|
||||
namePath: z.string().optional(),
|
||||
scopes: z.string().min(1, { message: "Scopes are required." }),
|
||||
autoProvision: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
|
||||
|
||||
interface ProviderTypeOption {
|
||||
id: "oidc";
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const providerTypes: ReadonlyArray<ProviderTypeOption> = [
|
||||
{
|
||||
id: "oidc",
|
||||
title: "OAuth2/OIDC",
|
||||
description: "Configure an OpenID Connect identity provider"
|
||||
}
|
||||
];
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
|
@ -76,6 +45,39 @@ export default function Page() {
|
|||
const router = useRouter();
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const { isUnlocked } = useLicenseStatusContext();
|
||||
const t = useTranslations();
|
||||
|
||||
const createIdpFormSchema = z.object({
|
||||
name: z.string().min(2, { message: t('nameMin', {len: 2}) }),
|
||||
type: z.enum(["oidc"]),
|
||||
clientId: z.string().min(1, { message: t('idpClientIdRequired') }),
|
||||
clientSecret: z.string().min(1, { message: t('idpClientSecretRequired') }),
|
||||
authUrl: z.string().url({ message: t('idpErrorAuthUrlInvalid') }),
|
||||
tokenUrl: z.string().url({ message: t('idpErrorTokenUrlInvalid') }),
|
||||
identifierPath: z
|
||||
.string()
|
||||
.min(1, { message: t('idpPathRequired') }),
|
||||
emailPath: z.string().optional(),
|
||||
namePath: z.string().optional(),
|
||||
scopes: z.string().min(1, { message: t('idpScopeRequired') }),
|
||||
autoProvision: z.boolean().default(false)
|
||||
});
|
||||
|
||||
type CreateIdpFormValues = z.infer<typeof createIdpFormSchema>;
|
||||
|
||||
interface ProviderTypeOption {
|
||||
id: "oidc";
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const providerTypes: ReadonlyArray<ProviderTypeOption> = [
|
||||
{
|
||||
id: "oidc",
|
||||
title: "OAuth2/OIDC",
|
||||
description: t('idpOidcDescription')
|
||||
}
|
||||
];
|
||||
|
||||
const form = useForm<CreateIdpFormValues>({
|
||||
resolver: zodResolver(createIdpFormSchema),
|
||||
|
@ -115,14 +117,14 @@ export default function Page() {
|
|||
|
||||
if (res.status === 201) {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Identity provider created successfully"
|
||||
title: t('success'),
|
||||
description: t('idpCreatedDescription')
|
||||
});
|
||||
router.push(`/admin/idp/${res.data.data.idpId}`);
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Error",
|
||||
title: t('error'),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
|
@ -135,8 +137,8 @@ export default function Page() {
|
|||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Create Identity Provider"
|
||||
description="Configure a new identity provider for user authentication"
|
||||
title={t('idpCreate')}
|
||||
description={t('idpCreateDescription')}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
@ -144,7 +146,7 @@ export default function Page() {
|
|||
router.push("/admin/idp");
|
||||
}}
|
||||
>
|
||||
See All Identity Providers
|
||||
{t('idpSeeAll')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
@ -152,11 +154,10 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
General Information
|
||||
{t('idpTitle')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the basic information for your identity
|
||||
provider
|
||||
{t('idpCreateSettingsDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -172,13 +173,12 @@ export default function Page() {
|
|||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t('name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A display name for this
|
||||
identity provider
|
||||
{t('idpDisplayName')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -188,7 +188,7 @@ export default function Page() {
|
|||
<div className="flex items-start mb-0">
|
||||
<SwitchInput
|
||||
id="auto-provision-toggle"
|
||||
label="Auto Provision Users"
|
||||
label={t('idpAutoProvisionUsers')}
|
||||
defaultChecked={form.getValues(
|
||||
"autoProvision"
|
||||
)}
|
||||
|
@ -201,10 +201,7 @@ export default function Page() {
|
|||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
When enabled, users will be
|
||||
automatically created in the system upon
|
||||
first login with the ability to map
|
||||
users to roles and organizations.
|
||||
{t('idpAutoProvisionUsersDescription')}
|
||||
</span>
|
||||
</form>
|
||||
</Form>
|
||||
|
@ -215,11 +212,10 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Provider Type
|
||||
{t('idpType')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Select the type of identity provider you want to
|
||||
configure
|
||||
{t('idpTypeDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -239,11 +235,10 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
OAuth2/OIDC Configuration
|
||||
{t('idpOidcConfigure')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure the OAuth2/OIDC provider endpoints
|
||||
and credentials
|
||||
{t('idpOidcConfigureDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -259,15 +254,13 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Client ID
|
||||
{t('idpClientId')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 client ID
|
||||
from your identity
|
||||
provider
|
||||
{t('idpClientIdDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -280,7 +273,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Client Secret
|
||||
{t('idpClientSecret')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -289,9 +282,7 @@ export default function Page() {
|
|||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 client secret
|
||||
from your identity
|
||||
provider
|
||||
{t('idpClientSecretDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -304,7 +295,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Authorization URL
|
||||
{t('idpAuthUrl')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -313,8 +304,7 @@ export default function Page() {
|
|||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 authorization
|
||||
endpoint URL
|
||||
{t('idpAuthUrlDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -327,7 +317,7 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Token URL
|
||||
{t('idpTokenUrl')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
|
@ -336,8 +326,7 @@ export default function Page() {
|
|||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The OAuth2 token
|
||||
endpoint URL
|
||||
{t('idpTokenUrlDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -349,14 +338,10 @@ export default function Page() {
|
|||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Important Information
|
||||
{t('idpOidcConfigureAlert')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
After creating the identity provider,
|
||||
you will need to configure the callback
|
||||
URL in your identity provider's
|
||||
settings. The callback URL will be
|
||||
provided after successful creation.
|
||||
{t('idpOidcConfigureAlertDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</SettingsSectionBody>
|
||||
|
@ -365,11 +350,10 @@ export default function Page() {
|
|||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Token Configuration
|
||||
{t('idpToken')}
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Configure how to extract user information
|
||||
from the ID token
|
||||
{t('idpTokenDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
|
@ -382,19 +366,17 @@ export default function Page() {
|
|||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
About JMESPath
|
||||
{t('idpJmespathAbout')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
The paths below use JMESPath
|
||||
syntax to extract values from
|
||||
the ID token.
|
||||
{t('idpJmespathAboutDescription')}{" "}
|
||||
<a
|
||||
href="https://jmespath.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center"
|
||||
>
|
||||
Learn more about JMESPath{" "}
|
||||
{t('idpJmespathAboutDescriptionLink')}{" "}
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</a>
|
||||
</AlertDescription>
|
||||
|
@ -406,15 +388,13 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Identifier Path
|
||||
{t('idpJmespathLabel')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The path to the user
|
||||
identifier in the ID
|
||||
token
|
||||
{t('idpJmespathLabelDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -427,15 +407,13 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email Path (Optional)
|
||||
{t('idpJmespathEmailPathOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The path to the
|
||||
user's email in the ID
|
||||
token
|
||||
{t('idpJmespathEmailPathOptionalDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -448,15 +426,13 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name Path (Optional)
|
||||
{t('idpJmespathNamePathOptional')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The path to the
|
||||
user's name in the ID
|
||||
token
|
||||
{t('idpJmespathNamePathOptionalDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -469,14 +445,13 @@ export default function Page() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Scopes
|
||||
{t('idpOidcConfigureScopes')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Space-separated list of
|
||||
OAuth2 scopes to request
|
||||
{t('idpOidcConfigureScopesDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
@ -498,7 +473,7 @@ export default function Page() {
|
|||
router.push("/admin/idp");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
|
@ -506,7 +481,7 @@ export default function Page() {
|
|||
loading={createLoading}
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
Create Identity Provider
|
||||
{t('idpSubmit')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -3,6 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
|||
import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import IdpTable, { IdpRow } from "./AdminIdpTable";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export default async function IdpPage() {
|
||||
let idps: IdpRow[] = [];
|
||||
|
@ -15,12 +16,14 @@ export default async function IdpPage() {
|
|||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage Identity Providers"
|
||||
description="View and manage identity providers in the system"
|
||||
title={t('idpManage')}
|
||||
description={t('idpManageDescription')}
|
||||
/>
|
||||
<IdpTable idps={idps} />
|
||||
</>
|
||||
|
|
|
@ -8,6 +8,7 @@ import { LicenseKeyCache } from "@server/license/license";
|
|||
import { ArrowUpDown } from "lucide-react";
|
||||
import moment from "moment";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type LicenseKeysDataTableProps = {
|
||||
licenseKeys: LicenseKeyCache[];
|
||||
|
@ -27,6 +28,9 @@ export function LicenseKeysDataTable({
|
|||
onDelete,
|
||||
onCreate
|
||||
}: LicenseKeysDataTableProps) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const columns: ColumnDef<LicenseKeyCache>[] = [
|
||||
{
|
||||
accessorKey: "licenseKey",
|
||||
|
@ -38,7 +42,7 @@ export function LicenseKeysDataTable({
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
License Key
|
||||
{t('licenseKey')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -63,13 +67,13 @@ export function LicenseKeysDataTable({
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Valid
|
||||
{t('valid')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return row.original.valid ? "Yes" : "No";
|
||||
return row.original.valid ? t('yes') : t('no');
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -82,7 +86,7 @@ export function LicenseKeysDataTable({
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Type
|
||||
{t('type')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -90,7 +94,7 @@ export function LicenseKeysDataTable({
|
|||
cell: ({ row }) => {
|
||||
const type = row.original.type;
|
||||
const label =
|
||||
type === "SITES" ? "Additional Sites" : "Host License";
|
||||
type === "SITES" ? t('sitesAdditional') : t('licenseHost');
|
||||
const variant = type === "SITES" ? "secondary" : "default";
|
||||
return row.original.valid ? (
|
||||
<Badge variant={variant}>{label}</Badge>
|
||||
|
@ -107,7 +111,7 @@ export function LicenseKeysDataTable({
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Number of Sites
|
||||
{t('numberOfSites')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -121,7 +125,7 @@ export function LicenseKeysDataTable({
|
|||
variant="outlinePrimary"
|
||||
onClick={() => onDelete(row.original)}
|
||||
>
|
||||
Delete
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
@ -132,11 +136,11 @@ export function LicenseKeysDataTable({
|
|||
<DataTable
|
||||
columns={columns}
|
||||
data={licenseKeys}
|
||||
title="License Keys"
|
||||
searchPlaceholder="Search license keys..."
|
||||
title={t('licenseKeys')}
|
||||
searchPlaceholder={t('licenseKeySearch')}
|
||||
searchColumn="licenseKey"
|
||||
onAdd={onCreate}
|
||||
addButtonText="Add License Key"
|
||||
addButtonText={t('licenseKeyAdd')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type SitePriceCalculatorProps = {
|
||||
isOpen: boolean;
|
||||
|
@ -55,27 +56,26 @@ export function SitePriceCalculator({
|
|||
? licenseFlatRate + siteCount * pricePerSite
|
||||
: siteCount * pricePerSite;
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Credenza open={isOpen} onOpenChange={onOpenChange}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{mode === "license"
|
||||
? "Purchase License"
|
||||
: "Purchase Additional Sites"}
|
||||
? t('licensePurchase')
|
||||
: t('licensePurchaseSites')}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Choose how many sites you want to{" "}
|
||||
{mode === "license"
|
||||
? "purchase a license for. You can always add more sites later."
|
||||
: "add to your existing license."}
|
||||
{t('licensePurchaseDescription', {selectedMode: mode})}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Number of Sites
|
||||
{t('numberOfSites')}
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
|
@ -83,7 +83,7 @@ export function SitePriceCalculator({
|
|||
size="icon"
|
||||
onClick={decrementSites}
|
||||
disabled={siteCount <= 1}
|
||||
aria-label="Decrease site count"
|
||||
aria-label={t('sitestCountDecrease')}
|
||||
>
|
||||
<MinusCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
|
@ -94,7 +94,7 @@ export function SitePriceCalculator({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={incrementSites}
|
||||
aria-label="Increase site count"
|
||||
aria-label={t('sitestCountIncrease')}
|
||||
>
|
||||
<PlusCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
|
@ -103,15 +103,14 @@ export function SitePriceCalculator({
|
|||
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-muted-foreground text-sm mt-2 text-center">
|
||||
For the most up-to-date pricing and discounts,
|
||||
please visit the{" "}
|
||||
{t('licensePricingPage')}
|
||||
<a
|
||||
href="https://docs.fossorial.io/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
pricing page
|
||||
{t('pricingPage')}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
|
@ -120,10 +119,10 @@ export function SitePriceCalculator({
|
|||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
<Button variant="outline">{t('cancel')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button onClick={continueToPayment}>
|
||||
See Purchase Portal
|
||||
{t('pricingPortal')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
|
|
@ -54,16 +54,7 @@ import Link from "next/link";
|
|||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
|
||||
const formSchema = z.object({
|
||||
licenseKey: z
|
||||
.string()
|
||||
.nonempty({ message: "License key is required" })
|
||||
.max(255),
|
||||
agreeToTerms: z.boolean().refine((val) => val === true, {
|
||||
message: "You must agree to the license terms"
|
||||
})
|
||||
});
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
function obfuscateLicenseKey(key: string): string {
|
||||
if (key.length <= 8) return key;
|
||||
|
@ -94,6 +85,18 @@ export default function LicensePage() {
|
|||
const [isRecheckingLicense, setIsRecheckingLicense] = useState(false);
|
||||
const { supporterStatus } = useSupporterStatusContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const formSchema = z.object({
|
||||
licenseKey: z
|
||||
.string()
|
||||
.nonempty({ message: t('licenseKeyRequired') })
|
||||
.max(255),
|
||||
agreeToTerms: z.boolean().refine((val) => val === true, {
|
||||
message: t('licenseTermsAgree')
|
||||
})
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
|
@ -127,10 +130,10 @@ export default function LicensePage() {
|
|||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to load license keys",
|
||||
title: t('licenseErrorKeyLoad'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred loading license keys"
|
||||
t('licenseErrorKeyLoadDescription')
|
||||
)
|
||||
});
|
||||
}
|
||||
|
@ -146,16 +149,16 @@ export default function LicensePage() {
|
|||
}
|
||||
await loadLicenseKeys();
|
||||
toast({
|
||||
title: "License key deleted",
|
||||
description: "The license key has been deleted"
|
||||
title: t('licenseKeyDeleted'),
|
||||
description: t('licenseKeyDeletedDescription')
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to delete license key",
|
||||
title: t('licenseErrorKeyDelete'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred deleting license key"
|
||||
t('licenseErrorKeyDeleteDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
|
@ -172,15 +175,15 @@ export default function LicensePage() {
|
|||
}
|
||||
await loadLicenseKeys();
|
||||
toast({
|
||||
title: "License keys rechecked",
|
||||
description: "All license keys have been rechecked"
|
||||
title: t('licenseErrorKeyRechecked'),
|
||||
description: t('licenseErrorKeyRecheckedDescription')
|
||||
});
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: "Failed to recheck license keys",
|
||||
title: t('licenseErrorKeyRecheck'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred rechecking license keys"
|
||||
t('licenseErrorKeyRecheckDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
|
@ -199,8 +202,8 @@ export default function LicensePage() {
|
|||
}
|
||||
|
||||
toast({
|
||||
title: "License key activated",
|
||||
description: "The license key has been successfully activated."
|
||||
title: t('licenseKeyActivated'),
|
||||
description: t('licenseKeyActivatedDescription')
|
||||
});
|
||||
|
||||
setIsCreateModalOpen(false);
|
||||
|
@ -209,10 +212,10 @@ export default function LicensePage() {
|
|||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to activate license key",
|
||||
title: t('licenseErrorKeyActivate'),
|
||||
description: formatAxiosError(
|
||||
e,
|
||||
"An error occurred while activating the license key."
|
||||
t('licenseErrorKeyActivateDescription')
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
|
@ -243,9 +246,9 @@ export default function LicensePage() {
|
|||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Activate License Key</CredenzaTitle>
|
||||
<CredenzaTitle>{t('licenseActivateKey')}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Enter a license key to activate it.
|
||||
{t('licenseActivateKeyDescription')}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
|
@ -260,7 +263,7 @@ export default function LicensePage() {
|
|||
name="licenseKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>License Key</FormLabel>
|
||||
<FormLabel>{t('licenseKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
|
@ -283,12 +286,7 @@ export default function LicensePage() {
|
|||
</FormControl>
|
||||
<div className="space-y-1 leading-none">
|
||||
<FormLabel>
|
||||
By checking this box, you
|
||||
confirm that you have read
|
||||
and agree to the license
|
||||
terms corresponding to the
|
||||
tier associated with your
|
||||
license key.
|
||||
{t('licenseAgreement')}
|
||||
{/* <br /> */}
|
||||
{/* <Link */}
|
||||
{/* href="https://fossorial.io/license.html" */}
|
||||
|
@ -296,9 +294,7 @@ export default function LicensePage() {
|
|||
{/* rel="noopener noreferrer" */}
|
||||
{/* className="text-primary hover:underline" */}
|
||||
{/* > */}
|
||||
{/* View Fossorial */}
|
||||
{/* Commercial License & */}
|
||||
{/* Subscription Terms */}
|
||||
{/* {t('fossorialLicense')} */}
|
||||
{/* </Link> */}
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
|
@ -311,7 +307,7 @@ export default function LicensePage() {
|
|||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t('close')}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
|
@ -319,7 +315,7 @@ export default function LicensePage() {
|
|||
loading={isActivatingLicense}
|
||||
disabled={isActivatingLicense}
|
||||
>
|
||||
Activate License
|
||||
{t('licenseActivate')}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
|
@ -335,48 +331,39 @@ export default function LicensePage() {
|
|||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to delete the license key{" "}
|
||||
<b>
|
||||
{obfuscateLicenseKey(
|
||||
selectedLicenseKey.licenseKey
|
||||
)}
|
||||
</b>
|
||||
?
|
||||
{t('licenseQuestionRemove', {selectedKey: obfuscateLicenseKey(selectedLicenseKey.licenseKey)})}
|
||||
</p>
|
||||
<p>
|
||||
<b>
|
||||
This will remove the license key and all
|
||||
associated permissions granted by it.
|
||||
{t('licenseMessageRemove')}
|
||||
</b>
|
||||
</p>
|
||||
<p>
|
||||
To confirm, please type the license key below.
|
||||
{t('licenseMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete License Key"
|
||||
buttonText={t('licenseKeyDeleteConfirm')}
|
||||
onConfirm={async () =>
|
||||
deleteLicenseKey(selectedLicenseKey.licenseKeyEncrypted)
|
||||
}
|
||||
string={selectedLicenseKey.licenseKey}
|
||||
title="Delete License Key"
|
||||
title={t('licenseKeyDelete')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SettingsSectionTitle
|
||||
title="Manage License Status"
|
||||
description="View and manage license keys in the system"
|
||||
title={t('licenseTitle')}
|
||||
description={t('licenseTitleDescription')}
|
||||
/>
|
||||
|
||||
<Alert variant="neutral" className="mb-6">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
About Licensing
|
||||
{t('licenseAbout')}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
This is for business and enterprise users who are using
|
||||
Pangolin in a commercial environment. If you are using
|
||||
Pangolin for personal use, you can ignore this section.
|
||||
{t('licenseAboutDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
|
@ -384,9 +371,9 @@ export default function LicensePage() {
|
|||
<SettingsSectionGrid cols={2}>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SSTitle>Host License</SSTitle>
|
||||
<SSTitle>{t('licenseHost')}</SSTitle>
|
||||
<SettingsSectionDescription>
|
||||
Manage the main license key for the host.
|
||||
{t('licenseHostDescription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<div className="space-y-4">
|
||||
|
@ -397,23 +384,23 @@ export default function LicensePage() {
|
|||
<Check />
|
||||
{licenseStatus?.tier ===
|
||||
"PROFESSIONAL"
|
||||
? "Commercial License"
|
||||
? t('licenseTierCommercial')
|
||||
: licenseStatus?.tier ===
|
||||
"ENTERPRISE"
|
||||
? "Commercial License"
|
||||
: "Licensed"}
|
||||
? t('licenseTierCommercial')
|
||||
: t('licensed')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{supporterStatus?.visible ? (
|
||||
<div className="text-2xl">
|
||||
Community Edition
|
||||
{t('communityEdition')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-2xl flex items-center gap-2 text-pink-500">
|
||||
<Heart />
|
||||
Community Edition
|
||||
{t('communityEdition')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -422,7 +409,7 @@ export default function LicensePage() {
|
|||
{licenseStatus?.hostId && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
Host ID
|
||||
{t('hostId')}
|
||||
</div>
|
||||
<CopyTextBox text={licenseStatus.hostId} />
|
||||
</div>
|
||||
|
@ -430,7 +417,7 @@ export default function LicensePage() {
|
|||
{hostLicense && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
License Key
|
||||
{t('licenseKey')}
|
||||
</div>
|
||||
<CopyTextBox
|
||||
text={hostLicense}
|
||||
|
@ -448,39 +435,33 @@ export default function LicensePage() {
|
|||
disabled={isRecheckingLicense}
|
||||
loading={isRecheckingLicense}
|
||||
>
|
||||
Recheck All Keys
|
||||
{t('licenseReckeckAll')}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SSTitle>Sites Usage</SSTitle>
|
||||
<SSTitle>{t('licenseSiteUsage')}</SSTitle>
|
||||
<SettingsSectionDescription>
|
||||
View the number of sites using this license.
|
||||
{t('licenseSiteUsageDecsription')}
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-2xl">
|
||||
{licenseStatus?.usedSites || 0}{" "}
|
||||
{licenseStatus?.usedSites === 1
|
||||
? "site"
|
||||
: "sites"}{" "}
|
||||
in system
|
||||
{t('licenseSitesUsed', {count: licenseStatus?.usedSites || 0})}
|
||||
</div>
|
||||
</div>
|
||||
{!licenseStatus?.isHostLicensed && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
There is no limit on the number of sites
|
||||
using an unlicensed host.
|
||||
{t('licenseNoSiteLimit')}
|
||||
</p>
|
||||
)}
|
||||
{licenseStatus?.maxSites && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{licenseStatus.usedSites || 0} of{" "}
|
||||
{licenseStatus.maxSites} sites used
|
||||
{t('licenseSitesUsedMax', {usedSites: licenseStatus.usedSites || 0, maxSites: licenseStatus.maxSites})}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{Math.round(
|
||||
|
@ -512,7 +493,7 @@ export default function LicensePage() {
|
|||
{/* setIsPurchaseModalOpen(true); */}
|
||||
{/* }} */}
|
||||
{/* > */}
|
||||
{/* Purchase License */}
|
||||
{/* {t('licensePurchase')} */}
|
||||
{/* </Button> */}
|
||||
{/* </> */}
|
||||
{/* ) : ( */}
|
||||
|
@ -524,7 +505,7 @@ export default function LicensePage() {
|
|||
{/* setIsPurchaseModalOpen(true); */}
|
||||
{/* }} */}
|
||||
{/* > */}
|
||||
{/* Purchase Additional Sites */}
|
||||
{/* {t('licensePurchaseSites')} */}
|
||||
{/* </Button> */}
|
||||
{/* </> */}
|
||||
{/* )} */}
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { DataTable } from "@app/components/ui/data-table";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
|
@ -14,12 +15,15 @@ export function UsersDataTable<TData, TValue>({
|
|||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="Server Users"
|
||||
searchPlaceholder="Search server users..."
|
||||
title={t('userServer')}
|
||||
searchPlaceholder={t('userSearch')}
|
||||
searchColumn="email"
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -11,6 +11,7 @@ import { toast } from "@app/hooks/useToast";
|
|||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type GlobalUserRow = {
|
||||
id: string;
|
||||
|
@ -29,6 +30,7 @@ type Props = {
|
|||
|
||||
export default function UsersTable({ users }: Props) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<GlobalUserRow | null>(null);
|
||||
|
@ -39,11 +41,11 @@ export default function UsersTable({ users }: Props) {
|
|||
const deleteUser = (id: string) => {
|
||||
api.delete(`/user/${id}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting user", e);
|
||||
console.error(t('userErrorDelete'), e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting user",
|
||||
description: formatAxiosError(e, "Error deleting user")
|
||||
title: t('userErrorDelete'),
|
||||
description: formatAxiosError(e, t('userErrorDelete'))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
|
@ -82,7 +84,7 @@ export default function UsersTable({ users }: Props) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Username
|
||||
{t('username')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -98,7 +100,7 @@ export default function UsersTable({ users }: Props) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Email
|
||||
{t('email')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -114,7 +116,7 @@ export default function UsersTable({ users }: Props) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
{t('name')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -130,7 +132,7 @@ export default function UsersTable({ users }: Props) {
|
|||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Identity Provider
|
||||
{t('identityProvider')}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
|
@ -151,7 +153,7 @@ export default function UsersTable({ users }: Props) {
|
|||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
{t('delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
@ -172,35 +174,26 @@ export default function UsersTable({ users }: Props) {
|
|||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to permanently delete{" "}
|
||||
<b className="break-all">
|
||||
{selected?.email ||
|
||||
selected?.name ||
|
||||
selected?.username}
|
||||
</b>{" "}
|
||||
from the server?
|
||||
{t('userQuestionRemove', {selectedUser: selected?.email || selected?.name || selected?.username})}
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>
|
||||
The user will be removed from all
|
||||
organizations and be completely removed from
|
||||
the server.
|
||||
{t('userMessageRemove')}
|
||||
</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the name of the user
|
||||
below.
|
||||
{t('userMessageConfirm')}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete User"
|
||||
buttonText={t('userDeleteConfirm')}
|
||||
onConfirm={async () => deleteUser(selected!.id)}
|
||||
string={
|
||||
selected.email || selected.name || selected.username
|
||||
}
|
||||
title="Delete User from Server"
|
||||
title={t('userDeleteServer')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import { AdminListUsersResponse } from "@server/routers/user/adminListUsers";
|
|||
import UsersTable, { GlobalUserRow } from "./AdminUsersTable";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -25,6 +26,8 @@ export default async function UsersPage(props: PageProps) {
|
|||
console.error(e);
|
||||
}
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
const userRows: GlobalUserRow[] = rows.map((row) => {
|
||||
return {
|
||||
id: row.id,
|
||||
|
@ -33,7 +36,7 @@ export default async function UsersPage(props: PageProps) {
|
|||
username: row.username,
|
||||
type: row.type,
|
||||
idpId: row.idpId,
|
||||
idpName: row.idpName || "Internal",
|
||||
idpName: row.idpName || t('idpNameInternal'),
|
||||
dateCreated: row.dateCreated,
|
||||
serverAdmin: row.serverAdmin
|
||||
};
|
||||
|
@ -42,14 +45,14 @@ export default async function UsersPage(props: PageProps) {
|
|||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage All Users"
|
||||
description="View and manage all users in the system"
|
||||
title={t('userTitle')}
|
||||
description={t('userDescription')}
|
||||
/>
|
||||
<Alert variant="neutral" className="mb-6">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">About User Management</AlertTitle>
|
||||
<AlertTitle className="font-semibold">{t('userAbount')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
This table displays all root user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their root user object - they will remain in the system. To completely remove a user from the system, you must delete their root user object using the delete action in this table.
|
||||
{t('userAbountDescription')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<UsersTable users={userRows} />
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ValidateOidcTokenParams = {
|
||||
orgId: string;
|
||||
|
@ -36,11 +37,13 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
|||
|
||||
const { licenseStatus, isLicenseViolation } = useLicenseStatusContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
async function validate() {
|
||||
setLoading(true);
|
||||
|
||||
console.log("Validating OIDC token", {
|
||||
console.log(t('idpOidcTokenValidating'), {
|
||||
code: props.code,
|
||||
expectedState: props.expectedState,
|
||||
stateCookie: props.stateCookie
|
||||
|
@ -59,7 +62,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
|||
storedState: props.stateCookie
|
||||
});
|
||||
|
||||
console.log("Validate OIDC token response", res.data);
|
||||
console.log(t('idpOidcTokenResponse'), res.data);
|
||||
|
||||
const redirectUrl = res.data.data.redirectUrl;
|
||||
|
||||
|
@ -76,7 +79,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
|||
router.push(res.data.data.redirectUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(formatAxiosError(e, "Error validating OIDC token"));
|
||||
setError(formatAxiosError(e, t('idpErrorOidcTokenValidating')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
@ -89,20 +92,20 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
|||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Connecting to {props.idp.name}</CardTitle>
|
||||
<CardDescription>Validating your identity</CardDescription>
|
||||
<CardTitle>{t('idpConnectingTo', {name: props.idp.name})}</CardTitle>
|
||||
<CardDescription>{t('idpConnectingToDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>Connecting...</span>
|
||||
<span>{t('idpConnectingToProcess')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && (
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span>Connected</span>
|
||||
<span>{t('idpConnectingToFinished')}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
|
@ -110,9 +113,7 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
|||
<AlertCircle className="h-5 w-5" />
|
||||
<AlertDescription className="flex flex-col space-y-2">
|
||||
<span>
|
||||
There was a problem connecting to{" "}
|
||||
{props.idp.name}. Please contact your
|
||||
administrator.
|
||||
{t('idpErrorConnectingTo', {name: props.idp.name})}
|
||||
</span>
|
||||
<span className="text-xs">{error}</span>
|
||||
</AlertDescription>
|
||||
|
|
|
@ -4,6 +4,7 @@ import { cache } from "react";
|
|||
import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { GetIdpResponse } from "@server/routers/idp";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
@ -16,6 +17,7 @@ export default async function Page(props: {
|
|||
}) {
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const t = await getTranslations();
|
||||
|
||||
const allCookies = await cookies();
|
||||
const stateCookie = allCookies.get("p_oidc_state")?.value;
|
||||
|
@ -28,7 +30,7 @@ export default async function Page(props: {
|
|||
const foundIdp = idpRes.data?.data?.idp;
|
||||
|
||||
if (!foundIdp) {
|
||||
return <div>IdP not found</div>;
|
||||
return <div>{t('idpErrorNotFound')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -8,6 +8,7 @@ import { AxiosResponse } from "axios";
|
|||
import { ExternalLink } from "lucide-react";
|
||||
import { Metadata } from "next";
|
||||
import { cache } from "react";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Auth - Pangolin`,
|
||||
|
@ -21,6 +22,7 @@ type AuthLayoutProps = {
|
|||
export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
const t = await getTranslations();
|
||||
|
||||
const licenseStatusRes = await cache(
|
||||
async () =>
|
||||
|
@ -71,7 +73,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
|||
aria-label="GitHub"
|
||||
className="flex items-center space-x-2 whitespace-nowrap"
|
||||
>
|
||||
<span>Community Edition</span>
|
||||
<span>{t('communityEdition')}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
|
|
|
@ -14,6 +14,7 @@ import { useRouter } from "next/navigation";
|
|||
import { useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type DashboardLoginFormProps = {
|
||||
redirect?: string;
|
||||
|
@ -38,23 +39,25 @@ export default function DashboardLoginForm({
|
|||
// logout();
|
||||
// });
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<Image
|
||||
src={`/logo/pangolin_orange.svg`}
|
||||
alt="Pangolin Logo"
|
||||
alt={t('pangolinLogoAlt')}
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<h1 className="text-2xl font-bold mt-1">
|
||||
Welcome to Pangolin
|
||||
{t('welcome')}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Log in to get started
|
||||
{t('loginStart')}
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
|
@ -11,6 +11,7 @@ import { LoginFormIDP } from "@app/components/LoginForm";
|
|||
import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ListIdpsResponse } from "@server/routers/idp";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
@ -44,6 +45,8 @@ export default async function Page(props: {
|
|||
name: idp.name
|
||||
})) as LoginFormIDP[];
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
{isInvite && (
|
||||
|
@ -51,11 +54,10 @@ export default async function Page(props: {
|
|||
<div className="flex flex-col items-center">
|
||||
<Mail className="w-12 h-12 mb-4 text-primary" />
|
||||
<h2 className="text-2xl font-bold mb-2 text-center">
|
||||
Looks like you've been invited!
|
||||
{t('inviteAlready')}
|
||||
</h2>
|
||||
<p className="text-center">
|
||||
To accept the invite, you must log in or create an
|
||||
account.
|
||||
{t('inviteAlreadyDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -65,7 +67,7 @@ export default async function Page(props: {
|
|||
|
||||
{(!signUpDisabled || isInvite) && (
|
||||
<p className="text-center text-muted-foreground mt-4">
|
||||
Don't have an account?{" "}
|
||||
{t('authNoAccount')}{" "}
|
||||
<Link
|
||||
href={
|
||||
!redirectUrl
|
||||
|
@ -74,7 +76,7 @@ export default async function Page(props: {
|
|||
}
|
||||
className="underline"
|
||||
>
|
||||
Sign up
|
||||
{t('signup')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue