B2B-88: add starter kit structure and elements
This commit is contained in:
3
packages/otp/README.md
Normal file
3
packages/otp/README.md
Normal 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.
|
||||
3
packages/otp/eslint.config.mjs
Normal file
3
packages/otp/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '@kit/eslint-config/base.js';
|
||||
|
||||
export default baseConfig;
|
||||
1
packages/otp/node_modules/@hookform/resolvers
generated
vendored
Symbolic link
1
packages/otp/node_modules/@hookform/resolvers
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/@hookform+resolvers@5.0.1_react-hook-form@7.57.0_react@19.1.0_/node_modules/@hookform/resolvers
|
||||
1
packages/otp/node_modules/@kit/email-templates
generated
vendored
Symbolic link
1
packages/otp/node_modules/@kit/email-templates
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../email-templates
|
||||
1
packages/otp/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
1
packages/otp/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/eslint
|
||||
1
packages/otp/node_modules/@kit/mailers
generated
vendored
Symbolic link
1
packages/otp/node_modules/@kit/mailers
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../mailers/core
|
||||
1
packages/otp/node_modules/@kit/next
generated
vendored
Symbolic link
1
packages/otp/node_modules/@kit/next
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../next
|
||||
1
packages/otp/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
1
packages/otp/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/prettier
|
||||
1
packages/otp/node_modules/@kit/shared
generated
vendored
Symbolic link
1
packages/otp/node_modules/@kit/shared
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../shared
|
||||
1
packages/otp/node_modules/@kit/supabase
generated
vendored
Symbolic link
1
packages/otp/node_modules/@kit/supabase
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../supabase
|
||||
1
packages/otp/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
1
packages/otp/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/typescript
|
||||
1
packages/otp/node_modules/@kit/ui
generated
vendored
Symbolic link
1
packages/otp/node_modules/@kit/ui
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../ui
|
||||
1
packages/otp/node_modules/@radix-ui/react-icons
generated
vendored
Symbolic link
1
packages/otp/node_modules/@radix-ui/react-icons
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/@radix-ui+react-icons@1.3.2_react@19.1.0/node_modules/@radix-ui/react-icons
|
||||
1
packages/otp/node_modules/@supabase/supabase-js
generated
vendored
Symbolic link
1
packages/otp/node_modules/@supabase/supabase-js
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/@supabase+supabase-js@2.49.4/node_modules/@supabase/supabase-js
|
||||
1
packages/otp/node_modules/@types/react
generated
vendored
Symbolic link
1
packages/otp/node_modules/@types/react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react
|
||||
1
packages/otp/node_modules/@types/react-dom
generated
vendored
Symbolic link
1
packages/otp/node_modules/@types/react-dom
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/@types+react-dom@19.1.5_@types+react@19.1.4/node_modules/@types/react-dom
|
||||
1
packages/otp/node_modules/react
generated
vendored
Symbolic link
1
packages/otp/node_modules/react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../node_modules/.pnpm/react@19.1.0/node_modules/react
|
||||
1
packages/otp/node_modules/react-dom
generated
vendored
Symbolic link
1
packages/otp/node_modules/react-dom
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom
|
||||
1
packages/otp/node_modules/react-hook-form
generated
vendored
Symbolic link
1
packages/otp/node_modules/react-hook-form
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../node_modules/.pnpm/react-hook-form@7.57.0_react@19.1.0/node_modules/react-hook-form
|
||||
1
packages/otp/node_modules/zod
generated
vendored
Symbolic link
1
packages/otp/node_modules/zod
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod
|
||||
43
packages/otp/package.json
Normal file
43
packages/otp/package.json
Normal 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": "^5.0.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.49.4",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/react-dom": "19.1.5",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.56.3",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
117
packages/otp/src/api/index.ts
Normal file
117
packages/otp/src/api/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1
packages/otp/src/components/index.ts
Normal file
1
packages/otp/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { VerifyOtpForm } from './verify-otp-form';
|
||||
257
packages/otp/src/components/verify-otp-form.tsx
Normal file
257
packages/otp/src/components/verify-otp-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
packages/otp/src/server/index.ts
Normal file
1
packages/otp/src/server/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './otp.service';
|
||||
62
packages/otp/src/server/otp-email.service.ts
Normal file
62
packages/otp/src/server/otp-email.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
275
packages/otp/src/server/otp.service.ts
Normal file
275
packages/otp/src/server/otp.service.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
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 = 900,
|
||||
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 = 1,
|
||||
} = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
95
packages/otp/src/server/server-actions.ts
Normal file
95
packages/otp/src/server/server-actions.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
'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,
|
||||
},
|
||||
);
|
||||
115
packages/otp/src/types/index.ts
Normal file
115
packages/otp/src/types/index.ts
Normal 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;
|
||||
}
|
||||
8
packages/otp/tsconfig.json
Normal file
8
packages/otp/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user