B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

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

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