Merge branch 'feature/setup-token-security' of github.com:adrianeastles/pangolin into adrianeastles-feature/setup-token-security

This commit is contained in:
Owen 2025-08-12 21:12:55 -07:00
commit 4f3cd71e1e
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
15 changed files with 447 additions and 18 deletions

View file

@ -215,6 +215,28 @@ func main() {
} }
} else { } else {
fmt.Println("Looks like you already installed, so I am going to do the setup...") fmt.Println("Looks like you already installed, so I am going to do the setup...")
// Read existing config to get DashboardDomain
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml")
if err != nil {
fmt.Printf("Warning: Could not read existing config: %v\n", err)
fmt.Println("You may need to manually enter your domain information.")
config = collectUserInput(reader)
} else {
config.DashboardDomain = traefikConfig.DashboardDomain
config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail
config.BadgerVersion = traefikConfig.BadgerVersion
// Show detected values and allow user to confirm or re-enter
fmt.Println("Detected existing configuration:")
fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain)
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
if !readBool(reader, "Are these values correct?", true) {
config = collectUserInput(reader)
}
}
} }
if !checkIsCrowdsecInstalledInCompose() { if !checkIsCrowdsecInstalledInCompose() {
@ -252,6 +274,23 @@ func main() {
} }
} }
// Setup Token Section
fmt.Println("\n=== Setup Token ===")
// Check if containers were started during this installation
containersStarted := false
if (isDockerInstalled() && chosenContainer == Docker) ||
(isPodmanInstalled() && chosenContainer == Podman) {
// Try to fetch and display the token if containers are running
containersStarted = true
printSetupToken(chosenContainer, config.DashboardDomain)
}
// If containers weren't started or token wasn't found, show instructions
if !containersStarted {
showSetupTokenInstructions(chosenContainer, config.DashboardDomain)
}
fmt.Println("Installation complete!") fmt.Println("Installation complete!")
fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain) fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain)
} }
@ -315,10 +354,16 @@ func collectUserInput(reader *bufio.Reader) Config {
// Basic configuration // Basic configuration
fmt.Println("\n=== Basic Configuration ===") fmt.Println("\n=== Basic Configuration ===")
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain)
config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true) // Set default dashboard domain after base domain is collected
defaultDashboardDomain := ""
if config.BaseDomain != "" {
defaultDashboardDomain = "pangolin." + config.BaseDomain
}
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", defaultDashboardDomain)
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "") config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true) config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true)
config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true)
// Email configuration // Email configuration
fmt.Println("\n=== Email Configuration ===") fmt.Println("\n=== Email Configuration ===")
@ -769,6 +814,91 @@ func waitForContainer(containerName string, containerType SupportedContainer) er
return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds())) return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds()))
} }
func printSetupToken(containerType SupportedContainer, dashboardDomain string) {
fmt.Println("Waiting for Pangolin to generate setup token...")
// Wait for Pangolin to be healthy
if err := waitForContainer("pangolin", containerType); err != nil {
fmt.Println("Warning: Pangolin container did not become healthy in time.")
return
}
// Give a moment for the setup token to be generated
time.Sleep(2 * time.Second)
// Fetch logs
var cmd *exec.Cmd
if containerType == Docker {
cmd = exec.Command("docker", "logs", "pangolin")
} else {
cmd = exec.Command("podman", "logs", "pangolin")
}
output, err := cmd.Output()
if err != nil {
fmt.Println("Warning: Could not fetch Pangolin logs to find setup token.")
return
}
// Parse for setup token
lines := strings.Split(string(output), "\n")
for i, line := range lines {
if strings.Contains(line, "=== SETUP TOKEN GENERATED ===") || strings.Contains(line, "=== SETUP TOKEN EXISTS ===") {
// Look for "Token: ..." in the next few lines
for j := i + 1; j < i+5 && j < len(lines); j++ {
trimmedLine := strings.TrimSpace(lines[j])
if strings.Contains(trimmedLine, "Token:") {
// Extract token after "Token:"
tokenStart := strings.Index(trimmedLine, "Token:")
if tokenStart != -1 {
token := strings.TrimSpace(trimmedLine[tokenStart+6:])
fmt.Printf("Setup token: %s\n", token)
fmt.Println("")
fmt.Println("This token is required to register the first admin account in the web UI at:")
fmt.Printf("https://%s/auth/initial-setup\n", dashboardDomain)
fmt.Println("")
fmt.Println("Save this token securely. It will be invalid after the first admin is created.")
return
}
}
}
}
}
fmt.Println("Warning: Could not find a setup token in Pangolin logs.")
}
func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomain string) {
fmt.Println("\n=== Setup Token Instructions ===")
fmt.Println("To get your setup token, you need to:")
fmt.Println("")
fmt.Println("1. Start the containers:")
if containerType == Docker {
fmt.Println(" docker-compose up -d")
} else {
fmt.Println(" podman-compose up -d")
}
fmt.Println("")
fmt.Println("2. Wait for the Pangolin container to start and generate the token")
fmt.Println("")
fmt.Println("3. Check the container logs for the setup token:")
if containerType == Docker {
fmt.Println(" docker logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
} else {
fmt.Println(" podman logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'")
}
fmt.Println("")
fmt.Println("4. Look for output like:")
fmt.Println(" === SETUP TOKEN GENERATED ===")
fmt.Println(" Token: [your-token-here]")
fmt.Println(" Use this token on the initial setup page")
fmt.Println("")
fmt.Println("5. Use the token to complete initial setup at:")
fmt.Printf(" https://%s/auth/initial-setup\n", dashboardDomain)
fmt.Println("")
fmt.Println("The setup token is required to register the first admin account.")
fmt.Println("Save it securely - it will be invalid after the first admin is created.")
fmt.Println("================================")
}
func generateRandomSecretKey() string { func generateRandomSecretKey() string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
const length = 32 const length = 32

View file

@ -967,6 +967,9 @@
"actionDeleteSite": "Delete Site", "actionDeleteSite": "Delete Site",
"actionGetSite": "Get Site", "actionGetSite": "Get Site",
"actionListSites": "List Sites", "actionListSites": "List Sites",
"setupToken": "Setup Token",
"setupTokenPlaceholder": "Enter the setup token from the server console",
"setupTokenRequired": "Setup token is required",
"actionUpdateSite": "Update Site", "actionUpdateSite": "Update Site",
"actionListSiteRoles": "List Allowed Site Roles", "actionListSiteRoles": "List Allowed Site Roles",
"actionCreateResource": "Create Resource", "actionCreateResource": "Create Resource",

View file

@ -593,6 +593,14 @@ export const webauthnChallenge = pgTable("webauthnChallenge", {
expiresAt: bigint("expiresAt", { mode: "number" }).notNull() // Unix timestamp expiresAt: bigint("expiresAt", { mode: "number" }).notNull() // Unix timestamp
}); });
export const setupTokens = pgTable("setupTokens", {
tokenId: varchar("tokenId").primaryKey(),
token: varchar("token").notNull(),
used: boolean("used").notNull().default(false),
dateCreated: varchar("dateCreated").notNull(),
dateUsed: varchar("dateUsed")
});
export type Org = InferSelectModel<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>; export type Site = InferSelectModel<typeof sites>;
@ -638,3 +646,4 @@ export type OlmSession = InferSelectModel<typeof olmSessions>;
export type UserClient = InferSelectModel<typeof userClients>; export type UserClient = InferSelectModel<typeof userClients>;
export type RoleClient = InferSelectModel<typeof roleClients>; export type RoleClient = InferSelectModel<typeof roleClients>;
export type OrgDomains = InferSelectModel<typeof orgDomains>; export type OrgDomains = InferSelectModel<typeof orgDomains>;
export type SetupToken = InferSelectModel<typeof setupTokens>;

