Completely remove PR 303

This commit is contained in:
LukeGus
2025-09-27 01:15:18 -05:00
parent dfbaf0d2f1
commit d2ba934f61
5 changed files with 27 additions and 995 deletions

View File

@@ -21,7 +21,6 @@ import { authLogger, apiLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { UserCrypto } from "../../utils/user-crypto.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { ZeroTrustMigration } from "../../utils/zero-trust-migration.js";
// Get auth manager instance
const authManager = AuthManager.getInstance();
@@ -1290,79 +1289,16 @@ router.post("/complete-reset", async (req, res) => {
}
const userId = user[0].id;
// 🔥 CRITICAL FIX: Use recovery DEK to preserve user data
if (user[0].recovery_dek && user[0].backup_encrypted_dek) {
try {
const userCrypto = UserCrypto.getInstance();
// Update password hash
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(newPassword, saltRounds);
// Decrypt DEK using recovery key
const recoveryDEK = Buffer.from(user[0].recovery_dek, 'hex');
const backupEncryptedDEK = JSON.parse(user[0].backup_encrypted_dek);
const originalDEK = (userCrypto as any).decryptDEK(backupEncryptedDEK, recoveryDEK);
await db
.update(users)
.set({ password_hash })
.where(eq(users.username, username));
// Generate new KEK with new password
const newKekSalt = await (userCrypto as any).generateKEKSalt();
const newKEK = (userCrypto as any).deriveKEK(newPassword, newKekSalt);
const newEncryptedDEK = (userCrypto as any).encryptDEK(originalDEK, newKEK);
// Update password hash
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(newPassword, saltRounds);
// Update everything atomically
await (userCrypto as any).storeKEKSalt(userId, newKekSalt);
await (userCrypto as any).storeEncryptedDEK(userId, newEncryptedDEK);
await db
.update(users)
.set({ password_hash })
.where(eq(users.username, username));
// Update recovery data for new password
const newRecoveryDEK = crypto.randomBytes(32);
const newBackupEncryptedDEK = (userCrypto as any).encryptDEK(originalDEK, newRecoveryDEK);
await db
.update(users)
.set({
recovery_dek: newRecoveryDEK.toString('hex'),
backup_encrypted_dek: JSON.stringify(newBackupEncryptedDEK),
})
.where(eq(users.id, userId));
// Clean sensitive data
originalDEK.fill(0);
newKEK.fill(0);
newRecoveryDEK.fill(0);
authLogger.success("Password reset with KEK-DEK re-encryption successful", {
operation: "password_reset_with_kek_dek",
username,
userId,
});
} catch (error) {
authLogger.error("Failed to re-encrypt KEK-DEK during password reset", error, {
operation: "password_reset_kek_dek_failed",
username,
});
return res.status(500).json({
error: "Failed to preserve encrypted data during password reset"
});
}
} else {
// Fallback: No recovery data available - user will lose encrypted data
const saltRounds = parseInt(process.env.SALT || "10", 10);
const password_hash = await bcrypt.hash(newPassword, saltRounds);
await db
.update(users)
.set({ password_hash })
.where(eq(users.username, username));
authLogger.warn("Password reset without KEK-DEK preservation - encrypted data may be lost", {
operation: "password_reset_no_recovery",
username,
userId,
});
}
authLogger.success(`Password successfully reset for user: ${username}`);
db.$client
.prepare("DELETE FROM settings WHERE key = ?")
@@ -1371,7 +1307,6 @@ router.post("/complete-reset", async (req, res) => {
.prepare("DELETE FROM settings WHERE key = ?")
.run(`temp_reset_token_${username}`);
authLogger.success(`Password successfully reset for user: ${username}`);
res.json({ message: "Password has been successfully reset" });
} catch (err) {
authLogger.error("Failed to complete password reset", err);
@@ -1926,8 +1861,6 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
}
});
// Duplicate logout route removed - handled by the main logout route above
// Route: Change user password (re-encrypt data keys)
// POST /users/change-password
router.post("/change-password", authenticateJWT, async (req, res) => {
@@ -1988,408 +1921,4 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
}
});
// Route: Get security status (admin)
// GET /users/security-status
router.get("/security-status", authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0 || !user[0].is_admin) {
return res.status(403).json({ error: "Not authorized" });
}
// Simplified security status for new architecture
const securityStatus = {
initialized: true,
system: { hasSecret: true, isValid: true },
activeSessions: {},
activeSessionCount: 0
};
res.json(securityStatus);
} catch (err) {
authLogger.error("Failed to get security status", err, {
operation: "security_status_error",
userId,
});
res.status(500).json({ error: "Failed to get security status" });
}
});
// ===== Password Recovery API endpoints (UX compromise) =====
// Route: Request recovery code (outputs to Docker logs)
// POST /users/recovery/request
router.post("/recovery/request", async (req, res) => {
const { username } = req.body;
if (!isNonEmptyString(username)) {
return res.status(400).json({ error: "Username is required" });
}
try {
// Check if user exists
const user = await db
.select()
.from(users)
.where(eq(users.username, username));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
// Check if user has recovery data
if (!user[0].recovery_dek) {
return res.status(400).json({
error: "Recovery not available for this user"
});
}
// Generate 6-digit recovery code
const recoveryCode = crypto.randomInt(100000, 1000000).toString();
const expiresAt = Date.now() + 60 * 1000; // 1 minute expiry
// Store recovery code in settings
const key = `recovery_code_${username}`;
const value = JSON.stringify({
code: recoveryCode,
expiresAt,
attempts: 0
});
const existing = await db.select().from(settings).where(eq(settings.key, key));
if (existing.length > 0) {
await db.update(settings).set({ value }).where(eq(settings.key, key));
} else {
await db.insert(settings).values({ key, value });
}
// 🔥 Output to Docker logs for physical access verification
console.log(`[RECOVERY] Code for user '${username}': ${recoveryCode} (expires in 60s)`);
authLogger.info("Recovery code generated", {
operation: "recovery_code_generated",
username,
expiresAt,
});
res.json({
message: "Recovery code sent to system logs",
instruction: "Use 'docker logs termix | grep RECOVERY' to view the code"
});
} catch (error) {
authLogger.error("Failed to generate recovery code", error, {
operation: "recovery_request_failed",
username,
});
res.status(500).json({ error: "Failed to generate recovery code" });
}
});
// Route: Verify recovery code and auto-decrypt user data
// POST /users/recovery/verify
router.post("/recovery/verify", async (req, res) => {
const { username, code } = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(code)) {
return res.status(400).json({
error: "Username and recovery code are required"
});
}
try {
// Get and verify recovery code
const key = `recovery_code_${username}`;
const result = await db.select().from(settings).where(eq(settings.key, key));
if (result.length === 0) {
return res.status(400).json({ error: "No recovery code found" });
}
const codeData = JSON.parse(result[0].value);
const now = Date.now();
// Check expiry
if (now > codeData.expiresAt) {
await db.delete(settings).where(eq(settings.key, key));
return res.status(400).json({ error: "Recovery code has expired" });
}
// Check attempts (prevent brute force)
if (codeData.attempts >= 3) {
await db.delete(settings).where(eq(settings.key, key));
return res.status(400).json({ error: "Too many attempts" });
}
// Verify code
if (codeData.code !== code) {
// Increment attempts
codeData.attempts++;
await db.update(settings)
.set({ value: JSON.stringify(codeData) })
.where(eq(settings.key, key));
return res.status(400).json({ error: "Invalid recovery code" });
}
// Get user data
const user = await db
.select()
.from(users)
.where(eq(users.username, username));
if (!user[0].recovery_dek || !user[0].backup_encrypted_dek) {
return res.status(400).json({ error: "Recovery data not available" });
}
// 🔥 Auto-decrypt using recovery DEK
const userCrypto = UserCrypto.getInstance();
const recoveryDEK = Buffer.from(user[0].recovery_dek, 'hex');
const backupEncryptedDEK = JSON.parse(user[0].backup_encrypted_dek);
try {
// Decrypt original DEK using recovery key
const originalDEK = (userCrypto as any).decryptDEK(backupEncryptedDEK, recoveryDEK);
// Create user session with decrypted DEK
const tempToken = crypto.randomBytes(32).toString('hex');
const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
// Store temporary session
const tempKey = `temp_recovery_session_${username}`;
const tempValue = JSON.stringify({
userId: user[0].id,
tempToken,
expiresAt,
dekHex: originalDEK.toString('hex')
});
const existingTemp = await db.select().from(settings).where(eq(settings.key, tempKey));
if (existingTemp.length > 0) {
await db.update(settings).set({ value: tempValue }).where(eq(settings.key, tempKey));
} else {
await db.insert(settings).values({ key: tempKey, value: tempValue });
}
// Clean up recovery code
await db.delete(settings).where(eq(settings.key, key));
authLogger.success("Recovery verification successful", {
operation: "recovery_verify_success",
username,
userId: user[0].id,
});
res.json({
success: true,
tempToken,
expiresAt,
message: "Recovery successful. You can now login normally.",
});
} catch (decryptError) {
authLogger.error("Failed to decrypt recovery data", decryptError, {
operation: "recovery_decrypt_failed",
username,
});
res.status(500).json({ error: "Failed to decrypt user data" });
}
} catch (error) {
authLogger.error("Recovery verification failed", error, {
operation: "recovery_verify_failed",
username,
});
res.status(500).json({ error: "Recovery verification failed" });
}
});
// Route: Login with recovery session (temporary login after recovery)
// POST /users/recovery/login
router.post("/recovery/login", async (req, res) => {
const { username, tempToken } = req.body;
if (!isNonEmptyString(username) || !isNonEmptyString(tempToken)) {
return res.status(400).json({
error: "Username and temporary token are required"
});
}
try {
// Get temporary session
const tempKey = `temp_recovery_session_${username}`;
const result = await db.select().from(settings).where(eq(settings.key, tempKey));
if (result.length === 0) {
return res.status(400).json({ error: "Invalid recovery session" });
}
const sessionData = JSON.parse(result[0].value);
const now = Date.now();
// Check expiry
if (now > sessionData.expiresAt) {
await db.delete(settings).where(eq(settings.key, tempKey));
return res.status(400).json({ error: "Recovery session has expired" });
}
// Verify token
if (sessionData.tempToken !== tempToken) {
return res.status(400).json({ error: "Invalid temporary token" });
}
// Get user
const user = await db.select().from(users).where(eq(users.id, sessionData.userId));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
// Create DEK session for user
const userCrypto = UserCrypto.getInstance();
const originalDEK = Buffer.from(sessionData.dekHex, 'hex');
// Set user session directly (bypass normal auth)
const sessionExpiry = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
(userCrypto as any).userSessions.set(sessionData.userId, {
dataKey: originalDEK,
lastActivity: Date.now(),
expiresAt: sessionExpiry,
});
// Generate JWT token
const token = await authManager.generateJWTToken(sessionData.userId, {
expiresIn: "24h",
});
// Clean up temporary session
await db.delete(settings).where(eq(settings.key, tempKey));
authLogger.success("Recovery login successful", {
operation: "recovery_login_success",
username,
userId: sessionData.userId,
});
return res
.cookie("jwt", token, authManager.getSecureCookieOptions(req, 24 * 60 * 60 * 1000))
.json({
success: true,
is_admin: !!user[0].is_admin,
username: user[0].username,
message: "Login successful via recovery"
});
} catch (error) {
authLogger.error("Recovery login failed", error, {
operation: "recovery_login_failed",
username,
});
res.status(500).json({ error: "Recovery login failed" });
}
});
// ===== Zero Trust Migration API endpoints =====
// Route: Get user security mode status
// GET /users/security-mode
router.get("/security-mode", authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const migration = ZeroTrustMigration.getInstance();
const status = await migration.getUserSecurityMode(userId);
res.json(status);
} catch (error) {
authLogger.error("Failed to get security mode", error, {
operation: "get_security_mode_failed",
userId,
});
res.status(500).json({ error: "Failed to get security mode" });
}
});
// Route: Migrate user to zero-trust mode
// POST /users/migrate-to-zero-trust
router.post("/migrate-to-zero-trust", authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const migration = ZeroTrustMigration.getInstance();
// Check if user can migrate
const canMigrate = await migration.canMigrateToZeroTrust(userId);
if (!canMigrate) {
return res.status(400).json({
error: "User cannot migrate to zero-trust mode"
});
}
// Perform migration
const recoverySeed = await migration.migrateUserToZeroTrust(userId);
if (!recoverySeed) {
return res.status(500).json({
error: "Migration failed"
});
}
authLogger.success("User migrated to zero-trust mode", {
operation: "zero_trust_migration_success",
userId,
});
res.json({
success: true,
recoverySeed,
message: "Migration successful. Save this recovery seed securely!",
warning: "This is the only time you will see this seed. Store it safely!"
});
} catch (error) {
authLogger.error("Zero-trust migration failed", error, {
operation: "zero_trust_migration_error",
userId,
});
res.status(500).json({ error: "Migration failed" });
}
});
// Route: Zero-trust password recovery (requires user seed)
// POST /users/recovery/zero-trust
router.post("/recovery/zero-trust", async (req, res) => {
const { username, code, userSeed } = req.body;
if (!username || !code || !userSeed) {
return res.status(400).json({
error: "Username, recovery code, and user seed are required"
});
}
try {
const migration = ZeroTrustMigration.getInstance();
const result = await migration.recoverInZeroTrustMode(username, code, userSeed);
if (result.success) {
res.json({
success: true,
tempToken: result.tempToken,
expiresAt: result.expiresAt,
message: "Zero-trust recovery successful"
});
} else {
res.status(400).json({
error: "Recovery failed. Check your code and recovery seed."
});
}
} catch (error) {
authLogger.error("Zero-trust recovery failed", error, {
operation: "zero_trust_recovery_error",
username,
});
res.status(500).json({ error: "Recovery failed" });
}
});
export default router;
export default router;

