first
This commit is contained in:
commit
8538ab969a
434 changed files with 44051 additions and 0 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
72
apps/web/modules/saas/settings/components/ChangeNameForm.tsx
Normal file
72
apps/web/modules/saas/settings/components/ChangeNameForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
apps/web/modules/saas/settings/components/ChangePassword.tsx
Normal file
130
apps/web/modules/saas/settings/components/ChangePassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
apps/web/modules/saas/settings/components/PasskeysBlock.tsx
Normal file
114
apps/web/modules/saas/settings/components/PasskeysBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/web/modules/saas/settings/components/SetPassword.tsx
Normal file
61
apps/web/modules/saas/settings/components/SetPassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
apps/web/modules/saas/settings/components/SettingsMenu.tsx
Normal file
61
apps/web/modules/saas/settings/components/SettingsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
290
apps/web/modules/saas/settings/components/TwoFactorBlock.tsx
Normal file
290
apps/web/modules/saas/settings/components/TwoFactorBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
apps/web/modules/saas/settings/components/UserAvatarForm.tsx
Normal file
29
apps/web/modules/saas/settings/components/UserAvatarForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
apps/web/modules/saas/settings/components/UserAvatarUpload.tsx
Normal file
111
apps/web/modules/saas/settings/components/UserAvatarUpload.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue