mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 16:20:03 +00:00
Implemented user registration flows
Implemented password reset for admins (when no notification provider is enabled) Major refactoring
This commit is contained in:
@@ -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}/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {type ClassValue, clsx} from "clsx"
|
||||
import {twMerge} from "tailwind-merge"
|
||||
import {getCsrfToken} from "Frontend/util/auth";
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
@@ -47,4 +48,37 @@ export async function fetchWithAuth(url: string, body: any = null, method = "POS
|
||||
method: method,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the time difference between a given Instant and the current time in the user's timezone.
|
||||
* @param {string} instantString - The Instant string returned by the backend.
|
||||
* @param {string} timeZone - The user's timezone.
|
||||
* @returns {string} - The time difference in a human-readable format.
|
||||
*/
|
||||
export function timeUntil(instantString: string, timeZone: string = moment.tz.guess()): string {
|
||||
const givenDate = moment.tz(instantString, timeZone);
|
||||
const now = moment.tz(timeZone);
|
||||
const diffInSeconds = givenDate.diff(now, 'seconds');
|
||||
|
||||
const units = [
|
||||
{name: "year", seconds: 31536000},
|
||||
{name: "month", seconds: 2592000},
|
||||
{name: "day", seconds: 86400},
|
||||
{name: "hour", seconds: 3600},
|
||||
{name: "minute", seconds: 60},
|
||||
{name: "second", seconds: 1}
|
||||
];
|
||||
|
||||
const isPast = diffInSeconds < 0;
|
||||
const absDiffInSeconds = Math.abs(diffInSeconds);
|
||||
|
||||
for (const unit of units) {
|
||||
const value = Math.floor(absDiffInSeconds / unit.seconds);
|
||||
if (value >= 1) {
|
||||
return `${isPast ? '-' : ''}${value} ${unit.name}${value > 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
return "just now";
|
||||
}
|
||||
@@ -1,41 +1,25 @@
|
||||
import {useAuth} from "Frontend/util/auth";
|
||||
import {useEffect, useState} from "react";
|
||||
import {WarningCircle, XCircle} from "@phosphor-icons/react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Input,
|
||||
Link,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
useDisclosure
|
||||
} from "@nextui-org/react";
|
||||
import {Alert, AlertDescription, AlertTitle} from "Frontend/@/components/ui/alert";
|
||||
import {Button, Card, CardBody, CardHeader, Link, useDisclosure} from "@nextui-org/react";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
||||
import {toast} from "sonner";
|
||||
import {Form, Formik} from "formik";
|
||||
import Input from "Frontend/components/general/Input";
|
||||
import PasswordResetModal from "Frontend/components/general/PasswordResetModal";
|
||||
import SignUpModal from "Frontend/components/general/SignUpModal";
|
||||
import {RegistrationEndpoint} from "Frontend/generated/endpoints";
|
||||
|
||||
export default function LoginView() {
|
||||
const {state, login} = useAuth();
|
||||
const {isOpen, onOpen, onOpenChange} = useDisclosure();
|
||||
|
||||
const [hasError, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [username, setUsername] = useState<string>();
|
||||
const [password, setPassword] = useState<string>();
|
||||
const [canResetPassword, setCanResetPassword] = useState(false);
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [resetEmail, setResetEmail] = useState<string>();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const passwordResetModal = useDisclosure();
|
||||
const signUpModal = useDisclosure();
|
||||
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [signUpAllowed, setSignUpAllowed] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
MessageEndpoint.isEnabled().then(setCanResetPassword);
|
||||
RegistrationEndpoint.isSelfRegistrationAllowed().then(setSignUpAllowed);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,9 +29,14 @@ export default function LoginView() {
|
||||
}
|
||||
}, [state.user]);
|
||||
|
||||
async function resetPassword() {
|
||||
await PasswordResetEndpoint.requestPasswordReset(resetEmail);
|
||||
toast.success("If the email address is registered, you will receive a message with further instructions.");
|
||||
async function tryLogin(values: any, formik: any) {
|
||||
const {defaultUrl, error, redirectUrl} = await login(values.username, values.password);
|
||||
if (!error) {
|
||||
setUrl(redirectUrl ?? defaultUrl ?? '/');
|
||||
} else {
|
||||
formik.setFieldError("username", " "); // Mark the field red, but don't show an error message
|
||||
formik.setFieldError("password", "Invalid username and/or password.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -61,109 +50,47 @@ export default function LoginView() {
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardBody className="mt-8 mb-2 w-80 max-w-screen-lg sm:w-96">
|
||||
{hasError &&
|
||||
<Alert className="mb-4" variant="destructive">
|
||||
<XCircle weight="fill" className="size-4"/>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>Wrong username and/or password</AlertDescription>
|
||||
</Alert>
|
||||
}
|
||||
<form
|
||||
className="mb-1 flex flex-col gap-6"
|
||||
onSubmit={async e => {
|
||||
e.preventDefault();
|
||||
if (typeof username === "string" && password != null) {
|
||||
setLoading(true);
|
||||
const {defaultUrl, error, redirectUrl} = await login(username, password);
|
||||
if (error) {
|
||||
setError(true);
|
||||
} else {
|
||||
setUrl(redirectUrl ?? defaultUrl ?? '/');
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label htmlFor="username">
|
||||
<h6 color="blue-gray" className="-mb-3">
|
||||
Username
|
||||
</h6>
|
||||
</label>
|
||||
<Input
|
||||
onChange={(event: any) => {
|
||||
setUsername(event.target.value);
|
||||
}}
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
placeholder=""
|
||||
/>
|
||||
<label htmlFor="current-password">
|
||||
<h6 color="blue-gray" className="-mb-3">
|
||||
Password
|
||||
</h6>
|
||||
</label>
|
||||
<Input
|
||||
onChange={(event: any) => {
|
||||
setPassword(event.target.value);
|
||||
}}
|
||||
id="current-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder=""
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<Link color="foreground" underline="always" onPress={onOpen}>
|
||||
Forgot password?
|
||||
</Link>
|
||||
<Button color="primary" type="submit" isLoading={loading}>
|
||||
{loading ? "" : "Log in"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<Formik
|
||||
initialValues={{}}
|
||||
onSubmit={tryLogin}>
|
||||
{(formik: { isSubmitting: any; }) => (
|
||||
<Form className="mb-1 flex flex-col gap-6">
|
||||
<Input
|
||||
name="username"
|
||||
label="Username"
|
||||
autoComplete="username"
|
||||
/>
|
||||
<Input
|
||||
name="password"
|
||||
label="Password"
|
||||
autoComplete="current-password"
|
||||
type="password"
|
||||
/>
|
||||
<div className="flex justify-between items-center">
|
||||
<Link color="foreground" underline="always" href="#"
|
||||
onPress={passwordResetModal.onOpen}>
|
||||
Forgot password?
|
||||
</Link>
|
||||
<div className="flex flex-row gap-2">
|
||||
{signUpAllowed &&
|
||||
<Button color="default" variant="light"
|
||||
onPress={signUpModal.onOpen}>
|
||||
Sign up
|
||||
</Button>
|
||||
}
|
||||
<Button color="primary" type="submit" isLoading={formik.isSubmitting}>
|
||||
{formik.isSubmitting ? "" : "Log in"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader className="flex flex-col gap-1">Request a password reset</ModalHeader>
|
||||
<ModalBody>
|
||||
{canResetPassword ?
|
||||
<Input
|
||||
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>
|
||||
<PasswordResetModal isOpen={passwordResetModal.isOpen} onOpenChange={passwordResetModal.onOpenChange}/>
|
||||
<SignUpModal isOpen={signUpModal.isOpen} onOpenChange={signUpModal.onOpenChange}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -57,11 +57,11 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
false
|
||||
)
|
||||
|
||||
data object Confirm : ConfigProperties<Boolean>(
|
||||
data object ConfirmationRequired : ConfigProperties<Boolean>(
|
||||
Boolean::class,
|
||||
"users.sign-ups.confirm",
|
||||
"users.sign-ups.confirmation-required",
|
||||
"Admins need to confirm new users",
|
||||
false
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,11 @@ class Utils {
|
||||
val scheme = request.scheme
|
||||
val serverName = request.serverName
|
||||
val serverPort = request.serverPort
|
||||
val contextPath = request.contextPath
|
||||
|
||||
return if (serverPort == 80 || serverPort == 443) {
|
||||
"$scheme://$serverName$contextPath"
|
||||
"$scheme://$serverName"
|
||||
} else {
|
||||
"$scheme://$serverName:$serverPort$contextPath"
|
||||
"$scheme://$serverName:$serverPort"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,17 @@ package de.grimsi.gameyfin.core.events
|
||||
|
||||
import de.grimsi.gameyfin.shared.token.Token
|
||||
import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
import org.springframework.context.ApplicationEvent
|
||||
|
||||
class UserInvitationEvent(source: Any) : ApplicationEvent(source)
|
||||
|
||||
class UserRegistrationEvent(source: Any) : ApplicationEvent(source)
|
||||
class UserRegistrationWaitingForApprovalEvent(source: Any, val newUser: User) : ApplicationEvent(source)
|
||||
|
||||
class UserRegistrationEvent(source: Any, val newUser: User, val baseUrl: String) : ApplicationEvent(source)
|
||||
|
||||
class RegistrationAttemptWithExistingEmailEvent(source: Any, val existingUser: User, val baseUrl: String) :
|
||||
ApplicationEvent(source)
|
||||
|
||||
class PasswordResetRequestEvent(source: Any, val token: Token<PasswordReset>, val baseUrl: String) :
|
||||
ApplicationEvent(source)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package de.grimsi.gameyfin.messages
|
||||
|
||||
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
||||
import de.grimsi.gameyfin.core.events.RegistrationAttemptWithExistingEmailEvent
|
||||
import de.grimsi.gameyfin.core.events.UserRegistrationEvent
|
||||
import de.grimsi.gameyfin.core.events.UserRegistrationWaitingForApprovalEvent
|
||||
import de.grimsi.gameyfin.messages.providers.AbstractMessageProvider
|
||||
import de.grimsi.gameyfin.messages.templates.MessageTemplateService
|
||||
import de.grimsi.gameyfin.messages.templates.MessageTemplates
|
||||
@@ -95,4 +98,64 @@ class MessageService(
|
||||
mapOf("username" to token.user.username, "resetLink" to resetLink)
|
||||
)
|
||||
}
|
||||
|
||||
@Async
|
||||
@EventListener(UserRegistrationWaitingForApprovalEvent::class)
|
||||
fun onUserRegistrationWaitingForApproval(event: UserRegistrationWaitingForApprovalEvent) {
|
||||
|
||||
if (!enabled) {
|
||||
log.error { "No notification provider available, can't send 'waiting for approval' message" }
|
||||
return
|
||||
}
|
||||
|
||||
log.info { "Sending waiting for approval notification" }
|
||||
|
||||
val user = event.newUser
|
||||
sendNotification(
|
||||
user.email,
|
||||
"[Gameyfin] Waiting for Approval",
|
||||
MessageTemplates.WaitingForApproval,
|
||||
mapOf("username" to user.username)
|
||||
)
|
||||
}
|
||||
|
||||
@Async
|
||||
@EventListener(UserRegistrationEvent::class)
|
||||
fun onUserRegistration(event: UserRegistrationEvent) {
|
||||
|
||||
if (!enabled) {
|
||||
log.error { "No notification provider available, can't send registration message" }
|
||||
return
|
||||
}
|
||||
|
||||
log.info { "Sending registration notification" }
|
||||
|
||||
val user = event.newUser
|
||||
sendNotification(
|
||||
user.email,
|
||||
"[Gameyfin] Welcome",
|
||||
MessageTemplates.Welcome,
|
||||
mapOf("username" to user.username, "baseUrl" to event.baseUrl)
|
||||
)
|
||||
}
|
||||
|
||||
@Async
|
||||
@EventListener(RegistrationAttemptWithExistingEmailEvent::class)
|
||||
fun onRegistrationAttemptWithExistingEmail(event: RegistrationAttemptWithExistingEmailEvent) {
|
||||
|
||||
if (!enabled) {
|
||||
log.error { "No notification provider available, can't send 'registration attempt with existing email' message" }
|
||||
return
|
||||
}
|
||||
|
||||
log.info { "Sending registration attempt with existing email notification" }
|
||||
|
||||
val user = event.existingUser
|
||||
sendNotification(
|
||||
user.email,
|
||||
"[Gameyfin] Account alert",
|
||||
MessageTemplates.RegistrationAttemptWithExistingEmail,
|
||||
mapOf("username" to user.username, "passwordResetLink" to event.baseUrl)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,25 @@ sealed class MessageTemplates(
|
||||
listOf("invitationLink")
|
||||
)
|
||||
|
||||
data object WaitingForApproval : MessageTemplates(
|
||||
"waiting-for-approval",
|
||||
"Waiting for approval",
|
||||
"Template for the waiting for approval message for new users",
|
||||
listOf("username")
|
||||
)
|
||||
|
||||
data object Welcome : MessageTemplates(
|
||||
"welcome",
|
||||
"Welcome",
|
||||
"Template for the welcome message for new users",
|
||||
listOf("username")
|
||||
listOf("username", "baseUrl")
|
||||
)
|
||||
|
||||
data object RegistrationAttemptWithExistingEmail : MessageTemplates(
|
||||
"email-already-registered",
|
||||
"Someone tried to register with your email",
|
||||
"Template for the email already registered message",
|
||||
listOf("username", "passwordResetLink")
|
||||
)
|
||||
|
||||
data object EmailConfirmation : MessageTemplates(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.grimsi.gameyfin.shared.token
|
||||
|
||||
import java.time.Instant
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
data class TokenDto(
|
||||
val secret: String,
|
||||
val type: String,
|
||||
val expiresAt: Instant
|
||||
) {
|
||||
constructor(token: Token<*>) : this(
|
||||
secret = token.secret,
|
||||
type = token.type.key,
|
||||
expiresAt = token.createdOn?.plus(token.type.expiration.toJavaDuration()) ?: Instant.MIN
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package de.grimsi.gameyfin.users
|
||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.shared.token.TokenDto
|
||||
import de.grimsi.gameyfin.shared.token.TokenValidationResult
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@@ -20,8 +21,8 @@ class PasswordResetEndpoint(
|
||||
}
|
||||
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
fun createPasswordResetTokenForUser(username: String): String {
|
||||
return passwordResetService.generate(username).secret
|
||||
fun createPasswordResetTokenForUser(username: String): TokenDto {
|
||||
return passwordResetService.generate(username)
|
||||
}
|
||||
|
||||
fun resetPassword(secret: String, newPassword: String): TokenValidationResult {
|
||||
|
||||
@@ -3,11 +3,8 @@ package de.grimsi.gameyfin.users
|
||||
import de.grimsi.gameyfin.core.Utils
|
||||
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
||||
import de.grimsi.gameyfin.messages.MessageService
|
||||
import de.grimsi.gameyfin.shared.token.Token
|
||||
import de.grimsi.gameyfin.shared.token.TokenRepository
|
||||
import de.grimsi.gameyfin.shared.token.TokenService
|
||||
import de.grimsi.gameyfin.shared.token.*
|
||||
import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset
|
||||
import de.grimsi.gameyfin.shared.token.TokenValidationResult
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
@@ -27,9 +24,6 @@ class PasswordResetService(
|
||||
|
||||
private val secureRandom = SecureRandom()
|
||||
|
||||
private val baseUrl: String
|
||||
get() = Utils.getBaseUrl()
|
||||
|
||||
override fun generate(user: User): Token<PasswordReset> {
|
||||
if (user.oidcProviderId != null) {
|
||||
throw IllegalStateException("Cannot create password reset token for user '${user.username}' because user is managed externally")
|
||||
@@ -44,7 +38,7 @@ class PasswordResetService(
|
||||
* - The user has no confirmed email address
|
||||
* - The user is not managed externally
|
||||
*/
|
||||
fun generate(username: String): Token<PasswordReset> {
|
||||
fun generate(username: String): TokenDto {
|
||||
if (messageService.enabled) {
|
||||
throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled")
|
||||
}
|
||||
@@ -52,11 +46,12 @@ class PasswordResetService(
|
||||
val user = userService.getByUsername(username)
|
||||
?: throw IllegalArgumentException("Cannot create password reset token for user '$username' because user does not exist")
|
||||
|
||||
if (user.emailConfirmed == true) {
|
||||
if (user.emailConfirmed) {
|
||||
throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled")
|
||||
}
|
||||
|
||||
return generate(user)
|
||||
val token = generate(user)
|
||||
return TokenDto(token)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +83,7 @@ class PasswordResetService(
|
||||
}
|
||||
|
||||
val token = generate(user)
|
||||
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, baseUrl))
|
||||
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, Utils.getBaseUrl()))
|
||||
|
||||
// Simulate a delay to prevent timing attacks
|
||||
Thread.sleep(secureRandom.nextLong(1024))
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.grimsi.gameyfin.users
|
||||
|
||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@AnonymousAllowed
|
||||
@Endpoint
|
||||
class RegistrationEndpoint(
|
||||
private val userService: UserService
|
||||
) {
|
||||
fun isSelfRegistrationAllowed(): Boolean {
|
||||
return userService.selfRegistrationAllowed
|
||||
}
|
||||
|
||||
fun registerUser(registration: UserRegistrationDto) {
|
||||
userService.selfRegisterUser(registration)
|
||||
|
||||
// No return value to prevent enumeration attacks
|
||||
}
|
||||
|
||||
fun isUsernameAvailable(username: String): Boolean {
|
||||
return !userService.existsByUsername(username)
|
||||
}
|
||||
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
fun confirmRegistration(username: String) {
|
||||
userService.confirmRegistration(username)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@ package de.grimsi.gameyfin.users
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.users.dto.UserInfoDto
|
||||
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
|
||||
import de.grimsi.gameyfin.users.dto.UserUpdateDto
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
import jakarta.annotation.security.PermitAll
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
import org.springframework.security.core.Authentication
|
||||
@@ -16,7 +14,6 @@ import org.springframework.security.core.context.SecurityContextHolder
|
||||
class UserEndpoint(
|
||||
private val userService: UserService
|
||||
) {
|
||||
|
||||
@PermitAll
|
||||
fun getUserInfo(): UserInfoDto {
|
||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
||||
@@ -28,12 +25,6 @@ class UserEndpoint(
|
||||
return userService.getAllUsers()
|
||||
}
|
||||
|
||||
@PermitAll
|
||||
fun registerUser(registration: UserRegistrationDto): UserInfoDto {
|
||||
val user: User = registerUser(registration, listOf(Roles.USER))
|
||||
return userService.toUserInfo(user)
|
||||
}
|
||||
|
||||
@PermitAll
|
||||
fun updateUser(updates: UserUpdateDto) {
|
||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
||||
@@ -55,14 +46,4 @@ class UserEndpoint(
|
||||
fun deleteUserByName(username: String) {
|
||||
userService.deleteUser(username)
|
||||
}
|
||||
|
||||
private fun registerUser(registration: UserRegistrationDto, roles: List<Roles>): User {
|
||||
val user = User(
|
||||
username = registration.username,
|
||||
password = registration.password,
|
||||
email = registration.email
|
||||
)
|
||||
|
||||
return userService.registerOrUpdateUser(user, roles)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
package de.grimsi.gameyfin.users
|
||||
|
||||
import de.grimsi.gameyfin.config.ConfigProperties
|
||||
import de.grimsi.gameyfin.config.ConfigService
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import de.grimsi.gameyfin.core.Utils
|
||||
import de.grimsi.gameyfin.core.events.RegistrationAttemptWithExistingEmailEvent
|
||||
import de.grimsi.gameyfin.core.events.UserRegistrationEvent
|
||||
import de.grimsi.gameyfin.core.events.UserRegistrationWaitingForApprovalEvent
|
||||
import de.grimsi.gameyfin.users.dto.UserInfoDto
|
||||
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
|
||||
import de.grimsi.gameyfin.users.dto.UserUpdateDto
|
||||
import de.grimsi.gameyfin.users.entities.Avatar
|
||||
import de.grimsi.gameyfin.users.entities.Role
|
||||
@@ -10,6 +17,7 @@ import de.grimsi.gameyfin.users.persistence.AvatarContentStore
|
||||
import de.grimsi.gameyfin.users.persistence.UserRepository
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.transaction.Transactional
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
@@ -30,11 +38,16 @@ class UserService(
|
||||
private val avatarStore: AvatarContentStore,
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val roleService: RoleService,
|
||||
private val sessionService: SessionService
|
||||
private val sessionService: SessionService,
|
||||
private val config: ConfigService,
|
||||
private val eventPublisher: ApplicationEventPublisher
|
||||
) : UserDetailsService {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
val selfRegistrationAllowed: Boolean
|
||||
get() = config.get(ConfigProperties.Users.SignUps.Allow) == true
|
||||
|
||||
override fun loadUserByUsername(username: String): UserDetails {
|
||||
val user = userByUsername(username)
|
||||
|
||||
@@ -124,6 +137,39 @@ class UserService(
|
||||
return userRepository.save(user)
|
||||
}
|
||||
|
||||
fun selfRegisterUser(registration: UserRegistrationDto) {
|
||||
if (!selfRegistrationAllowed) {
|
||||
throw IllegalStateException("Sign ups are not allowed")
|
||||
}
|
||||
|
||||
if (existsByUsername(registration.username)) {
|
||||
throw IllegalStateException("User with username '${registration.username}' already exists")
|
||||
}
|
||||
|
||||
userRepository.findByEmail(registration.email)?.let {
|
||||
eventPublisher.publishEvent(RegistrationAttemptWithExistingEmailEvent(this, it, Utils.getBaseUrl()))
|
||||
return
|
||||
}
|
||||
|
||||
val adminNeedsToApprove = config.get(ConfigProperties.Users.SignUps.ConfirmationRequired) == true
|
||||
|
||||
var user = User(
|
||||
username = registration.username,
|
||||
password = passwordEncoder.encode(registration.password),
|
||||
email = registration.email,
|
||||
enabled = !adminNeedsToApprove,
|
||||
roles = roleService.toRoles(listOf(Roles.USER))
|
||||
)
|
||||
|
||||
user = userRepository.save(user)
|
||||
|
||||
if (adminNeedsToApprove) {
|
||||
eventPublisher.publishEvent(UserRegistrationWaitingForApprovalEvent(this, user))
|
||||
} else {
|
||||
eventPublisher.publishEvent(UserRegistrationEvent(this, user, Utils.getBaseUrl()))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUser(username: String, updates: UserUpdateDto) {
|
||||
val user = userByUsername(username)
|
||||
|
||||
@@ -147,6 +193,13 @@ class UserService(
|
||||
userRepository.save(user)
|
||||
}
|
||||
|
||||
fun confirmRegistration(username: String) {
|
||||
val user = userByUsername(username)
|
||||
user.enabled = true
|
||||
userRepository.save(user)
|
||||
eventPublisher.publishEvent(UserRegistrationEvent(this, user, Utils.getBaseUrl()))
|
||||
}
|
||||
|
||||
fun deleteUser(username: String) {
|
||||
val user = userByUsername(username)
|
||||
userRepository.delete(user)
|
||||
@@ -157,6 +210,8 @@ class UserService(
|
||||
username = user.username,
|
||||
email = user.email,
|
||||
emailConfirmed = user.emailConfirmed,
|
||||
isEnabled = user.enabled,
|
||||
hasAvatar = user.avatar != null,
|
||||
managedBySso = user.oidcProviderId != null,
|
||||
roles = user.roles.map { r -> r.rolename }
|
||||
)
|
||||
|
||||
@@ -5,5 +5,7 @@ data class UserInfoDto(
|
||||
val managedBySso: Boolean,
|
||||
val email: String,
|
||||
val emailConfirmed: Boolean,
|
||||
val isEnabled: Boolean,
|
||||
val hasAvatar: Boolean,
|
||||
var roles: List<String>
|
||||
)
|
||||
@@ -27,8 +27,7 @@ class User(
|
||||
@Convert(converter = EncryptionConverter::class)
|
||||
var email: String,
|
||||
|
||||
// TODO: Add email confirmation
|
||||
var emailConfirmed: Boolean = true,
|
||||
var emailConfirmed: Boolean = false,
|
||||
|
||||
var enabled: Boolean = true,
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-title>[Gameyfin] Someone tried to register with your email</mj-title>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="Arial, sans-serif"/>
|
||||
<mj-text font-size="16px"/>
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-image width="128px" src="{logo}"/>
|
||||
<mj-image height="2px" padding-bottom="20px" src="{gradient}"/>
|
||||
|
||||
<mj-text font-size="20px" font-family="helvetica">Hello there {username},
|
||||
<br/>
|
||||
<br/>
|
||||
</mj-text>
|
||||
<mj-text>someone just tried to register a new account with this email address.
|
||||
<br/>
|
||||
<br/>
|
||||
If you just forgot your password, you can reset it by visiting <a href="{passwordResetLink}">
|
||||
Gameyfin
|
||||
</a> and
|
||||
clicking on the "Forgot password?" button.
|
||||
<br/>
|
||||
<br/>
|
||||
If this wasn't you, please ignore this email.
|
||||
</mj-text>
|
||||
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -0,0 +1,28 @@
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-title>[Gameyfin] Waiting for Approval</mj-title>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="Arial, sans-serif"/>
|
||||
<mj-text font-size="16px"/>
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-image width="128px" src="{logo}"/>
|
||||
<mj-image height="2px" padding-bottom="20px" src="{gradient}"/>
|
||||
|
||||
<mj-text font-size="20px" font-family="helvetica">Hello there {username},
|
||||
<br/>
|
||||
<br/>
|
||||
</mj-text>
|
||||
<mj-text>your registration was successful!
|
||||
<br/>
|
||||
Before you can log in, you need to wait for an admin to approve your account.
|
||||
We will notify you as soon as your account is approved.
|
||||
</mj-text>
|
||||
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -16,9 +16,9 @@
|
||||
<br/>
|
||||
<br/>
|
||||
</mj-text>
|
||||
<mj-text>your registration was successful!
|
||||
<mj-text>your registration has been approved!
|
||||
<br/>
|
||||
You can now start browsing games in Gameyfin.
|
||||
You can now start browsing games in <a href="{baseUrl}">Gameyfin</a>.
|
||||
</mj-text>
|
||||
|
||||
</mj-column>
|
||||
|
||||
Reference in New Issue
Block a user