fix: unable to change password when using magic link (#484)
* fix: fixed password not resetting & added password prompt with save guards * fix: migrated to using setPassword
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
TbLayoutSidebarRightCollapse,
|
TbLayoutSidebarRightCollapse,
|
||||||
TbLayoutSidebarRightExpand,
|
TbLayoutSidebarRightExpand,
|
||||||
} from "react-icons/tb";
|
} from "react-icons/tb";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
import { authClient } from "@kan/auth/client";
|
import { authClient } from "@kan/auth/client";
|
||||||
|
|
||||||
@@ -15,7 +16,10 @@ import { useClickOutside } from "~/hooks/useClickOutside";
|
|||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
import Button from "./Button";
|
||||||
|
import Modal from "./modal";
|
||||||
import SideNavigation from "./SideNavigation";
|
import SideNavigation from "./SideNavigation";
|
||||||
|
import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation";
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -43,7 +47,7 @@ export default function Dashboard({
|
|||||||
hasRightPanel = false,
|
hasRightPanel = false,
|
||||||
}: DashboardProps) {
|
}: DashboardProps) {
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const { openModal } = useModal();
|
const { openModal, closeModal, modalContentType } = useModal();
|
||||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -109,6 +113,24 @@ export default function Dashboard({
|
|||||||
}
|
}
|
||||||
}, [hasLoaded, availableWorkspaces.length, openModal, router]);
|
}, [hasLoaded, availableWorkspaces.length, openModal, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isCredentialsEnabled =
|
||||||
|
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||||
|
|
||||||
|
if (
|
||||||
|
!userLoading &&
|
||||||
|
user &&
|
||||||
|
isCredentialsEnabled &&
|
||||||
|
user.hasMagicLinkAccount &&
|
||||||
|
!user.hasPassword &&
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
!sessionStorage.getItem("set_password_prompted")
|
||||||
|
) {
|
||||||
|
sessionStorage.setItem("set_password_prompted", "1");
|
||||||
|
openModal("SET_PASSWORD_PROMPT");
|
||||||
|
}
|
||||||
|
}, [user, userLoading, openModal]);
|
||||||
|
|
||||||
const isDarkMode = resolvedTheme === "dark";
|
const isDarkMode = resolvedTheme === "dark";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -203,6 +225,22 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Modal modalSize="sm" isVisible={modalContentType === "SET_PASSWORD_PROMPT"}>
|
||||||
|
{user?.hasPassword ? (
|
||||||
|
<div className="p-5">
|
||||||
|
<h2 className="text-md pb-4 font-medium dark:text-white">{t`Password already set`}</h2>
|
||||||
|
<p className="mb-6 text-sm text-light-900">
|
||||||
|
{t`Your account already has a password. You can change it from your account settings.`}
|
||||||
|
</p>
|
||||||
|
<Button variant="secondary" onClick={closeModal} fullWidth size="lg">
|
||||||
|
{t`Close`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChangePasswordFormConfirmation hasPassword={false} />
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,17 +85,19 @@ export default function AccountSettings() {
|
|||||||
{isCredentialsEnabled && (
|
{isCredentialsEnabled && (
|
||||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||||
{t`Change Password`}
|
{data?.hasPassword ? t`Change Password` : t`Set Password`}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||||
{t`You are about to change your password.`}
|
{data?.hasPassword
|
||||||
|
? t`You are about to change your password.`
|
||||||
|
: t`Set a password to enable password-based login.`}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => openModal("CHANGE_PASSWORD")}
|
onClick={() => openModal("CHANGE_PASSWORD")}
|
||||||
>
|
>
|
||||||
{t`Change Password`}
|
{data?.hasPassword ? t`Change Password` : t`Set Password`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +115,7 @@ export default function AccountSettings() {
|
|||||||
modalSize="sm"
|
modalSize="sm"
|
||||||
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
|
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
|
||||||
>
|
>
|
||||||
<ChangePasswordFormConfirmation />
|
<ChangePasswordFormConfirmation hasPassword={data?.hasPassword ?? false} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Global modals */}
|
{/* Global modals */}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useRouter } from "next/navigation";
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { env } from "next-runtime-env";
|
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -14,27 +13,43 @@ import { useModal } from "~/providers/modal";
|
|||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
const FormSchema = z
|
const buildSchema = (hasPassword: boolean) => {
|
||||||
.object({
|
const base = z.object({
|
||||||
currentPassword: z.string().min(1, t`Current password is required`),
|
currentPassword: hasPassword
|
||||||
|
? z.string().min(1, t`Current password is required`)
|
||||||
|
: z.string().optional(),
|
||||||
newPassword: z
|
newPassword: z
|
||||||
.string()
|
.string()
|
||||||
.min(8, t`Password must be at least 8 characters`)
|
.min(8, t`Password must be at least 8 characters`)
|
||||||
.min(1, t`New password is required`),
|
.min(1, t`New password is required`),
|
||||||
confirmPassword: z.string().min(1, t`Please confirm your new password`),
|
confirmPassword: z.string().min(1, t`Please confirm your new password`),
|
||||||
})
|
|
||||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
|
||||||
message: t`Passwords do not match`,
|
|
||||||
path: ["confirmPassword"],
|
|
||||||
})
|
|
||||||
.refine((data) => data.currentPassword !== data.newPassword, {
|
|
||||||
message: t`New password must be different from current password`,
|
|
||||||
path: ["newPassword"],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormValues = z.infer<typeof FormSchema>;
|
return base
|
||||||
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
|
message: t`Passwords do not match`,
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => !hasPassword || data.currentPassword !== data.newPassword,
|
||||||
|
{
|
||||||
|
message: t`New password must be different from current password`,
|
||||||
|
path: ["newPassword"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function ChangePasswordFormConfirmation() {
|
type FormValues = {
|
||||||
|
currentPassword?: string;
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hasPassword: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
|
||||||
const { closeModal } = useModal();
|
const { closeModal } = useModal();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -47,15 +62,24 @@ export function ChangePasswordFormConfirmation() {
|
|||||||
reset,
|
reset,
|
||||||
setError,
|
setError,
|
||||||
} = useForm<FormValues>({
|
} = useForm<FormValues>({
|
||||||
resolver: zodResolver(FormSchema),
|
resolver: zodResolver(buildSchema(hasPassword)),
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const setPasswordMutation = api.user.setPassword.useMutation();
|
||||||
|
|
||||||
const changePasswordMutation = useMutation({
|
const changePasswordMutation = useMutation({
|
||||||
mutationFn: async (data: FormValues) => {
|
mutationFn: async (data: FormValues) => {
|
||||||
|
if (!hasPassword) {
|
||||||
|
await setPasswordMutation.mutateAsync({
|
||||||
|
newPassword: data.newPassword,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await authClient.changePassword({
|
const response = await authClient.changePassword({
|
||||||
newPassword: data.newPassword,
|
newPassword: data.newPassword,
|
||||||
currentPassword: data.currentPassword,
|
currentPassword: data.currentPassword ?? "",
|
||||||
revokeOtherSessions: true,
|
revokeOtherSessions: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,11 +90,19 @@ export function ChangePasswordFormConfirmation() {
|
|||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Password Changed`,
|
header: hasPassword ? t`Password Changed` : t`Password Set`,
|
||||||
message: t`Your password has been changed.`,
|
message: hasPassword
|
||||||
|
? t`Your password has been changed.`
|
||||||
|
: t`Your password has been set.`,
|
||||||
icon: "success",
|
icon: "success",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear the session prompt flag so future magic link logins
|
||||||
|
// don't re-show the set-password modal (password is now set)
|
||||||
|
if (!hasPassword && typeof window !== "undefined") {
|
||||||
|
sessionStorage.removeItem("set_password_prompted");
|
||||||
|
}
|
||||||
|
|
||||||
utils.invalidate();
|
utils.invalidate();
|
||||||
reset();
|
reset();
|
||||||
router.push("/");
|
router.push("/");
|
||||||
@@ -86,7 +118,7 @@ export function ChangePasswordFormConfirmation() {
|
|||||||
} else {
|
} else {
|
||||||
closeModal();
|
closeModal();
|
||||||
showPopup({
|
showPopup({
|
||||||
header: t`Error Changing Password`,
|
header: hasPassword ? t`Error Changing Password` : t`Error Setting Password`,
|
||||||
message: t`An unexpected error occurred. Please try again later.`,
|
message: t`An unexpected error occurred. Please try again later.`,
|
||||||
icon: "error",
|
icon: "error",
|
||||||
});
|
});
|
||||||
@@ -106,27 +138,33 @@ export function ChangePasswordFormConfirmation() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex w-full flex-col justify-between pb-4">
|
<div className="flex w-full flex-col justify-between pb-4">
|
||||||
<h2 className="text-md pb-4 font-medium dark:text-white">{t`Change Password`}</h2>
|
<h2 className="text-md pb-4 font-medium dark:text-white">
|
||||||
|
{hasPassword ? t`Change Password` : t`Set Password`}
|
||||||
|
</h2>
|
||||||
<p className="mb-4 text-sm text-light-900">
|
<p className="mb-4 text-sm text-light-900">
|
||||||
{t`Enter your current password and choose a new secure password.`}
|
{hasPassword
|
||||||
|
? t`Enter your current password and choose a new secure password.`
|
||||||
|
: t`You signed in without a password. Set a password to enable password-based login.`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div>
|
{hasPassword && (
|
||||||
<Input
|
<div>
|
||||||
id="currentPassword"
|
<Input
|
||||||
type="password"
|
id="currentPassword"
|
||||||
{...register("currentPassword")}
|
type="password"
|
||||||
placeholder={t`Enter your current password`}
|
{...register("currentPassword")}
|
||||||
/>
|
placeholder={t`Enter your current password`}
|
||||||
{errors.currentPassword && (
|
/>
|
||||||
<p className="mt-2 text-xs text-red-400">
|
{errors.currentPassword && (
|
||||||
{errors.currentPassword.message}
|
<p className="mt-2 text-xs text-red-400">
|
||||||
</p>
|
{errors.currentPassword.message}
|
||||||
)}
|
</p>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
@@ -175,7 +213,7 @@ export function ChangePasswordFormConfirmation() {
|
|||||||
fullWidth
|
fullWidth
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{t`Change Password`}
|
{hasPassword ? t`Change Password` : t`Set Password`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export const userRouter = createTRPCRouter({
|
|||||||
name: z.string().nullable(),
|
name: z.string().nullable(),
|
||||||
image: z.string().nullable(),
|
image: z.string().nullable(),
|
||||||
stripeCustomerId: z.string().nullable(),
|
stripeCustomerId: z.string().nullable(),
|
||||||
|
hasPassword: z.boolean(),
|
||||||
|
hasMagicLinkAccount: z.boolean(),
|
||||||
apiKey: z
|
apiKey: z
|
||||||
.object({
|
.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
@@ -61,6 +63,8 @@ export const userRouter = createTRPCRouter({
|
|||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
image: imageUrl,
|
image: imageUrl,
|
||||||
|
hasPassword: result.hasPassword,
|
||||||
|
hasMagicLinkAccount: result.hasMagicLinkAccount,
|
||||||
apiKey: apiKey ? { id: apiKey.id, prefix: apiKey.prefix } : null,
|
apiKey: apiKey ? { id: apiKey.id, prefix: apiKey.prefix } : null,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -114,4 +118,42 @@ export const userRouter = createTRPCRouter({
|
|||||||
image: imageUrl,
|
image: imageUrl,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
setPassword: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
newPassword: z
|
||||||
|
.string()
|
||||||
|
.min(8, "Password must be at least 8 characters"),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(z.object({ success: z.boolean() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const userId = ctx.user?.id;
|
||||||
|
|
||||||
|
if (!userId)
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `User not authenticated`,
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
});
|
||||||
|
|
||||||
|
const existing = await userRepo.getById(ctx.db, userId);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `User not found`,
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.hasPassword) {
|
||||||
|
throw new TRPCError({
|
||||||
|
message: `Password already set; use change password instead`,
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.auth.api.setPassword({ newPassword: input.newPassword });
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ const createAuthWithHeaders = (
|
|||||||
headers,
|
headers,
|
||||||
query: { referenceId: input.workspacePublicId },
|
query: { referenceId: input.workspacePublicId },
|
||||||
}),
|
}),
|
||||||
|
setPassword: (input: { newPassword: string }) =>
|
||||||
|
auth.api.setPassword({
|
||||||
|
headers,
|
||||||
|
body: { newPassword: input.newPassword },
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { count, desc, eq } from "drizzle-orm";
|
import { and, count, desc, eq, isNotNull } from "drizzle-orm";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
import type { dbClient } from "@kan/db/client";
|
import type { dbClient } from "@kan/db/client";
|
||||||
import { apikey, users } from "@kan/db/schema";
|
import { account, apikey, users } from "@kan/db/schema";
|
||||||
|
|
||||||
export const getCount = async (db: dbClient) => {
|
export const getCount = async (db: dbClient) => {
|
||||||
const result = await db.select({ count: count() }).from(users);
|
const result = await db.select({ count: count() }).from(users);
|
||||||
@@ -11,27 +11,58 @@ export const getCount = async (db: dbClient) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getById = async (db: dbClient, userId: string) => {
|
export const getById = async (db: dbClient, userId: string) => {
|
||||||
return await db.query.users.findFirst({
|
const [user, credentialAccount, magicLinkAccount] = await Promise.all([
|
||||||
columns: {
|
db.query.users.findFirst({
|
||||||
id: true,
|
columns: {
|
||||||
name: true,
|
id: true,
|
||||||
email: true,
|
name: true,
|
||||||
image: true,
|
email: true,
|
||||||
stripeCustomerId: true,
|
image: true,
|
||||||
},
|
stripeCustomerId: true,
|
||||||
with: {
|
|
||||||
apiKeys: {
|
|
||||||
columns: {
|
|
||||||
id: true,
|
|
||||||
prefix: true,
|
|
||||||
key: true,
|
|
||||||
},
|
|
||||||
orderBy: desc(apikey.createdAt),
|
|
||||||
limit: 1,
|
|
||||||
},
|
},
|
||||||
},
|
with: {
|
||||||
where: eq(users.id, userId),
|
apiKeys: {
|
||||||
});
|
columns: {
|
||||||
|
id: true,
|
||||||
|
prefix: true,
|
||||||
|
key: true,
|
||||||
|
},
|
||||||
|
orderBy: desc(apikey.createdAt),
|
||||||
|
limit: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
where: eq(users.id, userId),
|
||||||
|
}),
|
||||||
|
db
|
||||||
|
.select({ id: account.id })
|
||||||
|
.from(account)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(account.userId, userId),
|
||||||
|
eq(account.providerId, "credential"),
|
||||||
|
isNotNull(account.password),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1),
|
||||||
|
db
|
||||||
|
.select({ id: account.id })
|
||||||
|
.from(account)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(account.userId, userId),
|
||||||
|
eq(account.providerId, "magic-link"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!user) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
hasPassword: credentialAccount.length > 0,
|
||||||
|
hasMagicLinkAccount: magicLinkAccount.length > 0,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getByStripeCustomerId = async (
|
export const getByStripeCustomerId = async (
|
||||||
|
|||||||
Reference in New Issue
Block a user