add member onboarding

This commit is contained in:
daniel31x13 2024-10-26 09:42:21 -04:00
parent cffc74caa4
commit d3d2d5069e
13 changed files with 233 additions and 16 deletions

View File

@ -50,7 +50,7 @@ export default function InviteModal({ onClose }: Props) {
onSettled: () => {
signIn("invite", {
email: form.email,
callbackUrl: "/",
callbackUrl: "/member-onboarding",
redirect: false,
});
},

View File

@ -23,7 +23,10 @@ export default function AuthRedirect({ children }: Props) {
const isUnauthenticated = status === "unauthenticated";
const isPublicPage = router.pathname.startsWith("/public");
const hasInactiveSubscription =
user.id && !user.subscription?.active && stripeEnabled;
user.id &&
!user.subscription?.active &&
!user.parentSubscription?.active &&
stripeEnabled;
// There are better ways of doing this... but this one works for now
const routes = [
@ -50,6 +53,8 @@ export default function AuthRedirect({ children }: Props) {
} else {
if (isLoggedIn && hasInactiveSubscription) {
redirectTo("/subscribe");
} else if (isLoggedIn && !user.name && user.parentSubscriptionId) {
redirectTo("/member-onboarding");
} else if (
isLoggedIn &&
!routes.some((e) => router.pathname.startsWith(e.path) && e.isProtected)

View File

@ -12,6 +12,11 @@ export default async function getUserById(userId: number) {
},
},
subscriptions: true,
parentSubscription: {
include: {
user: true,
},
},
},
});
@ -22,7 +27,8 @@ export default async function getUserById(userId: number) {
(usernames) => usernames.username
);
const { password, subscriptions, ...lessSensitiveInfo } = user;
const { password, subscriptions, parentSubscription, ...lessSensitiveInfo } =
user;
const data = {
...lessSensitiveInfo,
@ -30,6 +36,12 @@ export default async function getUserById(userId: number) {
subscription: {
active: subscriptions?.active,
},
parentSubscription: {
active: parentSubscription?.active,
user: {
email: parentSubscription?.user.email,
},
},
};
return { response: data, status: 200 };

View File

@ -101,7 +101,6 @@ export default async function updateUserById(
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true, password: true, name: true },
});
if (user && user.email && data.email && data.email !== user.email) {
@ -170,8 +169,20 @@ export default async function updateUserById(
// Other settings / Apply changes
const isInvited =
user?.name === null && user.parentSubscriptionId && !user.password;
if (isInvited && data.password === "")
return {
response: "Password is required.",
status: 400,
};
const saltRounds = 10;
const newHashedPassword = bcrypt.hashSync(data.newPassword || "", saltRounds);
const newHashedPassword = bcrypt.hashSync(
data.newPassword || data.password || "",
saltRounds
);
const updatedUser = await prisma.user.update({
where: {
@ -198,18 +209,28 @@ export default async function updateUserById(
linksRouteTo: data.linksRouteTo,
preventDuplicateLinks: data.preventDuplicateLinks,
password:
data.newPassword && data.newPassword !== ""
isInvited || (data.newPassword && data.newPassword !== "")
? newHashedPassword
: undefined,
},
include: {
whitelistedUsers: true,
subscriptions: true,
parentSubscription: {
include: {
user: true,
},
},
},
});
const { whitelistedUsers, password, subscriptions, ...userInfo } =
updatedUser;
const {
whitelistedUsers,
password,
subscriptions,
parentSubscription,
...userInfo
} = updatedUser;
// If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed
const newWhitelistedUsernames: string[] = data.whitelistedUsers || [];
@ -250,11 +271,19 @@ export default async function updateUserById(
});
}
const response: Omit<AccountSettings, "password"> = {
const response = {
...userInfo,
whitelistedUsers: newWhitelistedUsernames,
image: userInfo.image ? `${userInfo.image}?${Date.now()}` : "",
subscription: { active: subscriptions?.active },
subscription: {
active: subscriptions?.active,
},
parentSubscription: {
active: parentSubscription?.active,
user: {
email: parentSubscription?.user.email,
},
},
};
return { response, status: 200 };

View File

@ -17,6 +17,7 @@ export default async function paymentCheckout(
},
include: {
subscriptions: true,
parentSubscription: true,
},
});

View File

