v1.8.0 #429

Merged
LukeGus merged 198 commits from dev-1.8.0 into main 2025-11-05 16:36:16 +00:00
11 changed files with 346 additions and 316 deletions
Showing only changes of commit cf431e59ac - Show all commits

View File

@@ -174,11 +174,23 @@ jobs:
- name: Build Linux (All Architectures)
run: npm run build && npx electron-builder --linux --x64 --arm64 --armv7l
- name: Rename tar.gz files to match convention
- name: Rename Linux artifacts for consistency
run: |
VERSION=$(node -p "require('./package.json').version")
cd release
# Rename x64 AppImage to use 'x64'
if [ -f "termix_linux_x86_64_${VERSION}_appimage.AppImage" ]; then
mv "termix_linux_x86_64_${VERSION}_appimage.AppImage" "termix_linux_x64_${VERSION}_appimage.AppImage"
echo "Renamed x64 AppImage to use 'x64' arch"
fi
# Rename x64 deb to use 'x64'
if [ -f "termix_linux_amd64_${VERSION}_deb.deb" ]; then
mv "termix_linux_amd64_${VERSION}_deb.deb" "termix_linux_x64_${VERSION}_deb.deb"
echo "Renamed x64 deb to use 'x64' arch"
fi
# Rename x64 tar.gz if it exists
if [ -f "termix-${VERSION}.tar.gz" ]; then
mv "termix-${VERSION}.tar.gz" "termix_linux_x64_${VERSION}_portable.tar.gz"

View File

