From 71205b67dc6054cb207949ed88cd93dc60990cb7 Mon Sep 17 00:00:00 2001 From: Morfixx <63129378+nahSystemu@users.noreply.github.com> Date: Mon, 11 May 2026 05:40:59 -0700 Subject: [PATCH] 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 --- apps/web/src/components/Dashboard.tsx | 40 ++++++- .../src/views/settings/AccountSettings.tsx | 10 +- .../components/ChangePasswordConfirmation.tsx | 108 ++++++++++++------ packages/api/src/routers/user.ts | 42 +++++++ packages/api/src/trpc.ts | 5 + packages/db/src/repository/user.repo.ts | 75 ++++++++---- 6 files changed, 218 insertions(+), 62 deletions(-) diff --git a/apps/web/src/components/Dashboard.tsx b/apps/web/src/components/Dashboard.tsx index ecff8acb..c6f2eac2 100644 --- a/apps/web/src/components/Dashboard.tsx +++ b/apps/web/src/components/Dashboard.tsx @@ -8,6 +8,7 @@ import { TbLayoutSidebarRightCollapse, TbLayoutSidebarRightExpand, } from "react-icons/tb"; +import { t } from "@lingui/core/macro"; import { authClient } from "@kan/auth/client"; @@ -15,7 +16,10 @@ import { useClickOutside } from "~/hooks/useClickOutside"; import { useModal } from "~/providers/modal"; import { useWorkspace, WorkspaceProvider } from "~/providers/workspace"; import { api } from "~/utils/api"; +import Button from "./Button"; +import Modal from "./modal"; import SideNavigation from "./SideNavigation"; +import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation"; interface DashboardProps { children: React.ReactNode; @@ -43,7 +47,7 @@ export default function Dashboard({ hasRightPanel = false, }: DashboardProps) { const { resolvedTheme } = useTheme(); - const { openModal } = useModal(); + const { openModal, closeModal, modalContentType } = useModal(); const { availableWorkspaces, hasLoaded } = useWorkspace(); const router = useRouter(); @@ -109,6 +113,24 @@ export default function Dashboard({ } }, [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"; return ( @@ -203,6 +225,22 @@ export default function Dashboard({ + + + {user?.hasPassword ? ( +
+

{t`Password already set`}

+

+ {t`Your account already has a password. You can change it from your account settings.`} +

+ +
+ ) : ( + + )} +
); } diff --git a/apps/web/src/views/settings/AccountSettings.tsx b/apps/web/src/views/settings/AccountSettings.tsx index c91ebf21..1672775e 100644 --- a/apps/web/src/views/settings/AccountSettings.tsx +++ b/apps/web/src/views/settings/AccountSettings.tsx @@ -85,17 +85,19 @@ export default function AccountSettings() { {isCredentialsEnabled && (

- {t`Change Password`} + {data?.hasPassword ? t`Change Password` : t`Set Password`}

- {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.`}

@@ -113,7 +115,7 @@ export default function AccountSettings() { modalSize="sm" isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"} > - + {/* Global modals */} diff --git a/apps/web/src/views/settings/components/ChangePasswordConfirmation.tsx b/apps/web/src/views/settings/components/ChangePasswordConfirmation.tsx index dfe97999..a22c5df4 100644 --- a/apps/web/src/views/settings/components/ChangePasswordConfirmation.tsx +++ b/apps/web/src/views/settings/components/ChangePasswordConfirmation.tsx @@ -2,7 +2,6 @@ import { useRouter } from "next/navigation"; import { zodResolver } from "@hookform/resolvers/zod"; import { t } from "@lingui/core/macro"; import { useMutation } from "@tanstack/react-query"; -import { env } from "next-runtime-env"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -14,27 +13,43 @@ import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; -const FormSchema = z - .object({ - currentPassword: z.string().min(1, t`Current password is required`), +const buildSchema = (hasPassword: boolean) => { + const base = z.object({ + currentPassword: hasPassword + ? z.string().min(1, t`Current password is required`) + : z.string().optional(), newPassword: z .string() .min(8, t`Password must be at least 8 characters`) .min(1, t`New password is required`), 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; + 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 { showPopup } = usePopup(); const router = useRouter(); @@ -47,15 +62,24 @@ export function ChangePasswordFormConfirmation() { reset, setError, } = useForm({ - resolver: zodResolver(FormSchema), + resolver: zodResolver(buildSchema(hasPassword)), mode: "onChange", }); + const setPasswordMutation = api.user.setPassword.useMutation(); + const changePasswordMutation = useMutation({ mutationFn: async (data: FormValues) => { + if (!hasPassword) { + await setPasswordMutation.mutateAsync({ + newPassword: data.newPassword, + }); + return; + } + const response = await authClient.changePassword({ newPassword: data.newPassword, - currentPassword: data.currentPassword, + currentPassword: data.currentPassword ?? "", revokeOtherSessions: true, }); @@ -66,11 +90,19 @@ export function ChangePasswordFormConfirmation() { onSuccess: async () => { closeModal(); showPopup({ - header: t`Password Changed`, - message: t`Your password has been changed.`, + header: hasPassword ? t`Password Changed` : t`Password Set`, + message: hasPassword + ? t`Your password has been changed.` + : t`Your password has been set.`, 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(); reset(); router.push("/"); @@ -86,7 +118,7 @@ export function ChangePasswordFormConfirmation() { } else { closeModal(); 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.`, icon: "error", }); @@ -106,27 +138,33 @@ export function ChangePasswordFormConfirmation() { return (
-

{t`Change Password`}

+

+ {hasPassword ? t`Change Password` : t`Set Password`} +

- {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.`}

-
- - {errors.currentPassword && ( -

- {errors.currentPassword.message} -

- )} -
+ {hasPassword && ( +
+ + {errors.currentPassword && ( +

+ {errors.currentPassword.message} +

+ )} +
+ )}
- {t`Change Password`} + {hasPassword ? t`Change Password` : t`Set Password`}
diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts index 2b39675f..918231fb 100644 --- a/packages/api/src/routers/user.ts +++ b/packages/api/src/routers/user.ts @@ -27,6 +27,8 @@ export const userRouter = createTRPCRouter({ name: z.string().nullable(), image: z.string().nullable(), stripeCustomerId: z.string().nullable(), + hasPassword: z.boolean(), + hasMagicLinkAccount: z.boolean(), apiKey: z .object({ id: z.number(), @@ -61,6 +63,8 @@ export const userRouter = createTRPCRouter({ return { ...result, image: imageUrl, + hasPassword: result.hasPassword, + hasMagicLinkAccount: result.hasMagicLinkAccount, apiKey: apiKey ? { id: apiKey.id, prefix: apiKey.prefix } : null, }; }), @@ -114,4 +118,42 @@ export const userRouter = createTRPCRouter({ 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 }; + }), }); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 73bcbbf0..b08154dc 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -63,6 +63,11 @@ const createAuthWithHeaders = ( headers, query: { referenceId: input.workspacePublicId }, }), + setPassword: (input: { newPassword: string }) => + auth.api.setPassword({ + headers, + body: { newPassword: input.newPassword }, + }), }, }; }; diff --git a/packages/db/src/repository/user.repo.ts b/packages/db/src/repository/user.repo.ts index 44a6a309..d191848c 100644 --- a/packages/db/src/repository/user.repo.ts +++ b/packages/db/src/repository/user.repo.ts @@ -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 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) => { 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) => { - return await db.query.users.findFirst({ - columns: { - id: true, - name: true, - email: true, - image: true, - stripeCustomerId: true, - }, - with: { - apiKeys: { - columns: { - id: true, - prefix: true, - key: true, - }, - orderBy: desc(apikey.createdAt), - limit: 1, + const [user, credentialAccount, magicLinkAccount] = await Promise.all([ + db.query.users.findFirst({ + columns: { + id: true, + name: true, + email: true, + image: true, + stripeCustomerId: true, }, - }, - where: eq(users.id, userId), - }); + with: { + 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 (