@ -4,17 +4,22 @@ import checkSubscriptionByEmail from "./checkSubscriptionByEmail";
interface UserIncludingSubscription extends User {
subscriptions: Subscription | null;
parentSubscription: Subscription | null;
}
export default async function verifySubscription(
user?: UserIncludingSubscription | null
) {
if (!user || !user.subscriptions) {
if (!user || (!user.subscriptions && !user.parentSubscription)) {
return null;
}
if (user.parentSubscription?.active) {
return user;
}
if (
!user.subscriptions.active ||
!user.subscriptions?.active ||
new Date() > user.subscriptions.currentPeriodEnd
) {
const subscription = await checkSubscriptionByEmail(user.email as string);

View File

@ -33,6 +33,7 @@ export default async function verifyByCredentials({
},
include: {
subscriptions: true,
parentSubscription: true,
},
});

View File

@ -30,6 +30,7 @@ export default async function verifyUser({
},
include: {
subscriptions: true,
parentSubscription: true,
},
});

View File

@ -1393,6 +1393,7 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
},
include: {
subscriptions: true,
parentSubscription: true,
},
});

View File

@ -41,6 +41,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
},
include: {
subscriptions: true,
parentSubscription: true,
},
});

158
pages/member-onboarding.tsx Normal file
View File

@ -0,0 +1,158 @@
import Button from "@/components/ui/Button";
import TextInput from "@/components/TextInput";
import CenteredForm from "@/layouts/CenteredForm";
import Link from "next/link";
import { useRouter } from "next/router";
import { FormEvent, useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import getServerSideProps from "@/lib/client/getServerSideProps";
import { Trans, useTranslation } from "next-i18next";
import { useUpdateUser, useUser } from "@/hooks/store/user";
import { useSession } from "next-auth/react";
interface FormData {
password: string;
name: string;
}
export default function MemberOnboarding() {
const { t } = useTranslation();
const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
const [form, setForm] = useState<FormData>({
password: "",
name: "",
});
const { data: user = {} } = useUser();
const updateUser = useUpdateUser();
const { status } = useSession();
useEffect(() => {
toast.success(t("accepted_invitation_please_fill"));
}, []);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (form.password !== "" && form.name !== "" && !submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("sending_password_recovery_link"));
await updateUser.mutateAsync(
{
...user,
name: form.name,
password: form.password,
},
{
onSuccess: (data) => {
router.push("/dashboard");
},
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(t("settings_applied"));
}
},
}
);
setSubmitLoader(false);
} else {
toast.error(t("please_fill_all_fields"));
}
}
return (
<CenteredForm>
<form onSubmit={submit}>
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
<p className="text-3xl text-center font-extralight">
{t("finalize_profile")}
</p>
<div className="divider my-0"></div>
<p
style={{
whiteSpace: "pre-line",
}}
>
{t("invitation_desc", {
owner: user?.parentSubscription?.user?.email,
})}
</p>
<div>
<p className="text-sm w-fit font-semibold mb-1">
{t("display_name")}
</p>
<TextInput
autoFocus
placeholder="John Doe"
value={form.name}
className="bg-base-100"
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
</div>
<div>
<p className="text-sm w-fit font-semibold mb-1">
{t("new_password")}
</p>
<TextInput
type="password"
placeholder="••••••••••••••"
value={form.password}
className="bg-base-100"
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</div>
{process.env.NEXT_PUBLIC_STRIPE && (
<div className="text-xs text-neutral mb-3">
<p>
<Trans
i18nKey="sign_up_agreement"
components={[
<Link
href="https://linkwarden.app/tos"
className="font-semibold"
data-testid="terms-of-service-link"
key={0}
/>,
<Link
href="https://linkwarden.app/privacy-policy"
className="font-semibold"
data-testid="privacy-policy-link"
key={1}
/>,
]}
/>
</p>
</div>
)}
<Button
type="submit"
intent="accent"
className="mt-2"
size="full"
loading={submitLoader}
>
{t("sign_up")}
</Button>
</div>
</form>
</CenteredForm>
);
}
export { getServerSideProps };

View File

@ -23,13 +23,13 @@ export default function Subscribe() {
const { data: user = {} } = useUser();
useEffect(() => {
console.log("user", user);
if (
session.status === "authenticated" &&
user.id &&
user?.subscription?.active
) {
(user?.subscription?.active || user.parentSubscription?.active)
)
router.push("/dashboard");
}
}, [session.status, user]);
async function submit() {

View File

@ -403,5 +403,8 @@
"invite_user_desc": "To invite someone to your team, please enter their email address below:",
"invite_user_note": "Please note that once the invitation is accepted, an additional seat will be used and your account will automatically be billed for this addition.",
"send_invitation": "Send Invitation",
"learn_more": "Learn more"
"learn_more": "Learn more",
"finalize_profile": "Finalize Your Profile",
"invitation_desc": "{{owner}} invited you to join Linkwarden. \nTo continue, please finish setting up your account.",
"accepted_invitation_please_fill": "You've accepted the invitation to join Linkwarden. Please fill out the following to finalize your account."
}