View file

@ -187,6 +187,14 @@ export const webauthnChallenge = sqliteTable("webauthnChallenge", {
expiresAt: integer("expiresAt").notNull() // Unix timestamp expiresAt: integer("expiresAt").notNull() // Unix timestamp
}); });
export const setupTokens = sqliteTable("setupTokens", {
tokenId: text("tokenId").primaryKey(),
token: text("token").notNull(),
used: integer("used", { mode: "boolean" }).notNull().default(false),
dateCreated: text("dateCreated").notNull(),
dateUsed: text("dateUsed")
});
export const newts = sqliteTable("newt", { export const newts = sqliteTable("newt", {
newtId: text("id").primaryKey(), newtId: text("id").primaryKey(),
secretHash: text("secretHash").notNull(), secretHash: text("secretHash").notNull(),
@ -680,3 +688,4 @@ export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>; export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>; export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
export type OrgDomains = InferSelectModel<typeof orgDomains>; export type OrgDomains = InferSelectModel<typeof orgDomains>;
export type SetupToken = InferSelectModel<typeof setupTokens>;

View file

@ -10,6 +10,7 @@ export * from "./resetPassword";
export * from "./requestPasswordReset"; export * from "./requestPasswordReset";
export * from "./setServerAdmin"; export * from "./setServerAdmin";
export * from "./initialSetupComplete"; export * from "./initialSetupComplete";
export * from "./validateSetupToken";
export * from "./changePassword"; export * from "./changePassword";
export * from "./checkResourceSession"; export * from "./checkResourceSession";
export * from "./securityKey"; export * from "./securityKey";

View file

@ -8,14 +8,15 @@ import logger from "@server/logger";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { passwordSchema } from "@server/auth/passwordSchema"; import { passwordSchema } from "@server/auth/passwordSchema";
import { response } from "@server/lib"; import { response } from "@server/lib";
import { db, users } from "@server/db"; import { db, users, setupTokens } from "@server/db";
import { eq } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import moment from "moment"; import moment from "moment";
export const bodySchema = z.object({ export const bodySchema = z.object({
email: z.string().toLowerCase().email(), email: z.string().toLowerCase().email(),
password: passwordSchema password: passwordSchema,
setupToken: z.string().min(1, "Setup token is required")
}); });
export type SetServerAdminBody = z.infer<typeof bodySchema>; export type SetServerAdminBody = z.infer<typeof bodySchema>;
@ -39,7 +40,27 @@ export async function setServerAdmin(
); );
} }
const { email, password } = parsedBody.data; const { email, password, setupToken } = parsedBody.data;
// Validate setup token
const [validToken] = await db
.select()
.from(setupTokens)
.where(
and(
eq(setupTokens.token, setupToken),
eq(setupTokens.used, false)
)
);
if (!validToken) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid or expired setup token"
)
);
}
const [existing] = await db const [existing] = await db
.select() .select()
@ -58,7 +79,18 @@ export async function setServerAdmin(
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
const userId = generateId(15); const userId = generateId(15);
await db.insert(users).values({ await db.transaction(async (trx) => {
// Mark the token as used
await trx
.update(setupTokens)
.set({
used: true,
dateUsed: moment().toISOString()
})
.where(eq(setupTokens.tokenId, validToken.tokenId));
// Create the server admin user
await trx.insert(users).values({
userId: userId, userId: userId,
email: email, email: email,
type: UserType.Internal, type: UserType.Internal,
@ -68,6 +100,7 @@ export async function setServerAdmin(
serverAdmin: true, serverAdmin: true,
emailVerified: true emailVerified: true
}); });
});
return response<SetServerAdminResponse>(res, { return response<SetServerAdminResponse>(res, {
data: null, data: null,

View file

@ -0,0 +1,84 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, setupTokens } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const validateSetupTokenSchema = z
.object({
token: z.string().min(1, "Token is required")
})
.strict();
export type ValidateSetupTokenResponse = {
valid: boolean;
message: string;
};
export async function validateSetupToken(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = validateSetupTokenSchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { token } = parsedBody.data;
// Find the token in the database
const [setupToken] = await db
.select()
.from(setupTokens)
.where(
and(
eq(setupTokens.token, token),
eq(setupTokens.used, false)
)
);
if (!setupToken) {
return response<ValidateSetupTokenResponse>(res, {
data: {
valid: false,
message: "Invalid or expired setup token"
},
success: true,
error: false,
message: "Token validation completed",
status: HttpCode.OK
});
}
return response<ValidateSetupTokenResponse>(res, {
data: {
valid: true,
message: "Setup token is valid"
},
success: true,
error: false,
message: "Token validation completed",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to validate setup token"
)
);
}
}

View file

@ -1033,6 +1033,7 @@ authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback);
authRouter.put("/set-server-admin", auth.setServerAdmin); authRouter.put("/set-server-admin", auth.setServerAdmin);
authRouter.get("/initial-setup-complete", auth.initialSetupComplete); authRouter.get("/initial-setup-complete", auth.initialSetupComplete);
authRouter.post("/validate-setup-token", auth.validateSetupToken);
// Security Key routes // Security Key routes
authRouter.post( authRouter.post(

View file

@ -0,0 +1,73 @@
import { db, setupTokens, users } from "@server/db";
import { eq } from "drizzle-orm";
import { generateRandomString, RandomReader } from "@oslojs/crypto/random";
import moment from "moment";
import logger from "@server/logger";
const random: RandomReader = {
read(bytes: Uint8Array): void {
crypto.getRandomValues(bytes);
}
};
function generateToken(): string {
// Generate a 32-character alphanumeric token
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
return generateRandomString(random, alphabet, 32);
}
function generateId(length: number): string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
return generateRandomString(random, alphabet, length);
}
export async function ensureSetupToken() {
try {
// Check if a server admin already exists
const [existingAdmin] = await db
.select()
.from(users)
.where(eq(users.serverAdmin, true));
// If admin exists, no need for setup token
if (existingAdmin) {
logger.warn("Server admin exists. Setup token generation skipped.");
return;
}
// Check if a setup token already exists
const existingTokens = await db
.select()
.from(setupTokens)
.where(eq(setupTokens.used, false));
// If unused token exists, display it instead of creating a new one
if (existingTokens.length > 0) {
console.log("=== SETUP TOKEN EXISTS ===");
console.log("Token:", existingTokens[0].token);
console.log("Use this token on the initial setup page");
console.log("================================");
return;
}
// Generate a new setup token
const token = generateToken();
const tokenId = generateId(15);
await db.insert(setupTokens).values({
tokenId: tokenId,
token: token,
used: false,
dateCreated: moment().toISOString(),
dateUsed: null
});
console.log("=== SETUP TOKEN GENERATED ===");
console.log("Token:", token);
console.log("Use this token on the initial setup page");
console.log("================================");
} catch (error) {
console.error("Failed to ensure setup token:", error);
throw error;
}
}

View file

@ -1,9 +1,11 @@
import { ensureActions } from "./ensureActions"; import { ensureActions } from "./ensureActions";
import { copyInConfig } from "./copyInConfig"; import { copyInConfig } from "./copyInConfig";
import { clearStaleData } from "./clearStaleData"; import { clearStaleData } from "./clearStaleData";
import { ensureSetupToken } from "./ensureSetupToken";
export async function runSetupFunctions() { export async function runSetupFunctions() {
await copyInConfig(); // copy in the config to the db as needed await copyInConfig(); // copy in the config to the db as needed
await ensureActions(); // make sure all of the actions are in the db and the roles await ensureActions(); // make sure all of the actions are in the db and the roles
await clearStaleData(); await clearStaleData();
await ensureSetupToken(); // ensure setup token exists for initial setup
} }

View file

@ -8,6 +8,7 @@ import path from "path";
import m1 from "./scriptsPg/1.6.0"; import m1 from "./scriptsPg/1.6.0";
import m2 from "./scriptsPg/1.7.0"; import m2 from "./scriptsPg/1.7.0";
import m3 from "./scriptsPg/1.8.0"; import m3 from "./scriptsPg/1.8.0";
import m4 from "./scriptsPg/1.9.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -16,7 +17,8 @@ import m3 from "./scriptsPg/1.8.0";
const migrations = [ const migrations = [
{ version: "1.6.0", run: m1 }, { version: "1.6.0", run: m1 },
{ version: "1.7.0", run: m2 }, { version: "1.7.0", run: m2 },
{ version: "1.8.0", run: m3 } { version: "1.8.0", run: m3 },
{ version: "1.9.0", run: m4 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as { ] as {
version: string; version: string;

View file

@ -25,6 +25,7 @@ import m20 from "./scriptsSqlite/1.5.0";
import m21 from "./scriptsSqlite/1.6.0"; import m21 from "./scriptsSqlite/1.6.0";
import m22 from "./scriptsSqlite/1.7.0"; import m22 from "./scriptsSqlite/1.7.0";
import m23 from "./scriptsSqlite/1.8.0"; import m23 from "./scriptsSqlite/1.8.0";
import m24 from "./scriptsSqlite/1.9.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@ -49,6 +50,7 @@ const migrations = [
{ version: "1.6.0", run: m21 }, { version: "1.6.0", run: m21 },
{ version: "1.7.0", run: m22 }, { version: "1.7.0", run: m22 },
{ version: "1.8.0", run: m23 }, { version: "1.8.0", run: m23 },
{ version: "1.9.0", run: m24 },
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;

View file

@ -0,0 +1,25 @@
import { db } from "@server/db/pg/driver";
import { sql } from "drizzle-orm";
const version = "1.9.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
await db.execute(sql`
CREATE TABLE "setupTokens" (
"tokenId" varchar PRIMARY KEY NOT NULL,
"token" varchar NOT NULL,
"used" boolean DEFAULT false NOT NULL,
"dateCreated" varchar NOT NULL,
"dateUsed" varchar
);
`);
console.log(`Added setupTokens table`);
} catch (e) {
console.log("Unable to add setupTokens table:", e);
throw e;
}
}

View file

@ -0,0 +1,35 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import path from "path";
const version = "1.9.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
db.pragma("foreign_keys = OFF");
db.transaction(() => {
db.exec(`
CREATE TABLE 'setupTokens' (
'tokenId' text PRIMARY KEY NOT NULL,
'token' text NOT NULL,
'used' integer DEFAULT 0 NOT NULL,
'dateCreated' text NOT NULL,
'dateUsed' text
);
`);
})();
db.pragma("foreign_keys = ON");
console.log(`Added setupTokens table`);
} catch (e) {
console.log("Unable to add setupTokens table:", e);
throw e;
}
}

View file

@ -31,6 +31,7 @@ import { passwordSchema } from "@server/auth/passwordSchema";
const formSchema = z const formSchema = z
.object({ .object({
setupToken: z.string().min(1, "Setup token is required"),
email: z.string().email({ message: "Invalid email address" }), email: z.string().email({ message: "Invalid email address" }),
password: passwordSchema, password: passwordSchema,
confirmPassword: z.string() confirmPassword: z.string()
@ -52,6 +53,7 @@ export default function InitialSetupPage() {
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
setupToken: "",
email: "", email: "",
password: "", password: "",
confirmPassword: "" confirmPassword: ""
@ -63,6 +65,7 @@ export default function InitialSetupPage() {
setError(null); setError(null);
try { try {
const res = await api.put("/auth/set-server-admin", { const res = await api.put("/auth/set-server-admin", {
setupToken: values.setupToken,
email: values.email, email: values.email,
password: values.password password: values.password
}); });
@ -102,6 +105,23 @@ export default function InitialSetupPage() {
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4" className="space-y-4"
> >
<FormField
control={form.control}
name="setupToken"
render={({ field }) => (
<FormItem>
<FormLabel>{t("setupToken")}</FormLabel>
<FormControl>
<Input
{...field}
placeholder={t("setupTokenPlaceholder")}
autoComplete="off"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="email" name="email"