mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-17 00:30:04 +00:00
Implemented user registration flows
Implemented password reset for admins (when no notification provider is enabled) Major refactoring
This commit is contained in:
Generated
+37
@@ -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
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user