Feat: Add 2FA/TOPT Support (#130)

This commit is contained in:
Daniel Luiz Alves
2025-07-08 00:51:42 -03:00
committed by GitHub
57 changed files with 5628 additions and 2533 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -15,7 +15,6 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }>
const MDXContent = page.data.body;
// Check if this is an older version page that needs a warning
const shouldShowWarning = page.url.startsWith("/docs/2.0.0-beta");
return (

View File

@@ -51,7 +51,9 @@
"node-fetch": "^3.3.2",
"nodemailer": "^6.10.0",
"openid-client": "^6.6.2",
"qrcode": "^1.5.4",
"sharp": "^0.34.2",
"speakeasy": "^2.0.0",
"zod": "^3.25.67"
},
"devDependencies": {
@@ -61,6 +63,8 @@
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.13.4",
"@types/nodemailer": "^6.4.17",
"@types/qrcode": "^1.5.5",
"@types/speakeasy": "^2.0.10",
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"eslint": "9.30.0",

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,11 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
twoFactorEnabled Boolean @default(false)
twoFactorSecret String?
twoFactorBackupCodes String?
twoFactorVerified Boolean @default(false)
files File[]
shares Share[]
reverseShares ReverseShare[]

View File

@@ -18,7 +18,6 @@ import {
TokenResponse,
} from "./types";
// Constants
const DEFAULT_BASE_URL = "http://localhost:3000";
const STATE_EXPIRY_TIME = 600000; // 10 minutes
const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
@@ -43,7 +42,6 @@ export class AuthProvidersService {
setInterval(() => this.cleanupExpiredStates(), CLEANUP_INTERVAL);
}
// Utility methods
private buildBaseUrl(requestContext?: RequestContextService): string {
return requestContext ? `${requestContext.protocol}://${requestContext.host}` : DEFAULT_BASE_URL;
}
@@ -87,7 +85,6 @@ export class AuthProvidersService {
}
}
// Provider configuration methods
private isOfficial(providerName: string): boolean {
return providerName in providersConfig.officialProviders;
}
@@ -114,7 +111,6 @@ export class AuthProvidersService {
}
private async resolveEndpoints(provider: any, config: ProviderConfig): Promise<ProviderEndpoints> {
// Use custom endpoints if all are provided
if (provider.authorizationEndpoint && provider.tokenEndpoint && provider.userInfoEndpoint) {
return {
authorizationEndpoint: this.resolveEndpointUrl(provider.authorizationEndpoint, provider.issuerUrl),
@@ -123,7 +119,6 @@ export class AuthProvidersService {
};
}
// Try discovery if supported
if (config.supportsDiscovery && provider.issuerUrl) {
const discoveredEndpoints = await this.attemptDiscovery(provider.issuerUrl);
if (discoveredEndpoints) {
@@ -131,7 +126,6 @@ export class AuthProvidersService {
}
}
// Fallback to intelligent endpoints
const baseUrl = provider.issuerUrl?.replace(/\/$/, "") || "";
const detectedType = detectProviderType(provider.issuerUrl || "");
const fallbackPattern = getFallbackEndpoints(detectedType);
@@ -224,7 +218,6 @@ export class AuthProvidersService {
return config.specialHandling?.emailEndpoint || null;
}
// PKCE and OAuth setup methods
private setupPkceIfNeeded(provider: any): { codeVerifier?: string; codeChallenge?: string } {
const needsPkce = provider.type === DEFAULT_PROVIDER_TYPE;
@@ -263,7 +256,6 @@ export class AuthProvidersService {
return authUrl.toString();
}
// Callback handling methods
private validateAndGetPendingState(state: string): PendingState {
const pendingState = this.pendingStates.get(state);
@@ -299,7 +291,6 @@ export class AuthProvidersService {
};
}
// Public methods
async getEnabledProviders(requestContext?: RequestContextService) {
const providers = await prisma.authProvider.findMany({
where: { enabled: true },
@@ -605,16 +596,13 @@ export class AuthProvidersService {
throw new Error(ERROR_MESSAGES.MISSING_USER_INFO);
}
// First, check if there's already an auth provider entry for this external ID
const existingAuthProvider = await this.findExistingAuthProvider(provider.id, String(externalId));
if (existingAuthProvider) {
return await this.updateExistingUserFromProvider(existingAuthProvider.user, userInfo);
}
// Check if there's a user with this email
const existingUser = await this.findExistingUserByEmail(userInfo.email);
if (existingUser) {
// Check if this user already has this provider linked
const existingUserProvider = await prisma.userAuthProvider.findFirst({
where: {
userId: existingUser.id,

View File

@@ -1,7 +1,12 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { env } from "../../env";
import { createResetPasswordSchema, LoginSchema, RequestPasswordResetSchema } from "./dto";
import {
CompleteTwoFactorLoginSchema,
createResetPasswordSchema,
LoginSchema,
RequestPasswordResetSchema,
} from "./dto";
import { AuthService } from "./service";
export class AuthController {
@@ -10,7 +15,36 @@ export class AuthController {
async login(request: FastifyRequest, reply: FastifyReply) {
try {
const input = LoginSchema.parse(request.body);
const user = await this.authService.login(input);
const result = await this.authService.login(input);
if ("requiresTwoFactor" in result) {
return reply.send(result);
}
const user = result;
const token = await request.jwtSign({
userId: user.id,
isAdmin: user.isAdmin,
});
reply.setCookie("token", token, {
httpOnly: true,
path: "/",
secure: env.SECURE_SITE === "true" ? true : false,
sameSite: env.SECURE_SITE === "true" ? "lax" : "strict",
});
return reply.send({ user });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
async completeTwoFactorLogin(request: FastifyRequest, reply: FastifyReply) {
try {
const input = CompleteTwoFactorLoginSchema.parse(request.body);
const user = await this.authService.completeTwoFactorLogin(input.userId, input.token);
const token = await request.jwtSign({
userId: user.id,
isAdmin: user.isAdmin,

View File

@@ -36,3 +36,10 @@ export const createResetPasswordSchema = async () => {
export type ResetPasswordInput = BaseResetPasswordInput & {
password: string;
};
export const CompleteTwoFactorLoginSchema = z.object({
userId: z.string().min(1, "User ID is required").describe("User ID"),
token: z.string().min(6, "Two-factor authentication code must be at least 6 characters").describe("2FA token"),
});
export type CompleteTwoFactorLoginInput = z.infer<typeof CompleteTwoFactorLoginSchema>;

View File

@@ -4,7 +4,7 @@ import { z } from "zod";
import { ConfigService } from "../config/service";
import { validatePasswordMiddleware } from "../user/middleware";
import { AuthController } from "./controller";
import { createResetPasswordSchema, RequestPasswordResetSchema } from "./dto";
import { CompleteTwoFactorLoginSchema, createResetPasswordSchema, RequestPasswordResetSchema } from "./dto";
const configService = new ConfigService();
@@ -31,6 +31,43 @@ export async function authRoutes(app: FastifyInstance) {
summary: "Login",
description: "Performs login and returns user data",
body: loginSchema,
response: {
200: z.union([
z.object({
user: z.object({
id: z.string().describe("User ID"),
firstName: z.string().describe("User first name"),
lastName: z.string().describe("User last name"),
username: z.string().describe("User username"),
email: z.string().email().describe("User email"),
isAdmin: z.boolean().describe("User is admin"),
isActive: z.boolean().describe("User is active"),
createdAt: z.date().describe("User creation date"),
updatedAt: z.date().describe("User last update date"),
}),
}),
z.object({
requiresTwoFactor: z.boolean().describe("Whether 2FA is required"),
userId: z.string().describe("User ID for 2FA verification"),
message: z.string().describe("2FA required message"),
}),
]),
400: z.object({ error: z.string().describe("Error message") }),
},
},
},
authController.login.bind(authController)
);
app.post(
"/auth/2fa/login",
{
schema: {
tags: ["Authentication"],
operationId: "completeTwoFactorLogin",
summary: "Complete Two-Factor Login",
description: "Complete login process with 2FA verification",
body: CompleteTwoFactorLoginSchema,
response: {
200: z.object({
user: z.object({
@@ -49,7 +86,7 @@ export async function authRoutes(app: FastifyInstance) {
},
},
},
authController.login.bind(authController)
authController.completeTwoFactorLogin.bind(authController)
);
app.post(

View File

@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
import { EmailService } from "../email/service";
import { TwoFactorService } from "../two-factor/service";
import { UserResponseSchema } from "../user/dto";
import { PrismaUserRepository } from "../user/repository";
import { LoginInput } from "./dto";
@@ -12,6 +13,7 @@ export class AuthService {
private userRepository = new PrismaUserRepository();
private configService = new ConfigService();
private emailService = new EmailService();
private twoFactorService = new TwoFactorService();
async login(data: LoginInput) {
const user = await this.userRepository.findUserByEmailOrUsername(data.emailOrUsername);
@@ -77,6 +79,42 @@ export class AuthService {
});
}
const has2FA = await this.twoFactorService.isEnabled(user.id);
if (has2FA) {
return {
requiresTwoFactor: true,
userId: user.id,
message: "Two-factor authentication required",
};
}
return UserResponseSchema.parse(user);
}
async completeTwoFactorLogin(userId: string, token: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new Error("User not found");
}
if (!user.isActive) {
throw new Error("Account is inactive. Please contact an administrator.");
}
const verificationResult = await this.twoFactorService.verifyToken(userId, token);
if (!verificationResult.success) {
throw new Error("Invalid two-factor authentication code");
}
await prisma.loginAttempt.deleteMany({
where: { userId },
});
return UserResponseSchema.parse(user);
}

View File

@@ -72,10 +72,8 @@ export class EmailService {
let smtpConfig: SmtpConfig;
if (config) {
// Use provided configuration
smtpConfig = config;
} else {
// Fallback to saved configuration
smtpConfig = {
smtpEnabled: await this.configService.getValue("smtpEnabled"),
smtpHost: await this.configService.getValue("smtpHost"),

View File

@@ -533,7 +533,6 @@ export class ReverseShareService {
const { FilesystemStorageProvider } = await import("../../providers/filesystem-storage.provider.js");
const provider = FilesystemStorageProvider.getInstance();
// Use streaming copy for filesystem mode
const sourcePath = provider.getFilePath(file.objectName);
const fs = await import("fs");
const { pipeline } = await import("stream/promises");
@@ -541,14 +540,11 @@ export class ReverseShareService {
const sourceStream = fs.createReadStream(sourcePath);
const decryptStream = provider.createDecryptStream();
// Create a passthrough stream to get the decrypted content
const { PassThrough } = await import("stream");
const passThrough = new PassThrough();
// First, decrypt the source file into the passthrough stream
await pipeline(sourceStream, decryptStream, passThrough);
// Then upload the decrypted content
await provider.uploadFileFromStream(newObjectName, passThrough);
} else {
const downloadUrl = await this.fileService.getPresignedGetUrl(file.objectName, 300);

View File

@@ -0,0 +1,159 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { prisma } from "shared/prisma";
import { z } from "zod";
import { ConfigService } from "../config/service";
import { TwoFactorService } from "./service";
const SetupSchema = z
.object({
appName: z.string().optional(),
})
.optional()
.default({});
const VerifySetupSchema = z.object({
token: z.string().min(6, "Token must be at least 6 characters"),
secret: z.string().min(1, "Secret is required"),
});
const VerifyTokenSchema = z.object({
token: z.string().min(6, "Token must be at least 6 characters"),
});
const DisableSchema = z.object({
password: z.string().min(1, "Password is required"),
});
export class TwoFactorController {
private twoFactorService = new TwoFactorService();
private configService = new ConfigService();
/**
* Generate 2FA setup (QR code and secret)
*/
async generateSetup(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const body = SetupSchema.parse(request.body || {});
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true },
});
if (!user) {
return reply.status(404).send({ error: "User not found" });
}
const appName = body?.appName || (await this.configService.getValue("appName")) || "Palmr";
const setupData = await this.twoFactorService.generateSetup(userId, user.email, appName);
return reply.send(setupData);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
/**
* Verify setup token and enable 2FA
*/
async verifySetup(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const body = VerifySetupSchema.parse(request.body);
const result = await this.twoFactorService.verifySetup(userId, body.token, body.secret);
return reply.send(result);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
/**
* Verify 2FA token during login
*/
async verifyToken(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const body = VerifyTokenSchema.parse(request.body);
const result = await this.twoFactorService.verifyToken(userId, body.token);
return reply.send(result);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
/**
* Disable 2FA
*/
async disable2FA(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const body = DisableSchema.parse(request.body);
const result = await this.twoFactorService.disable2FA(userId, body.password);
return reply.send(result);
} catch (error: any) {
console.error("2FA Disable Error:", error.message);
return reply.status(400).send({ error: error.message });
}
}
/**
* Generate new backup codes
*/
async generateBackupCodes(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const codes = await this.twoFactorService.generateNewBackupCodes(userId);
return reply.send({ backupCodes: codes });
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
/**
* Get 2FA status
*/
async getStatus(request: FastifyRequest, reply: FastifyReply) {
try {
const userId = (request as any).user?.userId;
if (!userId) {
return reply.status(401).send({ error: "Unauthorized" });
}
const status = await this.twoFactorService.getStatus(userId);
return reply.send(status);
} catch (error: any) {
return reply.status(400).send({ error: error.message });
}
}
}

View File

@@ -0,0 +1,170 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { TwoFactorController } from "./controller";
export async function twoFactorRoutes(app: FastifyInstance) {
const twoFactorController = new TwoFactorController();
const preValidation = async (request: FastifyRequest, reply: FastifyReply) => {
try {
await request.jwtVerify();
} catch (err) {
console.error(err);
reply.status(401).send({ error: "Unauthorized: a valid token is required to access this resource." });
}
};
app.post(
"/2fa/setup",
{
preValidation,
schema: {
tags: ["Two-Factor Authentication"],
operationId: "generate2FASetup",
summary: "Generate 2FA Setup",
description: "Generate QR code and secret for 2FA setup",
body: z.object({
appName: z.string().optional().describe("Application name for QR code"),
}),
response: {
200: z.object({
secret: z.string().describe("Base32 encoded secret"),
qrCode: z.string().describe("QR code as data URL"),
manualEntryKey: z.string().describe("Manual entry key"),
backupCodes: z
.array(
z.object({
code: z.string().describe("Backup code"),
used: z.boolean().describe("Whether backup code is used"),
})
)
.describe("Backup codes"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
twoFactorController.generateSetup.bind(twoFactorController)
);
app.post(
"/2fa/verify-setup",
{
preValidation,
schema: {
tags: ["Two-Factor Authentication"],
operationId: "verify2FASetup",
summary: "Verify 2FA Setup",
description: "Verify the setup token and enable 2FA",
body: z.object({
token: z.string().min(6).describe("TOTP token"),
secret: z.string().min(1).describe("Base32 encoded secret"),
}),
response: {
200: z.object({
success: z.boolean().describe("Setup success"),
backupCodes: z.array(z.string()).describe("Backup codes"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
twoFactorController.verifySetup.bind(twoFactorController)
);
app.post(
"/2fa/verify",
{
preValidation,
schema: {
tags: ["Two-Factor Authentication"],
operationId: "verify2FAToken",
summary: "Verify 2FA Token",
description: "Verify a 2FA token during authentication",
body: z.object({
token: z.string().min(6).describe("TOTP token or backup code"),
}),
response: {
200: z.object({
success: z.boolean().describe("Verification success"),
method: z.enum(["totp", "backup"]).describe("Verification method used"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
twoFactorController.verifyToken.bind(twoFactorController)
);
app.post(
"/2fa/disable",
{
preValidation,
schema: {
tags: ["Two-Factor Authentication"],
operationId: "disable2FA",
summary: "Disable 2FA",
description: "Disable two-factor authentication",
body: z.object({
password: z.string().min(1).describe("User password for confirmation"),
}),
response: {
200: z.object({
success: z.boolean().describe("Disable success"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
twoFactorController.disable2FA.bind(twoFactorController)
);
app.post(
"/2fa/backup-codes",
{
preValidation,
schema: {
tags: ["Two-Factor Authentication"],
operationId: "generateBackupCodes",
summary: "Generate Backup Codes",
description: "Generate new backup codes for 2FA",
response: {
200: z.object({
backupCodes: z.array(z.string()).describe("New backup codes"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
twoFactorController.generateBackupCodes.bind(twoFactorController)
);
app.get(
"/2fa/status",
{
preValidation,
schema: {
tags: ["Two-Factor Authentication"],
operationId: "get2FAStatus",
summary: "Get 2FA Status",
description: "Get current 2FA status for the user",
response: {
200: z.object({
enabled: z.boolean().describe("Whether 2FA is enabled"),
verified: z.boolean().describe("Whether 2FA is verified"),
availableBackupCodes: z.number().describe("Number of available backup codes"),
}),
400: z.object({ error: z.string().describe("Error message") }),
401: z.object({ error: z.string().describe("Error message") }),
},
},
},
twoFactorController.getStatus.bind(twoFactorController)
);
}

View File

@@ -0,0 +1,287 @@
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import QRCode from "qrcode";
import speakeasy from "speakeasy";
import { prisma } from "../../shared/prisma";
import { ConfigService } from "../config/service";
interface BackupCode {
code: string;
used: boolean;
}
export class TwoFactorService {
private configService = new ConfigService();
/**
* Generate a new 2FA secret and QR code for setup
*/
async generateSetup(userId: string, userEmail: string, appName?: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, twoFactorEnabled: true },
});
if (!user) {
throw new Error("User not found");
}
if (user.twoFactorEnabled) {
throw new Error("Two-factor authentication is already enabled");
}
const secret = speakeasy.generateSecret({
name: `${appName || "Palmr"}:${userEmail}`,
issuer: appName || "Palmr",
length: 32,
});
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || "");
return {
secret: secret.base32,
qrCode: qrCodeUrl,
manualEntryKey: secret.base32,
backupCodes: await this.generateBackupCodes(),
};
}
/**
* Verify setup token and enable 2FA
*/
async verifySetup(userId: string, token: string, secret: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, twoFactorEnabled: true },
});
if (!user) {
throw new Error("User not found");
}
if (user.twoFactorEnabled) {
throw new Error("Two-factor authentication is already enabled");
}
const verified = speakeasy.totp.verify({
secret: secret,
encoding: "base32",
token: token,
window: 1,
});
if (!verified) {
throw new Error("Invalid verification code");
}
const backupCodes = await this.generateBackupCodes();
await prisma.user.update({
where: { id: userId },
data: {
twoFactorEnabled: true,
twoFactorSecret: secret,
twoFactorBackupCodes: JSON.stringify(backupCodes),
twoFactorVerified: true,
},
});
return {
success: true,
backupCodes: backupCodes.map((bc) => bc.code),
};
}
/**
* Verify a 2FA token during login
*/
async verifyToken(userId: string, token: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
twoFactorEnabled: true,
twoFactorSecret: true,
twoFactorBackupCodes: true,
},
});
if (!user) {
throw new Error("User not found");
}
if (!user.twoFactorEnabled || !user.twoFactorSecret) {
throw new Error("Two-factor authentication is not enabled");
}
const verified = speakeasy.totp.verify({
secret: user.twoFactorSecret,
encoding: "base32",
token: token,
window: 1,
});
if (verified) {
return { success: true, method: "totp" };
}
if (user.twoFactorBackupCodes) {
const backupCodes: BackupCode[] = JSON.parse(user.twoFactorBackupCodes);
const backupCodeIndex = backupCodes.findIndex((bc) => bc.code === token && !bc.used);
if (backupCodeIndex !== -1) {
backupCodes[backupCodeIndex].used = true;
await prisma.user.update({
where: { id: userId },
data: {
twoFactorBackupCodes: JSON.stringify(backupCodes),
},
});
return { success: true, method: "backup" };
}
}
throw new Error("Invalid verification code");
}
/**
* Disable 2FA for a user
*/
async disable2FA(userId: string, password: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
password: true,
twoFactorEnabled: true,
},
});
if (!user) {
throw new Error("User not found");
}
if (!user.twoFactorEnabled) {
throw new Error("Two-factor authentication is not enabled");
}
if (!user.password) {
throw new Error("Password verification required");
}
let isValidPassword = false;
try {
isValidPassword = await bcrypt.compare(password, user.password);
} catch (error) {
console.error("bcrypt.compare error:", error);
throw new Error("Password verification failed");
}
if (!isValidPassword) {
throw new Error("Invalid password");
}
await prisma.user.update({
where: { id: userId },
data: {
twoFactorEnabled: false,
twoFactorSecret: null,
twoFactorBackupCodes: null,
twoFactorVerified: false,
},
});
return { success: true };
}
/**
* Generate new backup codes
*/
async generateNewBackupCodes(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, twoFactorEnabled: true },
});
if (!user) {
throw new Error("User not found");
}
if (!user.twoFactorEnabled) {
throw new Error("Two-factor authentication is not enabled");
}
const backupCodes = await this.generateBackupCodes();
await prisma.user.update({
where: { id: userId },
data: {
twoFactorBackupCodes: JSON.stringify(backupCodes),
},
});
return backupCodes.map((bc) => bc.code);
}
/**
* Get 2FA status for a user
*/
async getStatus(userId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
twoFactorEnabled: true,
twoFactorVerified: true,
twoFactorBackupCodes: true,
},
});
if (!user) {
throw new Error("User not found");
}
let availableBackupCodes = 0;
if (user.twoFactorBackupCodes) {
const backupCodes: BackupCode[] = JSON.parse(user.twoFactorBackupCodes);
availableBackupCodes = backupCodes.filter((bc) => !bc.used).length;
}
return {
enabled: user.twoFactorEnabled,
verified: user.twoFactorVerified,
availableBackupCodes,
};
}
/**
* Generate backup codes
*/
private async generateBackupCodes(): Promise<BackupCode[]> {
const codes: BackupCode[] = [];
for (let i = 0; i < 10; i++) {
const code = crypto.randomBytes(4).toString("hex").toUpperCase();
codes.push({
code: code.match(/.{1,4}/g)?.join("-") || code,
used: false,
});
}
return codes;
}
/**
* Check if user has 2FA enabled
*/
async isEnabled(userId: string): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { twoFactorEnabled: true },
});
return user?.twoFactorEnabled ?? false;
}
}

View File

@@ -179,7 +179,6 @@ export class FilesystemStorageProvider implements StorageProvider {
}
async uploadFile(objectName: string, buffer: Buffer): Promise<void> {
// For backward compatibility, convert buffer to stream and use streaming upload
const filePath = this.getFilePath(objectName);
const dir = path.dirname(filePath);
@@ -197,7 +196,6 @@ export class FilesystemStorageProvider implements StorageProvider {
await fs.mkdir(dir, { recursive: true });
// Use the new temp file system for better organization
const tempPath = getTempFilePath(objectName);
const tempDir = path.dirname(tempPath);
@@ -308,10 +306,8 @@ export class FilesystemStorageProvider implements StorageProvider {
*/
private async cleanupTempFile(tempPath: string): Promise<void> {
try {
// Remove the temp file
await fs.unlink(tempPath);
// Try to remove the parent directory if it's empty
const tempDir = path.dirname(tempPath);
try {
const files = await fs.readdir(tempDir);
@@ -319,7 +315,6 @@ export class FilesystemStorageProvider implements StorageProvider {
await fs.rmdir(tempDir);
}
} catch (dirError: any) {
// Ignore errors when trying to remove directory (might not be empty or might not exist)
if (dirError.code !== "ENOTEMPTY" && dirError.code !== "ENOENT") {
console.warn("Warning: Could not remove temp directory:", dirError.message);
}
@@ -338,11 +333,10 @@ export class FilesystemStorageProvider implements StorageProvider {
try {
const tempUploadsDir = directoriesConfig.tempUploads;
// Check if temp-uploads directory exists
try {
await fs.access(tempUploadsDir);
} catch {
return; // Directory doesn't exist, nothing to clean
return;
}
const items = await fs.readdir(tempUploadsDir);
@@ -354,14 +348,12 @@ export class FilesystemStorageProvider implements StorageProvider {
const stat = await fs.stat(itemPath);
if (stat.isDirectory()) {
// Check if directory is empty
const dirContents = await fs.readdir(itemPath);
if (dirContents.length === 0) {
await fs.rmdir(itemPath);
console.log(`🧹 Cleaned up empty temp directory: ${itemPath}`);
}
} else if (stat.isFile()) {
// Check if file is older than 1 hour (stale temp files)
const oneHourAgo = Date.now() - 60 * 60 * 1000;
if (stat.mtime.getTime() < oneHourAgo) {
await fs.unlink(itemPath);
@@ -369,7 +361,6 @@ export class FilesystemStorageProvider implements StorageProvider {
}
}
} catch (error: any) {
// Ignore errors for individual items
if (error.code !== "ENOENT") {
console.warn(`Warning: Could not process temp item ${itemPath}:`, error.message);
}

View File

@@ -16,6 +16,7 @@ import { healthRoutes } from "./modules/health/routes";
import { reverseShareRoutes } from "./modules/reverse-share/routes";
import { shareRoutes } from "./modules/share/routes";
import { storageRoutes } from "./modules/storage/routes";
import { twoFactorRoutes } from "./modules/two-factor/routes";
import { userRoutes } from "./modules/user/routes";
import { IS_RUNNING_IN_CONTAINER } from "./utils/container-detection";
@@ -69,6 +70,7 @@ async function startServer() {
app.register(authRoutes);
app.register(authProvidersRoutes, { prefix: "/auth" });
app.register(twoFactorRoutes, { prefix: "/auth" });
app.register(userRoutes);
app.register(fileRoutes);

View File

@@ -64,7 +64,6 @@ export default [
"@typescript-eslint/no-var-requires": "off",
},
},
// Ignore ESLint errors in @/ui directory
{
ignores: ["src/components/ui/**/*"],
},

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "بريد إلكتروني أو كلمة مرور غير صحيحة",
"userNotFound": "المستخدم غير موجود",
"accountLocked": "تم قفل الحساب. يرجى المحاولة لاحقًا",
"unexpectedError": "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى"
"unexpectedError": "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى",
"Invalid password": "كلمة المرور غير صحيحة",
"Invalid two-factor authentication code": "رمز المصادقة الثنائية غير صحيح",
"Invalid verification code": "رمز التحقق غير صحيح",
"Password verification required": "مطلوب التحقق من كلمة المرور",
"Two-factor authentication is already enabled": "المصادقة الثنائية مفعلة بالفعل",
"Two-factor authentication is not enabled": "المصادقة الثنائية غير مفعلة",
"Two-factor authentication required": "المصادقة الثنائية مطلوبة"
},
"fileActions": {
"editFile": "تعديل الملف",
@@ -1552,5 +1559,83 @@
},
"stats": "{iconCount} أيقونة من {libraryCount} مكتبة",
"categoryBadge": "{category} ({count} أيقونات)"
},
"twoFactor": {
"title": "المصادقة الثنائية",
"description": "أضف طبقة إضافية من الأمان إلى حسابك",
"enabled": "حسابك محمي بالمصادقة الثنائية",
"disabled": "المصادقة الثنائية غير مفعلة",
"setup": {
"title": "تفعيل المصادقة الثنائية",
"description": "امسح رمز QR باستخدام تطبيق المصادقة، ثم أدخل رمز التحقق.",
"qrCode": "رمز QR",
"manualEntryKey": "مفتاح الإدخال اليدوي",
"verificationCode": "رمز التحقق",
"verificationCodePlaceholder": "أدخل الرمز المكون من 6 أرقام",
"verificationCodeDescription": "أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة",
"verifyAndEnable": "تحقق وتفعيل",
"cancel": "إلغاء"
},
"disable": {
"title": "تعطيل المصادقة الثنائية",
"description": "أدخل كلمة المرور للتأكيد على تعطيل المصادقة الثنائية.",
"password": "كلمة المرور",
"passwordPlaceholder": "أدخل كلمة المرور",
"confirm": "تأكيد التعطيل",
"cancel": "إلغاء"
},
"backupCodes": {
"title": "رموز النسخ الاحتياطي",
"description": "احفظ رموز النسخ الاحتياطي هذه في مكان آمن. يمكنك استخدامها للوصول إلى حسابك في حالة فقدان جهاز المصادقة.",
"warning": "هام:",
"warningText": "يمكن استخدام كل رمز نسخ احتياطي مرة واحدة فقط. احتفظ بها بشكل آمن ولا تشاركها مع أي شخص.",
"generateNew": "إنشاء رموز نسخ احتياطي جديدة",
"download": "تحميل رموز النسخ الاحتياطي",
"copyToClipboard": "نسخ إلى الحافظة",
"savedMessage": "لقد حفظت رموز النسخ الاحتياطي",
"available": "{count} رموز نسخ احتياطي متاحة",
"instructions": [
"• احفظ هذه الرموز في مكان آمن",
"• يمكن استخدام كل رمز نسخ احتياطي مرة واحدة فقط",
"• يمكنك إنشاء رموز جديدة في أي وقت"
]
},
"verification": {
"title": "المصادقة الثنائية",
"description": "أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة",
"backupDescription": "أدخل أحد رموز النسخ الاحتياطي للمتابعة",
"verificationCode": "رمز التحقق",
"backupCode": "رمز النسخ الاحتياطي",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "تحقق",
"verifying": "جاري التحقق...",
"useBackupCode": "استخدم رمز النسخ الاحتياطي بدلاً من ذلك",
"useAuthenticatorCode": "استخدم رمز المصادقة بدلاً من ذلك"
},
"messages": {
"enabledSuccess": "تم تفعيل المصادقة الثنائية بنجاح!",
"disabledSuccess": "تم تعطيل المصادقة الثنائية بنجاح",
"backupCodesGenerated": "تم إنشاء رموز النسخ الاحتياطي الجديدة بنجاح",
"backupCodesCopied": "تم نسخ رموز النسخ الاحتياطي إلى الحافظة",
"setupFailed": "فشل في إنشاء إعداد المصادقة الثنائية",
"verificationFailed": "رمز التحقق غير صالح",
"disableFailed": "فشل في تعطيل المصادقة الثنائية. يرجى التحقق من كلمة المرور.",
"backupCodesFailed": "فشل في إنشاء رموز النسخ الاحتياطي",
"backupCodesCopyFailed": "فشل في نسخ رموز النسخ الاحتياطي",
"statusLoadFailed": "فشل في تحميل حالة المصادقة الثنائية",
"enterVerificationCode": "يرجى إدخال رمز التحقق",
"enterPassword": "يرجى إدخال كلمة المرور"
},
"errors": {
"invalidVerificationCode": "رمز التحقق غير صالح",
"invalidTwoFactorCode": "رمز المصادقة الثنائية غير صالح",
"twoFactorRequired": "المصادقة الثنائية مطلوبة",
"twoFactorAlreadyEnabled": "المصادقة الثنائية مفعلة بالفعل",
"twoFactorNotEnabled": "المصادقة الثنائية غير مفعلة",
"passwordVerificationRequired": "التحقق من كلمة المرور مطلوب",
"invalidPassword": "كلمة المرور غير صالحة",
"userNotFound": "المستخدم غير موجود"
}
}
}

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "Ungültige E-Mail oder Passwort",
"userNotFound": "Benutzer nicht gefunden",
"accountLocked": "Konto gesperrt. Bitte versuchen Sie es später erneut",
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut"
"unexpectedError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut",
"Invalid password": "Ungültiges Passwort",
"Invalid two-factor authentication code": "Ungültiger Zwei-Faktor-Authentifizierungscode",
"Invalid verification code": "Ungültiger Verifizierungscode",
"Password verification required": "Passwortüberprüfung erforderlich",
"Two-factor authentication is already enabled": "Zwei-Faktor-Authentifizierung ist bereits aktiviert",
"Two-factor authentication is not enabled": "Zwei-Faktor-Authentifizierung ist nicht aktiviert",
"Two-factor authentication required": "Zwei-Faktor-Authentifizierung erforderlich"
},
"fileActions": {
"editFile": "Datei bearbeiten",
@@ -1550,5 +1557,83 @@
},
"stats": "{iconCount} Symbole aus {libraryCount} Bibliotheken",
"categoryBadge": "{category} ({count} Symbole)"
},
"twoFactor": {
"title": "Zwei-Faktor-Authentifizierung",
"description": "Fügen Sie Ihrem Konto eine zusätzliche Sicherheitsebene hinzu",
"enabled": "Ihr Konto ist mit Zwei-Faktor-Authentifizierung geschützt",
"disabled": "Zwei-Faktor-Authentifizierung ist nicht aktiviert",
"setup": {
"title": "Zwei-Faktor-Authentifizierung aktivieren",
"description": "Scannen Sie den QR-Code mit Ihrer Authenticator-App und geben Sie dann den Bestätigungscode ein.",
"qrCode": "QR-Code",
"manualEntryKey": "Manueller Eingabeschlüssel",
"verificationCode": "Bestätigungscode",
"verificationCodePlaceholder": "6-stelligen Code eingeben",
"verificationCodeDescription": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein",
"verifyAndEnable": "Überprüfen & Aktivieren",
"cancel": "Abbrechen"
},
"disable": {
"title": "Zwei-Faktor-Authentifizierung deaktivieren",
"description": "Geben Sie Ihr Passwort ein, um die Deaktivierung der Zwei-Faktor-Authentifizierung zu bestätigen.",
"password": "Passwort",
"passwordPlaceholder": "Geben Sie Ihr Passwort ein",
"confirm": "Deaktivierung bestätigen",
"cancel": "Abbrechen"
},
"backupCodes": {
"title": "Backup-Codes",
"description": "Speichern Sie diese Backup-Codes an einem sicheren Ort. Sie können sie verwenden, um auf Ihr Konto zuzugreifen, wenn Sie Ihr Authentifizierungsgerät verlieren.",
"warning": "Wichtig:",
"warningText": "Jeder Backup-Code kann nur einmal verwendet werden. Bewahren Sie sie sicher auf und teilen Sie sie mit niemandem.",
"generateNew": "Neue Backup-Codes generieren",
"download": "Backup-Codes herunterladen",
"copyToClipboard": "In die Zwischenablage kopieren",
"savedMessage": "Ich habe meine Backup-Codes gespeichert",
"available": "{count} Backup-Codes verfügbar",
"instructions": [
"• Speichern Sie diese Codes an einem sicheren Ort",
"• Jeder Backup-Code kann nur einmal verwendet werden",
"• Sie können jederzeit neue Codes generieren"
]
},
"verification": {
"title": "Zwei-Faktor-Authentifizierung",
"description": "Geben Sie den 6-stelligen Code aus Ihrer Authenticator-App ein",
"backupDescription": "Geben Sie einen Ihrer Backup-Codes ein, um fortzufahren",
"verificationCode": "Bestätigungscode",
"backupCode": "Backup-Code",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Überprüfen",
"verifying": "Überprüfung läuft...",
"useBackupCode": "Stattdessen Backup-Code verwenden",
"useAuthenticatorCode": "Stattdessen Authenticator-Code verwenden"
},
"messages": {
"enabledSuccess": "Zwei-Faktor-Authentifizierung erfolgreich aktiviert!",
"disabledSuccess": "Zwei-Faktor-Authentifizierung erfolgreich deaktiviert",
"backupCodesGenerated": "Neue Backup-Codes erfolgreich generiert",
"backupCodesCopied": "Backup-Codes in die Zwischenablage kopiert",
"setupFailed": "2FA-Setup konnte nicht generiert werden",
"verificationFailed": "Ungültiger Bestätigungscode",
"disableFailed": "2FA konnte nicht deaktiviert werden. Bitte überprüfen Sie Ihr Passwort.",
"backupCodesFailed": "Backup-Codes konnten nicht generiert werden",
"backupCodesCopyFailed": "Backup-Codes konnten nicht kopiert werden",
"statusLoadFailed": "2FA-Status konnte nicht geladen werden",
"enterVerificationCode": "Bitte geben Sie den Bestätigungscode ein",
"enterPassword": "Bitte geben Sie Ihr Passwort ein"
},
"errors": {
"invalidVerificationCode": "Ungültiger Bestätigungscode",
"invalidTwoFactorCode": "Ungültiger Zwei-Faktor-Authentifizierungscode",
"twoFactorRequired": "Zwei-Faktor-Authentifizierung erforderlich",
"twoFactorAlreadyEnabled": "Zwei-Faktor-Authentifizierung ist bereits aktiviert",
"twoFactorNotEnabled": "Zwei-Faktor-Authentifizierung ist nicht aktiviert",
"passwordVerificationRequired": "Passwortüberprüfung erforderlich",
"invalidPassword": "Ungültiges Passwort",
"userNotFound": "Benutzer nicht gefunden"
}
}
}

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "Invalid email or password",
"userNotFound": "User not found",
"accountLocked": "Account locked. Please try again later",
"unexpectedError": "An unexpected error occurred. Please try again"
"unexpectedError": "An unexpected error occurred. Please try again",
"Invalid verification code": "Invalid verification code",
"Two-factor authentication is already enabled": "Two-factor authentication is already enabled",
"Two-factor authentication is not enabled": "Two-factor authentication is not enabled",
"Invalid password": "Invalid password",
"Password verification required": "Password verification required",
"Invalid two-factor authentication code": "Invalid two-factor authentication code",
"Two-factor authentication required": "Two-factor authentication required"
},
"fileActions": {
"editFile": "Edit File",
@@ -1536,6 +1543,84 @@
"nameRequired": "Name is required",
"required": "This field is required"
},
"twoFactor": {
"title": "Two-Factor Authentication",
"description": "Add an extra layer of security to your account",
"enabled": "Your account is protected with two-factor authentication",
"disabled": "Two-factor authentication is not enabled",
"setup": {
"title": "Enable Two-Factor Authentication",
"description": "Scan the QR code with your authenticator app, then enter the verification code.",
"qrCode": "QR Code",
"manualEntryKey": "Manual Entry Key",
"verificationCode": "Verification Code",
"verificationCodePlaceholder": "Enter 6-digit code",
"verificationCodeDescription": "Enter the 6-digit code from your authenticator app",
"verifyAndEnable": "Verify & Enable",
"cancel": "Cancel"
},
"disable": {
"title": "Disable Two-Factor Authentication",
"description": "Enter your password to confirm disabling two-factor authentication.",
"password": "Password",
"passwordPlaceholder": "Enter your password",
"confirm": "Confirm Disable",
"cancel": "Cancel"
},
"backupCodes": {
"title": "Backup Codes",
"description": "Save these backup codes in a safe place. You can use them to access your account if you lose your authenticator device.",
"warning": "Important:",
"warningText": "Each backup code can only be used once. Keep them secure and don't share them with anyone.",
"generateNew": "Generate New Backup Codes",
"download": "Download Backup Codes",
"copyToClipboard": "Copy to Clipboard",
"savedMessage": "I've Saved My Backup Codes",
"available": "{count} backup codes available",
"instructions": [
"• Save these codes in a secure location",
"• Each backup code can only be used once",
"• You can generate new codes anytime"
]
},
"verification": {
"title": "Two-Factor Authentication",
"description": "Enter the 6-digit code from your authenticator app",
"backupDescription": "Enter one of your backup codes to continue",
"verificationCode": "Verification Code",
"backupCode": "Backup Code",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Verify",
"verifying": "Verifying...",
"useBackupCode": "Use backup code instead",
"useAuthenticatorCode": "Use authenticator code instead"
},
"messages": {
"enabledSuccess": "Two-factor authentication enabled successfully!",
"disabledSuccess": "Two-factor authentication disabled successfully",
"backupCodesGenerated": "New backup codes generated successfully",
"backupCodesCopied": "Backup codes copied to clipboard",
"setupFailed": "Failed to generate 2FA setup",
"verificationFailed": "Invalid verification code",
"disableFailed": "Failed to disable 2FA. Please check your password.",
"backupCodesFailed": "Failed to generate backup codes",
"backupCodesCopyFailed": "Failed to copy backup codes",
"statusLoadFailed": "Failed to load 2FA status",
"enterVerificationCode": "Please enter the verification code",
"enterPassword": "Please enter your password"
},
"errors": {
"invalidVerificationCode": "Invalid verification code",
"invalidTwoFactorCode": "Invalid two-factor authentication code",
"twoFactorRequired": "Two-factor authentication required",
"twoFactorAlreadyEnabled": "Two-factor authentication is already enabled",
"twoFactorNotEnabled": "Two-factor authentication is not enabled",
"passwordVerificationRequired": "Password verification required",
"invalidPassword": "Invalid password",
"userNotFound": "User not found"
}
},
"iconPicker": {
"title": "Select Icon",
"placeholder": "Select an icon",

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "Correo electrónico o contraseña inválidos",
"userNotFound": "Usuario no encontrado",
"accountLocked": "Cuenta bloqueada. Por favor, inténtalo de nuevo más tarde",
"unexpectedError": "Ocurrió un error inesperado. Por favor, inténtalo de nuevo"
"unexpectedError": "Ocurrió un error inesperado. Por favor, inténtalo de nuevo",
"Invalid password": "Contraseña inválida",
"Invalid two-factor authentication code": "Código de autenticación de dos factores inválido",
"Invalid verification code": "Código de verificación inválido",
"Password verification required": "Se requiere verificación de contraseña",
"Two-factor authentication is already enabled": "La autenticación de dos factores ya está habilitada",
"Two-factor authentication is not enabled": "La autenticación de dos factores no está habilitada",
"Two-factor authentication required": "Se requiere autenticación de dos factores"
},
"fileActions": {
"editFile": "Editar archivo",
@@ -1550,5 +1557,83 @@
},
"stats": "{iconCount} iconos de {libraryCount} bibliotecas",
"categoryBadge": "{category} ({count} iconos)"
},
"twoFactor": {
"title": "Autenticación de dos factores",
"description": "Añade una capa extra de seguridad a tu cuenta",
"enabled": "Tu cuenta está protegida con autenticación de dos factores",
"disabled": "La autenticación de dos factores no está habilitada",
"setup": {
"title": "Habilitar autenticación de dos factores",
"description": "Escanea el código QR con tu aplicación de autenticación y luego ingresa el código de verificación.",
"qrCode": "Código QR",
"manualEntryKey": "Clave de entrada manual",
"verificationCode": "Código de verificación",
"verificationCodePlaceholder": "Ingresa el código de 6 dígitos",
"verificationCodeDescription": "Ingresa el código de 6 dígitos de tu aplicación de autenticación",
"verifyAndEnable": "Verificar y habilitar",
"cancel": "Cancelar"
},
"disable": {
"title": "Deshabilitar autenticación de dos factores",
"description": "Ingresa tu contraseña para confirmar la desactivación de la autenticación de dos factores.",
"password": "Contraseña",
"passwordPlaceholder": "Ingresa tu contraseña",
"confirm": "Confirmar desactivación",
"cancel": "Cancelar"
},
"backupCodes": {
"title": "Códigos de respaldo",
"description": "Guarda estos códigos de respaldo en un lugar seguro. Puedes usarlos para acceder a tu cuenta si pierdes tu dispositivo de autenticación.",
"warning": "Importante:",
"warningText": "Cada código de respaldo solo se puede usar una vez. Mantenlos seguros y no los compartas con nadie.",
"generateNew": "Generar nuevos códigos de respaldo",
"download": "Descargar códigos de respaldo",
"copyToClipboard": "Copiar al portapapeles",
"savedMessage": "He guardado mis códigos de respaldo",
"available": "{count} códigos de respaldo disponibles",
"instructions": [
"• Guarda estos códigos en un lugar seguro",
"• Cada código de respaldo solo se puede usar una vez",
"• Puedes generar nuevos códigos en cualquier momento"
]
},
"verification": {
"title": "Autenticación de dos factores",
"description": "Ingresa el código de 6 dígitos de tu aplicación de autenticación",
"backupDescription": "Ingresa uno de tus códigos de respaldo para continuar",
"verificationCode": "Código de verificación",
"backupCode": "Código de respaldo",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Verificar",
"verifying": "Verificando...",
"useBackupCode": "Usar código de respaldo en su lugar",
"useAuthenticatorCode": "Usar código de autenticación en su lugar"
},
"messages": {
"enabledSuccess": "¡Autenticación de dos factores habilitada exitosamente!",
"disabledSuccess": "Autenticación de dos factores deshabilitada exitosamente",
"backupCodesGenerated": "Nuevos códigos de respaldo generados exitosamente",
"backupCodesCopied": "Códigos de respaldo copiados al portapapeles",
"setupFailed": "Error al generar la configuración de 2FA",
"verificationFailed": "Código de verificación inválido",
"disableFailed": "Error al deshabilitar 2FA. Por favor verifica tu contraseña.",
"backupCodesFailed": "Error al generar códigos de respaldo",
"backupCodesCopyFailed": "Error al copiar códigos de respaldo",
"statusLoadFailed": "Error al cargar el estado de 2FA",
"enterVerificationCode": "Por favor ingresa el código de verificación",
"enterPassword": "Por favor ingresa tu contraseña"
},
"errors": {
"invalidVerificationCode": "Código de verificación inválido",
"invalidTwoFactorCode": "Código de autenticación de dos factores inválido",
"twoFactorRequired": "Se requiere autenticación de dos factores",
"twoFactorAlreadyEnabled": "La autenticación de dos factores ya está habilitada",
"twoFactorNotEnabled": "La autenticación de dos factores no está habilitada",
"passwordVerificationRequired": "Se requiere verificación de contraseña",
"invalidPassword": "Contraseña inválida",
"userNotFound": "Usuario no encontrado"
}
}
}

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "E-mail ou mot de passe invalide",
"userNotFound": "Utilisateur non trouvé",
"accountLocked": "Compte bloqué. Veuillez réessayer plus tard",
"unexpectedError": "Une erreur inattendue s'est produite. Veuillez réessayer"
"unexpectedError": "Une erreur inattendue s'est produite. Veuillez réessayer",
"Invalid password": "Mot de passe invalide",
"Invalid two-factor authentication code": "Code d'authentification à deux facteurs invalide",
"Invalid verification code": "Code de vérification invalide",
"Password verification required": "Vérification du mot de passe requise",
"Two-factor authentication is already enabled": "L'authentification à deux facteurs est déjà activée",
"Two-factor authentication is not enabled": "L'authentification à deux facteurs n'est pas activée",
"Two-factor authentication required": "L'authentification à deux facteurs est requise"
},
"fileActions": {
"editFile": "Modifier le Fichier",
@@ -1068,8 +1075,8 @@
"description": "URL de base du serveur Palmr (ex: https://palmr.exemple.com)"
},
"testSmtp": {
"title": "[TO_TRANSLATE] Test SMTP Connection",
"description": "[TO_TRANSLATE] Test if the SMTP configuration is valid"
"title": "Test de la Connexion SMTP",
"description": "Tester si la configuration SMTP est valide"
},
"smtpNoAuth": {
"title": "Pas d'Authentification",
@@ -1550,5 +1557,83 @@
},
"stats": "{iconCount} icônes de {libraryCount} bibliothèques",
"categoryBadge": "{category} ({count} icônes)"
},
"twoFactor": {
"title": "Authentification à Deux Facteurs",
"description": "Ajoutez une couche de sécurité supplémentaire à votre compte",
"enabled": "Votre compte est protégé par l'authentification à deux facteurs",
"disabled": "L'authentification à deux facteurs n'est pas activée",
"setup": {
"title": "Activer l'Authentification à Deux Facteurs",
"description": "Scannez le code QR avec votre application d'authentification, puis saisissez le code de vérification.",
"qrCode": "Code QR",
"manualEntryKey": "Clé de Saisie Manuelle",
"verificationCode": "Code de Vérification",
"verificationCodePlaceholder": "Entrez le code à 6 chiffres",
"verificationCodeDescription": "Saisissez le code à 6 chiffres de votre application d'authentification",
"verifyAndEnable": "Vérifier et Activer",
"cancel": "Annuler"
},
"disable": {
"title": "Désactiver l'Authentification à Deux Facteurs",
"description": "Saisissez votre mot de passe pour confirmer la désactivation de l'authentification à deux facteurs.",
"password": "Mot de passe",
"passwordPlaceholder": "Entrez votre mot de passe",
"confirm": "Confirmer la Désactivation",
"cancel": "Annuler"
},
"backupCodes": {
"title": "Codes de Secours",
"description": "Conservez ces codes de secours dans un endroit sûr. Vous pouvez les utiliser pour accéder à votre compte si vous perdez votre appareil d'authentification.",
"warning": "Important :",
"warningText": "Chaque code de secours ne peut être utilisé qu'une seule fois. Gardez-les en sécurité et ne les partagez avec personne.",
"generateNew": "Générer de Nouveaux Codes de Secours",
"download": "Télécharger les Codes de Secours",
"copyToClipboard": "Copier dans le Presse-papiers",
"savedMessage": "J'ai Sauvegardé Mes Codes de Secours",
"available": "{count} codes de secours disponibles",
"instructions": [
"• Sauvegardez ces codes dans un endroit sécurisé",
"• Chaque code de secours ne peut être utilisé qu'une seule fois",
"• Vous pouvez générer de nouveaux codes à tout moment"
]
},
"verification": {
"title": "Authentification à Deux Facteurs",
"description": "Saisissez le code à 6 chiffres de votre application d'authentification",
"backupDescription": "Saisissez l'un de vos codes de secours pour continuer",
"verificationCode": "Code de Vérification",
"backupCode": "Code de Secours",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Vérifier",
"verifying": "Vérification en cours...",
"useBackupCode": "Utiliser un code de secours à la place",
"useAuthenticatorCode": "Utiliser le code d'authentification à la place"
},
"messages": {
"enabledSuccess": "L'authentification à deux facteurs a été activée avec succès !",
"disabledSuccess": "L'authentification à deux facteurs a été désactivée avec succès",
"backupCodesGenerated": "Nouveaux codes de secours générés avec succès",
"backupCodesCopied": "Codes de secours copiés dans le presse-papiers",
"setupFailed": "Échec de la génération de la configuration 2FA",
"verificationFailed": "Code de vérification invalide",
"disableFailed": "Échec de la désactivation de la 2FA. Veuillez vérifier votre mot de passe.",
"backupCodesFailed": "Échec de la génération des codes de secours",
"backupCodesCopyFailed": "Échec de la copie des codes de secours",
"statusLoadFailed": "Échec du chargement du statut 2FA",
"enterVerificationCode": "Veuillez saisir le code de vérification",
"enterPassword": "Veuillez saisir votre mot de passe"
},
"errors": {
"invalidVerificationCode": "Code de vérification invalide",
"invalidTwoFactorCode": "Code d'authentification à deux facteurs invalide",
"twoFactorRequired": "L'authentification à deux facteurs est requise",
"twoFactorAlreadyEnabled": "L'authentification à deux facteurs est déjà activée",
"twoFactorNotEnabled": "L'authentification à deux facteurs n'est pas activée",
"passwordVerificationRequired": "Vérification du mot de passe requise",
"invalidPassword": "Mot de passe invalide",
"userNotFound": "Utilisateur non trouvé"
}
}
}

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "गलत ईमेल या पासवर्ड",
"userNotFound": "उपयोगकर्ता नहीं मिला",
"accountLocked": "खाता लॉक है। कृपया बाद में प्रयास करें",
"unexpectedError": "एक अप्रत्याशित त्रुटि हुई। कृपया पुनः प्रयास करें"
"unexpectedError": "एक अप्रत्याशित त्रुटि हुई। कृपया पुनः प्रयास करें",
"Invalid password": "अमान्य पासवर्ड",
"Invalid two-factor authentication code": "अमान्य दो-कारक प्रमाणीकरण कोड",
"Invalid verification code": "अमान्य सत्यापन कोड",
"Password verification required": "पासवर्ड सत्यापन आवश्यक है",
"Two-factor authentication is already enabled": "दो-कारक प्रमाणीकरण पहले से सक्षम है",
"Two-factor authentication is not enabled": "दो-कारक प्रमाणीकरण सक्षम नहीं है",
"Two-factor authentication required": "दो-कारक प्रमाणीकरण आवश्यक है"
},
"fileActions": {
"editFile": "फाइल संपादित करें",
@@ -1550,5 +1557,83 @@
},
"stats": "{libraryCount} लाइब्रेरी से {iconCount} आइकन",
"categoryBadge": "{category} ({count} आइकन)"
},
"twoFactor": {
"title": "दो-कारक प्रमाणीकरण",
"description": "अपने खाते में अतिरिक्त सुरक्षा स्तर जोड़ें",
"enabled": "आपका खाता दो-कारक प्रमाणीकरण से सुरक्षित है",
"disabled": "दो-कारक प्रमाणीकरण सक्षम नहीं है",
"setup": {
"title": "दो-कारक प्रमाणीकरण सक्षम करें",
"description": "अपने प्रमाणीकरण ऐप से QR कोड स्कैन करें, फिर सत्यापन कोड दर्ज करें।",
"qrCode": "QR कोड",
"manualEntryKey": "मैनुअल एंट्री कुंजी",
"verificationCode": "सत्यापन कोड",
"verificationCodePlaceholder": "6-अंकों का कोड दर्ज करें",
"verificationCodeDescription": "अपने प्रमाणीकरण ऐप से 6-अंकों का कोड दर्ज करें",
"verifyAndEnable": "सत्यापित करें और सक्षम करें",
"cancel": "रद्द करें"
},
"disable": {
"title": "दो-कारक प्रमाणीकरण अक्षम करें",
"description": "दो-कारक प्रमाणीकरण को अक्षम करने की पुष्टि के लिए अपना पासवर्ड दर्ज करें।",
"password": "पासवर्ड",
"passwordPlaceholder": "अपना पासवर्ड दर्ज करें",
"confirm": "अक्षम करने की पुष्टि करें",
"cancel": "रद्द करें"
},
"backupCodes": {
"title": "बैकअप कोड",
"description": "इन बैकअप कोड को सुरक्षित स्थान पर सहेजें। यदि आप अपना प्रमाणीकरण डिवाइस खो देते हैं तो आप इनका उपयोग अपने खाते तक पहुंचने के लिए कर सकते हैं।",
"warning": "महत्वपूर्ण:",
"warningText": "प्रत्येक बैकअप कोड का उपयोग केवल एक बार किया जा सकता है। उन्हें सुरक्षित रखें और किसी के साथ साझा न करें।",
"generateNew": "नए बैकअप कोड जनरेट करें",
"download": "बैकअप कोड डाउनलोड करें",
"copyToClipboard": "क्लिपबोर्ड पर कॉपी करें",
"savedMessage": "मैंने अपने बैकअप कोड सहेज लिए हैं",
"available": "{count} बैकअप कोड उपलब्ध हैं",
"instructions": [
"• इन कोड को सुरक्षित स्थान पर सहेजें",
"• प्रत्येक बैकअप कोड का उपयोग केवल एक बार किया जा सकता है",
"• आप कभी भी नए कोड जनरेट कर सकते हैं"
]
},
"verification": {
"title": "दो-कारक प्रमाणीकरण",
"description": "अपने प्रमाणीकरण ऐप से 6-अंकों का कोड दर्ज करें",
"backupDescription": "जारी रखने के लिए अपने बैकअप कोड में से एक दर्ज करें",
"verificationCode": "सत्यापन कोड",
"backupCode": "बैकअप कोड",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "सत्यापित करें",
"verifying": "सत्यापन हो रहा है...",
"useBackupCode": "इसके बजाय बैकअप कोड का उपयोग करें",
"useAuthenticatorCode": "इसके बजाय प्रमाणीकरण कोड का उपयोग करें"
},
"messages": {
"enabledSuccess": "दो-कारक प्रमाणीकरण सफलतापूर्वक सक्षम किया गया!",
"disabledSuccess": "दो-कारक प्रमाणीकरण सफलतापूर्वक अक्षम किया गया",
"backupCodesGenerated": "नए बैकअप कोड सफलतापूर्वक जनरेट किए गए",
"backupCodesCopied": "बैकअप कोड क्लिपबोर्ड पर कॉपी किए गए",
"setupFailed": "2FA सेटअप जनरेट करने में विफल",
"verificationFailed": "अमान्य सत्यापन कोड",
"disableFailed": "2FA अक्षम करने में विफल। कृपया अपना पासवर्ड जांचें।",
"backupCodesFailed": "बैकअप कोड जनरेट करने में विफल",
"backupCodesCopyFailed": "बैकअप कोड कॉपी करने में विफल",
"statusLoadFailed": "2FA स्थिति लोड करने में विफल",
"enterVerificationCode": "कृपया सत्यापन कोड दर्ज करें",
"enterPassword": "कृपया अपना पासवर्ड दर्ज करें"
},
"errors": {
"invalidVerificationCode": "अमान्य सत्यापन कोड",
"invalidTwoFactorCode": "अमान्य दो-कारक प्रमाणीकरण कोड",
"twoFactorRequired": "दो-कारक प्रमाणीकरण आवश्यक है",
"twoFactorAlreadyEnabled": "दो-कारक प्रमाणीकरण पहले से सक्षम है",
"twoFactorNotEnabled": "दो-कारक प्रमाणीकरण सक्षम नहीं है",
"passwordVerificationRequired": "पासवर्ड सत्यापन आवश्यक है",
"invalidPassword": "अमान्य पासवर्ड",
"userNotFound": "उपयोगकर्ता नहीं मिला"
}
}
}

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "Email o password non validi",
"userNotFound": "Utente non trovato",
"accountLocked": "Account bloccato. Riprova più tardi",
"unexpectedError": "Si è verificato un errore imprevisto. Riprova"
"unexpectedError": "Si è verificato un errore imprevisto. Riprova",
"Invalid password": "Password non valida",
"Invalid two-factor authentication code": "Codice di autenticazione a due fattori non valido",
"Invalid verification code": "Codice di verifica non valido",
"Password verification required": "Verifica della password richiesta",
"Two-factor authentication is already enabled": "L'autenticazione a due fattori è già abilitata",
"Two-factor authentication is not enabled": "L'autenticazione a due fattori non è abilitata",
"Two-factor authentication required": "Autenticazione a due fattori richiesta"
},
"fileActions": {
"editFile": "Modifica File",
@@ -1550,5 +1557,83 @@
},
"stats": "{iconCount} icone da {libraryCount} librerie",
"categoryBadge": "{category} ({count} icone)"
},
"twoFactor": {
"title": "Autenticazione a Due Fattori",
"description": "Aggiungi un ulteriore livello di sicurezza al tuo account",
"enabled": "Il tuo account è protetto con l'autenticazione a due fattori",
"disabled": "L'autenticazione a due fattori non è abilitata",
"setup": {
"title": "Abilita Autenticazione a Due Fattori",
"description": "Scansiona il codice QR con la tua app di autenticazione, quindi inserisci il codice di verifica.",
"qrCode": "Codice QR",
"manualEntryKey": "Chiave per Inserimento Manuale",
"verificationCode": "Codice di Verifica",
"verificationCodePlaceholder": "Inserisci il codice a 6 cifre",
"verificationCodeDescription": "Inserisci il codice a 6 cifre dalla tua app di autenticazione",
"verifyAndEnable": "Verifica e Abilita",
"cancel": "Annulla"
},
"disable": {
"title": "Disabilita Autenticazione a Due Fattori",
"description": "Inserisci la tua password per confermare la disabilitazione dell'autenticazione a due fattori.",
"password": "Password",
"passwordPlaceholder": "Inserisci la tua password",
"confirm": "Conferma Disabilitazione",
"cancel": "Annulla"
},
"backupCodes": {
"title": "Codici di Backup",
"description": "Salva questi codici di backup in un luogo sicuro. Puoi usarli per accedere al tuo account se perdi il tuo dispositivo di autenticazione.",
"warning": "Importante:",
"warningText": "Ogni codice di backup può essere utilizzato una sola volta. Conservali in modo sicuro e non condividerli con nessuno.",
"generateNew": "Genera Nuovi Codici di Backup",
"download": "Scarica Codici di Backup",
"copyToClipboard": "Copia negli Appunti",
"savedMessage": "Ho Salvato i Miei Codici di Backup",
"available": "{count} codici di backup disponibili",
"instructions": [
"• Salva questi codici in un luogo sicuro",
"• Ogni codice di backup può essere utilizzato una sola volta",
"• Puoi generare nuovi codici in qualsiasi momento"
]
},
"verification": {
"title": "Autenticazione a Due Fattori",
"description": "Inserisci il codice a 6 cifre dalla tua app di autenticazione",
"backupDescription": "Inserisci uno dei tuoi codici di backup per continuare",
"verificationCode": "Codice di Verifica",
"backupCode": "Codice di Backup",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Verifica",
"verifying": "Verifica in corso...",
"useBackupCode": "Usa invece un codice di backup",
"useAuthenticatorCode": "Usa invece il codice dell'autenticatore"
},
"messages": {
"enabledSuccess": "Autenticazione a due fattori abilitata con successo!",
"disabledSuccess": "Autenticazione a due fattori disabilitata con successo",
"backupCodesGenerated": "Nuovi codici di backup generati con successo",
"backupCodesCopied": "Codici di backup copiati negli appunti",
"setupFailed": "Impossibile generare la configurazione 2FA",
"verificationFailed": "Codice di verifica non valido",
"disableFailed": "Impossibile disabilitare 2FA. Verifica la tua password.",
"backupCodesFailed": "Impossibile generare i codici di backup",
"backupCodesCopyFailed": "Impossibile copiare i codici di backup",
"statusLoadFailed": "Impossibile caricare lo stato 2FA",
"enterVerificationCode": "Inserisci il codice di verifica",
"enterPassword": "Inserisci la tua password"
},
"errors": {
"invalidVerificationCode": "Codice di verifica non valido",
"invalidTwoFactorCode": "Codice di autenticazione a due fattori non valido",
"twoFactorRequired": "Autenticazione a due fattori richiesta",
"twoFactorAlreadyEnabled": "L'autenticazione a due fattori è già abilitata",
"twoFactorNotEnabled": "L'autenticazione a due fattori non è abilitata",
"passwordVerificationRequired": "Verifica password richiesta",
"invalidPassword": "Password non valida",
"userNotFound": "Utente non trovato"
}
}
}

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "無効なメールアドレスまたはパスワードです",
"userNotFound": "ユーザーが見つかりません",
"accountLocked": "アカウントがロックされています。後でもう一度お試しください",
"unexpectedError": "予期しないエラーが発生しました。もう一度お試しください"
"unexpectedError": "予期しないエラーが発生しました。もう一度お試しください",
"Invalid password": "パスワードが無効です",
"Invalid two-factor authentication code": "二要素認証コードが無効です",
"Invalid verification code": "認証コードが無効です",
"Password verification required": "パスワードの確認が必要です",
"Two-factor authentication is already enabled": "二要素認証は既に有効になっています",
"Two-factor authentication is not enabled": "二要素認証が有効になっていません",
"Two-factor authentication required": "二要素認証が必要です"
},
"fileActions": {
"editFile": "ファイルを編集",
@@ -1550,5 +1557,83 @@
},
"stats": "{libraryCount}ライブラリから{iconCount}個のアイコン",
"categoryBadge": "{category}{count}個のアイコン)"
},
"twoFactor": {
"title": "二要素認証",
"description": "アカウントにセキュリティ層を追加",
"enabled": "アカウントは二要素認証で保護されています",
"disabled": "二要素認証は有効になっていません",
"setup": {
"title": "二要素認証を有効にする",
"description": "認証アプリでQRコードをスキャンし、確認コードを入力してください。",
"qrCode": "QRコード",
"manualEntryKey": "手動入力キー",
"verificationCode": "確認コード",
"verificationCodePlaceholder": "6桁のコードを入力",
"verificationCodeDescription": "認証アプリから6桁のコードを入力してください",
"verifyAndEnable": "確認して有効化",
"cancel": "キャンセル"
},
"disable": {
"title": "二要素認証を無効にする",
"description": "二要素認証を無効にするには、パスワードを入力して確認してください。",
"password": "パスワード",
"passwordPlaceholder": "パスワードを入力",
"confirm": "無効化を確認",
"cancel": "キャンセル"
},
"backupCodes": {
"title": "バックアップコード",
"description": "これらのバックアップコードを安全な場所に保管してください。認証デバイスを紛失した場合、アカウントへのアクセスに使用できます。",
"warning": "重要:",
"warningText": "各バックアップコードは1回のみ使用できます。安全に保管し、誰とも共有しないでください。",
"generateNew": "新しいバックアップコードを生成",
"download": "バックアップコードをダウンロード",
"copyToClipboard": "クリップボードにコピー",
"savedMessage": "バックアップコードを保存しました",
"available": "利用可能なバックアップコード:{count}個",
"instructions": [
"• これらのコードを安全な場所に保管してください",
"• 各バックアップコードは1回のみ使用できます",
"• いつでも新しいコードを生成できます"
]
},
"verification": {
"title": "二要素認証",
"description": "認証アプリから6桁のコードを入力してください",
"backupDescription": "続行するにはバックアップコードを入力してください",
"verificationCode": "確認コード",
"backupCode": "バックアップコード",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "確認",
"verifying": "確認中...",
"useBackupCode": "代わりにバックアップコードを使用",
"useAuthenticatorCode": "代わりに認証アプリのコードを使用"
},
"messages": {
"enabledSuccess": "二要素認証が正常に有効化されました!",
"disabledSuccess": "二要素認証が正常に無効化されました",
"backupCodesGenerated": "新しいバックアップコードが正常に生成されました",
"backupCodesCopied": "バックアップコードがクリップボードにコピーされました",
"setupFailed": "2FA設定の生成に失敗しました",
"verificationFailed": "確認コードが無効です",
"disableFailed": "2FAの無効化に失敗しました。パスワードを確認してください。",
"backupCodesFailed": "バックアップコードの生成に失敗しました",
"backupCodesCopyFailed": "バックアップコードのコピーに失敗しました",
"statusLoadFailed": "2FAステータスの読み込みに失敗しました",
"enterVerificationCode": "確認コードを入力してください",
"enterPassword": "パスワードを入力してください"
},
"errors": {
"invalidVerificationCode": "確認コードが無効です",
"invalidTwoFactorCode": "二要素認証コードが無効です",
"twoFactorRequired": "二要素認証が必要です",
"twoFactorAlreadyEnabled": "二要素認証はすでに有効になっています",
"twoFactorNotEnabled": "二要素認証が有効になっていません",
"passwordVerificationRequired": "パスワードの確認が必要です",
"invalidPassword": "パスワードが無効です",
"userNotFound": "ユーザーが見つかりません"
}
}
}

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "잘못된 이메일 또는 비밀번호입니다",
"userNotFound": "사용자를 찾을 수 없습니다",
"accountLocked": "계정이 잠겼습니다. 나중에 다시 시도하세요",
"unexpectedError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요"
"unexpectedError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요",
"Invalid password": "잘못된 비밀번호입니다",
"Invalid two-factor authentication code": "잘못된 2단계 인증 코드입니다",
"Invalid verification code": "잘못된 인증 코드입니다",
"Password verification required": "비밀번호 확인이 필요합니다",
"Two-factor authentication is already enabled": "2단계 인증이 이미 활성화되어 있습니다",
"Two-factor authentication is not enabled": "2단계 인증이 활성화되어 있지 않습니다",
"Two-factor authentication required": "2단계 인증이 필요합니다"
},
"fileActions": {
"editFile": "파일 편집",
@@ -1550,5 +1557,83 @@
},
"stats": "{libraryCount}개의 라이브러리에서 {iconCount}개의 아이콘",
"categoryBadge": "{category} ({count}개의 아이콘)"
},
"twoFactor": {
"title": "2단계 인증",
"description": "계정에 추가 보안 계층 추가",
"enabled": "귀하의 계정은 2단계 인증으로 보호되고 있습니다",
"disabled": "2단계 인증이 활성화되지 않았습니다",
"setup": {
"title": "2단계 인증 활성화",
"description": "인증 앱으로 QR 코드를 스캔한 다음 인증 코드를 입력하세요.",
"qrCode": "QR 코드",
"manualEntryKey": "수동 입력 키",
"verificationCode": "인증 코드",
"verificationCodePlaceholder": "6자리 코드 입력",
"verificationCodeDescription": "인증 앱에서 6자리 코드를 입력하세요",
"verifyAndEnable": "확인 및 활성화",
"cancel": "취소"
},
"disable": {
"title": "2단계 인증 비활성화",
"description": "2단계 인증 비활성화를 확인하려면 비밀번호를 입력하세요.",
"password": "비밀번호",
"passwordPlaceholder": "비밀번호 입력",
"confirm": "비활성화 확인",
"cancel": "취소"
},
"backupCodes": {
"title": "백업 코드",
"description": "이 백업 코드를 안전한 곳에 보관하세요. 인증 기기를 분실한 경우 계정에 액세스하는 데 사용할 수 있습니다.",
"warning": "중요:",
"warningText": "각 백업 코드는 한 번만 사용할 수 있습니다. 안전하게 보관하고 다른 사람과 공유하지 마세요.",
"generateNew": "새 백업 코드 생성",
"download": "백업 코드 다운로드",
"copyToClipboard": "클립보드에 복사",
"savedMessage": "백업 코드를 저장했습니다",
"available": "사용 가능한 백업 코드 {count}개",
"instructions": [
"• 이 코드를 안전한 곳에 보관하세요",
"• 각 백업 코드는 한 번만 사용할 수 있습니다",
"• 언제든지 새 코드를 생성할 수 있습니다"
]
},
"verification": {
"title": "2단계 인증",
"description": "인증 앱에서 6자리 코드를 입력하세요",
"backupDescription": "계속하려면 백업 코드 중 하나를 입력하세요",
"verificationCode": "인증 코드",
"backupCode": "백업 코드",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "확인",
"verifying": "확인 중...",
"useBackupCode": "대신 백업 코드 사용",
"useAuthenticatorCode": "대신 인증 앱 코드 사용"
},
"messages": {
"enabledSuccess": "2단계 인증이 성공적으로 활성화되었습니다!",
"disabledSuccess": "2단계 인증이 성공적으로 비활성화되었습니다",
"backupCodesGenerated": "새 백업 코드가 성공적으로 생성되었습니다",
"backupCodesCopied": "백업 코드가 클립보드에 복사되었습니다",
"setupFailed": "2단계 인증 설정 생성 실패",
"verificationFailed": "잘못된 인증 코드",
"disableFailed": "2단계 인증 비활성화 실패. 비밀번호를 확인하세요.",
"backupCodesFailed": "백업 코드 생성 실패",
"backupCodesCopyFailed": "백업 코드 복사 실패",
"statusLoadFailed": "2단계 인증 상태 로드 실패",
"enterVerificationCode": "인증 코드를 입력하세요",
"enterPassword": "비밀번호를 입력하세요"
},
"errors": {
"invalidVerificationCode": "잘못된 인증 코드",
"invalidTwoFactorCode": "잘못된 2단계 인증 코드",
"twoFactorRequired": "2단계 인증이 필요합니다",
"twoFactorAlreadyEnabled": "2단계 인증이 이미 활성화되어 있습니다",
"twoFactorNotEnabled": "2단계 인증이 활성화되어 있지 않습니다",
"passwordVerificationRequired": "비밀번호 확인이 필요합니다",
"invalidPassword": "잘못된 비밀번호",
"userNotFound": "사용자를 찾을 수 없습니다"
}
}
}

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "Ongeldig e-mail of wachtwoord",
"userNotFound": "Gebruiker niet gevonden",
"accountLocked": "Account vergrendeld. Probeer het later opnieuw",
"unexpectedError": "Er is een onverwachte fout opgetreden. Probeer het opnieuw"
"unexpectedError": "Er is een onverwachte fout opgetreden. Probeer het opnieuw",
"Invalid password": "Ongeldig wachtwoord",
"Invalid two-factor authentication code": "Ongeldige tweefactorauthenticatiecode",
"Invalid verification code": "Ongeldige verificatiecode",
"Password verification required": "Wachtwoordverificatie vereist",
"Two-factor authentication is already enabled": "Tweefactorauthenticatie is al ingeschakeld",
"Two-factor authentication is not enabled": "Tweefactorauthenticatie is niet ingeschakeld",
"Two-factor authentication required": "Tweefactorauthenticatie vereist"
},
"fileActions": {
"editFile": "Bestand Bewerken",
@@ -1550,5 +1557,83 @@
},
"stats": "{iconCount} pictogrammen van {libraryCount} bibliotheken",
"categoryBadge": "{category} ({count} pictogrammen)"
},
"twoFactor": {
"title": "Twee-Factor Authenticatie",
"description": "Voeg een extra beveiligingslaag toe aan uw account",
"enabled": "Uw account is beveiligd met twee-factor authenticatie",
"disabled": "Twee-factor authenticatie is niet ingeschakeld",
"setup": {
"title": "Twee-Factor Authenticatie Inschakelen",
"description": "Scan de QR-code met uw authenticator-app en voer vervolgens de verificatiecode in.",
"qrCode": "QR-Code",
"manualEntryKey": "Handmatige Invoersleutel",
"verificationCode": "Verificatiecode",
"verificationCodePlaceholder": "Voer 6-cijferige code in",
"verificationCodeDescription": "Voer de 6-cijferige code van uw authenticator-app in",
"verifyAndEnable": "Verifiëren & Inschakelen",
"cancel": "Annuleren"
},
"disable": {
"title": "Twee-Factor Authenticatie Uitschakelen",
"description": "Voer uw wachtwoord in om het uitschakelen van twee-factor authenticatie te bevestigen.",
"password": "Wachtwoord",
"passwordPlaceholder": "Voer uw wachtwoord in",
"confirm": "Bevestig Uitschakelen",
"cancel": "Annuleren"
},
"backupCodes": {
"title": "Back-upcodes",
"description": "Bewaar deze back-upcodes op een veilige plaats. U kunt ze gebruiken om toegang te krijgen tot uw account als u uw authenticator-apparaat verliest.",
"warning": "Belangrijk:",
"warningText": "Elke back-upcode kan slechts één keer worden gebruikt. Bewaar ze veilig en deel ze met niemand.",
"generateNew": "Genereer Nieuwe Back-upcodes",
"download": "Download Back-upcodes",
"copyToClipboard": "Kopiëren naar Klembord",
"savedMessage": "Ik heb mijn back-upcodes opgeslagen",
"available": "{count} back-upcodes beschikbaar",
"instructions": [
"• Bewaar deze codes op een veilige plaats",
"• Elke back-upcode kan slechts één keer worden gebruikt",
"• U kunt op elk moment nieuwe codes genereren"
]
},
"verification": {
"title": "Twee-Factor Authenticatie",
"description": "Voer de 6-cijferige code van uw authenticator-app in",
"backupDescription": "Voer een van uw back-upcodes in om door te gaan",
"verificationCode": "Verificatiecode",
"backupCode": "Back-upcode",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Verifiëren",
"verifying": "Verifiëren...",
"useBackupCode": "Gebruik in plaats daarvan een back-upcode",
"useAuthenticatorCode": "Gebruik in plaats daarvan authenticator-code"
},
"messages": {
"enabledSuccess": "Twee-factor authenticatie succesvol ingeschakeld!",
"disabledSuccess": "Twee-factor authenticatie succesvol uitgeschakeld",
"backupCodesGenerated": "Nieuwe back-upcodes succesvol gegenereerd",
"backupCodesCopied": "Back-upcodes gekopieerd naar klembord",
"setupFailed": "Genereren van 2FA-configuratie mislukt",
"verificationFailed": "Ongeldige verificatiecode",
"disableFailed": "Uitschakelen van 2FA mislukt. Controleer uw wachtwoord.",
"backupCodesFailed": "Genereren van back-upcodes mislukt",
"backupCodesCopyFailed": "Kopiëren van back-upcodes mislukt",
"statusLoadFailed": "Laden van 2FA-status mislukt",
"enterVerificationCode": "Voer de verificatiecode in",
"enterPassword": "Voer uw wachtwoord in"
},
"errors": {
"invalidVerificationCode": "Ongeldige verificatiecode",
"invalidTwoFactorCode": "Ongeldige twee-factor authenticatiecode",
"twoFactorRequired": "Twee-factor authenticatie vereist",
"twoFactorAlreadyEnabled": "Twee-factor authenticatie is al ingeschakeld",
"twoFactorNotEnabled": "Twee-factor authenticatie is niet ingeschakeld",
"passwordVerificationRequired": "Wachtwoordverificatie vereist",
"invalidPassword": "Ongeldig wachtwoord",
"userNotFound": "Gebruiker niet gevonden"
}
}
}

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "Nieprawidłowy adres e-mail lub hasło",
"userNotFound": "Użytkownik nie znaleziony",
"accountLocked": "Konto zablokowane. Spróbuj ponownie później",
"unexpectedError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie."
"unexpectedError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie.",
"Invalid password": "Nieprawidłowe hasło",
"Invalid two-factor authentication code": "Nieprawidłowy kod uwierzytelniania dwuskładnikowego",
"Invalid verification code": "Nieprawidłowy kod weryfikacyjny",
"Password verification required": "Wymagana weryfikacja hasła",
"Two-factor authentication is already enabled": "Uwierzytelnianie dwuskładnikowe jest już włączone",
"Two-factor authentication is not enabled": "Uwierzytelnianie dwuskładnikowe nie jest włączone",
"Two-factor authentication required": "Wymagane uwierzytelnianie dwuskładnikowe"
},
"fileActions": {
"editFile": "Edytuj plik",
@@ -1145,7 +1152,7 @@
"description": "To udostępnienie mogło zostać usunięte lub wygasło."
},
"pageTitle": "Udostępnij",
"downloadAll": "[TO_TRANSLATE] Download All"
"downloadAll": "Pobierz wszystkie"
},
"shareActions": {
"deleteTitle": "Usuń udostępnienie",
@@ -1550,5 +1557,83 @@
},
"stats": "{iconCount} ikon z {libraryCount} bibliotek",
"categoryBadge": "{category} ({count} ikon)"
},
"twoFactor": {
"title": "Uwierzytelnianie dwuskładnikowe",
"description": "Dodaj dodatkową warstwę zabezpieczeń do swojego konta",
"enabled": "Twoje konto jest chronione uwierzytelnianiem dwuskładnikowym",
"disabled": "Uwierzytelnianie dwuskładnikowe nie jest włączone",
"setup": {
"title": "Włącz uwierzytelnianie dwuskładnikowe",
"description": "Zeskanuj kod QR za pomocą aplikacji uwierzytelniającej, a następnie wprowadź kod weryfikacyjny.",
"qrCode": "Kod QR",
"manualEntryKey": "Klucz do ręcznego wprowadzenia",
"verificationCode": "Kod weryfikacyjny",
"verificationCodePlaceholder": "Wprowadź 6-cyfrowy kod",
"verificationCodeDescription": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej",
"verifyAndEnable": "Zweryfikuj i włącz",
"cancel": "Anuluj"
},
"disable": {
"title": "Wyłącz uwierzytelnianie dwuskładnikowe",
"description": "Wprowadź hasło, aby potwierdzić wyłączenie uwierzytelniania dwuskładnikowego.",
"password": "Hasło",
"passwordPlaceholder": "Wprowadź swoje hasło",
"confirm": "Potwierdź wyłączenie",
"cancel": "Anuluj"
},
"backupCodes": {
"title": "Kody zapasowe",
"description": "Zapisz te kody zapasowe w bezpiecznym miejscu. Możesz ich użyć, aby uzyskać dostęp do swojego konta w przypadku utraty urządzenia uwierzytelniającego.",
"warning": "Ważne:",
"warningText": "Każdy kod zapasowy może być użyty tylko raz. Przechowuj je bezpiecznie i nie udostępniaj nikomu.",
"generateNew": "Wygeneruj nowe kody zapasowe",
"download": "Pobierz kody zapasowe",
"copyToClipboard": "Kopiuj do schowka",
"savedMessage": "Zapisałem moje kody zapasowe",
"available": "{count} dostępnych kodów zapasowych",
"instructions": [
"• Zapisz te kody w bezpiecznym miejscu",
"• Każdy kod zapasowy może być użyty tylko raz",
"• Możesz wygenerować nowe kody w dowolnym momencie"
]
},
"verification": {
"title": "Uwierzytelnianie dwuskładnikowe",
"description": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej",
"backupDescription": "Wprowadź jeden z kodów zapasowych, aby kontynuować",
"verificationCode": "Kod weryfikacyjny",
"backupCode": "Kod zapasowy",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Zweryfikuj",
"verifying": "Weryfikacja...",
"useBackupCode": "Użyj kodu zapasowego",
"useAuthenticatorCode": "Użyj kodu z aplikacji uwierzytelniającej"
},
"messages": {
"enabledSuccess": "Uwierzytelnianie dwuskładnikowe zostało pomyślnie włączone!",
"disabledSuccess": "Uwierzytelnianie dwuskładnikowe zostało pomyślnie wyłączone",
"backupCodesGenerated": "Nowe kody zapasowe zostały pomyślnie wygenerowane",
"backupCodesCopied": "Kody zapasowe skopiowane do schowka",
"setupFailed": "Nie udało się wygenerować konfiguracji 2FA",
"verificationFailed": "Nieprawidłowy kod weryfikacyjny",
"disableFailed": "Nie udało się wyłączyć 2FA. Sprawdź swoje hasło.",
"backupCodesFailed": "Nie udało się wygenerować kodów zapasowych",
"backupCodesCopyFailed": "Nie udało się skopiować kodów zapasowych",
"statusLoadFailed": "Nie udało się załadować statusu 2FA",
"enterVerificationCode": "Wprowadź kod weryfikacyjny",
"enterPassword": "Wprowadź swoje hasło"
},
"errors": {
"invalidVerificationCode": "Nieprawidłowy kod weryfikacyjny",
"invalidTwoFactorCode": "Nieprawidłowy kod uwierzytelniania dwuskładnikowego",
"twoFactorRequired": "Wymagane uwierzytelnianie dwuskładnikowe",
"twoFactorAlreadyEnabled": "Uwierzytelnianie dwuskładnikowe jest już włączone",
"twoFactorNotEnabled": "Uwierzytelnianie dwuskładnikowe nie jest włączone",
"passwordVerificationRequired": "Wymagana weryfikacja hasła",
"invalidPassword": "Nieprawidłowe hasło",
"userNotFound": "Nie znaleziono użytkownika"
}
}
}

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "E-mail ou senha inválidos",
"userNotFound": "Usuário não encontrado",
"accountLocked": "Conta bloqueada. Tente novamente mais tarde",
"unexpectedError": "Ocorreu um erro inesperado. Por favor, tente novamente"
"unexpectedError": "Ocorreu um erro inesperado. Por favor, tente novamente",
"Invalid password": "Senha inválida",
"Invalid two-factor authentication code": "Código de autenticação de dois fatores inválido",
"Invalid verification code": "Código de verificação inválido",
"Password verification required": "Verificação de senha necessária",
"Two-factor authentication is already enabled": "A autenticação de dois fatores já está ativada",
"Two-factor authentication is not enabled": "A autenticação de dois fatores não está ativada",
"Two-factor authentication required": "Autenticação de dois fatores necessária"
},
"fileActions": {
"editFile": "Editar arquivo",
@@ -1550,5 +1557,83 @@
},
"stats": "{iconCount} ícones de {libraryCount} bibliotecas",
"categoryBadge": "{category} ({count} ícones)"
},
"twoFactor": {
"title": "Autenticação de dois fatores",
"description": "Adicione uma camada extra de segurança à sua conta",
"enabled": "Sua conta está protegida com autenticação de dois fatores",
"disabled": "A autenticação de dois fatores não está ativada",
"setup": {
"title": "Ativar autenticação de dois fatores",
"description": "Escaneie o código QR com seu aplicativo autenticador e depois insira o código de verificação.",
"qrCode": "Código QR",
"manualEntryKey": "Chave de Entrada Manual",
"verificationCode": "Código de Verificação",
"verificationCodePlaceholder": "Digite o código de 6 dígitos",
"verificationCodeDescription": "Digite o código de 6 dígitos do seu aplicativo autenticador",
"verifyAndEnable": "Verificar e ativar",
"cancel": "Cancelar"
},
"disable": {
"title": "Desativar autenticação de dois fatores",
"description": "Digite sua senha para confirmar a desativação da autenticação de dois fatores.",
"password": "Senha",
"passwordPlaceholder": "Digite sua senha",
"confirm": "Confirmar Desativação",
"cancel": "Cancelar"
},
"backupCodes": {
"title": "Códigos de backup",
"description": "Salve estes códigos de backup em um local seguro. Você pode usá-los para acessar sua conta se perder seu dispositivo autenticador.",
"warning": "Importante:",
"warningText": "Cada código de backup só pode ser usado uma vez. Mantenha-os seguros e não os compartilhe com ninguém.",
"generateNew": "Gerar novos códigos de backup",
"download": "Baixar códigos de backup",
"copyToClipboard": "Copiar para área de transferência",
"savedMessage": "Salvei meus códigos de backup",
"available": "{count} códigos de backup disponíveis",
"instructions": [
"• Salve estes códigos em um local seguro",
"• Cada código de backup só pode ser usado uma vez",
"• Você pode gerar novos códigos a qualquer momento"
]
},
"verification": {
"title": "Autenticação de dois fatores",
"description": "Digite o código de 6 dígitos do seu aplicativo autenticador",
"backupDescription": "Digite um dos seus códigos de backup para continuar",
"verificationCode": "Código de verificação",
"backupCode": "Código de backup",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Verificar",
"verifying": "Verificando...",
"useBackupCode": "Usar código de backup",
"useAuthenticatorCode": "Usar código do autenticador"
},
"messages": {
"enabledSuccess": "Autenticação de dois fatores ativada com sucesso!",
"disabledSuccess": "Autenticação de dois fatores desativada com sucesso",
"backupCodesGenerated": "Novos códigos de backup gerados com sucesso",
"backupCodesCopied": "Códigos de backup copiados para a área de transferência",
"setupFailed": "Falha ao gerar configuração 2FA",
"verificationFailed": "Código de verificação inválido",
"disableFailed": "Falha ao desativar 2FA. Por favor, verifique sua senha.",
"backupCodesFailed": "Falha ao gerar códigos de backup",
"backupCodesCopyFailed": "Falha ao copiar códigos de backup",
"statusLoadFailed": "Falha ao carregar status do 2FA",
"enterVerificationCode": "Por favor, digite o código de verificação",
"enterPassword": "Por favor, digite sua senha"
},
"errors": {
"invalidVerificationCode": "Código de verificação inválido",
"invalidTwoFactorCode": "Código de autenticação de dois fatores inválido",
"twoFactorRequired": "Autenticação de dois fatores necessária",
"twoFactorAlreadyEnabled": "A autenticação de dois fatores já está ativada",
"twoFactorNotEnabled": "A autenticação de dois fatores não está ativada",
"passwordVerificationRequired": "Verificação de senha necessária",
"invalidPassword": "Senha inválida",
"userNotFound": "Usuário não encontrado"
}
}
}

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "Неверный адрес электронной почты или пароль",
"userNotFound": "Пользователь не найден",
"accountLocked": "Аккаунт заблокирован. Пожалуйста, попробуйте позже",
"unexpectedError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз"
"unexpectedError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз",
"Invalid password": "Неверный пароль",
"Invalid two-factor authentication code": "Неверный код двухфакторной аутентификации",
"Invalid verification code": "Неверный код подтверждения",
"Password verification required": "Требуется подтверждение пароля",
"Two-factor authentication is already enabled": "Двухфакторная аутентификация уже включена",
"Two-factor authentication is not enabled": "Двухфакторная аутентификация не включена",
"Two-factor authentication required": "Требуется двухфакторная аутентификация"
},
"fileActions": {
"editFile": "Редактировать файл",
@@ -1550,5 +1557,83 @@
},
"stats": "{iconCount} иконок из {libraryCount} библиотек",
"categoryBadge": "{category} ({count} иконок)"
},
"twoFactor": {
"title": "Двухфакторная аутентификация",
"description": "Добавьте дополнительный уровень безопасности для вашей учетной записи",
"enabled": "Ваша учетная запись защищена двухфакторной аутентификацией",
"disabled": "Двухфакторная аутентификация не включена",
"setup": {
"title": "Включить двухфакторную аутентификацию",
"description": "Отсканируйте QR-код с помощью приложения-аутентификатора, затем введите код подтверждения.",
"qrCode": "QR-код",
"manualEntryKey": "Ключ для ручного ввода",
"verificationCode": "Код подтверждения",
"verificationCodePlaceholder": "Введите 6-значный код",
"verificationCodeDescription": "Введите 6-значный код из вашего приложения-аутентификатора",
"verifyAndEnable": "Проверить и включить",
"cancel": "Отмена"
},
"disable": {
"title": "Отключить двухфакторную аутентификацию",
"description": "Введите ваш пароль для подтверждения отключения двухфакторной аутентификации.",
"password": "Пароль",
"passwordPlaceholder": "Введите ваш пароль",
"confirm": "Подтвердить отключение",
"cancel": "Отмена"
},
"backupCodes": {
"title": "Резервные коды",
"description": "Сохраните эти резервные коды в безопасном месте. Вы можете использовать их для доступа к своей учетной записи, если потеряете устройство аутентификации.",
"warning": "Важно:",
"warningText": "Каждый резервный код можно использовать только один раз. Храните их в безопасности и не делитесь ими ни с кем.",
"generateNew": "Сгенерировать новые резервные коды",
"download": "Скачать резервные коды",
"copyToClipboard": "Копировать в буфер обмена",
"savedMessage": "Я сохранил мои резервные коды",
"available": "{count} резервных кодов доступно",
"instructions": [
"• Сохраните эти коды в безопасном месте",
"• Каждый резервный код можно использовать только один раз",
"• Вы можете сгенерировать новые коды в любое время"
]
},
"verification": {
"title": "Двухфакторная аутентификация",
"description": "Введите 6-значный код из вашего приложения-аутентификатора",
"backupDescription": "Введите один из ваших резервных кодов для продолжения",
"verificationCode": "Код подтверждения",
"backupCode": "Резервный код",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Проверить",
"verifying": "Проверка...",
"useBackupCode": "Использовать резервный код",
"useAuthenticatorCode": "Использовать код аутентификатора"
},
"messages": {
"enabledSuccess": "Двухфакторная аутентификация успешно включена!",
"disabledSuccess": "Двухфакторная аутентификация успешно отключена",
"backupCodesGenerated": "Новые резервные коды успешно сгенерированы",
"backupCodesCopied": "Резервные коды скопированы в буфер обмена",
"setupFailed": "Не удалось сгенерировать настройку 2FA",
"verificationFailed": "Неверный код подтверждения",
"disableFailed": "Не удалось отключить 2FA. Пожалуйста, проверьте ваш пароль.",
"backupCodesFailed": "Не удалось сгенерировать резервные коды",
"backupCodesCopyFailed": "Не удалось скопировать резервные коды",
"statusLoadFailed": "Не удалось загрузить статус 2FA",
"enterVerificationCode": "Пожалуйста, введите код подтверждения",
"enterPassword": "Пожалуйста, введите ваш пароль"
},
"errors": {
"invalidVerificationCode": "Неверный код подтверждения",
"invalidTwoFactorCode": "Неверный код двухфакторной аутентификации",
"twoFactorRequired": "Требуется двухфакторная аутентификация",
"twoFactorAlreadyEnabled": "Двухфакторная аутентификация уже включена",
"twoFactorNotEnabled": "Двухфакторная аутентификация не включена",
"passwordVerificationRequired": "Требуется подтверждение пароля",
"invalidPassword": "Неверный пароль",
"userNotFound": "Пользователь не найден"
}
}
}

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "Geçersiz e-posta veya şifre",
"userNotFound": "Kullanıcı bulunamadı",
"accountLocked": "Hesap kilitlendi. Lütfen daha sonra tekrar deneyin",
"unexpectedError": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin"
"unexpectedError": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin",
"Invalid password": "Geçersiz şifre",
"Invalid two-factor authentication code": "Geçersiz iki faktörlü kimlik doğrulama kodu",
"Invalid verification code": "Geçersiz doğrulama kodu",
"Password verification required": "Şifre doğrulaması gerekli",
"Two-factor authentication is already enabled": "İki faktörlü kimlik doğrulama zaten etkin",
"Two-factor authentication is not enabled": "İki faktörlü kimlik doğrulama etkin değil",
"Two-factor authentication required": "İki faktörlü kimlik doğrulama gerekli"
},
"fileActions": {
"editFile": "Dosyayı Düzenle",
@@ -1550,5 +1557,83 @@
},
"stats": "{libraryCount} kütüphaneden {iconCount} simge",
"categoryBadge": "{category} ({count} simge)"
},
"twoFactor": {
"title": "İki Faktörlü Kimlik Doğrulama",
"description": "Hesabınıza ekstra bir güvenlik katmanı ekleyin",
"enabled": "Hesabınız iki faktörlü kimlik doğrulama ile korunuyor",
"disabled": "İki faktörlü kimlik doğrulama etkin değil",
"setup": {
"title": "İki Faktörlü Kimlik Doğrulamayı Etkinleştir",
"description": "QR kodunu kimlik doğrulayıcı uygulamanızla tarayın, ardından doğrulama kodunu girin.",
"qrCode": "QR Kodu",
"manualEntryKey": "Manuel Giriş Anahtarı",
"verificationCode": "Doğrulama Kodu",
"verificationCodePlaceholder": "6 haneli kodu girin",
"verificationCodeDescription": "Kimlik doğrulayıcı uygulamanızdan 6 haneli kodu girin",
"verifyAndEnable": "Doğrula ve Etkinleştir",
"cancel": "İptal"
},
"disable": {
"title": "İki Faktörlü Kimlik Doğrulamayı Devre Dışı Bırak",
"description": "İki faktörlü kimlik doğrulamayı devre dışı bırakmayı onaylamak için şifrenizi girin.",
"password": "Şifre",
"passwordPlaceholder": "Şifrenizi girin",
"confirm": "Devre Dışı Bırakmayı Onayla",
"cancel": "İptal"
},
"backupCodes": {
"title": "Yedek Kodlar",
"description": "Bu yedek kodları güvenli bir yerde saklayın. Kimlik doğrulayıcı cihazınızı kaybederseniz hesabınıza erişmek için bunları kullanabilirsiniz.",
"warning": "Önemli:",
"warningText": "Her yedek kod yalnızca bir kez kullanılabilir. Güvenli tutun ve kimseyle paylaşmayın.",
"generateNew": "Yeni Yedek Kodlar Oluştur",
"download": "Yedek Kodları İndir",
"copyToClipboard": "Panoya Kopyala",
"savedMessage": "Yedek Kodlarımı Kaydettim",
"available": "{count} yedek kod mevcut",
"instructions": [
"• Bu kodları güvenli bir yerde saklayın",
"• Her yedek kod yalnızca bir kez kullanılabilir",
"• İstediğiniz zaman yeni kodlar oluşturabilirsiniz"
]
},
"verification": {
"title": "İki Faktörlü Kimlik Doğrulama",
"description": "Kimlik doğrulayıcı uygulamanızdan 6 haneli kodu girin",
"backupDescription": "Devam etmek için yedek kodlarınızdan birini girin",
"verificationCode": "Doğrulama Kodu",
"backupCode": "Yedek Kod",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "Doğrula",
"verifying": "Doğrulanıyor...",
"useBackupCode": "Bunun yerine yedek kod kullan",
"useAuthenticatorCode": "Bunun yerine kimlik doğrulayıcı kodu kullan"
},
"messages": {
"enabledSuccess": "İki faktörlü kimlik doğrulama başarıyla etkinleştirildi!",
"disabledSuccess": "İki faktörlü kimlik doğrulama başarıyla devre dışı bırakıldı",
"backupCodesGenerated": "Yeni yedek kodlar başarıyla oluşturuldu",
"backupCodesCopied": "Yedek kodlar panoya kopyalandı",
"setupFailed": "2FA kurulumu oluşturulamadı",
"verificationFailed": "Geçersiz doğrulama kodu",
"disableFailed": "2FA devre dışı bırakılamadı. Lütfen şifrenizi kontrol edin.",
"backupCodesFailed": "Yedek kodlar oluşturulamadı",
"backupCodesCopyFailed": "Yedek kodlar kopyalanamadı",
"statusLoadFailed": "2FA durumu yüklenemedi",
"enterVerificationCode": "Lütfen doğrulama kodunu girin",
"enterPassword": "Lütfen şifrenizi girin"
},
"errors": {
"invalidVerificationCode": "Geçersiz doğrulama kodu",
"invalidTwoFactorCode": "Geçersiz iki faktörlü kimlik doğrulama kodu",
"twoFactorRequired": "İki faktörlü kimlik doğrulama gerekli",
"twoFactorAlreadyEnabled": "İki faktörlü kimlik doğrulama zaten etkin",
"twoFactorNotEnabled": "İki faktörlü kimlik doğrulama etkin değil",
"passwordVerificationRequired": "Şifre doğrulaması gerekli",
"invalidPassword": "Geçersiz şifre",
"userNotFound": "Kullanıcı bulunamadı"
}
}
}

View File

@@ -182,7 +182,14 @@
"invalidCredentials": "电子邮件或密码错误",
"userNotFound": "未找到用户",
"accountLocked": "账户已锁定。请稍后再试",
"unexpectedError": "发生意外错误。请重试"
"unexpectedError": "发生意外错误。请重试",
"Invalid password": "密码无效",
"Invalid two-factor authentication code": "双重认证码无效",
"Invalid verification code": "验证码无效",
"Password verification required": "需要密码验证",
"Two-factor authentication is already enabled": "双重认证已启用",
"Two-factor authentication is not enabled": "双重认证未启用",
"Two-factor authentication required": "需要双重认证"
},
"fileActions": {
"editFile": "编辑文件",
@@ -1550,5 +1557,83 @@
},
"stats": "来自 {libraryCount} 个库的 {iconCount} 个图标",
"categoryBadge": "{category}{count} 个图标)"
},
"twoFactor": {
"title": "双重认证",
"description": "为您的账户添加额外的安全保护",
"enabled": "您的账户已启用双重认证保护",
"disabled": "未启用双重认证",
"setup": {
"title": "启用双重认证",
"description": "使用您的认证器应用扫描二维码,然后输入验证码。",
"qrCode": "二维码",
"manualEntryKey": "手动输入密钥",
"verificationCode": "验证码",
"verificationCodePlaceholder": "输入6位数验证码",
"verificationCodeDescription": "输入认证器应用生成的6位数验证码",
"verifyAndEnable": "验证并启用",
"cancel": "取消"
},
"disable": {
"title": "禁用双重认证",
"description": "请输入您的密码以确认禁用双重认证。",
"password": "密码",
"passwordPlaceholder": "输入您的密码",
"confirm": "确认禁用",
"cancel": "取消"
},
"backupCodes": {
"title": "备用码",
"description": "请将这些备用码保存在安全的地方。如果您丢失了认证器设备,可以使用它们访问您的账户。",
"warning": "重要提示:",
"warningText": "每个备用码只能使用一次。请妥善保管,不要与任何人分享。",
"generateNew": "生成新的备用码",
"download": "下载备用码",
"copyToClipboard": "复制到剪贴板",
"savedMessage": "我已保存备用码",
"available": "可用备用码:{count}个",
"instructions": [
"• 将这些代码保存在安全的位置",
"• 每个备用码只能使用一次",
"• 您可以随时生成新的备用码"
]
},
"verification": {
"title": "双重认证",
"description": "请输入认证器应用生成的6位数验证码",
"backupDescription": "请输入一个备用码以继续",
"verificationCode": "验证码",
"backupCode": "备用码",
"verificationCodePlaceholder": "000000",
"backupCodePlaceholder": "XXXX-XXXX",
"verify": "验证",
"verifying": "验证中...",
"useBackupCode": "使用备用码",
"useAuthenticatorCode": "使用认证器验证码"
},
"messages": {
"enabledSuccess": "双重认证启用成功!",
"disabledSuccess": "双重认证已成功禁用",
"backupCodesGenerated": "新的备用码生成成功",
"backupCodesCopied": "备用码已复制到剪贴板",
"setupFailed": "生成双重认证设置失败",
"verificationFailed": "验证码无效",
"disableFailed": "禁用双重认证失败。请检查您的密码。",
"backupCodesFailed": "生成备用码失败",
"backupCodesCopyFailed": "复制备用码失败",
"statusLoadFailed": "加载双重认证状态失败",
"enterVerificationCode": "请输入验证码",
"enterPassword": "请输入您的密码"
},
"errors": {
"invalidVerificationCode": "验证码无效",
"invalidTwoFactorCode": "双重认证码无效",
"twoFactorRequired": "需要双重认证",
"twoFactorAlreadyEnabled": "双重认证已启用",
"twoFactorNotEnabled": "未启用双重认证",
"passwordVerificationRequired": "需要密码验证",
"invalidPassword": "密码无效",
"userNotFound": "未找到用户"
}
}
}

View File

@@ -53,6 +53,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.20.1",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"jszip": "^3.10.1",
"lucide-react": "^0.525.0",
@@ -61,12 +62,14 @@
"next-intl": "^4.3.1",
"next-themes": "^0.4.6",
"nookies": "^2.5.2",
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-country-flag": "^3.1.0",
"react-dom": "^19.1.0",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.59.0",
"react-icons": "^5.5.0",
"react-qr-reader": "3.0.0-beta-1",
"sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.3.4",
@@ -80,6 +83,7 @@
"@tailwindcss/postcss": "4.1.11",
"@types/js-cookie": "^3.0.6",
"@types/node": "22.14.0",
"@types/qrcode": "^1.5.5",
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@typescript-eslint/eslint-plugin": "8.35.1",

280
apps/web/pnpm-lock.yaml generated
View File

@@ -77,6 +77,9 @@ importers:
framer-motion:
specifier: ^12.20.1
version: 12.23.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
js-cookie:
specifier: ^3.0.5
version: 3.0.5
@@ -101,6 +104,9 @@ importers:
nookies:
specifier: ^2.5.2
version: 2.5.2
qrcode:
specifier: ^1.5.4
version: 1.5.4
react:
specifier: ^19.1.0
version: 19.1.0
@@ -119,6 +125,9 @@ importers:
react-icons:
specifier: ^5.5.0
version: 5.5.0(react@19.1.0)
react-qr-reader:
specifier: 3.0.0-beta-1
version: 3.0.0-beta-1(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
sonner:
specifier: ^2.0.5
version: 2.0.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -153,6 +162,9 @@ importers:
'@types/node':
specifier: 22.14.0
version: 22.14.0
'@types/qrcode':
specifier: ^1.5.5
version: 1.5.5
'@types/react':
specifier: 19.1.8
version: 19.1.8
@@ -1163,6 +1175,9 @@ packages:
'@types/node@22.14.0':
resolution: {integrity: sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==}
'@types/qrcode@1.5.5':
resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==}
'@types/react-dom@19.1.6':
resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==}
peerDependencies:
@@ -1332,6 +1347,18 @@ packages:
cpu: [x64]
os: [win32]
'@zxing/browser@0.0.7':
resolution: {integrity: sha512-AepzMgDnD6EjxewqmXpHJsi4S3Gw9ilZJLIbTf6fWuWySEcHBodnGu3p7FWlgq1Sd5QyfPhTum5z3CBkkhMVng==}
peerDependencies:
'@zxing/library': ^0.18.3
'@zxing/library@0.18.6':
resolution: {integrity: sha512-bulZ9JHoLFd9W36pi+7e7DnEYNJhljYjZ1UTsKPOoLMU3qtC+REHITeCRNx40zTRJZx18W5TBRXt5pq2Uopjsw==}
engines: {node: '>= 10.4.0'}
'@zxing/text-encoding@0.9.0':
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1345,6 +1372,10 @@ packages:
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
@@ -1454,6 +1485,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001727:
resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==}
@@ -1471,6 +1506,9 @@ packages:
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -1548,6 +1586,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -1573,6 +1615,9 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -1581,6 +1626,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
@@ -1803,6 +1851,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -1845,6 +1897,11 @@ packages:
react-dom:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -1855,6 +1912,10 @@ packages:
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -1949,6 +2010,12 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
input-otp@1.4.2:
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
@@ -2002,6 +2069,10 @@ packages:
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
engines: {node: '>= 0.4'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-generator-function@1.1.0:
resolution: {integrity: sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==}
engines: {node: '>= 0.4'}
@@ -2196,6 +2267,10 @@ packages:
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
engines: {node: '>= 12.0.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -2369,14 +2444,26 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
@@ -2406,6 +2493,10 @@ packages:
resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
engines: {node: '>=12'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -2450,6 +2541,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2487,6 +2583,12 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-qr-reader@3.0.0-beta-1:
resolution: {integrity: sha512-5HeFH9x/BlziRYQYGK2AeWS9WiKYZtGGMs9DXy3bcySTX3C9UJL9EwcPnWw8vlf7JP4FcrAlr1SnZ5nsWLQGyw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0
react-dom: ^16.8.0 || ^17.0.0
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
@@ -2547,6 +2649,13 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -2567,6 +2676,11 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rollup@2.79.2:
resolution: {integrity: sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==}
engines: {node: '>=10.0.0'}
hasBin: true
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -2597,6 +2711,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-cookie-parser@2.7.1:
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
@@ -2667,6 +2784,10 @@ packages:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string.prototype.includes@2.0.1:
resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
engines: {node: '>= 0.4'}
@@ -2693,6 +2814,10 @@ packages:
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -2757,6 +2882,10 @@ packages:
peerDependencies:
typescript: '>=4.8.4'
ts-custom-error@3.3.1:
resolution: {integrity: sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==}
engines: {node: '>=14.0.0'}
tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
@@ -2849,6 +2978,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.19:
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
engines: {node: '>= 0.4'}
@@ -2862,10 +2994,25 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -3811,6 +3958,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/qrcode@1.5.5':
dependencies:
'@types/node': 22.14.0
'@types/react-dom@19.1.6(@types/react@19.1.8)':
dependencies:
'@types/react': 19.1.8
@@ -3978,6 +4129,21 @@ snapshots:
'@unrs/resolver-binding-win32-x64-msvc@1.11.0':
optional: true
'@zxing/browser@0.0.7(@zxing/library@0.18.6)':
dependencies:
'@zxing/library': 0.18.6
optionalDependencies:
'@zxing/text-encoding': 0.9.0
'@zxing/library@0.18.6':
dependencies:
ts-custom-error: 3.3.1
optionalDependencies:
'@zxing/text-encoding': 0.9.0
'@zxing/text-encoding@0.9.0':
optional: true
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@@ -3991,6 +4157,8 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
@@ -4132,6 +4300,8 @@ snapshots:
callsites@3.1.0: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001727: {}
chalk@4.1.2:
@@ -4147,6 +4317,12 @@ snapshots:
client-only@0.0.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
clsx@2.1.1: {}
color-convert@2.0.1:
@@ -4219,6 +4395,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decimal.js@10.6.0: {}
deep-is@0.1.4: {}
@@ -4241,6 +4419,8 @@ snapshots:
detect-node-es@1.1.0: {}
dijkstrajs@1.0.3: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -4251,6 +4431,8 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
enhanced-resolve@5.18.2:
@@ -4615,6 +4797,11 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -4650,6 +4837,9 @@ snapshots:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
function.prototype.name@1.1.8:
@@ -4663,6 +4853,8 @@ snapshots:
functions-have-names@1.2.3: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -4751,6 +4943,11 @@ snapshots:
inherits@2.0.4: {}
input-otp@1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
internal-slot@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -4817,6 +5014,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
is-fullwidth-code-point@3.0.0: {}
is-generator-function@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -4993,6 +5192,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.1
lightningcss-win32-x64-msvc: 1.30.1
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -5164,14 +5367,24 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
p-try@2.2.0: {}
pako@1.0.11: {}
parent-module@1.0.1:
@@ -5190,6 +5403,8 @@ snapshots:
picomatch@4.0.2: {}
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss@8.4.31:
@@ -5228,6 +5443,12 @@ snapshots:
punycode@2.3.1: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
queue-microtask@1.2.3: {}
raf-schd@4.0.3: {}
@@ -5258,6 +5479,14 @@ snapshots:
react-is@16.13.1: {}
react-qr-reader@3.0.0-beta-1(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@zxing/browser': 0.0.7(@zxing/library@0.18.6)
'@zxing/library': 0.18.6
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
rollup: 2.79.2
react-redux@9.2.0(@types/react@19.1.8)(react@19.1.0)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
@@ -5328,6 +5557,10 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -5346,6 +5579,10 @@ snapshots:
reusify@1.1.0: {}
rollup@2.79.2:
optionalDependencies:
fsevents: 2.3.3
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -5377,6 +5614,8 @@ snapshots:
semver@7.7.2: {}
set-blocking@2.0.0: {}
set-cookie-parser@2.7.1: {}
set-function-length@1.2.2:
@@ -5487,6 +5726,12 @@ snapshots:
streamsearch@1.1.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string.prototype.includes@2.0.1:
dependencies:
call-bind: 1.0.8
@@ -5541,6 +5786,10 @@ snapshots:
dependencies:
safe-buffer: 5.1.2
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-bom@3.0.0: {}
strip-json-comments@3.1.1: {}
@@ -5590,6 +5839,8 @@ snapshots:
dependencies:
typescript: 5.8.3
ts-custom-error@3.3.1: {}
tsconfig-paths@3.15.0:
dependencies:
'@types/json5': 0.0.29
@@ -5736,6 +5987,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
which-module@2.0.1: {}
which-typed-array@1.1.19:
dependencies:
available-typed-arrays: 1.0.7
@@ -5752,8 +6005,35 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
y18n@4.0.3: {}
yallist@5.0.0: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yocto-queue@0.1.0: {}
zod@3.25.74: {}

View File

@@ -213,7 +213,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
return false;
}
// Check if either name or email is required
const nameRequired = reverseShare.nameFieldRequired === "REQUIRED";
const emailRequired = reverseShare.emailFieldRequired === "REQUIRED";
@@ -227,9 +226,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
return false;
}
// Remove the validation that requires at least one field when both are optional
// When both fields are OPTIONAL, they should be truly optional (can be empty)
return true;
};
@@ -275,8 +271,6 @@ export function FileUploadSection({ reverseShare, password, alias, onUploadSucce
if (emailRequired && !uploaderEmail.trim()) return false;
// When both fields are OPTIONAL, they should be truly optional (can be empty)
// Remove the check that requires at least one field to be filled
return true;
};

View File

@@ -460,7 +460,6 @@ export function ReceivedFilesModal({
const { editingFile, editValue, setEditValue, inputRef, startEdit, cancelEdit } = useFileEdit();
// Clear selections when files change
useEffect(() => {
setSelectedFiles(new Set());
}, [reverseShare?.files]);
@@ -651,7 +650,6 @@ export function ReceivedFilesModal({
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Clear selections after successful download
setSelectedFiles(new Set());
})(),
{
@@ -684,7 +682,6 @@ export function ReceivedFilesModal({
await Promise.all(copyPromises);
// Clear selections after successful copy
setSelectedFiles(new Set());
} finally {
setBulkCopying(false);
@@ -732,7 +729,6 @@ export function ReceivedFilesModal({
await Promise.all(deletePromises);
// Clear selections and refresh data
setSelectedFiles(new Set());
setFilesToDeleteBulk([]);
if (onRefresh) {

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
try {
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/auth/2fa/backup-codes`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
cookie: cookieHeader || "",
...Object.fromEntries(Array.from(req.headers.entries()).filter(([key]) => key.startsWith("authorization"))),
},
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
} catch (error) {
console.error("Error proxying 2FA backup codes request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/auth/2fa/disable`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
...Object.fromEntries(Array.from(req.headers.entries()).filter(([key]) => key.startsWith("authorization"))),
},
body,
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
} catch (error) {
console.error("Error proxying 2FA disable request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/auth/2fa/login`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
...Object.fromEntries(Array.from(req.headers.entries()).filter(([key]) => key.startsWith("authorization"))),
},
body,
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
} catch (error) {
console.error("Error proxying 2FA login request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/auth/2fa/setup`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
...Object.fromEntries(Array.from(req.headers.entries()).filter(([key]) => key.startsWith("authorization"))),
},
body: body || "{}",
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
} catch (error) {
console.error("Error proxying 2FA setup request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function GET(req: NextRequest) {
try {
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/auth/2fa/status`;
const apiRes = await fetch(url, {
method: "GET",
headers: {
cookie: cookieHeader || "",
...Object.fromEntries(Array.from(req.headers.entries()).filter(([key]) => key.startsWith("authorization"))),
},
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
} catch (error) {
console.error("Error proxying 2FA status request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/auth/2fa/verify-setup`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
...Object.fromEntries(Array.from(req.headers.entries()).filter(([key]) => key.startsWith("authorization"))),
},
body,
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
} catch (error) {
console.error("Error proxying 2FA verify setup request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
const API_BASE_URL = process.env.API_BASE_URL || "http://localhost:3333";
export async function POST(req: NextRequest) {
try {
const body = await req.text();
const cookieHeader = req.headers.get("cookie");
const url = `${API_BASE_URL}/auth/2fa/verify`;
const apiRes = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookieHeader || "",
...Object.fromEntries(Array.from(req.headers.entries()).filter(([key]) => key.startsWith("authorization"))),
},
body,
redirect: "manual",
});
const resBody = await apiRes.text();
const res = new NextResponse(resBody, {
status: apiRes.status,
headers: {
"Content-Type": "application/json",
},
});
const setCookie = apiRes.headers.getSetCookie?.() || [];
if (setCookie.length > 0) {
res.headers.set("Set-Cookie", setCookie.join(","));
}
return res;
} catch (error) {
console.error("Error proxying 2FA verify request:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View File

@@ -44,7 +44,6 @@ export default function AuthCallbackPage() {
}
if (token) {
// Set the token in a cookie and redirect to dashboard (using Palmr's standard cookie name)
document.cookie = `token=${token}; path=/; max-age=${7 * 24 * 60 * 60}; samesite=lax`;
toast.success("Successfully authenticated!");
@@ -52,7 +51,6 @@ export default function AuthCallbackPage() {
return;
}
// If no token or error, redirect to login
router.push("/login");
}, [router, searchParams]);

View File

@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { IconShield } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
interface TwoFactorVerificationProps {
twoFactorCode: string;
setTwoFactorCode: (code: string) => void;
onSubmit: () => void;
error?: string;
isSubmitting: boolean;
}
export function TwoFactorVerification({
twoFactorCode,
setTwoFactorCode,
onSubmit,
error,
isSubmitting,
}: TwoFactorVerificationProps) {
const t = useTranslations();
const [showBackupCode, setShowBackupCode] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit();
};
const handleCodeChange = (value: string) => {
setTwoFactorCode(value);
};
const handleBackupCodeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.toUpperCase();
setTwoFactorCode(value);
};
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
<div className="p-3 rounded-full bg-primary/10">
<IconShield className="h-8 w-8 text-primary" />
</div>
</div>
<CardTitle>{t("twoFactor.verification.title")}</CardTitle>
<CardDescription>
{showBackupCode ? t("twoFactor.verification.backupDescription") : t("twoFactor.verification.description")}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="twoFactorCode" className="mb-2">
{showBackupCode ? t("twoFactor.verification.backupCode") : t("twoFactor.verification.verificationCode")}
</Label>
{showBackupCode ? (
<Input
id="twoFactorCode"
type="text"
placeholder={t("twoFactor.verification.backupCodePlaceholder")}
value={twoFactorCode}
onChange={handleBackupCodeChange}
className="text-center tracking-widest font-mono"
maxLength={9}
/>
) : (
<div className="flex justify-center">
<InputOTP maxLength={6} value={twoFactorCode} onChange={handleCodeChange}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting || twoFactorCode.length < (showBackupCode ? 8 : 6)}
>
{isSubmitting ? t("twoFactor.verification.verifying") : t("twoFactor.verification.verify")}
</Button>
{error && (
<div className="text-sm text-destructive text-center bg-destructive/10 p-3 rounded-md">{error}</div>
)}
<div className="text-center">
<Button
type="button"
variant="link"
onClick={() => {
setShowBackupCode(!showBackupCode);
setTwoFactorCode("");
}}
className="text-sm text-muted-foreground hover:text-primary transition-colors"
>
{showBackupCode
? t("twoFactor.verification.useAuthenticatorCode")
: t("twoFactor.verification.useBackupCode")}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -9,6 +9,8 @@ import { z } from "zod";
import { useAuth } from "@/contexts/auth-context";
import { getAppInfo, getCurrentUser, login } from "@/http/endpoints";
import { completeTwoFactorLogin } from "@/http/endpoints/auth/two-factor";
import type { LoginResponse } from "@/http/endpoints/auth/two-factor/types";
import { LoginFormValues } from "../schemas/schema";
export const loginSchema = z.object({
@@ -26,6 +28,10 @@ export function useLogin() {
const [isVisible, setIsVisible] = useState(false);
const [error, setError] = useState<string | undefined>();
const [isInitialized, setIsInitialized] = useState(false);
const [requiresTwoFactor, setRequiresTwoFactor] = useState(false);
const [twoFactorUserId, setTwoFactorUserId] = useState<string | null>(null);
const [twoFactorCode, setTwoFactorCode] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const errorParam = searchParams.get("error");
@@ -96,16 +102,25 @@ export function useLogin() {
const onSubmit = async (data: LoginFormValues) => {
setError(undefined);
setIsSubmitting(true);
try {
await login(data);
const userResponse = await getCurrentUser();
const { isAdmin, ...userData } = userResponse.data.user;
const response = await login(data);
const loginData = response.data as LoginResponse;
setUser(userData);
setIsAdmin(isAdmin);
setIsAuthenticated(true);
router.replace("/dashboard");
if (loginData.requiresTwoFactor && loginData.userId) {
setRequiresTwoFactor(true);
setTwoFactorUserId(loginData.userId);
return;
}
if (loginData.user) {
const { isAdmin, ...userData } = loginData.user;
setUser({ ...userData, image: userData.image ?? null });
setIsAdmin(isAdmin);
setIsAuthenticated(true);
router.replace("/dashboard");
}
} catch (err) {
if (axios.isAxiosError(err) && err.response?.data?.error) {
setError(t(`errors.${err.response.data.error}`));
@@ -115,6 +130,40 @@ export function useLogin() {
setIsAuthenticated(false);
setUser(null);
setIsAdmin(false);
} finally {
setIsSubmitting(false);
}
};
const onTwoFactorSubmit = async () => {
if (!twoFactorUserId || !twoFactorCode) {
setError(t("twoFactor.messages.enterVerificationCode"));
return;
}
setError(undefined);
setIsSubmitting(true);
try {
const response = await completeTwoFactorLogin({
userId: twoFactorUserId,
token: twoFactorCode,
});
const { isAdmin, ...userData } = response.data.user;
setUser(userData);
setIsAdmin(isAdmin);
setIsAuthenticated(true);
router.replace("/dashboard");
} catch (err) {
if (axios.isAxiosError(err) && err.response?.data?.error) {
setError(err.response.data.error);
} else {
setError(t("twoFactor.errors.invalidTwoFactorCode"));
}
} finally {
setIsSubmitting(false);
}
};
@@ -124,5 +173,10 @@ export function useLogin() {
isVisible,
toggleVisibility,
onSubmit,
requiresTwoFactor,
twoFactorCode,
setTwoFactorCode,
onTwoFactorSubmit,
isSubmitting,
};
}

View File

@@ -10,6 +10,7 @@ import { LoginForm } from "./components/login-form";
import { LoginHeader } from "./components/login-header";
import { RegisterForm } from "./components/register-form";
import { StaticBackgroundLights } from "./components/static-background-lights";
import { TwoFactorVerification } from "./components/two-factor-verification";
import { useLogin } from "./hooks/use-login";
export default function LoginPage() {
@@ -38,6 +39,14 @@ export default function LoginPage() {
<LoginHeader firstAccess={firstAccess as boolean} />
{firstAccess ? (
<RegisterForm isVisible={login.isVisible} onToggleVisibility={login.toggleVisibility} />
) : login.requiresTwoFactor ? (
<TwoFactorVerification
twoFactorCode={login.twoFactorCode}
setTwoFactorCode={login.setTwoFactorCode}
onSubmit={login.onTwoFactorSubmit}
error={login.error}
isSubmitting={login.isSubmitting}
/>
) : (
<LoginForm
error={login.error}

View File

@@ -0,0 +1,275 @@
"use client";
import { useState } from "react";
import {
IconCopy,
IconDownload,
IconEye,
IconEyeClosed,
IconKey,
IconShield,
IconShieldCheck,
} from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import { useTwoFactor } from "../hooks/use-two-factor";
export function TwoFactorForm() {
const t = useTranslations();
const {
isLoading,
status,
setupData,
backupCodes,
verificationCode,
disablePassword,
isSetupModalOpen,
isDisableModalOpen,
isBackupCodesModalOpen,
setVerificationCode,
setDisablePassword,
setIsSetupModalOpen,
setIsDisableModalOpen,
setIsBackupCodesModalOpen,
startSetup,
verifySetup,
disable2FA,
generateNewBackupCodes,
downloadBackupCodes,
copyBackupCodes,
} = useTwoFactor();
const [showPassword, setShowPassword] = useState(false);
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<IconShield className="h-5 w-5" />
{t("twoFactor.title")}
</CardTitle>
<CardDescription>Loading...</CardDescription>
</CardHeader>
</Card>
);
}
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{status.enabled ? (
<IconShieldCheck className="h-5 w-5 text-green-600" />
) : (
<IconShield className="h-5 w-5" />
)}
{t("twoFactor.title")}
</CardTitle>
<CardDescription>{status.enabled ? t("twoFactor.enabled") : t("twoFactor.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Status: {status.enabled ? "Enabled" : "Disabled"}</p>
{status.enabled && (
<p className="text-sm text-muted-foreground">
{t("twoFactor.backupCodes.available", { count: status.availableBackupCodes })}
</p>
)}
</div>
<div className="flex gap-2">
{status.enabled ? (
<>
<Button variant="outline" onClick={generateNewBackupCodes} disabled={isLoading}>
<IconKey className="h-4 w-4" />
{t("twoFactor.backupCodes.generateNew")}
</Button>
<Button variant="destructive" onClick={() => setIsDisableModalOpen(true)} disabled={isLoading}>
Disable 2FA
</Button>
</>
) : (
<Button onClick={startSetup} disabled={isLoading}>
<IconShield className="h-4 w-4" />
Enable 2FA
</Button>
)}
</div>
</div>
</CardContent>
</Card>
{/* Setup Modal */}
<Dialog open={isSetupModalOpen} onOpenChange={setIsSetupModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.setup.title")}</DialogTitle>
<DialogDescription>{t("twoFactor.setup.description")}</DialogDescription>
</DialogHeader>
{setupData && (
<div className="space-y-4">
{/* QR Code */}
<div className="flex justify-center">
<img src={setupData.qrCode} alt="2FA QR Code" className="w-48 h-48 border rounded-lg" />
</div>
{/* Manual Entry */}
<div>
<Label className="text-sm font-medium">{t("twoFactor.setup.manualEntryKey")}</Label>
<div className="flex items-center gap-2 mt-1">
<Input value={setupData.manualEntryKey} readOnly className="font-mono text-xs" />
<Button
size="sm"
variant="outline"
onClick={() => navigator.clipboard.writeText(setupData.manualEntryKey)}
>
<IconCopy className="h-4 w-4" />
</Button>
</div>
</div>
{/* Verification Code */}
<div>
<Label htmlFor="verification-code" className="mb-2">
{t("twoFactor.setup.verificationCode")}
</Label>
<div className="flex justify-start">
<InputOTP maxLength={6} value={verificationCode} onChange={setVerificationCode}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
<span className="text-sm text-muted-foreground mt-1">
{t("twoFactor.setup.verificationCodeDescription")}
</span>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setIsSetupModalOpen(false)} disabled={isLoading}>
{t("twoFactor.setup.cancel")}
</Button>
<Button onClick={verifySetup} disabled={isLoading || !verificationCode || verificationCode.length !== 6}>
{t("twoFactor.setup.verifyAndEnable")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Disable Modal */}
<Dialog open={isDisableModalOpen} onOpenChange={setIsDisableModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.disable.title")}</DialogTitle>
<DialogDescription>{t("twoFactor.disable.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="disable-password" className="mb-2">
{t("twoFactor.disable.password")}
</Label>
<div className="relative">
<Input
id="disable-password"
type={showPassword ? "text" : "password"}
placeholder={t("twoFactor.disable.passwordPlaceholder")}
value={disablePassword}
onChange={(e) => setDisablePassword(e.target.value)}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<IconEye className="h-4 w-4 text-muted-foreground" />
) : (
<IconEyeClosed className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDisableModalOpen(false)} disabled={isLoading}>
{t("twoFactor.disable.cancel")}
</Button>
<Button variant="destructive" onClick={disable2FA} disabled={isLoading || !disablePassword}>
{t("twoFactor.disable.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Backup Codes Modal */}
<Dialog open={isBackupCodesModalOpen} onOpenChange={setIsBackupCodesModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("twoFactor.backupCodes.title")}</DialogTitle>
<DialogDescription>{t("twoFactor.backupCodes.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="bg-muted p-4 rounded-lg">
<div className="grid grid-cols-2 gap-2 font-mono text-sm">
{backupCodes.map((code, index) => (
<div key={index} className="text-center py-1">
{code}
</div>
))}
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={downloadBackupCodes} className="flex-1">
<IconDownload className="h-4 w-4" />
{t("twoFactor.backupCodes.download")}
</Button>
<Button variant="outline" onClick={copyBackupCodes} className="flex-1">
<IconCopy className="h-4 w-4" />
{t("twoFactor.backupCodes.copyToClipboard")}
</Button>
</div>
<div className="text-sm text-muted-foreground">
{t.raw("twoFactor.backupCodes.instructions").map((instruction: string, index: number) => (
<p key={index}>{instruction}</p>
))}
</div>
</div>
<DialogFooter>
<Button onClick={() => setIsBackupCodesModalOpen(false)}>{t("twoFactor.backupCodes.savedMessage")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,200 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useAppInfo } from "@/contexts/app-info-context";
import {
disableTwoFactor,
generate2FASetup,
generateBackupCodes,
getTwoFactorStatus,
verifyTwoFactorSetup,
} from "@/http/endpoints/auth/two-factor";
import type { TwoFactorSetupResponse, TwoFactorStatus } from "@/http/endpoints/auth/two-factor/types";
export function useTwoFactor() {
const t = useTranslations();
const { appName } = useAppInfo();
const [isLoading, setIsLoading] = useState(true);
const [status, setStatus] = useState<TwoFactorStatus>({
enabled: false,
verified: false,
availableBackupCodes: 0,
});
const [setupData, setSetupData] = useState<TwoFactorSetupResponse | null>(null);
const [isSetupModalOpen, setIsSetupModalOpen] = useState(false);
const [isDisableModalOpen, setIsDisableModalOpen] = useState(false);
const [isBackupCodesModalOpen, setIsBackupCodesModalOpen] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [verificationCode, setVerificationCode] = useState("");
const [disablePassword, setDisablePassword] = useState("");
const loadStatus = useCallback(async () => {
try {
setIsLoading(true);
const response = await getTwoFactorStatus();
setStatus(response.data);
} catch (error) {
console.error("Failed to load 2FA status:", error);
toast.error(t("twoFactor.messages.statusLoadFailed"));
} finally {
setIsLoading(false);
}
}, [t]);
const startSetup = async () => {
try {
setIsLoading(true);
const response = await generate2FASetup({ appName });
setSetupData(response.data);
setIsSetupModalOpen(true);
} catch (error: any) {
console.error("Failed to generate 2FA setup:", error);
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error(t("twoFactor.messages.setupFailed"));
}
} finally {
setIsLoading(false);
}
};
const verifySetup = async () => {
if (!setupData || !verificationCode) {
toast.error(t("twoFactor.messages.enterVerificationCode"));
return;
}
try {
setIsLoading(true);
const response = await verifyTwoFactorSetup({
token: verificationCode,
secret: setupData.secret,
});
if (response.data.success) {
setBackupCodes(response.data.backupCodes);
setIsSetupModalOpen(false);
setIsBackupCodesModalOpen(true);
setVerificationCode("");
toast.success(t("twoFactor.messages.enabledSuccess"));
await loadStatus();
}
} catch (error: any) {
console.error("Failed to verify 2FA setup:", error);
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error(t("twoFactor.messages.verificationFailed"));
}
} finally {
setIsLoading(false);
}
};
const disable2FA = async () => {
if (!disablePassword) {
toast.error(t("twoFactor.messages.enterPassword"));
return;
}
try {
setIsLoading(true);
const response = await disableTwoFactor({
password: disablePassword,
});
if (response.data.success) {
setIsDisableModalOpen(false);
setDisablePassword("");
toast.success(t("twoFactor.messages.disabledSuccess"));
await loadStatus();
}
} catch (error: any) {
console.error("Failed to disable 2FA:", error);
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error(t("twoFactor.messages.disableFailed"));
}
} finally {
setIsLoading(false);
}
};
const generateNewBackupCodes = async () => {
try {
setIsLoading(true);
const response = await generateBackupCodes();
setBackupCodes(response.data.backupCodes);
setIsBackupCodesModalOpen(true);
toast.success(t("twoFactor.messages.backupCodesGenerated"));
await loadStatus();
} catch (error: any) {
console.error("Failed to generate backup codes:", error);
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error(t("twoFactor.messages.backupCodesFailed"));
}
} finally {
setIsLoading(false);
}
};
const downloadBackupCodes = () => {
const content = backupCodes.join("\n");
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "palmr-backup-codes.txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const copyBackupCodes = async () => {
try {
await navigator.clipboard.writeText(backupCodes.join("\n"));
toast.success(t("twoFactor.messages.backupCodesCopied"));
} catch {
toast.error(t("twoFactor.messages.backupCodesCopyFailed"));
}
};
useEffect(() => {
loadStatus();
}, [loadStatus]);
return {
isLoading,
status,
setupData,
backupCodes,
verificationCode,
disablePassword,
isSetupModalOpen,
isDisableModalOpen,
isBackupCodesModalOpen,
setVerificationCode,
setDisablePassword,
setIsSetupModalOpen,
setIsDisableModalOpen,
setIsBackupCodesModalOpen,
startSetup,
verifySetup,
disable2FA,
generateNewBackupCodes,
downloadBackupCodes,
copyBackupCodes,
loadStatus,
};
}

View File

@@ -8,6 +8,7 @@ import { PasswordForm } from "./components/password-form";
import { ProfileForm } from "./components/profile-form";
import { ProfileHeader } from "./components/profile-header";
import { ProfilePicture } from "./components/profile-picture";
import { TwoFactorForm } from "./components/two-factor-form";
import { useProfile } from "./hooks/use-profile";
export default function ProfilePage() {
@@ -39,6 +40,7 @@ export default function ProfilePage() {
onToggleConfirmPassword={() => profile.setIsConfirmPasswordVisible(!profile.isConfirmPasswordVisible)}
onToggleNewPassword={() => profile.setIsNewPasswordVisible(!profile.isNewPasswordVisible)}
/>
<TwoFactorForm />
</div>
</div>
</div>

View File

@@ -70,7 +70,6 @@ export function EditProviderForm({
const [showClientSecret, setShowClientSecret] = useState(false);
const isOfficial = provider.isOfficial;
// Função para identificar providers oficiais que não devem ter o campo de provider URL editável
const isProviderUrlEditable = (providerName: string): boolean => {
const nonEditableProviders = ["google", "discord", "github"];
return !nonEditableProviders.includes(providerName.toLowerCase());

View File

@@ -77,7 +77,6 @@ export function SettingsInput({
);
}
// Use FileSizeInput for storage size fields
if (config.key === "maxFileSize" || config.key === "maxTotalStoragePerUser") {
const currentValue = watch(`configs.${config.key}`) || "0";
return (

View File

@@ -41,7 +41,6 @@ import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
// Custom CSS for additional grid columns
const customStyles = `
.grid-cols-16 {
grid-template-columns: repeat(16, minmax(0, 1fr));
@@ -51,9 +50,8 @@ const customStyles = `
}
`;
// Inject custom styles
if (typeof document !== "undefined") {
const styleElement = document.createElement("style");
const styleElement = document.createElement("iconPicker.style");
styleElement.textContent = customStyles;
if (!document.head.querySelector("style[data-icon-picker]")) {
styleElement.setAttribute("data-icon-picker", "true");
@@ -73,11 +71,9 @@ interface IconPickerProps {
placeholder?: string;
}
// Lazy loading configuration
const ICONS_PER_BATCH = 100;
const SCROLL_THRESHOLD = 200; // pixels from bottom to trigger load
const SCROLL_THRESHOLD = 200;
// Virtualized Icon Grid Component with Lazy Loading
interface VirtualizedIconGridProps {
icons: IconData[];
onIconSelect: (iconName: string) => void;
@@ -86,13 +82,12 @@ interface VirtualizedIconGridProps {
}
function VirtualizedIconGrid({ icons, onIconSelect, renderIcon, showCategories = false }: VirtualizedIconGridProps) {
const t = useTranslations("iconPicker");
const t = useTranslations();
const [visibleCount, setVisibleCount] = useState(ICONS_PER_BATCH);
const [isLoading, setIsLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
// Intersection Observer for infinite scroll
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
@@ -102,7 +97,6 @@ function VirtualizedIconGrid({ icons, onIconSelect, renderIcon, showCategories =
const entry = entries[0];
if (entry.isIntersecting && visibleCount < icons.length && !isLoading) {
setIsLoading(true);
// Simulate async loading with setTimeout for better UX
setTimeout(() => {
setVisibleCount((prev) => Math.min(prev + ICONS_PER_BATCH, icons.length));
setIsLoading(false);
@@ -123,19 +117,16 @@ function VirtualizedIconGrid({ icons, onIconSelect, renderIcon, showCategories =
};
}, [visibleCount, icons.length, isLoading]);
// Reset visible count when icons change
useEffect(() => {
setVisibleCount(ICONS_PER_BATCH);
}, [icons]);
// Reset visible count when switching between search and category view
useEffect(() => {
setVisibleCount(ICONS_PER_BATCH);
}, [showCategories]);
const visibleIcons = useMemo(() => icons.slice(0, visibleCount), [icons, visibleCount]);
// Group icons by category for category view - always compute, use conditionally
const iconsByCategory = useMemo(() => {
if (!showCategories) return [];
const grouped = new Map<string, IconData[]>();
@@ -155,7 +146,7 @@ function VirtualizedIconGrid({ icons, onIconSelect, renderIcon, showCategories =
{iconsByCategory.map(([category, categoryIcons]) => (
<div key={category}>
<Badge variant="secondary" className="text-xs mb-3">
{t("categoryBadge", {
{t("iconPicker.categoryBadge", {
category,
count: icons.filter((icon) => icon.category === category).length,
})}
@@ -177,10 +168,10 @@ function VirtualizedIconGrid({ icons, onIconSelect, renderIcon, showCategories =
{/* Loading indicator and sentinel */}
<div ref={sentinelRef} className="flex justify-center py-4">
{isLoading && <div className="text-sm text-muted-foreground">{t("loadingMore")}</div>}
{isLoading && <div className="text-sm text-muted-foreground">{t("iconPicker.loadingMore")}</div>}
{visibleCount >= icons.length && icons.length > 0 && (
<div className="text-sm text-muted-foreground">
{t("allIconsLoaded", { count: icons.length.toLocaleString() })}
{t("iconPicker.allIconsLoaded", { count: icons.length.toLocaleString() })}
</div>
)}
</div>
@@ -189,7 +180,6 @@ function VirtualizedIconGrid({ icons, onIconSelect, renderIcon, showCategories =
);
}
// Simple grid view for search results and specific tabs
return (
<div ref={scrollRef} className="max-h-[600px] overflow-y-auto overflow-x-hidden pr-2">
<div className="grid grid-cols-8 sm:grid-cols-12 lg:grid-cols-16 xl:grid-cols-20 gap-2 sm:gap-3">
@@ -205,12 +195,11 @@ function VirtualizedIconGrid({ icons, onIconSelect, renderIcon, showCategories =
))}
</div>
{/* Loading indicator and sentinel */}
<div ref={sentinelRef} className="flex justify-center py-4">
{isLoading && <div className="text-sm text-muted-foreground">{t("loadingMore")}</div>}
{isLoading && <div className="text-sm text-muted-foreground">{t("iconPicker.loadingMore")}</div>}
{visibleCount >= icons.length && icons.length > 0 && (
<div className="text-sm text-muted-foreground">
{t("allIconsLoaded", { count: icons.length.toLocaleString() })}
{t("iconPicker.allIconsLoaded", { count: icons.length.toLocaleString() })}
</div>
)}
</div>
@@ -218,7 +207,6 @@ function VirtualizedIconGrid({ icons, onIconSelect, renderIcon, showCategories =
);
}
// Popular icons for quick access
const POPULAR_ICONS = [
"FaGoogle",
"FaGithub",
@@ -252,7 +240,6 @@ const POPULAR_ICONS = [
"TbKey",
];
// Auth provider specific icons
const AUTH_PROVIDER_ICONS = [
"SiGoogle",
"SiGithub",
@@ -277,14 +264,12 @@ const AUTH_PROVIDER_ICONS = [
];
export function IconPicker({ value, onChange, placeholder }: IconPickerProps) {
const t = useTranslations("iconPicker");
const t = useTranslations();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
// Use translation for placeholder if not provided
const displayPlaceholder = placeholder || t("placeholder");
const displayPlaceholder = placeholder || t("iconPicker.placeholder");
// Combine ALL icon libraries
const allIcons = useMemo(() => {
const iconSets = [
{ icons: AiIcons, prefix: "Ai", category: "Ant Design Icons" },
@@ -344,23 +329,19 @@ export function IconPicker({ value, onChange, placeholder }: IconPickerProps) {
return icons;
}, []);
// Filter icons based on search
const filteredIcons = useMemo(() => {
if (!search) return allIcons;
return allIcons.filter((icon) => icon.name.toLowerCase().includes(search.toLowerCase()));
}, [allIcons, search]);
// Get popular icons
const popularIcons = useMemo(() => {
return POPULAR_ICONS.map((name) => allIcons.find((icon) => icon.name === name)).filter(Boolean) as IconData[];
}, [allIcons]);
// Get auth provider icons
const authProviderIcons = useMemo(() => {
return AUTH_PROVIDER_ICONS.map((name) => allIcons.find((icon) => icon.name === name)).filter(Boolean) as IconData[];
}, [allIcons]);
// Get current icon component
const currentIcon = useMemo(() => {
if (!value) return null;
return allIcons.find((icon) => icon.name === value);
@@ -380,7 +361,6 @@ export function IconPicker({ value, onChange, placeholder }: IconPickerProps) {
return <IconComponent className={className} size={size || 32} />;
}, []);
// Get unique categories for display
const categories = useMemo(() => {
const uniqueCategories = Array.from(new Set(allIcons.map((icon) => icon.category)));
return uniqueCategories.sort();
@@ -406,9 +386,9 @@ export function IconPicker({ value, onChange, placeholder }: IconPickerProps) {
<DialogContent className="max-w-5xl xl:max-w-6xl max-h-[90vh] overflow-hidden">
<div className="space-y-4 overflow-hidden">
<div className="flex items-center justify-between">
<DialogTitle>{t("title")}</DialogTitle>
<DialogTitle>{t("iconPicker.title")}</DialogTitle>
<div className="text-sm text-muted-foreground">
{t("stats", {
{t("iconPicker.stats", {
iconCount: allIcons.length.toLocaleString(),
libraryCount: categories.length,
})}
@@ -419,7 +399,7 @@ export function IconPicker({ value, onChange, placeholder }: IconPickerProps) {
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("searchPlaceholder")}
placeholder={t("iconPicker.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
@@ -438,9 +418,9 @@ export function IconPicker({ value, onChange, placeholder }: IconPickerProps) {
<Tabs defaultValue="all" className="w-full overflow-hidden">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="all">{t("tabs.all")}</TabsTrigger>
<TabsTrigger value="popular">{t("tabs.popular")}</TabsTrigger>
<TabsTrigger value="auth">{t("tabs.auth")}</TabsTrigger>
<TabsTrigger value="all">{t("iconPicker.tabs.all")}</TabsTrigger>
<TabsTrigger value="popular">{t("iconPicker.tabs.popular")}</TabsTrigger>
<TabsTrigger value="auth">{t("iconPicker.tabs.auth")}</TabsTrigger>
</TabsList>
{/* All Icons */}
@@ -490,7 +470,7 @@ export function IconPicker({ value, onChange, placeholder }: IconPickerProps) {
{search && filteredIcons.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<Search className="mx-auto h-12 w-12 opacity-50 mb-2" />
<p>{t("noIconsFound", { search })}</p>
<p>{t("iconPicker.noIconsFound", { search })}</p>
</div>
)}
</div>
@@ -499,7 +479,6 @@ export function IconPicker({ value, onChange, placeholder }: IconPickerProps) {
);
}
// Utility function to render an icon by name
export function renderIconByName(iconName: string, className = "w-5 h-5") {
const iconSets = [
AiIcons,
@@ -542,6 +521,5 @@ export function renderIconByName(iconName: string, className = "w-5 h-5") {
}
}
// Fallback to a default icon
return React.createElement(FaIcons.FaCog, { className });
}

View File

@@ -0,0 +1,77 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@@ -0,0 +1,42 @@
import apiInstance from "@/config/api";
import type {
CompleteTwoFactorLoginRequest,
DisableTwoFactorRequest,
DisableTwoFactorResponse,
GenerateBackupCodesResponse,
TwoFactorSetupRequest,
TwoFactorSetupResponse,
TwoFactorStatus,
VerifySetupRequest,
VerifySetupResponse,
VerifyTokenRequest,
VerifyTokenResponse,
} from "./types";
export const generate2FASetup = async (data?: TwoFactorSetupRequest) => {
return apiInstance.post<TwoFactorSetupResponse>("/api/auth/2fa/setup", data);
};
export const verifyTwoFactorSetup = async (data: VerifySetupRequest) => {
return apiInstance.post<VerifySetupResponse>("/api/auth/2fa/verify-setup", data);
};
export const verifyTwoFactorToken = async (data: VerifyTokenRequest) => {
return apiInstance.post<VerifyTokenResponse>("/api/auth/2fa/verify", data);
};
export const disableTwoFactor = async (data: DisableTwoFactorRequest) => {
return apiInstance.post<DisableTwoFactorResponse>("/api/auth/2fa/disable", data);
};
export const generateBackupCodes = async () => {
return apiInstance.post<GenerateBackupCodesResponse>("/api/auth/2fa/backup-codes");
};
export const getTwoFactorStatus = async () => {
return apiInstance.get<TwoFactorStatus>("/api/auth/2fa/status");
};
export const completeTwoFactorLogin = async (data: CompleteTwoFactorLoginRequest) => {
return apiInstance.post("/api/auth/2fa/login", data);
};

View File

@@ -0,0 +1,75 @@
export interface TwoFactorSetupRequest {
appName?: string;
}
export interface BackupCode {
code: string;
used: boolean;
}
export interface TwoFactorSetupResponse {
secret: string;
qrCode: string;
manualEntryKey: string;
backupCodes: BackupCode[];
}
export interface VerifySetupRequest {
token: string;
secret: string;
}
export interface VerifySetupResponse {
success: boolean;
backupCodes: string[];
}
export interface VerifyTokenRequest {
token: string;
}
export interface VerifyTokenResponse {
success: boolean;
method: "totp" | "backup";
}
export interface DisableTwoFactorRequest {
password: string;
}
export interface DisableTwoFactorResponse {
success: boolean;
}
export interface GenerateBackupCodesResponse {
backupCodes: string[];
}
export interface TwoFactorStatus {
enabled: boolean;
verified: boolean;
availableBackupCodes: number;
}
export interface CompleteTwoFactorLoginRequest {
userId: string;
token: string;
}
export interface LoginResponse {
user?: {
id: string;
firstName: string;
lastName: string;
username: string;
email: string;
image?: string | null;
isAdmin: boolean;
isActive: boolean;
createdAt: string;
updatedAt: string;
};
requiresTwoFactor?: boolean;
userId?: string;
message?: string;
}