B2B-88: add starter kit structure and elements
This commit is contained in:
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,
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user