View File

@@ -79,20 +79,13 @@ class UserCrypto {
const encryptedDEK = this.encryptDEK(DEK, KEK);
await this.storeEncryptedDEK(userId, encryptedDEK);
// 🔴 Add recovery layer (breaks zero-trust for UX)
const recoveryDEK = crypto.randomBytes(UserCrypto.DEK_LENGTH);
const backupEncryptedDEK = this.encryptDEK(DEK, recoveryDEK);
await this.storeRecoveryData(userId, recoveryDEK, backupEncryptedDEK);
// Immediately clean temporary keys
KEK.fill(0);
DEK.fill(0);
recoveryDEK.fill(0);
databaseLogger.success("User encryption setup completed with recovery layer", {
databaseLogger.success("User encryption setup completed", {
operation: "user_crypto_setup",
userId,
recoveryEnabled: true,
});
}
@@ -427,33 +420,6 @@ class UserCrypto {
}
}
/**
* Store recovery data (breaks zero-trust for UX compromise)
*/
private async storeRecoveryData(userId: string, recoveryDEK: Buffer, backupEncryptedDEK: EncryptedDEK): Promise<void> {
try {
await getDb()
.update(users)
.set({
recovery_dek: recoveryDEK.toString('hex'),
backup_encrypted_dek: JSON.stringify(backupEncryptedDEK),
zero_trust_mode: false, // Compromise mode for UX
})
.where(eq(users.id, userId));
databaseLogger.info("Recovery data stored for user", {
operation: "store_recovery_data",
userId,
mode: "compromise",
});
} catch (error) {
databaseLogger.error("Failed to store recovery data", error, {
operation: "store_recovery_data_failed",
userId,
});
throw error;
}
}
}
export { UserCrypto, type KEKSalt, type EncryptedDEK };

