Add support for OTPs and enhance sensitive apis with OTP verification (#191)

One-Time Password (OTP) package added with comprehensive token management, including OTP verification for team account deletion and ownership transfer.
This commit is contained in:
Giancarlo Buomprisco
2025-03-01 16:35:09 +07:00
committed by GitHub
parent 20f7fd2c22
commit d31f3eb993
60 changed files with 3543 additions and 1363 deletions

View File

@@ -7,7 +7,7 @@ export function CtaButton(
) {
return (
<Button
className="w-full bg-[#000000] rounded text-white text-[16px] font-semibold no-underline text-center py-3"
className="w-full rounded bg-[#000000] py-3 text-center text-[16px] font-semibold text-white no-underline"
href={props.href}
>
{props.children}

View File

@@ -3,9 +3,7 @@ import { Container, Section } from '@react-email/components';
export function EmailHeader(props: React.PropsWithChildren) {
return (
<Container>
<Section>
{props.children}
</Section>
<Section>{props.children}</Section>
</Container>
);
}

View File

@@ -54,29 +54,29 @@ export async function renderAccountDeleteEmail(props: Props) {
</EmailHeader>
<EmailContent>
<Text className="text-[14px] leading-[24px] text-black">
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:hello`, {
displayName: props.userDisplayName,
})}
</Text>
<Text className="text-[14px] leading-[24px] text-black">
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:paragraph1`, {
productName: props.productName,
})}
</Text>
<Text className="text-[14px] leading-[24px] text-black">
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:paragraph2`)}
</Text>
<Text className="text-[14px] leading-[24px] text-black">
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:paragraph3`, {
productName: props.productName,
})}
</Text>
<Text className="text-[14px] leading-[24px] text-black">
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:paragraph4`, {
productName: props.productName,
})}

View File

@@ -79,12 +79,12 @@ export async function renderInviteEmail(props: Props) {
</EmailHeader>
<EmailContent>
<Text className="text-[14px] leading-[24px] text-black">
<Text className="text-[16px] leading-[24px] text-[#242424]">
{hello}
</Text>
<Text
className="text-[14px] leading-[24px] text-black"
className="text-[16px] leading-[24px] text-[#242424]"
dangerouslySetInnerHTML={{ __html: mainText }}
/>
@@ -107,7 +107,7 @@ export async function renderInviteEmail(props: Props) {
<CtaButton href={props.link}>{joinTeam}</CtaButton>
</Section>
<Text className="text-[14px] leading-[24px] text-black">
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:copyPasteLink`)}{' '}
<Link href={props.link} className="text-blue-600 no-underline">
{props.link}

View File

@@ -0,0 +1,97 @@
import {
Body,
Button,
Head,
Html,
Preview,
Section,
Tailwind,
Text,
render,
} from '@react-email/components';
import { BodyStyle } from '../components/body-style';
import { EmailContent } from '../components/content';
import { EmailFooter } from '../components/footer';
import { EmailHeader } from '../components/header';
import { EmailHeading } from '../components/heading';
import { EmailWrapper } from '../components/wrapper';
import { initializeEmailI18n } from '../lib/i18n';
interface Props {
otp: string;
productName: string;
language?: string;
}
export async function renderOtpEmail(props: Props) {
const namespace = 'otp-email';
const { t } = await initializeEmailI18n({
language: props.language,
namespace,
});
const subject = t(`${namespace}:subject`, {
productName: props.productName,
});
const previewText = subject;
const heading = t(`${namespace}:heading`, {
productName: props.productName,
});
const otpText = t(`${namespace}:otpText`, {
otp: props.otp,
});
const mainText = t(`${namespace}:mainText`);
const footerText = t(`${namespace}:footerText`);
const html = await render(
<Html>
<Head>
<BodyStyle />
</Head>
<Preview>{previewText}</Preview>
<Tailwind>
<Body>
<EmailWrapper>
<EmailHeader>
<EmailHeading>{heading}</EmailHeading>
</EmailHeader>
<EmailContent>
<Text className="text-[16px] text-[#242424]">{mainText}</Text>
<Text className="text-[16px] text-[#242424]">{otpText}</Text>
<Section className="mb-[16px] mt-[16px] text-center">
<Button className={'w-full rounded bg-neutral-950 text-center'}>
<Text className="text-[16px] font-medium font-semibold leading-[16px] text-white">
{props.otp}
</Text>
</Button>
</Section>
<Text
className="text-[16px] text-[#242424]"
dangerouslySetInnerHTML={{ __html: footerText }}
/>
</EmailContent>
<EmailFooter>{props.productName}</EmailFooter>
</EmailWrapper>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
};
}

View File

@@ -1,2 +1,3 @@
export * from './emails/invite.email';
export * from './emails/account-delete.email';
export * from './emails/otp.email';

View File

@@ -0,0 +1,7 @@
{
"subject": "One-time password for {{productName}}",
"heading": "One-time password for {{productName}}",
"otpText": "Your one-time password is: {{otp}}",
"footerText": "Please enter the one-time password in the app to continue.",
"mainText": "You're receiving this email because you need to verify your identity using a one-time password."
}

View File

@@ -27,6 +27,7 @@
"@kit/mailers": "workspace:*",
"@kit/monitoring": "workspace:*",
"@kit/next": "workspace:*",
"@kit/otp": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",

View File