@@ -5,11 +5,15 @@ import { db } from "../db/index.js";
import {
users,
sshData,
sshCredentials,
fileManagerRecent,
fileManagerPinned,
fileManagerShortcuts,
dismissedAlerts,
settings,
sshCredentialUsage,
recentActivity,
snippets,
} from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import bcrypt from "bcryptjs";
@@ -405,6 +409,35 @@ router.delete("/oidc-config", authenticateJWT, async (req, res) => {
// Route: Get OIDC configuration (public - needed for login page)
// GET /users/oidc-config
router.get("/oidc-config", async (req, res) => {
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
.get();
if (!row) {
return res.json(null);
}
const config = JSON.parse((row as Record<string, unknown>).value as string);
// Only return public fields needed for login page
const publicConfig = {
client_id: config.client_id,
issuer_url: config.issuer_url,
authorization_url: config.authorization_url,
scopes: config.scopes,
};
return res.json(publicConfig);
} catch (err) {
authLogger.error("Failed to get OIDC config", err);
res.status(500).json({ error: "Failed to get OIDC config" });
}
});
// Route: Get OIDC configuration for Admin (admin only)
// GET /users/oidc-config/admin
router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'oidc_config'")
@@ -415,43 +448,6 @@ router.get("/oidc-config", async (req, res) => {
let config = JSON.parse((row as Record<string, unknown>).value as string);
// Check if user is authenticated admin
let isAuthenticatedAdmin = false;
let userId: string | null = null;
const authHeader = req.headers["authorization"];
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.split(" ")[1];
const authManager = AuthManager.getInstance();
const payload = await authManager.verifyJWTToken(token);
if (payload) {
userId = payload.userId;
const user = await db.select().from(users).where(eq(users.id, userId));
if (user && user.length > 0 && user[0].is_admin) {
isAuthenticatedAdmin = true;
}
}
}
// For non-admin users, hide sensitive fields
if (!isAuthenticatedAdmin) {
// Remove all sensitive fields for public access
delete config.client_secret;
delete config.id;
// Only return public fields needed for login page
const publicConfig = {
client_id: config.client_id,
issuer_url: config.issuer_url,
authorization_url: config.authorization_url,
scopes: config.scopes,
};
return res.json(publicConfig);
}
// For authenticated admins, decrypt sensitive fields
if (config.client_secret?.startsWith("encrypted:")) {
try {
const adminDataKey = DataCrypto.getUserDataKey(userId);
@@ -463,8 +459,6 @@ router.get("/oidc-config", async (req, res) => {
adminDataKey,
);
} else {
// Admin is authenticated but data key is not available
// This can happen if they haven't unlocked their data yet
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
}
} catch (decryptError) {
@@ -475,7 +469,6 @@ router.get("/oidc-config", async (req, res) => {
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
}
} else if (config.client_secret?.startsWith("encoded:")) {
// Decode for authenticated admins
try {
const decoded = Buffer.from(
config.client_secret.substring(8),
@@ -493,8 +486,8 @@ router.get("/oidc-config", async (req, res) => {
res.json(config);
} catch (err) {
authLogger.error("Failed to get OIDC config", err);
res.status(500).json({ error: "Failed to get OIDC config" });
authLogger.error("Failed to get OIDC config for admin", err);
res.status(500).json({ error: "Failed to get OIDC config for admin" });
}
});
@@ -1399,63 +1392,131 @@ router.post("/complete-reset", async (req, res) => {
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));
// Check if user is logged in and data is unlocked
let userIdFromJwt: string | null = null;
const cookie = req.cookies?.jwt;
let header: string | undefined;
if (req.headers?.authorization?.startsWith("Bearer ")) {
header = req.headers?.authorization?.split(" ")[1];
}
const token = cookie || header;
try {
// Delete all encrypted data since we're creating a new DEK
// The old DEK is lost, so old encrypted data becomes unreadable
await db.delete(sshData).where(eq(sshData.userId, userId));
await db
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId));
await db
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId));
await db
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId));
await db
.delete(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
if (token) {
const payload = await authManager.verifyJWTToken(token);
if (payload) {
userIdFromJwt = payload.userId;
}
}
// Now setup new encryption with new DEK
await authManager.registerUser(userId, newPassword);
authManager.logoutUser(userId);
if (userIdFromJwt === userId && authManager.isUserUnlocked(userId)) {
// Logged-in user: preserve data
try {
const success = await authManager.resetUserPasswordWithPreservedDEK(
userId,
newPassword,
);
// Clear TOTP settings
if (!success) {
throw new Error("Failed to re-encrypt user data with new password.");
}
await db
.update(users)
.set({ password_hash })
.where(eq(users.id, userId));
authManager.logoutUser(userId);
authLogger.success(
`Password reset (data preserved) for user: ${username}`,
{
operation: "password_reset_preserved",
userId,
username,
},
);
} catch (encryptionError) {
authLogger.error(
"Failed to setup user data encryption after password reset",
encryptionError,
{
operation: "password_reset_encryption_failed_preserved",
userId,
username,
},
);
return res.status(500).json({
error: "Password reset failed. Please contact administrator.",
});
}
} else {
// Logged-out user: data is lost
await db
.update(users)
.set({
totp_enabled: false,
totp_secret: null,
totp_backup_codes: null,
})
.where(eq(users.id, userId));
.set({ password_hash })
.where(eq(users.username, username));
authLogger.warn(
`Password reset completed for user: ${username}. All encrypted data has been deleted due to lost encryption key.`,
{
operation: "password_reset_data_deleted",
userId,
username,
},
);
} catch (encryptionError) {
authLogger.error(
"Failed to setup user data encryption after password reset",
encryptionError,
{
operation: "password_reset_encryption_failed",
userId,
username,
},
);
return res.status(500).json({
error: "Password reset failed. Please contact administrator.",
});
try {
// Delete all encrypted data since we're creating a new DEK
// The old DEK is lost, so old encrypted data becomes unreadable
await db
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.userId, userId));
await db
.delete(fileManagerRecent)
.where(eq(fileManagerRecent.userId, userId));
await db
.delete(fileManagerPinned)
.where(eq(fileManagerPinned.userId, userId));
await db
.delete(fileManagerShortcuts)
.where(eq(fileManagerShortcuts.userId, userId));
await db
.delete(recentActivity)
.where(eq(recentActivity.userId, userId));
await db
.delete(dismissedAlerts)
.where(eq(dismissedAlerts.userId, userId));
await db.delete(snippets).where(eq(snippets.userId, userId));
await db.delete(sshData).where(eq(sshData.userId, userId));
await db
.delete(sshCredentials)
.where(eq(sshCredentials.userId, userId));
// Now setup new encryption with new DEK
await authManager.registerUser(userId, newPassword);
authManager.logoutUser(userId);
// Clear TOTP settings
await db
.update(users)
.set({
totp_enabled: false,
totp_secret: null,
totp_backup_codes: null,
})
.where(eq(users.id, userId));
authLogger.warn(
`Password reset completed for user: ${username}. All encrypted data has been deleted due to lost encryption key.`,
{
operation: "password_reset_data_deleted",
userId,
username,
},
);
} catch (encryptionError) {
authLogger.error(
"Failed to setup user data encryption after password reset",
encryptionError,
{
operation: "password_reset_encryption_failed",
userId,
username,
},
);
return res.status(500).json({
error: "Password reset failed. Please contact administrator.",
});
}
}
authLogger.success(`Password successfully reset for user: ${username}`);
@@ -1474,6 +1535,52 @@ router.post("/complete-reset", async (req, res) => {
}
});
router.post("/change-password", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const { oldPassword, newPassword } = req.body;
if (!userId) {
return res.status(401).json({ error: "User not authenticated" });
}
if (!oldPassword || !newPassword) {
return res
.status(400)
.json({ error: "Old and new passwords are required." });
}
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
return res.status(404).json({ error: "User not found" });
}
// Verify old password for login hash
const isMatch = await bcrypt.compare(oldPassword, user[0].password_hash);
if (!isMatch) {
return res.status(401).json({ error: "Incorrect current password" });
}
// Change encryption keys and login hash
const success = await authManager.changeUserPassword(
userId,
oldPassword,
newPassword,
);
if (!success) {
return res
.status(500)
.json({ error: "Failed to update password and re-encrypt 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.id, userId));
authManager.logoutUser(userId); // Log out user for security
res.json({ message: "Password changed successfully. Please log in again." });
});
// Route: List all users (admin only)
// GET /users/list
router.get("/list", authenticateJWT, async (req, res) => {

View File

@@ -318,6 +318,8 @@
"language": "Sprache",
"autoDetect": "Automatische Erkennung",
"changeAccountPassword": "Passwort für Ihr Konto ändern",
"passwordResetTitle": "Passwort zurücksetzen",
"passwordResetDescription": "Sie sind dabei, Ihr Passwort zurückzusetzen. Dadurch werden Sie von allen aktiven Sitzungen abgemeldet.",
"enterSixDigitCode": "Geben Sie den 6-stelligen Code aus den Docker-Container-Protokollen \/ logs für den Benutzer ein:",
"enterNewPassword": "Geben Sie Ihr neues Passwort für den Benutzer ein:",
"passwordsDoNotMatch": "Passwörter stimmen nicht überein",
@@ -1170,7 +1172,8 @@
"enterNewPassword": "Geben Sie Ihr neues Passwort für den Benutzer ein:",
"passwordResetSuccess": "Erfolgreich!",
"passwordResetSuccessDesc": "Ihr Passwort wurde erfolgreich zurückgesetzt! Sie können sich jetzt mit Ihrem neuen Passwort anmelden.",
"signUp": "Registrierung"
"signUp": "Registrierung",
"dataLossWarning": "Wenn Sie Ihr Passwort auf diese Weise zurücksetzen, werden alle Ihre gespeicherten SSH-Hosts, Anmeldeinformationen und andere verschlüsselte Daten gelöscht. Diese Aktion kann nicht rückgängig gemacht werden. Verwenden Sie diese Option nur, wenn Sie Ihr Passwort vergessen haben und nicht angemeldet sind."
},
"errors": {
"notFound": "Seite nicht gefunden",
@@ -1242,7 +1245,10 @@
"authMethod": "Authentifizierungsmethode",
"local": "Lokal",
"external": "Extern (OIDC)",
"selectPreferredLanguage": "Wählen Sie Ihre bevorzugte Sprache für die Benutzeroberfläche"
"selectPreferredLanguage": "Wählen Sie Ihre bevorzugte Sprache für die Benutzeroberfläche",
"currentPassword": "Aktuelles Passwort",
"passwordChangedSuccess": "Passwort erfolgreich geändert! Bitte melden Sie sich erneut an.",
"failedToChangePassword": "Passwort konnte nicht geändert werden. Bitte überprüfen Sie Ihr aktuelles Passwort und versuchen Sie es erneut."
},
"user": {
"failedToLoadVersionInfo": "Fehler beim Laden der Versionsinformationen"

View File

@@ -367,6 +367,8 @@
"language": "Language",
"autoDetect": "Auto-detect",
"changeAccountPassword": "Change your account password",
"passwordResetTitle": "Password Reset",
"passwordResetDescription": "You are about to reset your password. This will log you out of all active sessions.",
"enterSixDigitCode": "Enter the 6-digit code from the docker container logs for user:",
"enterNewPassword": "Enter your new password for user:",
"passwordsDoNotMatch": "Passwords do not match",
@@ -1294,9 +1296,8 @@
"newPassword": "New Password",
"confirmNewPassword": "Confirm Password",
"enterNewPassword": "Enter your new password for user:",
"passwordResetSuccess": "Success!",
"passwordResetSuccessDesc": "Your password has been successfully reset! You can now log in with your new password.",
"signUp": "Sign Up",
"dataLossWarning": "Resetting your password this way will delete all your saved SSH hosts, credentials, and other encrypted data. This action cannot be undone. Only use this if you have forgotten your password and are not logged in.",
"authenticationDisabled": "Authentication Disabled",
"authenticationDisabledDesc": "All authentication methods are currently disabled. Please contact your administrator."
},
@@ -1370,7 +1371,10 @@
"authMethod": "Authentication Method",
"local": "Local",
"external": "External (OIDC)",
"selectPreferredLanguage": "Select your preferred language for the interface"
"selectPreferredLanguage": "Select your preferred language for the interface",
"currentPassword": "Current Password",
"passwordChangedSuccess": "Password changed successfully! Please log in again.",
"failedToChangePassword": "Failed to change password. Please check your current password and try again."
},
"user": {
"failedToLoadVersionInfo": "Failed to load version information"

View File

@@ -333,6 +333,8 @@
"language": "Idioma",
"autoDetect": "Detecção Automática",
"changeAccountPassword": "Alterar senha da conta",
"passwordResetTitle": "Redefinir Senha",
"passwordResetDescription": "Você está prestes a redefinir sua senha. Isso fará com que você seja desconectado de todas as sessões ativas.",
"enterSixDigitCode": "Digite o código de 6 dígitos dos logs do container docker para o usuário:",
"enterNewPassword": "Digite sua nova senha para o usuário:",
"passwordsDoNotMatch": "As senhas não correspondem",
@@ -1217,8 +1219,8 @@
"confirmNewPassword": "Confirmar Senha",
"enterNewPassword": "Digite sua nova senha para o usuário:",
"passwordResetSuccess": "Sucesso!",
"passwordResetSuccessDesc": "Sua senha foi redefinida com sucesso! Você pode agora entrar com sua nova senha.",
"signUp": "Cadastrar"
"signUp": "Cadastrar",
"dataLossWarning": "Redefinir sua senha desta forma excluirá todos os seus hosts SSH salvos, credenciais e outros dados criptografados. Esta ação não pode ser desfeita. Use isso apenas se você esqueceu sua senha e não está logado."
},
"errors": {
"notFound": "Página não encontrada",
@@ -1289,7 +1291,10 @@
"authMethod": "Método de Autenticação",
"local": "Local",
"external": "Externo (OIDC)",
"selectPreferredLanguage": "Selecione seu idioma preferido para a interface"
"selectPreferredLanguage": "Selecione seu idioma preferido para a interface",
"currentPassword": "Senha Atual",
"passwordChangedSuccess": "Senha alterada com sucesso! Por favor, faça login novamente.",
"failedToChangePassword": "Falha ao alterar a senha. Por favor, verifique sua senha atual e tente novamente."
},
"user": {
"failedToLoadVersionInfo": "Falha ao carregar informações da versão"

View File

@@ -353,6 +353,8 @@
"language": "语言",
"autoDetect": "自动检测",
"changeAccountPassword": "修改您的账户密码",
"passwordResetTitle": "重置密码",
"passwordResetDescription": "您即将重置密码。此操作将使您从所有活动会话中注销。",
"enterSixDigitCode": "输入来自 docker 容器日志中用户的 6 位数代码:",
"enterNewPassword": "为用户输入新密码:",
"passwordsDoNotMatch": "密码不匹配",
@@ -1277,7 +1279,8 @@
"enterNewPassword": "为用户输入新密码:",
"passwordResetSuccess": "成功!",
"passwordResetSuccessDesc": "您的密码已成功重置!您现在可以使用新密码登录。",
"signUp": "注册"
"signUp": "注册",
"dataLossWarning": "以这种方式重置密码将删除所有已保存的 SSH 主机、凭据和其他加密数据。此操作无法撤销。仅当您忘记密码且未登录时才使用此功能。"
},
"errors": {
"notFound": "页面未找到",
@@ -1349,7 +1352,10 @@
"authMethod": "认证方式",
"local": "本地",
"external": "外部 (OIDC)",
"selectPreferredLanguage": "选择您的界面首选语言"
"selectPreferredLanguage": "选择您的界面首选语言",
"currentPassword": "当前密码",
"passwordChangedSuccess": "密码修改成功!请重新登录。",
"failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。"
},
"user": {
"failedToLoadVersionInfo": "加载版本信息失败"

View File

@@ -34,7 +34,7 @@ import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getOIDCConfig,
getAdminOIDCConfig,
getRegistrationAllowed,
getPasswordLoginAllowed,
getUserList,
@@ -125,7 +125,7 @@ export function AdminSettings({
}
}
getOIDCConfig()
getAdminOIDCConfig()
.then((res) => {
if (res) setOidcConfig(res);
})

View File

@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { useTranslation } from "react-i18next";
import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx";
import { toast } from "sonner";
@@ -858,6 +859,12 @@ export function Auth({
<>
{resetStep === "initiate" && (
<>
<Alert variant="destructive" className="mb-4">
<AlertTitle>{t("common.warning")}</AlertTitle>
<AlertDescription>
{t("auth.dataLossWarning")}
</AlertDescription>
</Alert>
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.resetCodeDesc")}</p>
</div>

View File

@@ -7,13 +7,8 @@ import {
} from "@/components/ui/card.tsx";
import { Key } from "lucide-react";
import React, { useState } from "react";
import {
completePasswordReset,
initiatePasswordReset,
verifyPasswordResetCode,
} from "@/ui/main-axios.ts";
import { changePassword } from "@/ui/main-axios.ts";
import { Label } from "@/components/ui/label.tsx";
import { Input } from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
@@ -31,98 +26,42 @@ interface PasswordResetProps {
export function PasswordReset({ userInfo }: PasswordResetProps) {
const [error, setError] = useState<string | null>(null);
const [resetStep, setResetStep] = useState<
"initiate" | "verify" | "newPassword"
>("initiate");
const [resetCode, setResetCode] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [tempToken, setTempToken] = useState("");
const [resetLoading, setResetLoading] = useState(false);
const [loading, setLoading] = useState(false);
const { t } = useTranslation();
async function handleInitiatePasswordReset() {
async function handleChangePassword() {
setError(null);
setResetLoading(true);
try {
await initiatePasswordReset(userInfo.username);
setResetStep("verify");
setError(null);
} catch (err: unknown) {
const error = err as {
message?: string;
response?: { data?: { error?: string } };
};
setError(
error?.response?.data?.error ||
error?.message ||
t("common.failedToInitiatePasswordReset"),
);
} finally {
setResetLoading(false);
if (!currentPassword || !newPassword || !confirmPassword) {
setError(t("errors.requiredField"));
return;
}
}
function resetPasswordState() {
setResetStep("initiate");
setResetCode("");
setNewPassword("");
setConfirmPassword("");
setTempToken("");
setError(null);
}
async function handleVerifyResetCode() {
setError(null);
setResetLoading(true);
try {
const response = await verifyPasswordResetCode(
userInfo.username,
resetCode,
);
setTempToken(response.tempToken);
setResetStep("newPassword");
setError(null);
} catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } };
setError(
error?.response?.data?.error || t("common.failedToVerifyResetCode"),
);
} finally {
setResetLoading(false);
}
}
async function handleCompletePasswordReset() {
setError(null);
setResetLoading(true);
if (newPassword !== confirmPassword) {
setError(t("common.passwordsDoNotMatch"));
setResetLoading(false);
return;
}
if (newPassword.length < 6) {
setError(t("common.passwordMinLength"));
setResetLoading(false);
return;
}
setLoading(true);
try {
await completePasswordReset(userInfo.username, tempToken, newPassword);
toast.success(t("common.passwordResetSuccess"));
resetPasswordState();
await changePassword(currentPassword, newPassword);
toast.success(t("profile.passwordChangedSuccess"));
window.location.reload();
} catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } };
setError(
error?.response?.data?.error ||
t("common.failedToCompletePasswordReset"),
error?.response?.data?.error || t("profile.failedToChangePassword"),
);
} finally {
setResetLoading(false);
setLoading(false);
}
}
@@ -158,147 +97,64 @@ export function PasswordReset({ userInfo }: PasswordResetProps) {
<CardDescription>{t("common.changeAccountPassword")}</CardDescription>
</CardHeader>
<CardContent>
<>
{resetStep === "initiate" && (
<>
<Alert variant="destructive" className="mb-4">
<AlertTitle>Warning: Data Loss</AlertTitle>
<AlertDescription>
Resetting your password will delete all your saved SSH hosts,
credentials, and encrypted data. This action cannot be undone.
Only use this if you have forgotten your password.
</AlertDescription>
</Alert>
<div className="flex flex-col gap-4">
<Button
type="button"
className="w-full h-11 text-base"
disabled={resetLoading || !userInfo.username.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : t("common.sendResetCode")}
</Button>
</div>
</>
)}
{resetStep === "verify" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("common.enterSixDigitCode")}{" "}
<strong>{userInfo.username}</strong>
</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">{t("common.resetCode")}</Label>
<Input
id="reset-code"
type="text"
required
maxLength={6}
className="h-11 text-base text-center text-lg tracking-widest"
value={resetCode}
onChange={(e) =>
setResetCode(e.target.value.replace(/\D/g, ""))
}
disabled={resetLoading}
placeholder={t("placeholders.enterCode")}
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || resetCode.length !== 6}
onClick={handleVerifyResetCode}
>
{resetLoading ? Spinner : t("common.verifyCode")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("initiate");
setResetCode("");
}}
>
Back
</Button>
</div>
</>
)}
{resetStep === "newPassword" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>
{t("common.enterNewPassword")}{" "}
<strong>{userInfo.username}</strong>
</p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">
{t("common.newPassword")}
</Label>
<PasswordInput
id="new-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">
{t("common.confirmPassword")}
</Label>
<PasswordInput
id="confirm-password"
required
className="h-11 text-base focus:ring-2 focus:ring-primary/50 transition-all duration-200"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={resetLoading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading || !newPassword || !confirmPassword}
onClick={handleCompletePasswordReset}
>
{resetLoading ? Spinner : t("common.resetPassword")}
</Button>
<Button
type="button"
variant="outline"
className="w-full h-11 text-base font-semibold"
disabled={resetLoading}
onClick={() => {
setResetStep("verify");
setNewPassword("");
setConfirmPassword("");
}}
>
Back
</Button>
</div>
</>
)}
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="current-password">
{t("profile.currentPassword")}
</Label>
<PasswordInput
id="current-password"
required
className="h-11 text-base"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
disabled={loading}
autoComplete="current-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">{t("common.newPassword")}</Label>
<PasswordInput
id="new-password"
required
className="h-11 text-base"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={loading}
autoComplete="new-password"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">
{t("common.confirmPassword")}
</Label>
<PasswordInput
id="confirm-password"
required
className="h-11 text-base"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={loading}
autoComplete="new-password"
/>
</div>
<Button
type="button"
className="w-full h-11 text-base font-semibold mt-2"
disabled={
loading || !currentPassword || !newPassword || !confirmPassword
}
onClick={handleChangePassword}
>
{loading ? Spinner : t("profile.changePassword")}
</Button>
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</>
</div>
</CardContent>
</Card>
);

