feat(auth): add OAuth2 login (#276)
* feat(auth): add OAuth2 login with GitHub and Google * chore(translations): add files for Japanese * fix(auth): fix link function for GitHub * feat(oauth): basic oidc implementation * feat(oauth): oauth guard * fix: disable image optimizations for logo to prevent caching issues with custom logos * fix: memory leak while downloading large files * chore(translations): update translations via Crowdin (#278) * New translations en-us.ts (Japanese) * New translations en-us.ts (Japanese) * New translations en-us.ts (Japanese) * release: 0.18.2 * doc(translations): Add Japanese README (#279) * Added Japanese README. * Added JAPANESE README link to README.md. * Updated Japanese README. * Updated Environment Variable Table. * updated zh-cn README. * feat(oauth): unlink account * refactor(oauth): make providers extensible * fix(oauth): fix discoveryUri error when toggle google-enabled * feat(oauth): add microsoft and discord as oauth provider * docs(oauth): update README.md * docs(oauth): update oauth2-guide.md * set password to null for new oauth users * New translations en-us.ts (Japanese) (#281) * chore(translations): add Polish files * fix(oauth): fix random username and password * feat(oauth): add totp * fix(oauth): fix totp throttle * fix(oauth): fix qrcode and remove comment * feat(oauth): add error page * fix(oauth): i18n of error page * feat(auth): add OAuth2 login * fix(auth): fix link function for GitHub * feat(oauth): basic oidc implementation * feat(oauth): oauth guard * feat(oauth): unlink account * refactor(oauth): make providers extensible * fix(oauth): fix discoveryUri error when toggle google-enabled * feat(oauth): add microsoft and discord as oauth provider * docs(oauth): update README.md * docs(oauth): update oauth2-guide.md * set password to null for new oauth users * fix(oauth): fix random username and password * feat(oauth): add totp * fix(oauth): fix totp throttle * fix(oauth): fix qrcode and remove comment * feat(oauth): add error page * fix(oauth): i18n of error page * refactor: return null instead of `false` in `getIdOfCurrentUser` functiom * feat: show original oauth error if available * refactor: run formatter * refactor(oauth): error message i18n * refactor(oauth): make OAuth token available someone may use it (to revoke token or get other info etc.) also improved the i18n message * chore(oauth): remove unused import * chore: add database migration * fix: missing python installation for nanoid --------- Co-authored-by: Elias Schneider <login@eliasschneider.com> Co-authored-by: ふうせん <10260662+fusengum@users.noreply.github.com>
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import Link from "next/link";
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { TbAt, TbMail, TbShare, TbSquare } from "react-icons/tb";
|
||||
import { TbAt, TbMail, TbShare, TbSocial, TbSquare } from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const categories = [
|
||||
@@ -19,6 +19,7 @@ const categories = [
|
||||
{ name: "Email", icon: <TbMail /> },
|
||||
{ name: "Share", icon: <TbShare /> },
|
||||
{ name: "SMTP", icon: <TbAt /> },
|
||||
{ name: "OAuth", icon: <TbSocial /> },
|
||||
];
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
|
||||
@@ -2,9 +2,11 @@ import {
|
||||
Anchor,
|
||||
Button,
|
||||
Container,
|
||||
createStyles,
|
||||
Group,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
@@ -18,19 +20,47 @@ import { TbInfoCircle } from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as yup from "yup";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import { getOAuthIcon, getOAuthUrl } from "../../utils/oauth.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const useStyles = createStyles((theme) => ({
|
||||
or: {
|
||||
"&:before": {
|
||||
content: "''",
|
||||
flex: 1,
|
||||
display: "block",
|
||||
borderTopWidth: 1,
|
||||
borderTopStyle: "solid",
|
||||
borderColor:
|
||||
theme.colorScheme === "dark"
|
||||
? theme.colors.dark[3]
|
||||
: theme.colors.gray[4],
|
||||
},
|
||||
"&:after": {
|
||||
content: "''",
|
||||
flex: 1,
|
||||
display: "block",
|
||||
borderTopWidth: 1,
|
||||
borderTopStyle: "solid",
|
||||
borderColor:
|
||||
theme.colorScheme === "dark"
|
||||
? theme.colors.dark[3]
|
||||
: theme.colors.gray[4],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
const config = useConfig();
|
||||
const router = useRouter();
|
||||
const t = useTranslate();
|
||||
const { refreshUser } = useUser();
|
||||
const { classes } = useStyles();
|
||||
|
||||
const [showTotp, setShowTotp] = React.useState(false);
|
||||
const [loginToken, setLoginToken] = React.useState("");
|
||||
const [oauth, setOAuth] = React.useState<string[]>([]);
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
emailOrUsername: yup.string().required(t("common.error.field-required")),
|
||||
@@ -44,7 +74,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
initialValues: {
|
||||
emailOrUsername: "",
|
||||
password: "",
|
||||
totp: "",
|
||||
},
|
||||
validate: yupResolver(validationSchema),
|
||||
});
|
||||
@@ -55,7 +84,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
.then(async (response) => {
|
||||
if (response.data["loginToken"]) {
|
||||
// Prompt the user to enter their totp code
|
||||
setShowTotp(true);
|
||||
showNotification({
|
||||
icon: <TbInfoCircle />,
|
||||
color: "blue",
|
||||
@@ -63,7 +91,11 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
title: t("signIn.notify.totp-required.title"),
|
||||
message: t("signIn.notify.totp-required.description"),
|
||||
});
|
||||
setLoginToken(response.data["loginToken"]);
|
||||
router.push(
|
||||
`/auth/totp/${
|
||||
response.data["loginToken"]
|
||||
}?redirect=${encodeURIComponent(redirectPath)}`,
|
||||
);
|
||||
} else {
|
||||
await refreshUser();
|
||||
router.replace(redirectPath);
|
||||
@@ -72,25 +104,15 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
.catch(toast.axiosError);
|
||||
};
|
||||
|
||||
const signInTotp = (email: string, password: string, totp: string) => {
|
||||
authService
|
||||
.signInTotp(email, password, totp, loginToken)
|
||||
.then(async () => {
|
||||
await refreshUser();
|
||||
router.replace(redirectPath);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error?.response?.data?.error == "share_password_required") {
|
||||
toast.axiosError(error);
|
||||
// Refresh the page to start over
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
toast.axiosError(error);
|
||||
form.setValues({ totp: "" });
|
||||
});
|
||||
const getAvailableOAuth = async () => {
|
||||
const oauth = await authService.getAvailableOAuth();
|
||||
setOAuth(oauth.data);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
getAvailableOAuth().catch(toast.axiosError);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title order={2} align="center" weight={900}>
|
||||
@@ -107,9 +129,7 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form
|
||||
onSubmit={form.onSubmit((values) => {
|
||||
if (showTotp)
|
||||
signInTotp(values.emailOrUsername, values.password, values.totp);
|
||||
else signIn(values.emailOrUsername, values.password);
|
||||
signIn(values.emailOrUsername, values.password);
|
||||
})}
|
||||
>
|
||||
<TextInput
|
||||
@@ -123,15 +143,6 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
mt="md"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
{showTotp && (
|
||||
<TextInput
|
||||
variant="filled"
|
||||
label={t("account.modal.totp.code")}
|
||||
placeholder="******"
|
||||
mt="md"
|
||||
{...form.getInputProps("totp")}
|
||||
/>
|
||||
)}
|
||||
{config.get("smtp.enabled") && (
|
||||
<Group position="right" mt="xs">
|
||||
<Anchor component={Link} href="/auth/resetPassword" size="xs">
|
||||
@@ -143,6 +154,27 @@ const SignInForm = ({ redirectPath }: { redirectPath: string }) => {
|
||||
<FormattedMessage id="signin.button.submit" />
|
||||
</Button>
|
||||
</form>
|
||||
{oauth.length > 0 && (
|
||||
<Stack mt="xl">
|
||||
<Group align="center" className={classes.or}>
|
||||
<Text>{t("signIn.oauth.or")}</Text>
|
||||
</Group>
|
||||
<Group position="center">
|
||||
{oauth.map((provider) => (
|
||||
<Button
|
||||
key={provider}
|
||||
component="a"
|
||||
target="_blank"
|
||||
title={t(`signIn.oauth.${provider}`)}
|
||||
href={getOAuthUrl(config.get("general.appUrl"), provider)}
|
||||
variant="light"
|
||||
>
|
||||
{getOAuthIcon(provider)}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
|
||||
84
frontend/src/components/auth/TotpForm.tsx
Normal file
84
frontend/src/components/auth/TotpForm.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Group,
|
||||
Paper,
|
||||
PinInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as yup from "yup";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useState } from "react";
|
||||
import authService from "../../services/auth.service";
|
||||
import toast from "../../utils/toast.util";
|
||||
import { useRouter } from "next/router";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
|
||||
function TotpForm({ redirectPath }: { redirectPath: string }) {
|
||||
const t = useTranslate();
|
||||
const router = useRouter();
|
||||
const { refreshUser } = useUser();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const validationSchema = yup.object().shape({
|
||||
code: yup
|
||||
.string()
|
||||
.min(6, t("common.error.too-short", { length: 6 }))
|
||||
.required(t("common.error.field-required")),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
code: "",
|
||||
},
|
||||
validate: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await authService.signInTotp(
|
||||
form.values.code,
|
||||
router.query.loginToken as string,
|
||||
);
|
||||
await refreshUser();
|
||||
await router.replace(redirectPath);
|
||||
} catch (e) {
|
||||
toast.axiosError(e);
|
||||
form.setFieldError("code", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size={420} my={40}>
|
||||
<Title order={2} align="center" weight={900}>
|
||||
<FormattedMessage id="totp.title" />
|
||||
</Title>
|
||||
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Group position="center">
|
||||
<PinInput
|
||||
length={6}
|
||||
oneTimeCode
|
||||
aria-label="One time code"
|
||||
autoFocus={true}
|
||||
onComplete={onSubmit}
|
||||
{...form.getInputProps("code")}
|
||||
/>
|
||||
<Button mt="md" type="submit" loading={loading}>
|
||||
{t("totp.button.signIn")}
|
||||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default TotpForm;
|
||||
@@ -60,7 +60,7 @@ const Dropzone = ({
|
||||
toast.error(
|
||||
t("upload.dropzone.notify.file-too-big", {
|
||||
maxSize: byteToHumanSizeString(maxShareSize),
|
||||
})
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
files = files.map((newFile) => {
|
||||
|
||||
@@ -40,7 +40,7 @@ const showCreateUploadModal = (
|
||||
enableEmailRecepients: boolean;
|
||||
},
|
||||
files: FileUpload[],
|
||||
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void
|
||||
uploadCallback: (createShare: CreateShare, files: FileUpload[]) => void,
|
||||
) => {
|
||||
const t = translateOutsideContext();
|
||||
|
||||
@@ -137,7 +137,7 @@ const CreateUploadModalBody = ({
|
||||
maxViews: values.maxViews,
|
||||
},
|
||||
},
|
||||
files
|
||||
files,
|
||||
);
|
||||
modals.closeAll();
|
||||
}
|
||||
@@ -160,7 +160,7 @@ const CreateUploadModalBody = ({
|
||||
"link",
|
||||
Buffer.from(Math.random().toString(), "utf8")
|
||||
.toString("base64")
|
||||
.substr(10, 7)
|
||||
.substr(10, 7),
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -259,7 +259,7 @@ const CreateUploadModalBody = ({
|
||||
neverExpires: t("upload.modal.completed.never-expires"),
|
||||
expiresOn: t("upload.modal.completed.expires-on"),
|
||||
},
|
||||
form
|
||||
form,
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
@@ -274,7 +274,7 @@ const CreateUploadModalBody = ({
|
||||
<Textarea
|
||||
variant="filled"
|
||||
placeholder={t(
|
||||
"upload.modal.accordion.description.placeholder"
|
||||
"upload.modal.accordion.description.placeholder",
|
||||
)}
|
||||
{...form.getInputProps("description")}
|
||||
/>
|
||||
@@ -298,7 +298,7 @@ const CreateUploadModalBody = ({
|
||||
if (!query.match(/^\S+@\S+\.\S+$/)) {
|
||||
form.setFieldError(
|
||||
"recipients",
|
||||
t("upload.modal.accordion.email.invalid-email")
|
||||
t("upload.modal.accordion.email.invalid-email"),
|
||||
);
|
||||
} else {
|
||||
form.setFieldError("recipients", null);
|
||||
@@ -324,7 +324,7 @@ const CreateUploadModalBody = ({
|
||||
<PasswordInput
|
||||
variant="filled"
|
||||
placeholder={t(
|
||||
"upload.modal.accordion.security.password.placeholder"
|
||||
"upload.modal.accordion.security.password.placeholder",
|
||||
)}
|
||||
label={t("upload.modal.accordion.security.password.label")}
|
||||
autoComplete="off"
|
||||
@@ -335,7 +335,7 @@ const CreateUploadModalBody = ({
|
||||
type="number"
|
||||
variant="filled"
|
||||
placeholder={t(
|
||||
"upload.modal.accordion.security.max-views.placeholder"
|
||||
"upload.modal.accordion.security.max-views.placeholder",
|
||||
)}
|
||||
label={t("upload.modal.accordion.security.max-views.label")}
|
||||
{...form.getInputProps("maxViews")}
|
||||
|
||||
@@ -43,6 +43,12 @@ export default {
|
||||
"signIn.notify.totp-required.title": "Two-factor authentication required",
|
||||
"signIn.notify.totp-required.description":
|
||||
"Please enter your two-factor authentication code",
|
||||
"signIn.oauth.or": "OR",
|
||||
"signIn.oauth.github": "GitHub",
|
||||
"signIn.oauth.google": "Google",
|
||||
"signIn.oauth.microsoft": "Microsoft",
|
||||
"signIn.oauth.discord": "Discord",
|
||||
"signIn.oauth.oidc": "OpenID",
|
||||
|
||||
// END /auth/signin
|
||||
|
||||
@@ -58,6 +64,12 @@ export default {
|
||||
|
||||
// END /auth/signup
|
||||
|
||||
// /auth/totp
|
||||
"totp.title": "TOTP Authentication",
|
||||
"totp.button.signIn": "Sign in",
|
||||
|
||||
// END /auth/totp
|
||||
|
||||
// /auth/reset-password
|
||||
"resetPassword.title": "Forgot your password?",
|
||||
"resetPassword.description": "Enter your email to reset your password.",
|
||||
@@ -81,8 +93,23 @@ export default {
|
||||
"account.card.password.title": "Password",
|
||||
"account.card.password.old": "Old password",
|
||||
"account.card.password.new": "New password",
|
||||
"account.card.password.noPasswordSet": "You don't have a password set. If you want to sign in with email and password you need to set a password.",
|
||||
"account.notify.password.success": "Password changed successfully",
|
||||
|
||||
"account.card.oauth.title": "Social login",
|
||||
"account.card.oauth.github": "GitHub",
|
||||
"account.card.oauth.google": "Google",
|
||||
"account.card.oauth.microsoft": "Microsoft",
|
||||
"account.card.oauth.discord": "Discord",
|
||||
"account.card.oauth.oidc": "OpenID",
|
||||
"account.card.oauth.link": "Link",
|
||||
"account.card.oauth.unlink": "Unlink",
|
||||
"account.card.oauth.unlinked": "Unlinked",
|
||||
"account.modal.unlink.title": "Unlink account",
|
||||
"account.modal.unlink.description": "Unlinking your social accounts may cause you to lose your account if you don't remember your username and password.",
|
||||
"account.notify.oauth.unlinked.success": "Unlinked successfully",
|
||||
|
||||
|
||||
"account.card.security.title": "Security",
|
||||
"account.card.security.totp.enable.description":
|
||||
"Enter your current password to start enabling TOTP",
|
||||
@@ -336,6 +363,7 @@ export default {
|
||||
"admin.config.category.share": "Share",
|
||||
"admin.config.category.email": "Email",
|
||||
"admin.config.category.smtp": "SMTP",
|
||||
"admin.config.category.oauth": "Social Login",
|
||||
|
||||
"admin.config.general.app-name": "App name",
|
||||
"admin.config.general.app-name.description": "Name of the application",
|
||||
@@ -407,10 +435,66 @@ export default {
|
||||
"admin.config.smtp.password.description": "Password of the SMTP server",
|
||||
"admin.config.smtp.button.test": "Send test email",
|
||||
|
||||
"admin.config.oauth.allow-registration": "Allow registration",
|
||||
"admin.config.oauth.allow-registration.description": "Allow users to register via social login",
|
||||
"admin.config.oauth.ignore-totp": "Ignore TOTP",
|
||||
"admin.config.oauth.ignore-totp.description": "Whether to ignore TOTP when user using social login",
|
||||
"admin.config.oauth.github-enabled": "GitHub",
|
||||
"admin.config.oauth.github-enabled.description": "Whether GitHub login is enabled",
|
||||
"admin.config.oauth.github-client-id": "GitHub Client ID",
|
||||
"admin.config.oauth.github-client-id.description": "Client ID of the GitHub OAuth app",
|
||||
"admin.config.oauth.github-client-secret": "GitHub Client secret",
|
||||
"admin.config.oauth.github-client-secret.description": "Client secret of the GitHub OAuth app",
|
||||
"admin.config.oauth.google-enabled": "Google",
|
||||
"admin.config.oauth.google-enabled.description": "Whether Google login is enabled",
|
||||
"admin.config.oauth.google-client-id": "Google Client ID",
|
||||
"admin.config.oauth.google-client-id.description": "Client ID of the Google OAuth app",
|
||||
"admin.config.oauth.google-client-secret": "Google Client secret",
|
||||
"admin.config.oauth.google-client-secret.description": "Client secret of the Google OAuth app",
|
||||
"admin.config.oauth.microsoft-enabled": "Microsoft",
|
||||
"admin.config.oauth.microsoft-enabled.description": "Whether Microsoft login is enabled",
|
||||
"admin.config.oauth.microsoft-tenant": "Microsoft Tenant",
|
||||
"admin.config.oauth.microsoft-tenant.description": "Tenant ID of the Microsoft OAuth app\ncommon: Users with both a personal Microsoft account and a work or school account from Microsoft Entra ID can sign in to the application. organizations: Only users with work or school accounts from Microsoft Entra ID can sign in to the application.\nconsumers: Only users with a personal Microsoft account can sign in to the application.\ndomain name of the Microsoft Entra tenant or the tenant ID in GUID format: Only users from a specific Microsoft Entra tenant (directory members with a work or school account or directory guests with a personal Microsoft account) can sign in to the application.",
|
||||
"admin.config.oauth.microsoft-client-id": "Microsoft Client ID",
|
||||
"admin.config.oauth.microsoft-client-id.description": "Client ID of the Microsoft OAuth app",
|
||||
"admin.config.oauth.microsoft-client-secret": "Microsoft Client secret",
|
||||
"admin.config.oauth.microsoft-client-secret.description": "Client secret of the Microsoft OAuth app",
|
||||
"admin.config.oauth.discord-enabled": "Discord",
|
||||
"admin.config.oauth.discord-enabled.description": "Whether Discord login is enabled",
|
||||
"admin.config.oauth.discord-client-id": "Discord Client ID",
|
||||
"admin.config.oauth.discord-client-id.description": "Client ID of the Discord OAuth app",
|
||||
"admin.config.oauth.discord-client-secret": "Discord Client secret",
|
||||
"admin.config.oauth.discord-client-secret.description": "Client secret of the Discord OAuth app",
|
||||
"admin.config.oauth.oidc-enabled": "OpenID",
|
||||
"admin.config.oauth.oidc-enabled.description": "Whether OpenID login is enabled",
|
||||
"admin.config.oauth.oidc-discovery-uri": "OpenID Discovery URI",
|
||||
"admin.config.oauth.oidc-discovery-uri.description": "Discovery URI of the OpenID OAuth app",
|
||||
"admin.config.oauth.oidc-client-id": "OpenID Client ID",
|
||||
"admin.config.oauth.oidc-client-id.description": "Client ID of the OpenID OAuth app",
|
||||
"admin.config.oauth.oidc-client-secret": "OpenID Client secret",
|
||||
"admin.config.oauth.oidc-client-secret.description": "Client secret of the OpenID OAuth app",
|
||||
|
||||
// 404
|
||||
"404.description": "Oops this page doesn't exist.",
|
||||
"404.button.home": "Bring me back home",
|
||||
|
||||
// error
|
||||
"error.title": "Error",
|
||||
"error.description": "Oops!",
|
||||
"error.button.back": "Go back",
|
||||
"error.msg.default": "Something went wrong.",
|
||||
"error.msg.access_denied": "You canceled the authentication process, please try again.",
|
||||
"error.msg.expired_token": "The authentication process took too long, please try again.",
|
||||
"error.msg.no_user": "User linked to this {0} account doesn't exist.",
|
||||
"error.msg.no_email": "Can't get email address from this {0} account.",
|
||||
"error.msg.already_linked": "This {0} account is already linked to another account.",
|
||||
"error.msg.not_linked": "This {0} account haven't linked to any account yet.",
|
||||
"error.param.provider_github": "GitHub",
|
||||
"error.param.provider_google": "Google",
|
||||
"error.param.provider_microsoft": "Microsoft",
|
||||
"error.param.provider_discord": "Discord",
|
||||
"error.param.provider_oidc": "OpenID",
|
||||
|
||||
// Common translations
|
||||
"common.button.save": "Save",
|
||||
"common.button.create": "Create",
|
||||
|
||||
@@ -14,7 +14,7 @@ export const config = {
|
||||
export async function middleware(request: NextRequest) {
|
||||
const routes = {
|
||||
unauthenticated: new Routes(["/auth/*", "/"]),
|
||||
public: new Routes(["/share/*", "/s/*", "/upload/*"]),
|
||||
public: new Routes(["/share/*", "/s/*", "/upload/*", "/error"]),
|
||||
admin: new Routes(["/admin/*"]),
|
||||
account: new Routes(["/account*"]),
|
||||
disabled: new Routes([]),
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useForm, yupResolver } from "@mantine/form";
|
||||
import { useModals } from "@mantine/modals";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Tb2Fa } from "react-icons/tb";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import * as yup from "yup";
|
||||
@@ -20,16 +21,28 @@ import Meta from "../../components/Meta";
|
||||
import LanguagePicker from "../../components/account/LanguagePicker";
|
||||
import ThemeSwitcher from "../../components/account/ThemeSwitcher";
|
||||
import showEnableTotpModal from "../../components/account/showEnableTotpModal";
|
||||
import useConfig from "../../hooks/config.hook";
|
||||
import useTranslate from "../../hooks/useTranslate.hook";
|
||||
import useUser from "../../hooks/user.hook";
|
||||
import authService from "../../services/auth.service";
|
||||
import userService from "../../services/user.service";
|
||||
import { getOAuthIcon, getOAuthUrl, unlinkOAuth } from "../../utils/oauth.util";
|
||||
import toast from "../../utils/toast.util";
|
||||
|
||||
const Account = () => {
|
||||
const [oauth, setOAuth] = useState<string[]>([]);
|
||||
const [oauthStatus, setOAuthStatus] = useState<Record<
|
||||
string,
|
||||
{
|
||||
provider: string;
|
||||
providerUsername: string;
|
||||
}
|
||||
> | null>(null);
|
||||
|
||||
const { user, refreshUser } = useUser();
|
||||
const modals = useModals();
|
||||
const t = useTranslate();
|
||||
const config = useConfig();
|
||||
|
||||
const accountForm = useForm({
|
||||
initialValues: {
|
||||
@@ -53,10 +66,14 @@ const Account = () => {
|
||||
},
|
||||
validate: yupResolver(
|
||||
yup.object().shape({
|
||||
oldPassword: yup
|
||||
.string()
|
||||
.min(8, t("common.error.too-short", { length: 8 }))
|
||||
.required(t("common.error.field-required")),
|
||||
oldPassword: yup.string().when([], {
|
||||
is: () => !!user?.hasPassword,
|
||||
then: (schema) =>
|
||||
schema
|
||||
.min(8, t("common.error.too-short", { length: 8 }))
|
||||
.required(t("common.error.field-required")),
|
||||
otherwise: (schema) => schema.notRequired(),
|
||||
}),
|
||||
password: yup
|
||||
.string()
|
||||
.min(8, t("common.error.too-short", { length: 8 }))
|
||||
@@ -96,6 +113,25 @@ const Account = () => {
|
||||
),
|
||||
});
|
||||
|
||||
const refreshOAuthStatus = () => {
|
||||
authService
|
||||
.getOAuthStatus()
|
||||
.then((data) => {
|
||||
setOAuthStatus(data.data);
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
authService
|
||||
.getAvailableOAuth()
|
||||
.then((data) => {
|
||||
setOAuth(data.data);
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
refreshOAuthStatus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("account.title")} />
|
||||
@@ -143,7 +179,8 @@ const Account = () => {
|
||||
onSubmit={passwordForm.onSubmit((values) =>
|
||||
authService
|
||||
.updatePassword(values.oldPassword, values.password)
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
refreshUser();
|
||||
toast.success(t("account.notify.password.success"));
|
||||
passwordForm.reset();
|
||||
})
|
||||
@@ -151,10 +188,16 @@ const Account = () => {
|
||||
)}
|
||||
>
|
||||
<Stack>
|
||||
<PasswordInput
|
||||
label={t("account.card.password.old")}
|
||||
{...passwordForm.getInputProps("oldPassword")}
|
||||
/>
|
||||
{user?.hasPassword ? (
|
||||
<PasswordInput
|
||||
label={t("account.card.password.old")}
|
||||
{...passwordForm.getInputProps("oldPassword")}
|
||||
/>
|
||||
) : (
|
||||
<Text size="sm" color="dimmed">
|
||||
<FormattedMessage id="account.card.password.noPasswordSet" />
|
||||
</Text>
|
||||
)}
|
||||
<PasswordInput
|
||||
label={t("account.card.password.new")}
|
||||
{...passwordForm.getInputProps("password")}
|
||||
@@ -167,7 +210,79 @@ const Account = () => {
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
{oauth.length > 0 && (
|
||||
<Paper withBorder p="xl" mt="lg">
|
||||
<Title order={5} mb="xs">
|
||||
<FormattedMessage id="account.card.oauth.title" />
|
||||
</Title>
|
||||
|
||||
<Tabs defaultValue={oauth[0] || ""}>
|
||||
<Tabs.List>
|
||||
{oauth.map((provider) => (
|
||||
<Tabs.Tab
|
||||
value={provider}
|
||||
icon={getOAuthIcon(provider)}
|
||||
key={provider}
|
||||
>
|
||||
{t(`account.card.oauth.${provider}`)}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{oauth.map((provider) => (
|
||||
<Tabs.Panel value={provider} pt="xs" key={provider}>
|
||||
<Group position="apart">
|
||||
<Text>
|
||||
{oauthStatus?.[provider]
|
||||
? oauthStatus[provider].providerUsername
|
||||
: t("account.card.oauth.unlinked")}
|
||||
</Text>
|
||||
{oauthStatus?.[provider] ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
modals.openConfirmModal({
|
||||
title: t("account.modal.unlink.title"),
|
||||
children: (
|
||||
<Text>
|
||||
{t("account.modal.unlink.description")}
|
||||
</Text>
|
||||
),
|
||||
labels: {
|
||||
confirm: t("account.card.oauth.unlink"),
|
||||
cancel: t("common.button.cancel"),
|
||||
},
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => {
|
||||
unlinkOAuth(provider)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
t("account.notify.oauth.unlinked.success"),
|
||||
);
|
||||
refreshOAuthStatus();
|
||||
})
|
||||
.catch(toast.axiosError);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("account.card.oauth.unlink")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
component="a"
|
||||
href={getOAuthUrl(
|
||||
config.get("general.appUrl"),
|
||||
provider,
|
||||
)}
|
||||
>
|
||||
{t("account.card.oauth.link")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Paper>
|
||||
)}
|
||||
<Paper withBorder p="xl" mt="lg">
|
||||
<Title order={5} mb="xs">
|
||||
<FormattedMessage id="account.card.security.title" />
|
||||
|
||||
@@ -24,10 +24,7 @@ import CenterLoader from "../../../components/core/CenterLoader";
|
||||
import useConfig from "../../../hooks/config.hook";
|
||||
import configService from "../../../services/config.service";
|
||||
import { AdminConfig, UpdateConfig } from "../../../types/config.type";
|
||||
import {
|
||||
camelToKebab,
|
||||
capitalizeFirstLetter,
|
||||
} from "../../../utils/string.util";
|
||||
import { camelToKebab } from "../../../utils/string.util";
|
||||
import toast from "../../../utils/toast.util";
|
||||
import useTranslate from "../../../hooks/useTranslate.hook";
|
||||
|
||||
@@ -128,7 +125,7 @@ export default function AppShellDemo() {
|
||||
<>
|
||||
<Stack>
|
||||
<Title mb="md" order={3}>
|
||||
{capitalizeFirstLetter(categoryId)}
|
||||
{t("admin.config.category." + categoryId)}
|
||||
</Title>
|
||||
{configVariables.map((configVariable) => (
|
||||
<Group key={configVariable.key} position="apart">
|
||||
|
||||
18
frontend/src/pages/auth/totp/[loginToken].tsx
Normal file
18
frontend/src/pages/auth/totp/[loginToken].tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import useTranslate from "../../../hooks/useTranslate.hook";
|
||||
import Meta from "../../../components/Meta";
|
||||
import TotpForm from "../../../components/auth/TotpForm";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
const Totp = () => {
|
||||
const t = useTranslate();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("totp.title")} />
|
||||
<TotpForm redirectPath={(router.query.redirect as string) || "/upload"} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Totp;
|
||||
49
frontend/src/pages/error.tsx
Normal file
49
frontend/src/pages/error.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from "react";
|
||||
import { Button, createStyles, Stack, Text, Title } from "@mantine/core";
|
||||
import Meta from "../components/Meta";
|
||||
import useTranslate from "../hooks/useTranslate.hook";
|
||||
import { useRouter } from "next/router";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
|
||||
const useStyle = createStyles({
|
||||
title: {
|
||||
fontSize: 100,
|
||||
},
|
||||
});
|
||||
|
||||
export default function Error() {
|
||||
const { classes } = useStyle();
|
||||
const t = useTranslate();
|
||||
const router = useRouter();
|
||||
|
||||
const params = router.query.params
|
||||
? (router.query.params as string).split(",").map((param) => {
|
||||
return t(`error.param.${param}`);
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("error.title")} />
|
||||
<Stack align="center">
|
||||
<Title order={3} className={classes.title}>
|
||||
{t("error.description")}
|
||||
</Title>
|
||||
<Text mt="xl" size="lg">
|
||||
<FormattedMessage
|
||||
id={`error.msg.${router.query.error || "default"}`}
|
||||
values={Object.fromEntries(
|
||||
[params].map((value, key) => [key.toString(), value]),
|
||||
)}
|
||||
/>
|
||||
</Text>
|
||||
<Button
|
||||
mt="xl"
|
||||
onClick={() => router.push((router.query.redirect as string) || "/")}
|
||||
>
|
||||
{t("error.button.back")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ const Upload = ({
|
||||
file.uploadingProgress = progress;
|
||||
}
|
||||
return file;
|
||||
})
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -84,7 +84,7 @@ const Upload = ({
|
||||
name: file.name,
|
||||
},
|
||||
chunkIndex,
|
||||
chunks
|
||||
chunks,
|
||||
)
|
||||
.then((response) => {
|
||||
fileId = response.id;
|
||||
@@ -114,7 +114,7 @@ const Upload = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
Promise.all(fileUploadPromises);
|
||||
@@ -129,19 +129,19 @@ const Upload = ({
|
||||
isReverseShare,
|
||||
appUrl: config.get("general.appUrl"),
|
||||
allowUnauthenticatedShares: config.get(
|
||||
"share.allowUnauthenticatedShares"
|
||||
"share.allowUnauthenticatedShares",
|
||||
),
|
||||
enableEmailRecepients: config.get("email.enableShareEmailRecipients"),
|
||||
},
|
||||
files,
|
||||
uploadFiles
|
||||
uploadFiles,
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check if there are any files that failed to upload
|
||||
const fileErrorCount = files.filter(
|
||||
(file) => file.uploadingProgress == -1
|
||||
(file) => file.uploadingProgress == -1,
|
||||
).length;
|
||||
|
||||
if (fileErrorCount > 0) {
|
||||
@@ -151,7 +151,7 @@ const Upload = ({
|
||||
{
|
||||
withCloseButton: false,
|
||||
autoClose: false,
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
errorToastShown = true;
|
||||
|
||||
@@ -15,24 +15,11 @@ const signIn = async (emailOrUsername: string, password: string) => {
|
||||
return response;
|
||||
};
|
||||
|
||||
const signInTotp = async (
|
||||
emailOrUsername: string,
|
||||
password: string,
|
||||
totp: string,
|
||||
loginToken: string,
|
||||
) => {
|
||||
const emailOrUsernameBody = emailOrUsername.includes("@")
|
||||
? { email: emailOrUsername }
|
||||
: { username: emailOrUsername };
|
||||
|
||||
const response = await api.post("auth/signIn/totp", {
|
||||
...emailOrUsernameBody,
|
||||
password,
|
||||
const signInTotp = (totp: string, loginToken: string) => {
|
||||
return api.post("auth/signIn/totp", {
|
||||
totp,
|
||||
loginToken,
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const signUp = async (email: string, username: string, password: string) => {
|
||||
@@ -96,6 +83,14 @@ const disableTOTP = async (totpCode: string, password: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
const getAvailableOAuth = async () => {
|
||||
return api.get("/oauth/available");
|
||||
};
|
||||
|
||||
const getOAuthStatus = () => {
|
||||
return api.get("/oauth/status");
|
||||
};
|
||||
|
||||
export default {
|
||||
signIn,
|
||||
signInTotp,
|
||||
@@ -108,4 +103,6 @@ export default {
|
||||
enableTOTP,
|
||||
verifyTOTP,
|
||||
disableTOTP,
|
||||
getAvailableOAuth,
|
||||
getOAuthStatus,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ type User = {
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
totpVerified: boolean;
|
||||
hasPassword: boolean;
|
||||
};
|
||||
|
||||
export type CreateUser = {
|
||||
|
||||
29
frontend/src/utils/oauth.util.tsx
Normal file
29
frontend/src/utils/oauth.util.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
SiDiscord,
|
||||
SiGithub,
|
||||
SiGoogle,
|
||||
SiMicrosoft,
|
||||
SiOpenid,
|
||||
} from "react-icons/si";
|
||||
import React from "react";
|
||||
import api from "../services/api.service";
|
||||
|
||||
const getOAuthUrl = (appUrl: string, provider: string) => {
|
||||
return `${appUrl}/api/oauth/auth/${provider}`;
|
||||
};
|
||||
|
||||
const getOAuthIcon = (provider: string) => {
|
||||
return {
|
||||
google: <SiGoogle />,
|
||||
microsoft: <SiMicrosoft />,
|
||||
github: <SiGithub />,
|
||||
discord: <SiDiscord />,
|
||||
oidc: <SiOpenid />,
|
||||
}[provider];
|
||||
};
|
||||
|
||||
const unlinkOAuth = (provider: string) => {
|
||||
return api.post(`/oauth/unlink/${provider}`);
|
||||
};
|
||||
|
||||
export { getOAuthUrl, getOAuthIcon, unlinkOAuth };
|
||||
Reference in New Issue
Block a user