feat: Reset forgotten password added

This commit is contained in:
zurdi
2025-05-13 09:35:53 +00:00
parent eec1379d02
commit d27f4d626b
22 changed files with 562 additions and 108 deletions

View File

@@ -12,6 +12,8 @@ def str_to_bool(value: str) -> bool:
return value.lower() in ("true", "1")
ROMM_BASE_URL = os.environ.get("ROMM_BASE_URL", "http://0.0.0.0")
# GUNICORN
DEV_MODE: Final = str_to_bool(os.environ.get("DEV_MODE", "false"))
DEV_HOST: Final = os.environ.get("DEV_HOST", "127.0.0.1")

View File

@@ -12,11 +12,14 @@ from exceptions.auth_exceptions import (
OIDCNotConfiguredException,
UserDisabledException,
)
from fastapi import Depends, HTTPException, Request, status
from fastapi import Body, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from fastapi.security.http import HTTPBasic
from handler.auth import auth_handler, oauth_handler, oidc_handler
from handler.database import db_user_handler
from logger.formatter import CYAN
from logger.formatter import highlight as hl
from logger.logger import log
from utils.router import APIRouter
ACCESS_TOKEN_EXPIRE_MINUTES: Final = 30
@@ -270,3 +273,49 @@ async def auth_openid(request: Request):
)
return RedirectResponse(url="/")
@router.post("/forgot-password")
def request_password_reset(username: str = Body(..., embed=True)) -> MessageResponse:
"""Request password reset by email.
Args:
email (str): User's email
Returns:
MessageResponse: Confirmation message
"""
user = db_user_handler.get_user_by_username(username)
if user:
auth_handler.generate_password_reset_token(user)
else:
log.warning(
f"Reset password link requested for a user {hl(username, color=CYAN)}, but that username does not exist."
)
return {"msg": "If the username exists, a reset link has been sent."}
@router.post("/reset-password")
def reset_password(
token: str = Body(..., embed=True),
new_password: str = Body(..., embed=True),
) -> MessageResponse:
"""Reset password using the token.
Args:
token (str): Reset token from the URL
new_password (str): New user password
Returns:
MessageResponse: Confirmation message
"""
user = auth_handler.verify_password_reset_token(token)
auth_handler.set_user_new_password(user, new_password)
log.info(
f"Password was successfully reset for user {hl(user.username, color=CYAN)}."
)
return {"msg": "Password has been reset successfully."}

View File

