feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
33
apps/web/src/modules/user/lib/api.ts
Normal file
33
apps/web/src/modules/user/lib/api.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
const KEY = "user";
|
||||
|
||||
const queries = {
|
||||
invitations: {
|
||||
getAll: {
|
||||
queryKey: [KEY, "invitations"],
|
||||
queryFn: () =>
|
||||
authClient.organization.listUserInvitations({
|
||||
fetchOptions: { throw: true },
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
delete: {
|
||||
mutationKey: [KEY, "delete"],
|
||||
mutationFn: (params: Parameters<typeof authClient.deleteUser>[0]) =>
|
||||
authClient.deleteUser(params),
|
||||
},
|
||||
update: {
|
||||
mutationKey: [KEY, "update"],
|
||||
mutationFn: (params: Parameters<typeof authClient.updateUser>[0]) =>
|
||||
authClient.updateUser(params),
|
||||
},
|
||||
};
|
||||
|
||||
export const user = {
|
||||
queries,
|
||||
mutations,
|
||||
};
|
||||
72
apps/web/src/modules/user/settings/billing/manage-plan.tsx
Normal file
72
apps/web/src/modules/user/settings/billing/manage-plan.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { config, PricingPlanType } from "@turbostarter/billing";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { useCustomer } from "~/modules/billing/hooks/use-customer";
|
||||
import { billing } from "~/modules/billing/lib/api";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardContent,
|
||||
SettingsCardDescription,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
|
||||
export const ManagePlan = () => {
|
||||
const { t } = useTranslation("billing");
|
||||
const router = useRouter();
|
||||
const { data: customer } = useCustomer();
|
||||
|
||||
const getPortal = useMutation({
|
||||
...billing.mutations.portal.get,
|
||||
onSuccess: ({ url }) => {
|
||||
router.push(url);
|
||||
},
|
||||
});
|
||||
|
||||
const plan = config.plans.find(
|
||||
(plan) => plan.id === (customer?.plan ?? PricingPlanType.FREE),
|
||||
);
|
||||
|
||||
if (!plan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader>
|
||||
<SettingsCardTitle>{t("manage.billing.title")}</SettingsCardTitle>
|
||||
<SettingsCardDescription>
|
||||
{t("manage.billing.description")}
|
||||
</SettingsCardDescription>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<SettingsCardContent>
|
||||
<Button
|
||||
className="w-fit gap-1"
|
||||
disabled={getPortal.isPending}
|
||||
onClick={() =>
|
||||
getPortal.mutate({
|
||||
query: {
|
||||
redirectUrl: window.location.href,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("manage.billing.visitPortal")}
|
||||
{getPortal.isPending ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icons.ArrowUpRight className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</SettingsCardContent>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
119
apps/web/src/modules/user/settings/billing/plan-summary.tsx
Normal file
119
apps/web/src/modules/user/settings/billing/plan-summary.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import {
|
||||
ACTIVE_BILLING_STATUSES,
|
||||
BillingStatus,
|
||||
config,
|
||||
PricingPlanType,
|
||||
} from "@turbostarter/billing";
|
||||
import { isKey, useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { useCustomer } from "~/modules/billing/hooks/use-customer";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardContent,
|
||||
SettingsCardDescription,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
export const PlanSummary = () => {
|
||||
const { t, i18n } = useTranslation(["common", "billing"]);
|
||||
const { data: customer } = useCustomer();
|
||||
|
||||
const plan = config.plans.find(
|
||||
(plan) => plan.id === (customer?.plan ?? PricingPlanType.FREE),
|
||||
);
|
||||
|
||||
if (!plan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isHigherPlanAvailable =
|
||||
config.plans.findIndex((p) => p.id === plan.id) <
|
||||
config.plans.filter((p) => p.prices.some((price) => "amount" in price))
|
||||
.length -
|
||||
1;
|
||||
|
||||
const status = customer?.status ?? BillingStatus.ACTIVE;
|
||||
|
||||
const statusKey = `status.${status.toLowerCase().replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}`;
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader>
|
||||
<SettingsCardTitle>{t("manage.plan.title")}</SettingsCardTitle>
|
||||
<SettingsCardDescription>
|
||||
{t("manage.plan.description")}
|
||||
</SettingsCardDescription>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<SettingsCardContent className="-mt-2 border-t">
|
||||
<div className="flex flex-col gap-2 rounded-md pt-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{ACTIVE_BILLING_STATUSES.includes(status) ? (
|
||||
<Icons.BadgeCheck className="size-5" />
|
||||
) : (
|
||||
<Icons.BadgeX className="size-5" />
|
||||
)}
|
||||
<span className="text-lg font-bold capitalize">
|
||||
{isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name}
|
||||
</span>
|
||||
<Badge
|
||||
className={cn(
|
||||
"bg-destructive/15 text-destructive hover:bg-destructive/25 ml-1 capitalize",
|
||||
{
|
||||
"bg-success/15 text-success hover:bg-success/25":
|
||||
status === BillingStatus.ACTIVE,
|
||||
"bg-muted text-muted-foreground hover:bg-muted/25":
|
||||
status === BillingStatus.TRIALING,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{isKey(statusKey, i18n, "billing") ? t(statusKey) : statusKey}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("updatedAt")}{" "}
|
||||
{dayjs(customer?.updatedAt)
|
||||
.toDate()
|
||||
.toLocaleDateString(i18n.language)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isKey(plan.description, i18n, "billing")
|
||||
? t(plan.description)
|
||||
: plan.description}{" "}
|
||||
<TurboLink
|
||||
href={pathsConfig.marketing.pricing}
|
||||
className="hover:text-primary font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("learnMore")}
|
||||
</TurboLink>
|
||||
</p>
|
||||
|
||||
{isHigherPlanAvailable && (
|
||||
<TurboLink
|
||||
href={pathsConfig.marketing.pricing}
|
||||
className={cn(buttonVariants(), "mt-2 w-fit gap-1")}
|
||||
>
|
||||
{t("upgrade")}
|
||||
<Icons.ArrowUpRight className="size-4" />
|
||||
</TurboLink>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCardContent>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalTrigger,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
SettingsCardDescription,
|
||||
SettingsCardFooter,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
|
||||
import { user } from "../../lib/api";
|
||||
|
||||
export const DeleteAccount = () => {
|
||||
const { t } = useTranslation("auth");
|
||||
|
||||
return (
|
||||
<SettingsCard variant="destructive">
|
||||
<SettingsCardHeader>
|
||||
<SettingsCardTitle>{t("account.delete.title")}</SettingsCardTitle>
|
||||
<SettingsCardDescription>
|
||||
{t("account.delete.description")}
|
||||
</SettingsCardDescription>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<SettingsCardFooter>
|
||||
<ConfirmModal>
|
||||
<Button size="sm" className="ml-auto" variant="destructive">
|
||||
{t("account.delete.cta")}
|
||||
</Button>
|
||||
</ConfirmModal>
|
||||
</SettingsCardFooter>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmModal = ({ children }: { children: React.ReactNode }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const deleteAccount = useMutation({
|
||||
...user.mutations.delete,
|
||||
onSuccess: () => {
|
||||
toast.success(t("account.delete.confirmation.success"));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>{t("account.delete.title")}</ModalTitle>
|
||||
<ModalDescription className="whitespace-pre-line">
|
||||
{t("account.delete.disclaimer")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
onClick={() =>
|
||||
deleteAccount.mutate({
|
||||
callbackURL: pathsConfig.index,
|
||||
})
|
||||
}
|
||||
variant="destructive"
|
||||
disabled={deleteAccount.isPending}
|
||||
>
|
||||
{deleteAccount.isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("account.delete.confirmation.cta")
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
79
apps/web/src/modules/user/settings/general/edit-avatar.tsx
Normal file
79
apps/web/src/modules/user/settings/general/edit-avatar.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
import {
|
||||
AvatarForm,
|
||||
AvatarFormErrorMessage,
|
||||
AvatarFormInput,
|
||||
AvatarFormPreview,
|
||||
AvatarFormRemoveButton,
|
||||
} from "~/modules/common/avatar-form";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardDescription,
|
||||
SettingsCardFooter,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
|
||||
import { user } from "../../lib/api";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
|
||||
interface EditAvatarProps {
|
||||
readonly user: User;
|
||||
}
|
||||
|
||||
export const EditAvatar = memo<EditAvatarProps>((props) => {
|
||||
const { t } = useTranslation(["common", "validation", "auth"]);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const updateUser = useMutation(user.mutations.update);
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader className="block">
|
||||
<AvatarForm
|
||||
id={props.user.id}
|
||||
image={props.user.image}
|
||||
update={(image) => updateUser.mutateAsync({ image })}
|
||||
>
|
||||
<div className="relative float-right">
|
||||
<AvatarFormInput
|
||||
onUpload={() => {
|
||||
toast.success(t("account.avatar.update.success"));
|
||||
router.refresh();
|
||||
}}
|
||||
>
|
||||
<AvatarFormPreview />
|
||||
</AvatarFormInput>
|
||||
|
||||
<AvatarFormRemoveButton
|
||||
onRemove={() => {
|
||||
toast.success(t("account.avatar.remove.success"));
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<SettingsCardTitle>{t("avatar")}</SettingsCardTitle>
|
||||
<SettingsCardDescription className="py-1.5 whitespace-pre-line">
|
||||
{t("account.avatar.description")}
|
||||
</SettingsCardDescription>
|
||||
|
||||
<AvatarFormErrorMessage className="mt-1" />
|
||||
</AvatarForm>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<SettingsCardFooter>{t("account.avatar.info")}</SettingsCardFooter>
|
||||
</SettingsCard>
|
||||
);
|
||||
});
|
||||
|
||||
EditAvatar.displayName = "EditAvatar";
|
||||
152
apps/web/src/modules/user/settings/general/edit-email.tsx
Normal file
152
apps/web/src/modules/user/settings/general/edit-email.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { emailSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormMessage,
|
||||
FormField,
|
||||
FormControl,
|
||||
FormItem,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { auth } from "~/modules/auth/lib/api";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardTitle,
|
||||
SettingsCardHeader,
|
||||
SettingsCardDescription,
|
||||
SettingsCardFooter,
|
||||
SettingsCardContent,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
|
||||
interface EditEmailProps {
|
||||
readonly user: User;
|
||||
}
|
||||
|
||||
export const EditEmail = memo<EditEmailProps>((props) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(emailSchema),
|
||||
defaultValues: {
|
||||
email: props.user.email,
|
||||
},
|
||||
});
|
||||
|
||||
const sendVerification = useMutation({
|
||||
...auth.mutations.email.sendVerification,
|
||||
onSuccess: () => {
|
||||
toast.success(t("account.email.confirm.email.sent"));
|
||||
},
|
||||
});
|
||||
|
||||
const changeEmail = useMutation({
|
||||
...auth.mutations.email.change,
|
||||
onSuccess: () => {
|
||||
toast.success(t("account.email.change.success"));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<SettingsCardTitle>{t("email")}</SettingsCardTitle>
|
||||
<Badge
|
||||
className={cn({
|
||||
"bg-success/15 text-success hover:bg-success/25":
|
||||
props.user.emailVerified,
|
||||
"bg-destructive/15 text-destructive hover:bg-destructive/25":
|
||||
!props.user.emailVerified,
|
||||
})}
|
||||
>
|
||||
{props.user.emailVerified ? t("verified") : t("unverified")}
|
||||
</Badge>
|
||||
{!props.user.emailVerified && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
sendVerification.mutate({
|
||||
email: props.user.email,
|
||||
callbackURL: pathsConfig.dashboard.user.settings.index,
|
||||
})
|
||||
}
|
||||
disabled={sendVerification.isPending}
|
||||
type="button"
|
||||
className="h-auto px-3 py-1 text-xs"
|
||||
>
|
||||
{sendVerification.isPending
|
||||
? t("account.email.confirm.loading")
|
||||
: t("account.email.confirm.cta")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SettingsCardDescription>
|
||||
{t("account.email.change.description")}
|
||||
</SettingsCardDescription>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) =>
|
||||
changeEmail.mutateAsync({
|
||||
newEmail: data.email,
|
||||
callbackURL: pathsConfig.dashboard.user.settings.index,
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<SettingsCardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
className="max-w-xs"
|
||||
placeholder="john@doe.com"
|
||||
disabled={form.formState.isSubmitting}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsCardContent>
|
||||
|
||||
<SettingsCardFooter>
|
||||
{t("account.email.change.info")}
|
||||
<Button size="sm" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
t("save")
|
||||
)}
|
||||
</Button>
|
||||
</SettingsCardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsCard>
|
||||
);
|
||||
});
|
||||
|
||||
EditEmail.displayName = "EditEmail";
|
||||
110
apps/web/src/modules/user/settings/general/edit-name.tsx
Normal file
110
apps/web/src/modules/user/settings/general/edit-name.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { updateUserSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Input } from "@turbostarter/ui-web/input";
|
||||
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardContent,
|
||||
SettingsCardDescription,
|
||||
SettingsCardFooter,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
import { onPromise } from "~/utils";
|
||||
|
||||
import { user } from "../../lib/api";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
|
||||
interface EditNameProps {
|
||||
readonly user: User;
|
||||
}
|
||||
|
||||
export const EditName = memo<EditNameProps>((props) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const router = useRouter();
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(updateUserSchema.pick({ name: true })),
|
||||
defaultValues: {
|
||||
name: props.user.name,
|
||||
},
|
||||
});
|
||||
|
||||
const updateUser = useMutation({
|
||||
...user.mutations.update,
|
||||
onSuccess: () => {
|
||||
toast.success(t("account.name.edit.success"));
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader>
|
||||
<SettingsCardTitle>{t("name")}</SettingsCardTitle>
|
||||
<SettingsCardDescription>
|
||||
{t("account.name.edit.description")}
|
||||
</SettingsCardDescription>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={onPromise(
|
||||
form.handleSubmit((data) => updateUser.mutateAsync(data)),
|
||||
)}
|
||||
>
|
||||
<SettingsCardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
disabled={form.formState.isSubmitting}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsCardContent>
|
||||
|
||||
<SettingsCardFooter>
|
||||
{t("account.name.edit.info")}
|
||||
<Button size="sm" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
t("save")
|
||||
)}
|
||||
</Button>
|
||||
</SettingsCardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsCard>
|
||||
);
|
||||
});
|
||||
|
||||
EditName.displayName = "EditName";
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
import { I18nControls } from "~/modules/common/i18n/controls";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardContent,
|
||||
SettingsCardDescription,
|
||||
SettingsCardFooter,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
|
||||
export const LanguageSwitcher = () => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader>
|
||||
<SettingsCardTitle>{t("language.label")}</SettingsCardTitle>
|
||||
<SettingsCardDescription>
|
||||
{t("language.description")}
|
||||
</SettingsCardDescription>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<SettingsCardContent>
|
||||
<div className="max-w-xs">
|
||||
<I18nControls />
|
||||
</div>
|
||||
</SettingsCardContent>
|
||||
|
||||
<SettingsCardFooter>{t("language.info")}</SettingsCardFooter>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
91
apps/web/src/modules/user/settings/layout/nav.tsx
Normal file
91
apps/web/src/modules/user/settings/layout/nav.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { useBreakpoint } from "@turbostarter/ui-web";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerTrigger,
|
||||
} from "@turbostarter/ui-web/drawer";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
interface SettingsNavProps {
|
||||
readonly links: {
|
||||
label: string;
|
||||
href: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const SettingsNav = memo(({ links }: SettingsNavProps) => {
|
||||
const isDesktop = useBreakpoint("lg");
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
const normalizedPathname = pathname.replace(
|
||||
`/${params.locale?.toString()}`,
|
||||
"",
|
||||
);
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<ul className="-ml-3 flex flex-col pr-10">
|
||||
{links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<TurboLink
|
||||
href={link.href}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:bg-muted block rounded-md px-3 py-2.5 text-sm",
|
||||
{
|
||||
"text-foreground font-medium":
|
||||
normalizedPathname === link.href,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
</TurboLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Icons.Menu className="size-5" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<ul className="flex flex-col p-6">
|
||||
{links.map((link) => (
|
||||
<li key={link.href}>
|
||||
<DrawerClose asChild>
|
||||
<TurboLink
|
||||
href={link.href}
|
||||
className={cn(
|
||||
"text-muted-foreground block rounded-md py-2.5",
|
||||
{
|
||||
"text-foreground font-medium":
|
||||
normalizedPathname === link.href,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
</TurboLink>
|
||||
</DrawerClose>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
});
|
||||
|
||||
SettingsNav.displayName = "SettingsNav";
|
||||
235
apps/web/src/modules/user/settings/security/accounts.tsx
Normal file
235
apps/web/src/modules/user/settings/security/accounts.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { capitalize } from "@turbostarter/shared/utils";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Modal,
|
||||
ModalTrigger,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalClose,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { authConfig } from "~/config/auth";
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { SocialIcons } from "~/modules/auth/form/social-providers";
|
||||
import { auth } from "~/modules/auth/lib/api";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardContent,
|
||||
SettingsCardDescription,
|
||||
SettingsCardFooter,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
|
||||
import type { SocialProvider } from "@turbostarter/auth";
|
||||
|
||||
export const Accounts = () => {
|
||||
const { t, i18n } = useTranslation(["auth", "common"]);
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
...auth.queries.accounts.getAll,
|
||||
enabled: !!session?.user.id,
|
||||
});
|
||||
|
||||
const accounts = data ?? [];
|
||||
const socials = accounts.filter((account) =>
|
||||
authConfig.providers.oAuth.includes(account.providerId),
|
||||
);
|
||||
const missing = authConfig.providers.oAuth.filter(
|
||||
(provider) => !socials.some((social) => social.providerId === provider),
|
||||
);
|
||||
|
||||
const connect = useMutation({
|
||||
...auth.mutations.accounts.connect,
|
||||
onSuccess: async (_, { provider }) => {
|
||||
await queryClient.invalidateQueries(auth.queries.accounts.getAll);
|
||||
toast.success(
|
||||
t("account.accounts.connect.success", {
|
||||
provider: capitalize(provider as string),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const disconnect = useMutation({
|
||||
...auth.mutations.accounts.disconnect,
|
||||
onSuccess: async (_, { providerId }) => {
|
||||
await queryClient.invalidateQueries(auth.queries.accounts.getAll);
|
||||
toast.success(
|
||||
t("account.accounts.disconnect.success", {
|
||||
provider: capitalize(providerId),
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader>
|
||||
<SettingsCardTitle>{t("account.accounts.title")}</SettingsCardTitle>
|
||||
<SettingsCardDescription>
|
||||
{t("account.accounts.description")}
|
||||
</SettingsCardDescription>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<SettingsCardContent className="space-y-4">
|
||||
{isLoading && <Skeleton className="h-24" />}
|
||||
|
||||
{socials.length > 0 && !isLoading && (
|
||||
<ul className="overflow-hidden rounded-md border">
|
||||
{socials.map((social) => {
|
||||
const provider = social.providerId as SocialProvider;
|
||||
const Icon = SocialIcons[provider];
|
||||
|
||||
return (
|
||||
<li
|
||||
key={social.accountId}
|
||||
className="flex items-center gap-3 border-b px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<Icon className="size-8" />
|
||||
|
||||
<div className="mr-auto flex flex-col">
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{provider}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("account.accounts.connectedAt", {
|
||||
date: social.updatedAt.toLocaleDateString(
|
||||
i18n.language,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
provider={provider}
|
||||
onConfirm={() =>
|
||||
disconnect.mutate({ providerId: provider })
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={accounts.length === 1 || disconnect.isPending}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("account.accounts.disconnect.cta", {
|
||||
provider,
|
||||
})}
|
||||
</span>
|
||||
{disconnect.isPending &&
|
||||
disconnect.variables.providerId === provider ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icons.Trash className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</ConfirmModal>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{missing.length > 0 && !isLoading && (
|
||||
<div className="flex flex-col items-start gap-3 rounded-md border border-dashed px-4 py-3">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Icons.Plus className="size-4" />
|
||||
<span className="text-sm font-medium">{t("addNew")}</span>
|
||||
</div>
|
||||
|
||||
<hr className="bg-border w-full" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{missing.map((provider) => {
|
||||
const Icon = SocialIcons[provider];
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 px-3 capitalize"
|
||||
key={provider}
|
||||
disabled={connect.isPending}
|
||||
onClick={() =>
|
||||
connect.mutate({
|
||||
provider,
|
||||
callbackURL:
|
||||
pathsConfig.dashboard.user.settings.security,
|
||||
errorCallbackURL: pathsConfig.auth.error,
|
||||
})
|
||||
}
|
||||
>
|
||||
{connect.isPending &&
|
||||
connect.variables.provider === provider ? (
|
||||
<Icons.Loader2 className="size-6 animate-spin" />
|
||||
) : (
|
||||
<Icon className="size-6" />
|
||||
)}
|
||||
{provider}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCardContent>
|
||||
|
||||
<SettingsCardFooter>
|
||||
<span>{t("account.accounts.info")}</span>
|
||||
</SettingsCardFooter>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmModal = ({
|
||||
provider,
|
||||
children,
|
||||
onConfirm,
|
||||
}: {
|
||||
provider: SocialProvider;
|
||||
children: React.ReactNode;
|
||||
onConfirm: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
{t("account.accounts.disconnect.cta", {
|
||||
provider: capitalize(provider),
|
||||
})}
|
||||
</ModalTitle>
|
||||
<ModalDescription className="whitespace-pre-line">
|
||||
{t("account.accounts.disconnect.disclaimer", {
|
||||
provider: capitalize(provider),
|
||||
})}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</ModalClose>
|
||||
<ModalClose asChild>
|
||||
<Button onClick={onConfirm}>{t("continue")}</Button>
|
||||
</ModalClose>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
160
apps/web/src/modules/user/settings/security/edit-password.tsx
Normal file
160
apps/web/src/modules/user/settings/security/edit-password.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { changePasswordSchema } from "@turbostarter/auth";
|
||||
import { Trans, useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { PasswordInput } from "@turbostarter/ui-web/input";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { auth } from "~/modules/auth/lib/api";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardContent,
|
||||
SettingsCardDescription,
|
||||
SettingsCardFooter,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { onPromise } from "~/utils";
|
||||
|
||||
export const EditPassword = () => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const session = authClient.useSession();
|
||||
const { data: accounts, isLoading } = useQuery({
|
||||
...auth.queries.accounts.getAll,
|
||||
enabled: !!session.data?.user.id,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(changePasswordSchema),
|
||||
});
|
||||
|
||||
const changePassword = useMutation({
|
||||
...auth.mutations.password.change,
|
||||
onSuccess: () => {
|
||||
toast.success(t("account.password.update.success"));
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const hasPassword = accounts
|
||||
?.map((account) => account.providerId)
|
||||
.includes("credential");
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader>
|
||||
<SettingsCardTitle>{t("password")}</SettingsCardTitle>
|
||||
<SettingsCardDescription>
|
||||
{t("account.password.update.description")}
|
||||
</SettingsCardDescription>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={onPromise(
|
||||
form.handleSubmit((data) =>
|
||||
changePassword.mutateAsync({
|
||||
...data,
|
||||
currentPassword: data.password,
|
||||
revokeOtherSessions: true,
|
||||
}),
|
||||
),
|
||||
)}
|
||||
>
|
||||
<SettingsCardContent>
|
||||
{isLoading && <Skeleton className="mt-0 h-20" />}
|
||||
|
||||
{!isLoading &&
|
||||
(hasPassword ? (
|
||||
<div className="flex w-full flex-wrap gap-3 lg:gap-5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full max-w-xs space-y-1">
|
||||
<FormLabel>{t("currentPassword")}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
disabled={form.formState.isSubmitting}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full max-w-xs space-y-1">
|
||||
<FormLabel>{t("newPassword")}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
disabled={form.formState.isSubmitting}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-center rounded-md border border-dashed p-6">
|
||||
<p className="text-center text-sm">
|
||||
<Trans
|
||||
i18nKey="account.password.update.noPassword"
|
||||
ns="auth"
|
||||
components={{
|
||||
bold: (
|
||||
<TurboLink
|
||||
href={pathsConfig.auth.forgotPassword}
|
||||
className="hover:text-primary font-medium underline underline-offset-4"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</SettingsCardContent>
|
||||
<SettingsCardFooter>
|
||||
{t("account.password.update.info")}
|
||||
|
||||
{!isLoading && hasPassword && (
|
||||
<Button size="sm" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
t("save")
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</SettingsCardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
182
apps/web/src/modules/user/settings/security/passkeys.tsx
Normal file
182
apps/web/src/modules/user/settings/security/passkeys.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { isKey, useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalTrigger,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { auth } from "~/modules/auth/lib/api";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardContent,
|
||||
SettingsCardDescription,
|
||||
SettingsCardFooter,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
|
||||
export const Passkeys = () => {
|
||||
const { t, i18n } = useTranslation(["auth", "common"]);
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const userId = session?.user.id ?? "";
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
...auth.queries.passkeys.getAll,
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const passkeys = data ?? [];
|
||||
|
||||
const add = useMutation({
|
||||
...auth.mutations.passkeys.add,
|
||||
onSuccess: async (_, __) => {
|
||||
await queryClient.invalidateQueries(auth.queries.passkeys.getAll);
|
||||
toast.success(t("account.passkeys.add.success"));
|
||||
},
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
...auth.mutations.passkeys.delete,
|
||||
onSuccess: async (_, __) => {
|
||||
await queryClient.invalidateQueries(auth.queries.passkeys.getAll);
|
||||
toast.success(t("account.passkeys.remove.success"));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader>
|
||||
<SettingsCardTitle>{t("account.passkeys.title")}</SettingsCardTitle>
|
||||
<SettingsCardDescription>
|
||||
{t("account.passkeys.description")}
|
||||
</SettingsCardDescription>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<SettingsCardContent className="space-y-4">
|
||||
{isLoading && <Skeleton className="h-24" />}
|
||||
|
||||
{passkeys.length > 0 && (
|
||||
<ul className="overflow-hidden rounded-md border">
|
||||
{passkeys.map((passkey) => {
|
||||
return (
|
||||
<li
|
||||
key={passkey.id}
|
||||
className="flex min-w-0 items-center gap-3 border-b px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<Icons.Key className="size-6 shrink-0" />
|
||||
|
||||
<div className="mr-auto grid grid-cols-1">
|
||||
<span className="truncate text-sm font-medium capitalize">
|
||||
{isKey(
|
||||
`account.passkeys.type.${passkey.deviceType}`,
|
||||
i18n,
|
||||
)
|
||||
? t(`account.passkeys.type.${passkey.deviceType}`)
|
||||
: passkey.deviceType}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{t("account.passkeys.addedAt", {
|
||||
date: passkey.createdAt.toLocaleDateString(
|
||||
i18n.language,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
onConfirm={() => remove.mutate({ id: passkey.id })}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={remove.isPending}
|
||||
className="shrink-0"
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("account.passkeys.remove.cta")}
|
||||
</span>
|
||||
{remove.isPending &&
|
||||
remove.variables.id === passkey.id ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icons.Trash className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</ConfirmModal>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
disabled={add.isPending}
|
||||
onClick={() => add.mutate()}
|
||||
>
|
||||
{add.isPending ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Icons.Plus className="size-4" />
|
||||
{t("account.passkeys.add.cta")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</SettingsCardContent>
|
||||
|
||||
<SettingsCardFooter>{t("account.passkeys.info")}</SettingsCardFooter>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfirmModal = ({
|
||||
children,
|
||||
onConfirm,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onConfirm: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>{t("account.passkeys.remove.cta")}</ModalTitle>
|
||||
<ModalDescription className="whitespace-pre-line">
|
||||
{t("account.passkeys.remove.disclaimer")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline">{t("cancel")}</Button>
|
||||
</ModalClose>
|
||||
<ModalClose asChild>
|
||||
<Button onClick={onConfirm}>{t("continue")}</Button>
|
||||
</ModalClose>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
166
apps/web/src/modules/user/settings/security/require-password.tsx
Normal file
166
apps/web/src/modules/user/settings/security/require-password.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { passwordSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { PasswordInput } from "@turbostarter/ui-web/input";
|
||||
import {
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalTrigger,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
|
||||
import { onPromise } from "~/utils";
|
||||
|
||||
import type { PasswordPayload } from "@turbostarter/auth";
|
||||
import type { ComponentProps } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
interface PasswordFormProps {
|
||||
readonly form: UseFormReturn<PasswordPayload>;
|
||||
readonly onSubmit: (data: PasswordPayload) => Promise<void>;
|
||||
readonly children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PasswordForm = memo<PasswordFormProps>(({ form, onSubmit, children }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={onPromise(form.handleSubmit(onSubmit))}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full space-y-1 px-6 md:px-0">
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
{...field}
|
||||
autoComplete="current-password"
|
||||
disabled={form.formState.isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
PasswordForm.displayName = "PasswordForm";
|
||||
|
||||
interface RequirePasswordProps extends ComponentProps<typeof Modal> {
|
||||
readonly title?: string;
|
||||
readonly description?: string;
|
||||
readonly cta?: string;
|
||||
readonly onConfirm: (data: PasswordPayload) => Promise<void>;
|
||||
}
|
||||
|
||||
export const RequirePassword = memo<RequirePasswordProps>(
|
||||
({
|
||||
title,
|
||||
description,
|
||||
onConfirm,
|
||||
cta,
|
||||
children,
|
||||
open: _open,
|
||||
|
||||
onOpenChange: _onOpenChange,
|
||||
...props
|
||||
}) => {
|
||||
const [open, setOpen] = useState(_open ?? false);
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(passwordSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: PasswordPayload) => {
|
||||
try {
|
||||
if (document.activeElement && "blur" in document.activeElement) {
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
}
|
||||
await onConfirm(data);
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
setTimeout(() => form.setFocus("password"), 0);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
setOpen(open);
|
||||
_onOpenChange?.(open);
|
||||
},
|
||||
[_onOpenChange, setOpen],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(_open ?? false);
|
||||
}, [_open]);
|
||||
|
||||
return (
|
||||
<Modal {...props} open={open} onOpenChange={onOpenChange}>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
{title ?? t("account.password.require.title")}
|
||||
</ModalTitle>
|
||||
<ModalDescription className="whitespace-pre-line">
|
||||
{description ?? t("account.password.require.description")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
<PasswordForm form={form} onSubmit={onSubmit}>
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
(cta ?? t("continue"))
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</PasswordForm>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RequirePassword.displayName = "RequirePassword";
|
||||
120
apps/web/src/modules/user/settings/security/sessions.tsx
Normal file
120
apps/web/src/modules/user/settings/security/sessions.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { auth } from "~/modules/auth/lib/api";
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
SettingsCardDescription,
|
||||
SettingsCardFooter,
|
||||
SettingsCardContent,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
|
||||
export const Sessions = () => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const session = authClient.useSession();
|
||||
const router = useRouter();
|
||||
|
||||
const signOut = useMutation({
|
||||
...auth.mutations.signOut,
|
||||
onSuccess: () => {
|
||||
router.replace(pathsConfig.index);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: sessions,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
...auth.queries.sessions.getAll,
|
||||
enabled: !!session.data?.user.id,
|
||||
});
|
||||
|
||||
const revoke = useMutation({
|
||||
...auth.mutations.sessions.revoke,
|
||||
onSuccess: async (_, token) => {
|
||||
toast.success(t("account.sessions.revoke.success"));
|
||||
await refetch();
|
||||
if (token === session.data?.session.token) {
|
||||
await signOut.mutateAsync(undefined);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader>
|
||||
<SettingsCardTitle>{t("account.sessions.title")}</SettingsCardTitle>
|
||||
<SettingsCardDescription className="text-foreground flex flex-col gap-1 pb-1.5">
|
||||
{t("account.sessions.description")}
|
||||
</SettingsCardDescription>
|
||||
</SettingsCardHeader>
|
||||
|
||||
<SettingsCardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-24" />
|
||||
) : sessions && sessions.length > 0 ? (
|
||||
<ul className="overflow-hidden rounded-md border">
|
||||
{sessions.map((session) => {
|
||||
return (
|
||||
<li
|
||||
key={session.id}
|
||||
className="flex min-w-0 items-center gap-3 border-b px-4 py-3 last:border-b-0"
|
||||
>
|
||||
<Icons.MonitorSmartphone className="size-6 shrink-0" />
|
||||
|
||||
<div className="mr-auto grid grid-cols-1">
|
||||
<span className="truncate text-sm font-medium capitalize">
|
||||
{session.ipAddress}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{session.userAgent}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={
|
||||
revoke.isPending && revoke.variables === session.token
|
||||
}
|
||||
onClick={() => revoke.mutate(session.token)}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("account.sessions.revoke.cta")}
|
||||
</span>
|
||||
{revoke.isPending && revoke.variables === session.token ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icons.Trash className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex items-center justify-center rounded-md border border-dashed p-6">
|
||||
<p className="text-center">{t("account.sessions.noSessions")}</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCardContent>
|
||||
|
||||
<SettingsCardFooter>
|
||||
<span>{t("account.sessions.info")}</span>
|
||||
</SettingsCardFooter>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalClose,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
|
||||
import { useCopyToClipboard } from "~/modules/common/hooks/use-copy-to-clipboard";
|
||||
|
||||
import { RequirePassword } from "../../require-password";
|
||||
import { useTwoFactor } from "../use-two-factor";
|
||||
|
||||
import { useBackupCodes } from "./use-backup-codes";
|
||||
|
||||
interface BackupCodesModalProps {
|
||||
readonly open?: boolean;
|
||||
readonly onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const BackupCodesModal = ({
|
||||
open,
|
||||
onOpenChange: _onOpenChange,
|
||||
}: BackupCodesModalProps) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const { codes, setCodes } = useBackupCodes();
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
_onOpenChange?.(open);
|
||||
|
||||
if (!open) {
|
||||
setCodes([]);
|
||||
}
|
||||
},
|
||||
[_onOpenChange, setCodes],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>
|
||||
{t("account.twoFactor.backupCodes.save.title")}
|
||||
</ModalTitle>
|
||||
<ModalDescription>
|
||||
{t("account.twoFactor.backupCodes.save.description")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<div className="flex w-full flex-col items-center gap-4">
|
||||
<div className="w-full overflow-hidden rounded-md border">
|
||||
<div className="bg-muted/25 grid grid-cols-2 gap-2 border-b py-1">
|
||||
{codes.map((code) => (
|
||||
<code
|
||||
key={code}
|
||||
className="rounded p-1.5 text-center font-mono text-sm"
|
||||
>
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end p-2">
|
||||
<Download />
|
||||
<Copy />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalFooter className="flex w-full justify-end gap-2">
|
||||
<ModalClose asChild>
|
||||
<Button>{t("continue")}</Button>
|
||||
</ModalClose>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const Copy = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const [_, copy] = useCopyToClipboard();
|
||||
const [showCheck, setShowCheck] = useState(false);
|
||||
|
||||
const { codes } = useBackupCodes();
|
||||
|
||||
const handleCopy = async () => {
|
||||
const success = await copy(codes.join("\n"));
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowCheck(true);
|
||||
setTimeout(() => {
|
||||
setShowCheck(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto gap-1.5 px-3 py-1.5"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{showCheck ? (
|
||||
<Icons.Check className="size-4" />
|
||||
) : (
|
||||
<Icons.Copy className="size-4" />
|
||||
)}
|
||||
{t("copy")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const Download = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const { codes } = useBackupCodes();
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([codes.join("\n")], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "recovery-codes.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-auto gap-1.5 px-3 py-1.5"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Icons.Download className="size-4" />
|
||||
{t("download")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const BackupCodesTile = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const { enabled } = useTwoFactor();
|
||||
const { generate } = useBackupCodes();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium">
|
||||
{t("account.twoFactor.backupCodes.title")}
|
||||
</span>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("account.twoFactor.backupCodes.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RequirePassword
|
||||
onConfirm={async (data) => {
|
||||
await generate.mutateAsync(data);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" disabled={!enabled}>
|
||||
{t("regenerate")}
|
||||
</Button>
|
||||
</RequirePassword>
|
||||
</div>
|
||||
|
||||
<BackupCodesModal open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { create } from "zustand";
|
||||
|
||||
import { auth } from "~/modules/auth/lib/api";
|
||||
|
||||
const useBackupCodesStore = create<{
|
||||
codes: string[];
|
||||
setCodes: (codes: string[]) => void;
|
||||
}>((set) => ({
|
||||
codes: [],
|
||||
setCodes: (codes) => set({ codes }),
|
||||
}));
|
||||
|
||||
export const useBackupCodes = () => {
|
||||
const { codes, setCodes } = useBackupCodesStore();
|
||||
|
||||
const generate = useMutation({
|
||||
...auth.mutations.twoFactor.backupCodes.generate,
|
||||
onSuccess: ({ backupCodes }) => {
|
||||
setCodes(backupCodes);
|
||||
},
|
||||
});
|
||||
|
||||
const verify = useMutation(auth.mutations.twoFactor.backupCodes.verify);
|
||||
|
||||
return { codes, setCodes, generate, verify };
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import QRCode from "react-qr-code";
|
||||
|
||||
import { otpSchema } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
FormField,
|
||||
FormControl,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
Form,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@turbostarter/ui-web/input-otp";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalClose,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
|
||||
import { useCopyToClipboard } from "~/modules/common/hooks/use-copy-to-clipboard";
|
||||
|
||||
import { RequirePassword } from "../../require-password";
|
||||
import { BackupCodesModal } from "../backup-codes/backup-codes";
|
||||
import { useTwoFactor } from "../use-two-factor";
|
||||
|
||||
import { useTotp } from "./use-totp";
|
||||
|
||||
import type { OtpPayload, PasswordPayload } from "@turbostarter/auth";
|
||||
|
||||
interface TotpModalProps {
|
||||
readonly open?: boolean;
|
||||
readonly onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const TotpModal = ({ open, onOpenChange }: TotpModalProps) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [backupCodesOpen, setBackupCodesOpen] = useState(false);
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const { uri, verify } = useTotp();
|
||||
const form = useForm({
|
||||
resolver: standardSchemaResolver(otpSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: OtpPayload) => {
|
||||
try {
|
||||
if (document.activeElement && "blur" in document.activeElement) {
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
}
|
||||
await verify.mutateAsync(data);
|
||||
onOpenChange?.(false);
|
||||
setBackupCodesOpen(true);
|
||||
} catch (error) {
|
||||
setTimeout(() => form.setFocus("code"), 0);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>{t("account.twoFactor.totp.enable.title")}</ModalTitle>
|
||||
<ModalDescription className="whitespace-pre-line">
|
||||
{t("account.twoFactor.totp.enable.description")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<div className="mt-2 flex w-full flex-col-reverse items-center gap-2">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="mt-2 flex w-full flex-col items-center space-y-4 md:space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
disabled={form.formState.isSubmitting}
|
||||
onComplete={form.handleSubmit(onSubmit)}
|
||||
{...field}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<InputOTPSlot key={index} index={index} />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<ModalFooter className="flex w-full justify-end">
|
||||
<ModalClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{t("close")}
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
t("continue")
|
||||
)}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<QRCode
|
||||
value={uri}
|
||||
size={180}
|
||||
bgColor="transparent"
|
||||
fgColor={resolvedTheme === "dark" ? "#fff" : "#000"}
|
||||
/>
|
||||
|
||||
<Secret />
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<BackupCodesModal
|
||||
open={backupCodesOpen}
|
||||
onOpenChange={setBackupCodesOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Secret = () => {
|
||||
const { uri } = useTotp();
|
||||
const [showCheck, setShowCheck] = useState(false);
|
||||
|
||||
const [, copy] = useCopyToClipboard();
|
||||
|
||||
const secret = useMemo(() => {
|
||||
return uri ? new URL(uri).searchParams.get("secret") : null;
|
||||
}, [uri]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const success = await copy(secret ?? "");
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowCheck(true);
|
||||
setTimeout(() => {
|
||||
setShowCheck(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
if (!secret) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="mb-1 inline-block w-full gap-2 px-6 text-center leading-none text-balance">
|
||||
<span className="font-mono text-sm leading-none text-balance break-all">
|
||||
{secret}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-1 size-6"
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{showCheck ? (
|
||||
<Icons.Check className="size-3" />
|
||||
) : (
|
||||
<Icons.Copy className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const TotpTile = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const { enabled } = useTwoFactor();
|
||||
|
||||
const { setUri, getUri } = useTotp();
|
||||
|
||||
const onEdit = async (data: PasswordPayload) => {
|
||||
const response = await getUri.mutateAsync(data);
|
||||
setUri(response.totpURI);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium">
|
||||
{t("account.twoFactor.totp.title")}
|
||||
</span>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("account.twoFactor.totp.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RequirePassword onConfirm={onEdit}>
|
||||
<Button variant="outline" disabled={!enabled}>
|
||||
{enabled ? t("edit") : t("add")}
|
||||
</Button>
|
||||
</RequirePassword>
|
||||
<TotpModal open={open} onOpenChange={setOpen} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { create } from "zustand";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
import { auth } from "~/modules/auth/lib/api";
|
||||
|
||||
import { useBackupCodes } from "../backup-codes/use-backup-codes";
|
||||
|
||||
const useUri = create<{
|
||||
uri: string;
|
||||
setUri: (uri: string) => void;
|
||||
}>((set) => ({
|
||||
uri: "",
|
||||
setUri: (uri) => set({ uri }),
|
||||
}));
|
||||
|
||||
export const useTotp = () => {
|
||||
const { t } = useTranslation(["auth"]);
|
||||
const { uri, setUri } = useUri();
|
||||
const { codes, setCodes } = useBackupCodes();
|
||||
|
||||
const getUri = useMutation({
|
||||
...auth.mutations.twoFactor.totp.getUri,
|
||||
onSuccess: async ({ totpURI }, data) => {
|
||||
setUri(totpURI);
|
||||
if (!codes.length) {
|
||||
const backupCodes =
|
||||
await auth.mutations.twoFactor.backupCodes.generate.mutationFn(data);
|
||||
|
||||
setCodes(backupCodes.backupCodes);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const verify = useMutation({
|
||||
...auth.mutations.twoFactor.totp.verify,
|
||||
onSuccess: (_, __) => {
|
||||
toast.success(t("account.twoFactor.totp.success"));
|
||||
},
|
||||
});
|
||||
|
||||
return { uri, setUri, verify, getUri };
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Switch } from "@turbostarter/ui-web/switch";
|
||||
|
||||
import {
|
||||
SettingsCard,
|
||||
SettingsCardHeader,
|
||||
SettingsCardTitle,
|
||||
SettingsCardDescription,
|
||||
SettingsCardContent,
|
||||
SettingsCardFooter,
|
||||
} from "~/modules/common/layout/dashboard/settings-card";
|
||||
|
||||
import { RequirePassword } from "../require-password";
|
||||
|
||||
import { BackupCodesTile } from "./backup-codes/backup-codes";
|
||||
import { useBackupCodes } from "./backup-codes/use-backup-codes";
|
||||
import { TotpModal, TotpTile } from "./totp/totp";
|
||||
import { useTotp } from "./totp/use-totp";
|
||||
import { useTwoFactor } from "./use-two-factor";
|
||||
|
||||
import type { PasswordPayload } from "@turbostarter/auth";
|
||||
|
||||
export const TwoFactorAuthentication = () => {
|
||||
const { t } = useTranslation(["auth", "common"]);
|
||||
const [twoFactorOpen, setTwoFactorOpen] = useState(false);
|
||||
const [totpOpen, setTotpOpen] = useState(false);
|
||||
|
||||
const { setUri } = useTotp();
|
||||
const { setCodes } = useBackupCodes();
|
||||
|
||||
const { enabled, enable, disable } = useTwoFactor();
|
||||
|
||||
const onEnable = useCallback(
|
||||
async (data: PasswordPayload) => {
|
||||
const response = await enable.mutateAsync(data);
|
||||
|
||||
setUri(response.totpURI);
|
||||
setCodes(response.backupCodes);
|
||||
setTwoFactorOpen(false);
|
||||
setTotpOpen(true);
|
||||
},
|
||||
[enable, setUri, setCodes, setTwoFactorOpen, setTotpOpen],
|
||||
);
|
||||
|
||||
const onDisable = useCallback(
|
||||
async (data: PasswordPayload) => {
|
||||
await disable.mutateAsync(data);
|
||||
},
|
||||
[disable],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsCard className="h-fit w-full overflow-hidden">
|
||||
<SettingsCardHeader className="flex flex-row items-start justify-between gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<SettingsCardTitle>{t("account.twoFactor.title")}</SettingsCardTitle>
|
||||
<SettingsCardDescription>
|
||||
{t("account.twoFactor.description")}
|
||||
</SettingsCardDescription>
|
||||
</div>
|
||||
|
||||
<TwoFactorSwitch
|
||||
onSubmit={enabled ? onDisable : onEnable}
|
||||
open={twoFactorOpen}
|
||||
onOpenChange={setTwoFactorOpen}
|
||||
/>
|
||||
<TotpModal open={totpOpen} onOpenChange={setTotpOpen} />
|
||||
</SettingsCardHeader>
|
||||
|
||||
<SettingsCardContent className="space-y-4">
|
||||
<TotpTile />
|
||||
<BackupCodesTile />
|
||||
</SettingsCardContent>
|
||||
<SettingsCardFooter>
|
||||
<span>{t("account.twoFactor.info")}</span>
|
||||
</SettingsCardFooter>
|
||||
</SettingsCard>
|
||||
);
|
||||
};
|
||||
|
||||
const TwoFactorSwitch = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onSubmit: (data: PasswordPayload) => Promise<void>;
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
|
||||
const { enabled } = useTwoFactor();
|
||||
|
||||
const key = useMemo(() => {
|
||||
return enabled ? "disable" : "enable";
|
||||
}, [enabled]);
|
||||
|
||||
return (
|
||||
<RequirePassword
|
||||
onConfirm={onSubmit}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t(`account.twoFactor.${key}.title`)}
|
||||
description={t(`account.twoFactor.${key}.description`)}
|
||||
cta={t(key)}
|
||||
>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
className={cn({
|
||||
"bg-input": !enabled,
|
||||
"bg-primary": enabled,
|
||||
})}
|
||||
/>
|
||||
</RequirePassword>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { auth } from "~/modules/auth/lib/api";
|
||||
|
||||
export const useTwoFactor = () => {
|
||||
const { data } = authClient.useSession();
|
||||
const { t } = useTranslation("auth");
|
||||
|
||||
const enabled = data?.user.twoFactorEnabled ?? false;
|
||||
|
||||
const enable = useMutation(auth.mutations.twoFactor.enable);
|
||||
|
||||
const disable = useMutation({
|
||||
...auth.mutations.twoFactor.disable,
|
||||
onSuccess: (_, __) => {
|
||||
toast.success(t("account.twoFactor.disable.success"));
|
||||
},
|
||||
});
|
||||
|
||||
return { enabled, enable, disable };
|
||||
};
|
||||
187
apps/web/src/modules/user/user-navigation.tsx
Normal file
187
apps/web/src/modules/user/user-navigation.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
|
||||
import { hasAdminPermission } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuGroup,
|
||||
} from "@turbostarter/ui-web/dropdown-menu";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { SidebarMenuButton, useSidebar } from "@turbostarter/ui-web/sidebar";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { auth } from "~/modules/auth/lib/api";
|
||||
import { ThemeControlsProvider } from "~/modules/common/theme";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import type { User } from "@turbostarter/auth";
|
||||
|
||||
interface UserNavigationProps {
|
||||
readonly user: User;
|
||||
}
|
||||
|
||||
export const UserNavigation = memo<UserNavigationProps>(({ user }) => {
|
||||
const { t } = useTranslation(["common", "auth"]);
|
||||
const router = useRouter();
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
const { refetch } = authClient.useListOrganizations();
|
||||
|
||||
const signOut = useMutation({
|
||||
...auth.mutations.signOut,
|
||||
onSuccess: async () => {
|
||||
await refetch();
|
||||
router.replace(pathsConfig.index);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={user.image ?? undefined} alt={user.name} />
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound className="w-5" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
{user.name && (
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
)}
|
||||
{user.email && (
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
)}
|
||||
</div>
|
||||
<Icons.EllipsisVertical className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="flex items-center gap-2 font-normal">
|
||||
<Avatar className="size-8">
|
||||
<AvatarImage src={user.image ?? undefined} alt={user.name} />
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound className="w-5" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex w-full min-w-0 flex-col space-y-1">
|
||||
{user.name && (
|
||||
<p className="truncate text-sm leading-none font-medium">
|
||||
{user.name}
|
||||
</p>
|
||||
)}
|
||||
{user.email && (
|
||||
<p className="text-muted-foreground truncate text-xs leading-none">
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<TurboLink
|
||||
href={pathsConfig.dashboard.user.index}
|
||||
className="flex w-full cursor-pointer items-center gap-1.5"
|
||||
onClick={() => setOpenMobile(false)}
|
||||
>
|
||||
<Icons.Home className="size-4" />
|
||||
{t("dashboard")}
|
||||
</TurboLink>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<TurboLink
|
||||
href={pathsConfig.dashboard.user.settings.index}
|
||||
className="flex w-full cursor-pointer items-center gap-1.5"
|
||||
onClick={() => setOpenMobile(false)}
|
||||
>
|
||||
<Icons.Settings className="size-4" />
|
||||
{t("settings")}
|
||||
</TurboLink>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<ThemeControlsProvider>
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div className="flex w-full cursor-pointer items-center gap-1.5">
|
||||
<Icons.Sun className="size-4 dark:hidden" />
|
||||
<Icons.Moon className="hidden size-4 dark:block" />
|
||||
{t("theme.title")}
|
||||
|
||||
<div className="bg-primary ml-auto size-3 rounded-full"></div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</ThemeControlsProvider>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
{hasAdminPermission(user) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<TurboLink
|
||||
href={pathsConfig.admin.index}
|
||||
className="flex w-full cursor-pointer items-center gap-1.5"
|
||||
>
|
||||
<Icons.ShieldUser className="size-4" />
|
||||
{t("common:admin")}
|
||||
</TurboLink>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5"
|
||||
onClick={() => signOut.mutate(undefined)}
|
||||
>
|
||||
<Icons.LogOut className="size-4" />
|
||||
{t("logout.cta")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu>
|
||||
);
|
||||
});
|
||||
|
||||
export const UserNavigationSkeleton = () => {
|
||||
return <Skeleton className="size-10 rounded-full" />;
|
||||
};
|
||||
|
||||
UserNavigation.displayName = "UserNavigation";
|
||||
Reference in New Issue
Block a user