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:
Alejandro Gutiérrez
2026-04-04 21:19:32 +01:00
commit d3163a5bff
1384 changed files with 314925 additions and 0 deletions

View 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,
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View 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";

View 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";

View 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";

View File

@@ -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>
);
};

View 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";

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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";

View 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>
);
};

View File

@@ -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} />
</>
);
};

View File

@@ -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 };
};

View File

@@ -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>
);
};

View File

@@ -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 };
};

View File

@@ -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>
);
};

View File

@@ -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 };
};

View 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";