@@ -2,11 +2,11 @@ import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from config import OIDC_ENABLED, ROMM_AUTH_SECRET_KEY
from config import OIDC_ENABLED, ROMM_AUTH_SECRET_KEY, ROMM_BASE_URL
from decorators.auth import oauth
from exceptions.auth_exceptions import OAuthCredentialsException, UserDisabledException
from fastapi import HTTPException, status
from handler.auth.constants import ALGORITHM, DEFAULT_OAUTH_TOKEN_EXPIRY
from handler.auth.constants import ALGORITHM, DEFAULT_OAUTH_TOKEN_EXPIRY, TokenPurpose
from joserfc import jwt
from joserfc.errors import BadSignatureError
from joserfc.jwk import OctKey
@@ -20,6 +20,7 @@ from starlette.requests import HTTPConnection
class AuthHandler:
def __init__(self) -> None:
self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
self.reset_passwd_token_expires_in_minutes = 10
def verify_password(self, plain_password, hashed_password):
return self.pwd_context.verify(plain_password, hashed_password)
@@ -63,6 +64,77 @@ class AuthHandler:
return user
def generate_password_reset_token(self, user: Any) -> None:
now = datetime.now(timezone.utc)
to_encode = {
"sub": user.username,
"email": user.email,
"type": TokenPurpose.RESET,
"iat": int(now.timestamp()),
"exp": int(
(
now + timedelta(minutes=self.reset_passwd_token_expires_in_minutes)
).timestamp()
),
"jti": str(uuid.uuid4()),
}
token = jwt.encode(
{"alg": ALGORITHM}, to_encode, OctKey.import_key(ROMM_AUTH_SECRET_KEY)
)
log.info(
f"Reset password link requested for {hl(user.username, color=CYAN)}. Reset link: {hl(f'{ROMM_BASE_URL}/reset-password?token={token}')}"
)
def verify_password_reset_token(self, token: str) -> Any:
"""Verify the password reset token.
Args:
token (str): The token to verify.
Raises:
HTTPException: If the token is invalid or expired.
HTTPException: If the token is missing or malformed.
HTTPException: If the user is not found.
HTTPException: If the token is not for password reset.
"""
from handler.database import db_user_handler
try:
payload = jwt.decode(token, ROMM_AUTH_SECRET_KEY, algorithms=[ALGORITHM])
except (BadSignatureError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Invalid token") from exc
if payload.claims.get("type") != TokenPurpose.RESET:
raise HTTPException(status_code=400, detail="Invalid token purpose")
username = payload.claims.get("sub")
if not username:
raise HTTPException(status_code=400, detail="Invalid token payload")
user = db_user_handler.get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
now = datetime.now(timezone.utc).timestamp()
if now > payload.claims.get("exp"):
raise HTTPException(status_code=400, detail="Token has expired")
return user
def set_user_new_password(self, user: Any, new_password: str) -> None:
"""
Set the new password for the user.
Args:
user (Any): The user object.
new_password (str): The new password to set.
"""
from handler.database import db_user_handler
db_user_handler.update_user(
user.id, {"hashed_password": self.get_password_hash(new_password)}
)
class OAuthHandler:
def __init__(self) -> None:

View File

@@ -59,3 +59,7 @@ READ_SCOPES: Final = list(READ_SCOPES_MAP.keys())
WRITE_SCOPES: Final = READ_SCOPES + list(WRITE_SCOPES_MAP.keys())
EDIT_SCOPES: Final = WRITE_SCOPES + list(EDIT_SCOPES_MAP.keys())
FULL_SCOPES: Final = EDIT_SCOPES + list(FULL_SCOPES_MAP.keys())
class TokenPurpose(enum.StrEnum):
RESET = "reset"

View File

@@ -113,7 +113,7 @@ start_bin_nginx() {
# if container runs as root, drop permissions
nginx -g 'user romm;'
fi
info_log "🚀 RomM is now available at http://0.0.0.0:8080"
info_log "🚀 RomM is now available at ${ROMM_BASE_URL}:8080"
}
# Commands to start valkey-server (handling PID creation internally)

View File

@@ -32,6 +32,12 @@ const HEADERS = [
sortable: true,
key: "username",
},
{
title: "Email",
align: "start",
sortable: true,
key: "email",
},
{
title: "Role",
align: "start",

View File

@@ -3,5 +3,11 @@
"login-oidc": "Einloggen mit {oidc}",
"or": "oder",
"password": "Passwort",
"username": "Benutzername"
"username": "Benutzername",
"forgot-password": "Passwort vergessen?",
"send-reset-link": "Link zum Zurücksetzen senden",
"reset-sent": "Link zum Zurücksetzen wurde gesendet. Kontaktieren Sie Ihren Administrator.",
"confirm-password": "Passwort bestätigen",
"reset-password": "Passwort zurücksetzen",
"back-to-login": "Zurück zum Login"
}

View File

@@ -3,5 +3,11 @@
"login-oidc": "Login with {oidc}",
"or": "or",
"password": "Password",
"username": "Username"
"username": "Username",
"forgot-password": "Forgot your password?",
"send-reset-link": "Send reset link",
"reset-sent": "Reset link sent. Contact your admin.",
"confirm-password": "Confirm password",
"reset-password": "Reset password",
"back-to-login": "Back to login"
}

View File

@@ -3,5 +3,11 @@
"login-oidc": "Login with {oidc}",
"or": "or",
"password": "Password",
"username": "Username"
"username": "Username",
"forgot-password": "Forgot your password?",
"send-reset-link": "Send reset link",
"reset-sent": "Reset link sent. Contact your admin.",
"confirm-password": "Confirm password",
"reset-password": "Reset password",
"back-to-login": "Back to login"
}

View File

@@ -3,5 +3,11 @@
"login-oidc": "Iniciar sesión con {oidc}",
"or": "o",
"password": "Contraseña",
"username": "Usuario"
"username": "Usuario",
"forgot-password": "¿Olvidates tu contraseña?",
"send-reset-link": "Enviar enlace de restablecimiento",
"reset-sent": "Enlace de restablecimiento enviado. Contacta a tu administrador.",
"confirm-password": "Confirmar contraseña",
"reset-password": "Restablecer contraseña",
"back-to-login": "Volver a iniciar sesión"
}

