mirror of
https://github.com/rommapp/romm.git
synced 2026-02-18 00:27:41 +01:00
feat: Reset forgotten password added
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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."}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -32,6 +32,12 @@ const HEADERS = [
|
||||
sortable: true,
|
||||
key: "username",
|
||||
},
|
||||
{
|
||||
title: "Email",
|
||||
align: "start",
|
||||
sortable: true,
|
||||
key: "email",
|
||||
},
|
||||
{
|
||||
title: "Role",
|
||||
align: "start",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "ログインに戻る"
|
||||
}
|
||||
|
||||
@@ -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": "로그인으로 돌아가기"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Вернуться к входу"
|
||||
}
|
||||
|
||||
@@ -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": "返回登录"
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
118
frontend/src/views/Auth/ResetPassword.vue
Normal file
118
frontend/src/views/Auth/ResetPassword.vue
Normal 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>
|
||||
Reference in New Issue
Block a user