diff --git a/package-lock.json b/package-lock.json index 8331fc9..3be4431 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,8 @@ "framer-motion": "^11.3.28", "http-status-codes": "^2.3.0", "lit": "3.1.4", + "moment": "^2.30.1", + "moment-timezone": "^0.5.45", "next-themes": "^0.3.0", "react": "18.3.1", "react-dom": "18.3.1", @@ -9325,6 +9327,7 @@ "version": "2.29.3", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==", + "license": "MIT", "engines": { "node": ">=0.11" }, @@ -11391,6 +11394,27 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.45", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz", + "integrity": "sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -22831,6 +22855,19 @@ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" }, + "moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==" + }, + "moment-timezone": { + "version": "0.5.45", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz", + "integrity": "sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==", + "requires": { + "moment": "^2.30.1" + } + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", diff --git a/package.json b/package.json index edaee18..76c0825 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "framer-motion": "^11.3.28", "http-status-codes": "^2.3.0", "lit": "3.1.4", + "moment": "^2.30.1", + "moment-timezone": "^0.5.45", "next-themes": "^0.3.0", "react": "18.3.1", "react-dom": "18.3.1", @@ -118,7 +120,9 @@ "@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin", "@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles", "@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles", - "cron-validator": "$cron-validator" + "cron-validator": "$cron-validator", + "moment": "$moment", + "moment-timezone": "$moment-timezone" }, "vaadin": { "dependencies": { @@ -177,6 +181,6 @@ "workbox-core": "7.1.0", "workbox-precaching": "7.1.0" }, - "hash": "0f5c2e2c0e435bad7d8e5153f11e7caac2a6024927de828fb8a6192acc6fd341" + "hash": "9af3f915316df3b36e94b855108f7148a48217645c62f7b0e5a970f3030981b5" } } diff --git a/src/main/frontend/components/administration/UserManagement.tsx b/src/main/frontend/components/administration/UserManagement.tsx index c666790..e44180a 100644 --- a/src/main/frontend/components/administration/UserManagement.tsx +++ b/src/main/frontend/components/administration/UserManagement.tsx @@ -28,7 +28,7 @@ function UserManagementLayout({getConfig, formik}: any) {
-
diff --git a/src/main/frontend/components/general/ConfirmUserDeletionModal.tsx b/src/main/frontend/components/general/ConfirmUserDeletionModal.tsx new file mode 100644 index 0000000..207f24a --- /dev/null +++ b/src/main/frontend/components/general/ConfirmUserDeletionModal.tsx @@ -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(""); + + useEffect(() => { + setConfirmUsername(""); + }, []); + + async function deleteUser() { + await UserEndpoint.deleteUserByName(user.username); + window.location.reload(); + } + + return ( + + + {(onClose) => ( + <> + Confirm user deletion + +

+ Confirm deletion of user {user.username} by entering the username + below +

+ setConfirmUsername(e.target.value)}/> +
+ + + + + + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/main/frontend/components/general/Input.tsx b/src/main/frontend/components/general/Input.tsx index 01218c7..21ceceb 100644 --- a/src/main/frontend/components/general/Input.tsx +++ b/src/main/frontend/components/general/Input.tsx @@ -18,7 +18,7 @@ const Input = ({label, ...props}) => { isInvalid={meta.touched && !!meta.error} />
- {meta.touched && meta.error && ( + {meta.touched && meta.error && meta.error.trim().length > 0 && ( )}
diff --git a/src/main/frontend/components/general/PasswordResetModal.tsx b/src/main/frontend/components/general/PasswordResetModal.tsx new file mode 100644 index 0000000..5c5b0f9 --- /dev/null +++ b/src/main/frontend/components/general/PasswordResetModal.tsx @@ -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(); + + 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 ( + + + {(onClose) => ( + <> + Request a password reset + + {canResetPassword ? + { + setResetEmail(event.target.value); + }} + type="email" + placeholder="Email" + /> : +
+ +

+ Password self-service is disabled.
+ To reset your password please contact your administrator. +

+
+ } +
+ + + + + + )} +
+
+ ); +} \ No newline at end of file diff --git a/src/main/frontend/components/general/PasswortResetTokenModal.tsx b/src/main/frontend/components/general/PasswortResetTokenModal.tsx new file mode 100644 index 0000000..1f7e929 --- /dev/null +++ b/src/main/frontend/components/general/PasswortResetTokenModal.tsx @@ -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(""); + + 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 ( + + + {(onClose) => ( + <> + + The user can reset their password using the following link + + + {passwordResetLink()} + { + !timeUntilExpiry.startsWith("-") + ? + This link will expire in {timeUntilExpiry} + + : + This link has expired {timeUntilExpiry.substring(1)} ago + + } + + + + + + )} + + + ); +} \ No newline at end of file diff --git a/src/main/frontend/components/general/SignUpModal.tsx b/src/main/frontend/components/general/SignUpModal.tsx new file mode 100644 index 0000000..e67a369 --- /dev/null +++ b/src/main/frontend/components/general/SignUpModal.tsx @@ -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 ( + + + {(onClose) => ( + { + 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') + })}> +
+ Register a new account + +
+ + + + +
+
+ + + + +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/main/frontend/components/general/UserManagementCard.tsx b/src/main/frontend/components/general/UserManagementCard.tsx index 0265c56..1641990 100644 --- a/src/main/frontend/components/general/UserManagementCard.tsx +++ b/src/main/frontend/components/general/UserManagementCard.tsx @@ -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([]); + const [dropdownItems, setDropdownItems] = useState([]); + const [passwordResetToken, setPasswordResetToken] = useState(); const auth = useAuth(); - const [confirmUsername, setConfirmUsername] = useState(""); 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 ( - -
- -
-

{user.username}

-

{user.email}

- {user.roles?.map((role) => - {roleToRoleName(role!)})} + <> + +
+ +
+

{user.username}

+

{user.email}

+ {user.roles?.map((role) => + {roleToRoleName(role!)})} +
-
- - - - - - AvatarEndpoint.removeAvatarByName(user.username!)}> - Remove avatar - - - Delete user - - - - - - - {(onClose) => ( - <> - Confirm user deletion - -

- Confirm deletion of user {user.username} by entering the username - below -

- setConfirmUsername(e.target.value)}/> -
- - - - - - )} -
-
- + + + + + + {(item) => ( + + {item.label} + + )} + + + + + + ) } \ No newline at end of file diff --git a/src/main/frontend/util/utils.ts b/src/main/frontend/util/utils.ts index edc40a4..0f271b1 100644 --- a/src/main/frontend/util/utils.ts +++ b/src/main/frontend/util/utils.ts @@ -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"; } \ No newline at end of file diff --git a/src/main/frontend/views/LoginView.tsx b/src/main/frontend/views/LoginView.tsx index 5f87f0c..4258988 100644 --- a/src/main/frontend/views/LoginView.tsx +++ b/src/main/frontend/views/LoginView.tsx @@ -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(); - const [password, setPassword] = useState(); - const [canResetPassword, setCanResetPassword] = useState(false); - const [url, setUrl] = useState(); - const [resetEmail, setResetEmail] = useState(); - const navigate = useNavigate(); + const passwordResetModal = useDisclosure(); + const signUpModal = useDisclosure(); + + const [url, setUrl] = useState(); + const [signUpAllowed, setSignUpAllowed] = useState(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() { /> - {hasError && - - - Error - Wrong username and/or password - - } -
{ - 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); - } - }} - > - - { - setUsername(event.target.value); - }} - id="username" - type="text" - autoComplete="username" - placeholder="" - /> - - { - setPassword(event.target.value); - }} - id="current-password" - type="password" - autoComplete="current-password" - placeholder="" - /> -
- - Forgot password? - - -
-
+ + {(formik: { isSubmitting: any; }) => ( +
+ + +
+ + Forgot password? + +
+ {signUpAllowed && + + } + +
+
+
+ )} +
- - - {(onClose) => ( - <> - Request a password reset - - {canResetPassword ? - { - setResetEmail(event.target.value); - }} - type="email" - placeholder="Email" - /> : -
- -

- Password self-service is disabled.
- To reset your password please contact your administrator. -

-
- } -
- - - - - - )} -
-
+ +
); } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt index 06b20fb..f26f8f8 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigProperties.kt @@ -57,11 +57,11 @@ sealed class ConfigProperties( false ) - data object Confirm : ConfigProperties( + data object ConfirmationRequired : ConfigProperties( Boolean::class, - "users.sign-ups.confirm", + "users.sign-ups.confirmation-required", "Admins need to confirm new users", - false + true ) } } diff --git a/src/main/kotlin/de/grimsi/gameyfin/core/Utils.kt b/src/main/kotlin/de/grimsi/gameyfin/core/Utils.kt index 08b89e4..fa9e9f0 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/core/Utils.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/core/Utils.kt @@ -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" } } } diff --git a/src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt b/src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt index f9bfca6..40e90fb 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/core/events/Events.kt @@ -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, val baseUrl: String) : ApplicationEvent(source) diff --git a/src/main/kotlin/de/grimsi/gameyfin/messages/MessageService.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/MessageService.kt index f3c7b3f..2cc65b7 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/messages/MessageService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/MessageService.kt @@ -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) + ) + } } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplates.kt b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplates.kt index 2e0e5a3..7db7ba6 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplates.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/messages/templates/MessageTemplates.kt @@ -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( diff --git a/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenDto.kt b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenDto.kt new file mode 100644 index 0000000..04641a8 --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/shared/token/TokenDto.kt @@ -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 + ) +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt index 902ba93..31cfab1 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt @@ -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 { diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt index b981ae9..e85eba4 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt @@ -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 { 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 { + 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)) diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/RegistrationEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/RegistrationEndpoint.kt new file mode 100644 index 0000000..786f2bb --- /dev/null +++ b/src/main/kotlin/de/grimsi/gameyfin/users/RegistrationEndpoint.kt @@ -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) + } +} \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt index 79edfc7..9862e61 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt @@ -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): User { - val user = User( - username = registration.username, - password = registration.password, - email = registration.email - ) - - return userService.registerOrUpdateUser(user, roles) - } } \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt index 041130b..61540ad 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt @@ -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 } ) diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt index 50aa358..590133f 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt @@ -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 ) \ No newline at end of file diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt b/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt index c41f235..c8d538a 100644 --- a/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt +++ b/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt @@ -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, diff --git a/src/main/resources/templates/messages/email-already-registered.mjml b/src/main/resources/templates/messages/email-already-registered.mjml new file mode 100644 index 0000000..0ef4d97 --- /dev/null +++ b/src/main/resources/templates/messages/email-already-registered.mjml @@ -0,0 +1,34 @@ + + + [Gameyfin] Someone tried to register with your email + + + + + + + + + + + + Hello there {username}, +
+
+
+ someone just tried to register a new account with this email address. +
+
+ If you just forgot your password, you can reset it by visiting + Gameyfin + and + clicking on the "Forgot password?" button. +
+
+ If this wasn't you, please ignore this email. +
+ +
+
+
+
\ No newline at end of file diff --git a/src/main/resources/templates/messages/waiting-for-approval.mjml b/src/main/resources/templates/messages/waiting-for-approval.mjml new file mode 100644 index 0000000..53d8532 --- /dev/null +++ b/src/main/resources/templates/messages/waiting-for-approval.mjml @@ -0,0 +1,28 @@ + + + [Gameyfin] Waiting for Approval + + + + + + + + + + + + Hello there {username}, +
+
+
+ your registration was successful! +
+ 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. +
+ +
+
+
+
\ No newline at end of file diff --git a/src/main/resources/templates/messages/welcome.mjml b/src/main/resources/templates/messages/welcome.mjml index ef7c93c..911677e 100644 --- a/src/main/resources/templates/messages/welcome.mjml +++ b/src/main/resources/templates/messages/welcome.mjml @@ -16,9 +16,9 @@

- your registration was successful! + your registration has been approved!
- You can now start browsing games in Gameyfin. + You can now start browsing games in Gameyfin.