View File

@@ -1,264 +0,0 @@
import crypto from "crypto";
import { getDb } from "../database/db/index.js";
import { users, settings } from "../database/db/schema.js";
import { eq } from "drizzle-orm";
import { databaseLogger } from "./logger.js";
import { UserCrypto, type EncryptedDEK } from "./user-crypto.js";
/**
* ZeroTrustMigration - Handle migration between compromise and zero-trust modes
*
* Linus principles:
* - Simple migration path with clear rollback capability
* - User controls the security tradeoff
* - Future-proof architecture for security upgrades
*/
export class ZeroTrustMigration {
private static instance: ZeroTrustMigration;
private constructor() {}
static getInstance(): ZeroTrustMigration {
if (!this.instance) {
this.instance = new ZeroTrustMigration();
}
return this.instance;
}
/**
* Check if user can migrate to zero-trust mode
*/
async canMigrateToZeroTrust(userId: string): Promise<boolean> {
try {
const db = getDb();
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return false;
}
// Must have recovery data and be in compromise mode
return !!(user[0].recovery_dek && !user[0].zero_trust_mode);
} catch (error) {
databaseLogger.error("Failed to check zero-trust migration eligibility", error, {
operation: "zero_trust_check_failed",
userId,
});
return false;
}
}
/**
* Migrate user to zero-trust mode
* Returns recovery seed for user to save
*/
async migrateUserToZeroTrust(userId: string): Promise<string | null> {
try {
const db = getDb();
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
throw new Error("User not found");
}
const userData = user[0];
if (!userData.recovery_dek || !userData.backup_encrypted_dek) {
throw new Error("No recovery data available");
}
if (userData.zero_trust_mode) {
throw new Error("User already in zero-trust mode");
}
// Generate user recovery seed (256-bit)
const userRecoverySeed = crypto.randomBytes(32).toString('hex');
// Get current DEK from plaintext recovery data
const userCrypto = UserCrypto.getInstance();
const recoveryDEK = Buffer.from(userData.recovery_dek, 'hex');
const backupEncryptedDEK = JSON.parse(userData.backup_encrypted_dek);
const originalDEK = (userCrypto as any).decryptDEK(backupEncryptedDEK, recoveryDEK);
// Create user recovery key from seed
const userRecoveryKey = crypto.pbkdf2Sync(
userRecoverySeed,
'zero_trust_recovery',
100000,
32,
'sha256'
);
// Re-encrypt DEK with user recovery key
const newBackupEncryptedDEK = (userCrypto as any).encryptDEK(originalDEK, userRecoveryKey);
// 🔥 Remove plaintext recovery data and enable zero-trust mode
await db
.update(users)
.set({
recovery_dek: null, // Delete plaintext recovery key
backup_encrypted_dek: JSON.stringify(newBackupEncryptedDEK),
zero_trust_mode: true, // Enable zero-trust mode
})
.where(eq(users.id, userId));
databaseLogger.success("User migrated to zero-trust mode", {
operation: "zero_trust_migration_success",
userId,
mode: "zero_trust",
});
return userRecoverySeed;
} catch (error) {
databaseLogger.error("Failed to migrate user to zero-trust", error, {
operation: "zero_trust_migration_failed",
userId,
});
throw error;
}
}
/**
* Zero-trust recovery (requires user recovery seed)
*/
async recoverInZeroTrustMode(
username: string,
code: string,
userSeed: string
): Promise<{ success: boolean; tempToken?: string; expiresAt?: number }> {
try {
const db = getDb();
// Verify recovery code (same mechanism as compromise mode)
const key = `recovery_code_${username}`;
const result = await db.select().from(settings).where(eq(settings.key, key));
if (result.length === 0) {
return { success: false };
}
const codeData = JSON.parse(result[0].value);
const now = Date.now();
// Check expiry and attempts
if (now > codeData.expiresAt || codeData.attempts >= 3) {
await db.delete(settings).where(eq(settings.key, key));
return { success: false };
}
// Verify code
if (codeData.code !== code) {
codeData.attempts++;
await db.update(settings)
.set({ value: JSON.stringify(codeData) })
.where(eq(settings.key, key));
return { success: false };
}
// Get user
const user = await db.select().from(users).where(eq(users.username, username));
if (!user || user.length === 0) {
return { success: false };
}
const userData = user[0];
if (!userData.zero_trust_mode || !userData.backup_encrypted_dek) {
return { success: false };
}
// Derive user recovery key from seed
const userRecoveryKey = crypto.pbkdf2Sync(
userSeed,
'zero_trust_recovery',
100000,
32,
'sha256'
);
try {
// Decrypt DEK using user recovery key
const userCrypto = UserCrypto.getInstance();
const backupEncryptedDEK = JSON.parse(userData.backup_encrypted_dek);
const originalDEK = (userCrypto as any).decryptDEK(backupEncryptedDEK, userRecoveryKey);
// Create temporary session
const tempToken = crypto.randomBytes(32).toString('hex');
const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes
const tempKey = `temp_recovery_session_${username}`;
const tempValue = JSON.stringify({
userId: userData.id,
tempToken,
expiresAt,
dekHex: originalDEK.toString('hex'),
mode: 'zero_trust'
});
const existingTemp = await db.select().from(settings).where(eq(settings.key, tempKey));
if (existingTemp.length > 0) {
await db.update(settings).set({ value: tempValue }).where(eq(settings.key, tempKey));
} else {
await db.insert(settings).values({ key: tempKey, value: tempValue });
}
// Clean up recovery code
await db.delete(settings).where(eq(settings.key, key));
databaseLogger.success("Zero-trust recovery successful", {
operation: "zero_trust_recovery_success",
username,
userId: userData.id,
});
return { success: true, tempToken, expiresAt };
} catch (decryptError) {
// Invalid seed - decryption failed
databaseLogger.warn("Zero-trust recovery failed - invalid seed", {
operation: "zero_trust_recovery_invalid_seed",
username,
});
return { success: false };
}
} catch (error) {
databaseLogger.error("Zero-trust recovery failed", error, {
operation: "zero_trust_recovery_error",
username,
});
return { success: false };
}
}
/**
* Get user security mode status
*/
async getUserSecurityMode(userId: string): Promise<{
mode: 'compromise' | 'zero_trust';
canMigrate: boolean;
hasRecoveryData: boolean;
}> {
try {
const db = getDb();
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return { mode: 'compromise', canMigrate: false, hasRecoveryData: false };
}
const userData = user[0];
const mode = userData.zero_trust_mode ? 'zero_trust' : 'compromise';
const hasRecoveryData = !!(userData.recovery_dek || userData.backup_encrypted_dek);
const canMigrate = mode === 'compromise' && hasRecoveryData;
return { mode, canMigrate, hasRecoveryData };
} catch (error) {
databaseLogger.error("Failed to get user security mode", error, {
operation: "get_security_mode_failed",
userId,
});
return { mode: 'compromise', canMigrate: false, hasRecoveryData: false };
}
}
}

