This commit is contained in:
 Илья Шалонин 2025-10-29 13:43:43 +05:00
commit 8538ab969a
434 changed files with 44051 additions and 0 deletions

View file

@ -0,0 +1,109 @@
"use client";
import { authClient } from "@repo/auth/client";
import { useSession } from "@saas/auth/hooks/use-session";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useRouter } from "@shared/hooks/router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@ui/components/button";
import { Skeleton } from "@ui/components/skeleton";
import { ComputerIcon, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
export function ActiveSessionsBlock() {
const t = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
const { session: currentSession } = useSession();
const { data: sessions, isPending } = useQuery({
queryKey: ["active-sessions"],
queryFn: async () => {
const { data, error } = await authClient.listSessions();
if (error) {
throw error;
}
return data;
},
});
const revokeSession = (token: string) => {
authClient.revokeSession(
{
token,
},
{
onSuccess: () => {
toast.success(
t(
"settings.account.security.activeSessions.notifications.revokeSession.success",
),
);
queryClient.invalidateQueries({
queryKey: ["active-sessions"],
});
router.refresh();
},
},
);
};
return (
<SettingsItem
title={t("settings.account.security.activeSessions.title")}
description={t(
"settings.account.security.activeSessions.description",
)}
>
<div className="grid grid-cols-1 gap-2">
{isPending ? (
<>
<div className="flex gap-2">
<Skeleton className="size-6 shrink-0" />
<div className="flex-1">
<Skeleton className="mb-0.5 h-4 w-full" />
<Skeleton className="h-8 w-full" />
</div>
<Skeleton className="size-9 shrink-0" />
</div>
</>
) : (
sessions?.map((session) => (
<div
key={session.id}
className="flex justify-between gap-4"
>
<div className="flex gap-2">
<ComputerIcon className="size-6 shrink-0 text-primary/50" />
<div>
<strong className="block text-sm">
{session.id === currentSession?.id
? t(
"settings.account.security.activeSessions.currentSession",
)
: session.ipAddress}
</strong>
<small className="block text-foreground/60 text-xs leading-tight">
{session.userAgent}
</small>
</div>
</div>
<Button
variant="light"
size="icon"
className="shrink-0"
onClick={() => revokeSession(session.token)}
>
<XIcon className="size-4" />
</Button>
</div>
))
)}
</div>
</SettingsItem>
);
}

View file

@ -0,0 +1,76 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { authClient } from "@repo/auth/client";
import { useSession } from "@saas/auth/hooks/use-session";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { Button } from "@ui/components/button";
import { Input } from "@ui/components/input";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
email: z.string().email(),
});
type FormSchema = z.infer<typeof formSchema>;
export function ChangeEmailForm() {
const { user, reloadSession } = useSession();
const t = useTranslations();
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
email: user?.email ?? "",
},
});
const onSubmit = form.handleSubmit(async ({ email }) => {
const { error } = await authClient.changeEmail({
newEmail: email,
});
if (error) {
toast.error(t("settings.account.changeEmail.notifications.error"));
return;
}
toast.success(t("settings.account.changeEmail.notifications.success"));
reloadSession();
});
return (
<SettingsItem
title={t("settings.account.changeEmail.title")}
description={t("settings.account.changeEmail.description")}
>
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
>
<Input type="email" {...form.register("email")} />
<div className="mt-4 flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={
!(
form.formState.isValid &&
form.formState.dirtyFields.email
)
}
>
{t("settings.save")}
</Button>
</div>
</form>
</SettingsItem>
);
}

View file

@ -0,0 +1,72 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { authClient } from "@repo/auth/client";
import { useSession } from "@saas/auth/hooks/use-session";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { Button } from "@ui/components/button";
import { Input } from "@ui/components/input";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
name: z.string().min(3),
});
type FormSchema = z.infer<typeof formSchema>;
export function ChangeNameForm() {
const { user, reloadSession } = useSession();
const t = useTranslations();
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
name: user?.name ?? "",
},
});
const onSubmit = form.handleSubmit(async ({ name }) => {
const { error } = await authClient.updateUser({
name,
});
if (error) {
toast.error(t("settings.account.changeName.notifications.error"));
return;
}
toast.success(t("settings.account.changeName.notifications.success"));
reloadSession();
form.reset({
name,
});
});
return (
<SettingsItem title={t("settings.account.changeName.title")}>
<form onSubmit={onSubmit}>
<Input type="text" {...form.register("name")} />
<div className="mt-4 flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={
!(
form.formState.isValid &&
form.formState.dirtyFields.name
)
}
>
{t("settings.save")}
</Button>
</div>
</form>
</SettingsItem>
);
}

