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
+37
View File
@@ -40,6 +40,8 @@
"framer-motion": "^11.3.28", "framer-motion": "^11.3.28",
"http-status-codes": "^2.3.0", "http-status-codes": "^2.3.0",
"lit": "3.1.4", "lit": "3.1.4",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
@@ -9325,6 +9327,7 @@
"version": "2.29.3", "version": "2.29.3",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==", "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==",
"license": "MIT",
"engines": { "engines": {
"node": ">=0.11" "node": ">=0.11"
}, },
@@ -11391,6 +11394,27 @@
"node": ">=16 || 14 >=14.17" "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": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "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", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==" "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": { "ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+6 -2
View File
@@ -35,6 +35,8 @@
"framer-motion": "^11.3.28", "framer-motion": "^11.3.28",
"http-status-codes": "^2.3.0", "http-status-codes": "^2.3.0",
"lit": "3.1.4", "lit": "3.1.4",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
@@ -118,7 +120,9 @@
"@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin", "@vaadin/vaadin-themable-mixin": "$@vaadin/vaadin-themable-mixin",
"@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles", "@vaadin/vaadin-lumo-styles": "$@vaadin/vaadin-lumo-styles",
"@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles", "@vaadin/vaadin-material-styles": "$@vaadin/vaadin-material-styles",
"cron-validator": "$cron-validator" "cron-validator": "$cron-validator",
"moment": "$moment",
"moment-timezone": "$moment-timezone"
}, },
"vaadin": { "vaadin": {
"dependencies": { "dependencies": {
@@ -177,6 +181,6 @@
"workbox-core": "7.1.0", "workbox-core": "7.1.0",
"workbox-precaching": "7.1.0" "workbox-precaching": "7.1.0"
}, },
"hash": "0f5c2e2c0e435bad7d8e5153f11e7caac2a6024927de828fb8a6192acc6fd341" "hash": "9af3f915316df3b36e94b855108f7148a48217645c62f7b0e5a970f3030981b5"
} }
} }
@@ -28,7 +28,7 @@ function UserManagementLayout({getConfig, formik}: any) {
<Section title="Sign-Ups"/> <Section title="Sign-Ups"/>
<div className="flex flex-row"> <div className="flex flex-row">
<ConfigFormField configElement={getConfig("users.sign-ups.allow")}/> <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}/> isDisabled={!formik.values.users["sign-ups"].allow}/>
</div> </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} isInvalid={meta.touched && !!meta.error}
/> />
<div className="min-h-6 text-danger"> <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}/> <SmallInfoField icon={XCircle} message={meta.error}/>
)} )}
</div> </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 {Card, Chip, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@nextui-org/react";
import {
Button,
Card,
Chip,
Code,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Input,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
useDisclosure
} from "@nextui-org/react";
import {roleToColor, roleToRoleName} from "Frontend/util/utils"; import {roleToColor, roleToRoleName} from "Frontend/util/utils";
import {DotsThreeVertical} from "@phosphor-icons/react"; import {DotsThreeVertical} from "@phosphor-icons/react";
import {useAuth} from "Frontend/util/auth"; import {useAuth} from "Frontend/util/auth";
import {useEffect, useState} from "react"; 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 {AvatarEndpoint} from "Frontend/endpoints/endpoints";
import Avatar from "Frontend/components/general/Avatar"; 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 }) { 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 [disabledKeys, setDisabledKeys] = useState<string[]>([]);
const [dropdownItems, setDropdownItems] = useState<any[]>([]);
const [passwordResetToken, setPasswordResetToken] = useState<TokenDto>();
const auth = useAuth(); const auth = useAuth();
const [confirmUsername, setConfirmUsername] = useState<string>("");
useEffect(() => { 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(() => { useEffect(() => {
setConfirmUsername(""); setDropdownItems(getDropdownItems());
}, [isOpen]); }, [userEnabled]);
function canUserBeDeleted(): Boolean { function canUserBeDeleted(): Boolean {
// User should not be able to delete himself through this menu (can be done via "My profile") // 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; if (auth.state.user?.roles?.includes("ROLE_SUPERADMIN")) return true;
// Admins should be only allowed to delete other users, not other admins // Admins should be only allowed to delete other users, not other admins
if (user.roles?.includes("ROLE_ADMIN")) return false; return !user.roles?.includes("ROLE_ADMIN");
return true;
} }
async function deleteUser() { async function resetPassword() {
await UserEndpoint.deleteUserByName(user.username); let token = await PasswordResetEndpoint.createPasswordResetTokenForUser(user.username);
window.location.reload(); 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 ( return (
<Card className="flex flex-row justify-between p-2"> <>
<div className="flex flex-row items-center gap-4"> <Card className={`flex flex-row justify-between p-2 ${userEnabled ? "" : "bg-warning/25"}`}>
<Avatar username={user.username} <div className="flex flex-row items-center gap-4">
name={user.username?.charAt(0)} <Avatar username={user.username}
classNames={{ name={user.username?.charAt(0)}
base: "gradient-primary size-20", classNames={{
icon: "text-background/80", base: "gradient-primary size-20",
name: "text-background/80 text-5xl", icon: "text-background/80",
}}/> name: "text-background/80 text-5xl",
<div className="flex flex-col gap-1"> }}/>
<p className="font-semibold">{user.username}</p> <div className="flex flex-col gap-1">
<p className="text-sm">{user.email}</p> <p className="font-semibold">{user.username}</p>
{user.roles?.map((role) => <p className="text-sm">{user.email}</p>
<Chip key={role} size="sm" radius="sm" {user.roles?.map((role) =>
className={`text-xs bg-${roleToColor(role!)}-500`}>{roleToRoleName(role!)}</Chip>)} <Chip key={role} size="sm" radius="sm"
className={`text-xs bg-${roleToColor(role!)}-500`}>{roleToRoleName(role!)}</Chip>)}
</div>
</div> </div>
</div>
<Dropdown placement="bottom-end" size="sm" backdrop="opaque"> <Dropdown placement="bottom-end" size="sm" backdrop="opaque">
<DropdownTrigger> <DropdownTrigger>
<DotsThreeVertical/> <DotsThreeVertical/>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu aria-label="Static Actions" disabledKeys={disabledKeys}> <DropdownMenu aria-label="Static Actions" items={dropdownItems} disabledKeys={disabledKeys}>
<DropdownItem key="removeAvatar" onPress={() => AvatarEndpoint.removeAvatarByName(user.username!)}> {(item) => (
Remove avatar <DropdownItem
</DropdownItem> key={item.key}
<DropdownItem key="delete" className="text-danger" color="danger" onPress={item.onPress}
onPress={onOpen}> color={item.key === "delete" ? "danger" : "default"}
Delete user className={item.key === "delete" ? "text-danger" : ""}
</DropdownItem> >
</DropdownMenu> {item.label}
</Dropdown> </DropdownItem>
)}
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" isDismissable={false} </DropdownMenu>
hideCloseButton={true} size="lg"> </Dropdown>
<ModalContent> </Card>
{(onClose) => ( <ConfirmUserDeletionModal isOpen={userDeletionConfirmationModal.isOpen}
<> onOpenChange={userDeletionConfirmationModal.onOpenChange}
<ModalHeader className="flex flex-col gap-1">Confirm user deletion</ModalHeader> user={user}/>
<ModalBody> <PasswordResetTokenModal isOpen={passwordResetTokenModal.isOpen}
<p> onOpenChange={passwordResetTokenModal.onOpenChange}
Confirm deletion of user <Code>{user.username}</Code> by entering the username token={passwordResetToken as TokenDto}/>
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>
) )
} }
+34
View File
@@ -1,6 +1,7 @@
import {type ClassValue, clsx} from "clsx" import {type ClassValue, clsx} from "clsx"
import {twMerge} from "tailwind-merge" import {twMerge} from "tailwind-merge"
import {getCsrfToken} from "Frontend/util/auth"; import {getCsrfToken} from "Frontend/util/auth";
import moment from 'moment-timezone';
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@@ -48,3 +49,36 @@ export async function fetchWithAuth(url: string, body: any = null, method = "POS
body: body 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 {useAuth} from "Frontend/util/auth";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {WarningCircle, XCircle} from "@phosphor-icons/react"; import {Button, Card, CardBody, CardHeader, Link, useDisclosure} from "@nextui-org/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 {useNavigate} from "react-router-dom"; import {useNavigate} from "react-router-dom";
import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints"; import {Form, Formik} from "formik";
import {toast} from "sonner"; 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() { export default function LoginView() {
const {state, login} = useAuth(); 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 navigate = useNavigate();
const passwordResetModal = useDisclosure();
const signUpModal = useDisclosure();
const [url, setUrl] = useState<string>();
const [signUpAllowed, setSignUpAllowed] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
MessageEndpoint.isEnabled().then(setCanResetPassword); RegistrationEndpoint.isSelfRegistrationAllowed().then(setSignUpAllowed);
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -45,9 +29,14 @@ export default function LoginView() {
} }
}, [state.user]); }, [state.user]);
async function resetPassword() { async function tryLogin(values: any, formik: any) {
await PasswordResetEndpoint.requestPasswordReset(resetEmail); const {defaultUrl, error, redirectUrl} = await login(values.username, values.password);
toast.success("If the email address is registered, you will receive a message with further instructions."); 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 ( return (
@@ -61,109 +50,47 @@ export default function LoginView() {
/> />
</CardHeader> </CardHeader>
<CardBody className="mt-8 mb-2 w-80 max-w-screen-lg sm:w-96"> <CardBody className="mt-8 mb-2 w-80 max-w-screen-lg sm:w-96">
{hasError && <Formik
<Alert className="mb-4" variant="destructive"> initialValues={{}}
<XCircle weight="fill" className="size-4"/> onSubmit={tryLogin}>
<AlertTitle>Error</AlertTitle> {(formik: { isSubmitting: any; }) => (
<AlertDescription>Wrong username and/or password</AlertDescription> <Form className="mb-1 flex flex-col gap-6">
</Alert> <Input
} name="username"
<form label="Username"
className="mb-1 flex flex-col gap-6" autoComplete="username"
onSubmit={async e => { />
e.preventDefault(); <Input
if (typeof username === "string" && password != null) { name="password"
setLoading(true); label="Password"
const {defaultUrl, error, redirectUrl} = await login(username, password); autoComplete="current-password"
if (error) { type="password"
setError(true); />
} else { <div className="flex justify-between items-center">
setUrl(redirectUrl ?? defaultUrl ?? '/'); <Link color="foreground" underline="always" href="#"
} onPress={passwordResetModal.onOpen}>
setLoading(false); Forgot password?
} </Link>
}} <div className="flex flex-row gap-2">
> {signUpAllowed &&
<label htmlFor="username"> <Button color="default" variant="light"
<h6 color="blue-gray" className="-mb-3"> onPress={signUpModal.onOpen}>
Username Sign up
</h6> </Button>
</label> }
<Input <Button color="primary" type="submit" isLoading={formik.isSubmitting}>
onChange={(event: any) => { {formik.isSubmitting ? "" : "Log in"}
setUsername(event.target.value); </Button>
}} </div>
id="username" </div>
type="text" </Form>
autoComplete="username" )}
placeholder="" </Formik>
/>
<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>
</CardBody> </CardBody>
</Card> </Card>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl"> <PasswordResetModal isOpen={passwordResetModal.isOpen} onOpenChange={passwordResetModal.onOpenChange}/>
<ModalContent> <SignUpModal isOpen={signUpModal.isOpen} onOpenChange={signUpModal.onOpenChange}/>
{(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>
</div> </div>
); );
} }
@@ -57,11 +57,11 @@ sealed class ConfigProperties<T : Serializable>(
false false
) )
data object Confirm : ConfigProperties<Boolean>( data object ConfirmationRequired : ConfigProperties<Boolean>(
Boolean::class, Boolean::class,
"users.sign-ups.confirm", "users.sign-ups.confirmation-required",
"Admins need to confirm new users", "Admins need to confirm new users",
false true
) )
} }
} }
@@ -15,12 +15,11 @@ class Utils {
val scheme = request.scheme val scheme = request.scheme
val serverName = request.serverName val serverName = request.serverName
val serverPort = request.serverPort val serverPort = request.serverPort
val contextPath = request.contextPath
return if (serverPort == 80 || serverPort == 443) { return if (serverPort == 80 || serverPort == 443) {
"$scheme://$serverName$contextPath" "$scheme://$serverName"
} else { } 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.Token
import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset
import de.grimsi.gameyfin.users.entities.User
import org.springframework.context.ApplicationEvent import org.springframework.context.ApplicationEvent
class UserInvitationEvent(source: Any) : ApplicationEvent(source) 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) : class PasswordResetRequestEvent(source: Any, val token: Token<PasswordReset>, val baseUrl: String) :
ApplicationEvent(source) ApplicationEvent(source)
@@ -1,6 +1,9 @@
package de.grimsi.gameyfin.messages package de.grimsi.gameyfin.messages
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent 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.providers.AbstractMessageProvider
import de.grimsi.gameyfin.messages.templates.MessageTemplateService import de.grimsi.gameyfin.messages.templates.MessageTemplateService
import de.grimsi.gameyfin.messages.templates.MessageTemplates import de.grimsi.gameyfin.messages.templates.MessageTemplates
@@ -95,4 +98,64 @@ class MessageService(
mapOf("username" to token.user.username, "resetLink" to resetLink) 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") 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( data object Welcome : MessageTemplates(
"welcome", "welcome",
"Welcome", "Welcome",
"Template for the welcome message for new users", "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( 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.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.shared.token.TokenDto
import de.grimsi.gameyfin.shared.token.TokenValidationResult import de.grimsi.gameyfin.shared.token.TokenValidationResult
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
@@ -20,8 +21,8 @@ class PasswordResetEndpoint(
} }
@RolesAllowed(Roles.Names.ADMIN) @RolesAllowed(Roles.Names.ADMIN)
fun createPasswordResetTokenForUser(username: String): String { fun createPasswordResetTokenForUser(username: String): TokenDto {
return passwordResetService.generate(username).secret return passwordResetService.generate(username)
} }
fun resetPassword(secret: String, newPassword: String): TokenValidationResult { 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.Utils
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
import de.grimsi.gameyfin.messages.MessageService import de.grimsi.gameyfin.messages.MessageService
import de.grimsi.gameyfin.shared.token.Token import de.grimsi.gameyfin.shared.token.*
import de.grimsi.gameyfin.shared.token.TokenRepository
import de.grimsi.gameyfin.shared.token.TokenService
import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset
import de.grimsi.gameyfin.shared.token.TokenValidationResult
import de.grimsi.gameyfin.users.entities.User import de.grimsi.gameyfin.users.entities.User
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationEventPublisher import org.springframework.context.ApplicationEventPublisher
@@ -27,9 +24,6 @@ class PasswordResetService(
private val secureRandom = SecureRandom() private val secureRandom = SecureRandom()
private val baseUrl: String
get() = Utils.getBaseUrl()
override fun generate(user: User): Token<PasswordReset> { override fun generate(user: User): Token<PasswordReset> {
if (user.oidcProviderId != null) { if (user.oidcProviderId != null) {
throw IllegalStateException("Cannot create password reset token for user '${user.username}' because user is managed externally") 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 has no confirmed email address
* - The user is not managed externally * - The user is not managed externally
*/ */
fun generate(username: String): Token<PasswordReset> { fun generate(username: String): TokenDto {
if (messageService.enabled) { if (messageService.enabled) {
throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is 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) val user = userService.getByUsername(username)
?: throw IllegalArgumentException("Cannot create password reset token for user '$username' because user does not exist") ?: 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") 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) val token = generate(user)
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, baseUrl)) eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, Utils.getBaseUrl()))
// Simulate a delay to prevent timing attacks // Simulate a delay to prevent timing attacks
Thread.sleep(secureRandom.nextLong(1024)) 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 com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.users.dto.UserInfoDto 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.dto.UserUpdateDto
import de.grimsi.gameyfin.users.entities.User
import jakarta.annotation.security.PermitAll import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import org.springframework.security.core.Authentication import org.springframework.security.core.Authentication
@@ -16,7 +14,6 @@ import org.springframework.security.core.context.SecurityContextHolder
class UserEndpoint( class UserEndpoint(
private val userService: UserService private val userService: UserService
) { ) {
@PermitAll @PermitAll
fun getUserInfo(): UserInfoDto { fun getUserInfo(): UserInfoDto {
val auth: Authentication = SecurityContextHolder.getContext().authentication val auth: Authentication = SecurityContextHolder.getContext().authentication
@@ -28,12 +25,6 @@ class UserEndpoint(
return userService.getAllUsers() return userService.getAllUsers()
} }
@PermitAll
fun registerUser(registration: UserRegistrationDto): UserInfoDto {
val user: User = registerUser(registration, listOf(Roles.USER))
return userService.toUserInfo(user)
}
@PermitAll @PermitAll
fun updateUser(updates: UserUpdateDto) { fun updateUser(updates: UserUpdateDto) {
val auth: Authentication = SecurityContextHolder.getContext().authentication val auth: Authentication = SecurityContextHolder.getContext().authentication
@@ -55,14 +46,4 @@ class UserEndpoint(
fun deleteUserByName(username: String) { fun deleteUserByName(username: String) {
userService.deleteUser(username) 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 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.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.UserInfoDto
import de.grimsi.gameyfin.users.dto.UserRegistrationDto
import de.grimsi.gameyfin.users.dto.UserUpdateDto import de.grimsi.gameyfin.users.dto.UserUpdateDto
import de.grimsi.gameyfin.users.entities.Avatar import de.grimsi.gameyfin.users.entities.Avatar
import de.grimsi.gameyfin.users.entities.Role 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 de.grimsi.gameyfin.users.persistence.UserRepository
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional import jakarta.transaction.Transactional
import org.springframework.context.ApplicationEventPublisher
import org.springframework.security.core.Authentication import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority
@@ -30,11 +38,16 @@ class UserService(
private val avatarStore: AvatarContentStore, private val avatarStore: AvatarContentStore,
private val passwordEncoder: PasswordEncoder, private val passwordEncoder: PasswordEncoder,
private val roleService: RoleService, private val roleService: RoleService,
private val sessionService: SessionService private val sessionService: SessionService,
private val config: ConfigService,
private val eventPublisher: ApplicationEventPublisher
) : UserDetailsService { ) : UserDetailsService {
private val log = KotlinLogging.logger {} private val log = KotlinLogging.logger {}
val selfRegistrationAllowed: Boolean
get() = config.get(ConfigProperties.Users.SignUps.Allow) == true
override fun loadUserByUsername(username: String): UserDetails { override fun loadUserByUsername(username: String): UserDetails {
val user = userByUsername(username) val user = userByUsername(username)
@@ -124,6 +137,39 @@ class UserService(
return userRepository.save(user) 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) { fun updateUser(username: String, updates: UserUpdateDto) {
val user = userByUsername(username) val user = userByUsername(username)
@@ -147,6 +193,13 @@ class UserService(
userRepository.save(user) 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) { fun deleteUser(username: String) {
val user = userByUsername(username) val user = userByUsername(username)
userRepository.delete(user) userRepository.delete(user)
@@ -157,6 +210,8 @@ class UserService(
username = user.username, username = user.username,
email = user.email, email = user.email,
emailConfirmed = user.emailConfirmed, emailConfirmed = user.emailConfirmed,
isEnabled = user.enabled,
hasAvatar = user.avatar != null,
managedBySso = user.oidcProviderId != null, managedBySso = user.oidcProviderId != null,
roles = user.roles.map { r -> r.rolename } roles = user.roles.map { r -> r.rolename }
) )
@@ -5,5 +5,7 @@ data class UserInfoDto(
val managedBySso: Boolean, val managedBySso: Boolean,
val email: String, val email: String,
val emailConfirmed: Boolean, val emailConfirmed: Boolean,
val isEnabled: Boolean,
val hasAvatar: Boolean,
var roles: List<String> var roles: List<String>
) )
@@ -27,8 +27,7 @@ class User(
@Convert(converter = EncryptionConverter::class) @Convert(converter = EncryptionConverter::class)
var email: String, var email: String,
// TODO: Add email confirmation var emailConfirmed: Boolean = false,
var emailConfirmed: Boolean = true,
var enabled: Boolean = true, 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/>
<br/> <br/>
</mj-text> </mj-text>
<mj-text>your registration was successful! <mj-text>your registration has been approved!
<br/> <br/>
You can now start browsing games in Gameyfin. You can now start browsing games in <a href="{baseUrl}">Gameyfin</a>.
</mj-text> </mj-text>
</mj-column> </mj-column>