mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
🔒 feat: Two-Factor Authentication with Backup Codes & QR support (#5685)
* 🔒 feat: add Two-Factor Authentication (2FA) with backup codes & QR support (#5684) * working version for generating TOTP and authenticate. * better looking UI * refactored + better TOTP logic * fixed issue with UI * fixed issue: remove initial setup when closing window before completion. * added: onKeyDown for verify and disable * refactored some code and cleaned it up a bit. * refactored some code and cleaned it up a bit. * refactored some code and cleaned it up a bit. * refactored some code and cleaned it up a bit. * fixed issue after updating to new main branch * updated example * refactored controllers * removed `passport-totp` not used. * update the generateBackupCodes function to generate 10 codes by default: * update the backup codes to an object. * fixed issue with backup codes not working * be able to disable 2FA with backup codes. * removed new env. replaced with JWT_SECRET * ✨ style: improved a11y and style for TwoFactorAuthentication * 🔒 fix: small types checks * ✨ feat: improve 2FA UI components * fix: remove unnecessary console log * add option to disable 2FA with backup codes * - add option to refresh backup codes - (optional) maybe show the user which backup codes have already been used? * removed text to be able to merge the main. * removed eng tx to be able to merge * fix: migrated lang to new format. * feat: rewrote whole 2FA UI + refactored 2FA backend * chore: resolving conflicts * chore: resolving conflicts * fix: missing packages, because of resolving conflicts. * fix: UI issue and improved a11y * fix: 2FA backup code not working * fix: update localization keys for UI consistency * fix: update button label to use localized text * fix: refactor backup codes regeneration and update localization keys * fix: remove outdated translation for shared links management * fix: remove outdated 2FA code prompts from translation.json * fix: add cursor styles for backup codes item based on usage state * fix: resolve conflict issue * fix: resolve conflict issue * fix: resolve conflict issue * fix: missing packages in package-lock.json * fix: add disabled opacity to the verify button in TwoFactorScreen * ⚙ fix: update 2FA logic to rely on backup codes instead of TOTP status * ⚙️ fix: Simplify user retrieval in 2FA logic by removing unnecessary TOTP secret query * ⚙️ test: Add unit tests for TwoFactorAuthController and twoFactorControllers * ⚙️ fix: Ensure backup codes are validated as an array before usage in 2FA components * ⚙️ fix: Update module path mappings in tests to use relative paths * ⚙️ fix: Update moduleNameMapper in jest.config.js to remove the caret from path mapping * ⚙️ refactor: Simplify import paths in TwoFactorAuthController and twoFactorControllers test files * ⚙️ test: Mock twoFactorService methods in twoFactorControllers tests * ⚙️ refactor: Comment out unused imports and mock setups in test files for two-factor authentication * ⚙️ refactor: removed files * refactor: Exclude totpSecret from user data retrieval in AuthController, LoginController, and jwtStrategy * refactor: Consolidate backup code verification to apply DRY and remove default array in user schema * refactor: Enhance two-factor authentication ux/flow with improved error handling and loading state management, prevent redirect to /login --------- Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com> Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
46ceae1a93
commit
f0f09138bd
63 changed files with 1976 additions and 129 deletions
|
@ -39,6 +39,12 @@ const Session = mongoose.Schema({
|
|||
},
|
||||
});
|
||||
|
||||
const backupCodeSchema = mongoose.Schema({
|
||||
codeHash: { type: String, required: true },
|
||||
used: { type: Boolean, default: false },
|
||||
usedAt: { type: Date, default: null },
|
||||
});
|
||||
|
||||
/** @type {MongooseSchema<MongoUser>} */
|
||||
const userSchema = mongoose.Schema(
|
||||
{
|
||||
|
@ -119,7 +125,12 @@ const userSchema = mongoose.Schema(
|
|||
},
|
||||
plugins: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
totpSecret: {
|
||||
type: String,
|
||||
},
|
||||
backupCodes: {
|
||||
type: [backupCodeSchema],
|
||||
},
|
||||
refreshToken: {
|
||||
type: [Session],
|
||||
|
|
|
@ -61,7 +61,7 @@ const refreshController = async (req, res) => {
|
|||
|
||||
try {
|
||||
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
const user = await getUserById(payload.id, '-password -__v');
|
||||
const user = await getUserById(payload.id, '-password -__v -totpSecret');
|
||||
if (!user) {
|
||||
return res.status(401).redirect('/login');
|
||||
}
|
||||
|
|
111
api/server/controllers/TwoFactorController.js
Normal file
111
api/server/controllers/TwoFactorController.js
Normal file
|
@ -0,0 +1,111 @@
|
|||
const {
|
||||
verifyTOTP,
|
||||
verifyBackupCode,
|
||||
generateTOTPSecret,
|
||||
generateBackupCodes,
|
||||
} = require('~/server/services/twoFactorService');
|
||||
const { updateUser, getUserById } = require('~/models');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const enable2FAController = async (req, res) => {
|
||||
const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');
|
||||
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const secret = generateTOTPSecret();
|
||||
const { plainCodes, codeObjects } = await generateBackupCodes();
|
||||
|
||||
const user = await updateUser(userId, { totpSecret: secret, backupCodes: codeObjects });
|
||||
|
||||
const otpauthUrl = `otpauth://totp/${safeAppTitle}:${user.email}?secret=${secret}&issuer=${safeAppTitle}`;
|
||||
|
||||
res.status(200).json({
|
||||
otpauthUrl,
|
||||
backupCodes: plainCodes,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('[enable2FAController]', err);
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const verify2FAController = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { token, backupCode } = req.body;
|
||||
const user = await getUserById(userId);
|
||||
if (!user || !user.totpSecret) {
|
||||
return res.status(400).json({ message: '2FA not initiated' });
|
||||
}
|
||||
|
||||
let verified = false;
|
||||
if (token && (await verifyTOTP(user.totpSecret, token))) {
|
||||
return res.status(200).json();
|
||||
} else if (backupCode) {
|
||||
verified = await verifyBackupCode({ user, backupCode });
|
||||
}
|
||||
if (verified) {
|
||||
return res.status(200).json();
|
||||
}
|
||||
|
||||
return res.status(400).json({ message: 'Invalid token.' });
|
||||
} catch (err) {
|
||||
logger.error('[verify2FAController]', err);
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const confirm2FAController = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { token } = req.body;
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (!user || !user.totpSecret) {
|
||||
return res.status(400).json({ message: '2FA not initiated' });
|
||||
}
|
||||
|
||||
if (await verifyTOTP(user.totpSecret, token)) {
|
||||
return res.status(200).json();
|
||||
}
|
||||
|
||||
return res.status(400).json({ message: 'Invalid token.' });
|
||||
} catch (err) {
|
||||
logger.error('[confirm2FAController]', err);
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const disable2FAController = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
await updateUser(userId, { totpSecret: null, backupCodes: [] });
|
||||
res.status(200).json();
|
||||
} catch (err) {
|
||||
logger.error('[disable2FAController]', err);
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const regenerateBackupCodesController = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { plainCodes, codeObjects } = await generateBackupCodes();
|
||||
await updateUser(userId, { backupCodes: codeObjects });
|
||||
res.status(200).json({
|
||||
backupCodes: plainCodes,
|
||||
backupCodesHash: codeObjects,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('[regenerateBackupCodesController]', err);
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
enable2FAController,
|
||||
verify2FAController,
|
||||
confirm2FAController,
|
||||
disable2FAController,
|
||||
regenerateBackupCodesController,
|
||||
};
|
|
@ -19,7 +19,9 @@ const { Transaction } = require('~/models/Transaction');
|
|||
const { logger } = require('~/config');
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
res.status(200).send(req.user);
|
||||
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
|
||||
delete userData.totpSecret;
|
||||
res.status(200).send(userData);
|
||||
};
|
||||
|
||||
const getTermsStatusController = async (req, res) => {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
const { generate2FATempToken } = require('~/server/services/twoFactorService');
|
||||
const { setAuthTokens } = require('~/server/services/AuthService');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
|
@ -7,7 +8,12 @@ const loginController = async (req, res) => {
|
|||
return res.status(400).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const { password: _, __v, ...user } = req.user;
|
||||
if (req.user.backupCodes != null && req.user.backupCodes.length > 0) {
|
||||
const tempToken = generate2FATempToken(req.user._id);
|
||||
return res.status(200).json({ twoFAPending: true, tempToken });
|
||||
}
|
||||
|
||||
const { password: _p, totpSecret: _t, __v, ...user } = req.user;
|
||||
user.id = user._id.toString();
|
||||
|
||||
const token = await setAuthTokens(req.user._id, res);
|
||||
|
|
56
api/server/controllers/auth/TwoFactorAuthController.js
Normal file
56
api/server/controllers/auth/TwoFactorAuthController.js
Normal file
|
@ -0,0 +1,56 @@
|
|||
const jwt = require('jsonwebtoken');
|
||||
const { verifyTOTP, verifyBackupCode } = require('~/server/services/twoFactorService');
|
||||
const { setAuthTokens } = require('~/server/services/AuthService');
|
||||
const { getUserById } = require('~/models/userMethods');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const verify2FA = async (req, res) => {
|
||||
try {
|
||||
const { tempToken, token, backupCode } = req.body;
|
||||
if (!tempToken) {
|
||||
return res.status(400).json({ message: 'Missing temporary token' });
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(tempToken, process.env.JWT_SECRET);
|
||||
} catch (err) {
|
||||
return res.status(401).json({ message: 'Invalid or expired temporary token' });
|
||||
}
|
||||
|
||||
const user = await getUserById(payload.userId);
|
||||
// Ensure that the user exists and has backup codes (i.e. 2FA enabled)
|
||||
if (!user || !(user.backupCodes && user.backupCodes.length > 0)) {
|
||||
return res.status(400).json({ message: '2FA is not enabled for this user' });
|
||||
}
|
||||
|
||||
let verified = false;
|
||||
|
||||
if (token && (await verifyTOTP(user.totpSecret, token))) {
|
||||
verified = true;
|
||||
} else if (backupCode) {
|
||||
verified = await verifyBackupCode({ user, backupCode });
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
return res.status(401).json({ message: 'Invalid 2FA code or backup code' });
|
||||
}
|
||||
|
||||
// Prepare user data for response.
|
||||
// If the user is a plain object (from lean queries), we create a shallow copy.
|
||||
const userData = user.toObject ? user.toObject() : { ...user };
|
||||
// Remove sensitive fields
|
||||
delete userData.password;
|
||||
delete userData.__v;
|
||||
delete userData.totpSecret;
|
||||
userData.id = user._id.toString();
|
||||
|
||||
const authToken = await setAuthTokens(user._id, res);
|
||||
return res.status(200).json({ token: authToken, user: userData });
|
||||
} catch (err) {
|
||||
logger.error('[verify2FA]', err);
|
||||
return res.status(500).json({ message: 'Something went wrong' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { verify2FA };
|
|
@ -7,6 +7,13 @@ const {
|
|||
} = require('~/server/controllers/AuthController');
|
||||
const { loginController } = require('~/server/controllers/auth/LoginController');
|
||||
const { logoutController } = require('~/server/controllers/auth/LogoutController');
|
||||
const { verify2FA } = require('~/server/controllers/auth/TwoFactorAuthController');
|
||||
const {
|
||||
enable2FAController,
|
||||
verify2FAController,
|
||||
disable2FAController,
|
||||
regenerateBackupCodesController, confirm2FAController,
|
||||
} = require('~/server/controllers/TwoFactorController');
|
||||
const {
|
||||
checkBan,
|
||||
loginLimiter,
|
||||
|
@ -50,4 +57,11 @@ router.post(
|
|||
);
|
||||
router.post('/resetPassword', checkBan, validatePasswordReset, resetPasswordController);
|
||||
|
||||
router.get('/2fa/enable', requireJwtAuth, enable2FAController);
|
||||
router.post('/2fa/verify', requireJwtAuth, verify2FAController);
|
||||
router.post('/2fa/verify-temp', checkBan, verify2FA);
|
||||
router.post('/2fa/confirm', requireJwtAuth, confirm2FAController);
|
||||
router.post('/2fa/disable', requireJwtAuth, disable2FAController);
|
||||
router.post('/2fa/backup/regenerate', requireJwtAuth, regenerateBackupCodesController);
|
||||
|
||||
module.exports = router;
|
||||
|
|
205
api/server/services/twoFactorService.js
Normal file
205
api/server/services/twoFactorService.js
Normal file
|
@ -0,0 +1,205 @@
|
|||
const { sign } = require('jsonwebtoken');
|
||||
const { webcrypto } = require('node:crypto');
|
||||
const { hashBackupCode } = require('~/server/utils/crypto');
|
||||
const { updateUser } = require('~/models/userMethods');
|
||||
|
||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
/**
|
||||
* Encodes a Buffer into a Base32 string using RFC 4648 alphabet.
|
||||
* @param {Buffer} buffer - The buffer to encode.
|
||||
* @returns {string} - The Base32 encoded string.
|
||||
*/
|
||||
const encodeBase32 = (buffer) => {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let output = '';
|
||||
for (const byte of buffer) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) {
|
||||
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes a Base32-encoded string back into a Buffer.
|
||||
* @param {string} base32Str
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
const decodeBase32 = (base32Str) => {
|
||||
const cleaned = base32Str.replace(/=+$/, '').toUpperCase();
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const output = [];
|
||||
for (const char of cleaned) {
|
||||
const idx = BASE32_ALPHABET.indexOf(char);
|
||||
if (idx === -1) {
|
||||
continue;
|
||||
}
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
output.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(output);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a temporary token for 2FA verification.
|
||||
* This token is signed with JWT_SECRET and expires in 5 minutes.
|
||||
*/
|
||||
const generate2FATempToken = (userId) =>
|
||||
sign({ userId, twoFAPending: true }, process.env.JWT_SECRET, { expiresIn: '5m' });
|
||||
|
||||
/**
|
||||
* Generate a TOTP secret.
|
||||
* Generates 10 random bytes using WebCrypto and encodes them into a Base32 string.
|
||||
*/
|
||||
const generateTOTPSecret = () => {
|
||||
const randomArray = new Uint8Array(10);
|
||||
webcrypto.getRandomValues(randomArray);
|
||||
return encodeBase32(Buffer.from(randomArray));
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a TOTP code based on the secret and current time.
|
||||
* Uses a 30-second time step and generates a 6-digit code.
|
||||
*
|
||||
* @param {string} secret - Base32-encoded secret
|
||||
* @param {number} [forTime=Date.now()] - Time in milliseconds
|
||||
* @returns {Promise<string>} - The 6-digit TOTP code.
|
||||
*/
|
||||
const generateTOTP = async (secret, forTime = Date.now()) => {
|
||||
const timeStep = 30; // seconds
|
||||
const counter = Math.floor(forTime / 1000 / timeStep);
|
||||
const counterBuffer = new ArrayBuffer(8);
|
||||
const counterView = new DataView(counterBuffer);
|
||||
// Write counter into the last 4 bytes (big-endian)
|
||||
counterView.setUint32(4, counter, false);
|
||||
|
||||
// Decode the secret into an ArrayBuffer
|
||||
const keyBuffer = decodeBase32(secret);
|
||||
const keyArrayBuffer = keyBuffer.buffer.slice(
|
||||
keyBuffer.byteOffset,
|
||||
keyBuffer.byteOffset + keyBuffer.byteLength,
|
||||
);
|
||||
|
||||
// Import the key for HMAC-SHA1 signing
|
||||
const cryptoKey = await webcrypto.subtle.importKey(
|
||||
'raw',
|
||||
keyArrayBuffer,
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
|
||||
// Generate HMAC signature
|
||||
const signatureBuffer = await webcrypto.subtle.sign('HMAC', cryptoKey, counterBuffer);
|
||||
const hmac = new Uint8Array(signatureBuffer);
|
||||
|
||||
const offset = hmac[hmac.length - 1] & 0xf;
|
||||
const slice = hmac.slice(offset, offset + 4);
|
||||
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength);
|
||||
const binaryCode = view.getUint32(0, false) & 0x7fffffff;
|
||||
const code = (binaryCode % 1000000).toString().padStart(6, '0');
|
||||
return code;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a provided TOTP token against the secret.
|
||||
* Allows for a ±1 time-step window.
|
||||
*
|
||||
* @param {string} secret
|
||||
* @param {string} token
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const verifyTOTP = async (secret, token) => {
|
||||
const timeStepMS = 30 * 1000;
|
||||
const currentTime = Date.now();
|
||||
for (let offset = -1; offset <= 1; offset++) {
|
||||
const expected = await generateTOTP(secret, currentTime + offset * timeStepMS);
|
||||
if (expected === token) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate backup codes.
|
||||
* Generates `count` backup code objects and returns an object with both plain codes
|
||||
* (for one-time download) and their objects (for secure storage). Uses WebCrypto for randomness and hashing.
|
||||
*
|
||||
* @param {number} count - Number of backup codes to generate (default: 10).
|
||||
* @returns {Promise<Object>} - Contains `plainCodes` (array of strings) and `codeObjects` (array of objects).
|
||||
*/
|
||||
const generateBackupCodes = async (count = 10) => {
|
||||
const plainCodes = [];
|
||||
const codeObjects = [];
|
||||
const encoder = new TextEncoder();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const randomArray = new Uint8Array(4);
|
||||
webcrypto.getRandomValues(randomArray);
|
||||
const code = Array.from(randomArray)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join(''); // 8-character hex code
|
||||
plainCodes.push(code);
|
||||
|
||||
// Compute SHA-256 hash of the code using WebCrypto
|
||||
const codeBuffer = encoder.encode(code);
|
||||
const hashBuffer = await webcrypto.subtle.digest('SHA-256', codeBuffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const codeHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
codeObjects.push({ codeHash, used: false, usedAt: null });
|
||||
}
|
||||
return { plainCodes, codeObjects };
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies a backup code and updates the user's backup codes if valid
|
||||
* @param {Object} params
|
||||
* @param {TUser | undefined} [params.user] - The user object
|
||||
* @param {string | undefined} [params.backupCode] - The backup code to verify
|
||||
* @returns {Promise<boolean>} - Whether the backup code was valid
|
||||
*/
|
||||
const verifyBackupCode = async ({ user, backupCode }) => {
|
||||
if (!backupCode || !user || !Array.isArray(user.backupCodes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hashedInput = await hashBackupCode(backupCode.trim());
|
||||
const matchingCode = user.backupCodes.find(
|
||||
(codeObj) => codeObj.codeHash === hashedInput && !codeObj.used,
|
||||
);
|
||||
|
||||
if (matchingCode) {
|
||||
const updatedBackupCodes = user.backupCodes.map((codeObj) =>
|
||||
codeObj.codeHash === hashedInput && !codeObj.used
|
||||
? { ...codeObj, used: true, usedAt: new Date() }
|
||||
: codeObj,
|
||||
);
|
||||
|
||||
await updateUser(user._id, { backupCodes: updatedBackupCodes });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
verifyTOTP,
|
||||
generateTOTP,
|
||||
verifyBackupCode,
|
||||
generateTOTPSecret,
|
||||
generateBackupCodes,
|
||||
generate2FATempToken,
|
||||
};
|
|
@ -112,4 +112,25 @@ async function getRandomValues(length) {
|
|||
return Buffer.from(randomValues).toString('hex');
|
||||
}
|
||||
|
||||
module.exports = { encrypt, decrypt, encryptV2, decryptV2, hashToken, getRandomValues };
|
||||
/**
|
||||
* Computes SHA-256 hash for the given input using WebCrypto
|
||||
* @param {string} input
|
||||
* @returns {Promise<string>} - Hex hash string
|
||||
*/
|
||||
const hashBackupCode = async (input) => {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(input);
|
||||
const hashBuffer = await webcrypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
encrypt,
|
||||
decrypt,
|
||||
encryptV2,
|
||||
decryptV2,
|
||||
hashToken,
|
||||
hashBackupCode,
|
||||
getRandomValues,
|
||||
};
|
||||
|
|
|
@ -12,7 +12,7 @@ const jwtLogin = async () =>
|
|||
},
|
||||
async (payload, done) => {
|
||||
try {
|
||||
const user = await getUserById(payload?.id, '-password -__v');
|
||||
const user = await getUserById(payload?.id, '-password -__v -totpSecret');
|
||||
if (user) {
|
||||
user.id = user._id.toString();
|
||||
if (!user.role) {
|
||||
|
|
|
@ -44,6 +44,7 @@
|
|||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.0",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
|
@ -66,6 +67,7 @@
|
|||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.3",
|
||||
"js-cookie": "^3.0.5",
|
||||
"input-otp": "^1.4.2",
|
||||
"librechat-data-provider": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.394.0",
|
||||
|
@ -142,4 +144,4 @@
|
|||
"vite-plugin-node-polyfills": "^0.17.0",
|
||||
"vite-plugin-pwa": "^0.21.1"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -85,7 +85,8 @@ function AuthLayout({
|
|||
</h1>
|
||||
)}
|
||||
{children}
|
||||
{(pathname.includes('login') || pathname.includes('register')) && (
|
||||
{!pathname.includes('2fa') &&
|
||||
(pathname.includes('login') || pathname.includes('register')) && (
|
||||
<SocialLoginRender startupConfig={startupConfig} />
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -166,9 +166,7 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
|||
type="submit"
|
||||
className="
|
||||
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
|
||||
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
|
||||
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
|
||||
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
|
||||
transition-colors hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700
|
||||
"
|
||||
>
|
||||
{localize('com_auth_continue')}
|
||||
|
|
176
client/src/components/Auth/TwoFactorScreen.tsx
Normal file
176
client/src/components/Auth/TwoFactorScreen.tsx
Normal file
|
@ -0,0 +1,176 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp';
|
||||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label } from '~/components';
|
||||
import { useVerifyTwoFactorTempMutation } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface VerifyPayload {
|
||||
tempToken: string;
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
}
|
||||
|
||||
type TwoFactorFormInputs = {
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
};
|
||||
|
||||
const TwoFactorScreen: React.FC = React.memo(() => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const tempTokenRaw = searchParams.get('tempToken');
|
||||
const tempToken = tempTokenRaw !== null && tempTokenRaw !== '' ? tempTokenRaw : '';
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<TwoFactorFormInputs>();
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const [useBackup, setUseBackup] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const { mutate: verifyTempMutate } = useVerifyTwoFactorTempMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.token != null && result.token !== '') {
|
||||
window.location.href = '/';
|
||||
}
|
||||
},
|
||||
onMutate: () => {
|
||||
setIsLoading(true);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
setIsLoading(false);
|
||||
const err = error as { response?: { data?: { message?: unknown } } };
|
||||
const errorMsg =
|
||||
typeof err.response?.data?.message === 'string'
|
||||
? err.response.data.message
|
||||
: 'Error verifying 2FA';
|
||||
showToast({ message: errorMsg, status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: TwoFactorFormInputs) => {
|
||||
const payload: VerifyPayload = { tempToken };
|
||||
if (useBackup && data.backupCode != null && data.backupCode !== '') {
|
||||
payload.backupCode = data.backupCode;
|
||||
} else if (data.token != null && data.token !== '') {
|
||||
payload.token = data.token;
|
||||
}
|
||||
verifyTempMutate(payload);
|
||||
},
|
||||
[tempToken, useBackup, verifyTempMutate],
|
||||
);
|
||||
|
||||
const toggleBackupOn = useCallback(() => {
|
||||
setUseBackup(true);
|
||||
}, []);
|
||||
|
||||
const toggleBackupOff = useCallback(() => {
|
||||
setUseBackup(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Label className="flex justify-center break-keep text-center text-sm text-text-primary">
|
||||
{localize('com_auth_two_factor')}
|
||||
</Label>
|
||||
{!useBackup && (
|
||||
<div className="my-4 flex justify-center text-text-primary">
|
||||
<Controller
|
||||
name="token"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={value != null ? value : ''}
|
||||
onChange={onChange}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
)}
|
||||
/>
|
||||
{errors.token && <span className="text-sm text-red-500">{errors.token.message}</span>}
|
||||
</div>
|
||||
)}
|
||||
{useBackup && (
|
||||
<div className="my-4 flex justify-center text-text-primary">
|
||||
<Controller
|
||||
name="backupCode"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<InputOTP
|
||||
maxLength={8}
|
||||
value={value != null ? value : ''}
|
||||
onChange={onChange}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
<InputOTPSlot index={6} />
|
||||
<InputOTPSlot index={7} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
)}
|
||||
/>
|
||||
{errors.backupCode && (
|
||||
<span className="text-sm text-red-500">{errors.backupCode.message}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={localize('com_auth_continue')}
|
||||
data-testid="login-button"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-80 dark:bg-green-600 dark:hover:bg-green-700"
|
||||
>
|
||||
{isLoading ? localize('com_auth_email_verifying_ellipsis') : localize('com_ui_verify')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center">
|
||||
{!useBackup ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleBackupOn}
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
{localize('com_ui_use_backup_code')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleBackupOff}
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
{localize('com_ui_use_2fa_code')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TwoFactorScreen;
|
|
@ -4,3 +4,4 @@ export { default as ResetPassword } from './ResetPassword';
|
|||
export { default as VerifyEmail } from './VerifyEmail';
|
||||
export { default as ApiErrorWatcher } from './ApiErrorWatcher';
|
||||
export { default as RequestPasswordReset } from './RequestPasswordReset';
|
||||
export { default as TwoFactorScreen } from './TwoFactorScreen';
|
||||
|
|
|
@ -55,7 +55,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
|||
|
||||
let statusText: string;
|
||||
if (!status) {
|
||||
statusText = text ?? localize('com_endpoint_import');
|
||||
statusText = text ?? localize('com_ui_import');
|
||||
} else if (status === 'success') {
|
||||
statusText = successText ?? localize('com_ui_upload_success');
|
||||
} else {
|
||||
|
@ -72,12 +72,12 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
|||
)}
|
||||
>
|
||||
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" />
|
||||
<span className="flex text-xs ">{statusText}</span>
|
||||
<span className="flex text-xs">{statusText}</span>
|
||||
<input
|
||||
id={`file-upload-${id}`}
|
||||
value=""
|
||||
type="file"
|
||||
className={cn('hidden ', className)}
|
||||
className={cn('hidden', className)}
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
|
|
@ -2,19 +2,36 @@ import React from 'react';
|
|||
import DisplayUsernameMessages from './DisplayUsernameMessages';
|
||||
import DeleteAccount from './DeleteAccount';
|
||||
import Avatar from './Avatar';
|
||||
import EnableTwoFactorItem from './TwoFactorAuthentication';
|
||||
import BackupCodesItem from './BackupCodesItem';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
|
||||
function Account() {
|
||||
const user = useAuthContext();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
<div className="pb-3">
|
||||
<DisplayUsernameMessages />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<Avatar />
|
||||
</div>
|
||||
{user?.user?.provider === 'local' && (
|
||||
<>
|
||||
<div className="pb-3">
|
||||
<EnableTwoFactorItem />
|
||||
</div>
|
||||
{Array.isArray(user.user?.backupCodes) && user.user?.backupCodes.length > 0 && (
|
||||
<div className="pb-3">
|
||||
<BackupCodesItem />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="pb-3">
|
||||
<DeleteAccount />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<DisplayUsernameMessages />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -47,7 +47,7 @@ function Avatar() {
|
|||
const { mutate: uploadAvatar, isLoading: isUploading } = useUploadAvatarMutation({
|
||||
onSuccess: (data) => {
|
||||
showToast({ message: localize('com_ui_upload_success') });
|
||||
setUser((prev) => ({ ...prev, avatar: data.url } as TUser));
|
||||
setUser((prev) => ({ ...prev, avatar: data.url }) as TUser);
|
||||
openButtonRef.current?.click();
|
||||
},
|
||||
onError: (error) => {
|
||||
|
@ -133,9 +133,11 @@ function Avatar() {
|
|||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{localize('com_nav_profile_picture')}</span>
|
||||
<OGDialogTrigger ref={openButtonRef} className="btn btn-neutral relative">
|
||||
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" />
|
||||
<span>{localize('com_nav_change_picture')}</span>
|
||||
<OGDialogTrigger ref={openButtonRef}>
|
||||
<Button variant="outline">
|
||||
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" />
|
||||
<span>{localize('com_nav_change_picture')}</span>
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -0,0 +1,194 @@
|
|||
import React, { useState } from 'react';
|
||||
import { RefreshCcw, ShieldX } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { TBackupCode, TRegenerateBackupCodesResponse, type TUser } from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogContent,
|
||||
OGDialogTitle,
|
||||
OGDialogTrigger,
|
||||
Button,
|
||||
Label,
|
||||
Spinner,
|
||||
TooltipAnchor,
|
||||
} from '~/components';
|
||||
import { useRegenerateBackupCodesMutation } from '~/data-provider';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import store from '~/store';
|
||||
|
||||
const BackupCodesItem: React.FC = () => {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const { showToast } = useToastContext();
|
||||
const setUser = useSetRecoilState(store.user);
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
|
||||
const { mutate: regenerateBackupCodes, isLoading } = useRegenerateBackupCodesMutation();
|
||||
|
||||
const fetchBackupCodes = (auto: boolean = false) => {
|
||||
regenerateBackupCodes(undefined, {
|
||||
onSuccess: (data: TRegenerateBackupCodesResponse) => {
|
||||
const newBackupCodes: TBackupCode[] = data.backupCodesHash.map((codeHash) => ({
|
||||
codeHash,
|
||||
used: false,
|
||||
usedAt: null,
|
||||
}));
|
||||
|
||||
setUser((prev) => ({ ...prev, backupCodes: newBackupCodes }) as TUser);
|
||||
showToast({
|
||||
message: localize('com_ui_backup_codes_regenerated'),
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
// Trigger file download only when user explicitly clicks the button.
|
||||
if (!auto && newBackupCodes.length) {
|
||||
const codesString = data.backupCodes.join('\n');
|
||||
const blob = new Blob([codesString], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'backup-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
},
|
||||
onError: () =>
|
||||
showToast({
|
||||
message: localize('com_ui_backup_codes_regenerate_error'),
|
||||
status: 'error',
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
fetchBackupCodes(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Label className="font-light">{localize('com_ui_backup_codes')}</Label>
|
||||
</div>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button aria-label="Show Backup Codes" variant="outline">
|
||||
{localize('com_ui_show')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
</div>
|
||||
|
||||
<OGDialogContent className="w-11/12 max-w-lg">
|
||||
<OGDialogTitle className="mb-6 text-2xl font-semibold">
|
||||
{localize('com_ui_backup_codes')}
|
||||
</OGDialogTitle>
|
||||
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mt-4"
|
||||
>
|
||||
{Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{user?.backupCodes.map((code, index) => {
|
||||
const isUsed = code.used;
|
||||
const description = `Backup code number ${index + 1}, ${
|
||||
isUsed
|
||||
? `used on ${code.usedAt ? new Date(code.usedAt).toLocaleDateString() : 'an unknown date'}`
|
||||
: 'not used yet'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={code.codeHash}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
aria-label={description}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
onFocus={() => {
|
||||
const announcement = new CustomEvent('announce', {
|
||||
detail: { message: description },
|
||||
});
|
||||
document.dispatchEvent(announcement);
|
||||
}}
|
||||
className={`flex flex-col rounded-xl border p-4 backdrop-blur-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
|
||||
isUsed
|
||||
? 'border-red-200 bg-red-50/80 dark:border-red-800 dark:bg-red-900/20'
|
||||
: 'border-green-200 bg-green-50/80 dark:border-green-800 dark:bg-green-900/20'
|
||||
} `}
|
||||
>
|
||||
<div className="flex items-center justify-between" aria-hidden="true">
|
||||
<span className="text-sm font-medium text-text-secondary">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<TooltipAnchor
|
||||
description={
|
||||
code.usedAt ? new Date(code.usedAt).toLocaleDateString() : ''
|
||||
}
|
||||
disabled={!isUsed}
|
||||
focusable={false}
|
||||
className={isUsed ? 'cursor-pointer' : 'cursor-default'}
|
||||
render={
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-sm font-medium ${
|
||||
isUsed
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||
}`}
|
||||
>
|
||||
{isUsed ? localize('com_ui_used') : localize('com_ui_not_used')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<Button
|
||||
onClick={handleRegenerate}
|
||||
disabled={isLoading}
|
||||
variant="default"
|
||||
className="px-8 py-3 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner className="mr-2" />
|
||||
) : (
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isLoading
|
||||
? localize('com_ui_regenerating')
|
||||
: localize('com_ui_regenerate_backup')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<ShieldX className="h-12 w-12 text-text-primary" />
|
||||
<p className="text-lg text-text-secondary">{localize('com_ui_no_backup_codes')}</p>
|
||||
<Button
|
||||
onClick={handleRegenerate}
|
||||
disabled={isLoading}
|
||||
variant="default"
|
||||
className="px-8 py-3 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isLoading && <Spinner className="mr-2" />}
|
||||
{localize('com_ui_generate_backup')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(BackupCodesItem);
|
|
@ -57,7 +57,7 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
|
|||
</Button>
|
||||
</OGDialogTrigger>
|
||||
</div>
|
||||
<OGDialogContent className="w-11/12 max-w-2xl">
|
||||
<OGDialogContent className="w-11/12 max-w-md">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle className="text-lg font-medium leading-6">
|
||||
{localize('com_nav_delete_account_confirm')}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { LockIcon, UnlockIcon } from 'lucide-react';
|
||||
import { Label, Button } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface DisableTwoFactorToggleProps {
|
||||
enabled: boolean;
|
||||
onChange: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DisableTwoFactorToggle: React.FC<DisableTwoFactorToggleProps> = ({
|
||||
enabled,
|
||||
onChange,
|
||||
disabled,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label className="font-light"> {localize('com_nav_2fa')}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={enabled ? 'destructive' : 'outline'}
|
||||
onClick={onChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
{enabled ? localize('com_ui_2fa_disable') : localize('com_ui_2fa_enable')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,298 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { SmartphoneIcon } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { TUser, TVerify2FARequest } from 'librechat-data-provider';
|
||||
import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle, Progress } from '~/components';
|
||||
import { SetupPhase, QRPhase, VerifyPhase, BackupPhase, DisablePhase } from './TwoFactorPhases';
|
||||
import { DisableTwoFactorToggle } from './DisableTwoFactorToggle';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import store from '~/store';
|
||||
import {
|
||||
useConfirmTwoFactorMutation,
|
||||
useDisableTwoFactorMutation,
|
||||
useEnableTwoFactorMutation,
|
||||
useVerifyTwoFactorMutation,
|
||||
} from '~/data-provider';
|
||||
|
||||
export type Phase = 'setup' | 'qr' | 'verify' | 'backup' | 'disable';
|
||||
|
||||
const phaseVariants = {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
animate: { opacity: 1, scale: 1, transition: { duration: 0.3, ease: 'easeOut' } },
|
||||
exit: { opacity: 0, scale: 0.95, transition: { duration: 0.3, ease: 'easeIn' } },
|
||||
};
|
||||
|
||||
const TwoFactorAuthentication: React.FC = () => {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const setUser = useSetRecoilState(store.user);
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const [secret, setSecret] = useState<string>('');
|
||||
const [otpauthUrl, setOtpauthUrl] = useState<string>('');
|
||||
const [downloaded, setDownloaded] = useState<boolean>(false);
|
||||
const [disableToken, setDisableToken] = useState<string>('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [verificationToken, setVerificationToken] = useState<string>('');
|
||||
const [phase, setPhase] = useState<Phase>(Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? 'disable' : 'setup');
|
||||
|
||||
const { mutate: confirm2FAMutate } = useConfirmTwoFactorMutation();
|
||||
const { mutate: enable2FAMutate, isLoading: isGenerating } = useEnableTwoFactorMutation();
|
||||
const { mutate: verify2FAMutate, isLoading: isVerifying } = useVerifyTwoFactorMutation();
|
||||
const { mutate: disable2FAMutate, isLoading: isDisabling } = useDisableTwoFactorMutation();
|
||||
|
||||
const steps = ['Setup', 'Scan QR', 'Verify', 'Backup'];
|
||||
const phasesLabel: Record<Phase, string> = {
|
||||
setup: 'Setup',
|
||||
qr: 'Scan QR',
|
||||
verify: 'Verify',
|
||||
backup: 'Backup',
|
||||
disable: '',
|
||||
};
|
||||
|
||||
const currentStep = steps.indexOf(phasesLabel[phase]);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
if (Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 && otpauthUrl) {
|
||||
disable2FAMutate(undefined, {
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
|
||||
});
|
||||
}
|
||||
|
||||
setOtpauthUrl('');
|
||||
setSecret('');
|
||||
setBackupCodes([]);
|
||||
setVerificationToken('');
|
||||
setDisableToken('');
|
||||
setPhase(Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? 'disable' : 'setup');
|
||||
setDownloaded(false);
|
||||
}, [user, otpauthUrl, disable2FAMutate, localize, showToast]);
|
||||
|
||||
const handleGenerateQRCode = useCallback(() => {
|
||||
enable2FAMutate(undefined, {
|
||||
onSuccess: ({ otpauthUrl, backupCodes }) => {
|
||||
setOtpauthUrl(otpauthUrl);
|
||||
setSecret(otpauthUrl.split('secret=')[1].split('&')[0]);
|
||||
setBackupCodes(backupCodes);
|
||||
setPhase('qr');
|
||||
},
|
||||
onError: () => showToast({ message: localize('com_ui_2fa_generate_error'), status: 'error' }),
|
||||
});
|
||||
}, [enable2FAMutate, localize, showToast]);
|
||||
|
||||
const handleVerify = useCallback(() => {
|
||||
if (!verificationToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
verify2FAMutate(
|
||||
{ token: verificationToken },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showToast({ message: localize('com_ui_2fa_verified') });
|
||||
confirm2FAMutate(
|
||||
{ token: verificationToken },
|
||||
{
|
||||
onSuccess: () => setPhase('backup'),
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
|
||||
},
|
||||
);
|
||||
}, [verificationToken, verify2FAMutate, confirm2FAMutate, localize, showToast]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!backupCodes.length) {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([backupCodes.join('\n')], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'backup-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setDownloaded(true);
|
||||
}, [backupCodes]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
setPhase('disable');
|
||||
showToast({ message: localize('com_ui_2fa_enabled') });
|
||||
setUser(
|
||||
(prev) =>
|
||||
({
|
||||
...prev,
|
||||
backupCodes: backupCodes.map((code) => ({
|
||||
code,
|
||||
codeHash: code,
|
||||
used: false,
|
||||
usedAt: null,
|
||||
})),
|
||||
}) as TUser,
|
||||
);
|
||||
}, [setUser, localize, showToast, backupCodes]);
|
||||
|
||||
const handleDisableVerify = useCallback(
|
||||
(token: string, useBackup: boolean) => {
|
||||
// Validate: if not using backup, ensure token has at least 6 digits;
|
||||
// if using backup, ensure backup code has at least 8 characters.
|
||||
if (!useBackup && token.trim().length < 6) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (useBackup && token.trim().length < 8) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: TVerify2FARequest = {};
|
||||
if (useBackup) {
|
||||
payload.backupCode = token.trim();
|
||||
} else {
|
||||
payload.token = token.trim();
|
||||
}
|
||||
|
||||
verify2FAMutate(payload, {
|
||||
onSuccess: () => {
|
||||
disable2FAMutate(undefined, {
|
||||
onSuccess: () => {
|
||||
showToast({ message: localize('com_ui_2fa_disabled') });
|
||||
setDialogOpen(false);
|
||||
setUser(
|
||||
(prev) =>
|
||||
({
|
||||
...prev,
|
||||
totpSecret: '',
|
||||
backupCodes: [],
|
||||
}) as TUser,
|
||||
);
|
||||
setPhase('setup');
|
||||
setOtpauthUrl('');
|
||||
},
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
|
||||
});
|
||||
},
|
||||
onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
|
||||
});
|
||||
},
|
||||
[disableToken, verify2FAMutate, disable2FAMutate, showToast, localize, setUser],
|
||||
);
|
||||
|
||||
return (
|
||||
<OGDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
resetState();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DisableTwoFactorToggle
|
||||
enabled={Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0}
|
||||
onChange={() => setDialogOpen(true)}
|
||||
disabled={isVerifying || isDisabling || isGenerating}
|
||||
/>
|
||||
|
||||
<OGDialogContent className="w-11/12 max-w-lg p-6">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={phase}
|
||||
variants={phaseVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="space-y-6"
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle className="mb-2 flex items-center gap-3 text-2xl font-bold">
|
||||
<SmartphoneIcon className="h-6 w-6 text-primary" />
|
||||
{Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? localize('com_ui_2fa_disable') : localize('com_ui_2fa_setup')}
|
||||
</OGDialogTitle>
|
||||
{Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 && phase !== 'disable' && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<Progress
|
||||
value={(steps.indexOf(phasesLabel[phase]) / (steps.length - 1)) * 100}
|
||||
className="h-2 rounded-full"
|
||||
/>
|
||||
<div className="flex justify-between text-sm">
|
||||
{steps.map((step, index) => (
|
||||
<motion.span
|
||||
key={step}
|
||||
animate={{
|
||||
color:
|
||||
currentStep >= index ? 'var(--text-primary)' : 'var(--text-tertiary)',
|
||||
}}
|
||||
className="font-medium"
|
||||
>
|
||||
{step}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OGDialogHeader>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{phase === 'setup' && (
|
||||
<SetupPhase
|
||||
isGenerating={isGenerating}
|
||||
onGenerate={handleGenerateQRCode}
|
||||
onNext={() => setPhase('qr')}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'qr' && (
|
||||
<QRPhase
|
||||
secret={secret}
|
||||
otpauthUrl={otpauthUrl}
|
||||
onNext={() => setPhase('verify')}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'verify' && (
|
||||
<VerifyPhase
|
||||
token={verificationToken}
|
||||
onTokenChange={setVerificationToken}
|
||||
isVerifying={isVerifying}
|
||||
onNext={handleVerify}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'backup' && (
|
||||
<BackupPhase
|
||||
backupCodes={backupCodes}
|
||||
onDownload={handleDownload}
|
||||
downloaded={downloaded}
|
||||
onNext={handleConfirm}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'disable' && (
|
||||
<DisablePhase
|
||||
onDisable={handleDisableVerify}
|
||||
isDisabling={isDisabling}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TwoFactorAuthentication);
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Button, Label } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface BackupPhaseProps {
|
||||
onNext: () => void;
|
||||
onError: (error: Error) => void;
|
||||
backupCodes: string[];
|
||||
onDownload: () => void;
|
||||
downloaded: boolean;
|
||||
}
|
||||
|
||||
export const BackupPhase: React.FC<BackupPhaseProps> = ({
|
||||
backupCodes,
|
||||
onDownload,
|
||||
downloaded,
|
||||
onNext,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-6">
|
||||
<Label className="break-keep text-sm">{localize('com_ui_download_backup_tooltip')}</Label>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-xl bg-surface-secondary p-6">
|
||||
{backupCodes.map((code, index) => (
|
||||
<motion.div
|
||||
key={code}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="rounded-lg bg-surface-tertiary p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="hidden text-sm text-text-secondary sm:inline">#{index + 1}</span>
|
||||
<span className="font-mono text-lg">{code}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="outline" onClick={onDownload} className="flex-1 gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{localize('com_ui_download_backup')}</span>
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!downloaded} className="flex-1">
|
||||
{localize('com_ui_complete_setup')}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,88 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp';
|
||||
import {
|
||||
Button,
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
Spinner,
|
||||
} from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface DisablePhaseProps {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
onDisable: (token: string, useBackup: boolean) => void;
|
||||
isDisabling: boolean;
|
||||
}
|
||||
|
||||
export const DisablePhase: React.FC<DisablePhaseProps> = ({ onDisable, isDisabling }) => {
|
||||
const localize = useLocalize();
|
||||
const [token, setToken] = useState('');
|
||||
const [useBackup, setUseBackup] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-8">
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
value={token}
|
||||
onChange={setToken}
|
||||
maxLength={useBackup ? 8 : 6}
|
||||
pattern={useBackup ? REGEXP_ONLY_DIGITS_AND_CHARS : REGEXP_ONLY_DIGITS}
|
||||
className="gap-2"
|
||||
>
|
||||
{useBackup ? (
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
<InputOTPSlot index={6} />
|
||||
<InputOTPSlot index={7} />
|
||||
</InputOTPGroup>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => onDisable(token, useBackup)}
|
||||
disabled={isDisabling || token.length !== (useBackup ? 8 : 6)}
|
||||
className="w-full rounded-xl px-6 py-3 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isDisabling === true && <Spinner className="mr-2" />}
|
||||
{isDisabling ? localize('com_ui_disabling') : localize('com_ui_2fa_disable')}
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setUseBackup(!useBackup)}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{useBackup ? localize('com_ui_use_2fa_code') : localize('com_ui_use_backup_code')}
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,66 @@
|
|||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { Input, Button, Label } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface QRPhaseProps {
|
||||
secret: string;
|
||||
otpauthUrl: string;
|
||||
onNext: () => void;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export const QRPhase: React.FC<QRPhaseProps> = ({ secret, otpauthUrl, onNext }) => {
|
||||
const localize = useLocalize();
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(secret);
|
||||
setIsCopying(true);
|
||||
setTimeout(() => setIsCopying(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-6">
|
||||
<div className="flex flex-col items-center space-y-6">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="rounded-2xl bg-white p-4 shadow-lg"
|
||||
>
|
||||
<QRCodeSVG value={otpauthUrl} size={240} />
|
||||
</motion.div>
|
||||
<div className="w-full space-y-3">
|
||||
<Label className="text-sm font-medium text-text-secondary">
|
||||
{localize('com_ui_secret_key')}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={secret} readOnly className="font-mono text-lg tracking-wider" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
className={cn('h-auto shrink-0', isCopying ? 'cursor-default' : '')}
|
||||
>
|
||||
{isCopying ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={onNext} className="w-full">
|
||||
{localize('com_ui_continue')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
import React from 'react';
|
||||
import { QrCode } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button, Spinner } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface SetupPhaseProps {
|
||||
onNext: () => void;
|
||||
onError: (error: Error) => void;
|
||||
isGenerating: boolean;
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
export const SetupPhase: React.FC<SetupPhaseProps> = ({ isGenerating, onGenerate, onNext }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-6">
|
||||
<div className="rounded-xl bg-surface-secondary p-6">
|
||||
<h3 className="mb-4 flex justify-center text-lg font-medium">
|
||||
{localize('com_ui_2fa_account_security')}
|
||||
</h3>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onGenerate}
|
||||
className="flex w-full"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? <Spinner className="size-5" /> : <QrCode className="size-5" />}
|
||||
{isGenerating ? localize('com_ui_generating') : localize('com_ui_generate_qrcode')}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '~/components';
|
||||
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface VerifyPhaseProps {
|
||||
token: string;
|
||||
onTokenChange: (value: string) => void;
|
||||
isVerifying: boolean;
|
||||
onNext: () => void;
|
||||
onError: (error: Error) => void;
|
||||
}
|
||||
|
||||
export const VerifyPhase: React.FC<VerifyPhaseProps> = ({
|
||||
token,
|
||||
onTokenChange,
|
||||
isVerifying,
|
||||
onNext,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-8">
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
value={token}
|
||||
onChange={onTokenChange}
|
||||
maxLength={6}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
className="gap-2"
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<InputOTPSlot key={i} index={i} />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<InputOTPSlot key={i + 3} index={i + 3} />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
<Button onClick={onNext} disabled={isVerifying || token.length !== 6} className="w-full">
|
||||
{localize('com_ui_verify')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
export * from './BackupPhase';
|
||||
export * from './QRPhase';
|
||||
export * from './VerifyPhase';
|
||||
export * from './SetupPhase';
|
||||
export * from './DisablePhase';
|
|
@ -82,7 +82,7 @@ function ImportConversations() {
|
|||
onClick={handleImportClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!allowImport}
|
||||
aria-label={localize('com_ui_import_conversation')}
|
||||
aria-label={localize('com_ui_import')}
|
||||
className="btn btn-neutral relative"
|
||||
>
|
||||
{allowImport ? (
|
||||
|
@ -90,7 +90,7 @@ function ImportConversations() {
|
|||
) : (
|
||||
<Spinner className="mr-1 w-4" />
|
||||
)}
|
||||
<span>{localize('com_ui_import_conversation')}</span>
|
||||
<span>{localize('com_ui_import')}</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
|
|
@ -270,9 +270,7 @@ export default function SharedLinks() {
|
|||
|
||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
|
||||
<button className="btn btn-neutral relative">
|
||||
{localize('com_nav_shared_links_manage')}
|
||||
</button>
|
||||
<Button variant="outline">{localize('com_ui_manage')}</Button>
|
||||
</OGDialogTrigger>
|
||||
|
||||
<OGDialogContent
|
||||
|
|
|
@ -12,7 +12,7 @@ export default function ArchivedChats() {
|
|||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button variant="outline" aria-label="Archived chats">
|
||||
{localize('com_nav_archived_chats_manage')}
|
||||
{localize('com_ui_manage')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
|
|
68
client/src/components/ui/InputOTP.tsx
Normal file
68
client/src/components/ui/InputOTP.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
import * as React from 'react';
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
import { Minus } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
'flex items-center gap-2 has-[:disabled]:opacity-50',
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
InputOTP.displayName = 'InputOTP';
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center', className)} {...props} />
|
||||
));
|
||||
InputOTPGroup.displayName = 'InputOTPGroup';
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-md relative flex h-11 w-11 items-center justify-center border-y border-r border-input shadow-sm transition-all first:rounded-l-xl first:border-l last:rounded-r-xl',
|
||||
isActive && 'z-10 ring-1 ring-ring',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
InputOTPSlot.displayName = 'InputOTPSlot';
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Minus />
|
||||
</div>
|
||||
));
|
||||
InputOTPSeparator.displayName = 'InputOTPSeparator';
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
22
client/src/components/ui/Progress.tsx
Normal file
22
client/src/components/ui/Progress.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
|
@ -24,6 +24,8 @@ export * from './Textarea';
|
|||
export * from './TextareaAutosize';
|
||||
export * from './Tooltip';
|
||||
export * from './Pagination';
|
||||
export * from './Progress';
|
||||
export * from './InputOTP';
|
||||
export { default as Combobox } from './Combobox';
|
||||
export { default as Dropdown } from './Dropdown';
|
||||
export { default as FileUpload } from './FileUpload';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useResetRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { MutationKeys, dataService, request } from 'librechat-data-provider';
|
||||
import { MutationKeys, QueryKeys, dataService, request } from 'librechat-data-provider';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import useClearStates from '~/hooks/Config/useClearStates';
|
||||
|
@ -84,3 +84,91 @@ export const useDeleteUserMutation = (
|
|||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0
|
||||
|
||||
export const useEnableTwoFactorMutation = (): UseMutationResult<
|
||||
t.TEnable2FAResponse,
|
||||
unknown,
|
||||
void,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(() => dataService.enableTwoFactor(), {
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData([QueryKeys.user, '2fa'], data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useVerifyTwoFactorMutation = (): UseMutationResult<
|
||||
t.TVerify2FAResponse,
|
||||
unknown,
|
||||
t.TVerify2FARequest,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation((payload: t.TVerify2FARequest) => dataService.verifyTwoFactor(payload), {
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData([QueryKeys.user, '2fa'], data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useConfirmTwoFactorMutation = (): UseMutationResult<
|
||||
t.TVerify2FAResponse,
|
||||
unknown,
|
||||
t.TVerify2FARequest,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation((payload: t.TVerify2FARequest) => dataService.confirmTwoFactor(payload), {
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData([QueryKeys.user, '2fa'], data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDisableTwoFactorMutation = (): UseMutationResult<
|
||||
t.TDisable2FAResponse,
|
||||
unknown,
|
||||
void,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(() => dataService.disableTwoFactor(), {
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData([QueryKeys.user, '2fa'], null);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRegenerateBackupCodesMutation = (): UseMutationResult<
|
||||
t.TRegenerateBackupCodesResponse,
|
||||
unknown,
|
||||
void,
|
||||
unknown
|
||||
> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(() => dataService.regenerateBackupCodes(), {
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData([QueryKeys.user, '2fa', 'backup'], data);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useVerifyTwoFactorTempMutation = (
|
||||
options?: t.MutationOptions<t.TVerify2FATempResponse, t.TVerify2FATempRequest, unknown, unknown>,
|
||||
): UseMutationResult<t.TVerify2FATempResponse, unknown, t.TVerify2FATempRequest, unknown> => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
(payload: t.TVerify2FATempRequest) => dataService.verifyTwoFactorTemp(payload),
|
||||
{
|
||||
...(options || {}),
|
||||
onSuccess: (data, ...args) => {
|
||||
queryClient.setQueryData([QueryKeys.user, '2fa'], data);
|
||||
options?.onSuccess?.(data, ...args);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
@ -70,7 +70,12 @@ const AuthContextProvider = ({
|
|||
|
||||
const loginUser = useLoginUserMutation({
|
||||
onSuccess: (data: t.TLoginResponse) => {
|
||||
const { user, token } = data;
|
||||
const { user, token, twoFAPending, tempToken } = data;
|
||||
if (twoFAPending) {
|
||||
// Redirect to the two-factor authentication route.
|
||||
navigate(`/login/2fa?tempToken=${tempToken}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
setError(undefined);
|
||||
setUserContext({ token, isAuthenticated: true, user, redirect: '/c/new' });
|
||||
},
|
||||
|
@ -212,4 +217,4 @@ const useAuthContext = () => {
|
|||
return context;
|
||||
};
|
||||
|
||||
export { AuthContextProvider, useAuthContext };
|
||||
export { AuthContextProvider, useAuthContext };
|
|
@ -178,7 +178,6 @@
|
|||
"com_endpoint_google_temp": "القيم الأعلى = أكثر عشوائية، بينما القيم الأقل = أكثر تركيزًا وحتمية. نوصي بتغيير هذا أو Top P ولكن ليس كلاهما.",
|
||||
"com_endpoint_google_topk": "Top-k يغير كيفية اختيار النموذج للرموز للإخراج. top-k من 1 يعني أن الرمز المحدد هو الأكثر احتمالية بين جميع الرموز في مفردات النموذج (يسمى أيضًا الترميز الجشعي)، بينما top-k من 3 يعني أن الرمز التالي يتم اختياره من بين الرموز الثلاثة الأكثر احتمالية (باستخدام الحرارة).",
|
||||
"com_endpoint_google_topp": "Top-p يغير كيفية اختيار النموذج للرموز للإخراج. يتم اختيار الرموز من الأكثر K (انظر معلمة topK) احتمالًا إلى الأقل حتى يصبح مجموع احتمالاتهم يساوي قيمة top-p.",
|
||||
"com_endpoint_import": "استيراد",
|
||||
"com_endpoint_instructions_assistants": "تعليمات التجاوز",
|
||||
"com_endpoint_instructions_assistants_placeholder": "يتجاوز التعليمات الخاصة بالمساعد. هذا مفيد لتعديل السلوك على أساس كل مرة.",
|
||||
"com_endpoint_max_output_tokens": "الحد الأقصى لعدد الرموز المنتجة",
|
||||
|
@ -262,7 +261,6 @@
|
|||
"com_nav_archive_name": "الاسم",
|
||||
"com_nav_archived_chats": "الدردشات المؤرشفة",
|
||||
"com_nav_archived_chats_empty": "ليس لديك أي دردشات مؤرشفة.",
|
||||
"com_nav_archived_chats_manage": "إدارة",
|
||||
"com_nav_at_command": "أمر-@",
|
||||
"com_nav_at_command_description": "تبديل الأمر \"@\" للتنقل بين نقاط النهاية والنماذج والإعدادات المسبقة وغيرها",
|
||||
"com_nav_audio_play_error": "خطأ في تشغيل الصوت: {{0}}",
|
||||
|
@ -383,7 +381,6 @@
|
|||
"com_nav_setting_speech": "الكلام",
|
||||
"com_nav_settings": "الإعدادات",
|
||||
"com_nav_shared_links": "روابط مشتركة",
|
||||
"com_nav_shared_links_manage": "الإدارة",
|
||||
"com_nav_show_code": "إظهار الشفرة دائمًا عند استخدام مفسر الشفرة",
|
||||
"com_nav_slash_command": "/-الأمر",
|
||||
"com_nav_slash_command_description": "تبديل الأمر \"/\" لاختيار موجه عبر لوحة المفاتيح",
|
||||
|
@ -587,7 +584,7 @@
|
|||
"com_ui_happy_birthday": "إنه عيد ميلادي الأول!",
|
||||
"com_ui_host": "مُضيف",
|
||||
"com_ui_image_gen": "توليد الصور",
|
||||
"com_ui_import_conversation": "استيراد",
|
||||
"com_ui_import": "استيراد",
|
||||
"com_ui_import_conversation_error": "حدث خطأ أثناء استيراد محادثاتك",
|
||||
"com_ui_import_conversation_file_type_error": "نوع الملف غير مدعوم للاستيراد",
|
||||
"com_ui_import_conversation_info": "استيراد محادثات من ملف JSON",
|
||||
|
@ -719,4 +716,4 @@
|
|||
"com_ui_zoom": "تكبير",
|
||||
"com_user_message": "أنت",
|
||||
"com_warning_resubmit_unsupported": "إعادة إرسال رسالة الذكاء الاصطناعي غير مدعومة لنقطة النهاية هذه"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -182,7 +182,6 @@
|
|||
"com_endpoint_google_temp": "Höhere Werte = zufälliger, während niedrigere Werte = fokussierter und deterministischer. Wir empfehlen, entweder dies oder Top P zu ändern, aber nicht beides.",
|
||||
"com_endpoint_google_topk": "Top-k ändert, wie das Modell Token für die Antwort auswählt. Ein Top-k von 1 bedeutet, dass das ausgewählte Token das wahrscheinlichste unter allen Token im Vokabular des Modells ist (auch Greedy-Decoding genannt), während ein Top-k von 3 bedeutet, dass das nächste Token aus den 3 wahrscheinlichsten Token ausgewählt wird (unter Verwendung der Temperatur).",
|
||||
"com_endpoint_google_topp": "Top-p ändert, wie das Modell Token für die Antwort auswählt. Token werden von den wahrscheinlichsten K (siehe topK-Parameter) bis zu den am wenigsten wahrscheinlichen ausgewählt, bis die Summe ihrer Wahrscheinlichkeiten dem Top-p-Wert entspricht.",
|
||||
"com_endpoint_import": "Importieren",
|
||||
"com_endpoint_instructions_assistants": "Anweisungen überschreiben",
|
||||
"com_endpoint_instructions_assistants_placeholder": "Überschreibt die Anweisungen des Assistenten. Dies ist nützlich, um das Verhalten auf Basis einzelner Ausführungen zu modifizieren.",
|
||||
"com_endpoint_max_output_tokens": "Max. Antwort-Token",
|
||||
|
@ -268,7 +267,6 @@
|
|||
"com_nav_archive_name": "Name",
|
||||
"com_nav_archived_chats": "Archivierte Chats",
|
||||
"com_nav_archived_chats_empty": "Du hast keine archivierten Chats.",
|
||||
"com_nav_archived_chats_manage": "Verwalten",
|
||||
"com_nav_at_command": "@-Befehl",
|
||||
"com_nav_at_command_description": "Schaltet den Befehl \"@\" zum Wechseln von Endpunkten, Modellen, Voreinstellungen usw. um.",
|
||||
"com_nav_audio_play_error": "Fehler beim Abspielen des Audios: {{0}}",
|
||||
|
@ -391,7 +389,6 @@
|
|||
"com_nav_setting_speech": "Sprache",
|
||||
"com_nav_settings": "Einstellungen",
|
||||
"com_nav_shared_links": "Geteilte Links",
|
||||
"com_nav_shared_links_manage": "Verwalten",
|
||||
"com_nav_show_code": "Code immer anzeigen, wenn der Code-Interpreter verwendet wird",
|
||||
"com_nav_show_thinking": "Denkprozess-Dropdowns standardmäßig öffnen",
|
||||
"com_nav_slash_command": "/-Befehl",
|
||||
|
@ -617,7 +614,7 @@
|
|||
"com_ui_hide_qr": "QR-Code ausblenden",
|
||||
"com_ui_host": "Host",
|
||||
"com_ui_image_gen": "Bildgenerierung",
|
||||
"com_ui_import_conversation": "Importieren",
|
||||
"com_ui_import": "Importieren",
|
||||
"com_ui_import_conversation_error": "Beim Importieren Ihrer Konversationen ist ein Fehler aufgetreten",
|
||||
"com_ui_import_conversation_file_type_error": "Nicht unterstützter Importtyp",
|
||||
"com_ui_import_conversation_info": "Konversationen aus einer JSON-Datei importieren",
|
||||
|
@ -767,4 +764,4 @@
|
|||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "Du",
|
||||
"com_warning_resubmit_unsupported": "Das erneute Senden der KI-Nachricht wird für diesen Endpunkt nicht unterstützt."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"chat_direction_left_to_right": "something needs to go here. was empty",
|
||||
"chat_direction_right_to_left": "something needs to go here. was empty",
|
||||
"chat_direction_left_to_right": "Chat direction is now left to right",
|
||||
"chat_direction_right_to_left": "Chat direction is now right to left",
|
||||
"com_a11y_ai_composing": "The AI is still composing.",
|
||||
"com_a11y_end": "The AI has finished their reply.",
|
||||
"com_a11y_start": "The AI has started their reply.",
|
||||
|
@ -87,6 +87,7 @@
|
|||
"com_auth_email_verification_redirecting": "Redirecting in {{0}} seconds...",
|
||||
"com_auth_email_verification_resend_prompt": "Didn't receive the email?",
|
||||
"com_auth_email_verification_success": "Email verified successfully",
|
||||
"com_auth_email_verifying_ellipsis": "Verifying...",
|
||||
"com_auth_error_create": "There was an error attempting to register your account. Please try again.",
|
||||
"com_auth_error_invalid_reset_token": "This password reset token is no longer valid.",
|
||||
"com_auth_error_login": "Unable to login with the information provided. Please check your credentials and try again.",
|
||||
|
@ -184,7 +185,6 @@
|
|||
"com_endpoint_google_temp": "Higher values = more random, while lower values = more focused and deterministic. We recommend altering this or Top P but not both.",
|
||||
"com_endpoint_google_topk": "Top-k changes how the model selects tokens for output. A top-k of 1 means the selected token is the most probable among all tokens in the model's vocabulary (also called greedy decoding), while a top-k of 3 means that the next token is selected from among the 3 most probable tokens (using temperature).",
|
||||
"com_endpoint_google_topp": "Top-p changes how the model selects tokens for output. Tokens are selected from most K (see topK parameter) probable to least until the sum of their probabilities equals the top-p value.",
|
||||
"com_endpoint_import": "Import",
|
||||
"com_endpoint_instructions_assistants": "Override Instructions",
|
||||
"com_endpoint_instructions_assistants_placeholder": "Overrides the instructions of the assistant. This is useful for modifying the behavior on a per-run basis.",
|
||||
"com_endpoint_max_output_tokens": "Max Output Tokens",
|
||||
|
@ -263,7 +263,7 @@
|
|||
"com_files_filter": "Filter files...",
|
||||
"com_files_no_results": "No results.",
|
||||
"com_files_number_selected": "{{0}} of {{1}} items(s) selected",
|
||||
"com_files_table": "something needs to go here. was empty",
|
||||
"com_files_table": "Files Table",
|
||||
"com_generated_files": "Generated files:",
|
||||
"com_hide_examples": "Hide Examples",
|
||||
"com_nav_account_settings": "Account Settings",
|
||||
|
@ -272,7 +272,6 @@
|
|||
"com_nav_archive_name": "Name",
|
||||
"com_nav_archived_chats": "Archived chats",
|
||||
"com_nav_archived_chats_empty": "You have no archived conversations.",
|
||||
"com_nav_archived_chats_manage": "Manage",
|
||||
"com_nav_at_command": "@-Command",
|
||||
"com_nav_at_command_description": "Toggle command \"@\" for switching endpoints, models, presets, etc.",
|
||||
"com_nav_audio_play_error": "Error playing audio: {{0}}",
|
||||
|
@ -396,7 +395,6 @@
|
|||
"com_nav_setting_speech": "Speech",
|
||||
"com_nav_settings": "Settings",
|
||||
"com_nav_shared_links": "Shared links",
|
||||
"com_nav_shared_links_manage": "Manage",
|
||||
"com_nav_show_code": "Always show code when using code interpreter",
|
||||
"com_nav_show_thinking": "Open Thinking Dropdowns by Default",
|
||||
"com_nav_slash_command": "/-Command",
|
||||
|
@ -437,6 +435,9 @@
|
|||
"com_sidepanel_parameters": "Parameters",
|
||||
"com_sidepanel_select_agent": "Select an Agent",
|
||||
"com_sidepanel_select_assistant": "Select an Assistant",
|
||||
"com_nav_2fa": "Two-Factor Authentication (2FA)",
|
||||
"com_auth_verify_your_identity": "Verify Your Identity",
|
||||
"com_auth_two_factor": "Check your preferred one-time password application for a code",
|
||||
"com_ui_accept": "I accept",
|
||||
"com_ui_add": "Add",
|
||||
"com_ui_add_model_preset": "Add a model or preset for an additional response",
|
||||
|
@ -626,7 +627,7 @@
|
|||
"com_ui_fork_split_target_setting": "Start fork from target message by default",
|
||||
"com_ui_fork_success": "Successfully forked conversation",
|
||||
"com_ui_fork_visible": "Visible messages only",
|
||||
"com_ui_global_group": "something needs to go here. was empty",
|
||||
"com_ui_global_group": "Global Group",
|
||||
"com_ui_go_back": "Go back",
|
||||
"com_ui_go_to_conversation": "Go to conversation",
|
||||
"com_ui_happy_birthday": "It's my 1st birthday!",
|
||||
|
@ -634,7 +635,7 @@
|
|||
"com_ui_host": "Host",
|
||||
"com_ui_idea": "Ideas",
|
||||
"com_ui_image_gen": "Image Gen",
|
||||
"com_ui_import_conversation": "Import",
|
||||
"com_ui_import": "Import",
|
||||
"com_ui_import_conversation_error": "There was an error importing your conversations",
|
||||
"com_ui_import_conversation_file_type_error": "Unsupported import type",
|
||||
"com_ui_import_conversation_info": "Import conversations from a JSON file",
|
||||
|
@ -670,9 +671,9 @@
|
|||
"com_ui_no_bookmarks": "it seems like you have no bookmarks yet. Click on a chat and add a new one",
|
||||
"com_ui_no_category": "No category",
|
||||
"com_ui_no_changes": "No changes to update",
|
||||
"com_ui_no_data": "something needs to go here. was empty",
|
||||
"com_ui_no_data": "No data",
|
||||
"com_ui_no_terms_content": "No terms and conditions content to display",
|
||||
"com_ui_no_valid_items": "something needs to go here. was empty",
|
||||
"com_ui_no_valid_items": "No valid items",
|
||||
"com_ui_none": "None",
|
||||
"com_ui_none_selected": "None selected",
|
||||
"com_ui_nothing_found": "Nothing found",
|
||||
|
@ -795,6 +796,36 @@
|
|||
"com_ui_write": "Writing",
|
||||
"com_ui_yes": "Yes",
|
||||
"com_ui_zoom": "Zoom",
|
||||
"com_ui_secret_key": "Secret Key",
|
||||
"com_ui_2fa_account_security": "Two-factor authentication adds an extra layer of security to your account",
|
||||
"com_ui_2fa_generate_error": "There was an error generating two-factor authentication settings",
|
||||
"com_ui_backup_codes": "Backup Codes",
|
||||
"com_ui_2fa_invalid": "Invalid two-factor authentication code",
|
||||
"com_ui_2fa_setup": "Setup 2FA",
|
||||
"com_ui_2fa_enable": "Enable 2FA",
|
||||
"com_ui_2fa_disable": "Disable 2FA",
|
||||
"com_ui_disabling": "Disabling...",
|
||||
"com_ui_2fa_enabled": "2FA has been enabled",
|
||||
"com_ui_2fa_disabled": "2FA has been disabled",
|
||||
"com_ui_download_backup": "Download Backup Codes",
|
||||
"com_ui_use_backup_code": "Use Backup Code Instead",
|
||||
"com_ui_use_2fa_code": "Use 2FA Code Instead",
|
||||
"com_ui_verify": "Verify",
|
||||
"com_ui_2fa_disable_error": "There was an error disabling two-factor authentication",
|
||||
"com_ui_2fa_verified": "Successfully verified Two-Factor Authentication",
|
||||
"com_ui_generate_backup": "Generate Backup Codes",
|
||||
"com_ui_regenerate_backup": "Regenerate Backup Codes",
|
||||
"com_ui_regenerating": "Regenerating...",
|
||||
"com_ui_used": "Used",
|
||||
"com_ui_not_used": "Not Used",
|
||||
"com_ui_backup_codes_regenerated": "Backup codes have been regenerated successfully",
|
||||
"com_ui_backup_codes_regenerate_error": "There was an error regenerating backup codes",
|
||||
"com_ui_no_backup_codes": "No backup codes available. Please generate new ones",
|
||||
"com_ui_generating": "Generating...",
|
||||
"com_ui_generate_qrcode": "Generate QR Code",
|
||||
"com_ui_complete_setup": "Complete Setup",
|
||||
"com_ui_download_backup_tooltip": "Before you continue, download your backup codes. You will need them to regain access if you lose your authenticator device",
|
||||
"com_ui_show": "Show",
|
||||
"com_user_message": "You",
|
||||
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -178,7 +178,6 @@
|
|||
"com_endpoint_google_temp": "Los valores más altos = más aleatorios, mientras que los valores más bajos = más enfocados y deterministas. Recomendamos alterar esto o Top P, pero no ambos.",
|
||||
"com_endpoint_google_topk": "Top-k cambia la forma en que el modelo selecciona tokens para la salida. Un top-k de 1 significa que el token seleccionado es el más probable entre todos los tokens en el vocabulario del modelo (también llamado decodificación codiciosa), mientras que un top-k de 3 significa que el siguiente token se selecciona entre los 3 tokens más probables (usando temperatura).",
|
||||
"com_endpoint_google_topp": "Top-p cambia la forma en que el modelo selecciona tokens para la salida. Los tokens se seleccionan desde los más K (ver parámetro topK) probables hasta los menos probables hasta que la suma de sus probabilidades sea igual al valor top-p.",
|
||||
"com_endpoint_import": "Importar",
|
||||
"com_endpoint_instructions_assistants": "Anular instrucciones",
|
||||
"com_endpoint_instructions_assistants_placeholder": "Anula las instrucciones del asistente. Esto es útil para modificar el comportamiento por ejecución.",
|
||||
"com_endpoint_max_output_tokens": "Tokens de Salida Máximos",
|
||||
|
@ -262,7 +261,6 @@
|
|||
"com_nav_archive_name": "Nombre",
|
||||
"com_nav_archived_chats": "Archivadas",
|
||||
"com_nav_archived_chats_empty": "No tienes conversaciones archivadas.",
|
||||
"com_nav_archived_chats_manage": "Gestionar",
|
||||
"com_nav_at_command": "Comando @",
|
||||
"com_nav_at_command_description": "Alternar comando \"@\" para cambiar entre puntos de conexión, modelos, ajustes predefinidos, etc.",
|
||||
"com_nav_audio_play_error": "Error al reproducir el audio: {{0}}",
|
||||
|
@ -383,7 +381,6 @@
|
|||
"com_nav_setting_speech": "Voz y habla",
|
||||
"com_nav_settings": "Configuración",
|
||||
"com_nav_shared_links": "Links Compartidos",
|
||||
"com_nav_shared_links_manage": "Gerenciar",
|
||||
"com_nav_show_code": "Mostrar siempre el código cuando se use el intérprete de código",
|
||||
"com_nav_slash_command": "Comando /",
|
||||
"com_nav_slash_command_description": "Alternar comando '/' para seleccionar un mensaje predefinido mediante el teclado",
|
||||
|
@ -587,7 +584,7 @@
|
|||
"com_ui_happy_birthday": "¡Es mi primer cumpleaños!",
|
||||
"com_ui_host": "Host",
|
||||
"com_ui_image_gen": "Gen Imágenes",
|
||||
"com_ui_import_conversation": "Importar",
|
||||
"com_ui_import": "Importar",
|
||||
"com_ui_import_conversation_error": "Hubo un error al importar tus chats",
|
||||
"com_ui_import_conversation_file_type_error": "com_ui_import_conversation_file_type_error: Tipo de archivo no compatible para importar",
|
||||
"com_ui_import_conversation_info": "Importar chats de un archivo JSON",
|
||||
|
@ -719,4 +716,4 @@
|
|||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "Usted",
|
||||
"com_warning_resubmit_unsupported": "No se admite el reenvío del mensaje de IA para este punto de conexión."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -396,7 +396,6 @@
|
|||
"com_nav_setting_speech": "Kõne",
|
||||
"com_nav_settings": "Seaded",
|
||||
"com_nav_shared_links": "Jagatud lingid",
|
||||
"com_nav_shared_links_manage": "Halda",
|
||||
"com_nav_show_code": "Näita koodi alati, kui kasutatakse koodiinterpreteerijat",
|
||||
"com_nav_show_thinking": "Ava mõtlemise rippmenüüd vaikimisi",
|
||||
"com_nav_slash_command": "/-käsk",
|
||||
|
@ -787,4 +786,4 @@
|
|||
"com_ui_zoom": "Suumi",
|
||||
"com_user_message": "Sina",
|
||||
"com_warning_resubmit_unsupported": "AI sõnumi uuesti esitamine pole selle otspunkti jaoks toetatud."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -149,7 +149,6 @@
|
|||
"com_endpoint_google_temp": "Korkeampi arvo = satunnaisempi; matalampi arvo = keskittyneempi ja deterministisempi. Suosittelemme, että muokkaat tätä tai Top P:tä, mutta ei molempia.",
|
||||
"com_endpoint_google_topk": "Top-k vaikuttaa siihen, miten malli valitsee tokeineita tulokseen. Jos Top-k on 1, valitaan se token, joka on kaikkien todennäköisen mallin sanastossa (tunnetaan myös nimellä ahne dekoodaus), kun taas top-k 3 tarkoittaisi, että seuraavat token valitaan 3 todennäköisimmän tokenin joukosta, lämpötilaa hyödyntäen.",
|
||||
"com_endpoint_google_topp": "Top-P vaikuttaa siihen kuinka malli valitsee tokeneita tulokseen. Tokenit valitaan top-k:sta (ks. Top-k -parametri) todennäköisimmistä vähiten todennäköseen, kunnes niiden todennäköisyyksien summa ylittää Top-P -arvon.",
|
||||
"com_endpoint_import": "Tuo",
|
||||
"com_endpoint_instructions_assistants": "Yliaja ohjeet",
|
||||
"com_endpoint_instructions_assistants_placeholder": "Yliajaa Avustajan ohjeet. Tätä voi hyödyntää käytöksen muuttamiseen keskustelukohtaisesti.",
|
||||
"com_endpoint_max_output_tokens": "Tulos-tokeneiden maksimimäärä",
|
||||
|
@ -220,7 +219,6 @@
|
|||
"com_nav_archive_name": "Nimi",
|
||||
"com_nav_archived_chats": "Arkistoidut keskustelut",
|
||||
"com_nav_archived_chats_empty": "Sinulla ei ole arkistoituja keskusteluita.",
|
||||
"com_nav_archived_chats_manage": "Hallinnoi",
|
||||
"com_nav_audio_play_error": "Virhe ääntä toistaessa: {{0}}",
|
||||
"com_nav_audio_process_error": "Virhe ääntä käsitellessä: {{0}}",
|
||||
"com_nav_auto_scroll": "Vieritä automaattisesti viimeisimpään viestiin keskustelua avatessa",
|
||||
|
@ -315,7 +313,6 @@
|
|||
"com_nav_setting_speech": "Puhe",
|
||||
"com_nav_settings": "Asetukset",
|
||||
"com_nav_shared_links": "Jaetut linkit",
|
||||
"com_nav_shared_links_manage": "Hallinnoi",
|
||||
"com_nav_show_code": "Kooditulkkia käyttäessä näytä aina koodi",
|
||||
"com_nav_speech_to_text": "Puheesta tekstiksi",
|
||||
"com_nav_text_to_speech": "Tekstistä puheeksi",
|
||||
|
@ -461,7 +458,7 @@
|
|||
"com_ui_happy_birthday": "On 1. syntymäpäiväni!",
|
||||
"com_ui_host": "Host",
|
||||
"com_ui_image_gen": "Kuvanluonti",
|
||||
"com_ui_import_conversation": "Tuo",
|
||||
"com_ui_import": "Tuo",
|
||||
"com_ui_import_conversation_error": "Keskustelujesi tuonnissa tapahtui virhe",
|
||||
"com_ui_import_conversation_file_type_error": "Tiedostotyyppi ei ole tuettu tuonnissa",
|
||||
"com_ui_import_conversation_info": "Tuo keskusteluja JSON-tiedostosta",
|
||||
|
@ -554,4 +551,4 @@
|
|||
"com_ui_versions": "Versiot",
|
||||
"com_ui_yes": "Kyllä",
|
||||
"com_user_message": "Sinä"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -179,7 +179,6 @@
|
|||
"com_endpoint_google_temp": "Des valeurs plus élevées = plus aléatoires, tandis que des valeurs plus faibles = plus concentrées et déterministes. Nous vous recommandons de modifier ceci ou Top P mais pas les deux.",
|
||||
"com_endpoint_google_topk": "Top-k change la façon dont le modèle sélectionne les jetons pour la sortie. Un top-k de 1 signifie que le jeton sélectionné est le plus probable parmi tous les jetons du vocabulaire du modèle (également appelé décodage glouton), tandis qu'un top-k de 3 signifie que le jeton suivant est sélectionné parmi les 3 jetons les plus probables (en utilisant la température).",
|
||||
"com_endpoint_google_topp": "Top-p change la façon dont le modèle sélectionne les jetons pour la sortie. Les jetons sont sélectionnés du plus K (voir le paramètre topK) probable au moins jusqu'à ce que la somme de leurs probabilités égale la valeur top-p.",
|
||||
"com_endpoint_import": "Importer",
|
||||
"com_endpoint_instructions_assistants": "Instructions de remplacement",
|
||||
"com_endpoint_instructions_assistants_placeholder": "Remplace les instructions de l'assistant. Cela est utile pour modifier le comportement au cas par cas.",
|
||||
"com_endpoint_max_output_tokens": "Nombre maximum de jetons en sortie",
|
||||
|
@ -265,7 +264,6 @@
|
|||
"com_nav_archive_name": "Nom",
|
||||
"com_nav_archived_chats": "Conversations archivées",
|
||||
"com_nav_archived_chats_empty": "Vous n'avez aucune conversation archivée.",
|
||||
"com_nav_archived_chats_manage": "Gérer",
|
||||
"com_nav_at_command": "Commande-@",
|
||||
"com_nav_at_command_description": "Basculer la commande \"@\" pour changer d'endpoints, de modèles, de préréglages, etc.",
|
||||
"com_nav_audio_play_error": "Erreur de lecture audio : {{0}}",
|
||||
|
@ -388,7 +386,6 @@
|
|||
"com_nav_setting_speech": "Parole",
|
||||
"com_nav_settings": "Paramètres",
|
||||
"com_nav_shared_links": "Liens partagés",
|
||||
"com_nav_shared_links_manage": "Gérer",
|
||||
"com_nav_show_code": "Toujours afficher le code lors de l'utilisation de l'interpréteur de code",
|
||||
"com_nav_show_thinking": "Ovrir les menus déroulants de réflexion par défaut",
|
||||
"com_nav_slash_command": "/-Commande",
|
||||
|
@ -602,7 +599,7 @@
|
|||
"com_ui_hide_qr": "Cacher le code QR",
|
||||
"com_ui_host": "Hôte",
|
||||
"com_ui_image_gen": "Génération d'image",
|
||||
"com_ui_import_conversation": "Importer",
|
||||
"com_ui_import": "Importer",
|
||||
"com_ui_import_conversation_error": "Une erreur s'est produite lors de l'importation de vos conversations",
|
||||
"com_ui_import_conversation_file_type_error": "Type de fichier non pris en charge pour l'importation",
|
||||
"com_ui_import_conversation_info": "Importer des conversations à partir d'un fichier JSON",
|
||||
|
@ -736,4 +733,4 @@
|
|||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "Vous",
|
||||
"com_warning_resubmit_unsupported": "La resoumission du message IA n'est pas prise en charge pour ce point de terminaison."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -167,7 +167,6 @@
|
|||
"com_nav_archive_name": "שם",
|
||||
"com_nav_archived_chats": "שיחות מארכיון",
|
||||
"com_nav_archived_chats_empty": "אין שיחות מארכיון.",
|
||||
"com_nav_archived_chats_manage": "ניהול",
|
||||
"com_nav_auto_scroll": "Auto-s גלול אל הכי חדש בפתיחה",
|
||||
"com_nav_balance": "לְאַזֵן",
|
||||
"com_nav_change_picture": "שנה תמונה",
|
||||
|
@ -231,7 +230,6 @@
|
|||
"com_nav_setting_general": "כללי",
|
||||
"com_nav_settings": "הגדרות",
|
||||
"com_nav_shared_links": "קישורים משותפים",
|
||||
"com_nav_shared_links_manage": "ניהול",
|
||||
"com_nav_theme": "נושא",
|
||||
"com_nav_theme_dark": "כהה",
|
||||
"com_nav_theme_light": "אור",
|
||||
|
@ -293,7 +291,7 @@
|
|||
"com_ui_error": "שגיאה",
|
||||
"com_ui_examples": "דוגמאות",
|
||||
"com_ui_happy_birthday": "זה יום ההולדת הראשון שלי!",
|
||||
"com_ui_import_conversation": "יבוא",
|
||||
"com_ui_import": "יבוא",
|
||||
"com_ui_import_conversation_error": "אירעה שגיאה בעת ייבוא השיחות שלך",
|
||||
"com_ui_import_conversation_info": "ייבא שיחות מקובץ JSON",
|
||||
"com_ui_import_conversation_success": "השיחות יובאו בהצלחה",
|
||||
|
@ -333,4 +331,4 @@
|
|||
"com_ui_upload_success": "קובץ שהועלה בהצלחה",
|
||||
"com_ui_use_prompt": "השתמש בהודעת",
|
||||
"com_user_message": "אתה"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -91,7 +91,6 @@
|
|||
"com_endpoint_google_temp": "Nilai yang lebih tinggi = lebih acak, sedangkan nilai yang lebih rendah = lebih fokus dan deterministik. Kami merekomendasikan untuk mengubah ini atau Top P tetapi tidak keduanya.",
|
||||
"com_endpoint_google_topk": "Top-k mengubah cara model memilih token untuk output. Top-k 1 berarti token yang dipilih adalah yang paling mungkin di antara semua token dalam kosakata model (juga disebut decoding serakah), sedangkan top-k 3 berarti token berikutnya dipilih dari antara 3 token yang paling mungkin (menggunakan temperatur).",
|
||||
"com_endpoint_google_topp": "Top-p mengubah cara model memilih token untuk output. Token dipilih dari yang paling mungkin (lihat parameter topK) hingga yang paling tidak mungkin sampai jumlah probabilitas mereka sama dengan nilai top-p.",
|
||||
"com_endpoint_import": "Impor",
|
||||
"com_endpoint_max_output_tokens": "Token Output Maks",
|
||||
"com_endpoint_message": "Pesan",
|
||||
"com_endpoint_message_not_appendable": "Edit pesan Anda atau Regenerasi.",
|
||||
|
@ -142,7 +141,6 @@
|
|||
"com_nav_archive_name": "Nama",
|
||||
"com_nav_archived_chats": "Percakapan Arsip",
|
||||
"com_nav_archived_chats_empty": "Tidak ada percakapan yang diarsipkan.",
|
||||
"com_nav_archived_chats_manage": "Pengelolaan",
|
||||
"com_nav_auto_scroll": "Otomatis gulir ke Baru saat Buka",
|
||||
"com_nav_balance": "Keseimbangan",
|
||||
"com_nav_change_picture": "Ubah foto",
|
||||
|
@ -205,7 +203,6 @@
|
|||
"com_nav_setting_general": "Umum",
|
||||
"com_nav_settings": "Pengaturan",
|
||||
"com_nav_shared_links": "Link berbagi",
|
||||
"com_nav_shared_links_manage": "Pengeluaran",
|
||||
"com_nav_theme": "Tema",
|
||||
"com_nav_theme_dark": "Gelap",
|
||||
"com_nav_theme_light": "Terang",
|
||||
|
@ -250,7 +247,7 @@
|
|||
"com_ui_enter": "Masuk",
|
||||
"com_ui_examples": "Contoh",
|
||||
"com_ui_happy_birthday": "Ini ulang tahun pertamaku!",
|
||||
"com_ui_import_conversation": "Impor",
|
||||
"com_ui_import": "Impor",
|
||||
"com_ui_import_conversation_error": "Terjadi kesalahan saat mengimpor percakapan Anda",
|
||||
"com_ui_import_conversation_info": "Impor percakapan dari file JSON",
|
||||
"com_ui_import_conversation_success": "Percakapan berhasil diimpor",
|
||||
|
@ -287,4 +284,4 @@
|
|||
"com_ui_upload_success": "Berhasil mengunggah file",
|
||||
"com_ui_use_prompt": "Gunakan petunjuk",
|
||||
"com_user_message": "Kamu"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -179,7 +179,6 @@
|
|||
"com_endpoint_google_temp": "Valori più alti = più casualità, mentre valori più bassi = più focalizzati e deterministici. Consigliamo di modificare questo o Top P ma non entrambi.",
|
||||
"com_endpoint_google_topk": "Top-k cambia il modo in cui il modello seleziona i token per l'output. Un top-k di 1 significa che il token selezionato è il più probabile tra tutti i token nel vocabolario del modello (anche chiamato greedy decoding), mentre un top-k di 3 significa che il prossimo token è selezionato tra i 3 più probabili (usando la temperatura).",
|
||||
"com_endpoint_google_topp": "Top-p cambia il modo in cui il modello seleziona i token per l'output. I token vengono selezionati dai più probabili K (vedi parametro topK) ai meno probabili fino a quando la somma delle loro probabilità eguaglia il valore top-p.",
|
||||
"com_endpoint_import": "Importa",
|
||||
"com_endpoint_instructions_assistants": "Sovrascrivi istruzioni",
|
||||
"com_endpoint_instructions_assistants_placeholder": "Sovrascrive le istruzioni dell'assistente. Utile per modificare il comportamento su base singola.",
|
||||
"com_endpoint_max_output_tokens": "Token di output massimi",
|
||||
|
@ -265,7 +264,6 @@
|
|||
"com_nav_archive_name": "Nome",
|
||||
"com_nav_archived_chats": "Chat archiviate",
|
||||
"com_nav_archived_chats_empty": "Non hai chat archiviate.",
|
||||
"com_nav_archived_chats_manage": "Gestisci",
|
||||
"com_nav_at_command": "Comando @",
|
||||
"com_nav_at_command_description": "Attiva il comando \"@\" per cambiare endpoint, modelli, preset e altro",
|
||||
"com_nav_audio_play_error": "Errore durante la riproduzione audio: {{0}}",
|
||||
|
@ -388,7 +386,6 @@
|
|||
"com_nav_setting_speech": "Voce",
|
||||
"com_nav_settings": "Impostazioni",
|
||||
"com_nav_shared_links": "Link condivisi",
|
||||
"com_nav_shared_links_manage": "Gestisci",
|
||||
"com_nav_show_code": "Mostra sempre il codice quando si usa l'interprete di codice",
|
||||
"com_nav_show_thinking": "Apri i menu a tendina del ragionamento per impostazione predefinita",
|
||||
"com_nav_slash_command": "/-Comando",
|
||||
|
@ -603,7 +600,7 @@
|
|||
"com_ui_hide_qr": "Nascondi codice QR",
|
||||
"com_ui_host": "Host",
|
||||
"com_ui_image_gen": "Generazione immagine",
|
||||
"com_ui_import_conversation": "Importa",
|
||||
"com_ui_import": "Importa",
|
||||
"com_ui_import_conversation_error": "Si è verificato un errore durante l'importazione delle conversazioni",
|
||||
"com_ui_import_conversation_file_type_error": "Tipo di importazione non supportato",
|
||||
"com_ui_import_conversation_info": "Importa conversazioni da un file JSON",
|
||||
|
@ -747,4 +744,4 @@
|
|||
"com_ui_zoom": "Zoom",
|
||||
"com_user_message": "Mostra nome utente nei messaggi",
|
||||
"com_warning_resubmit_unsupported": "Il reinvio del messaggio AI non è supportato per questo endpoint."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -178,7 +178,6 @@
|
|||
"com_endpoint_google_temp": "大きい値 = ランダム性が増します。低い値 = より決定論的になります。この値を変更するか、Top P の変更をおすすめしますが、両方を変更はおすすめしません。",
|
||||
"com_endpoint_google_topk": "Top-k はモデルがトークンをどのように選択して出力するかを変更します。top-kが1の場合はモデルの語彙に含まれるすべてのトークンの中で最も確率が高い1つが選択されます(greedy decodingと呼ばれている)。top-kが3の場合は上位3つのトークンの中から選択されます。(temperatureを使用)",
|
||||
"com_endpoint_google_topp": "Top-p はモデルがトークンをどのように選択して出力するかを変更します。K(topKを参照)の確率の合計がtop-pの確率と等しくなるまでのトークンが選択されます。",
|
||||
"com_endpoint_import": "インポート",
|
||||
"com_endpoint_instructions_assistants": "指示をオーバーライドする",
|
||||
"com_endpoint_instructions_assistants_placeholder": "アシスタントの指示を上書きします。これは、実行ごとに動作を変更する場合に便利です。",
|
||||
"com_endpoint_max_output_tokens": "最大出力トークン数",
|
||||
|
@ -262,7 +261,6 @@
|
|||
"com_nav_archive_name": "名前",
|
||||
"com_nav_archived_chats": "アーカイブされたチャット",
|
||||
"com_nav_archived_chats_empty": "アーカイブされたチャットはありません",
|
||||
"com_nav_archived_chats_manage": "管理",
|
||||
"com_nav_at_command": "@-Command",
|
||||
"com_nav_at_command_description": "コマンド\"@\"でエンドポイント、モデル、プリセットを切り替える",
|
||||
"com_nav_audio_play_error": "オーディオの再生エラー: {{0}}",
|
||||
|
@ -383,7 +381,6 @@
|
|||
"com_nav_setting_speech": "スピーチ",
|
||||
"com_nav_settings": "設定",
|
||||
"com_nav_shared_links": "共有リンク",
|
||||
"com_nav_shared_links_manage": "管理",
|
||||
"com_nav_show_code": "Code Interpreter を使用する際は常にコードを表示する",
|
||||
"com_nav_slash_command": "/-Command",
|
||||
"com_nav_slash_command_description": "コマンド\"/\"でキーボードでプロンプトを選択する",
|
||||
|
@ -587,7 +584,7 @@
|
|||
"com_ui_happy_birthday": "初めての誕生日です!",
|
||||
"com_ui_host": "ホスト",
|
||||
"com_ui_image_gen": "画像生成",
|
||||
"com_ui_import_conversation": "インポート",
|
||||
"com_ui_import": "インポート",
|
||||
"com_ui_import_conversation_error": "会話のインポート時にエラーが発生しました",
|
||||
"com_ui_import_conversation_file_type_error": "サポートされていないインポート形式です",
|
||||
"com_ui_import_conversation_info": "JSONファイルから会話をインポートする",
|
||||
|
@ -719,4 +716,4 @@
|
|||
"com_ui_zoom": "ズーム",
|
||||
"com_user_message": "あなた",
|
||||
"com_warning_resubmit_unsupported": "このエンドポイントではAIメッセージの再送信はサポートされていません"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -178,7 +178,6 @@
|
|||
"com_endpoint_google_temp": "높은 값 = 더 무작위, 낮은 값 = 더 집중적이고 결정적입니다. 이 값을 변경하거나 Top P 중 하나만 변경하는 것을 권장합니다.",
|
||||
"com_endpoint_google_topk": "Top-k는 모델이 출력에 사용할 토큰을 선택하는 방식을 변경합니다. top-k가 1인 경우 모델의 어휘 중 가장 확률이 높은 토큰이 선택됩니다(greedy decoding). top-k가 3인 경우 다음 토큰은 가장 확률이 높은 3개의 토큰 중에서 선택됩니다(temperature 사용).",
|
||||
"com_endpoint_google_topp": "Top-p는 모델이 출력에 사용할 토큰을 선택하는 방식을 변경합니다. 토큰은 가장 높은 확률부터 가장 낮은 확률까지 선택됩니다. 선택된 토큰의 확률의 합이 top-p 값과 같아질 때까지 선택됩니다.",
|
||||
"com_endpoint_import": "가져오기",
|
||||
"com_endpoint_instructions_assistants": "에이전트 지침 재정의",
|
||||
"com_endpoint_instructions_assistants_placeholder": "어시스턴트의 지침을 재정의합니다. 이를 통해 실행마다 동작을 수정할 수 있습니다.",
|
||||
"com_endpoint_max_output_tokens": "최대 출력 토큰 수",
|
||||
|
@ -262,7 +261,6 @@
|
|||
"com_nav_archive_name": "이름",
|
||||
"com_nav_archived_chats": "아카이브된 채팅",
|
||||
"com_nav_archived_chats_empty": "아카이브된 채팅이 없습니다",
|
||||
"com_nav_archived_chats_manage": "관리",
|
||||
"com_nav_at_command": "@ 명령어",
|
||||
"com_nav_at_command_description": "엔드포인트, 모델, 프리셋 등을 전환하는 \"@\" 명령어 토글",
|
||||
"com_nav_audio_play_error": "오디오 재생 오류: {{0}}",
|
||||
|
@ -383,7 +381,6 @@
|
|||
"com_nav_setting_speech": "음성",
|
||||
"com_nav_settings": "설정",
|
||||
"com_nav_shared_links": "공유 링크",
|
||||
"com_nav_shared_links_manage": "관리",
|
||||
"com_nav_show_code": "코드 인터프리터 사용 시 항상 코드 표시",
|
||||
"com_nav_slash_command": "슬래시 명령어",
|
||||
"com_nav_slash_command_description": "키보드로 프롬프트를 선택하려면 \"/\" 명령어 토글",
|
||||
|
@ -587,7 +584,7 @@
|
|||
"com_ui_happy_birthday": "내 첫 생일이야!",
|
||||
"com_ui_host": "호스트",
|
||||
"com_ui_image_gen": "이미지 생성",
|
||||
"com_ui_import_conversation": "가져오기",
|
||||
"com_ui_import": "가져오기",
|
||||
"com_ui_import_conversation_error": "대화를 가져오는 동안 오류가 발생했습니다",
|
||||
"com_ui_import_conversation_file_type_error": "가져올 수 없는 파일 형식입니다",
|
||||
"com_ui_import_conversation_info": "JSON 파일에서 대화 가져오기",
|
||||
|
@ -719,4 +716,4 @@
|
|||
"com_ui_zoom": "확대/축소",
|
||||
"com_user_message": "당신",
|
||||
"com_warning_resubmit_unsupported": "이 엔드포인트에서는 AI 메시지 재전송이 지원되지 않습니다"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -93,7 +93,6 @@
|
|||
"com_endpoint_google_temp": "Hogere waarden = meer willekeurig, terwijl lagere waarden = meer gericht en deterministisch. We raden aan dit of Top P te wijzigen, maar niet beide.",
|
||||
"com_endpoint_google_topk": "Top-k verandert hoe het model tokens selecteert voor uitvoer. Een top-k van 1 betekent dat het geselecteerde token het meest waarschijnlijk is van alle tokens in de vocabulaire van het model (ook wel 'greedy decoding' genoemd), terwijl een top-k van 3 betekent dat het volgende token wordt geselecteerd uit de 3 meest waarschijnlijke tokens (met behulp van temperatuur).",
|
||||
"com_endpoint_google_topp": "Top-p verandert hoe het model tokens selecteert voor uitvoer. Tokens worden geselecteerd van meest K (zie topK-parameter) waarschijnlijk tot minst waarschijnlijk totdat de som van hun kansen gelijk is aan de top-p-waarde.",
|
||||
"com_endpoint_import": "Importeren",
|
||||
"com_endpoint_max_output_tokens": "Max. uitvoertokens",
|
||||
"com_endpoint_my_preset": "Mijn voorinstelling",
|
||||
"com_endpoint_no_presets": "Nog geen voorinstellingen, gebruik de instellingenknop om er een te maken",
|
||||
|
@ -127,7 +126,6 @@
|
|||
"com_nav_archive_name": "Naam",
|
||||
"com_nav_archived_chats": "Gearchiveerde chats",
|
||||
"com_nav_archived_chats_empty": "Geen gearchiveerde chats",
|
||||
"com_nav_archived_chats_manage": "Beheren",
|
||||
"com_nav_auto_scroll": "Automatisch scrollen naar Nieuwste bij openen",
|
||||
"com_nav_balance": "Evenwicht",
|
||||
"com_nav_clear_all_chats": "Alle chats wissen",
|
||||
|
@ -182,7 +180,6 @@
|
|||
"com_nav_setting_general": "Algemeen",
|
||||
"com_nav_settings": "Instellingen",
|
||||
"com_nav_shared_links": "Gedeelde links",
|
||||
"com_nav_shared_links_manage": "Beheren",
|
||||
"com_nav_theme": "Thema",
|
||||
"com_nav_theme_dark": "Donker",
|
||||
"com_nav_theme_light": "Licht",
|
||||
|
@ -222,7 +219,7 @@
|
|||
"com_ui_enter": "Invoeren",
|
||||
"com_ui_examples": "Voorbeelden",
|
||||
"com_ui_happy_birthday": "Het is mijn eerste verjaardag!",
|
||||
"com_ui_import_conversation": "Importeren",
|
||||
"com_ui_import": "Importeren",
|
||||
"com_ui_import_conversation_error": "Er is een fout opgetreden bij het importeren van je gesprekken",
|
||||
"com_ui_import_conversation_info": "Gesprekken importeren vanuit een JSON-bestand",
|
||||
"com_ui_import_conversation_success": "Gesprekken succesvol geïmporteerd",
|
||||
|
@ -253,4 +250,4 @@
|
|||
"com_ui_unarchive_error": "Kan conversatie niet uit archiveren",
|
||||
"com_ui_upload_success": "Bestand succesvol geüpload",
|
||||
"com_ui_use_prompt": "Gebruik prompt"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -161,7 +161,6 @@
|
|||
"com_endpoint_google_temp": "Wyższe wartości oznaczają większą losowość, natomiast niższe wartości prowadzą do bardziej skoncentrowanych i deterministycznych wyników. Zalecamy dostosowanie tej wartości lub Top P, ale nie obu jednocześnie.",
|
||||
"com_endpoint_google_topk": "Top-k wpływa na sposób, w jaki model wybiera tokeny do wygenerowania odpowiedzi. Top-k 1 oznacza, że wybrany token jest najbardziej prawdopodobny spośród wszystkich tokenów w słowniku modelu (nazywane też dekodowaniem zachłannym), podczas gdy top-k 3 oznacza, że następny token jest wybierany spośród 3 najbardziej prawdopodobnych tokenów (z uwzględnieniem temperatury).",
|
||||
"com_endpoint_google_topp": "Top-p wpływa na sposób, w jaki model wybiera tokeny do wygenerowania odpowiedzi. Tokeny są wybierane od najbardziej prawdopodobnych do najmniej, aż suma ich prawdopodobieństw osiągnie wartość top-p.",
|
||||
"com_endpoint_import": "Importuj",
|
||||
"com_endpoint_instructions_assistants": "Nadpisz instrukcje",
|
||||
"com_endpoint_max_output_tokens": "Maksymalna liczba tokenów wyjściowych",
|
||||
"com_endpoint_message": "Wiadomość",
|
||||
|
@ -238,7 +237,6 @@
|
|||
"com_nav_archive_name": "Nazwa",
|
||||
"com_nav_archived_chats": "Zarchiwizowane rozmowy",
|
||||
"com_nav_archived_chats_empty": "Nie masz żadnych zarchiwizowanych rozmów.",
|
||||
"com_nav_archived_chats_manage": "Zarządzaj",
|
||||
"com_nav_at_command": "Polecenie @",
|
||||
"com_nav_at_command_description": "Przełącz polecenie \"@\" do przełączania punktów końcowych, modeli, presetów, itp.",
|
||||
"com_nav_audio_play_error": "Błąd odtwarzania audio: {0}",
|
||||
|
@ -355,7 +353,6 @@
|
|||
"com_nav_setting_speech": "Mowa",
|
||||
"com_nav_settings": "Ustawienia",
|
||||
"com_nav_shared_links": "Linki udostępnione",
|
||||
"com_nav_shared_links_manage": "Beheren",
|
||||
"com_nav_show_code": "Zawsze pokazuj kod podczas używania interpretera kodu",
|
||||
"com_nav_show_thinking": "Domyślnie otwieraj rozwijane menu myślenia",
|
||||
"com_nav_slash_command": "Polecenie /",
|
||||
|
@ -565,7 +562,7 @@
|
|||
"com_ui_hide_qr": "Ukryj kod QR",
|
||||
"com_ui_host": "Host",
|
||||
"com_ui_image_gen": "Generowanie obrazu",
|
||||
"com_ui_import_conversation": "Importuj",
|
||||
"com_ui_import": "Importuj",
|
||||
"com_ui_import_conversation_error": "Wystąpił błąd podczas importowania konwersacji",
|
||||
"com_ui_import_conversation_file_type_error": "Nieobsługiwany typ importu",
|
||||
"com_ui_import_conversation_info": "Importuj konwersacje z pliku JSON",
|
||||
|
@ -707,4 +704,4 @@
|
|||
"com_ui_zoom": "Powiększ",
|
||||
"com_user_message": "Ty",
|
||||
"com_warning_resubmit_unsupported": "Ponowne przesyłanie wiadomości AI nie jest obsługiwane dla tego punktu końcowego."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -178,7 +178,6 @@
|
|||
"com_endpoint_google_temp": "Более высокие значения = более случайные результаты, более низкие значения = более фокусированные и детерминированные результаты. Мы рекомендуем изменять это или Top P, но не оба значения одновременно.",
|
||||
"com_endpoint_google_topk": "Top-k изменяет то, как модель выбирает токены для вывода. Top-k равное 1 означает, что выбирается наиболее вероятный токен из всего словаря модели (так называемое жадное декодирование), а Top-k равное 3 означает, что следующий токен выбирается из трех наиболее вероятных токенов (с использованием температуры).",
|
||||
"com_endpoint_google_topp": "Top-p изменяет то, как модель выбирает токены для вывода. Токены выбираются из наиболее вероятных K (см. параметр topK) до наименее вероятных, пока сумма их вероятностей не достигнет значения top-p.",
|
||||
"com_endpoint_import": "Импорт",
|
||||
"com_endpoint_instructions_assistants": "Инструкции для ассистентов",
|
||||
"com_endpoint_instructions_assistants_placeholder": "Переопределяет инструкции для ассистента. Это полезно для изменения поведения для отдельного запуска.",
|
||||
"com_endpoint_max_output_tokens": "Максимальное количество выводимых токенов",
|
||||
|
@ -262,7 +261,6 @@
|
|||
"com_nav_archive_name": "Имя",
|
||||
"com_nav_archived_chats": "Архивированные чаты",
|
||||
"com_nav_archived_chats_empty": "У вас нет архивированных чатов.",
|
||||
"com_nav_archived_chats_manage": "Управление",
|
||||
"com_nav_at_command": "@-команда",
|
||||
"com_nav_at_command_description": "Переключение команды \"@\" для выбора эндпоинтов, моделей, пресетов и др.",
|
||||
"com_nav_audio_play_error": "Ошибка воспроизведения аудио: {{0}}",
|
||||
|
@ -383,7 +381,6 @@
|
|||
"com_nav_setting_speech": "Голос",
|
||||
"com_nav_settings": "Настройки",
|
||||
"com_nav_shared_links": "Связываемые ссылки",
|
||||
"com_nav_shared_links_manage": "Управление",
|
||||
"com_nav_show_code": "Всегда показывать код при использовании интерпретатора",
|
||||
"com_nav_slash_command": "/-Команда",
|
||||
"com_nav_slash_command_description": "Вызов командной строки клавишей '/' для выбора промта с клавиатуры",
|
||||
|
@ -587,7 +584,7 @@
|
|||
"com_ui_happy_birthday": "Это мой первый день рождения!",
|
||||
"com_ui_host": "Хост",
|
||||
"com_ui_image_gen": "Генератор изображений",
|
||||
"com_ui_import_conversation": "Импортировать",
|
||||
"com_ui_import": "Импортировать",
|
||||
"com_ui_import_conversation_error": "При импорте бесед произошла ошибка",
|
||||
"com_ui_import_conversation_file_type_error": "Неподдерживаемый тип импорта",
|
||||
"com_ui_import_conversation_info": "Импортировать беседы из файла JSON",
|
||||
|
@ -719,4 +716,4 @@
|
|||
"com_ui_zoom": "Масштаб",
|
||||
"com_user_message": "Вы",
|
||||
"com_warning_resubmit_unsupported": "Повторная отправка сообщения ИИ не поддерживается для данной конечной точки"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -82,7 +82,6 @@
|
|||
"com_endpoint_google_temp": "Högre värden = mer slumpmässigt, medan lägre värden = mer fokuserat och bestämt. Vi rekommenderar att ändra detta eller Top P men inte båda.",
|
||||
"com_endpoint_google_topk": "Top-k ändrar hur modellen väljer tokens för utdata. Ett top-k av 1 innebär att den valda token är den mest sannolika bland alla tokens i modellens vokabulär (kallas också girig avkodning), medan ett top-k av 3 innebär att nästa token väljs bland de 3 mest sannolika tokens (med temperatur).",
|
||||
"com_endpoint_google_topp": "Top-p ändrar hur modellen väljer tokens för utdata. Tokens väljs från de mest K (se topK-parameter) sannolika till de minst tills summan av deras sannolikheter når top-p-värdet.",
|
||||
"com_endpoint_import": "Importera",
|
||||
"com_endpoint_max_output_tokens": "Max utdatatokens",
|
||||
"com_endpoint_my_preset": "Min förinställning",
|
||||
"com_endpoint_no_presets": "Ingen förinställning ännu",
|
||||
|
@ -115,7 +114,6 @@
|
|||
"com_nav_archive_name": "Namn",
|
||||
"com_nav_archived_chats": "Arkiverade chattar",
|
||||
"com_nav_archived_chats_empty": "Du har inga arkiverade chattar.",
|
||||
"com_nav_archived_chats_manage": "Hantera",
|
||||
"com_nav_balance": "Balans",
|
||||
"com_nav_clear_all_chats": "Rensa alla chattar",
|
||||
"com_nav_clear_conversation": "Rensa konversationer",
|
||||
|
@ -169,7 +167,6 @@
|
|||
"com_nav_setting_general": "Allmänt",
|
||||
"com_nav_settings": "Inställningar",
|
||||
"com_nav_shared_links": "Delade länkar",
|
||||
"com_nav_shared_links_manage": "Hantera",
|
||||
"com_nav_theme": "Tema",
|
||||
"com_nav_theme_dark": "Mörkt",
|
||||
"com_nav_theme_light": "Ljust",
|
||||
|
@ -209,7 +206,7 @@
|
|||
"com_ui_enter": "Ange",
|
||||
"com_ui_examples": "Exempel",
|
||||
"com_ui_happy_birthday": "Det är min första födelsedag!",
|
||||
"com_ui_import_conversation": "Importera",
|
||||
"com_ui_import": "Importera",
|
||||
"com_ui_import_conversation_error": "Det uppstod ett fel vid import av dina konversationer",
|
||||
"com_ui_import_conversation_info": "Importera konversationer från en JSON-fil",
|
||||
"com_ui_import_conversation_success": "Konversationer har importerats framgångsrikt",
|
||||
|
@ -239,4 +236,4 @@
|
|||
"com_ui_unarchive_error": "Kunde inte avarkivera chatt",
|
||||
"com_ui_upload_success": "Uppladdningen av filen lyckades",
|
||||
"com_ui_use_prompt": "Använd prompt"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -179,7 +179,6 @@
|
|||
"com_endpoint_google_temp": "Yüksek değerler = daha rastgele, düşük değerler = daha odaklı ve belirleyici. Bu parametre ile Olasılık Kütüphanesini değiştirmeyi öneririz (ikisini birden değiştirmemek).",
|
||||
"com_endpoint_google_topk": "Top-k, modelin çıktı için token seçimini nasıl yaptığını değiştirir. 1 olan bir top-k, modelin kelime haznesindeki en olası tokenin seçildiği (açgözlü kod çözme olarak da adlandırılır) anlamına gelirken, 3 olan bir top-k, bir sonraki tokenin en olası üç token arasından (sıcaklık kullanılarak) seçileceği anlamına gelir.",
|
||||
"com_endpoint_google_topp": "Olasılık Kütüphanesi, modelin çıktı için token seçme şeklini değiştirir. Tokenler, en olasılıktan (bkz. topK parametresi) en az olasıya kadar seçilir ve olasılıkları toplamı, top-p değerine eşit olana kadar devam eder.",
|
||||
"com_endpoint_import": "İthal et",
|
||||
"com_endpoint_instructions_assistants": "Talimatları Geçersiz Kıl",
|
||||
"com_endpoint_instructions_assistants_placeholder": "Asistanın talimatlarını geçersiz kılar. Bu, davranışı tek tek çalışma bazında değiştirmek için yararlıdır.",
|
||||
"com_endpoint_max_output_tokens": "Maksimum Çıktı Tokenleri",
|
||||
|
@ -265,7 +264,6 @@
|
|||
"com_nav_archive_name": "Ad",
|
||||
"com_nav_archived_chats": "Arşivlenmiş sohbetler",
|
||||
"com_nav_archived_chats_empty": "Arşivlenmiş konuşmanız yok.",
|
||||
"com_nav_archived_chats_manage": "Yönet",
|
||||
"com_nav_at_command": "@-Komutu",
|
||||
"com_nav_at_command_description": "Uç noktaları, modelleri, ön ayarları vb. değiştirmek için \"@\" komutunu aç/kapat",
|
||||
"com_nav_audio_play_error": "Ses oynatma hatası: {{0}}",
|
||||
|
@ -388,7 +386,6 @@
|
|||
"com_nav_setting_speech": "Konuşma",
|
||||
"com_nav_settings": "Ayarlar",
|
||||
"com_nav_shared_links": "Paylaşılan bağlantılar",
|
||||
"com_nav_shared_links_manage": "Yönet",
|
||||
"com_nav_show_code": "Kod yorumlayıcı kullanırken her zaman kodu göster",
|
||||
"com_nav_show_thinking": "Düşünme Açılır Menülerini Varsayılan Olarak Aç",
|
||||
"com_nav_slash_command": "/-Komutu",
|
||||
|
@ -605,7 +602,7 @@
|
|||
"com_ui_hide_qr": "QR Kodunu Gizle",
|
||||
"com_ui_host": "Host",
|
||||
"com_ui_image_gen": "Görüntü Oluştur",
|
||||
"com_ui_import_conversation": "İçe Aktar",
|
||||
"com_ui_import": "İçe Aktar",
|
||||
"com_ui_import_conversation_error": "Konuşmalarınızı içe aktarma sırasında bir hata oluştu",
|
||||
"com_ui_import_conversation_file_type_error": "Desteklenmeyen içe aktarma türü",
|
||||
"com_ui_import_conversation_info": "JSON dosyasından konuşmaları içe aktar",
|
||||
|
@ -750,4 +747,4 @@
|
|||
"com_ui_zoom": "Yakınlaştır",
|
||||
"com_user_message": "Sen",
|
||||
"com_warning_resubmit_unsupported": "Bu uç nokta için yapay zeka mesajını yeniden gönderme desteklenmiyor."
|
||||
}
|
||||
}
|
||||
|
|
|
@ -84,7 +84,6 @@
|
|||
"com_endpoint_google_temp": "Giá trị cao = ngẫu nhiên hơn, trong khi giá trị thấp = tập trung và xác định hơn. Chúng tôi khuyến nghị thay đổi giá trị này hoặc Top P nhưng không phải cả hai.",
|
||||
"com_endpoint_google_topk": "Top-k thay đổi cách mô hình chọn mã thông báo để xuất. Top-k là 1 có nghĩa là mã thông báo được chọn là phổ biến nhất trong tất cả các mã thông báo trong bảng từ vựng của mô hình (còn được gọi là giải mã tham lam), trong khi top-k là 3 có nghĩa là mã thông báo tiếp theo được chọn từ giữa 3 mã thông báo phổ biến nhất (sử dụng nhiệt độ).",
|
||||
"com_endpoint_google_topp": "Top-p thay đổi cách mô hình chọn mã thông báo để xuất. Mã thông báo được chọn từ căn cứ có xác suất cao nhất đến thấp nhất cho đến khi tổng xác suất của chúng bằng giá trị top-p.",
|
||||
"com_endpoint_import": "Nhập",
|
||||
"com_endpoint_max_output_tokens": "Số mã thông báo tối đa",
|
||||
"com_endpoint_my_preset": "Đặt sẵn của tôi",
|
||||
"com_endpoint_no_presets": "Chưa có đặt sẵn",
|
||||
|
@ -167,7 +166,6 @@
|
|||
"com_nav_setting_general": "Chung",
|
||||
"com_nav_settings": "Cài đặt",
|
||||
"com_nav_shared_links": "Liên kết được chia sẻ",
|
||||
"com_nav_shared_links_manage": "Quản l",
|
||||
"com_nav_theme": "Chủ đề",
|
||||
"com_nav_theme_dark": "Tối",
|
||||
"com_nav_theme_light": "Sáng",
|
||||
|
@ -207,7 +205,7 @@
|
|||
"com_ui_enter": "Nhập",
|
||||
"com_ui_examples": "Ví dụ",
|
||||
"com_ui_happy_birthday": "Đây là sinh nhật đầu tiên của tôi!",
|
||||
"com_ui_import_conversation": "Nhập khẩu",
|
||||
"com_ui_import": "Nhập khẩu",
|
||||
"com_ui_import_conversation_error": "Đã xảy ra lỗi khi nhập khẩu cuộc trò chuyện của bạn",
|
||||
"com_ui_import_conversation_info": "Nhập khẩu cuộc trò chuyện từ một tệp JSON",
|
||||
"com_ui_import_conversation_success": "Đã nhập khẩu cuộc trò chuyện thành công",
|
||||
|
@ -237,4 +235,4 @@
|
|||
"com_ui_unarchive_error": "Không thể bỏ lưu trữ cuộc trò chuyện",
|
||||
"com_ui_upload_success": "Tải tệp thành công",
|
||||
"com_ui_use_prompt": "Sử dụng gợi ý"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -178,7 +178,6 @@
|
|||
"com_endpoint_google_temp": "較高的值表示更隨機,而較低的值表示更集中和確定。我們建議修改這個或 Top P,但不建議兩者都修改。",
|
||||
"com_endpoint_google_topk": "Top-k 調整模型如何選取輸出的 token。當 Top-k 設為 1 時,模型會選取在其詞彙庫中機率最高的 token 進行輸出(這也被稱為貪婪解碼)。相對地,當 Top-k 設為 3 時,模型會從機率最高的三個 token 中選取下一個輸出 token(這會涉及到所謂的「溫度」調整)",
|
||||
"com_endpoint_google_topp": "Top-p 調整模型在輸出 token 時的選擇機制。從最可能的 K(見 topK 參數)開始選擇 token,直到它們的機率之和達到 top-p 值。",
|
||||
"com_endpoint_import": "匯入",
|
||||
"com_endpoint_instructions_assistants": "覆寫提示指令",
|
||||
"com_endpoint_instructions_assistants_placeholder": "覆寫助理的提示指令。這對於在每次執行時修改行為很有用。",
|
||||
"com_endpoint_max_output_tokens": "最大輸出 token 數",
|
||||
|
@ -262,7 +261,6 @@
|
|||
"com_nav_archive_name": "名稱",
|
||||
"com_nav_archived_chats": "封存的對話",
|
||||
"com_nav_archived_chats_empty": "您沒有任何封存的對話。",
|
||||
"com_nav_archived_chats_manage": "管理",
|
||||
"com_nav_at_command": "@-指令",
|
||||
"com_nav_at_command_description": "使用「@」指令切換端點、模型和預設值等",
|
||||
"com_nav_audio_play_error": "播放音訊時發生錯誤:{{0}}",
|
||||
|
@ -383,7 +381,6 @@
|
|||
"com_nav_setting_speech": "語音",
|
||||
"com_nav_settings": "設定",
|
||||
"com_nav_shared_links": "共享連結",
|
||||
"com_nav_shared_links_manage": "管理",
|
||||
"com_nav_show_code": "一律顯示使用程式碼解譯器時的程式碼",
|
||||
"com_nav_slash_command": "/指令",
|
||||
"com_nav_slash_command_description": "使用鍵盤按下 \"/\" 快速選擇提示詞",
|
||||
|
@ -587,7 +584,7 @@
|
|||
"com_ui_happy_birthday": "這是我的第一個生日!",
|
||||
"com_ui_host": "主機",
|
||||
"com_ui_image_gen": "影像生成",
|
||||
"com_ui_import_conversation": "匯入",
|
||||
"com_ui_import": "匯入",
|
||||
"com_ui_import_conversation_error": "匯入對話時發生錯誤",
|
||||
"com_ui_import_conversation_file_type_error": "不支援的匯入檔案類型",
|
||||
"com_ui_import_conversation_info": "從 JSON 文件匯入對話",
|
||||
|
@ -719,4 +716,4 @@
|
|||
"com_ui_zoom": "縮放",
|
||||
"com_user_message": "您",
|
||||
"com_warning_resubmit_unsupported": "此端點不支援重新送出 AI 訊息。"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ const headerMap: Record<string, TranslationKeys> = {
|
|||
'/register': 'com_auth_create_account',
|
||||
'/forgot-password': 'com_auth_reset_password',
|
||||
'/reset-password': 'com_auth_reset_password',
|
||||
'/login/2fa': 'com_auth_verify_your_identity',
|
||||
};
|
||||
|
||||
export default function StartupLayout({ isAuthenticated }: { isAuthenticated?: boolean }) {
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
ResetPassword,
|
||||
VerifyEmail,
|
||||
ApiErrorWatcher,
|
||||
TwoFactorScreen,
|
||||
} from '~/components/Auth';
|
||||
import { AuthContextProvider } from '~/hooks/AuthContext';
|
||||
import RouteErrorBoundary from './RouteErrorBoundary';
|
||||
|
@ -66,6 +67,10 @@ export const router = createBrowserRouter([
|
|||
path: 'login',
|
||||
element: <Login />,
|
||||
},
|
||||
{
|
||||
path: 'login/2fa',
|
||||
element: <TwoFactorScreen />,
|
||||
},
|
||||
],
|
||||
},
|
||||
dashboardRoutes,
|
||||
|
|
107
package-lock.json
generated
107
package-lock.json
generated
|
@ -1053,6 +1053,7 @@
|
|||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.0",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
|
@ -1074,6 +1075,7 @@
|
|||
"html-to-image": "^1.11.11",
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.3",
|
||||
"input-otp": "^1.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"librechat-data-provider": "*",
|
||||
"lodash": "^4.17.21",
|
||||
|
@ -13744,6 +13746,101 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.2.tgz",
|
||||
"integrity": "sha512-u1IgJFQ4zNAUTjGdDL5dcl/U8ntOR6jsnhxKb5RKp5Ozwl88xKR9EqRZOe/Mk8tnx0x5tNUe2F+MzsyjqMg0MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
|
||||
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
|
||||
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.1.3.tgz",
|
||||
|
@ -23618,6 +23715,16 @@
|
|||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.3.tgz",
|
||||
"integrity": "sha512-qlD8YNDqyTKTyuITrDOffsl6Tdhv+UC4hcdAVuQsK4IMQ99nSgd1MIA/Q+jQYoh9r3hVUXhYh7urSRmXPkW04g=="
|
||||
},
|
||||
"node_modules/input-otp": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
|
||||
"integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==",
|
||||
"license": "MIT",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/internal-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
|
||||
|
|
|
@ -237,3 +237,11 @@ export const addTagToConversation = (conversationId: string) =>
|
|||
export const userTerms = () => '/api/user/terms';
|
||||
export const acceptUserTerms = () => '/api/user/terms/accept';
|
||||
export const banner = () => '/api/banner';
|
||||
|
||||
// Two-Factor Endpoints
|
||||
export const enableTwoFactor = () => '/api/auth/2fa/enable';
|
||||
export const verifyTwoFactor = () => '/api/auth/2fa/verify';
|
||||
export const confirmTwoFactor = () => '/api/auth/2fa/confirm';
|
||||
export const disableTwoFactor = () => '/api/auth/2fa/disable';
|
||||
export const regenerateBackupCodes = () => '/api/auth/2fa/backup/regenerate';
|
||||
export const verifyTwoFactorTemp = () => '/api/auth/2fa/verify-temp';
|
|
@ -774,3 +774,33 @@ export function acceptTerms(): Promise<t.TAcceptTermsResponse> {
|
|||
export function getBanner(): Promise<t.TBannerResponse> {
|
||||
return request.get(endpoints.banner());
|
||||
}
|
||||
|
||||
export function enableTwoFactor(): Promise<t.TEnable2FAResponse> {
|
||||
return request.get(endpoints.enableTwoFactor());
|
||||
}
|
||||
|
||||
export function verifyTwoFactor(
|
||||
payload: t.TVerify2FARequest,
|
||||
): Promise<t.TVerify2FAResponse> {
|
||||
return request.post(endpoints.verifyTwoFactor(), payload);
|
||||
}
|
||||
|
||||
export function confirmTwoFactor(
|
||||
payload: t.TVerify2FARequest,
|
||||
): Promise<t.TVerify2FAResponse> {
|
||||
return request.post(endpoints.confirmTwoFactor(), payload);
|
||||
}
|
||||
|
||||
export function disableTwoFactor(): Promise<t.TDisable2FAResponse> {
|
||||
return request.post(endpoints.disableTwoFactor());
|
||||
}
|
||||
|
||||
export function regenerateBackupCodes(): Promise<t.TRegenerateBackupCodesResponse> {
|
||||
return request.post(endpoints.regenerateBackupCodes());
|
||||
}
|
||||
|
||||
export function verifyTwoFactorTemp(
|
||||
payload: t.TVerify2FATempRequest,
|
||||
): Promise<t.TVerify2FATempResponse> {
|
||||
return request.post(endpoints.verifyTwoFactorTemp(), payload);
|
||||
}
|
|
@ -67,4 +67,6 @@ export enum MutationKeys {
|
|||
deleteAgentAction = 'deleteAgentAction',
|
||||
deleteUser = 'deleteUser',
|
||||
updateRole = 'updateRole',
|
||||
enableTwoFactor = 'enableTwoFactor',
|
||||
verifyTwoFactor = 'verifyTwoFactor',
|
||||
}
|
||||
|
|
|
@ -91,6 +91,9 @@ axios.interceptors.response.use(
|
|||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (originalRequest.url?.includes('/api/auth/2fa') === true) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
if (originalRequest.url?.includes('/api/auth/logout') === true) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
|
|
@ -100,6 +100,12 @@ export type TError = {
|
|||
};
|
||||
};
|
||||
|
||||
export type TBackupCode = {
|
||||
codeHash: string;
|
||||
used: boolean;
|
||||
usedAt: Date | null;
|
||||
};
|
||||
|
||||
export type TUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
|
@ -109,6 +115,7 @@ export type TUser = {
|
|||
role: string;
|
||||
provider: string;
|
||||
plugins?: string[];
|
||||
backupCodes?: TBackupCode[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
@ -285,11 +292,61 @@ export type TRegisterUser = {
|
|||
export type TLoginUser = {
|
||||
email: string;
|
||||
password: string;
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
};
|
||||
|
||||
export type TLoginResponse = {
|
||||
token: string;
|
||||
user: TUser;
|
||||
token?: string;
|
||||
user?: TUser;
|
||||
twoFAPending?: boolean;
|
||||
tempToken?: string;
|
||||
};
|
||||
|
||||
export type TEnable2FAResponse = {
|
||||
otpauthUrl: string;
|
||||
backupCodes: string[];
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type TVerify2FARequest = {
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
};
|
||||
|
||||
export type TVerify2FAResponse = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* For verifying 2FA during login with a temporary token.
|
||||
*/
|
||||
export type TVerify2FATempRequest = {
|
||||
tempToken: string;
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
};
|
||||
|
||||
export type TVerify2FATempResponse = {
|
||||
token?: string;
|
||||
user?: TUser;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response from disabling 2FA.
|
||||
*/
|
||||
export type TDisable2FAResponse = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response from regenerating backup codes.
|
||||
*/
|
||||
export type TRegenerateBackupCodesResponse = {
|
||||
message: string;
|
||||
backupCodes: string[];
|
||||
backupCodesHash: string[];
|
||||
};
|
||||
|
||||
export type TRequestPasswordReset = {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue