mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-07 11:16:37 +00:00
allow server admins to generate password reset code
This commit is contained in:
@@ -924,6 +924,10 @@
|
|||||||
"passwordResetSent": "We'll send a password reset code to this email address.",
|
"passwordResetSent": "We'll send a password reset code to this email address.",
|
||||||
"passwordResetCode": "Reset Code",
|
"passwordResetCode": "Reset Code",
|
||||||
"passwordResetCodeDescription": "Check your email for the reset code.",
|
"passwordResetCodeDescription": "Check your email for the reset code.",
|
||||||
|
"generatePasswordResetCode": "Generate Password Reset Code",
|
||||||
|
"passwordResetCodeGenerated": "Password Reset Code Generated",
|
||||||
|
"passwordResetCodeGeneratedDescription": "Share this code with the user. They can use it to reset their password.",
|
||||||
|
"passwordResetUrl": "Reset URL",
|
||||||
"passwordNew": "New Password",
|
"passwordNew": "New Password",
|
||||||
"passwordNewConfirm": "Confirm New Password",
|
"passwordNewConfirm": "Confirm New Password",
|
||||||
"changePassword": "Change Password",
|
"changePassword": "Change Password",
|
||||||
@@ -941,8 +945,9 @@
|
|||||||
"pincodeAuth": "Authenticator Code",
|
"pincodeAuth": "Authenticator Code",
|
||||||
"pincodeSubmit2": "Submit Code",
|
"pincodeSubmit2": "Submit Code",
|
||||||
"passwordResetSubmit": "Request Reset",
|
"passwordResetSubmit": "Request Reset",
|
||||||
|
"passwordResetAlreadyHaveCode": "Enter Password Reset Code",
|
||||||
"passwordResetSmtpRequired": "Please contact your administrator",
|
"passwordResetSmtpRequired": "Please contact your administrator",
|
||||||
"passwordResetSmtpRequiredDescription": "Password reset is not available because no SMTP server is configured. Please contact your administrator for assistance.",
|
"passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.",
|
||||||
"passwordBack": "Back to Password",
|
"passwordBack": "Back to Password",
|
||||||
"loginBack": "Go back to log in",
|
"loginBack": "Go back to log in",
|
||||||
"signup": "Sign up",
|
"signup": "Sign up",
|
||||||
|
|||||||
@@ -715,6 +715,11 @@ unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice);
|
|||||||
|
|
||||||
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
|
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
|
||||||
authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser);
|
authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser);
|
||||||
|
authenticated.post(
|
||||||
|
"/user/:userId/generate-password-reset-code",
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
user.adminGeneratePasswordResetCode
|
||||||
|
);
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/user/:userId",
|
"/user/:userId",
|
||||||
verifyUserIsServerAdmin,
|
verifyUserIsServerAdmin,
|
||||||
|
|||||||
125
server/routers/user/adminGeneratePasswordResetCode.ts
Normal file
125
server/routers/user/adminGeneratePasswordResetCode.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import { response } from "@server/lib/response";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { passwordResetTokens, users } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { alphabet, generateRandomString } from "oslo/crypto";
|
||||||
|
import { createDate } from "oslo";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { TimeSpan } from "oslo";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
|
const adminGeneratePasswordResetCodeSchema = z.strictObject({
|
||||||
|
userId: z.string().min(1)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AdminGeneratePasswordResetCodeBody = z.infer<typeof adminGeneratePasswordResetCodeSchema>;
|
||||||
|
|
||||||
|
export type AdminGeneratePasswordResetCodeResponse = {
|
||||||
|
token: string;
|
||||||
|
email: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function adminGeneratePasswordResetCode(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
const parsedParams = adminGeneratePasswordResetCodeSchema.safeParse(req.params);
|
||||||
|
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = parsedParams.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingUser = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.userId, userId));
|
||||||
|
|
||||||
|
if (!existingUser || !existingUser.length) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"User not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingUser[0].type !== UserType.Internal) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Password reset codes can only be generated for internal users"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingUser[0].email) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"User does not have an email address"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await trx
|
||||||
|
.delete(passwordResetTokens)
|
||||||
|
.where(eq(passwordResetTokens.userId, existingUser[0].userId));
|
||||||
|
|
||||||
|
const tokenHash = await hashPassword(token);
|
||||||
|
|
||||||
|
await trx.insert(passwordResetTokens).values({
|
||||||
|
userId: existingUser[0].userId,
|
||||||
|
email: existingUser[0].email!,
|
||||||
|
tokenHash,
|
||||||
|
expiresAt: createDate(new TimeSpan(2, "h")).getTime()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${existingUser[0].email}&token=${token}`;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Admin generated password reset code for user ${existingUser[0].email} (${userId})`
|
||||||
|
);
|
||||||
|
|
||||||
|
return response<AdminGeneratePasswordResetCodeResponse>(res, {
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
email: existingUser[0].email!,
|
||||||
|
url
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Password reset code generated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to generate password reset code"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ export * from "./getOrgUser";
|
|||||||
export * from "./adminListUsers";
|
export * from "./adminListUsers";
|
||||||
export * from "./adminRemoveUser";
|
export * from "./adminRemoveUser";
|
||||||
export * from "./adminGetUser";
|
export * from "./adminGetUser";
|
||||||
|
export * from "./adminGeneratePasswordResetCode";
|
||||||
export * from "./listInvitations";
|
export * from "./listInvitations";
|
||||||
export * from "./removeInvitation";
|
export * from "./removeInvitation";
|
||||||
export * from "./createOrgUser";
|
export * from "./createOrgUser";
|
||||||
|
|||||||
@@ -7,16 +7,6 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
|
||||||
import { InfoIcon } from "lucide-react";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -32,7 +22,6 @@ export default async function Page(props: {
|
|||||||
const getUser = cache(verifySession);
|
const getUser = cache(verifySession);
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
const env = pullEnv();
|
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
let loggedOut = false;
|
let loggedOut = false;
|
||||||
@@ -55,48 +44,6 @@ export default async function Page(props: {
|
|||||||
redirectUrl = cleanRedirect(searchParams.redirect);
|
redirectUrl = cleanRedirect(searchParams.redirect);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If email is not enabled, show a message instead of the form
|
|
||||||
if (!env.email.emailEnabled) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="w-full max-w-md">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t("passwordReset")}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t("passwordResetDescription")}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Alert variant="neutral">
|
|
||||||
<InfoIcon className="h-4 w-4" />
|
|
||||||
<AlertTitle className="font-semibold">
|
|
||||||
{t("passwordResetSmtpRequired")}
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
{t("passwordResetSmtpRequiredDescription")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-muted-foreground mt-4">
|
|
||||||
<Link
|
|
||||||
href={
|
|
||||||
!searchParams.redirect
|
|
||||||
? `/auth/login`
|
|
||||||
: `/auth/login?redirect=${redirectUrl}`
|
|
||||||
}
|
|
||||||
className="underline"
|
|
||||||
>
|
|
||||||
{t("loginBack")}
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResetPasswordForm
|
<ResetPasswordForm
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ import {
|
|||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaClose
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
|
||||||
export type GlobalUserRow = {
|
export type GlobalUserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,6 +49,12 @@ type Props = {
|
|||||||
users: GlobalUserRow[];
|
users: GlobalUserRow[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AdminGeneratePasswordResetCodeResponse = {
|
||||||
|
token: string;
|
||||||
|
email: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function UsersTable({ users }: Props) {
|
export default function UsersTable({ users }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -48,6 +66,11 @@ export default function UsersTable({ users }: Props) {
|
|||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [isPasswordResetCodeDialogOpen, setIsPasswordResetCodeDialogOpen] =
|
||||||
|
useState(false);
|
||||||
|
const [passwordResetCodeData, setPasswordResetCodeData] =
|
||||||
|
useState<AdminGeneratePasswordResetCodeResponse | null>(null);
|
||||||
|
const [isGeneratingCode, setIsGeneratingCode] = useState(false);
|
||||||
|
|
||||||
const refreshData = async () => {
|
const refreshData = async () => {
|
||||||
console.log("Data refreshed");
|
console.log("Data refreshed");
|
||||||
@@ -86,6 +109,29 @@ export default function UsersTable({ users }: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const generatePasswordResetCode = async (userId: string) => {
|
||||||
|
setIsGeneratingCode(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post<
|
||||||
|
AxiosResponse<AdminGeneratePasswordResetCodeResponse>
|
||||||
|
>(`/user/${userId}/generate-password-reset-code`);
|
||||||
|
|
||||||
|
if (res.data?.data) {
|
||||||
|
setPasswordResetCodeData(res.data.data);
|
||||||
|
setIsPasswordResetCodeDialogOpen(true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to generate password reset code", e);
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("error"),
|
||||||
|
description: formatAxiosError(e, t("errorOccurred"))
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingCode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ExtendedColumnDef<GlobalUserRow>[] = [
|
const columns: ExtendedColumnDef<GlobalUserRow>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "id",
|
accessorKey: "id",
|
||||||
@@ -195,7 +241,7 @@ export default function UsersTable({ users }: Props) {
|
|||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
{userRow.twoFactorEnabled ||
|
{userRow.twoFactorEnabled ||
|
||||||
userRow.twoFactorSetupRequested ? (
|
userRow.twoFactorSetupRequested ? (
|
||||||
<span className="text-green-500">
|
<span className="text-green-500">
|
||||||
{t("enabled")}
|
{t("enabled")}
|
||||||
</span>
|
</span>
|
||||||
@@ -217,17 +263,21 @@ export default function UsersTable({ users }: Props) {
|
|||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
variant="ghost"
|
<span className="sr-only">Open menu</span>
|
||||||
className="h-8 w-8 p-0"
|
|
||||||
>
|
|
||||||
<span className="sr-only">
|
|
||||||
Open menu
|
|
||||||
</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
{r.type !== "internal" && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
generatePasswordResetCode(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("generatePasswordResetCode")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelected(r);
|
setSelected(r);
|
||||||
@@ -295,6 +345,58 @@ export default function UsersTable({ users }: Props) {
|
|||||||
onRefresh={refreshData}
|
onRefresh={refreshData}
|
||||||
isRefreshing={isRefreshing}
|
isRefreshing={isRefreshing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Credenza
|
||||||
|
open={isPasswordResetCodeDialogOpen}
|
||||||
|
onOpenChange={setIsPasswordResetCodeDialogOpen}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{t("passwordResetCodeGenerated")}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("passwordResetCodeGeneratedDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
{passwordResetCodeData && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-2 block">
|
||||||
|
{t("email")}
|
||||||
|
</label>
|
||||||
|
<CopyToClipboard
|
||||||
|
text={passwordResetCodeData.email}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-2 block">
|
||||||
|
{t("passwordResetCode")}
|
||||||
|
</label>
|
||||||
|
<CopyToClipboard
|
||||||
|
text={passwordResetCodeData.token}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-2 block">
|
||||||
|
{t("passwordResetUrl")}
|
||||||
|
</label>
|
||||||
|
<CopyToClipboard
|
||||||
|
text={passwordResetCodeData.url}
|
||||||
|
isLink={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">{t("close")}</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,24 +26,21 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2 max-w-full">
|
<div className="flex items-center space-x-2 min-w-0 max-w-full">
|
||||||
{isLink ? (
|
{isLink ? (
|
||||||
<Link
|
<Link
|
||||||
href={text}
|
href={text}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="truncate hover:underline text-sm"
|
className="truncate hover:underline text-sm min-w-0 max-w-full"
|
||||||
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
|
|
||||||
title={text} // Shows full text on hover
|
title={text} // Shows full text on hover
|
||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
className="truncate text-sm"
|
className="truncate text-sm min-w-0 max-w-full"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: "100%",
|
|
||||||
display: "block",
|
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis"
|
textOverflow: "ellipsis"
|
||||||
@@ -55,7 +52,7 @@ const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) =>
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer"
|
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer flex-shrink-0"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
>
|
>
|
||||||
{!copied ? (
|
{!copied ? (
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
|
|||||||
// );
|
// );
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("px-0 mb-4 space-y-4", className)} {...props}>
|
<div className={cn("px-0 mb-4 space-y-4 overflow-x-hidden min-w-0", className)} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ import {
|
|||||||
ResetPasswordBody,
|
ResetPasswordBody,
|
||||||
ResetPasswordResponse
|
ResetPasswordResponse
|
||||||
} from "@server/routers/auth";
|
} from "@server/routers/auth";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2, InfoIcon } from "lucide-react";
|
||||||
import { Alert, AlertDescription } from "./ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
@@ -84,22 +84,23 @@ export default function ResetPasswordForm({
|
|||||||
|
|
||||||
const [state, setState] = useState<"request" | "reset" | "mfa">(getState());
|
const [state, setState] = useState<"request" | "reset" | "mfa">(getState());
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.email({ message: t('emailInvalid') }),
|
email: z.email({ message: t("emailInvalid") }),
|
||||||
token: z.string().min(8, { message: t('tokenInvalid') }),
|
token: z.string().min(8, { message: t("tokenInvalid") }),
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
confirmPassword: passwordSchema
|
confirmPassword: passwordSchema
|
||||||
})
|
})
|
||||||
.refine((data) => data.password === data.confirmPassword, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
path: ["confirmPassword"],
|
path: ["confirmPassword"],
|
||||||
message: t('passwordNotMatch')
|
message: t("passwordNotMatch")
|
||||||
});
|
});
|
||||||
|
|
||||||
const mfaSchema = z.object({
|
const mfaSchema = z.object({
|
||||||
code: z.string().length(6, { message: t('pincodeInvalid') })
|
code: z.string().length(6, { message: t("pincodeInvalid") })
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
@@ -139,8 +140,8 @@ export default function ResetPasswordForm({
|
|||||||
} as RequestPasswordResetBody
|
} as RequestPasswordResetBody
|
||||||
)
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
setError(formatAxiosError(e, t('errorOccurred')));
|
setError(formatAxiosError(e, t("errorOccurred")));
|
||||||
console.error(t('passwordErrorRequestReset'), e);
|
console.error(t("passwordErrorRequestReset"), e);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -169,8 +170,8 @@ export default function ResetPasswordForm({
|
|||||||
} as ResetPasswordBody
|
} as ResetPasswordBody
|
||||||
)
|
)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
setError(formatAxiosError(e, t('errorOccurred')));
|
setError(formatAxiosError(e, t("errorOccurred")));
|
||||||
console.error(t('passwordErrorReset'), e);
|
console.error(t("passwordErrorReset"), e);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,7 +187,11 @@ export default function ResetPasswordForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccessMessage(quickstart ? t('accountSetupSuccess') : t('passwordResetSuccess'));
|
setSuccessMessage(
|
||||||
|
quickstart
|
||||||
|
? t("accountSetupSuccess")
|
||||||
|
: t("passwordResetSuccess")
|
||||||
|
);
|
||||||
|
|
||||||
// Auto-login after successful password reset
|
// Auto-login after successful password reset
|
||||||
try {
|
try {
|
||||||
@@ -208,7 +213,10 @@ export default function ResetPasswordForm({
|
|||||||
try {
|
try {
|
||||||
await api.post("/auth/verify-email/request");
|
await api.post("/auth/verify-email/request");
|
||||||
} catch (verificationError) {
|
} catch (verificationError) {
|
||||||
console.error("Failed to send verification code:", verificationError);
|
console.error(
|
||||||
|
"Failed to send verification code:",
|
||||||
|
verificationError
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
@@ -229,7 +237,6 @@ export default function ResetPasswordForm({
|
|||||||
}
|
}
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
||||||
} catch (loginError) {
|
} catch (loginError) {
|
||||||
// Auto-login failed, but password reset was successful
|
// Auto-login failed, but password reset was successful
|
||||||
console.error("Auto-login failed:", loginError);
|
console.error("Auto-login failed:", loginError);
|
||||||
@@ -251,47 +258,70 @@ export default function ResetPasswordForm({
|
|||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{quickstart ? t('completeAccountSetup') : t('passwordReset')}
|
{quickstart
|
||||||
|
? t("completeAccountSetup")
|
||||||
|
: t("passwordReset")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{quickstart
|
{quickstart
|
||||||
? t('completeAccountSetupDescription')
|
? t("completeAccountSetupDescription")
|
||||||
: t('passwordResetDescription')
|
: t("passwordResetDescription")}
|
||||||
}
|
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{state === "request" && (
|
{state === "request" && (
|
||||||
<Form {...requestForm}>
|
<>
|
||||||
<form
|
{!env.email.emailEnabled && (
|
||||||
onSubmit={requestForm.handleSubmit(
|
<Alert variant="neutral">
|
||||||
onRequest
|
<InfoIcon className="h-4 w-4" />
|
||||||
)}
|
<AlertTitle className="font-semibold">
|
||||||
className="space-y-4"
|
{t("passwordResetSmtpRequired")}
|
||||||
id="form"
|
</AlertTitle>
|
||||||
>
|
<AlertDescription>
|
||||||
<FormField
|
{t(
|
||||||
control={requestForm.control}
|
"passwordResetSmtpRequiredDescription"
|
||||||
name="email"
|
)}
|
||||||
render={({ field }) => (
|
</AlertDescription>
|
||||||
<FormItem>
|
</Alert>
|
||||||
<FormLabel>{t('email')}</FormLabel>
|
)}
|
||||||
<FormControl>
|
{env.email.emailEnabled && (
|
||||||
<Input {...field} />
|
<Form {...requestForm}>
|
||||||
</FormControl>
|
<form
|
||||||
<FormMessage />
|
onSubmit={requestForm.handleSubmit(
|
||||||
<FormDescription>
|
onRequest
|
||||||
{quickstart
|
)}
|
||||||
? t('accountSetupSent')
|
className="space-y-4"
|
||||||
: t('passwordResetSent')
|
id="form"
|
||||||
}
|
>
|
||||||
</FormDescription>
|
<FormField
|
||||||
</FormItem>
|
control={requestForm.control}
|
||||||
)}
|
name="email"
|
||||||
/>
|
render={({ field }) => (
|
||||||
</form>
|
<FormItem>
|
||||||
</Form>
|
<FormLabel>
|
||||||
|
{t("email")}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
{quickstart
|
||||||
|
? t(
|
||||||
|
"accountSetupSent"
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"passwordResetSent"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state === "reset" && (
|
{state === "reset" && (
|
||||||
@@ -306,11 +336,13 @@ export default function ResetPasswordForm({
|
|||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('email')}</FormLabel>
|
<FormLabel>
|
||||||
|
{t("email")}
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
{...field}
|
{...field}
|
||||||
disabled
|
disabled={env.email.emailEnabled}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -326,9 +358,12 @@ export default function ResetPasswordForm({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{quickstart
|
{quickstart
|
||||||
? t('accountSetupCode')
|
? t(
|
||||||
: t('passwordResetCode')
|
"accountSetupCode"
|
||||||
}
|
)
|
||||||
|
: t(
|
||||||
|
"passwordResetCode"
|
||||||
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@@ -337,12 +372,17 @@ export default function ResetPasswordForm({
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
<FormDescription>
|
{env.email.emailEnabled && (
|
||||||
{quickstart
|
<FormDescription>
|
||||||
? t('accountSetupCodeDescription')
|
{quickstart
|
||||||
: t('passwordResetCodeDescription')
|
? t(
|
||||||
}
|
"accountSetupCodeDescription"
|
||||||
</FormDescription>
|
)
|
||||||
|
: t(
|
||||||
|
"passwordResetCodeDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -355,9 +395,8 @@ export default function ResetPasswordForm({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{quickstart
|
{quickstart
|
||||||
? t('passwordCreate')
|
? t("passwordCreate")
|
||||||
: t('passwordNew')
|
: t("passwordNew")}
|
||||||
}
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@@ -376,9 +415,12 @@ export default function ResetPasswordForm({
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{quickstart
|
{quickstart
|
||||||
? t('passwordCreateConfirm')
|
? t(
|
||||||
: t('passwordNewConfirm')
|
"passwordCreateConfirm"
|
||||||
}
|
)
|
||||||
|
: t(
|
||||||
|
"passwordNewConfirm"
|
||||||
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
@@ -407,7 +449,7 @@ export default function ResetPasswordForm({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t('pincodeAuth')}
|
{t("pincodeAuth")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
@@ -475,26 +517,45 @@ export default function ResetPasswordForm({
|
|||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
{state === "reset"
|
{state === "reset"
|
||||||
? (quickstart ? t('completeSetup') : t('passwordReset'))
|
? quickstart
|
||||||
: t('pincodeSubmit2')}
|
? t("completeSetup")
|
||||||
|
: t("passwordReset")
|
||||||
|
: t("pincodeSubmit2")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state === "request" && (
|
{state === "request" && (
|
||||||
<Button
|
<div className="flex flex-col gap-2">
|
||||||
type="submit"
|
{env.email.emailEnabled && (
|
||||||
form="form"
|
<Button
|
||||||
className="w-full"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
form="form"
|
||||||
>
|
className="w-full"
|
||||||
{isSubmitting && (
|
disabled={isSubmitting}
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
>
|
||||||
|
{isSubmitting && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{quickstart
|
||||||
|
? t("accountSetupSubmit")
|
||||||
|
: t("passwordResetSubmit")}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{quickstart
|
<Button
|
||||||
? t('accountSetupSubmit')
|
type="button"
|
||||||
: t('passwordResetSubmit')
|
className="w-full"
|
||||||
}
|
onClick={() => {
|
||||||
</Button>
|
const email =
|
||||||
|
requestForm.getValues("email");
|
||||||
|
if (email) {
|
||||||
|
form.setValue("email", email);
|
||||||
|
}
|
||||||
|
setState("reset");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("passwordResetAlreadyHaveCode")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state === "mfa" && (
|
{state === "mfa" && (
|
||||||
@@ -507,7 +568,7 @@ export default function ResetPasswordForm({
|
|||||||
mfaForm.reset();
|
mfaForm.reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('passwordBack')}
|
{t("passwordBack")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -521,7 +582,7 @@ export default function ResetPasswordForm({
|
|||||||
form.reset();
|
form.reset();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('backToEmail')}
|
{t("backToEmail")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user