mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 08:49:13 +00:00
feat(auth): change password and username (#1295)
* feat(backend): added change password api endpoint. * feat(web): added profile UI to change password. I think we can change the username too, but I don't know if we should for now disabled the username field. * refactor: don't leak username or password. * refactor: protect the route. * generic * feat: add ChangeUsername * fix(tests): speculative fix for TestUserRepo_Update * Revert "feat: add ChangeUsername" This reverts commit d4c1645002883a278aa45dec3c8c19fa1cc75d9b. * refactor into 1 endpoint that handles both * feat: added option to change username as well. :pain: * refactor: frontend * refactor: function names in backend I think this makes it more clear what their function is * fix: change to 2 cols with separator * refactor: update user * fix: test db create user --------- Co-authored-by: Kyle Sanderson <kyle.leet@gmail.com> Co-authored-by: soup <soup@r4tio.dev> Co-authored-by: martylukyy <35452459+martylukyy@users.noreply.github.com> Co-authored-by: ze0s <ze0s@riseup.net>
This commit is contained in:
parent
d898b3cd8d
commit
df2612602b
17 changed files with 390 additions and 57 deletions
|
@ -158,7 +158,9 @@ export const APIClient = {
|
|||
onboard: (username: string, password: string) => appClient.Post("api/auth/onboard", {
|
||||
body: { username, password }
|
||||
}),
|
||||
canOnboard: () => appClient.Get("api/auth/onboard")
|
||||
canOnboard: () => appClient.Get("api/auth/onboard"),
|
||||
updateUser: (req: UserUpdate) => appClient.Patch(`api/auth/user/${req.username_current}`,
|
||||
{ body: req })
|
||||
},
|
||||
actions: {
|
||||
create: (action: Action) => appClient.Post("api/actions", {
|
||||
|
|
|
@ -55,6 +55,25 @@ export const RightNav = (props: RightNavProps) => {
|
|||
static
|
||||
className="origin-top-right absolute right-0 mt-2 w-48 z-10 divide-y divide-gray-100 dark:divide-gray-750 rounded-md shadow-lg bg-white dark:bg-gray-800 border border-gray-250 dark:border-gray-775 focus:outline-none"
|
||||
>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<Link
|
||||
to="/settings/account"
|
||||
className={classNames(
|
||||
active
|
||||
? "bg-gray-100 dark:bg-gray-600"
|
||||
: "",
|
||||
"flex items-center transition rounded-t-md px-2 py-2 text-sm text-gray-900 dark:text-gray-200"
|
||||
)}
|
||||
>
|
||||
<UserIcon
|
||||
className="w-5 h-5 mr-1 text-gray-700 dark:text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Account
|
||||
</Link>
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<Link
|
||||
|
|
|
@ -79,6 +79,7 @@ export const TextField = ({
|
|||
)}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
data-1p-ignore
|
||||
/>
|
||||
|
||||
{meta.touched && meta.error && (
|
||||
|
@ -116,42 +117,42 @@ export const RegexField = ({
|
|||
disabled
|
||||
}: RegexFieldProps) => {
|
||||
const validRegex = (pattern: string) => {
|
||||
|
||||
|
||||
// Check for unsupported lookahead and lookbehind assertions
|
||||
if (/\(\?<=|\(\?<!|\(\?=|\(\?!/.test(pattern)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check for unsupported atomic groups
|
||||
if (/\(\?>/.test(pattern)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check for unsupported recursive patterns
|
||||
if (/\(\?(R|0)\)/.test(pattern)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check for unsupported possessive quantifiers
|
||||
if (/[*+?]{1}\+|\{[0-9]+,[0-9]*\}\+/.test(pattern)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check for unsupported control verbs
|
||||
if (/\\g</.test(pattern)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check for unsupported conditionals
|
||||
if (/\(\?\((\?[=!][^)]*)\)[^)]*\|?[^)]*\)/.test(pattern)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check for unsupported backreferences
|
||||
if (/\\k</.test(pattern)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check if the pattern is a valid regex
|
||||
try {
|
||||
new RegExp(pattern);
|
||||
|
@ -160,7 +161,7 @@ export const RegexField = ({
|
|||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const validateRegexp = (val: string) => {
|
||||
let error = "";
|
||||
|
@ -548,6 +549,7 @@ interface PasswordFieldProps {
|
|||
defaultValue?: string;
|
||||
help?: string;
|
||||
required?: boolean;
|
||||
tooltip?: JSX.Element;
|
||||
}
|
||||
|
||||
export const PasswordField = ({
|
||||
|
@ -558,6 +560,7 @@ export const PasswordField = ({
|
|||
columns,
|
||||
autoComplete,
|
||||
help,
|
||||
tooltip,
|
||||
required
|
||||
}: PasswordFieldProps) => {
|
||||
const [isVisible, toggleVisibility] = useToggle(false);
|
||||
|
@ -570,8 +573,13 @@ export const PasswordField = ({
|
|||
)}
|
||||
>
|
||||
{label && (
|
||||
<label htmlFor={name} className="block ml-px text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">
|
||||
{label} {required && <span className="text-gray-500">*</span>}
|
||||
<label htmlFor={name} className="flex ml-px text-xs font-bold text-gray-800 dark:text-gray-100 uppercase tracking-wide">
|
||||
{tooltip ? (
|
||||
<DocsTooltip label={label}>{tooltip}</DocsTooltip>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
{required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
)}
|
||||
<div>
|
||||
|
@ -591,7 +599,7 @@ export const PasswordField = ({
|
|||
meta.touched && meta.error
|
||||
? "border-red-500 focus:ring-red-500 focus:border-red-500"
|
||||
: "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500",
|
||||
"mt-1 block w-full rounded-md bg-gray-100 dark:bg-gray-850 dark:text-gray-100"
|
||||
"mt-1 block w-full rounded-md bg-gray-100 dark:bg-gray-815 dark:text-gray-100"
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
|
|
@ -53,6 +53,7 @@ export const LocalRouter = ({ isLoggedIn }: { isLoggedIn: boolean }) => (
|
|||
<Route path="notifications" element={<SettingsSubPage.Notification />} />
|
||||
<Route path="releases" element={<SettingsSubPage.Release />} />
|
||||
<Route path="regex-playground" element={<SettingsSubPage.RegexPlayground />} />
|
||||
<Route path="account" element={<SettingsSubPage.Account />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
|
|
|
@ -13,7 +13,8 @@ import {
|
|||
KeyIcon,
|
||||
RectangleStackIcon,
|
||||
RssIcon,
|
||||
Square3Stack3DIcon
|
||||
Square3Stack3DIcon,
|
||||
UserCircleIcon
|
||||
} from "@heroicons/react/24/outline";
|
||||
|
||||
import { classNames } from "@utils";
|
||||
|
@ -34,7 +35,8 @@ const subNavigation: NavTabType[] = [
|
|||
{ name: "Clients", href: "clients", icon: FolderArrowDownIcon },
|
||||
{ name: "Notifications", href: "notifications", icon: BellIcon },
|
||||
{ name: "API keys", href: "api-keys", icon: KeyIcon },
|
||||
{ name: "Releases", href: "releases", icon: RectangleStackIcon }
|
||||
{ name: "Releases", href: "releases", icon: RectangleStackIcon },
|
||||
{ name: "Account", href: "account", icon: UserCircleIcon }
|
||||
// {name: 'Regex Playground', href: 'regex-playground', icon: CogIcon, current: false}
|
||||
// {name: 'Rules', href: 'rules', icon: ClipboardCheckIcon, current: false},
|
||||
];
|
||||
|
|
150
web/src/screens/settings/Account.tsx
Normal file
150
web/src/screens/settings/Account.tsx
Normal file
|
@ -0,0 +1,150 @@
|
|||
/*
|
||||
* Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*/
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { APIClient } from "@api/APIClient";
|
||||
import Toast from "@components/notifications/Toast";
|
||||
import { Section } from "./_components";
|
||||
import { Form, Formik } from "formik";
|
||||
import { PasswordField, TextField } from "@components/inputs";
|
||||
import { AuthContext } from "@utils/Context";
|
||||
import toast from "react-hot-toast";
|
||||
import { UserIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
const AccountSettings = () => (
|
||||
<Section
|
||||
title="Account"
|
||||
description="Manage account settings."
|
||||
>
|
||||
<div className="py-0.5">
|
||||
<Credentials />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
|
||||
interface InputValues {
|
||||
username: string;
|
||||
newUsername: string;
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
function Credentials() {
|
||||
const [ getAuthContext ] = AuthContext.use();
|
||||
|
||||
|
||||
const validate = (values: InputValues) => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!values.username)
|
||||
errors.username = "Required";
|
||||
|
||||
if (values.newPassword !== values.confirmPassword)
|
||||
errors.confirmPassword = "Passwords don't match!";
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: APIClient.auth.logout,
|
||||
onSuccess: () => {
|
||||
AuthContext.reset();
|
||||
toast.custom((t) => (
|
||||
<Toast type="success" body="User updated successfully. Please sign in again!" t={t} />
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
const updateUserMutation = useMutation({
|
||||
mutationFn: (data: UserUpdate) => APIClient.auth.updateUser(data),
|
||||
onSuccess: () => {
|
||||
logoutMutation.mutate();
|
||||
}
|
||||
});
|
||||
|
||||
const separatorClass = "mb-6";
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Change credentials"
|
||||
description="The username and password can be changed either separately or simultaneously. Note that you will be logged out after changing credentials."
|
||||
noLeftPadding
|
||||
>
|
||||
<div className="px-2 pb-6 bg-white dark:bg-gray-800">
|
||||
<Formik
|
||||
initialValues={{
|
||||
username: getAuthContext.username,
|
||||
newUsername: "",
|
||||
oldPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: ""
|
||||
}}
|
||||
onSubmit={(data) => {
|
||||
updateUserMutation.mutate({
|
||||
username_current: data.username,
|
||||
username_new: data.newUsername,
|
||||
password_current: data.oldPassword,
|
||||
password_new: data.newPassword,
|
||||
});
|
||||
}}
|
||||
validate={validate}
|
||||
>
|
||||
{({ values }) => (
|
||||
<Form>
|
||||
<div className="grid grid-cols-2 gap-x-10">
|
||||
<div className={separatorClass}>
|
||||
<TextField name="username" label="Current Username" autoComplete="username" disabled />
|
||||
</div>
|
||||
<div className={separatorClass}>
|
||||
<TextField name="newUsername" label="New Username" tooltip={
|
||||
<div>
|
||||
<p>Optional</p>
|
||||
</div>
|
||||
} />
|
||||
</div>
|
||||
|
||||
<hr className="col-span-2 mb-6 border-t border-gray-300 dark:border-gray-750" />
|
||||
|
||||
<div className={separatorClass}>
|
||||
<PasswordField name="oldPassword" placeholder="Required" label="Current Password" autoComplete="current-password" required tooltip={
|
||||
<div>
|
||||
<p>Required if updating credentials</p>
|
||||
</div>
|
||||
} />
|
||||
</div>
|
||||
<div>
|
||||
<div className={separatorClass}>
|
||||
<PasswordField name="newPassword" label="New Password" autoComplete="new-password" tooltip={
|
||||
<div>
|
||||
<p>Optional</p>
|
||||
</div>
|
||||
} />
|
||||
</div>
|
||||
{values.newPassword && (
|
||||
<div className={separatorClass}>
|
||||
<PasswordField name="confirmPassword" label="Confirm New Password" autoComplete="new-password" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-4 w-auto flex items-center py-2 px-4 transition rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-blue-500"
|
||||
>
|
||||
<UserIcon className="w-4 h-4 mr-1" />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountSettings;
|
|
@ -11,15 +11,22 @@ type SectionProps = {
|
|||
description: string | React.ReactNode;
|
||||
rightSide?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
noLeftPadding?: boolean;
|
||||
};
|
||||
|
||||
export const Section = ({
|
||||
title,
|
||||
description,
|
||||
rightSide,
|
||||
children
|
||||
children,
|
||||
noLeftPadding = false,
|
||||
}: SectionProps) => (
|
||||
<div className="pb-6 px-4 lg:col-span-9">
|
||||
<div
|
||||
className={classNames(
|
||||
"pb-6 px-4 lg:col-span-9",
|
||||
noLeftPadding ? 'pl-0' : '',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"mt-6 mb-4",
|
||||
|
|
|
@ -13,3 +13,4 @@ export { default as Logs } from "./Logs";
|
|||
export { default as Notification } from "./Notifications";
|
||||
export { default as Release } from "./Releases";
|
||||
export { default as RegexPlayground } from "./RegexPlayground";
|
||||
export { default as Account } from "./Account";
|
||||
|
|
9
web/src/types/API.d.ts
vendored
9
web/src/types/API.d.ts
vendored
|
@ -8,4 +8,11 @@ interface APIKey {
|
|||
key: string;
|
||||
scopes: string[];
|
||||
created_at: Date;
|
||||
}
|
||||
}
|
||||
|
||||
interface UserUpdate {
|
||||
username_current: string;
|
||||
username_new?: string;
|
||||
password_current?: string;
|
||||
password_new?: string;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue