Implemented user registration flows

Implemented password reset for admins (when no notification provider is enabled)
Major refactoring
This commit is contained in:
grimsi
2024-09-27 02:10:41 +02:00
parent c864a6a491
commit 913ff9d289
27 changed files with 815 additions and 269 deletions
@@ -28,7 +28,7 @@ function UserManagementLayout({getConfig, formik}: any) {
<Section title="Sign-Ups"/>
<div className="flex flex-row">
<ConfigFormField configElement={getConfig("users.sign-ups.allow")}/>
<ConfigFormField configElement={getConfig("users.sign-ups.confirm")}
<ConfigFormField configElement={getConfig("users.sign-ups.confirmation-required")}
isDisabled={!formik.values.users["sign-ups"].allow}/>
</div>
@@ -0,0 +1,56 @@
import React, {useEffect, useState} from "react";
import {Button, Code, Input, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
import {UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
interface ConfirmUserDeletionModalProps {
isOpen: boolean;
onOpenChange: () => void;
user: UserInfoDto;
}
export default function ConfirmUserDeletionModal({
isOpen,
onOpenChange,
user
}: ConfirmUserDeletionModalProps) {
const [confirmUsername, setConfirmUsername] = useState<string>("");
useEffect(() => {
setConfirmUsername("");
}, []);
async function deleteUser() {
await UserEndpoint.deleteUserByName(user.username);
window.location.reload();
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" isDismissable={false}
hideCloseButton={true} size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Confirm user deletion</ModalHeader>
<ModalBody>
<p>
Confirm deletion of user <Code>{user.username}</Code> by entering the username
below
</p>
<Input onChange={(e) => setConfirmUsername(e.target.value)}/>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="danger" onPress={deleteUser}
isDisabled={confirmUsername != user.username}>
Confirm deletion
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -18,7 +18,7 @@ const Input = ({label, ...props}) => {
isInvalid={meta.touched && !!meta.error}
/>
<div className="min-h-6 text-danger">
{meta.touched && meta.error && (
{meta.touched && meta.error && meta.error.trim().length > 0 && (
<SmallInfoField icon={XCircle} message={meta.error}/>
)}
</div>
@@ -0,0 +1,71 @@
import React, {useEffect, useState} from "react";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
import {Input as NextInput} from "@nextui-org/input";
import {WarningCircle} from "@phosphor-icons/react";
import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
import {toast} from "sonner";
interface PasswordResetModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function PasswordResetModal({
isOpen,
onOpenChange
}: PasswordResetModalProps) {
const [canResetPassword, setCanResetPassword] = useState(false);
const [resetEmail, setResetEmail] = useState<string>();
useEffect(() => {
MessageEndpoint.isEnabled().then(setCanResetPassword);
}, []);
async function resetPassword() {
await PasswordResetEndpoint.requestPasswordReset(resetEmail);
toast.success("If the email address is registered, you will receive a message with further instructions.");
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Request a password reset</ModalHeader>
<ModalBody>
{canResetPassword ?
<NextInput
onChange={(event: any) => {
setResetEmail(event.target.value);
}}
type="email"
placeholder="Email"
/> :
<div className="flex flex-row items-center gap-4 text-warning">
<WarningCircle size={40}/>
<p>
Password self-service is disabled.<br/>
To reset your password please contact your administrator.
</p>
</div>
}
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary"
isDisabled={!canResetPassword}
onPress={async () => {
await resetPassword();
onClose();
}}>
Send request
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,69 @@
import React, {useEffect, useState} from "react";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader, Snippet} from "@nextui-org/react";
import TokenDto from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenDto";
import {timeUntil} from "Frontend/util/utils";
interface PasswordResetTokenModalProps {
isOpen: boolean;
onOpenChange: () => void;
token: TokenDto;
}
export default function PasswordResetTokenModal({
isOpen,
onOpenChange,
token
}: PasswordResetTokenModalProps) {
const [timeUntilExpiry, setTimeUntilExpiry] = useState<string>("");
const timeoutRefresh = setInterval(updateTimeUntilExpiry, 1000);
useEffect(updateTimeUntilExpiry, [token]);
useEffect(() => {
return () => {
clearInterval(timeoutRefresh);
};
}, []);
function passwordResetLink() {
return `${document.baseURI}reset-password?token=${token.secret}`;
}
function updateTimeUntilExpiry() {
if (!token) return;
setTimeUntilExpiry(timeUntil(token.expiresAt as string));
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} isDismissable={false}
backdrop="opaque" size="4xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
The user can reset their password using the following link
</ModalHeader>
<ModalBody>
<Snippet symbol="">{passwordResetLink()}</Snippet>
{
!timeUntilExpiry.startsWith("-")
? <small className="text-warning">
This link will expire in {timeUntilExpiry}
</small>
: <small className="text-danger">
This link has expired {timeUntilExpiry.substring(1)} ago
</small>
}
</ModalBody>
<ModalFooter>
<Button color="primary" onPress={onClose}>
OK
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,104 @@
import React from "react";
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
import {RegistrationEndpoint} from "Frontend/generated/endpoints";
import UserRegistrationDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserRegistrationDto";
import {Form, Formik} from "formik";
import * as Yup from "yup";
import Input from "Frontend/components/general/Input";
import {toast} from "sonner";
interface SignUpModalProps {
isOpen: boolean;
onOpenChange: () => void;
}
export default function SignUpModal({
isOpen,
onOpenChange
}: SignUpModalProps) {
async function signUp(registration: UserRegistrationDto, onClose: () => void) {
try {
await RegistrationEndpoint.registerUser({
username: registration.username,
password: registration.password,
email: registration.email
});
onClose();
toast.success('You will receive an email with further instructions shortly.');
} catch (_) {
toast.error('An error occurred while registering your account.');
return;
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<Formik initialValues={{}}
onSubmit={async (values: any, {setFieldError}) => {
let usernameAvailable = await RegistrationEndpoint.isUsernameAvailable(values.username);
if (!usernameAvailable) {
setFieldError('username', 'Username already taken');
return;
} else {
await signUp(values, onClose);
}
}}
validationSchema={Yup.object({
username: Yup.string()
.required('Required'),
password: Yup.string()
.min(8, 'Password must be at least 8 characters long')
.required('Required'),
email: Yup.string()
.email()
.required('Required'),
passwordRepeat: Yup.string()
.equals([Yup.ref('password')], 'Passwords do not match')
.required('Required')
})}>
<Form>
<ModalHeader className="flex flex-col gap-1">Register a new account</ModalHeader>
<ModalBody>
<div className="flex flex-col">
<Input
label="Username"
name="username"
type="text"
/>
<Input
label="E-Mail"
name="email"
type="email"
/>
<Input
label="Password"
name="password"
type="password"
/>
<Input
label="Password (repeat)"
name="passwordRepeat"
type="password"
/>
</div>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" type="submit">
Create account
</Button>
</ModalFooter>
</Form>
</Formik>
)}
</ModalContent>
</Modal>
);
}
@@ -1,42 +1,39 @@
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
import {
Button,
Card,
Chip,
Code,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Input,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
useDisclosure
} from "@nextui-org/react";
import {Card, Chip, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@nextui-org/react";
import {roleToColor, roleToRoleName} from "Frontend/util/utils";
import {DotsThreeVertical} from "@phosphor-icons/react";
import {useAuth} from "Frontend/util/auth";
import {useEffect, useState} from "react";
import {UserEndpoint} from "Frontend/generated/endpoints";
import {MessageEndpoint, PasswordResetEndpoint, RegistrationEndpoint} from "Frontend/generated/endpoints";
import {AvatarEndpoint} from "Frontend/endpoints/endpoints";
import Avatar from "Frontend/components/general/Avatar";
import ConfirmUserDeletionModal from "Frontend/components/general/ConfirmUserDeletionModal";
import PasswordResetTokenModal from "Frontend/components/general/PasswortResetTokenModal";
import TokenDto from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenDto";
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
export function UserManagementCard({user}: { user: UserInfoDto }) {
const {isOpen, onOpen, onOpenChange} = useDisclosure();
const userDeletionConfirmationModal = useDisclosure();
const passwordResetTokenModal = useDisclosure();
const [userEnabled, setUserEnabled] = useState(true);
const [disabledKeys, setDisabledKeys] = useState<string[]>([]);
const [dropdownItems, setDropdownItems] = useState<any[]>([]);
const [passwordResetToken, setPasswordResetToken] = useState<TokenDto>();
const auth = useAuth();
const [confirmUsername, setConfirmUsername] = useState<string>("");
useEffect(() => {
if (!canUserBeDeleted()) setDisabledKeys(["delete"])
setUserEnabled(user.enabled);
let keysToBeDisabled: string[] = [];
MessageEndpoint.isEnabled().then((isEnabled) => {
if (isEnabled) keysToBeDisabled.push("resetPassword");
if (!canUserBeDeleted()) keysToBeDisabled.push("delete")
if (!user.hasAvatar) keysToBeDisabled.push("removeAvatar");
setDisabledKeys(keysToBeDisabled);
});
}, []);
useEffect(() => {
setConfirmUsername("");
}, [isOpen]);
setDropdownItems(getDropdownItems());
}, [userEnabled]);
function canUserBeDeleted(): Boolean {
// User should not be able to delete himself through this menu (can be done via "My profile")
@@ -49,76 +46,98 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
if (auth.state.user?.roles?.includes("ROLE_SUPERADMIN")) return true;
// Admins should be only allowed to delete other users, not other admins
if (user.roles?.includes("ROLE_ADMIN")) return false;
return true;
return !user.roles?.includes("ROLE_ADMIN");
}
async function deleteUser() {
await UserEndpoint.deleteUserByName(user.username);
window.location.reload();
async function resetPassword() {
let token = await PasswordResetEndpoint.createPasswordResetTokenForUser(user.username);
if (token === undefined) return;
setPasswordResetToken(token);
passwordResetTokenModal.onOpen();
}
function getDropdownItems() {
let items = [];
if (!user.enabled) {
items.push(
{
key: "enableUser",
onPress: () => {
RegistrationEndpoint.confirmRegistration(user.username).then(() => {
setUserEnabled(true);
})
},
label: "Enable user"
}
);
}
items.push(
{
key: "removeAvatar",
onPress: () => AvatarEndpoint.removeAvatarByName(user.username!),
label: "Remove avatar"
},
{
key: "resetPassword",
onPress: resetPassword,
label: "Reset password"
},
{
key: "delete",
onPress: userDeletionConfirmationModal.onOpen,
label: "Delete user"
}
);
return items;
}
return (
<Card className="flex flex-row justify-between p-2">
<div className="flex flex-row items-center gap-4">
<Avatar username={user.username}
name={user.username?.charAt(0)}
classNames={{
base: "gradient-primary size-20",
icon: "text-background/80",
name: "text-background/80 text-5xl",
}}/>
<div className="flex flex-col gap-1">
<p className="font-semibold">{user.username}</p>
<p className="text-sm">{user.email}</p>
{user.roles?.map((role) =>
<Chip key={role} size="sm" radius="sm"
className={`text-xs bg-${roleToColor(role!)}-500`}>{roleToRoleName(role!)}</Chip>)}
<>
<Card className={`flex flex-row justify-between p-2 ${userEnabled ? "" : "bg-warning/25"}`}>
<div className="flex flex-row items-center gap-4">
<Avatar username={user.username}
name={user.username?.charAt(0)}
classNames={{
base: "gradient-primary size-20",
icon: "text-background/80",
name: "text-background/80 text-5xl",
}}/>
<div className="flex flex-col gap-1">
<p className="font-semibold">{user.username}</p>
<p className="text-sm">{user.email}</p>
{user.roles?.map((role) =>
<Chip key={role} size="sm" radius="sm"
className={`text-xs bg-${roleToColor(role!)}-500`}>{roleToRoleName(role!)}</Chip>)}
</div>
</div>
</div>
<Dropdown placement="bottom-end" size="sm" backdrop="opaque">
<DropdownTrigger>
<DotsThreeVertical/>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions" disabledKeys={disabledKeys}>
<DropdownItem key="removeAvatar" onPress={() => AvatarEndpoint.removeAvatarByName(user.username!)}>
Remove avatar
</DropdownItem>
<DropdownItem key="delete" className="text-danger" color="danger"
onPress={onOpen}>
Delete user
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" isDismissable={false}
hideCloseButton={true} size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Confirm user deletion</ModalHeader>
<ModalBody>
<p>
Confirm deletion of user <Code>{user.username}</Code> by entering the username
below
</p>
<Input onChange={(e) => setConfirmUsername(e.target.value)}/>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="danger" onPress={deleteUser}
isDisabled={confirmUsername != user.username}>
Confirm deletion
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</Card>
<Dropdown placement="bottom-end" size="sm" backdrop="opaque">
<DropdownTrigger>
<DotsThreeVertical/>
</DropdownTrigger>
<DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}>
{(item) => (
<DropdownItem
key={item.key}
onPress={item.onPress}
color={item.key === "delete" ? "danger" : "default"}
className={item.key === "delete" ? "text-danger" : ""}
>
{item.label}
</DropdownItem>
)}
</DropdownMenu>
</Dropdown>
</Card>
<ConfirmUserDeletionModal isOpen={userDeletionConfirmationModal.isOpen}
onOpenChange={userDeletionConfirmationModal.onOpenChange}
user={user}/>
<PasswordResetTokenModal isOpen={passwordResetTokenModal.isOpen}
onOpenChange={passwordResetTokenModal.onOpenChange}
token={passwordResetToken as TokenDto}/>
</>
)
}