View File

@@ -14,9 +14,6 @@ import {
getRegistrationAllowed,
getOIDCConfig,
getSetupRequired,
requestRecoveryCode,
verifyRecoveryCode,
loginWithRecovery,
initiatePasswordReset,
verifyPasswordResetCode,
completePasswordReset,
@@ -84,15 +81,6 @@ export function HomepageAuth({
const [registrationAllowed, setRegistrationAllowed] = useState(true);
const [oidcConfigured, setOidcConfigured] = useState(false);
// Recovery states (new UX compromise flow)
const [recoveryStep, setRecoveryStep] = useState<
"request" | "verify" | "login"
>("request");
const [recoveryCode, setRecoveryCode] = useState("");
const [recoveryTempToken, setRecoveryTempToken] = useState("");
const [recoveryLoading, setRecoveryLoading] = useState(false);
const [recoverySuccess, setRecoverySuccess] = useState(false);
// Legacy reset states (kept for compatibility)
const [resetStep, setResetStep] = useState<
"initiate" | "verify" | "newPassword"
@@ -257,80 +245,9 @@ export function HomepageAuth({
}
}
// ===== New Recovery Functions (UX compromise) =====
async function handleRequestRecoveryCode() {
setError(null);
setRecoveryLoading(true);
try {
const result = await requestRecoveryCode(localUsername);
setRecoveryStep("verify");
toast.success("Recovery code sent to Docker logs");
} catch (err: any) {
toast.error(
err?.response?.data?.error ||
err?.message ||
"Failed to request recovery code",
);
} finally {
setRecoveryLoading(false);
}
}
async function handleVerifyRecoveryCode() {
setError(null);
setRecoveryLoading(true);
try {
const response = await verifyRecoveryCode(localUsername, recoveryCode);
setRecoveryTempToken(response.tempToken);
setRecoveryStep("login");
toast.success("Recovery verification successful");
} catch (err: any) {
toast.error(err?.response?.data?.error || "Failed to verify recovery code");
} finally {
setRecoveryLoading(false);
}
}
async function handleRecoveryLogin() {
setError(null);
setRecoveryLoading(true);
try {
const response = await loginWithRecovery(localUsername, recoveryTempToken);
// JWT token is now automatically set as HttpOnly cookie by backend
console.log("Recovery login successful - JWT set as secure HttpOnly cookie");
setLoggedIn(true);
setIsAdmin(response.is_admin);
setUsername(response.username);
onAuthSuccess({
isAdmin: response.is_admin,
username: response.username,
userId: response.userId || null,
});
// Reset recovery state
setRecoveryStep("request");
setRecoveryCode("");
setRecoveryTempToken("");
setRecoverySuccess(true);
toast.success("Login successful via recovery");
} catch (err: any) {
toast.error(err?.response?.data?.error || "Recovery login failed");
} finally {
setRecoveryLoading(false);
}
}
function resetRecoveryState() {
setRecoveryStep("request");
setRecoveryCode("");
setRecoveryTempToken("");
setError(null);
}
// ===== Legacy password reset functions (deprecated) =====
@@ -395,6 +312,10 @@ export function HomepageAuth({
setResetSuccess(true);
toast.success(t("messages.passwordResetSuccess"));
// Immediately redirect to login after successful reset
setTab("login");
resetPasswordState();
} catch (err: any) {
toast.error(err?.response?.data?.error || t("errors.failedCompleteReset"));
} finally {
@@ -899,119 +820,33 @@ export function HomepageAuth({
)}
{tab === "reset" && (
<>
{/* New Recovery Flow (UX compromise) */}
{recoveryStep === "request" && (
{resetStep === "initiate" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p className="text-sm mt-2">
Recovery requires server access to view Docker logs
</p>
<p>{t("auth.resetCodeDesc")}</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="recovery-username">
<Label htmlFor="reset-username">
{t("common.username")}
</Label>
<Input
id="recovery-username"
id="reset-username"
type="text"
required
className="h-11 text-base"
value={localUsername}
onChange={(e) => setLocalUsername(e.target.value)}
disabled={recoveryLoading}
disabled={resetLoading}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={recoveryLoading || !localUsername.trim()}
onClick={handleRequestRecoveryCode}
disabled={resetLoading || !localUsername.trim()}
onClick={handleInitiatePasswordReset}
>
{recoveryLoading ? Spinner : "Request Recovery Code"}
</Button>
</div>
</>
)}
{recoveryStep === "verify" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
Check Docker logs for recovery code for{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="recovery-code">
Recovery Code (6 digits)
</Label>
<Input
id="recovery-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={recoveryCode}
onChange={(e) =>
setRecoveryCode(e.target.value.replace(/\D/g, ""))
}
disabled={recoveryLoading}
placeholder="000000"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={recoveryLoading || recoveryCode.length !== 6}
onClick={handleVerifyRecoveryCode}
>
{recoveryLoading ? Spinner : "Verify & Unlock"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={recoveryLoading}
onClick={() => {
setRecoveryStep("request");
setRecoveryCode("");
}}
>
Back
</Button>
</div>
</>
)}
{recoveryStep === "login" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p className="text-sm mt-2">
Click below to complete login for{" "}
<strong>{localUsername}</strong>
</p>
</div>
<div className="flex flex-col gap-4">
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={recoveryLoading}
onClick={handleRecoveryLogin}
>
{recoveryLoading ? Spinner : "Complete Login"}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={recoveryLoading}
onClick={() => {
resetRecoveryState();
}}
>
Start Over
{resetLoading ? Spinner : t("auth.sendResetCode")}
</Button>
</div>
</>
@@ -1070,25 +905,6 @@ export function HomepageAuth({
</>
)}
{resetSuccess && (
<>
<div className="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/20 mb-4">
<p className="text-green-400 text-sm">
{t("auth.passwordResetSuccessDesc")}
</p>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
onClick={() => {
setTab("login");
resetPasswordState();
}}
>
{t("auth.goToLogin")}
</Button>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>
@@ -1100,7 +916,7 @@ export function HomepageAuth({
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">
<Label htmlFor="new-p assword">
{t("auth.newPassword")}
</Label>
<PasswordInput

View File

@@ -286,6 +286,10 @@ export function HomepageAuth({
setResetSuccess(true);
toast.success(t("messages.passwordResetSuccess"));
// Immediately redirect to login after successful reset
setTab("login");
resetPasswordState();
} catch (err: any) {
toast.error(err?.response?.data?.error || t("errors.failedCompleteReset"));
} finally {
@@ -702,25 +706,6 @@ export function HomepageAuth({
</>
)}
{resetSuccess && (
<>
<div className="text-center p-4 bg-green-500/10 rounded-lg border border-green-500/20 mb-4">
<p className="text-green-400 text-sm">
{t("auth.passwordResetSuccessDesc")}
</p>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
onClick={() => {
setTab("login");
resetPasswordState();
}}
>
{t("auth.goToLogin")}
</Button>
</>
)}
{resetStep === "newPassword" && !resetSuccess && (
<>