mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-08 02:27:37 +00:00
Merge branch 'main' of https://github.com/Infisical/infisical
This commit is contained in:
@@ -452,7 +452,7 @@ export const updateSecrets = async (req: Request, res: Response) => {
|
|||||||
secretValueTag,
|
secretValueTag,
|
||||||
tags,
|
tags,
|
||||||
...((
|
...((
|
||||||
secretCommentCiphertext &&
|
secretCommentCiphertext !== undefined &&
|
||||||
secretCommentIV &&
|
secretCommentIV &&
|
||||||
secretCommentTag
|
secretCommentTag
|
||||||
) ? {
|
) ? {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
<p>{{inviterFirstName}}({{inviterEmail}}) has invited you to their Infisical organization — {{organizationName}}</p>
|
<p>{{inviterFirstName}}({{inviterEmail}}) has invited you to their Infisical organization — {{organizationName}}</p>
|
||||||
<a href="{{callback_url}}?token={{token}}&to={{email}}">Join now</a>
|
<a href="{{callback_url}}?token={{token}}&to={{email}}">Join now</a>
|
||||||
<h3>What is Infisical?</h3>
|
<h3>What is Infisical?</h3>
|
||||||
<p>Infisical is a simple end-to-end encrypted solution that enables teams to sync and manage their environment
|
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.</p>
|
||||||
variables.</p>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -8,9 +8,9 @@
|
|||||||
<body>
|
<body>
|
||||||
<h2>Infisical</h2>
|
<h2>Infisical</h2>
|
||||||
<h2>Join your team on Infisical</h2>
|
<h2>Join your team on Infisical</h2>
|
||||||
<p>{{inviterFirstName}}({{inviterEmail}}) has invited you to their Infisical workspace — {{workspaceName}}</p>
|
<p>{{inviterFirstName}}({{inviterEmail}}) has invited you to their Infisical project — {{workspaceName}}</p>
|
||||||
<a href="{{callback_url}}">Join now</a>
|
<a href="{{callback_url}}">Join now</a>
|
||||||
<h3>What is Infisical?</h3>
|
<h3>What is Infisical?</h3>
|
||||||
<p>Infisical is a simple end-to-end encrypted solution that enables teams to sync and manage their environment variables.</p>
|
<p>Infisical is an easy-to-use end-to-end encrypted tool that enables developers to sync and manage their secrets and configs.</p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+24
-15
@@ -201,21 +201,30 @@ type GetEncryptedSecretsV2Request struct {
|
|||||||
|
|
||||||
type GetEncryptedSecretsV2Response struct {
|
type GetEncryptedSecretsV2Response struct {
|
||||||
Secrets []struct {
|
Secrets []struct {
|
||||||
ID string `json:"_id"`
|
ID string `json:"_id"`
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Workspace string `json:"workspace"`
|
Workspace string `json:"workspace"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Environment string `json:"environment"`
|
Environment string `json:"environment"`
|
||||||
SecretKeyCiphertext string `json:"secretKeyCiphertext"`
|
SecretKeyCiphertext string `json:"secretKeyCiphertext"`
|
||||||
SecretKeyIV string `json:"secretKeyIV"`
|
SecretKeyIV string `json:"secretKeyIV"`
|
||||||
SecretKeyTag string `json:"secretKeyTag"`
|
SecretKeyTag string `json:"secretKeyTag"`
|
||||||
SecretValueCiphertext string `json:"secretValueCiphertext"`
|
SecretValueCiphertext string `json:"secretValueCiphertext"`
|
||||||
SecretValueIV string `json:"secretValueIV"`
|
SecretValueIV string `json:"secretValueIV"`
|
||||||
SecretValueTag string `json:"secretValueTag"`
|
SecretValueTag string `json:"secretValueTag"`
|
||||||
V int `json:"__v"`
|
SecretCommentCiphertext string `json:"secretCommentCiphertext"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
SecretCommentIV string `json:"secretCommentIV"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
SecretCommentTag string `json:"secretCommentTag"`
|
||||||
User string `json:"user,omitempty"`
|
V int `json:"__v"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
Tags []struct {
|
||||||
|
ID string `json:"_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Workspace string `json:"workspace"`
|
||||||
|
} `json:"tags"`
|
||||||
} `json:"secrets"`
|
} `json:"secrets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+182
-1
@@ -6,6 +6,8 @@ package cmd
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
@@ -22,7 +24,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var secretsCmd = &cobra.Command{
|
var secretsCmd = &cobra.Command{
|
||||||
Example: `infisical secrets"`,
|
Example: `infisical secrets`,
|
||||||
Short: "Used to create, read update and delete secrets",
|
Short: "Used to create, read update and delete secrets",
|
||||||
Use: "secrets",
|
Use: "secrets",
|
||||||
DisableFlagsInUseLine: true,
|
DisableFlagsInUseLine: true,
|
||||||
@@ -67,6 +69,16 @@ var secretsGetCmd = &cobra.Command{
|
|||||||
Run: getSecretsByNames,
|
Run: getSecretsByNames,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var secretsGenerateExampleEnvCmd = &cobra.Command{
|
||||||
|
Example: `secrets generate-example-env > .example-env`,
|
||||||
|
Short: "Used to generate a example .env file",
|
||||||
|
Use: "generate-example-env",
|
||||||
|
DisableFlagsInUseLine: true,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
PreRun: toggleDebug,
|
||||||
|
Run: generateExampleEnv,
|
||||||
|
}
|
||||||
|
|
||||||
var secretsSetCmd = &cobra.Command{
|
var secretsSetCmd = &cobra.Command{
|
||||||
Example: `secrets set <secretName=secretValue> <secretName=secretValue>..."`,
|
Example: `secrets set <secretName=secretValue> <secretName=secretValue>..."`,
|
||||||
Short: "Used set secrets",
|
Short: "Used set secrets",
|
||||||
@@ -357,6 +369,171 @@ func getSecretsByNames(cmd *cobra.Command, args []string) {
|
|||||||
visualize.PrintAllSecretDetails(requestedSecrets)
|
visualize.PrintAllSecretDetails(requestedSecrets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateExampleEnv(cmd *cobra.Command, args []string) {
|
||||||
|
environmentName, err := cmd.Flags().GetString("env")
|
||||||
|
if err != nil {
|
||||||
|
util.HandleError(err, "Unable to parse flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceFileExists := util.WorkspaceConfigFileExistsInCurrentPath()
|
||||||
|
if !workspaceFileExists {
|
||||||
|
util.HandleError(err, "Unable to parse flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
infisicalToken, err := cmd.Flags().GetString("token")
|
||||||
|
if err != nil {
|
||||||
|
util.HandleError(err, "Unable to parse flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
secrets, err := util.GetAllEnvironmentVariables(models.GetAllSecretsParameters{Environment: environmentName, InfisicalToken: infisicalToken})
|
||||||
|
if err != nil {
|
||||||
|
util.HandleError(err, "To fetch all secrets")
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsHashToSecretKey := make(map[string]int)
|
||||||
|
|
||||||
|
type TagsAndSecrets struct {
|
||||||
|
Secrets []models.SingleEnvironmentVariable
|
||||||
|
Tags []struct {
|
||||||
|
ID string `json:"_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Workspace string `json:"workspace"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort secrets by associated tags (most number of tags to least tags)
|
||||||
|
sort.Slice(secrets, func(i, j int) bool {
|
||||||
|
return len(secrets[i].Tags) > len(secrets[j].Tags)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, secret := range secrets {
|
||||||
|
listOfTagSlugs := []string{}
|
||||||
|
|
||||||
|
for _, tag := range secret.Tags {
|
||||||
|
listOfTagSlugs = append(listOfTagSlugs, tag.Slug)
|
||||||
|
}
|
||||||
|
sort.Strings(listOfTagSlugs)
|
||||||
|
|
||||||
|
tagsHash := util.GetHashFromStringList(listOfTagSlugs)
|
||||||
|
|
||||||
|
tagsHashToSecretKey[tagsHash] += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
finalTagHashToSecretKey := make(map[string]TagsAndSecrets)
|
||||||
|
|
||||||
|
for _, secret := range secrets {
|
||||||
|
listOfTagSlugs := []string{}
|
||||||
|
for _, tag := range secret.Tags {
|
||||||
|
listOfTagSlugs = append(listOfTagSlugs, tag.Slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort the slug so we get the same hash each time
|
||||||
|
sort.Strings(listOfTagSlugs)
|
||||||
|
|
||||||
|
tagsHash := util.GetHashFromStringList(listOfTagSlugs)
|
||||||
|
occurrence, exists := tagsHashToSecretKey[tagsHash]
|
||||||
|
if exists && occurrence > 0 {
|
||||||
|
|
||||||
|
value, exists2 := finalTagHashToSecretKey[tagsHash]
|
||||||
|
allSecretsForTags := append(value.Secrets, secret)
|
||||||
|
|
||||||
|
// sort the the secrets by keys so that they can later be sorted by the first item in the secrets array
|
||||||
|
sort.Slice(allSecretsForTags, func(i, j int) bool {
|
||||||
|
return allSecretsForTags[i].Key < allSecretsForTags[j].Key
|
||||||
|
})
|
||||||
|
|
||||||
|
if exists2 {
|
||||||
|
finalTagHashToSecretKey[tagsHash] = TagsAndSecrets{
|
||||||
|
Tags: secret.Tags,
|
||||||
|
Secrets: allSecretsForTags,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finalTagHashToSecretKey[tagsHash] = TagsAndSecrets{
|
||||||
|
Tags: secret.Tags,
|
||||||
|
Secrets: []models.SingleEnvironmentVariable{secret},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsHashToSecretKey[tagsHash] -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort the fianl result by secret key fo consistent print order
|
||||||
|
listOfsecretDetails := make([]TagsAndSecrets, 0, len(finalTagHashToSecretKey))
|
||||||
|
for _, secretDetails := range finalTagHashToSecretKey {
|
||||||
|
listOfsecretDetails = append(listOfsecretDetails, secretDetails)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort the order of the headings by the order of the secrets
|
||||||
|
sort.Slice(listOfsecretDetails, func(i, j int) bool {
|
||||||
|
return len(listOfsecretDetails[i].Tags) < len(listOfsecretDetails[j].Tags)
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, secretDetails := range listOfsecretDetails {
|
||||||
|
listOfKeyValue := []string{}
|
||||||
|
|
||||||
|
for _, secret := range secretDetails.Secrets {
|
||||||
|
re := regexp.MustCompile(`(?s)(.*)DEFAULT:(.*)`)
|
||||||
|
match := re.FindStringSubmatch(secret.Comment)
|
||||||
|
defaultValue := ""
|
||||||
|
comment := secret.Comment
|
||||||
|
|
||||||
|
// Case: Only has default value
|
||||||
|
if len(match) == 2 {
|
||||||
|
defaultValue = strings.TrimSpace(match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case: has a comment and a default value
|
||||||
|
if len(match) == 3 {
|
||||||
|
comment = match[1]
|
||||||
|
defaultValue = match[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
row := ""
|
||||||
|
if comment != "" {
|
||||||
|
comment = addHash(comment)
|
||||||
|
row = fmt.Sprintf("%s \n%s=%s", strings.TrimSpace(comment), strings.TrimSpace(secret.Key), strings.TrimSpace(defaultValue))
|
||||||
|
} else {
|
||||||
|
row = fmt.Sprintf("%s=%s", strings.TrimSpace(secret.Key), strings.TrimSpace(defaultValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
// each secret row to be added to the file
|
||||||
|
listOfKeyValue = append(listOfKeyValue, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
listOfTagNames := []string{}
|
||||||
|
for _, tag := range secretDetails.Tags {
|
||||||
|
listOfTagNames = append(listOfTagNames, tag.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
heading := CenterString(strings.Join(listOfTagNames, " & "), 80)
|
||||||
|
|
||||||
|
if len(listOfTagNames) == 0 {
|
||||||
|
fmt.Printf("\n%s \n", strings.Join(listOfKeyValue, "\n \n"))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("\n\n\n%s\n \n%s \n", heading, strings.Join(listOfKeyValue, "\n \n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func CenterString(s string, numStars int) string {
|
||||||
|
stars := strings.Repeat("*", numStars)
|
||||||
|
padding := (numStars - len(s)) / 2
|
||||||
|
cenetredTextWithStar := stars[:padding] + " " + strings.ToUpper(s) + " " + stars[padding:]
|
||||||
|
|
||||||
|
hashes := strings.Repeat("#", len(cenetredTextWithStar)+2)
|
||||||
|
return fmt.Sprintf("%s \n# %s \n%s", hashes, cenetredTextWithStar, hashes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addHash(input string) string {
|
||||||
|
lines := strings.Split(input, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
lines[i] = "# " + line
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
func getSecretsByKeys(secrets []models.SingleEnvironmentVariable) map[string]models.SingleEnvironmentVariable {
|
func getSecretsByKeys(secrets []models.SingleEnvironmentVariable) map[string]models.SingleEnvironmentVariable {
|
||||||
secretMapByName := make(map[string]models.SingleEnvironmentVariable)
|
secretMapByName := make(map[string]models.SingleEnvironmentVariable)
|
||||||
|
|
||||||
@@ -368,6 +545,10 @@ func getSecretsByKeys(secrets []models.SingleEnvironmentVariable) map[string]mod
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
||||||
|
secretsGenerateExampleEnvCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token")
|
||||||
|
secretsCmd.AddCommand(secretsGenerateExampleEnvCmd)
|
||||||
|
|
||||||
secretsGetCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token")
|
secretsGetCmd.Flags().String("token", "", "Fetch secrets using the Infisical Token")
|
||||||
secretsCmd.AddCommand(secretsGetCmd)
|
secretsCmd.AddCommand(secretsGetCmd)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import (
|
|||||||
|
|
||||||
// will decrypt cipher text to plain text using iv and tag
|
// will decrypt cipher text to plain text using iv and tag
|
||||||
func DecryptSymmetric(key []byte, cipherText []byte, tag []byte, iv []byte) ([]byte, error) {
|
func DecryptSymmetric(key []byte, cipherText []byte, tag []byte, iv []byte) ([]byte, error) {
|
||||||
|
// Case: empty string
|
||||||
|
if len(cipherText) == 0 && len(tag) == 0 && len(iv) == 0 {
|
||||||
|
return []byte{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
block, err := aes.NewCipher(key)
|
block, err := aes.NewCipher(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "github.com/99designs/keyring"
|
import (
|
||||||
|
"github.com/99designs/keyring"
|
||||||
|
)
|
||||||
|
|
||||||
type UserCredentials struct {
|
type UserCredentials struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -19,6 +21,13 @@ type SingleEnvironmentVariable struct {
|
|||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ID string `json:"_id"`
|
ID string `json:"_id"`
|
||||||
|
Tags []struct {
|
||||||
|
ID string `json:"_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Workspace string `json:"workspace"`
|
||||||
|
} `json:"tags"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Workspace struct {
|
type Workspace struct {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -98,3 +99,14 @@ func RequireLocalWorkspaceFile() {
|
|||||||
PrintMessageAndExit("Your project id is missing in your local config file. Please add it or run again [infisical init]")
|
PrintMessageAndExit("Your project id is missing in your local config file. Please add it or run again [infisical init]")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetHashFromStringList(list []string) string {
|
||||||
|
hash := sha256.New()
|
||||||
|
|
||||||
|
for _, item := range list {
|
||||||
|
hash.Write([]byte(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := sha256.Sum256(hash.Sum(nil))
|
||||||
|
return fmt.Sprintf("%x", sum)
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func PrintErrorAndExit(exitCode int, err error, messages ...string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func PrintWarning(message string) {
|
func PrintWarning(message string) {
|
||||||
color.Yellow("Warning: %v", message)
|
color.New(color.FgYellow).Fprintf(os.Stderr, "Warning: %v \n", message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func PrintMessageAndExit(messages ...string) {
|
func PrintMessageAndExit(messages ...string) {
|
||||||
|
|||||||
@@ -315,11 +315,34 @@ func GetPlainTextSecrets(key []byte, encryptedSecrets api.GetEncryptedSecretsV2R
|
|||||||
return nil, fmt.Errorf("unable to symmetrically decrypt secret value")
|
return nil, fmt.Errorf("unable to symmetrically decrypt secret value")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decrypt comment
|
||||||
|
comment_iv, err := base64.StdEncoding.DecodeString(secret.SecretCommentIV)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to decode secret IV for secret value")
|
||||||
|
}
|
||||||
|
|
||||||
|
comment_tag, err := base64.StdEncoding.DecodeString(secret.SecretCommentTag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to decode secret authentication tag for secret value")
|
||||||
|
}
|
||||||
|
|
||||||
|
comment_ciphertext, _ := base64.StdEncoding.DecodeString(secret.SecretCommentCiphertext)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to decode secret cipher text for secret key")
|
||||||
|
}
|
||||||
|
|
||||||
|
plainTextComment, err := crypto.DecryptSymmetric(key, comment_ciphertext, comment_tag, comment_iv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to symmetrically decrypt secret comment")
|
||||||
|
}
|
||||||
|
|
||||||
plainTextSecret := models.SingleEnvironmentVariable{
|
plainTextSecret := models.SingleEnvironmentVariable{
|
||||||
Key: string(plainTextKey),
|
Key: string(plainTextKey),
|
||||||
Value: string(plainTextValue),
|
Value: string(plainTextValue),
|
||||||
Type: string(secret.Type),
|
Type: string(secret.Type),
|
||||||
ID: secret.ID,
|
ID: secret.ID,
|
||||||
|
Tags: secret.Tags,
|
||||||
|
Comment: string(plainTextComment),
|
||||||
}
|
}
|
||||||
|
|
||||||
plainTextSecrets = append(plainTextSecrets, plainTextSecret)
|
plainTextSecrets = append(plainTextSecrets, plainTextSecret)
|
||||||
|
|||||||
@@ -246,9 +246,9 @@ const Layout = ({ children }: LayoutProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex h-screen w-full flex-col overflow-x-hidden">
|
<div className="h-screen w-full flex-col overflow-x-hidden hidden md:flex">
|
||||||
<NavBarDashboard />
|
<NavBarDashboard />
|
||||||
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row">
|
<div className="flex flex-grow flex-col overflow-y-hidden md:flex-row dark">
|
||||||
<aside className="w-full border-r border-mineshaft-500 bg-bunker-600 md:w-60">
|
<aside className="w-full border-r border-mineshaft-500 bg-bunker-600 md:w-60">
|
||||||
<nav className="items-between flex h-full flex-col justify-between">
|
<nav className="items-between flex h-full flex-col justify-between">
|
||||||
{/* <div className="py-6"></div> */}
|
{/* <div className="py-6"></div> */}
|
||||||
@@ -368,10 +368,10 @@ const Layout = ({ children }: LayoutProps) => {
|
|||||||
error={error}
|
error={error}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800">{children}</main>
|
<main className="flex-1 overflow-y-auto overflow-x-hidden bg-bunker-800 dark:[color-scheme:dark]">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 md:hidden">
|
<div className="flex h-screen w-screen flex-col items-center justify-center bg-bunker-800 z-[200] md:hidden">
|
||||||
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
|
<FontAwesomeIcon icon={faMobile} className="mb-8 text-7xl text-gray-300" />
|
||||||
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
|
<p className="max-w-sm px-6 text-center text-lg text-gray-200">
|
||||||
{` ${t('common:no-mobile')} `}
|
{` ${t('common:no-mobile')} `}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const BottonRightPopup = ({
|
|||||||
}: PopupProps): JSX.Element => {
|
}: PopupProps): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="z-50 drop-shadow-xl border-gray-600/50 border flex flex-col items-start bg-bunker max-w-xl text-gray-200 pt-3 pb-4 rounded-md absolute bottom-0 right-0 mr-6 mb-6"
|
className="z-[100] drop-shadow-xl border-gray-600/50 border flex flex-col items-start bg-bunker max-w-xl text-gray-200 pt-3 pb-4 rounded-md absolute bottom-0 right-0 mr-6 mb-6"
|
||||||
role="alert"
|
role="alert"
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full border-b border-gray-600/70 pb-3 px-6">
|
<div className="flex flex-row items-center justify-between w-full border-b border-gray-600/70 pb-3 px-6">
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import { Menu, Transition } from '@headlessui/react';
|
|||||||
import { Tag } from 'public/data/frequentInterfaces';
|
import { Tag } from 'public/data/frequentInterfaces';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the menu that is used to download secrets as .env ad .yml files (in future we may have more options)
|
* This is the menu that is used to add more tags to a secret
|
||||||
* @param {object} obj
|
* @param {object} obj
|
||||||
* @param {SecretDataProps[]} obj.data -
|
* @param {Tag[]} obj.allTags - all available tags for a vertain project
|
||||||
*
|
* @param {Tag[]} obj.currentTags - currently selected tags for a certain secret
|
||||||
|
* @param {function} obj.modifyTags - modify tags for a certain secret
|
||||||
|
* @param {Tag[]} obj.position - currently selected tags for a certain secret
|
||||||
*/
|
*/
|
||||||
const AddTagsMenu = ({ allTags, currentTags, modifyTags, position }: { allTags: Tag[]; currentTags: Tag[]; modifyTags: (value: Tag[], position: number) => void; position: number; }) => {
|
const AddTagsMenu = ({ allTags, currentTags, modifyTags, position }: { allTags: Tag[]; currentTags: Tag[]; modifyTags: (value: Tag[], position: number) => void; position: number; }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -21,7 +23,7 @@ const AddTagsMenu = ({ allTags, currentTags, modifyTags, position }: { allTags:
|
|||||||
>
|
>
|
||||||
<div className='bg-mineshaft/30 cursor-pointer rounded-sm text-sm text-mineshaft-200/50 hover:bg-mineshaft/70 duration-200 flex items-center'>
|
<div className='bg-mineshaft/30 cursor-pointer rounded-sm text-sm text-mineshaft-200/50 hover:bg-mineshaft/70 duration-200 flex items-center'>
|
||||||
<FontAwesomeIcon icon={faPlus} className="p-[0.28rem]"/>
|
<FontAwesomeIcon icon={faPlus} className="p-[0.28rem]"/>
|
||||||
{currentTags.length > 2 && <span className='pr-2'>{currentTags.length - 2}</span>}
|
{currentTags?.length > 2 && <span className='pr-2'>{currentTags.length - 2}</span>}
|
||||||
</div>
|
</div>
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
<Transition
|
<Transition
|
||||||
@@ -38,10 +40,10 @@ const AddTagsMenu = ({ allTags, currentTags, modifyTags, position }: { allTags:
|
|||||||
<Menu.Item key={tag._id}>
|
<Menu.Item key={tag._id}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${currentTags.map(currentTag => currentTag.name).includes(tag.name) ? "opacity-30 cursor-default" : "hover:bg-mineshaft-700"} w-full text-left bg-mineshaft-800 px-2 py-0.5 text-bunker-200 rounded-sm flex items-center`}
|
className={`${currentTags?.map(currentTag => currentTag.name).includes(tag.name) ? "opacity-30 cursor-default" : "hover:bg-mineshaft-700"} w-full text-left bg-mineshaft-800 px-2 py-0.5 text-bunker-200 rounded-sm flex items-center`}
|
||||||
onClick={() => {if (!currentTags.map(currentTag => currentTag.name).includes(tag.name)) {modifyTags(currentTags.concat([tag]), position)}}}
|
onClick={() => {if (!currentTags?.map(currentTag => currentTag.name).includes(tag.name)) {modifyTags(currentTags.concat([tag]), position)}}}
|
||||||
>
|
>
|
||||||
{currentTags.map(currentTag => currentTag.name).includes(tag.name) ? <FontAwesomeIcon icon={faCheckSquare} className="text-xs mr-2 text-primary"/> : <FontAwesomeIcon icon={faSquare} className="text-xs mr-2"/>} {tag.name}
|
{currentTags?.map(currentTag => currentTag.name).includes(tag.name) ? <FontAwesomeIcon icon={faCheckSquare} className="text-xs mr-2 text-primary"/> : <FontAwesomeIcon icon={faSquare} className="text-xs mr-2"/>} {tag.name}
|
||||||
</button>
|
</button>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)})}
|
)})}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const CommentField = ({
|
|||||||
<div className="relative mt-4 px-4 pt-6">
|
<div className="relative mt-4 px-4 pt-6">
|
||||||
<p className="text-sm text-bunker-300 pl-0.5">{t('dashboard:sidebar.comments')}</p>
|
<p className="text-sm text-bunker-300 pl-0.5">{t('dashboard:sidebar.comments')}</p>
|
||||||
<textarea
|
<textarea
|
||||||
className="placeholder:text-bunker-400 h-32 w-full bg-bunker-800 px-2 py-1.5 rounded-md border border-mineshaft-500 text-sm text-bunker-300 outline-none focus:ring-2 ring-primary-800 ring-opacity-70"
|
className="placeholder:text-bunker-400 dark:[color-scheme:dark] h-32 w-full bg-bunker-800 px-2 py-1.5 rounded-md border border-mineshaft-500 text-sm text-bunker-300 outline-none focus:ring-2 ring-primary-800 ring-opacity-70"
|
||||||
value={comment}
|
value={comment}
|
||||||
onChange={(e) => modifyComment(e.target.value, position)}
|
onChange={(e) => modifyComment(e.target.value, position)}
|
||||||
placeholder="Leave any comments here..."
|
placeholder="Leave any comments here..."
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ const DashboardInputField = ({
|
|||||||
const error = startsWithNumber || isDuplicate;
|
const error = startsWithNumber || isDuplicate;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative flex-col w-full h-10 ${
|
<div title={value} className={`relative flex-col w-full h-10 ${
|
||||||
isSideBarOpen && 'bg-mineshaft-700 duration-200'
|
isSideBarOpen && 'bg-mineshaft-700 duration-200'
|
||||||
}`}>
|
}`}>
|
||||||
<div
|
<div
|
||||||
@@ -137,7 +137,7 @@ const DashboardInputField = ({
|
|||||||
onChange={(e) => onChangeHandler(e.target.value, position)}
|
onChange={(e) => onChangeHandler(e.target.value, position)}
|
||||||
type={type}
|
type={type}
|
||||||
value={value}
|
value={value}
|
||||||
className='z-10 peer font-mono ph-no-capture bg-transparent py-2.5 caret-bunker-200 text-sm px-2 w-full min-w-16 outline-none text-bunker-300 focus:text-bunker-100 placeholder:text-bunker-400 placeholder:focus:text-transparent placeholder duration-200'
|
className='z-10 peer ph-no-capture bg-transparent py-2.5 caret-bunker-200 text-sm px-2 w-full min-w-16 outline-none text-bunker-300 focus:text-bunker-100 placeholder:text-bunker-400 placeholder:focus:text-transparent placeholder duration-200'
|
||||||
spellCheck="false"
|
spellCheck="false"
|
||||||
placeholder='–'
|
placeholder='–'
|
||||||
/>
|
/>
|
||||||
@@ -209,7 +209,7 @@ const DashboardInputField = ({
|
|||||||
{value?.split('').map(() => (
|
{value?.split('').map(() => (
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
key={guidGenerator()}
|
key={guidGenerator()}
|
||||||
className="text-xxs mx-0.5"
|
className="text-xxs mr-0.5"
|
||||||
icon={faCircle}
|
icon={faCircle}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ const colors = [
|
|||||||
'bg-[#cb1c8d]/40',
|
'bg-[#cb1c8d]/40',
|
||||||
'bg-[#badc58]/40',
|
'bg-[#badc58]/40',
|
||||||
'bg-[#ff5400]/40',
|
'bg-[#ff5400]/40',
|
||||||
'bg-[#00bbf9]/40'
|
'bg-[#3AB0FF]/40',
|
||||||
|
'bg-[#6F1AB6]/40',
|
||||||
|
'bg-[#C40B13]/40',
|
||||||
|
'bg-[#332FD0]/40'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -43,7 +46,10 @@ const colorsText = [
|
|||||||
'text-[#f2c6e3]/70',
|
'text-[#f2c6e3]/70',
|
||||||
'text-[#eef6d5]/70',
|
'text-[#eef6d5]/70',
|
||||||
'text-[#ffddcc]/70',
|
'text-[#ffddcc]/70',
|
||||||
'text-[#f0fffd]/70'
|
'text-[#f0fffd]/70',
|
||||||
|
'text-[#FFE5F1]/70',
|
||||||
|
'text-[#FFDEDE]/70',
|
||||||
|
'text-[#DFF6FF]/70'
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,9 +101,9 @@ const KeyPair = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="relative flex flex-row justify-between w-full mr-auto max-h-14 items-center">
|
<div className="relative flex flex-row justify-between w-full mr-auto max-h-14 items-center">
|
||||||
<div className="w-2/12 border-r border-mineshaft-600 flex flex-row items-center">
|
<div className="w-1/5 border-r border-mineshaft-600 flex flex-row items-center">
|
||||||
<div className='text-bunker-400 text-xs flex items-center justify-center w-14 h-10 cursor-default'>{keyPair.pos + 1}</div>
|
<div className='text-bunker-400 text-xs flex items-center justify-center w-14 h-10 cursor-default'>{keyPair.pos + 1}</div>
|
||||||
<div className="flex items-center max-h-16">
|
<div className="flex items-center max-h-16 w-full">
|
||||||
<DashboardInputField
|
<DashboardInputField
|
||||||
isCapitalized = {isCapitalized}
|
isCapitalized = {isCapitalized}
|
||||||
onChangeHandler={modifyKey}
|
onChangeHandler={modifyKey}
|
||||||
@@ -140,7 +146,7 @@ const KeyPair = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-2/12 h-10 flex items-center overflow-visible overflow-r-scroll no-scrollbar no-scrollbar::-webkit-scrollbar">
|
<div className="w-2/12 h-10 flex items-center overflow-visible overflow-r-scroll no-scrollbar no-scrollbar::-webkit-scrollbar">
|
||||||
<div className="flex items-center max-h-16">
|
<div className="flex items-center max-h-16">
|
||||||
{keyPair.tags.map((tag, index) => (
|
{keyPair.tags?.map((tag, index) => (
|
||||||
index < 2 && <div key={keyPair.pos} className={`ml-2 px-1.5 ${tagData.filter(tagDp => tagDp._id === tag._id)[0]?.color} rounded-sm text-sm ${tagData.filter(tagDp => tagDp._id === tag._id)[0]?.colorText} flex items-center`}>
|
index < 2 && <div key={keyPair.pos} className={`ml-2 px-1.5 ${tagData.filter(tagDp => tagDp._id === tag._id)[0]?.color} rounded-sm text-sm ${tagData.filter(tagDp => tagDp._id === tag._id)[0]?.colorText} flex items-center`}>
|
||||||
<span className='mb-0.5 cursor-default'>{tag.name}</span>
|
<span className='mb-0.5 cursor-default'>{tag.name}</span>
|
||||||
<FontAwesomeIcon icon={faXmark} className="ml-1 cursor-pointer p-1" onClick={() => modifyTags(keyPair.tags.filter(ttag => ttag._id !== tag._id), keyPair.pos)}/>
|
<FontAwesomeIcon icon={faXmark} className="ml-1 cursor-pointer p-1" onClick={() => modifyTags(keyPair.tags.filter(ttag => ttag._id !== tag._id), keyPair.pos)}/>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default function Navbar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row justify-between w-full bg-bunker text-white border-b border-mineshaft-500 z-50">
|
<div className="flex flex-row justify-between w-full bg-bunker text-white border-b border-mineshaft-500 z-[61]">
|
||||||
<div className="m-auto flex justify-start items-center mx-4">
|
<div className="m-auto flex justify-start items-center mx-4">
|
||||||
<div className="flex flex-row items-center">
|
<div className="flex flex-row items-center">
|
||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
@@ -166,8 +166,8 @@ export default function Navbar() {
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items className="absolute right-0 mt-0.5 w-64 origin-top-right divide-y divide-gray-700 rounded-md bg-bunker border border-mineshaft-700 shadow-lg ring-1 ring-black z-20 ring-opacity-5 focus:outline-none">
|
<Menu.Items className="absolute right-0 mt-0.5 w-64 origin-top-right divide-y divide-gray-700 rounded-md bg-bunker border border-mineshaft-700 shadow-lg ring-1 ring-black z-[65] ring-opacity-5 focus:outline-none">
|
||||||
<div className="px-1 py-1 ">
|
<div className="px-1 py-1 z-[100]">
|
||||||
<div className="text-gray-400 self-start ml-2 mt-2 text-xs font-semibold tracking-wide">
|
<div className="text-gray-400 self-start ml-2 mt-2 text-xs font-semibold tracking-wide">
|
||||||
{t('nav:user.signed-in-as')}
|
{t('nav:user.signed-in-as')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const attemptLogin = async (
|
|||||||
// eslint-disable-next-line no-template-curly-in-string
|
// eslint-disable-next-line no-template-curly-in-string
|
||||||
value: 'mongodb+srv://${DB_USERNAME}:${DB_PASSWORD}@mongodb.net',
|
value: 'mongodb+srv://${DB_USERNAME}:${DB_PASSWORD}@mongodb.net',
|
||||||
valueOverride: undefined,
|
valueOverride: undefined,
|
||||||
comment: 'This is an example of secret referencing.',
|
comment: 'Secret referencing example',
|
||||||
id: '',
|
id: '',
|
||||||
tags: []
|
tags: []
|
||||||
},
|
},
|
||||||
@@ -147,7 +147,7 @@ const attemptLogin = async (
|
|||||||
value: 'OVERRIDE_THIS',
|
value: 'OVERRIDE_THIS',
|
||||||
valueOverride: undefined,
|
valueOverride: undefined,
|
||||||
comment:
|
comment:
|
||||||
'This is an example of secret overriding. Your team can have a shared value of a secret, while you can override it to whatever value you need',
|
'Override secrets with personal value',
|
||||||
id: '',
|
id: '',
|
||||||
tags: []
|
tags: []
|
||||||
},
|
},
|
||||||
@@ -157,7 +157,7 @@ const attemptLogin = async (
|
|||||||
value: 'OVERRIDE_THIS',
|
value: 'OVERRIDE_THIS',
|
||||||
valueOverride: undefined,
|
valueOverride: undefined,
|
||||||
comment:
|
comment:
|
||||||
'This is an example of secret overriding. Your team can have a shared value of a secret, while you can override it to whatever value you need',
|
'Another secret override',
|
||||||
id: '',
|
id: '',
|
||||||
tags: []
|
tags: []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -446,8 +446,8 @@ export default function Dashboard() {
|
|||||||
initDataPoint.key ||
|
initDataPoint.key ||
|
||||||
newData!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0].comment !==
|
newData!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0].comment !==
|
||||||
initDataPoint.comment) ||
|
initDataPoint.comment) ||
|
||||||
newData!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0].tags !==
|
newData!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0]?.tags !==
|
||||||
initDataPoint.tags
|
initDataPoint?.tags
|
||||||
)
|
)
|
||||||
.map((secret) => secret.id)
|
.map((secret) => secret.id)
|
||||||
.includes(newDataPoint.id)
|
.includes(newDataPoint.id)
|
||||||
@@ -498,8 +498,7 @@ export default function Dashboard() {
|
|||||||
initDataPoint.key ||
|
initDataPoint.key ||
|
||||||
newOverrides!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0]
|
newOverrides!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0]
|
||||||
.comment !== initDataPoint.comment ||
|
.comment !== initDataPoint.comment ||
|
||||||
newOverrides!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0]
|
newOverrides!.filter((dataPoint) => dataPoint.id === initDataPoint.id)[0]?.tags !== initDataPoint?.tags)
|
||||||
.tags !== initDataPoint.tags)
|
|
||||||
)
|
)
|
||||||
.map((secret) => secret.id)
|
.map((secret) => secret.id)
|
||||||
.includes(newDataPoint.id)
|
.includes(newDataPoint.id)
|
||||||
@@ -825,7 +824,7 @@ export default function Dashboard() {
|
|||||||
className='group flex flex-col items-center bg-mineshaft-800 border-b-2 border-mineshaft-500 duration-100 sticky top-0 z-[60]'
|
className='group flex flex-col items-center bg-mineshaft-800 border-b-2 border-mineshaft-500 duration-100 sticky top-0 z-[60]'
|
||||||
>
|
>
|
||||||
<div className="relative flex flex-row justify-between w-full mr-auto max-h-14 items-center">
|
<div className="relative flex flex-row justify-between w-full mr-auto max-h-14 items-center">
|
||||||
<div className="w-2/12 border-r border-mineshaft-600 flex flex-row items-center">
|
<div className="w-1/5 border-r border-mineshaft-600 flex flex-row items-center">
|
||||||
<div className='text-transparent text-xs flex items-center justify-center w-14 h-10 cursor-default'>0</div>
|
<div className='text-transparent text-xs flex items-center justify-center w-14 h-10 cursor-default'>0</div>
|
||||||
<span className='px-2 text-bunker-300 font-semibold'>Key</span>
|
<span className='px-2 text-bunker-300 font-semibold'>Key</span>
|
||||||
{!snapshotData && <IconButton
|
{!snapshotData && <IconButton
|
||||||
@@ -872,12 +871,15 @@ export default function Dashboard() {
|
|||||||
<div className="bg-mineshaft-800 rounded-b-md border-bunker-600">
|
<div className="bg-mineshaft-800 rounded-b-md border-bunker-600">
|
||||||
{!snapshotData &&
|
{!snapshotData &&
|
||||||
data
|
data
|
||||||
?.filter((row) => row.key?.toUpperCase().includes(searchKeys.toUpperCase()) || row.tags?.map(tag => tag.name).join(" ")?.toUpperCase().includes(searchKeys.toUpperCase()))
|
?.filter((row) =>
|
||||||
|
row.key?.toUpperCase().includes(searchKeys.toUpperCase())
|
||||||
|
|| row.tags?.map(tag => tag.name).join(" ")?.toUpperCase().includes(searchKeys.toUpperCase())
|
||||||
|
|| row.comment?.toUpperCase().includes(searchKeys.toUpperCase()))
|
||||||
.filter((row) => !sharedToHide.includes(row.id))
|
.filter((row) => !sharedToHide.includes(row.id))
|
||||||
.map((keyPair) => (
|
.map((keyPair) => (
|
||||||
<KeyPair
|
<KeyPair
|
||||||
isCapitalized={autoCapitalization}
|
isCapitalized={autoCapitalization}
|
||||||
key={keyPair.id}
|
key={keyPair.id ? keyPair.id : keyPair.idOverride}
|
||||||
keyPair={keyPair}
|
keyPair={keyPair}
|
||||||
modifyValue={listenChangeValue}
|
modifyValue={listenChangeValue}
|
||||||
modifyValueOverride={listenChangeValueOverride}
|
modifyValueOverride={listenChangeValueOverride}
|
||||||
@@ -993,7 +995,7 @@ export default function Dashboard() {
|
|||||||
toggleSidebar={toggleSidebar}
|
toggleSidebar={toggleSidebar}
|
||||||
data={data.filter(
|
data={data.filter(
|
||||||
(row: SecretDataProps) =>
|
(row: SecretDataProps) =>
|
||||||
row.key === data.filter((r) => r.id === sidebarSecretId)[0]?.key
|
row.id === sidebarSecretId
|
||||||
)}
|
)}
|
||||||
modifyKey={listenChangeKey}
|
modifyKey={listenChangeKey}
|
||||||
modifyValue={listenChangeValue}
|
modifyValue={listenChangeValue}
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ export default function Home() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-6 lg:mx-0 w-full overflow-y-scroll pt-20 h-screen">
|
<div className="mx-6 lg:mx-0 w-full overflow-y-scroll pt-4">
|
||||||
<div className="flex flex-col items-center text-gray-300 text-lg mx-auto max-w-2xl lg:max-w-3xl xl:max-w-4xl py-6">
|
<div className="flex flex-col items-center text-gray-300 text-lg mx-auto max-w-2xl lg:max-w-3xl xl:max-w-4xl py-6">
|
||||||
<div className="text-3xl font-bold text-left w-full">Your quick start guide</div>
|
<div className="text-3xl font-bold text-left w-full">Your quick start guide</div>
|
||||||
<div className="text-md text-left w-full pt-2 pb-4 text-bunker-300">
|
<div className="text-md text-left w-full pt-2 pb-4 text-bunker-300">
|
||||||
|
|||||||
Reference in New Issue
Block a user