View File

@@ -740,6 +740,12 @@ export function Auth({
<>
{resetStep === "initiate" && (
<>
<Alert variant="destructive" className="mb-4">
<AlertTitle>{t("common.warning")}</AlertTitle>
<AlertDescription>
{t("auth.dataLossWarning")}
</AlertDescription>
</Alert>
<div className="text-center text-muted-foreground mb-4">
<p>{t("auth.resetCodeDesc")}</p>
</div>

View File

@@ -1755,6 +1755,15 @@ export async function getOIDCConfig(): Promise<Record<string, unknown>> {
}
}
export async function getAdminOIDCConfig(): Promise<Record<string, unknown>> {
try {
const response = await authApi.get("/users/oidc-config/admin");
return response.data;
} catch (error) {
handleApiError(error, "fetch admin OIDC config");
}
}
export async function getSetupRequired(): Promise<{ setup_required: boolean }> {
try {
const response = await authApi.get("/users/setup-required");
@@ -1816,6 +1825,18 @@ export async function completePasswordReset(
}
}
export async function changePassword(oldPassword: string, newPassword: string) {
try {
const response = await authApi.post("/users/change-password", {
oldPassword,
newPassword,
});
return response.data;
} catch (error) {
handleApiError(error, "change password");
}
}
export async function getOIDCAuthorizeUrl(): Promise<OIDCAuthorize> {
try {
const response = await authApi.get("/users/oidc/authorize");