View file

@ -0,0 +1,130 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { authClient } from "@repo/auth/client";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useRouter } from "@shared/hooks/router";
import { Button } from "@ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@ui/components/form";
import { PasswordInput } from "@ui/components/password-input";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8),
});
export function ChangePasswordForm() {
const t = useTranslations();
const router = useRouter();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
currentPassword: "",
newPassword: "",
},
});
const onSubmit = form.handleSubmit(async (values) => {
const { error } = await authClient.changePassword({
...values,
revokeOtherSessions: true,
});
if (error) {
toast.error(
t(
"settings.account.security.changePassword.notifications.error",
),
);
return;
}
toast.success(
t("settings.account.security.changePassword.notifications.success"),
);
form.reset({});
router.refresh();
});
return (
<SettingsItem
title={t("settings.account.security.changePassword.title")}
>
<Form {...form}>
<form onSubmit={onSubmit}>
<div className="grid grid-cols-1 gap-4">
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"settings.account.security.changePassword.currentPassword",
)}
</FormLabel>
<FormControl>
<PasswordInput
autoComplete="current-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"settings.account.security.changePassword.newPassword",
)}
</FormLabel>
<FormControl>
<PasswordInput
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={
!(
form.formState.isValid &&
Object.keys(form.formState.dirtyFields)
.length
)
}
>
{t("settings.save")}
</Button>
</div>
</div>
</form>
</Form>
</SettingsItem>
);
}

View file

@ -0,0 +1,82 @@
"use client";
import { authClient } from "@repo/auth/client";
import {
type OAuthProvider,
oAuthProviders,
} from "@saas/auth/constants/oauth-providers";
import { useUserAccountsQuery } from "@saas/auth/lib/api";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { Button } from "@ui/components/button";
import { Skeleton } from "@ui/components/skeleton";
import { CheckCircle2Icon, LinkIcon } from "lucide-react";
import { useTranslations } from "next-intl";
export function ConnectedAccountsBlock() {
const t = useTranslations();
const { data, isPending } = useUserAccountsQuery();
const isProviderLinked = (provider: OAuthProvider) =>
data?.some((account) => account.provider === provider);
const linkProvider = (provider: OAuthProvider) => {
const callbackURL = window.location.href;
if (!isProviderLinked(provider)) {
authClient.linkSocial({
provider,
callbackURL,
});
}
};
return (
<SettingsItem
title={t("settings.account.security.connectedAccounts.title")}
>
<div className="grid grid-cols-1 divide-y">
{Object.entries(oAuthProviders).map(
([provider, providerData]) => {
const isLinked = isProviderLinked(
provider as OAuthProvider,
);
return (
<div
key={provider}
className="flex h-14 items-center justify-between gap-2 py-2"
>
<div className="flex items-center gap-2">
<providerData.icon className="size-4 text-primary/50" />
<span className="text-sm">
{providerData.name}
</span>
</div>
{isPending ? (
<Skeleton className="h-10 w-28" />
) : isLinked ? (
<CheckCircle2Icon className="size-6 text-success" />
) : (
<Button
variant={isLinked ? "outline" : "light"}
onClick={() =>
linkProvider(
provider as OAuthProvider,
)
}
>
<LinkIcon className="mr-1.5 size-4" />
<span>
{t(
"settings.account.security.connectedAccounts.connect",
)}
</span>
</Button>
)}
</div>
);
},
)}
</div>
</SettingsItem>
);
}

View file