View File

@@ -3,5 +3,11 @@
"login-oidc": "Se connecter avec {oidc}",
"or": "ou",
"password": "Mot de passe",
"username": "Nom d'utilisateur"
"username": "Nom d'utilisateur",
"forgot-password": "Mot de passe oublié ?",
"send-reset-link": "Envoyer le lien de réinitialisation",
"reset-sent": "Lien de réinitialisation envoyé. Contactez votre administrateur.",
"confirm-password": "Confirmer le mot de passe",
"reset-password": "Réinitialiser le mot de passe",
"back-to-login": "Retour à la connexion"
}

View File

@@ -3,5 +3,11 @@
"login-oidc": "Login con {oidc}",
"or": "o",
"password": "Password",
"username": "Nome utente"
"username": "Nome utente",
"forgot-password": "Hai dimenticato la password?",
"send-reset-link": "Invia link di reimpostazione",
"reset-sent": "Link di reimpostazione inviato. Contatta l'amministratore.",
"confirm-password": "Conferma password",
"reset-password": "Reimposta password",
"back-to-login": "Torna al login"
}

View File

@@ -3,5 +3,11 @@
"login-oidc": "Login with {oidc}",
"or": "or",
"password": "パスワード",
"username": "ユーザ名"
"username": "ユーザ名",
"forgot-password": "パスワードをお忘れですか?",
"send-reset-link": "リセットリンクを送信",
"reset-sent": "リセットリンクが送信されました。管理者に連絡してください。",
"confirm-password": "パスワードを確認",
"reset-password": "パスワードをリセット",
"back-to-login": "ログインに戻る"
}

View File

@@ -3,5 +3,11 @@
"login-oidc": "{oidc}로 로그인",
"or": "또는",
"password": "패스워드",
"username": "아이디"
"username": "아이디",
"forgot-password": "비밀번호를 잊으셨나요?",
"send-reset-link": "재설정 링크 보내기",
"reset-sent": "재설정 링크가 전송되었습니다. 관리자에게 문의하세요.",
"confirm-password": "비밀번호 확인",
"reset-password": "비밀번호 재설정",
"back-to-login": "로그인으로 돌아가기"
}

View File

@@ -3,5 +3,11 @@
"login-oidc": "Entrar com {oidc}",
"or": "ou",
"password": "Senha",
"username": "Nome de usuário"
"username": "Nome de usuário",
"forgot-password": "Esqueceu sua senha?",
"send-reset-link": "Enviar link de redefinição",
"reset-sent": "Link de redefinição enviado. Contate o administrador.",
"confirm-password": "Confirmar senha",
"reset-password": "Redefinir senha",
"back-to-login": "Voltar para o login"
}

View File

@@ -3,5 +3,11 @@
"login-oidc": "Autentificare cu {oidc}",
"or": "sau",
"password": "Parolă",
"username": "Nume utilizator"
"username": "Nume utilizator",
"forgot-password": "Ai uitat parola?",
"send-reset-link": "Trimite link de resetare",
"reset-sent": "Linkul de resetare a fost trimis. Contactează administratorul.",
"confirm-password": "Confirmă parola",
"reset-password": "Resetează parola",
"back-to-login": "Înapoi la autentificare"
}

View File

@@ -3,5 +3,11 @@
"login-oidc": "Войти с {oidc}",
"or": "или",
"password": "Пароль",
"username": "Имя пользователя"
"username": "Имя пользователя",
"forgot-password": "Забыли пароль?",
"send-reset-link": "Отправить ссылку для сброса",
"reset-sent": "Ссылка для сброса отправлена. Обратитесь к администратору.",
"confirm-password": "Подтвердите пароль",
"reset-password": "Сбросить пароль",
"back-to-login": "Вернуться к входу"
}

View File

@@ -3,5 +3,11 @@
"login-oidc": "使用 {oidc} 登录",
"or": "或",
"password": "密码",
"username": "用户名"
"username": "用户名",
"forgot-password": "忘记密码?",
"send-reset-link": "发送重置链接",
"reset-sent": "重置链接已发送。请联系管理员。",
"confirm-password": "确认密码",
"reset-password": "重置密码",
"back-to-login": "返回登录"
}

