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}/>
</>
)
}
+34
View File
@@ -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";
}
+59 -132
View File
@@ -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>