@ -0,0 +1,79 @@
"use client";
import { Button } from "@ui/components/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@ui/components/dialog";
import { useMemo, useRef } from "react";
import type { ReactCropperElement } from "react-cropper";
import Cropper from "react-cropper";
export function CropImageDialog({
image,
open,
onOpenChange,
onCrop,
}: {
image: File | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onCrop: (croppedImage: Blob | null) => void;
}) {
const cropperRef = useRef<ReactCropperElement>(null);
const getCroppedImage = async () => {
const cropper = cropperRef.current?.cropper;
const imageBlob = await new Promise<Blob | null>((resolve) => {
cropper
?.getCroppedCanvas({
maxWidth: 256,
maxHeight: 256,
})
.toBlob(resolve);
});
return imageBlob;
};
const imageSrc = useMemo(
() => image && URL.createObjectURL(image),
[image],
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle />
</DialogHeader>
<div>
{imageSrc && (
<Cropper
src={imageSrc}
style={{ width: "100%" }}
initialAspectRatio={1}
aspectRatio={1}
guides={true}
ref={cropperRef}
/>
)}
</div>
<DialogFooter>
<Button
onClick={async () => {
onCrop(await getCroppedImage());
onOpenChange(false);
}}
>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,42 @@
"use client";
import { useCreateCustomerPortalLinkMutation } from "@saas/payments/lib/api";
import { Button } from "@ui/components/button";
import { CreditCardIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
export function CustomerPortalButton({ purchaseId }: { purchaseId: string }) {
const t = useTranslations();
const createCustomerPortalMutation = useCreateCustomerPortalLinkMutation();
const createCustomerPortal = async () => {
try {
const { customerPortalLink } =
await createCustomerPortalMutation.mutateAsync({
purchaseId,
redirectUrl: window.location.href,
});
window.location.href = customerPortalLink;
} catch {
toast.error(
t(
"settings.billing.createCustomerPortal.notifications.error.title",
),
);
}
};
return (
<Button
variant="light"
size="sm"
onClick={() => createCustomerPortal()}
loading={createCustomerPortalMutation.isPending}
>
<CreditCardIcon className="mr-2 size-4" />
{t("settings.billing.createCustomerPortal.label")}
</Button>
);
}

View file

@ -0,0 +1,64 @@
"use client";
import { authClient } from "@repo/auth/client";
import { useSession } from "@saas/auth/hooks/use-session";
import { useConfirmationAlert } from "@saas/shared/components/ConfirmationAlertProvider";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useMutation } from "@tanstack/react-query";
import {} from "@ui/components/alert-dialog";
import { Button } from "@ui/components/button";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
export function DeleteAccountForm() {
const t = useTranslations();
const { reloadSession } = useSession();
const { confirm } = useConfirmationAlert();
const deleteUserMutation = useMutation({
mutationFn: async () => {
const { error } = await authClient.deleteUser({});
if (error) {
throw error;
}
},
onSuccess: () => {
toast.success(
t("settings.account.deleteAccount.notifications.success"),
);
reloadSession();
},
onError: () => {
toast.error(
t("settings.account.deleteAccount.notifications.error"),
);
},
});
const confirmDelete = () => {
confirm({
title: t("settings.account.deleteAccount.title"),
message: t("settings.account.deleteAccount.confirmation"),
onConfirm: async () => {
await deleteUserMutation.mutateAsync();
},
});
};
return (
<>
<SettingsItem
danger
title={t("settings.account.deleteAccount.title")}
description={t("settings.account.deleteAccount.description")}
>
<div className="mt-4 flex justify-end">
<Button variant="error" onClick={() => confirmDelete()}>
{t("settings.account.deleteAccount.submit")}
</Button>
</div>
</SettingsItem>
</>
);
}

View file

@ -0,0 +1,114 @@
"use client";
import { authClient } from "@repo/auth/client";
import { useUserPasskeysQuery } from "@saas/auth/lib/api";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@ui/components/button";
import { Skeleton } from "@ui/components/skeleton";
import { KeyIcon, PlusIcon, TrashIcon } from "lucide-react";
import { useFormatter, useTranslations } from "next-intl";
import { toast } from "sonner";
export function PasskeysBlock() {
const t = useTranslations();
const queryClient = useQueryClient();
const formatter = useFormatter();
const { data: passkeys, isPending } = useUserPasskeysQuery();
const addPasskey = async () => {
await authClient.passkey.addPasskey({
fetchOptions: {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["passkeys"] });
toast.success(
t(
"settings.account.security.passkeys.notifications.addPasskey.success.title",
),
);
},
onError: () => {
toast.error(
t(
"settings.account.security.passkeys.notifications.addPasskey.error.title",
),
);
},
},
});
};
const deletePasskey = (id: string) => {
toast.promise(
async () => {
await authClient.passkey.deletePasskey({
id,
});
},
{
loading: t(
"settings.account.security.passkeys.notifications.deletePasskey.loading.title",
),
success: t(
"settings.account.security.passkeys.notifications.deletePasskey.success.title",
),
error: t(
"settings.account.security.passkeys.notifications.deletePasskey.error.title",
),
},
);
};
return (
<SettingsItem
title={t("settings.account.security.passkeys.title")}
description={t("settings.account.security.passkeys.description")}
>
<div className="grid grid-cols-1 gap-2">
{isPending ? (
<>
<div className="flex gap-2">
<Skeleton className="size-6 shrink-0" />
<div className="flex-1">
<Skeleton className="mb-0.5 h-4 w-full" />
<Skeleton className="h-8 w-full" />
</div>
<Skeleton className="size-9 shrink-0" />
</div>
</>
) : (
passkeys?.map((passkey) => (
<div key={passkey.id} className="flex gap-2">
<KeyIcon className="size-6 shrink-0 text-primary/50" />
<div className="flex-1">
<strong className="block text-sm">
{passkey.deviceType} {passkey.name}
</strong>
<small className="block text-foreground/60 text-xs leading-tight">
{formatter.dateTime(
new Date(passkey.createdAt),
)}
</small>
</div>
<Button
variant="light"
size="icon"
className="shrink-0"
onClick={() => deletePasskey(passkey.id)}
>
<TrashIcon className="size-4" />
</Button>
</div>
))
)}
<div className="flex justify-start">
<Button variant="light" onClick={addPasskey}>
<PlusIcon className="mr-1.5 size-4" />
Add passkey
</Button>
</div>
</div>
</SettingsItem>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import { authClient } from "@repo/auth/client";
import { useSession } from "@saas/auth/hooks/use-session";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { Button } from "@ui/components/button";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { toast } from "sonner";
export function SetPasswordForm() {
const t = useTranslations();
const { user } = useSession();
const [submitting, setSubmitting] = useState(false);
const onSubmit = async () => {
if (!user) {
return;
}
setSubmitting(true);
await authClient.forgetPassword(
{
email: user.email,
redirectTo: `${window.location.origin}/auth/reset-password`,
},
{
onSuccess: () => {
toast.success(
t(
"settings.account.security.setPassword.notifications.success",
),
);
},
onError: () => {
toast.error(
t(
"settings.account.security.setPassword.notifications.error",
),
);
},
onResponse: () => {
setSubmitting(false);
},
},
);
};
return (
<SettingsItem
title={t("settings.account.security.setPassword.title")}
description={t("settings.account.security.setPassword.description")}
>
<div className="flex justify-end">
<Button type="submit" loading={submitting} onClick={onSubmit}>
{t("settings.account.security.setPassword.submit")}
</Button>
</div>
</SettingsItem>
);
}

View file

@ -0,0 +1,61 @@
"use client";
import { cn } from "@ui/lib";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
export function SettingsMenu({
menuItems,
}: {
menuItems: {
title: string;
avatar: ReactNode;
items: {
title: string;
href: string;
icon?: ReactNode;
}[];
}[];
}) {
const pathname = usePathname();
const isActiveMenuItem = (href: string) => pathname.includes(href);
return (
<div className="space-y-8">
{menuItems.map((item, i) => (
<div key={i}>
<div className="flex items-center justify-start gap-2">
{item.avatar}
<h2 className="font-semibold text-foreground/60 text-xs">
{item.title}
</h2>
</div>
<ul className="mt-2 flex list-none flex-row gap-6 lg:mt-4 lg:flex-col lg:gap-2">
{item.items.map((subitem, k) => (
<li key={k}>
<Link
href={subitem.href}
className={cn(
"lg:-ml-0.5 flex items-center gap-2 border-b-2 py-1.5 text-sm lg:border-b-0 lg:border-l-2 lg:pl-2",
isActiveMenuItem(subitem.href)
? "border-primary font-bold"
: "border-transparent",
)}
data-active={isActiveMenuItem(subitem.href)}
>
<span className="shrink-0">
{subitem.icon}
</span>
<span>{subitem.title}</span>
</Link>
</li>
))}
</ul>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,38 @@
"use client";
import type { BadgeProps } from "@ui/components/badge";
import { Badge } from "@ui/components/badge";
import { useTranslations } from "next-intl";
export function SubscriptionStatusBadge({
status,
}: {
status: string;
className?: string;
}) {
const t = useTranslations();
const badgeLabels: Record<string, string> = {
active: t("settings.billing.activePlan.status.active"),
canceled: t("settings.billing.activePlan.status.canceled"),
expired: t("settings.billing.activePlan.status.expired"),
incomplete: t("settings.billing.activePlan.status.incomplete"),
past_due: t("settings.billing.activePlan.status.past_due"),
paused: t("settings.billing.activePlan.status.paused"),
trialing: t("settings.billing.activePlan.status.trialing"),
unpaid: t("settings.billing.activePlan.status.unpaid"),
};
const badgeColors: Record<string, BadgeProps["status"]> = {
active: "success",
canceled: "error",
expired: "error",
incomplete: "warning",
past_due: "warning",
paused: "warning",
trialing: "info",
unpaid: "error",
};
return <Badge status={badgeColors[status]}>{badgeLabels[status]}</Badge>;
}

View file

@ -0,0 +1,290 @@
"use client";
import { authClient } from "@repo/auth/client";
import { useSession } from "@saas/auth/hooks/use-session";
import { useUserAccountsQuery } from "@saas/auth/lib/api";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useMutation } from "@tanstack/react-query";
import { Button } from "@ui/components/button";
import { Card } from "@ui/components/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@ui/components/dialog";
import { FormItem } from "@ui/components/form";
import { Input } from "@ui/components/input";
import { Label } from "@ui/components/label";
import { PasswordInput } from "@ui/components/password-input";
import {
ArrowRightIcon,
CheckIcon,
ShieldCheckIcon,
TabletSmartphoneIcon,
XIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
import QRCode from "react-qr-code";
import { toast } from "sonner";
export function TwoFactorBlock() {
const t = useTranslations();
const { user, reloadSession } = useSession();
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogView, setDialogView] = useState<"password" | "totp-url">(
"password",
);
const [totpURI, setTotpURI] = useState("");
const [password, setPassword] = useState("");
const [totpCode, setTotpCode] = useState("");
const { data: accounts } = useUserAccountsQuery();
useEffect(() => {
setPassword("");
}, [dialogOpen]);
const totpURISecret = useMemo(() => {
if (!totpURI) {
return null;
}
const url = new URL(totpURI);
return url.searchParams.get("secret") || null;
}, [totpURI]);
const verifyPassword = async () => {
setDialogView("password");
setDialogOpen(true);
};
const enableTwoFactorMutation = useMutation({
mutationKey: ["enableTwoFactor"],
mutationFn: async () => {
const { data, error } = await authClient.twoFactor.enable({
password,
});
if (error) {
throw error;
}
setTotpURI(data.totpURI);
setDialogView("totp-url");
},
onError: () => {
toast.error(
t(
"settings.account.security.twoFactor.notifications.enable.error.title",
),
);
},
});
const disableTwoFactorMutation = useMutation({
mutationKey: ["disableTwoFactor"],
mutationFn: async () => {
const { data, error } = await authClient.twoFactor.disable({
password,
});
if (error) {
throw error;
}
setDialogOpen(false);
toast.success(
t(
"settings.account.security.twoFactor.notifications.disable.success.title",
),
);
reloadSession();
},
onError: () => {
toast.error(
t(
"settings.account.security.twoFactor.notifications.enable.error.title",
),
);
},
});
const verifyTwoFactorMutation = useMutation({
mutationKey: ["verifyTwoFactor"],
mutationFn: async () => {
const { error } = await authClient.twoFactor.verifyTotp({
code: totpCode,
});
if (error) {
throw error;
}
toast.success(
t(
"settings.account.security.twoFactor.notifications.verify.success.title",
),
);
reloadSession();
setDialogOpen(false);
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (user?.twoFactorEnabled) {
disableTwoFactorMutation.mutate();
return;
}
if (dialogView === "password") {
enableTwoFactorMutation.mutate();
return;
}
verifyTwoFactorMutation.mutate();
};
if (!accounts?.some((account) => account.provider === "credential")) {
return null;
}
return (
<SettingsItem
title={t("settings.account.security.twoFactor.title")}
description={t("settings.account.security.twoFactor.description")}
>
{user?.twoFactorEnabled ? (
<div className="flex items-start flex-col gap-4">
<div className="flex items-center gap-1.5">
<ShieldCheckIcon className="size-6 text-green-500" />
<p className="text-sm text-foreground">
{t("settings.account.security.twoFactor.enabled")}
</p>
</div>
<Button variant="light" onClick={verifyPassword}>
<XIcon className="mr-1.5 size-4" />
{t("settings.account.security.twoFactor.disable")}
</Button>
</div>
) : (
<div className="flex justify-start">
<Button variant="light" onClick={verifyPassword}>
<TabletSmartphoneIcon className="mr-1.5 size-4" />
{t("settings.account.security.twoFactor.enable")}
</Button>
</div>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{dialogView === "password"
? t(
"settings.account.security.twoFactor.dialog.password.title",
)
: t(
"settings.account.security.twoFactor.dialog.totpUrl.title",
)}
</DialogTitle>
</DialogHeader>
{dialogView === "password" ? (
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-4">
<p className="text-sm text-foreground/60">
{t(
"settings.account.security.twoFactor.dialog.password.description",
)}
</p>
<FormItem>
<Label className="block">
{t(
"settings.account.security.twoFactor.dialog.password.label",
)}
</Label>
<PasswordInput
value={password}
onChange={(value) => setPassword(value)}
/>
</FormItem>
</div>
<div className="mt-4">
<Button
type="submit"
variant="secondary"
className="w-full"
loading={
enableTwoFactorMutation.isPending ||
disableTwoFactorMutation.isPending
}
>
{t("common.actions.continue")}
<ArrowRightIcon className="ml-1.5 size-4" />
</Button>
</div>
</form>
) : (
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-4">
<p className="text-sm text-foreground/60">
{t(
"settings.account.security.twoFactor.dialog.totpUrl.description",
)}
</p>
<Card className="flex flex-col items-center gap-4 p-6">
<QRCode value={totpURI} />
{totpURISecret && (
<p className="text-xs text-muted-foreground text-center">
{totpURISecret}
</p>
)}
</Card>
<hr />
<div className="grid grid-cols-1 gap-4">
<FormItem>
<Label className="block">
{t(
"settings.account.security.twoFactor.dialog.totpUrl.code",
)}
</Label>
<Input
value={totpCode}
onChange={(e) =>
setTotpCode(e.target.value)
}
/>
</FormItem>
</div>
</div>
<div className="mt-4">
<Button
type="submit"
variant="secondary"
className="w-full"
loading={verifyTwoFactorMutation.isPending}
>
<CheckIcon className="mr-1.5 size-4" />
{t("common.actions.verify")}
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
</SettingsItem>
);
}

View file

@ -0,0 +1,29 @@
"use client";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { UserAvatarUpload } from "./UserAvatarUpload";
export function UserAvatarForm() {
const t = useTranslations();
return (
<SettingsItem
title={t("settings.account.avatar.title")}
description={t("settings.account.avatar.description")}
>
<UserAvatarUpload
onSuccess={() => {
toast.success(
t("settings.account.avatar.notifications.success"),
);
}}
onError={() => {
toast.error(
t("settings.account.avatar.notifications.error"),
);
}}
/>
</SettingsItem>
);
}

View file

@ -0,0 +1,111 @@
"use client";
import { authClient } from "@repo/auth/client";
import { config } from "@repo/config";
import { useSession } from "@saas/auth/hooks/use-session";
import { useSignedUploadUrlMutation } from "@saas/shared/lib/api";
import { Spinner } from "@shared/components/Spinner";
import { UserAvatar } from "@shared/components/UserAvatar";
import { useState } from "react";
import { useDropzone } from "react-dropzone";
import { v4 as uuid } from "uuid";
import { CropImageDialog } from "./CropImageDialog";
export function UserAvatarUpload({
onSuccess,
onError,
}: {
onSuccess: () => void;
onError: () => void;
}) {
const { user, reloadSession } = useSession();
const [uploading, setUploading] = useState(false);
const [cropDialogOpen, setCropDialogOpen] = useState(false);
const [image, setImage] = useState<File | null>(null);
const getSignedUploadUrlMutation = useSignedUploadUrlMutation();
const { getRootProps, getInputProps } = useDropzone({
onDrop: (acceptedFiles) => {
setImage(acceptedFiles[0]);
setCropDialogOpen(true);
},
accept: {
"image/png": [".png"],
"image/jpeg": [".jpg", ".jpeg"],
},
multiple: false,
});
if (!user) {
return null;
}
const onCrop = async (croppedImageData: Blob | null) => {
if (!croppedImageData) {
return;
}
setUploading(true);
try {
const path = `${user.id}-${uuid()}.png`;
const { signedUrl } = await getSignedUploadUrlMutation.mutateAsync({
path,
bucket: config.storage.bucketNames.avatars,
});
const response = await fetch(signedUrl, {
method: "PUT",
body: croppedImageData,
headers: {
"Content-Type": "image/png",
},
});
if (!response.ok) {
throw new Error("Failed to upload image");
}
const { error } = await authClient.updateUser({
image: path,
});
if (error) {
throw error;
}
await reloadSession();
onSuccess();
} catch (e) {
onError();
} finally {
setUploading(false);
}
};
return (
<>
<div className="relative size-24 rounded-full" {...getRootProps()}>
<input {...getInputProps()} />
<UserAvatar
className="size-24 cursor-pointer text-xl"
avatarUrl={user.image}
name={user.name ?? ""}
/>
{uploading && (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-card/90">
<Spinner className="size-6" />
</div>
)}
</div>
<CropImageDialog
image={image}
open={cropDialogOpen}
onOpenChange={setCropDialogOpen}
onCrop={onCrop}
/>
</>
);
}

View file

@ -0,0 +1,81 @@
"use client";
import { updateLocale } from "@i18n/lib/update-locale";
import { authClient } from "@repo/auth/client";
import { config } from "@repo/config";
import type { Locale } from "@repo/i18n";
import { SettingsItem } from "@saas/shared/components/SettingsItem";
import { useMutation } from "@tanstack/react-query";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/components/select";
import { useLocale, useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
const { locales } = config.i18n;
export function UserLanguageForm() {
const currentLocale = useLocale();
const t = useTranslations();
const router = useRouter();
const [locale, setLocale] = useState<Locale | undefined>(
currentLocale as Locale,
);
const updateLocaleMutation = useMutation({
mutationFn: async () => {
if (!locale) {
return;
}
await authClient.updateUser({
locale,
});
await updateLocale(locale);
router.refresh();
},
});
const saveLocale = async () => {
try {
await updateLocaleMutation.mutateAsync();
toast.success(t("settings.account.language.notifications.success"));
} catch {
toast.error(t("settings.account.language.notifications.error"));
}
};
return (
<SettingsItem
title={t("settings.account.language.title")}
description={t("settings.account.language.description")}
>
<Select
value={locale}
onValueChange={(value) => {
setLocale(value as Locale);
saveLocale();
}}
disabled={updateLocaleMutation.isPending}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(locales).map(([key, value]) => (
<SelectItem key={key} value={key}>
{value.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingsItem>
);
}