@@ -4,9 +4,11 @@ import { useFormStatus } from 'react-dom';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { ErrorBoundary } from '@kit/monitoring/components';
import { VerifyOtpForm } from '@kit/otp/components';
import { useUser } from '@kit/supabase/hooks/use-user';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
AlertDialog,
@@ -18,8 +20,7 @@ import {
AlertDialogTrigger,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import { Form, FormControl, FormItem, FormLabel } from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Form } from '@kit/ui/form';
import { Trans } from '@kit/ui/trans';
import { DeletePersonalAccountSchema } from '../../schema/delete-personal-account.schema';
@@ -46,6 +47,12 @@ export function AccountDangerZone() {
}
function DeleteAccountModal() {
const { data: user } = useUser();
if (!user?.email) {
return null;
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -61,22 +68,39 @@ function DeleteAccountModal() {
</AlertDialogTitle>
</AlertDialogHeader>
<ErrorBoundary fallback={<DeleteAccountErrorAlert />}>
<DeleteAccountForm />
<ErrorBoundary fallback={<DeleteAccountErrorContainer />}>
<DeleteAccountForm email={user.email} />
</ErrorBoundary>
</AlertDialogContent>
</AlertDialog>
);
}
function DeleteAccountForm() {
function DeleteAccountForm(props: { email: string }) {
const form = useForm({
resolver: zodResolver(DeletePersonalAccountSchema),
defaultValues: {
confirmation: '' as 'DELETE'
otp: '',
},
});
const { otp } = useWatch({ control: form.control });
if (!otp) {
return (
<VerifyOtpForm
purpose={'delete-personal-account'}
email={props.email}
onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })}
CancelButton={
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
}
/>
);
}
return (
<Form {...form}>
<form
@@ -84,9 +108,13 @@ function DeleteAccountForm() {
action={deletePersonalAccountAction}
className={'flex flex-col space-y-4'}
>
<input type="hidden" name="otp" value={otp} />
<div className={'flex flex-col space-y-6'}>
<div
className={'border-destructive text-destructive border p-4 text-sm'}
className={
'border-destructive text-destructive rounded-md border p-4 text-sm'
}
>
<div className={'flex flex-col space-y-2'}>
<div>
@@ -98,25 +126,6 @@ function DeleteAccountForm() {
</div>
</div>
</div>
<FormItem>
<FormLabel>
<Trans i18nKey={'account:deleteProfileConfirmationInputLabel'} />
</FormLabel>
<FormControl>
<Input
autoComplete={'off'}
data-test={'delete-account-input-field'}
required
name={'confirmation'}
type={'text'}
className={'w-full'}
placeholder={''}
pattern={`DELETE`}
/>
</FormControl>
</FormItem>
</div>
<AlertDialogFooter>
@@ -124,21 +133,21 @@ function DeleteAccountForm() {
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
<DeleteAccountSubmitButton />
<DeleteAccountSubmitButton disabled={!form.formState.isValid} />
</AlertDialogFooter>
</form>
</Form>
);
}
function DeleteAccountSubmitButton() {
function DeleteAccountSubmitButton(props: { disabled: boolean }) {
const { pending } = useFormStatus();
return (
<Button
data-test={'confirm-delete-account-button'}
type={'submit'}
disabled={pending}
disabled={pending || props.disabled}
name={'action'}
variant={'destructive'}
>
@@ -151,6 +160,20 @@ function DeleteAccountSubmitButton() {
);
}
function DeleteAccountErrorContainer() {
return (
<div className="flex flex-col gap-y-4">
<DeleteAccountErrorAlert />
<div>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
</div>
</div>
);
}
function DeleteAccountErrorAlert() {
return (
<Alert variant={'destructive'}>

View File

@@ -412,8 +412,15 @@ function FactorNameForm(
}
function QrImage({ src }: { src: string }) {
// eslint-disable-next-line @next/next/no-img-element
return <img alt={'QR Code'} src={src} width={160} height={160} className={'p-2 bg-white'} />;
return (
<img
alt={'QR Code'}
src={src}
width={160}
height={160}
className={'bg-white p-2'}
/>
);
}
function useEnrollFactor(userId: string) {

View File

@@ -1,5 +1,5 @@
import { z } from 'zod';
export const DeletePersonalAccountSchema = z.object({
confirmation: z.string().refine((value) => value === 'DELETE'),
otp: z.string().min(6),
});

View File

@@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { enhanceAction } from '@kit/next/actions';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -40,6 +41,12 @@ export const deletePersonalAccountAction = enhanceAction(
userId: user.id,
};
const otp = formData.get('otp') as string;
if (!otp) {
throw new Error('OTP is required');
}
if (!enableAccountDeletion) {
logger.warn(ctx, `Account deletion is not enabled`);
@@ -48,14 +55,33 @@ export const deletePersonalAccountAction = enhanceAction(
logger.info(ctx, `Deleting account...`);
// verify the OTP
const client = getSupabaseServerClient();
const otpApi = createOtpApi(client);
const otpResult = await otpApi.verifyToken({
token: otp,
userId: user.id,
purpose: 'delete-personal-account',
});
if (!otpResult.valid) {
throw new Error('Invalid OTP');
}
// validate the user ID matches the nonce's user ID
if (otpResult.user_id !== user.id) {
logger.error(
ctx,
`This token was meant to be used by a different user. Exiting.`,
);
throw new Error('Nonce mismatch');
}
// create a new instance of the personal accounts service
const service = createDeletePersonalAccountService();
// sign out the user before deleting their account
await client.auth.signOut();
// delete the user's account and cancel all subscriptions
await service.deletePersonalAccount({
adminClient: getSupabaseServerAdminClient(),
@@ -63,6 +89,9 @@ export const deletePersonalAccountAction = enhanceAction(
userEmail: user.email ?? null,
});
// sign out the user after deleting their account
await client.auth.signOut();
logger.info(ctx, `Account request successfully sent`);
// clear the cache for all pages

View File

@@ -47,6 +47,12 @@ class DeletePersonalAccountService {
// execute the deletion of the user
try {
await params.adminClient.auth.admin.deleteUser(userId);
logger.info(ctx, 'User successfully deleted!');
return {
success: true,
};
} catch (error) {
logger.error(
{
@@ -58,7 +64,5 @@ class DeletePersonalAccountService {
throw new Error('Error deleting user');
}
logger.info(ctx, 'User successfully deleted!');
}
}

View File

@@ -26,6 +26,7 @@
"@kit/mailers": "workspace:*",
"@kit/monitoring": "workspace:*",
"@kit/next": "workspace:*",
"@kit/otp": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",

View File

@@ -3,8 +3,10 @@
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { VerifyOtpForm } from '@kit/otp/components';
import { useUser } from '@kit/supabase/hooks/use-user';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
AlertDialog,
@@ -16,17 +18,8 @@ import {
AlertDialogTitle,
} from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { Form } from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans';
import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-ownership-confirmation.schema';
@@ -82,16 +75,44 @@ function TransferOrganizationOwnershipForm({
}) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>();
const { data: user } = useUser();
const form = useForm({
const form = useForm<{
accountId: string;
userId: string;
otp: string;
}>({
resolver: zodResolver(TransferOwnershipConfirmationSchema),
defaultValues: {
confirmation: '',
accountId,
userId,
otp: '',
},
});
const { otp } = useWatch({ control: form.control });
// If no OTP has been entered yet, show the OTP verification form
if (!otp) {
return (
<div className="flex flex-col space-y-6">
<VerifyOtpForm
purpose={`transfer-team-ownership-${accountId}`}
email={user?.email || ''}
onSuccess={(otpValue) => {
form.setValue('otp', otpValue, { shouldValidate: true });
}}
CancelButton={
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
}
data-test="verify-otp-form"
/>
</div>
);
}
return (
<Form {...form}>
<form
@@ -112,43 +133,19 @@ function TransferOrganizationOwnershipForm({
<TransferOwnershipErrorAlert />
</If>
<p>
<Trans
i18nKey={'teams:transferOwnershipDisclaimer'}
values={{
member: targetDisplayName,
}}
components={{ b: <b /> }}
/>
</p>
<div className="border-destructive rounded-md border p-4">
<p className="text-destructive text-sm">
<Trans
i18nKey={'teams:transferOwnershipDisclaimer'}
values={{
member: targetDisplayName,
}}
components={{ b: <b /> }}
/>
</p>
</div>
<FormField
name={'confirmation'}
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:transferOwnershipInputLabel'} />
</FormLabel>
<FormControl>
<Input
autoComplete={'off'}
type={'text'}
required
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:transferOwnershipInputDescription'} />
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<input type="hidden" name="otp" value={otp} />
<div>
<p className={'text-muted-foreground'}>

View File

@@ -3,10 +3,11 @@
import { useFormStatus } from 'react-dom';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import { ErrorBoundary } from '@kit/monitoring/components';
import { VerifyOtpForm } from '@kit/otp/components';
import { useUser } from '@kit/supabase/hooks/use-user';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -61,8 +62,12 @@ export function TeamAccountDangerZone({
// Only the primary owner can delete the team account
const userIsPrimaryOwner = user.id === primaryOwnerUserId;
if (userIsPrimaryOwner && features.enableTeamDeletion) {
return <DeleteTeamContainer account={account} />;
if (userIsPrimaryOwner) {
if (features.enableTeamDeletion) {
return <DeleteTeamContainer account={account} />;
}
return;
}
// A primary owner can't leave the team account
@@ -79,7 +84,7 @@ function DeleteTeamContainer(props: {
return (
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-1'}>
<span className={'font-medium'}>
<span className={'text-sm font-medium'}>
<Trans i18nKey={'teams:deleteTeam'} />
</span>
@@ -139,22 +144,42 @@ function DeleteTeamConfirmationForm({
name: string;
id: string;
}) {
const { data: user } = useUser();
const form = useForm({
mode: 'onChange',
reValidateMode: 'onChange',
resolver: zodResolver(
z.object({
name: z.string().refine((value) => value === name, {
message: 'Name does not match',
path: ['name'],
}),
otp: z.string().min(6).max(6),
}),
),
defaultValues: {
name: ''
otp: '',
},
});
const { otp } = useWatch({ control: form.control });
if (!user?.email) {
return <LoadingOverlay fullPage={false} />;
}
if (!otp) {
return (
<VerifyOtpForm
purpose={`delete-team-account-${id}`}
email={user.email}
onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })}
CancelButton={
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
}
/>
);
}
return (
<ErrorBoundary fallback={<DeleteTeamErrorAlert />}>
<Form {...form}>
@@ -166,8 +191,7 @@ function DeleteTeamConfirmationForm({
<div className={'flex flex-col space-y-2'}>
<div
className={
'border-2 border-red-500 p-4 text-sm text-red-500' +
' my-4 flex flex-col space-y-2'
'border-destructive text-destructive my-4 flex flex-col space-y-2 rounded-md border-2 p-4 text-sm'
}
>
<div>
@@ -185,36 +209,7 @@ function DeleteTeamConfirmationForm({
</div>
<input type="hidden" value={id} name={'accountId'} />
<FormField
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'teams:teamNameInputLabel'} />
</FormLabel>
<FormControl>
<Input
data-test={'delete-team-form-confirm-input'}
required
type={'text'}
autoComplete={'off'}
className={'w-full'}
placeholder={''}
pattern={name}
{...field}
/>
</FormControl>
<FormDescription>
<Trans i18nKey={'teams:deleteTeamInputField'} />
</FormDescription>
<FormMessage />
</FormItem>
)}
name={'confirm'}
/>
<input type="hidden" value={otp} name={'otp'} />
</div>
<AlertDialogFooter>
@@ -260,7 +255,7 @@ function LeaveTeamContainer(props: {
}),
),
defaultValues: {
confirmation: '' as 'LEAVE'
confirmation: '' as 'LEAVE',
},
});
@@ -375,7 +370,7 @@ function LeaveTeamSubmitButton() {
function LeaveTeamErrorAlert() {
return (
<>
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:leaveTeamErrorHeading'} />
@@ -391,20 +386,28 @@ function LeaveTeamErrorAlert() {
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
</AlertDialogFooter>
</>
</div>
);
}
function DeleteTeamErrorAlert() {
return (
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:deleteTeamErrorHeading'} />
</AlertTitle>
<div className={'flex flex-col space-y-4'}>
<Alert variant={'destructive'}>
<AlertTitle>
<Trans i18nKey={'teams:deleteTeamErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common:genericError'} />
</AlertDescription>
</Alert>
<AlertDescription>
<Trans i18nKey={'common:genericError'} />
</AlertDescription>
</Alert>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
</AlertDialogCancel>
</AlertDialogFooter>
</div>
);
}

View File

@@ -2,4 +2,5 @@ import { z } from 'zod';
export const DeleteTeamAccountSchema = z.object({
accountId: z.string().uuid(),
otp: z.string().min(1),
});

View File

@@ -1,9 +1,11 @@
import { z } from 'zod';
const confirmationString = 'TRANSFER';
export const TransferOwnershipConfirmationSchema = z.object({
userId: z.string().uuid(),
confirmation: z.custom((value) => value === confirmationString),
accountId: z.string().uuid(),
userId: z.string().uuid(),
otp: z.string().min(6),
});
export type TransferOwnershipConfirmationData = z.infer<
typeof TransferOwnershipConfirmationSchema
>;

View File

@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
import type { SupabaseClient } from '@supabase/supabase-js';
import { enhanceAction } from '@kit/next/actions';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -23,6 +24,18 @@ export const deleteTeamAccountAction = enhanceAction(
Object.fromEntries(formData.entries()),
);
const otpService = createOtpApi(getSupabaseServerClient());
const otpResult = await otpService.verifyToken({
purpose: `delete-team-account-${params.accountId}`,
userId: user.id,
token: params.otp,
});
if (!otpResult.valid) {
throw new Error('Invalid OTP code');
}
const ctx = {
name: 'team-accounts.delete',
userId: user.id,
@@ -59,7 +72,7 @@ async function deleteTeamAccount(params: {
const service = createDeleteTeamAccountService();
// verify that the user has the necessary permissions to delete the team account
await assertUserPermissionsToDeleteTeamAccount(client, params);
await assertUserPermissionsToDeleteTeamAccount(client, params.accountId);
// delete the team account
await service.deleteTeamAccount(client, params);
@@ -67,20 +80,17 @@ async function deleteTeamAccount(params: {
async function assertUserPermissionsToDeleteTeamAccount(
client: SupabaseClient<Database>,
params: {
accountId: string;
userId: string;
},
accountId: string,
) {
const { data, error } = await client
.from('accounts')
.select('id')
.eq('primary_owner_user_id', params.userId)
.eq('is_personal_account', false)
.eq('id', params.accountId)
const { data: isOwner, error } = await client
.rpc('is_account_owner', {
account_id: accountId,
})
.single();
if (error ?? !data) {
throw new Error('Account not found');
if (error || !isOwner) {
throw new Error('You do not have permission to delete this account');
}
return isOwner;
}

View File

@@ -3,6 +3,8 @@
import { revalidatePath } from 'next/cache';
import { enhanceAction } from '@kit/next/actions';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -61,25 +63,66 @@ export const updateMemberRoleAction = enhanceAction(
/**
* @name transferOwnershipAction
* @description Transfers the ownership of an account to another member.
* Requires OTP verification for security.
*/
export const transferOwnershipAction = enhanceAction(
async (data) => {
async (data, user) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const ctx = {
name: 'teams.transferOwnership',
userId: user.id,
accountId: data.accountId,
};
logger.info(ctx, 'Processing team ownership transfer request...');
// assert that the user is the owner of the account
const { data: isOwner, error } = await client.rpc('is_account_owner', {
account_id: data.accountId,
});
if (error ?? !isOwner) {
if (error || !isOwner) {
logger.error(ctx, 'User is not the owner of this account');
throw new Error(
`You must be the owner of the account to transfer ownership`,
);
}
// Verify the OTP
const otpApi = createOtpApi(client);
const otpResult = await otpApi.verifyToken({
token: data.otp,
userId: user.id,
purpose: `transfer-team-ownership-${data.accountId}`,
});
if (!otpResult.valid) {
logger.error(ctx, 'Invalid OTP provided');
throw new Error('Invalid OTP');
}
// validate the user ID matches the nonce's user ID
if (otpResult.user_id !== user.id) {
logger.error(
ctx,
`This token was meant to be used by a different user. Exiting.`,
);
throw new Error('Nonce mismatch');
}
logger.info(
ctx,
'OTP verification successful. Proceeding with ownership transfer...',
);
const service = createAccountMembersService(client);
// at this point, the user is authenticated and is the owner of the account
// at this point, the user is authenticated, is the owner of the account, and has verified via OTP
// so we proceed with the transfer of ownership with admin privileges
const adminClient = getSupabaseServerAdminClient();
@@ -89,6 +132,8 @@ export const transferOwnershipAction = enhanceAction(
// revalidate all pages that depend on the account
revalidatePath('/home/[account]', 'layout');
logger.info(ctx, 'Team ownership transferred successfully');
return {
success: true,
};

3
packages/otp/README.md Normal file
View File

@@ -0,0 +1,3 @@
# One-Time Password (OTP) - @kit/otp
This package provides a service for working with one-time passwords and tokens in Supabase.

View File

@@ -0,0 +1,3 @@
import baseConfig from '@kit/eslint-config/base.js';
export default baseConfig;

43
packages/otp/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "@kit/otp",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/api/index.ts",
"./components": "./src/components/index.ts"
},
"devDependencies": {
"@hookform/resolvers": "^4.1.1",
"@kit/email-templates": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/mailers": "workspace:*",
"@kit/next": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@radix-ui/react-icons": "^1.3.2",
"@supabase/supabase-js": "2.48.1",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hook-form": "^7.54.2",
"zod": "^3.24.2"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -0,0 +1,117 @@
/**
* @file API for one-time passwords/tokens
*
* Usage
*
* ```typescript
* import { createOtpApi } from '@kit/otp/api';
* import { getSupabaseServerClient } from '@kit/supabase/server-client';
* import { NoncePurpose } from '@kit/otp/types';
*
* const client = getSupabaseServerClient();
* const api = createOtpApi(client);
*
* // Create a one-time password token
* const { token } = await api.createToken({
* userId: user.id,
* purpose: NoncePurpose.PASSWORD_RESET, // Or use a custom string like 'password-reset'
* expiresInSeconds: 3600, // 1 hour
* metadata: { redirectTo: '/reset-password' },
* });
*
* // Verify a token
* const result = await api.verifyToken({
* token: '...',
* purpose: NoncePurpose.PASSWORD_RESET, // Must match the purpose used when creating
* });
*
* if (result.valid) {
* // Token is valid
* const { userId, metadata } = result;
* // Proceed with the operation
* } else {
* // Token is invalid or expired
* }
* ```
*/
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
import { createOtpEmailService } from '../server/otp-email.service';
import { createOtpService } from '../server/otp.service';
import {
CreateNonceParams,
GetNonceStatusParams,
RevokeNonceParams,
SendOtpEmailParams,
VerifyNonceParams,
} from '../types';
/**
* @name createOtpApi
* @description Create an instance of the OTP API
* @param client
*/
export function createOtpApi(client: SupabaseClient<Database>) {
return new OtpApi(client);
}
/**
* @name OtpApi
* @description API for working with one-time tokens/passwords
*/
class OtpApi {
private readonly service: ReturnType<typeof createOtpService>;
private readonly emailService: ReturnType<typeof createOtpEmailService>;
constructor(client: SupabaseClient<Database>) {
this.service = createOtpService(client);
this.emailService = createOtpEmailService();
}
/**
* @name sendOtpEmail
* @description Sends an OTP email to the user
* @param params
*/
sendOtpEmail(params: SendOtpEmailParams) {
return this.emailService.sendOtpEmail(params);
}
/**
* @name createToken
* @description Creates a new one-time token
* @param params
*/
createToken(params: CreateNonceParams) {
return this.service.createNonce(params);
}
/**
* @name verifyToken
* @description Verifies a one-time token
* @param params
*/
verifyToken(params: VerifyNonceParams) {
return this.service.verifyNonce(params);
}
/**
* @name revokeToken
* @description Revokes a one-time token to prevent its use
* @param params
*/
revokeToken(params: RevokeNonceParams) {
return this.service.revokeNonce(params);
}
/**
* @name getTokenStatus
* @description Gets the status of a one-time token
* @param params
*/
getTokenStatus(params: GetNonceStatusParams) {
return this.service.getNonceStatus(params);
}
}

View File

@@ -0,0 +1 @@
export { VerifyOtpForm } from './verify-otp-form';

View File

@@ -0,0 +1,257 @@
'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { Button } from '@kit/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from '@kit/ui/input-otp';
import { Spinner } from '@kit/ui/spinner';
import { Trans } from '@kit/ui/trans';
import { sendOtpEmailAction } from '../server/server-actions';
// Email form schema
const SendOtpSchema = z.object({
email: z.string().email({ message: 'Please enter a valid email address' }),
});
// OTP verification schema
const VerifyOtpSchema = z.object({
otp: z.string().min(6, { message: 'Please enter a valid OTP code' }).max(6),
});
type VerifyOtpFormProps = {
// Purpose of the OTP (e.g., 'email-verification', 'password-reset')
purpose: string;
// Callback when OTP is successfully verified
onSuccess: (otp: string) => void;
// Email address to send the OTP to
email: string;
// Customize form appearance
className?: string;
// Optional cancel button
CancelButton?: React.ReactNode;
};
export function VerifyOtpForm({
purpose,
email,
className,
CancelButton,
onSuccess,
}: VerifyOtpFormProps) {
// Track the current step (email entry or OTP verification)
const [step, setStep] = useState<'email' | 'otp'>('email');
const [isPending, startTransition] = useTransition();
// Track errors
const [error, setError] = useState<string | null>(null);
// Track verification success
const [, setVerificationSuccess] = useState(false);
// Email form
const emailForm = useForm<z.infer<typeof SendOtpSchema>>({
resolver: zodResolver(SendOtpSchema),
defaultValues: {
email,
},
});
// OTP verification form
const otpForm = useForm<z.infer<typeof VerifyOtpSchema>>({
resolver: zodResolver(VerifyOtpSchema),
defaultValues: {
otp: '',
},
});
// Handle sending OTP email
const handleSendOtp = () => {
setError(null);
startTransition(async () => {
try {
const result = await sendOtpEmailAction({
purpose,
email,
});
if (result.success) {
setStep('otp');
} else {
setError(result.error || 'Failed to send OTP. Please try again.');
}
} catch (err) {
setError('An unexpected error occurred. Please try again.');
console.error('Error sending OTP:', err);
}
});
};
// Handle OTP verification
const handleVerifyOtp = (data: z.infer<typeof VerifyOtpSchema>) => {
setVerificationSuccess(true);
onSuccess(data.otp);
};
return (
<div className={className}>
{step === 'email' ? (
<Form {...emailForm}>
<form
className="flex flex-col gap-y-8"
onSubmit={emailForm.handleSubmit(handleSendOtp)}
>
<div className="flex flex-col gap-y-2">
<p className="text-muted-foreground text-sm">
<Trans
i18nKey="common:otp.requestVerificationCodeDescription"
values={{ email }}
/>
</p>
</div>
<If condition={Boolean(error)}>
<Alert variant="destructive">
<ExclamationTriangleIcon className="h-4 w-4" />
<AlertTitle>
<Trans i18nKey="common:otp.errorSendingCode" />
</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</If>
<div className="flex w-full justify-end gap-2">
{CancelButton}
<Button
type="submit"
disabled={isPending}
data-test="otp-send-verification-button"
>
{isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" />
<Trans i18nKey="common:otp.sendingCode" />
</>
) : (
<Trans i18nKey="common:otp.sendVerificationCode" />
)}
</Button>
</div>
</form>
</Form>
) : (
<Form {...otpForm}>
<div className="flex w-full flex-col items-center gap-y-8">
<div className="text-muted-foreground text-sm">
<Trans i18nKey="common:otp.codeSentToEmail" values={{ email }} />
</div>
<form
className="flex w-full flex-col items-center space-y-8"
onSubmit={otpForm.handleSubmit(handleVerifyOtp)}
>
<If condition={Boolean(error)}>
<Alert variant="destructive">
<ExclamationTriangleIcon className="h-4 w-4" />
<AlertTitle>
<Trans i18nKey="common:error" />
</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</If>
<FormField
name="otp"
control={otpForm.control}
render={({ field }) => (
<FormItem>
<FormControl>
<InputOTP
maxLength={6}
{...field}
disabled={isPending}
data-test="otp-input"
>
<InputOTPGroup>
<InputOTPSlot index={0} data-slot="0" />
<InputOTPSlot index={1} data-slot="1" />
<InputOTPSlot index={2} data-slot="2" />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} data-slot="3" />
<InputOTPSlot index={4} data-slot="4" />
<InputOTPSlot index={5} data-slot="5" />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormDescription>
<Trans i18nKey="common:otp.enterCodeFromEmail" />
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full justify-between gap-2">
{CancelButton}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
disabled={isPending}
onClick={() => setStep('email')}
>
<Trans i18nKey="common:otp.requestNewCode" />
</Button>
<Button
type="submit"
disabled={isPending}
data-test="otp-verify-button"
>
{isPending ? (
<>
<Spinner className="mr-2 h-4 w-4" />
<Trans i18nKey="common:otp.verifying" />
</>
) : (
<Trans i18nKey="common:otp.verifyCode" />
)}
</Button>
</div>
</div>
</form>
</div>
</Form>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export * from './otp.service';

View File

@@ -0,0 +1,62 @@
import { z } from 'zod';
import { renderOtpEmail } from '@kit/email-templates';
import { getMailer } from '@kit/mailers';
import { getLogger } from '@kit/shared/logger';
const EMAIL_SENDER = z
.string({
required_error: 'EMAIL_SENDER is required',
})
.min(1)
.parse(process.env.EMAIL_SENDER);
const PRODUCT_NAME = z
.string({
required_error: 'PRODUCT_NAME is required',
})
.min(1)
.parse(process.env.NEXT_PUBLIC_PRODUCT_NAME);
/**
* @name createOtpEmailService
* @description Creates a new OtpEmailService
* @returns {OtpEmailService}
*/
export function createOtpEmailService() {
return new OtpEmailService();
}
/**
* @name OtpEmailService
* @description Service for sending OTP emails
*/
class OtpEmailService {
async sendOtpEmail(params: { email: string; otp: string }) {
const logger = await getLogger();
const { email, otp } = params;
const mailer = await getMailer();
const { html, subject } = await renderOtpEmail({
otp,
productName: PRODUCT_NAME,
});
try {
logger.info({ otp }, 'Sending OTP email...');
await mailer.sendEmail({
to: email,
subject,
html,
from: EMAIL_SENDER,
});
logger.info({ otp }, 'OTP email sent');
} catch (error) {
logger.error({ otp, error }, 'Error sending OTP email');
throw error;
}
}
}

View File

@@ -0,0 +1,267 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import { Database, Json } from '@kit/supabase/database';
import {
CreateNonceParams,
CreateNonceResult,
GetNonceStatusParams,
GetNonceStatusResult,
RevokeNonceParams,
VerifyNonceParams,
VerifyNonceResult,
} from '../types';
/**
* @name createOtpService
* @description Creates an instance of the OtpService
* @param client
*/
export function createOtpService(client: SupabaseClient<Database>) {
return new OtpService(client);
}
// Type declarations for RPC parameters
type CreateNonceRpcParams = {
p_user_id?: string;
p_purpose?: string;
p_expires_in_seconds?: number;
p_metadata?: Json;
p_description?: string;
p_tags?: string[];
p_scopes?: string[];
p_revoke_previous?: boolean;
};
type VerifyNonceRpcParams = {
p_token: string;
p_purpose: string;
p_required_scopes?: string[];
p_max_verification_attempts?: number;
};
/**
* @name OtpService
* @description Service for creating and verifying one-time tokens/passwords
*/
class OtpService {
constructor(private readonly client: SupabaseClient<Database>) {}
/**
* @name createNonce
* @description Creates a new one-time token for a user
* @param params
*/
async createNonce(params: CreateNonceParams) {
const logger = await getLogger();
const {
userId,
purpose,
expiresInSeconds = 3600,
metadata = {},
description,
tags,
scopes,
revokePrevious = true,
} = params;
const ctx = { userId, purpose, name: 'nonce' };
logger.info(ctx, 'Creating one-time token');
try {
const result = await this.client.rpc('create_nonce', {
p_user_id: userId,
p_purpose: purpose,
p_expires_in_seconds: expiresInSeconds,
p_metadata: metadata as Json,
p_description: description,
p_tags: tags,
p_scopes: scopes,
p_revoke_previous: revokePrevious,
} as CreateNonceRpcParams);
if (result.error) {
logger.error(
{ ...ctx, error: result.error.message },
'Failed to create one-time token',
);
throw new Error(
`Failed to create one-time token: ${result.error.message}`,
);
}
const data = result.data as unknown as CreateNonceResult;
logger.info(
{ ...ctx, revokedPreviousCount: data.revoked_previous_count },
'One-time token created successfully',
);
return {
id: data.id,
token: data.token,
expiresAt: data.expires_at,
revokedPreviousCount: data.revoked_previous_count,
};
} catch (error) {
logger.error({ ...ctx, error }, 'Error creating one-time token');
throw error;
}
}
/**
* @name verifyNonce
* @description Verifies a one-time token
* @param params
*/
async verifyNonce(params: VerifyNonceParams) {
const logger = await getLogger();
const { token, purpose, requiredScopes, maxVerificationAttempts } = params;
const ctx = { purpose, name: 'verify-nonce' };
logger.info(ctx, 'Verifying one-time token');
try {
const result = await this.client.rpc('verify_nonce', {
p_token: token,
p_user_id: params.userId,
p_purpose: purpose,
p_required_scopes: requiredScopes,
p_max_verification_attempts: maxVerificationAttempts,
} as VerifyNonceRpcParams);
if (result.error) {
logger.error(
{ ...ctx, error: result.error.message },
'Failed to verify one-time token',
);
throw new Error(
`Failed to verify one-time token: ${result.error.message}`,
);
}
const data = result.data as unknown as VerifyNonceResult;
logger.info(
{
...ctx,
...data,
},
'One-time token verification complete',
);
return data;
} catch (error) {
logger.error({ ...ctx, error }, 'Error verifying one-time token');
throw error;
}
}
/**
* @name revokeNonce
* @description Revokes a one-time token to prevent its use
* @param params
*/
async revokeNonce(params: RevokeNonceParams) {
const logger = await getLogger();
const { id, reason } = params;
const ctx = { id, reason, name: 'revoke-nonce' };
logger.info(ctx, 'Revoking one-time token');
try {
const { data, error } = await this.client.rpc('revoke_nonce', {
p_id: id,
p_reason: reason,
});
if (error) {
logger.error(
{ ...ctx, error: error.message },
'Failed to revoke one-time token',
);
throw new Error(`Failed to revoke one-time token: ${error.message}`);
}
logger.info(
{ ...ctx, success: data },
'One-time token revocation complete',
);
return {
success: data,
};
} catch (error) {
logger.error({ ...ctx, error }, 'Error revoking one-time token');
throw error;
}
}
/**
* @name getNonceStatus
* @description Gets the status of a one-time token
* @param params
*/
async getNonceStatus(params: GetNonceStatusParams) {
const logger = await getLogger();
const { id } = params;
const ctx = { id, name: 'get-nonce-status' };
logger.info(ctx, 'Getting one-time token status');
try {
const result = await this.client.rpc('get_nonce_status', {
p_id: id,
});
if (result.error) {
logger.error(
{ ...ctx, error: result.error.message },
'Failed to get one-time token status',
);
throw new Error(
`Failed to get one-time token status: ${result.error.message}`,
);
}
const data = result.data as unknown as GetNonceStatusResult;
logger.info(
{ ...ctx, exists: data.exists },
'Retrieved one-time token status',
);
if (!data.exists) {
return {
exists: false,
};
}
return {
exists: data.exists,
purpose: data.purpose,
userId: data.user_id,
createdAt: data.created_at,
expiresAt: data.expires_at,
usedAt: data.used_at,
revoked: data.revoked,
revokedReason: data.revoked_reason,
verificationAttempts: data.verification_attempts,
lastVerificationAt: data.last_verification_at,
lastVerificationIp: data.last_verification_ip,
isValid: data.is_valid,
};
} catch (error) {
logger.error({ ...ctx, error }, 'Error getting one-time token status');
throw error;
}
}
}

View File

@@ -0,0 +1,88 @@
'use server';
import { z } from 'zod';
import { enhanceAction } from '@kit/next/actions';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { createOtpApi } from '../api';
// Schema for sending OTP email
const SendOtpEmailSchema = z.object({
email: z.string().email({ message: 'Please enter a valid email address' }),
// Purpose of the OTP (e.g., 'email-verification', 'password-reset')
purpose: z.string().min(1).max(1000),
// how long the OTP should be valid for. Defaults to 1 hour. Max is 7 days. Min is 30 seconds.
expiresInSeconds: z.number().min(30).max(86400 * 7).default(3600).optional(),
});
/**
* Server action to generate an OTP and send it via email
*/
export const sendOtpEmailAction = enhanceAction(
async function (data: z.infer<typeof SendOtpEmailSchema>, user) {
const logger = await getLogger();
const ctx = { name: 'send-otp-email', userId: user.id };
const email = user.email;
// validate edge case where user has no email
if (!email) {
throw new Error('User has no email. OTP verification is not possible.');
}
// validate edge case where email is not the same as the one provided
// this is highly unlikely to happen, but we want to make sure the client-side code is correct in
// sending the correct user email
if (data.email !== email) {
throw new Error('User email does not match the email provided. This is likely an error in the client.');
}
try {
const { purpose, expiresInSeconds } = data;
logger.info(
{ ...ctx, email, purpose },
'Creating OTP token and sending email',
);
const client = getSupabaseServerAdminClient();
const otpApi = createOtpApi(client);
// Create a token that will be verified later
const tokenResult = await otpApi.createToken({
userId: user.id,
purpose,
expiresInSeconds,
});
// Send the email with the OTP
await otpApi.sendOtpEmail({
email,
otp: tokenResult.token,
});
logger.info(
{ ...ctx, tokenId: tokenResult.id },
'OTP email sent successfully',
);
return {
success: true,
tokenId: tokenResult.id,
};
} catch (error) {
logger.error({ ...ctx, error }, 'Failed to send OTP email');
return {
success: false,
error:
error instanceof Error ? error.message : 'Failed to send OTP email',
};
}
},
{
schema: SendOtpEmailSchema,
auth: true,
},
);

View File

@@ -0,0 +1,115 @@
/**
* @name CreateNonceParams - Parameters for creating a nonce
*/
export interface CreateNonceParams {
userId?: string;
purpose: string;
expiresInSeconds?: number;
metadata?: Record<string, unknown>;
description?: string;
tags?: string[];
scopes?: string[];
revokePrevious?: boolean;
}
/**
* @name VerifyNonceParams - Parameters for verifying a nonce
*/
export interface VerifyNonceParams {
token: string;
purpose: string;
userId?: string;
requiredScopes?: string[];
maxVerificationAttempts?: number;
}
/**
* @name RevokeNonceParams - Parameters for revoking a nonce
*/
export interface RevokeNonceParams {
id: string;
reason?: string;
}
/**
* @name CreateNonceResult - Result of creating a nonce
*/
export interface CreateNonceResult {
id: string;
token: string;
expires_at: string;
revoked_previous_count?: number;
}
/**
* @name ValidNonceResult - Result of verifying a nonce
*/
type ValidNonceResult = {
valid: boolean;
user_id?: string;
metadata?: Record<string, unknown>;
message?: string;
scopes?: string[];
purpose?: string;
};
/**
* @name InvalidNonceResult - Result of verifying a nonce
*/
type InvalidNonceResult = {
valid: false;
message: string;
max_attempts_exceeded?: boolean;
};
/**
* @name VerifyNonceResult - Result of verifying a nonce
*/
export type VerifyNonceResult = ValidNonceResult | InvalidNonceResult;
/**
* @name GetNonceStatusParams - Parameters for getting nonce status
*/
export interface GetNonceStatusParams {
id: string;
}
/**
* @name SuccessGetNonceStatusResult - Result of getting nonce status
*/
type SuccessGetNonceStatusResult = {
exists: true;
purpose?: string;
user_id?: string;
created_at?: string;
expires_at?: string;
used_at?: string;
revoked?: boolean;
revoked_reason?: string;
verification_attempts?: number;
last_verification_at?: string;
last_verification_ip?: string;
is_valid?: boolean;
};
/**
* @name FailedGetNonceStatusResult - Result of getting nonce status
*/
type FailedGetNonceStatusResult = {
exists: false;
};
/**
* @name GetNonceStatusResult - Result of getting nonce status
*/
export type GetNonceStatusResult =
| SuccessGetNonceStatusResult
| FailedGetNonceStatusResult;
/**
* @name SendOtpEmailParams - Parameters for sending an OTP email
*/
export interface SendOtpEmailParams {
email: string;
otp: string;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -8,8 +8,12 @@ export type Json =
export type Database = {
graphql_public: {
Tables: Record<never, never>;
Views: Record<never, never>;
Tables: {
[_ in never]: never;
};
Views: {
[_ in never]: never;
};
Functions: {
graphql: {
Args: {
@@ -21,8 +25,12 @@ export type Database = {
Returns: Json;
};
};
Enums: Record<never, never>;
CompositeTypes: Record<never, never>;
Enums: {
[_ in never]: never;
};
CompositeTypes: {
[_ in never]: never;
};
};
public: {
Tables: {
@@ -69,29 +77,7 @@ export type Database = {
updated_at?: string | null;
updated_by?: string | null;
};
Relationships: [
{
foreignKeyName: 'accounts_created_by_fkey';
columns: ['created_by'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
{
foreignKeyName: 'accounts_primary_owner_user_id_fkey';
columns: ['primary_owner_user_id'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
{
foreignKeyName: 'accounts_updated_by_fkey';
columns: ['updated_by'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
];
Relationships: [];
};
accounts_memberships: {
Row: {
@@ -150,27 +136,6 @@ export type Database = {
referencedRelation: 'roles';
referencedColumns: ['name'];
},
{
foreignKeyName: 'accounts_memberships_created_by_fkey';
columns: ['created_by'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
{
foreignKeyName: 'accounts_memberships_updated_by_fkey';
columns: ['updated_by'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
{
foreignKeyName: 'accounts_memberships_user_id_fkey';
columns: ['user_id'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
];
};
billing_customers: {
@@ -296,13 +261,6 @@ export type Database = {
referencedRelation: 'user_accounts';
referencedColumns: ['id'];
},
{
foreignKeyName: 'invitations_invited_by_fkey';
columns: ['invited_by'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
{
foreignKeyName: 'invitations_role_fkey';
columns: ['role'];
@@ -312,6 +270,69 @@ export type Database = {
},
];
};
nonces: {
Row: {
client_token: string;
created_at: string;
description: string | null;
expires_at: string;
id: string;
last_verification_at: string | null;
last_verification_ip: unknown | null;
last_verification_user_agent: string | null;
metadata: Json | null;
nonce: string;
purpose: string;
revoked: boolean;
revoked_reason: string | null;
scopes: string[] | null;
tags: string[] | null;
used_at: string | null;
user_id: string | null;
verification_attempts: number;
};
Insert: {
client_token: string;
created_at?: string;
description?: string | null;
expires_at: string;
id?: string;
last_verification_at?: string | null;
last_verification_ip?: unknown | null;
last_verification_user_agent?: string | null;
metadata?: Json | null;
nonce: string;
purpose: string;
revoked?: boolean;
revoked_reason?: string | null;
scopes?: string[] | null;
tags?: string[] | null;
used_at?: string | null;
user_id?: string | null;
verification_attempts?: number;
};
Update: {
client_token?: string;
created_at?: string;
description?: string | null;
expires_at?: string;
id?: string;
last_verification_at?: string | null;
last_verification_ip?: unknown | null;
last_verification_user_agent?: string | null;
metadata?: Json | null;
nonce?: string;
purpose?: string;
revoked?: boolean;
revoked_reason?: string | null;
scopes?: string[] | null;
tags?: string[] | null;
used_at?: string | null;
user_id?: string | null;
verification_attempts?: number;
};
Relationships: [];
};
notifications: {
Row: {
account_id: string;
@@ -719,6 +740,19 @@ export type Database = {
updated_at: string;
};
};
create_nonce: {
Args: {
p_user_id?: string;
p_purpose?: string;
p_expires_in_seconds?: number;
p_metadata?: Json;
p_description?: string;
p_tags?: string[];
p_scopes?: string[];
p_revoke_previous?: boolean;
};
Returns: Json;
};
create_team_account: {
Args: {
account_name: string;
@@ -777,6 +811,12 @@ export type Database = {
Args: Record<PropertyKey, never>;
Returns: Json;
};
get_nonce_status: {
Args: {
p_id: string;
};
Returns: Json;
};
get_upper_system_role: {
Args: Record<PropertyKey, never>;
Returns: string;
@@ -843,6 +883,13 @@ export type Database = {
};
Returns: boolean;
};
revoke_nonce: {
Args: {
p_id: string;
p_reason?: string;
};
Returns: boolean;
};
team_account_workspace: {
Args: {
account_slug: string;
@@ -922,6 +969,18 @@ export type Database = {
updated_at: string;
};
};
verify_nonce: {
Args: {
p_token: string;
p_purpose: string;
p_user_id?: string;
p_required_scopes?: string[];
p_max_verification_attempts?: number;
p_ip?: unknown;
p_user_agent?: string;
};
Returns: Json;
};
};
Enums: {
app_permissions:
@@ -1026,6 +1085,7 @@ export type Database = {
owner_id: string | null;
path_tokens: string[] | null;
updated_at: string | null;
user_metadata: Json | null;
version: string | null;
};
Insert: {
@@ -1039,6 +1099,7 @@ export type Database = {
owner_id?: string | null;
path_tokens?: string[] | null;
updated_at?: string | null;
user_metadata?: Json | null;
version?: string | null;
};
Update: {
@@ -1052,6 +1113,7 @@ export type Database = {
owner_id?: string | null;
path_tokens?: string[] | null;
updated_at?: string | null;
user_metadata?: Json | null;
version?: string | null;
};
Relationships: [
@@ -1073,6 +1135,7 @@ export type Database = {
key: string;
owner_id: string | null;
upload_signature: string;
user_metadata: Json | null;
version: string;
};
Insert: {
@@ -1083,6 +1146,7 @@ export type Database = {
key: string;
owner_id?: string | null;
upload_signature: string;
user_metadata?: Json | null;
version: string;
};
Update: {
@@ -1093,6 +1157,7 @@ export type Database = {
key?: string;
owner_id?: string | null;
upload_signature?: string;
user_metadata?: Json | null;
version?: string;
};
Relationships: [
@@ -1160,7 +1225,9 @@ export type Database = {
];
};
};
Views: Record<never, never>;
Views: {
[_ in never]: never;
};
Functions: {
can_insert_object: {
Args: {
@@ -1227,6 +1294,10 @@ export type Database = {
updated_at: string;
}[];
};
operation: {
Args: Record<PropertyKey, never>;
Returns: string;
};
search: {
Args: {
prefix: string;
@@ -1248,8 +1319,12 @@ export type Database = {
}[];
};
};
Enums: Record<never, never>;
CompositeTypes: Record<never, never>;
Enums: {
[_ in never]: never;
};
CompositeTypes: {
[_ in never]: never;
};
};
};
@@ -1334,3 +1409,18 @@ export type Enums<
: PublicEnumNameOrOptions extends keyof PublicSchema['Enums']
? PublicSchema['Enums'][PublicEnumNameOrOptions]
: never;
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof PublicSchema['CompositeTypes']
| { schema: keyof Database },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database;
}
? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes']
: never = never,
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema['CompositeTypes']
? PublicSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions]
: never;

View File

@@ -47,9 +47,10 @@ const AlertTitle: React.FC<React.ComponentPropsWithRef<'h5'>> = ({
);
AlertTitle.displayName = 'AlertTitle';
const AlertDescription: React.FC<
React.ComponentPropsWithRef<'div'>
> = ({ className, ...props }) => (
const AlertDescription: React.FC<React.ComponentPropsWithRef<'div'>> = ({
className,
...props
}) => (
<div
className={cn('text-sm font-normal [&_p]:leading-relaxed', className)}
{...props}

View File

@@ -18,7 +18,7 @@ const Switch: React.FC<
>
<SwitchPrimitives.Thumb
className={cn(
'bg-background pointer-events-none block h-4 w-4 rounded-full ring-0 shadow-lg transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
'bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>