View File

@@ -15,6 +15,7 @@ import romApi from "@/services/api/rom";
export const ROUTES = {
SETUP: "setup",
LOGIN: "login",
RESET_PASSWORD: "reset-password",
MAIN: "main",
HOME: "home",
SEARCH: "search",
@@ -54,6 +55,17 @@ const routes = [
},
],
},
{
path: "/reset-password",
component: () => import("@/layouts/Auth.vue"),
children: [
{
path: "",
name: ROUTES.RESET_PASSWORD,
component: () => import("@/views/Auth/ResetPassword.vue"),
},
],
},
{
path: "/",
name: ROUTES.MAIN,
@@ -188,7 +200,12 @@ const routePermissions: RoutePermissions[] = [
function checkRoutePermissions(route: string, user: User | null): boolean {
// No checks needed for login and setup pages
if (route === ROUTES.LOGIN || route === ROUTES.SETUP) return true;
if (
route === ROUTES.LOGIN ||
route === ROUTES.SETUP ||
route === ROUTES.RESET_PASSWORD
)
return true;
// No user, no access
if (!user) return false;
@@ -216,7 +233,11 @@ router.beforeEach(async (to, _from, next) => {
}
// Handle authentication
if (!user.value && currentRoute !== ROUTES.LOGIN) {
if (
!user.value &&
currentRoute !== ROUTES.LOGIN &&
currentRoute !== ROUTES.RESET_PASSWORD
) {
return next({ name: ROUTES.LOGIN });
}
@@ -238,7 +259,7 @@ router.beforeEach(async (to, _from, next) => {
router.afterEach(() => {
// Scroll to top to avoid annoying behaviour on mobile
window.scrollTo({ top: 0, left: 0 });
// window.scrollTo({ top: 0, left: 0 });
});
router.beforeResolve(async () => {

View File

@@ -23,7 +23,22 @@ async function logout(): Promise<{ data: MessageResponse }> {
return api.post("/logout");
}
async function requestPasswordReset(
username: string,
): Promise<{ data: MessageResponse }> {
return api.post("/forgot-password", { username });
}
async function resetPassword(
token: string,
newPassword: string,
): Promise<{ data: MessageResponse }> {
return api.post("/reset-password", { token, new_password: newPassword });
}
export default {
login,
logout,
requestPasswordReset,
resetPassword,
};

View File

@@ -10,6 +10,7 @@ import { storeToRefs } from "pinia";
import { inject, ref } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { ROUTES } from "@/plugins/router";
// Props
const { t } = useI18n();
@@ -23,11 +24,13 @@ const password = ref("");
const visiblePassword = ref(false);
const loggingIn = ref(false);
const loggingInOIDC = ref(false);
const {
OIDC: { ENABLED: oidcEnabled, PROVIDER: oidcProvider },
FRONTEND: { DISABLE_USERPASS_LOGIN: loginDisabled },
} = heartbeatStore.value;
const forgotMode = ref(false);
const forgotUser = ref("");
const sendingReset = ref(false);
// Functions
async function login() {
@@ -66,6 +69,28 @@ async function login() {
});
}
async function sendReset() {
sendingReset.value = true;
try {
await identityApi.requestPasswordReset(forgotUser.value);
emitter?.emit("snackbarShow", {
msg: t("login.reset-sent"),
icon: "mdi-check-circle",
color: "green",
});
forgotMode.value = false;
forgotUser.value = "";
} catch (error: any) {
emitter?.emit("snackbarShow", {
msg: error.response?.data?.detail || error.message || "Error",
icon: "mdi-alert-circle",
color: "red",
});
} finally {
sendingReset.value = false;
}
}
async function loginOIDC() {
loggingInOIDC.value = true;
window.open("/api/login/openid", "_self");
@@ -75,93 +100,163 @@ async function loginOIDC() {
<template>
<v-card class="translucent-dark py-8 px-5" width="500">
<v-img src="/assets/isotipo.svg" class="mx-auto mb-8" width="80" />
<v-row class="text-white justify-center mt-2" no-gutters>
<v-col cols="10">
<v-form v-if="!loginDisabled" @submit.prevent="login">
<v-text-field
v-model="username"
:label="t('login.username')"
type="text"
required
autocomplete="on"
prepend-inner-icon="mdi-account"
variant="underlined"
/>
<v-text-field
v-model="password"
:label="t('login.password')"
:type="visiblePassword ? 'text' : 'password'"
required
autocomplete="on"
prepend-inner-icon="mdi-lock"
:append-inner-icon="visiblePassword ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="visiblePassword = !visiblePassword"
variant="underlined"
/>
<v-btn
type="submit"
class="bg-toplayer mt-4"
variant="text"
block
:loading="loggingIn"
:disabled="loggingIn || !username || !password || loggingInOIDC"
>
<template #prepend>
<v-icon>mdi-login</v-icon>
</template>
{{ t("login.login") }}
<template #loader>
<v-progress-circular
color="primary"
:width="2"
:size="20"
indeterminate
/>
</template>
</v-btn>
</v-form>
<template v-if="oidcEnabled">
<v-divider v-if="!loginDisabled" class="my-4">
<template #default>
<span class="px-1">{{ t("login.or") }}</span>
</template>
</v-divider>
<v-btn
block
type="submit"
class="bg-toplayer"
variant="text"
:disabled="loggingInOIDC || loggingIn"
:loading="loggingInOIDC"
@click="loginOIDC()"
>
<template v-if="oidcProvider" #prepend>
<v-icon size="20">
<v-img
:src="`/assets/dashboard-icons/${oidcProvider.toLowerCase().replace(/ /g, '-')}.png`"
>
<template #error>
<v-icon size="20">mdi-key</v-icon>
</template>
</v-img>
</v-icon>
</template>
{{
t("login.login-oidc", {
oidc: oidcProvider || "OIDC",
})
}}
<template #loader>
<v-progress-circular
color="primary"
:width="2"
:size="20"
indeterminate
/>
</template>
</v-btn>
</template>
</v-col>
</v-row>
<v-expand-transition>
<v-row
v-if="!forgotMode"
class="text-white justify-center mt-2"
no-gutters
>
<v-col cols="10">
<v-form v-if="!loginDisabled" @submit.prevent="login">
<v-text-field
v-model="username"
:label="t('login.username')"
type="text"
required
autocomplete="on"
prepend-inner-icon="mdi-account"
variant="underlined"
/>
<v-text-field
v-model="password"
:label="t('login.password')"
:type="visiblePassword ? 'text' : 'password'"
required
autocomplete="on"
prepend-inner-icon="mdi-lock"
:append-inner-icon="visiblePassword ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="visiblePassword = !visiblePassword"
variant="underlined"
/>
<v-btn
type="submit"
class="bg-toplayer mt-4"
variant="text"
block
:loading="loggingIn"
:disabled="loggingIn || !username || !password || loggingInOIDC"
>
<template #prepend>
<v-icon>mdi-login</v-icon>
</template>
{{ t("login.login") }}
<template #loader>
<v-progress-circular
color="primary"
:width="2"
:size="20"
indeterminate
/>
</template>
</v-btn>
</v-form>
<template v-if="oidcEnabled">
<v-divider v-if="!loginDisabled" class="my-4">
<template #default>
<span class="px-1">{{ t("login.or") }}</span>
</template>
</v-divider>
<v-btn
block
type="submit"
class="bg-toplayer"
variant="text"
:disabled="loggingInOIDC || loggingIn"
:loading="loggingInOIDC"
@click="loginOIDC()"
>
<template v-if="oidcProvider" #prepend>
<v-icon size="20">
<v-img
:src="`/assets/dashboard-icons/${oidcProvider
.toLowerCase()
.replace(/ /g, '-')}.png`"
>
<template #error>
<v-icon size="20">mdi-key</v-icon>
</template>
</v-img>
</v-icon>
</template>
{{
t("login.login-oidc", {
oidc: oidcProvider || "OIDC",
})
}}
<template #loader>
<v-progress-circular
color="primary"
:width="2"
:size="20"
indeterminate
/>
</template>
</v-btn>
</template>
<div class="my-6 text-right">
<a
class="text-blue text-caption"
href="#"
@click.prevent="forgotMode = true"
>
{{ t("login.forgot-password") }}
</a>
</div>
</v-col>
</v-row>
</v-expand-transition>
<v-expand-transition>
<v-row
v-if="forgotMode"
class="text-white justify-center mt-2"
no-gutters
>
<v-col cols="10">
<v-form @submit.prevent="sendReset">
<v-text-field
v-model="forgotUser"
:label="t('login.username')"
type="text"
required
prepend-inner-icon="mdi-account"
variant="underlined"
/>
<v-btn
type="submit"
class="bg-toplayer mt-4"
variant="text"
block
:loading="sendingReset"
:disabled="sendingReset || !forgotUser"
>
<template #prepend>
<v-icon>mdi-lock-reset</v-icon>
</template>
{{ t("login.send-reset-link") }}
<template #loader>
<v-progress-circular
color="primary"
:width="2"
:size="20"
indeterminate
/>
</template>
</v-btn>
<v-btn
variant="text"
block
class="mt-2"
prepend-icon="mdi-chevron-left"
@click="
forgotMode = false;
forgotUser = '';
"
>
{{ t("common.cancel") }}
</v-btn>
</v-form>
</v-col>
</v-row>
</v-expand-transition>
</v-card>
</template>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import identityApi from "@/services/api/identity";
import { refetchCSRFToken } from "@/services/api/index";
import type { Events } from "@/types/emitter";
import userApi from "@/services/api/user";
import type { Emitter } from "mitt";
import storeAuth from "@/stores/auth";
import { inject, ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
// Props
const { t } = useI18n();
const auth = storeAuth();
const emitter = inject<Emitter<Events>>("emitter");
const route = useRoute();
const router = useRouter();
const token = route.query.token as string;
const newPassword = ref("");
const confirmPassword = ref("");
const visibleNewPassword = ref(false);
const visibleConfirmNewPassword = ref(false);
// Functions
async function resetPassword() {
await identityApi
.resetPassword(token, newPassword.value)
.then(async () => {
await refetchCSRFToken();
try {
const { data: userData } = await userApi.fetchCurrentUser();
auth.setUser(userData);
} catch (error) {
console.error("Error setting a new password: ", error);
}
const params = new URLSearchParams(window.location.search);
router.push(params.get("next") ?? "/");
})
.catch(({ response, message }) => {
const errorMessage =
response.data?.detail ||
response.data ||
message ||
response.statusText;
emitter?.emit("snackbarShow", {
msg: `Unable to reset password: ${errorMessage}`,
icon: "mdi-close-circle",
color: "red",
});
console.error(
`[${response.status} ${response.statusText}] ${errorMessage}`,
);
});
}
</script>
<template>
<v-card class="translucent-dark py-8 px-5" width="500">
<v-img src="/assets/isotipo.svg" class="mx-auto mb-8" width="80" />
<v-row class="text-white justify-center mt-2" no-gutters>
<v-col cols="10">
<v-form @submit.prevent="resetPassword">
<v-text-field
v-model="newPassword"
:label="t('login.password')"
:type="visibleNewPassword ? 'text' : 'password'"
required
:append-inner-icon="visibleNewPassword ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="visibleNewPassword = !visibleNewPassword"
variant="underlined"
/>
<v-text-field
v-model="confirmPassword"
:label="t('login.confirm-password')"
:type="visibleConfirmNewPassword ? 'text' : 'password'"
required
:append-inner-icon="
visibleConfirmNewPassword ? 'mdi-eye-off' : 'mdi-eye'
"
@click:append-inner="
visibleConfirmNewPassword = !visibleConfirmNewPassword
"
variant="underlined"
/>
<span
class="text-red text-caption"
v-if="newPassword !== confirmPassword && newPassword.length > 0"
></span>
<v-btn
type="submit"
class="bg-toplayer mt-4"
variant="text"
block
:disabled="newPassword !== confirmPassword"
>
<template #prepend>
<v-icon>mdi-send</v-icon>
</template>
{{ t("login.reset-password") }}
<template #loader>
<v-progress-circular
color="primary"
:width="2"
:size="20"
indeterminate
/>
</template>
</v-btn>
</v-form>
<div class="my-6 text-right">
<a class="text-blue text-caption" href="/login">
{{ t("login.back-to-login") }}
</a>
</div>
</v-col>
</v-row>
</v-card>
</template>