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

@@ -48,6 +48,7 @@ export default async function EmailPage(props: EmailPageProps) {
'change-email-address-email': 'Change Email Address Email', 'change-email-address-email': 'Change Email Address Email',
'reset-password-email': 'Reset Password Email', 'reset-password-email': 'Reset Password Email',
'magic-link-email': 'Magic Link Email', 'magic-link-email': 'Magic Link Email',
'otp-email': 'OTP Email',
}} }}
/> />
} }

View File

@@ -1,45 +1,48 @@
import { import {
renderAccountDeleteEmail, renderAccountDeleteEmail,
renderInviteEmail, renderInviteEmail,
renderOtpEmail,
} from '@kit/email-templates'; } from '@kit/email-templates';
export async function loadEmailTemplate(id: string) { export async function loadEmailTemplate(id: string) {
if (id === 'account-delete-email') { switch (id) {
return renderAccountDeleteEmail({ case 'account-delete-email':
productName: 'Makerkit', return renderAccountDeleteEmail({
userDisplayName: 'Giancarlo', productName: 'Makerkit',
}); userDisplayName: 'Giancarlo',
} });
if (id === 'invite-email') { case 'invite-email':
return renderInviteEmail({ return renderInviteEmail({
teamName: 'Makerkit', teamName: 'Makerkit',
teamLogo: teamLogo: '',
'', inviter: 'Giancarlo',
inviter: 'Giancarlo', invitedUserEmail: 'test@makerkit.dev',
invitedUserEmail: 'test@makerkit.dev', link: 'https://makerkit.dev',
link: 'https://makerkit.dev', productName: 'Makerkit',
productName: 'Makerkit', });
});
}
if (id === 'magic-link-email') { case 'otp-email':
return loadFromFileSystem('magic-link'); return renderOtpEmail({
} productName: 'Makerkit',
otp: '123456',
});
if (id === 'reset-password-email') { case 'magic-link-email':
return loadFromFileSystem('reset-password'); return loadFromFileSystem('magic-link');
}
if (id === 'change-email-address-email') { case 'reset-password-email':
return loadFromFileSystem('change-email-address'); return loadFromFileSystem('reset-password');
}
if (id === 'confirm-email') { case 'change-email-address-email':
return loadFromFileSystem('confirm-email'); return loadFromFileSystem('change-email-address');
}
throw new Error(`Email template not found: ${id}`); case 'confirm-email':
return loadFromFileSystem('confirm-email');
default:
throw new Error(`Email template not found: ${id}`);
}
} }
async function loadFromFileSystem(fileName: string) { async function loadFromFileSystem(fileName: string) {

View File

@@ -75,6 +75,14 @@ export default async function EmailsPage() {
</CardButtonHeader> </CardButtonHeader>
</Link> </Link>
</CardButton> </CardButton>
<CardButton asChild>
<Link href={'/emails/otp-email'}>
<CardButtonHeader>
<CardButtonTitle>OTP Email</CardButtonTitle>
</CardButtonHeader>
</Link>
</CardButton>
</div> </div>
</div> </div>
</PageBody> </PageBody>

View File

@@ -7,7 +7,7 @@ const testIgnore: string[] = [];
if (!enableBillingTests) { if (!enableBillingTests) {
console.log( console.log(
`Billing tests are disabled. To enable them, set the environment variable ENABLE_BILLING_TESTS=true.`, `Billing tests are disabled. To enable them, set the environment variable ENABLE_BILLING_TESTS=true.`,
`Current value: "${process.env.ENABLE_BILLING_TESTS}"` `Current value: "${process.env.ENABLE_BILLING_TESTS}"`,
); );
testIgnore.push('*-billing.spec.ts'); testIgnore.push('*-billing.spec.ts');
@@ -45,15 +45,14 @@ export default defineConfig({
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry',
navigationTimeout: 5000,
}, },
// test timeout set to 1 minutes // test timeout set to 1 minutes
timeout: 60 * 1000, timeout: 60 * 1000,
expect: { expect: {
// expect timeout set to 10 seconds // expect timeout set to 10 seconds
timeout: 10 * 1000, timeout: 10 * 1000,
}, },
/* Configure projects for major browsers */ /* Configure projects for major browsers */
projects: [ projects: [
{ {

View File

@@ -1,14 +1,17 @@
import { Page, expect } from '@playwright/test'; import { Page, expect } from '@playwright/test';
import { AuthPageObject } from '../authentication/auth.po'; import { AuthPageObject } from '../authentication/auth.po';
import { OtpPo } from '../utils/otp.po';
export class AccountPageObject { export class AccountPageObject {
private readonly page: Page; private readonly page: Page;
public auth: AuthPageObject; public auth: AuthPageObject;
private otp: OtpPo;
constructor(page: Page) { constructor(page: Page) {
this.page = page; this.page = page;
this.auth = new AuthPageObject(page); this.auth = new AuthPageObject(page);
this.otp = new OtpPo(page);
} }
async setup() { async setup() {
@@ -58,32 +61,16 @@ export class AccountPageObject {
await this.page.click('[data-test="account-password-form"] button'); await this.page.click('[data-test="account-password-form"] button');
} }
async deleteAccount() { async deleteAccount(email: string) {
await expect(async () => { // Click the delete account button to open the modal
await this.page.click('[data-test="delete-account-button"]'); await this.page.click('[data-test="delete-account-button"]');
await this.page.fill( // Complete the OTP verification process
'[data-test="delete-account-input-field"]', await this.otp.completeOtpVerification(email);
'DELETE',
);
const click = this.page.click( await this.page.waitForTimeout(500);
'[data-test="confirm-delete-account-button"]',
);
const response = await this.page await this.page.click('[data-test="confirm-delete-account-button"]');
.waitForResponse((resp) => {
return (
resp.url().includes('home/settings') &&
resp.request().method() === 'POST'
);
})
.then((response) => {
expect(response.status()).toBe(303);
});
await Promise.all([click, response]);
}).toPass();
} }
getProfileName() { getProfileName() {

View File

@@ -1,6 +1,7 @@
import { Page, expect, test } from '@playwright/test'; import { Page, expect, test } from '@playwright/test';
import { AccountPageObject } from './account.po'; import { AccountPageObject } from './account.po';
import {AuthPageObject} from "../authentication/auth.po";
test.describe('Account Settings', () => { test.describe('Account Settings', () => {
let page: Page; let page: Page;
@@ -51,22 +52,22 @@ test.describe('Account Settings', () => {
test.describe('Account Deletion', () => { test.describe('Account Deletion', () => {
test('user can delete their own account', async ({ page }) => { test('user can delete their own account', async ({ page }) => {
const account = new AccountPageObject(page); const account = new AccountPageObject(page);
const auth = new AuthPageObject(page);
await account.setup(); const { email } = await account.setup();
const request = account.deleteAccount(); await account.deleteAccount(email);
const response = page await page.waitForURL('/');
.waitForResponse((resp) => {
return (
resp.url().includes('home/settings') &&
resp.request().method() === 'POST'
);
})
.then((response) => {
expect(response.status()).toBe(303);
});
await Promise.all([request, response]); await page.goto('/auth/sign-in');
// sign in will now fail
await auth.signIn({
email,
password: 'testingpassword',
});
await expect(page.locator('[data-test="auth-error-message"]')).toBeVisible();
}); });
}); });

View File

@@ -25,7 +25,7 @@ export class AuthPageObject {
} }
async signIn(params: { email: string; password: string }) { async signIn(params: { email: string; password: string }) {
await this.page.waitForTimeout(1000); await this.page.waitForTimeout(500);
await this.page.fill('input[name="email"]', params.email); await this.page.fill('input[name="email"]', params.email);
await this.page.fill('input[name="password"]', params.password); await this.page.fill('input[name="password"]', params.password);
@@ -37,7 +37,7 @@ export class AuthPageObject {
password: string; password: string;
repeatPassword: string; repeatPassword: string;
}) { }) {
await this.page.waitForTimeout(1000); await this.page.waitForTimeout(500);
await this.page.fill('input[name="email"]', params.email); await this.page.fill('input[name="email"]', params.email);
await this.page.fill('input[name="password"]', params.password); await this.page.fill('input[name="password"]', params.password);
@@ -50,6 +50,7 @@ export class AuthPageObject {
email: string, email: string,
params: { params: {
deleteAfter: boolean; deleteAfter: boolean;
subject?: string;
} = { } = {
deleteAfter: true, deleteAfter: true,
}, },
@@ -79,6 +80,10 @@ export class AuthPageObject {
}); });
await this.visitConfirmEmailLink(email); await this.visitConfirmEmailLink(email);
return {
email,
};
} }
async updatePassword(password: string) { async updatePassword(password: string) {

View File

@@ -51,6 +51,23 @@ test.describe('Auth flow', () => {
expect(page.url()).toContain('/'); expect(page.url()).toContain('/');
}); });
test('will sign out using the dropdown', async ({ page }) => {
const auth = new AuthPageObject(page);
await page.goto('/home/settings');
await auth.signIn({
email: 'test@makerkit.dev',
password: 'testingpassword',
});
await page.waitForURL('/home/settings');
await auth.signOut();
await page.waitForURL('/');
});
}); });
test.describe('Protected routes', () => { test.describe('Protected routes', () => {

View File

@@ -2,35 +2,60 @@ import { expect, test } from '@playwright/test';
import { AuthPageObject } from './auth.po'; import { AuthPageObject } from './auth.po';
const email = 'owner@makerkit.dev';
const newPassword = (Math.random() * 10000).toString(); const newPassword = (Math.random() * 10000).toString();
test.describe('Password Reset Flow', () => { test.describe('Password Reset Flow', () => {
test.describe.configure({ mode: 'serial' });
test('will reset the password and sign in with new one', async ({ page }) => { test('will reset the password and sign in with new one', async ({ page }) => {
const auth = new AuthPageObject(page); const auth = new AuthPageObject(page);
await page.goto('/auth/password-reset'); let email = '';
await page.fill('[name="email"]', email); await expect(async () => {
await page.click('[type="submit"]'); email = `test-${Math.random() * 10000}@makerkit.dev`;
await auth.visitConfirmEmailLink(email); await page.goto('/auth/sign-up');
await page.waitForURL('/update-password'); await auth.signUp({
email,
password: 'password',
repeatPassword: 'password',
});
await auth.updatePassword(newPassword); await auth.visitConfirmEmailLink(email, {
deleteAfter: true,
subject: 'Confirm your email',
});
await page await page.context().clearCookies();
.locator('a', { await page.reload();
hasText: 'Back to Home Page',
})
.click();
await page.waitForURL('/home'); await page.goto('/auth/password-reset');
await auth.signOut(); await page.fill('[name="email"]', email);
await page.click('[type="submit"]');
await auth.visitConfirmEmailLink(email, {
deleteAfter: true,
subject: 'Reset your password',
});
await page.waitForURL('/update-password', {
timeout: 1000,
});
await auth.updatePassword(newPassword);
await page
.locator('a', {
hasText: 'Back to Home Page',
})
.click();
await page.waitForURL('/home');
}).toPass();
await page.context().clearCookies();
await page.reload();
await page await page
.locator('a', { .locator('a', {
@@ -43,6 +68,8 @@ test.describe('Password Reset Flow', () => {
password: newPassword, password: newPassword,
}); });
await page.waitForURL('/home'); await page.waitForURL('/home', {
timeout: 2000,
});
}); });
}); });

View File

@@ -64,7 +64,7 @@ export class InvitationsPageObject {
}) })
.click(); .click();
return expect(this.page.url()).toContain('members'); await this.page.waitForURL('**/home/*/members');
}).toPass() }).toPass()
} }

View File

@@ -1,20 +1,25 @@
import { Page, expect } from '@playwright/test'; import { Page, expect } from '@playwright/test';
import { AuthPageObject } from '../authentication/auth.po'; import { AuthPageObject } from '../authentication/auth.po';
import { OtpPo } from '../utils/otp.po';
export class TeamAccountsPageObject { export class TeamAccountsPageObject {
private readonly page: Page; private readonly page: Page;
public auth: AuthPageObject; public auth: AuthPageObject;
public otp: OtpPo;
constructor(page: Page) { constructor(page: Page) {
this.page = page; this.page = page;
this.auth = new AuthPageObject(page); this.auth = new AuthPageObject(page);
this.otp = new OtpPo(page);
} }
async setup(params = this.createTeamName()) { async setup(params = this.createTeamName()) {
await this.auth.signUpFlow('/home'); const { email } = await this.auth.signUpFlow('/home');
await this.createTeam(params); await this.createTeam(params);
return { email, teamName: params.teamName, slug: params.slug };
} }
getTeamFromSelector(teamName: string) { getTeamFromSelector(teamName: string) {
@@ -39,6 +44,18 @@ export class TeamAccountsPageObject {
}).toPass(); }).toPass();
} }
goToMembers() {
return expect(async () => {
await this.page
.locator('a', {
hasText: 'Members',
})
.click();
await this.page.waitForURL('**/home/*/members');
}).toPass();
}
goToBilling() { goToBilling() {
return expect(async () => { return expect(async () => {
await this.page await this.page
@@ -94,18 +111,11 @@ export class TeamAccountsPageObject {
}).toPass(); }).toPass();
} }
async deleteAccount(teamName: string) { async deleteAccount(email: string) {
await expect(async () => { await expect(async () => {
await this.page.click('[data-test="delete-team-trigger"]'); await this.page.click('[data-test="delete-team-trigger"]');
await expect( await this.otp.completeOtpVerification(email);
this.page.locator('[data-test="delete-team-form-confirm-input"]'),
).toBeVisible();
await this.page.fill(
'[data-test="delete-team-form-confirm-input"]',
teamName,
);
const click = this.page.click( const click = this.page.click(
'[data-test="delete-team-form-confirm-button"]', '[data-test="delete-team-form-confirm-button"]',
@@ -117,6 +127,51 @@ export class TeamAccountsPageObject {
}).toPass(); }).toPass();
} }
async updateMemberRole(memberEmail: string, newRole: string) {
await expect(async () => {
// Find the member row and click the actions button
const memberRow = this.page.getByRole('row', { name: memberEmail });
await memberRow.getByRole('button').click();
// Click the update role option in the dropdown menu
await this.page.getByText('Update Role').click();
// Select the new role
await this.page.click('[data-test="role-selector-trigger"]');
await this.page.click(`[data-test="role-option-${newRole}"]`);
// Click the confirm button
const click = this.page.click('[data-test="confirm-update-member-role"]');
// Wait for the update to complete and page to reload
const response = this.page.waitForURL('**/home/*/members');
return Promise.all([click, response]);
}).toPass();
}
async transferOwnership(memberEmail: string, ownerEmail: string) {
await expect(async () => {
// Find the member row and click the actions button
const memberRow = this.page.getByRole('row', { name: memberEmail });
await memberRow.getByRole('button').click();
// Click the transfer ownership option in the dropdown menu
await this.page.getByText('Transfer Ownership').click();
// Complete OTP verification
await this.otp.completeOtpVerification(ownerEmail);
// Click the confirm button
const click = this.page.click('[data-test="confirm-transfer-ownership-button"]');
// Wait for the transfer to complete and page to reload
const response = this.page.waitForURL('**/home/*/members');
return Promise.all([click, response]);
}).toPass();
}
createTeamName() { createTeamName() {
const random = (Math.random() * 100000000).toFixed(0); const random = (Math.random() * 100000000).toFixed(0);

View File

@@ -1,7 +1,74 @@
import { Page, expect, test } from '@playwright/test'; import { Page, expect, test } from '@playwright/test';
import { InvitationsPageObject } from '../invitations/invitations.po';
import { TeamAccountsPageObject } from './team-accounts.po'; import { TeamAccountsPageObject } from './team-accounts.po';
// Helper function to set up a team with a member
async function setupTeamWithMember(page: Page, memberRole = 'member') {
// Setup invitations page object
const invitations = new InvitationsPageObject(page);
const teamAccounts = invitations.teamAccounts;
// Setup team with owner
const { email: ownerEmail, slug } = await invitations.setup();
// Navigate to members page
await invitations.navigateToMembers();
// Create a new member email and invite them with the specified role
const memberEmail = invitations.auth.createRandomEmail();
const invites = [
{
email: memberEmail,
role: memberRole,
},
];
await invitations.openInviteForm();
await invitations.inviteMembers(invites);
// Verify the invitation was sent
await expect(invitations.getInvitations()).toHaveCount(1);
// Sign out the current user
await page.context().clearCookies();
// Sign up with the new member email and accept the invitation
await invitations.auth.visitConfirmEmailLink(memberEmail);
await invitations.auth.signUp({
email: memberEmail,
password: 'password',
repeatPassword: 'password',
});
await invitations.auth.visitConfirmEmailLink(memberEmail);
await invitations.acceptInvitation();
await invitations.teamAccounts.openAccountsSelector();
await expect(invitations.teamAccounts.getTeams()).toHaveCount(1);
// Sign out and sign back in as the original owner
await page.context().clearCookies();
await page.goto('/auth/sign-in');
await invitations.auth.signIn({
email: ownerEmail,
password: 'password',
});
await page.waitForURL('/home');
// Navigate to the team members page
await page.goto(`/home/${slug}/members`);
return { invitations, teamAccounts, ownerEmail, memberEmail, slug };
}
test.describe('Team Accounts', () => { test.describe('Team Accounts', () => {
let page: Page; let page: Page;
let teamAccounts: TeamAccountsPageObject; let teamAccounts: TeamAccountsPageObject;
@@ -31,15 +98,15 @@ test.describe('Team Accounts', () => {
}); });
}); });
test.describe('Account Deletion', () => { test.describe('Team Account Deletion', () => {
test('user can delete their team account', async ({ page }) => { test('user can delete their team account', async ({ page }) => {
const teamAccounts = new TeamAccountsPageObject(page); const teamAccounts = new TeamAccountsPageObject(page);
const params = teamAccounts.createTeamName(); const params = teamAccounts.createTeamName();
await teamAccounts.setup(params); const { email } = await teamAccounts.setup(params);
await teamAccounts.goToSettings(); await teamAccounts.goToSettings();
await teamAccounts.deleteAccount(params.teamName); await teamAccounts.deleteAccount(email);
await teamAccounts.openAccountsSelector(); await teamAccounts.openAccountsSelector();
await expect( await expect(
@@ -47,3 +114,62 @@ test.describe('Account Deletion', () => {
).not.toBeVisible(); ).not.toBeVisible();
}); });
}); });
test.describe('Team Member Role Management', () => {
test("owner can update a team member's role", async ({ page }) => {
// Setup team with a regular member
const { teamAccounts, memberEmail } = await setupTeamWithMember(page);
// Get the current role badge text
const memberRow = page.getByRole('row', { name: memberEmail });
const initialRoleBadge = memberRow.locator(
'[data-test="member-role-badge"]',
);
await expect(initialRoleBadge).toHaveText('Member');
// Update the member's role to admin
await teamAccounts.updateMemberRole(memberEmail, 'owner');
// Wait for the page to fully load after the update
await page.waitForTimeout(1000);
// Verify the role was updated successfully
const updatedRoleBadge = page
.getByRole('row', { name: memberEmail })
.locator('[data-test="member-role-badge"]');
await expect(updatedRoleBadge).toHaveText('Owner');
});
});
test.describe('Team Ownership Transfer', () => {
test('owner can transfer ownership to another team member', async ({
page,
}) => {
// Setup team with an owner member (required for ownership transfer)
const { teamAccounts, ownerEmail, memberEmail } = await setupTeamWithMember(
page,
'owner',
);
// Transfer ownership to the member
await teamAccounts.transferOwnership(memberEmail, ownerEmail);
// Wait for the page to fully load after the transfer
await page.waitForTimeout(1000);
// Verify the transfer was successful by checking if the primary owner badge
// is now on the new owner's row
const memberRow = page.getByRole('row', { name: memberEmail });
// Check for the primary owner badge on the member's row
await expect(memberRow.locator('text=Primary Owner')).toBeVisible({
timeout: 5000,
});
// The original owner should no longer have the primary owner badge
const ownerRow = page.getByRole('row', { name: ownerEmail.split('@')[0] });
await expect(ownerRow.locator('text=Primary Owner')).not.toBeVisible();
});
});

View File

@@ -8,6 +8,7 @@ export class Mailbox {
email: string, email: string,
params: { params: {
deleteAfter: boolean; deleteAfter: boolean;
subject?: string;
}, },
) { ) {
const mailbox = email.split('@')[0]; const mailbox = email.split('@')[0];
@@ -18,13 +19,17 @@ export class Mailbox {
throw new Error('Invalid email'); throw new Error('Invalid email');
} }
const json = await this.getInviteEmail(mailbox, params); const json = await this.getEmail(mailbox, params);
if (!json?.body) { if (!json?.body) {
throw new Error('Email body was not found'); throw new Error('Email body was not found');
} }
console.log('Email found'); console.log(`Email found for ${email}`, {
id: json.id,
subject: json.subject,
date: json.date,
});
const html = (json.body as { html: string }).html; const html = (json.body as { html: string }).html;
const el = parse(html); const el = parse(html);
@@ -40,10 +45,49 @@ export class Mailbox {
return this.page.goto(linkHref); return this.page.goto(linkHref);
} }
async getInviteEmail( /**
* Retrieves an OTP code from an email
* @param email The email address to check for the OTP
* @param deleteAfter Whether to delete the email after retrieving the OTP
* @returns The OTP code
*/
async getOtpFromEmail(email: string, deleteAfter: boolean = true) {
const mailbox = email.split('@')[0];
console.log(`Retrieving OTP from mailbox ${email} ...`);
if (!mailbox) {
throw new Error('Invalid email');
}
const json = await this.getEmail(mailbox, {
deleteAfter,
subject: `One-time password for Makerkit`,
});
if (!json?.body) {
throw new Error('Email body was not found');
}
const html = (json.body as { html: string }).html;
const text = html.match(
new RegExp(`Your one-time password is: (\\d{6})`),
)?.[1];
if (text) {
console.log(`OTP code found in text: ${text}`);
return text;
}
throw new Error('Could not find OTP code in email');
}
async getEmail(
mailbox: string, mailbox: string,
params: { params: {
deleteAfter: boolean; deleteAfter: boolean;
subject?: string;
}, },
) { ) {
const url = `http://127.0.0.1:54324/api/v1/mailbox/${mailbox}`; const url = `http://127.0.0.1:54324/api/v1/mailbox/${mailbox}`;
@@ -54,13 +98,34 @@ export class Mailbox {
throw new Error(`Failed to fetch emails: ${response.statusText}`); throw new Error(`Failed to fetch emails: ${response.statusText}`);
} }
const json = (await response.json()) as Array<{ id: string }>; const json = (await response.json()) as Array<{
id: string;
subject: string;
}>;
if (!json || !json.length) { if (!json || !json.length) {
console.log(`No emails found for mailbox ${mailbox}`);
return; return;
} }
const messageId = json[0]?.id; const message = params.subject
? (() => {
const filtered = json.filter(
(item) => item.subject === params.subject,
);
console.log(
`Found ${filtered.length} emails with subject ${params.subject}`,
);
return filtered[filtered.length - 1];
})()
: json[0];
console.log(`Message: ${JSON.stringify(message)}`);
const messageId = message?.id;
const messageUrl = `${url}/${messageId}`; const messageUrl = `${url}/${messageId}`;
const messageResponse = await fetch(messageUrl); const messageResponse = await fetch(messageUrl);

View File

@@ -0,0 +1,63 @@
import { Page, expect } from '@playwright/test';
import { Mailbox } from './mailbox';
export class OtpPo {
private readonly page: Page;
private readonly mailbox: Mailbox;
constructor(page: Page) {
this.page = page;
this.mailbox = new Mailbox(page);
}
/**
* Completes the OTP verification process
* @param email The email address to send the OTP to
*/
async completeOtpVerification(email: string) {
// Click the "Send Verification Code" button
await this.page.click('[data-test="otp-send-verification-button"]');
// wait for the OTP to be sent
await this.page.waitForTimeout(500);
await expect(async () => {
// Get the OTP code from the email
const otpCode = await this.getOtpCodeFromEmail(email);
expect(otpCode).not.toBeNull();
// Enter the OTP code
await this.enterOtpCode(otpCode);
}).toPass();
// Click the "Verify Code" button
await this.page.click('[data-test="otp-verify-button"]');
}
/**
* Retrieves the OTP code from an email
* @param email The email address to check for the OTP
* @returns The OTP code
*/
async getOtpCodeFromEmail(email: string) {
// Get the OTP from the email
const otpCode = await this.mailbox.getOtpFromEmail(email);
if (!otpCode) {
throw new Error('Failed to retrieve OTP code from email');
}
return otpCode;
}
/**
* Enters the OTP code into the input fields
* @param otpCode The 6-digit OTP code
*/
async enterOtpCode(otpCode: string) {
console.log(`Entering OTP code: ${otpCode}`);
await this.page.fill('[data-input-otp]', otpCode);
}
}

View File

@@ -93,7 +93,7 @@ async function getLayoutState() {
const sidebarOpenCookieValue = sidebarOpenCookie const sidebarOpenCookieValue = sidebarOpenCookie
? sidebarOpenCookie.value === 'false' ? sidebarOpenCookie.value === 'false'
: personalAccountNavigationConfig.sidebarCollapsed; : !personalAccountNavigationConfig.sidebarCollapsed;
const style = const style =
layoutStyleCookie?.value ?? personalAccountNavigationConfig.style; layoutStyleCookie?.value ?? personalAccountNavigationConfig.style;

View File

@@ -126,12 +126,13 @@ async function getLayoutState(account: string) {
const cookieStore = await cookies(); const cookieStore = await cookies();
const sidebarOpenCookie = cookieStore.get('sidebar:state'); const sidebarOpenCookie = cookieStore.get('sidebar:state');
const layoutCookie = cookieStore.get('layout-style'); const layoutCookie = cookieStore.get('layout-style');
const layoutStyle = layoutCookie?.value as PageLayoutStyle; const layoutStyle = layoutCookie?.value as PageLayoutStyle;
const config = getTeamAccountSidebarConfig(account); const config = getTeamAccountSidebarConfig(account);
const sidebarOpenCookieValue = sidebarOpenCookie const sidebarOpenCookieValue = sidebarOpenCookie
? sidebarOpenCookie.value === 'false' ? sidebarOpenCookie.value === 'false'
: config.sidebarCollapsed; : !config.sidebarCollapsed;
return { return {
open: sidebarOpenCookieValue, open: sidebarOpenCookieValue,

View File

@@ -77,29 +77,7 @@ export type Database = {
updated_at?: string | null; updated_at?: string | null;
updated_by?: string | null; updated_by?: string | null;
}; };
Relationships: [ 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'];
},
];
}; };
accounts_memberships: { accounts_memberships: {
Row: { Row: {
@@ -158,27 +136,6 @@ export type Database = {
referencedRelation: 'roles'; referencedRelation: 'roles';
referencedColumns: ['name']; 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: { billing_customers: {
@@ -304,13 +261,6 @@ export type Database = {
referencedRelation: 'user_accounts'; referencedRelation: 'user_accounts';
referencedColumns: ['id']; referencedColumns: ['id'];
}, },
{
foreignKeyName: 'invitations_invited_by_fkey';
columns: ['invited_by'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
{ {
foreignKeyName: 'invitations_role_fkey'; foreignKeyName: 'invitations_role_fkey';
columns: ['role']; columns: ['role'];
@@ -320,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: { notifications: {
Row: { Row: {
account_id: string; account_id: string;
@@ -727,6 +740,19 @@ export type Database = {
updated_at: string; 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: { create_team_account: {
Args: { Args: {
account_name: string; account_name: string;
@@ -785,6 +811,12 @@ export type Database = {
Args: Record<PropertyKey, never>; Args: Record<PropertyKey, never>;
Returns: Json; Returns: Json;
}; };
get_nonce_status: {
Args: {
p_id: string;
};
Returns: Json;
};
get_upper_system_role: { get_upper_system_role: {
Args: Record<PropertyKey, never>; Args: Record<PropertyKey, never>;
Returns: string; Returns: string;
@@ -851,6 +883,13 @@ export type Database = {
}; };
Returns: boolean; Returns: boolean;
}; };
revoke_nonce: {
Args: {
p_id: string;
p_reason?: string;
};
Returns: boolean;
};
team_account_workspace: { team_account_workspace: {
Args: { Args: {
account_slug: string; account_slug: string;
@@ -930,6 +969,18 @@ export type Database = {
updated_at: string; 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: { Enums: {
app_permissions: app_permissions:
@@ -1034,6 +1085,7 @@ export type Database = {
owner_id: string | null; owner_id: string | null;
path_tokens: string[] | null; path_tokens: string[] | null;
updated_at: string | null; updated_at: string | null;
user_metadata: Json | null;
version: string | null; version: string | null;
}; };
Insert: { Insert: {
@@ -1047,6 +1099,7 @@ export type Database = {
owner_id?: string | null; owner_id?: string | null;
path_tokens?: string[] | null; path_tokens?: string[] | null;
updated_at?: string | null; updated_at?: string | null;
user_metadata?: Json | null;
version?: string | null; version?: string | null;
}; };
Update: { Update: {
@@ -1060,6 +1113,7 @@ export type Database = {
owner_id?: string | null; owner_id?: string | null;
path_tokens?: string[] | null; path_tokens?: string[] | null;
updated_at?: string | null; updated_at?: string | null;
user_metadata?: Json | null;
version?: string | null; version?: string | null;
}; };
Relationships: [ Relationships: [
@@ -1081,6 +1135,7 @@ export type Database = {
key: string; key: string;
owner_id: string | null; owner_id: string | null;
upload_signature: string; upload_signature: string;
user_metadata: Json | null;
version: string; version: string;
}; };
Insert: { Insert: {
@@ -1091,6 +1146,7 @@ export type Database = {
key: string; key: string;
owner_id?: string | null; owner_id?: string | null;
upload_signature: string; upload_signature: string;
user_metadata?: Json | null;
version: string; version: string;
}; };
Update: { Update: {
@@ -1101,6 +1157,7 @@ export type Database = {
key?: string; key?: string;
owner_id?: string | null; owner_id?: string | null;
upload_signature?: string; upload_signature?: string;
user_metadata?: Json | null;
version?: string; version?: string;
}; };
Relationships: [ Relationships: [
@@ -1237,6 +1294,10 @@ export type Database = {
updated_at: string; updated_at: string;
}[]; }[];
}; };
operation: {
Args: Record<PropertyKey, never>;
Returns: string;
};
search: { search: {
Args: { Args: {
prefix: string; prefix: string;
@@ -1348,3 +1409,18 @@ export type Enums<
: PublicEnumNameOrOptions extends keyof PublicSchema['Enums'] : PublicEnumNameOrOptions extends keyof PublicSchema['Enums']
? PublicSchema['Enums'][PublicEnumNameOrOptions] ? PublicSchema['Enums'][PublicEnumNameOrOptions]
: never; : 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

@@ -73,6 +73,20 @@
"label": "Member" "label": "Member"
} }
}, },
"otp": {
"requestVerificationCode": "Request Verification Code",
"requestVerificationCodeDescription": "We must verify your identity to continue with this action. We'll send a verification code to the email address {{email}}.",
"sendingCode": "Sending Code...",
"sendVerificationCode": "Send Verification Code",
"enterVerificationCode": "Enter Verification Code",
"codeSentToEmail": "We've sent a verification code to the email address {{email}}.",
"verificationCode": "Verification Code",
"enterCodeFromEmail": "Enter the 6-digit code we sent to your email.",
"verifying": "Verifying...",
"verifyCode": "Verify Code",
"requestNewCode": "Request New Code",
"errorSendingCode": "Error sending code. Please try again."
},
"cookieBanner": { "cookieBanner": {
"title": "Hey, we use cookies \uD83C\uDF6A", "title": "Hey, we use cookies \uD83C\uDF6A",
"description": "This website uses cookies to ensure you get the best experience on our website.", "description": "This website uses cookies to ensure you get the best experience on our website.",

View File

@@ -0,0 +1,346 @@
create extension if not exists pg_cron;
-- Create a table to store one-time tokens (nonces)
CREATE TABLE IF NOT EXISTS public.nonces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_token TEXT NOT NULL, -- token sent to client (hashed)
nonce TEXT NOT NULL, -- token stored in DB (hashed)
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NULL, -- Optional to support anonymous tokens
purpose TEXT NOT NULL, -- e.g., 'password-reset', 'email-verification', etc.
-- Status fields
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
used_at TIMESTAMPTZ,
revoked BOOLEAN NOT NULL DEFAULT FALSE, -- For administrative revocation
revoked_reason TEXT, -- Reason for revocation if applicable
-- Audit fields
verification_attempts INTEGER NOT NULL DEFAULT 0, -- Track attempted uses
last_verification_at TIMESTAMPTZ, -- Timestamp of last verification attempt
last_verification_ip INET, -- For tracking verification source
last_verification_user_agent TEXT, -- For tracking client information
-- Extensibility fields
metadata JSONB DEFAULT '{}'::JSONB, -- optional metadata
scopes TEXT[] DEFAULT '{}' -- OAuth-style authorized scopes
);
-- Create indexes for efficient lookups
CREATE INDEX IF NOT EXISTS idx_nonces_status ON public.nonces (client_token, user_id, purpose, expires_at)
WHERE used_at IS NULL AND revoked = FALSE;
-- Enable Row Level Security (RLS)
ALTER TABLE public.nonces ENABLE ROW LEVEL SECURITY;
-- RLS policies
-- Users can view their own nonces for verification
CREATE POLICY "Users can read their own nonces"
ON public.nonces
FOR SELECT
USING (
user_id = (select auth.uid())
);
-- Create a function to create a nonce
CREATE OR REPLACE FUNCTION public.create_nonce(
p_user_id UUID DEFAULT NULL,
p_purpose TEXT DEFAULT NULL,
p_expires_in_seconds INTEGER DEFAULT 3600, -- 1 hour by default
p_metadata JSONB DEFAULT NULL,
p_scopes TEXT[] DEFAULT NULL,
p_revoke_previous BOOLEAN DEFAULT TRUE -- New parameter to control automatic revocation
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_client_token TEXT;
v_nonce TEXT;
v_expires_at TIMESTAMPTZ;
v_id UUID;
v_plaintext_token TEXT;
v_revoked_count INTEGER;
BEGIN
-- Revoke previous tokens for the same user and purpose if requested
-- This only applies if a user ID is provided (not for anonymous tokens)
IF p_revoke_previous = TRUE AND p_user_id IS NOT NULL THEN
WITH revoked AS (
UPDATE public.nonces
SET
revoked = TRUE,
revoked_reason = 'Superseded by new token with same purpose'
WHERE
user_id = p_user_id
AND purpose = p_purpose
AND used_at IS NULL
AND revoked = FALSE
AND expires_at > NOW()
RETURNING 1
)
SELECT COUNT(*) INTO v_revoked_count FROM revoked;
END IF;
-- Generate a 6-digit token
v_plaintext_token := (100000 + floor(random() * 900000))::text;
v_client_token := crypt(v_plaintext_token, gen_salt('bf'));
-- Still generate a secure nonce for internal use
v_nonce := encode(gen_random_bytes(24), 'base64');
v_nonce := crypt(v_nonce, gen_salt('bf'));
-- Calculate expiration time
v_expires_at := NOW() + (p_expires_in_seconds * interval '1 second');
-- Insert the new nonce
INSERT INTO public.nonces (
client_token,
nonce,
user_id,
expires_at,
metadata,
purpose,
scopes
)
VALUES (
v_client_token,
v_nonce,
p_user_id,
v_expires_at,
COALESCE(p_metadata, '{}'::JSONB),
p_purpose,
COALESCE(p_scopes, '{}'::TEXT[])
)
RETURNING id INTO v_id;
-- Return the token information
-- Note: returning the plaintext token, not the hash
RETURN jsonb_build_object(
'id', v_id,
'token', v_plaintext_token,
'expires_at', v_expires_at,
'revoked_previous_count', COALESCE(v_revoked_count, 0)
);
END;
$$;
grant execute on function public.create_nonce to service_role;
-- Create a function to verify a nonce
CREATE OR REPLACE FUNCTION public.verify_nonce(
p_token TEXT,
p_purpose TEXT,
p_user_id UUID DEFAULT NULL,
p_required_scopes TEXT[] DEFAULT NULL,
p_max_verification_attempts INTEGER DEFAULT 5,
p_ip INET DEFAULT NULL,
p_user_agent TEXT DEFAULT NULL
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_nonce RECORD;
v_matching_count INTEGER;
BEGIN
-- Add debugging info
RAISE NOTICE 'Verifying token: %, purpose: %, user_id: %', p_token, p_purpose, p_user_id;
-- Count how many matching tokens exist before verification attempt
SELECT COUNT(*) INTO v_matching_count
FROM public.nonces
WHERE purpose = p_purpose;
-- Update verification attempt counter and tracking info for all matching tokens
UPDATE public.nonces
SET
verification_attempts = verification_attempts + 1,
last_verification_at = NOW(),
last_verification_ip = COALESCE(p_ip, last_verification_ip),
last_verification_user_agent = COALESCE(p_user_agent, last_verification_user_agent)
WHERE
client_token = crypt(p_token, client_token)
AND purpose = p_purpose;
-- Find the nonce by token and purpose
-- Modified to handle user-specific tokens better
SELECT *
INTO v_nonce
FROM public.nonces
WHERE
client_token = crypt(p_token, client_token)
AND purpose = p_purpose
-- Only apply user_id filter if the token was created for a specific user
AND (
-- Case 1: Anonymous token (user_id is NULL in DB)
(user_id IS NULL)
OR
-- Case 2: User-specific token (check if user_id matches)
(user_id = p_user_id)
)
AND used_at IS NULL
AND NOT revoked
AND expires_at > NOW();
-- Check if nonce exists
IF v_nonce.id IS NULL THEN
RETURN jsonb_build_object(
'valid', false,
'message', 'Invalid or expired token'
);
END IF;
-- Check if max verification attempts exceeded
IF p_max_verification_attempts > 0 AND v_nonce.verification_attempts > p_max_verification_attempts THEN
-- Automatically revoke the token
UPDATE public.nonces
SET
revoked = TRUE,
revoked_reason = 'Maximum verification attempts exceeded'
WHERE id = v_nonce.id;
RETURN jsonb_build_object(
'valid', false,
'message', 'Token revoked due to too many verification attempts',
'max_attempts_exceeded', true
);
END IF;
-- Check scopes if required
IF p_required_scopes IS NOT NULL AND array_length(p_required_scopes, 1) > 0 THEN
-- Fix scope validation to properly check if token scopes contain all required scopes
-- Using array containment check: array1 @> array2 (array1 contains array2)
IF NOT (v_nonce.scopes @> p_required_scopes) THEN
RETURN jsonb_build_object(
'valid', false,
'message', 'Token does not have required permissions',
'token_scopes', v_nonce.scopes,
'required_scopes', p_required_scopes
);
END IF;
END IF;
-- Mark nonce as used
UPDATE public.nonces
SET used_at = NOW()
WHERE id = v_nonce.id;
-- Return success with metadata
RETURN jsonb_build_object(
'valid', true,
'user_id', v_nonce.user_id,
'metadata', v_nonce.metadata,
'scopes', v_nonce.scopes,
'purpose', v_nonce.purpose
);
END;
$$;
grant execute on function public.verify_nonce to authenticated,service_role;
-- Create a function to revoke a nonce
CREATE OR REPLACE FUNCTION public.revoke_nonce(
p_id UUID,
p_reason TEXT DEFAULT NULL
)
RETURNS BOOLEAN
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_affected_rows INTEGER;
BEGIN
UPDATE public.nonces
SET
revoked = TRUE,
revoked_reason = p_reason
WHERE
id = p_id
AND used_at IS NULL
AND NOT revoked
RETURNING 1 INTO v_affected_rows;
RETURN v_affected_rows > 0;
END;
$$;
grant execute on function public.revoke_nonce to service_role;
-- Create a function to clean up expired nonces
CREATE OR REPLACE FUNCTION kit.cleanup_expired_nonces(
p_older_than_days INTEGER DEFAULT 1,
p_include_used BOOLEAN DEFAULT TRUE,
p_include_revoked BOOLEAN DEFAULT TRUE
)
RETURNS INTEGER
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_count INTEGER;
BEGIN
-- Count and delete expired or used nonces based on parameters
WITH deleted AS (
DELETE FROM public.nonces
WHERE
(
-- Expired and unused tokens
(expires_at < NOW() AND used_at IS NULL)
-- Used tokens older than specified days (if enabled)
OR (p_include_used = TRUE AND used_at < NOW() - (p_older_than_days * interval '1 day'))
-- Revoked tokens older than specified days (if enabled)
OR (p_include_revoked = TRUE AND revoked = TRUE AND created_at < NOW() - (p_older_than_days * interval '1 day'))
)
RETURNING 1
)
SELECT COUNT(*) INTO v_count FROM deleted;
RETURN v_count;
END;
$$;
-- Create a function to get token status (for administrative use)
CREATE OR REPLACE FUNCTION public.get_nonce_status(
p_id UUID
)
RETURNS JSONB
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_nonce public.nonces;
BEGIN
SELECT * INTO v_nonce FROM public.nonces WHERE id = p_id;
IF v_nonce.id IS NULL THEN
RETURN jsonb_build_object('exists', false);
END IF;
RETURN jsonb_build_object(
'exists', true,
'purpose', v_nonce.purpose,
'user_id', v_nonce.user_id,
'created_at', v_nonce.created_at,
'expires_at', v_nonce.expires_at,
'used_at', v_nonce.used_at,
'revoked', v_nonce.revoked,
'revoked_reason', v_nonce.revoked_reason,
'verification_attempts', v_nonce.verification_attempts,
'last_verification_at', v_nonce.last_verification_at,
'last_verification_ip', v_nonce.last_verification_ip,
'is_valid', (v_nonce.used_at IS NULL AND NOT v_nonce.revoked AND v_nonce.expires_at > NOW())
);
END;
$$;
-- Comments for documentation
COMMENT ON TABLE public.nonces IS 'Table for storing one-time tokens with enhanced security and audit features';
COMMENT ON FUNCTION public.create_nonce IS 'Creates a new one-time token for a specific purpose with enhanced options';
COMMENT ON FUNCTION public.verify_nonce IS 'Verifies a one-time token, checks scopes, and marks it as used';
COMMENT ON FUNCTION public.revoke_nonce IS 'Administratively revokes a token to prevent its use';
COMMENT ON FUNCTION kit.cleanup_expired_nonces IS 'Cleans up expired, used, or revoked tokens based on parameters';
COMMENT ON FUNCTION public.get_nonce_status IS 'Retrieves the status of a token for administrative purposes';

View File

@@ -10,6 +10,19 @@ alter default PRIVILEGES in schema makerkit revoke execute on FUNCTIONS from pub
alter default PRIVILEGES in schema makerkit grant execute on FUNCTIONS to anon, alter default PRIVILEGES in schema makerkit grant execute on FUNCTIONS to anon,
authenticated, service_role; authenticated, service_role;
create or replace function makerkit.get_id_by_identifier(
identifier text
)
returns uuid
as $$
begin
return (select id from auth.users where raw_user_meta_data->>'test_identifier' = identifier);
end;
$$ language PLPGSQL;
create or replace function makerkit.set_identifier( create or replace function makerkit.set_identifier(
identifier text, identifier text,
user_email text user_email text

View File

@@ -0,0 +1,853 @@
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan(); -- Use no_plan for flexibility
select tests.create_supabase_user('token_creator', 'creator@example.com');
select tests.create_supabase_user('token_verifier', 'verifier@example.com');
-- ==========================================
-- Test 1: Permission Tests
-- ==========================================
-- Test 1.1: Regular users cannot create nonces directly
select tests.authenticate_as('token_creator');
select throws_ok(
$$ select public.create_nonce(auth.uid(), 'password-reset', 3600) $$,
'permission denied for function create_nonce',
'Regular users should not be able to create nonces directly'
);
-- Test 1.2: Regular users cannot revoke nonces
select throws_ok(
$$ select public.revoke_nonce('00000000-0000-0000-0000-000000000000'::uuid, 'test') $$,
'permission denied for function revoke_nonce',
'Regular users should not be able to revoke tokens'
);
-- Test 1.3: Service role can create nonces
set local role service_role;
-- Create a token and store it for later verification
do $$
declare
token_result jsonb;
begin
token_result := public.create_nonce(
null,
'password-reset',
3600,
'{"redirect_url": "/reset-password"}'::jsonb,
ARRAY['auth:reset']
);
-- Store the token for later verification
perform set_config('app.settings.test_token', token_result->>'token', false);
perform set_config('app.settings.test_token_id', token_result->>'id', false);
perform set_config('app.settings.test_token_json', token_result::text, false);
end $$;
-- Check token result properties
select ok(
(current_setting('app.settings.test_token_json', false)::jsonb) ? 'id',
'Token result should contain an id'
);
select ok(
(current_setting('app.settings.test_token_json', false)::jsonb) ? 'token',
'Token result should contain a token'
);
select ok(
(current_setting('app.settings.test_token_json', false)::jsonb) ? 'expires_at',
'Token result should contain an expiration time'
);
set local role postgres;
-- Create a token for an authenticated user
do $$
declare
token_result jsonb;
auth_user_id uuid;
begin
auth_user_id := makerkit.get_id_by_identifier('token_creator');
token_result := public.create_nonce(
auth_user_id,
'email-verification',
3600
);
-- Store the token for later user verification
perform set_config('app.settings.user_token', token_result->>'token', false);
perform set_config('app.settings.user_token_id', token_result->>'id', false);
perform set_config('app.settings.user_token_json', token_result::text, false);
end $$;
-- Check user token result properties
select ok(
(current_setting('app.settings.user_token_json', false)::jsonb) ? 'id',
'Token result with minimal params should contain an id'
);
select ok(
(current_setting('app.settings.user_token_json', false)::jsonb) ? 'token',
'Token result with minimal params should contain a token'
);
-- Create an anonymous token (no user_id)
do $$
declare
token_result jsonb;
begin
token_result := public.create_nonce(
null,
'user-invitation',
7200,
'{"team_id": "123456"}'::jsonb
);
-- Store the anonymous token for later verification
perform set_config('app.settings.anonymous_token', token_result->>'token', false);
perform set_config('app.settings.anonymous_token_id', token_result->>'id', false);
perform set_config('app.settings.anonymous_token_json', token_result::text, false);
end $$;
-- Check anonymous token result properties
select ok(
(current_setting('app.settings.anonymous_token_json', false)::jsonb) ? 'id',
'Anonymous token result should contain an id'
);
select ok(
(current_setting('app.settings.anonymous_token_json', false)::jsonb) ? 'token',
'Anonymous token result should contain a token'
);
-- ==========================================
-- Test 2: Verify Tokens
-- ==========================================
-- Test 2.1: Authenticated users can verify tokens
select tests.authenticate_as('token_creator');
-- Verify token and store result
do $$
declare
test_token text;
verification_result jsonb;
begin
test_token := current_setting('app.settings.test_token', false);
verification_result := public.verify_nonce(
test_token,
'password-reset'
);
perform set_config('app.settings.verification_result', verification_result::text, false);
end $$;
-- Check verification result
select is(
(current_setting('app.settings.verification_result', false)::jsonb)->>'valid',
'true',
'Token should be valid'
);
select ok(
(current_setting('app.settings.verification_result', false)::jsonb) ? 'metadata',
'Result should contain metadata'
);
select ok(
(current_setting('app.settings.verification_result', false)::jsonb) ? 'scopes',
'Result should contain scopes'
);
-- Test 2.2: Users can verify tokens assigned to them
do $$
declare
user_token text;
verification_result jsonb;
user_id uuid;
begin
user_token := current_setting('app.settings.user_token', false);
set local role postgres;
user_id := makerkit.get_id_by_identifier('token_creator');
perform tests.authenticate_as('token_creator');
verification_result := public.verify_nonce(
user_token,
'email-verification',
user_id
);
perform set_config('app.settings.user_verification_result', verification_result::text, false);
end $$;
-- Check user verification result
select is(
(current_setting('app.settings.user_verification_result', false)::jsonb)->>'valid',
'true',
'User-specific token should be valid'
);
select isnt(
(current_setting('app.settings.user_verification_result', false)::jsonb)->>'user_id',
null,
'User-specific token should have user_id'
);
-- Test 2.3: Verify token with scopes
set local role service_role;
-- Create token with scopes
do $$
declare
scope_token_result jsonb;
begin
-- Create token with scopes
scope_token_result := public.create_nonce(
null,
'api-access',
3600,
'{"permissions": "read-only"}'::jsonb,
ARRAY['read:profile', 'read:posts']
);
-- Store for verification
perform set_config('app.settings.scope_token', scope_token_result->>'token', false);
end $$;
-- Verify with correct scope
do $$
declare
scope_token text;
verification_result jsonb;
user_id uuid;
begin
set local role postgres;
scope_token := current_setting('app.settings.scope_token', false);
user_id := makerkit.get_id_by_identifier('token_verifier');
perform tests.authenticate_as('token_verifier');
-- Verify with correct required scope
verification_result := public.verify_nonce(
scope_token,
'api-access',
null,
ARRAY['read:profile']
);
perform set_config('app.settings.correct_scope_result', verification_result::text, false);
-- Verify with incorrect required scope
verification_result := public.verify_nonce(
scope_token,
'api-access',
null,
ARRAY['write:posts']
);
perform set_config('app.settings.incorrect_scope_result', verification_result::text, false);
end $$;
-- Check scope verification results
select is(
(current_setting('app.settings.correct_scope_result', false)::jsonb)->>'valid',
'true',
'Token with correct scope should be valid'
);
select is(
(current_setting('app.settings.incorrect_scope_result', false)::jsonb)->>'valid',
'false',
'Token with incorrect scope should be invalid'
);
-- Test 2.4: Once used, token becomes invalid
do $$
declare
token_result jsonb;
first_verification jsonb;
second_verification jsonb;
begin
-- Use service role to create a token
set local role service_role;
-- Create a token
token_result := public.create_nonce(
null,
'one-time-action',
3600
);
set local role authenticated;
-- Verify it once (uses it)
first_verification := public.verify_nonce(
token_result->>'token',
'one-time-action'
);
-- Try to verify again
second_verification := public.verify_nonce(
token_result->>'token',
'one-time-action'
);
perform set_config('app.settings.first_verification', first_verification::text, false);
perform set_config('app.settings.second_verification', second_verification::text, false);
end $$;
-- Check first and second verification results
select is(
(current_setting('app.settings.first_verification', false)::jsonb)->>'valid',
'true',
'First verification should succeed'
);
select is(
(current_setting('app.settings.second_verification', false)::jsonb)->>'valid',
'false',
'Token should not be valid on second use'
);
-- Test 2.5: Verify with incorrect purpose
do $$
declare
token_result jsonb;
verification_result jsonb;
begin
-- Use service role to create a token
set local role service_role;
-- Create a token
token_result := public.create_nonce(
null,
'specific-purpose',
3600
);
set local role authenticated;
-- Verify with wrong purpose
verification_result := public.verify_nonce(
token_result->>'token',
'different-purpose'
);
perform set_config('app.settings.wrong_purpose_result', verification_result::text, false);
end $$;
-- Check wrong purpose verification result
select is(
(current_setting('app.settings.wrong_purpose_result', false)::jsonb)->>'valid',
'false',
'Token with incorrect purpose should be invalid'
);
-- ==========================================
-- Test 3: Revoke Tokens
-- ==========================================
-- Test 3.1: Only service_role can revoke tokens
select tests.authenticate_as('token_creator');
select
has_function(
'public',
'revoke_nonce',
ARRAY['uuid', 'text'],
'revoke_nonce function should exist'
);
select throws_ok(
$$ select public.revoke_nonce('00000000-0000-0000-0000-000000000000'::uuid, 'test reason') $$,
'permission denied for function revoke_nonce',
'Regular users should not be able to revoke tokens'
);
-- Test 3.2: Service role can revoke tokens
set local role service_role;
do $$
declare
token_result jsonb;
revoke_result boolean;
verification_result jsonb;
token_id uuid;
begin
-- Create a token
token_result := public.create_nonce(
null,
'revokable-action',
3600
);
token_id := token_result->>'id';
-- Revoke the token
revoke_result := public.revoke_nonce(
token_id,
'Security concern'
);
-- Switch to regular user to try to verify the revoked token
set local role authenticated;
-- Try to verify the revoked token
verification_result := public.verify_nonce(
token_result->>'token',
'revokable-action'
);
perform set_config('app.settings.revoke_result', revoke_result::text, false);
perform set_config('app.settings.revoked_verification', verification_result::text, false);
end $$;
-- Check revocation results
select is(
current_setting('app.settings.revoke_result', false)::boolean,
true,
'Token revocation should succeed'
);
select is(
(current_setting('app.settings.revoked_verification', false)::jsonb)->>'valid',
'false',
'Revoked token should be invalid'
);
-- ==========================================
-- Test 4: Get Token Status
-- ==========================================
-- Test 4.1: Verify permission on get_nonce_status
select tests.authenticate_as('token_creator');
select throws_ok(
$$ select public.get_nonce_status('00000000-0000-0000-0000-000000000000'::uuid) $$,
'permission denied for function get_nonce_status',
'Regular users should not be able to check token status'
);
-- Test 4.2: Service role can check token status
set local role service_role;
select
has_function(
'public',
'get_nonce_status',
ARRAY['uuid'],
'get_nonce_status function should exist'
);
do $$
declare
token_result jsonb;
status_result jsonb;
token_id uuid;
begin
-- Create a token
token_result := public.create_nonce(
null,
'status-check-test',
3600
);
token_id := token_result->>'id';
-- Get status
status_result := public.get_nonce_status(token_id);
perform set_config('app.settings.status_result', status_result::text, false);
end $$;
-- Check status result
select is(
(current_setting('app.settings.status_result', false)::jsonb)->>'exists',
'true',
'Token should exist'
);
select is(
(current_setting('app.settings.status_result', false)::jsonb)->>'purpose',
'status-check-test',
'Purpose should match'
);
select is(
(current_setting('app.settings.status_result', false)::jsonb)->>'is_valid',
'true',
'Token should be valid'
);
select is(
(current_setting('app.settings.status_result', false)::jsonb)->>'verification_attempts',
'0',
'New token should have 0 verification attempts'
);
select is(
(current_setting('app.settings.status_result', false)::jsonb)->>'used_at',
null,
'New token should not be used'
);
select is(
(current_setting('app.settings.status_result', false)::jsonb)->>'revoked',
'false',
'New token should not be revoked'
);
-- ==========================================
-- Test 5: Cleanup Expired Tokens
-- ==========================================
-- Test 5.1: Regular users cannot access cleanup function
select tests.authenticate_as('token_creator');
select throws_ok(
$$ select kit.cleanup_expired_nonces() $$,
'permission denied for schema kit',
'Regular users should not be able to clean up tokens'
);
-- Test 5.2: Postgres can clean up expired tokens
set local role postgres;
select
has_function(
'kit',
'cleanup_expired_nonces',
ARRAY['integer', 'boolean', 'boolean'],
'cleanup_expired_nonces function should exist'
);
do $$
declare
token_result jsonb;
cleanup_result integer;
token_count integer;
begin
-- Create an expired token (expiring in -10 seconds from now)
token_result := public.create_nonce(
null,
'expired-token-test',
-10 -- Negative value to create an already expired token
);
-- Run cleanup
cleanup_result := kit.cleanup_expired_nonces();
-- Verify the token is gone
select count(*) into token_count from public.nonces where id = (token_result->>'id')::uuid;
perform set_config('app.settings.cleanup_result', cleanup_result::text, false);
perform set_config('app.settings.token_count_after_cleanup', token_count::text, false);
end $$;
-- Check cleanup results
select cmp_ok(
current_setting('app.settings.cleanup_result', false)::integer,
'>=',
1,
'Cleanup should remove at least one expired token'
);
select is(
current_setting('app.settings.token_count_after_cleanup', false)::integer,
0,
'Expired token should be removed after cleanup'
);
-- ==========================================
-- Test 6: Security Tests
-- ==========================================
-- Test 6.1: Regular users cannot view tokens directly from the nonces table
select tests.authenticate_as('token_creator');
set local role postgres;
do $$
declare
creator_id uuid;
token_id uuid;
begin
-- Get the user id
creator_id := makerkit.get_id_by_identifier('token_creator');
-- Create a token for this user
token_id := (public.create_nonce(creator_id, 'security-test', 3600))->>'id';
perform set_config('app.settings.security_test_token_id', token_id::text, false);
end $$;
select tests.authenticate_as('token_creator');
do $$
declare
token_id uuid;
token_count integer;
begin
-- Get the token ID created by service role
token_id := (current_setting('app.settings.security_test_token_id', false))::uuid;
-- Try to view token directly from nonces table
select count(*) into token_count from public.nonces where id = token_id;
perform set_config('app.settings.creator_token_count', token_count::text, false);
end $$;
-- Check creator can see their own token
select is(
current_setting('app.settings.creator_token_count', false)::integer,
1,
'User should be able to see their own tokens in the table'
);
-- Test 6.2: Users cannot see tokens belonging to other users
select tests.authenticate_as('token_verifier');
do $$
declare
token_id uuid;
token_count integer;
begin
-- Get the token ID created for the creator user
token_id := (current_setting('app.settings.security_test_token_id', false))::uuid;
-- Verifier tries to view token created for creator
select count(*) into token_count from public.nonces where id = token_id;
perform set_config('app.settings.verifier_token_count', token_count::text, false);
end $$;
-- Check verifier cannot see creator's token
select is(
current_setting('app.settings.verifier_token_count', false)::integer,
0,
'User should not be able to see tokens belonging to other users'
);
-- ==========================================
-- Test 7: Auto-Revocation of Previous Tokens
-- ==========================================
-- Test 7.1: Creating a new token should revoke previous tokens with the same purpose by default
set local role postgres;
do $$
declare
auth_user_id uuid;
first_token_result jsonb;
second_token_result jsonb;
first_token_id uuid;
first_token_status jsonb;
begin
-- Get user ID
auth_user_id := makerkit.get_id_by_identifier('token_creator');
-- Create first token
first_token_result := public.create_nonce(
auth_user_id,
'password-reset',
3600,
'{"first": true}'::jsonb
);
first_token_id := first_token_result->>'id';
-- Verify first token is valid
first_token_status := public.get_nonce_status(first_token_id);
-- Create second token with same purpose
second_token_result := public.create_nonce(
auth_user_id,
'password-reset',
3600,
'{"second": true}'::jsonb
);
-- Check that first token is now revoked
first_token_status := public.get_nonce_status(first_token_id);
perform set_config('app.settings.first_token_valid_before', 'true', false);
perform set_config('app.settings.revoked_previous_count', (second_token_result->>'revoked_previous_count')::text, false);
perform set_config('app.settings.first_token_revoked', (first_token_status->>'revoked')::text, false);
perform set_config('app.settings.first_token_revoked_reason', (first_token_status->>'revoked_reason')::text, false);
perform set_config('app.settings.first_token_valid_after', (first_token_status->>'is_valid')::text, false);
end $$;
-- Check auto-revocation results
select is(
current_setting('app.settings.first_token_valid_before', false),
'true',
'First token should be valid initially'
);
select is(
current_setting('app.settings.revoked_previous_count', false)::integer,
1,
'Should report one revoked token'
);
select is(
current_setting('app.settings.first_token_revoked', false),
'true',
'First token should be revoked'
);
select is(
current_setting('app.settings.first_token_revoked_reason', false),
'Superseded by new token with same purpose',
'Revocation reason should be set'
);
select is(
current_setting('app.settings.first_token_valid_after', false),
'false',
'First token should be invalid'
);
-- ==========================================
-- Test 8: Maximum Verification Attempts
-- ==========================================
-- Test 8.1: Token should be revoked after exceeding max verification attempts
set local role service_role;
do $$
declare
token_result jsonb;
verification_result jsonb;
status_result jsonb;
token_id uuid;
token_text text;
begin
-- Create a token
token_result := public.create_nonce(
null,
'max-attempts-test',
3600
);
token_id := token_result->>'id';
token_text := token_result->>'token';
-- Manually set verification_attempts to just below the limit (3)
UPDATE public.nonces
SET verification_attempts = 3
WHERE id = token_id;
-- Get status after manual update
status_result := public.get_nonce_status(token_id);
-- Now perform a verification with an incorrect token - this should trigger max attempts exceeded
verification_result := public.verify_nonce(
'wrong-token', -- Wrong token
'max-attempts-test', -- Correct purpose,
NULL, -- No user id
NULL, -- No required scopes
3 -- Max 3 attempts
);
-- The above won't increment the counter, so we need to make one more attempt with the correct token
verification_result := public.verify_nonce(
token_text, -- Correct token
'max-attempts-test', -- Correct purpose,
NULL, -- No user id
NULL, -- No required scopes
3 -- Max 3 attempts
);
-- Check token status to verify it was revoked
status_result := public.get_nonce_status(token_id);
-- Store results for assertions outside the DO block
perform set_config('app.settings.max_attempts_verification_result', verification_result::text, false);
perform set_config('app.settings.max_attempts_status_result', status_result::text, false);
end $$;
-- Check max attempts results outside the DO block
select is(
(current_setting('app.settings.max_attempts_verification_result', false)::jsonb)->>'valid',
'false',
'Token should be invalid after exceeding max attempts'
);
select is(
(current_setting('app.settings.max_attempts_verification_result', false)::jsonb)->>'max_attempts_exceeded',
'true',
'Max attempts exceeded flag should be set'
);
select is(
(current_setting('app.settings.max_attempts_status_result', false)::jsonb)->>'revoked',
'true',
'Token should be revoked after exceeding max attempts'
);
select is(
(current_setting('app.settings.max_attempts_status_result', false)::jsonb)->>'revoked_reason',
'Maximum verification attempts exceeded',
'Revocation reason should indicate max attempts exceeded'
);
-- Test 8.2: Setting max attempts to 0 should disable the limit
do $$
declare
token_result jsonb;
verification_result jsonb;
status_result jsonb;
token_id uuid;
token_text text;
begin
-- Create a token
token_result := public.create_nonce(
null,
'unlimited-attempts-test',
3600
);
token_id := token_result->>'id';
token_text := token_result->>'token';
-- Manually set verification_attempts to a high number
UPDATE public.nonces
SET verification_attempts = 10
WHERE id = token_id;
-- Get status after manual update
status_result := public.get_nonce_status(token_id);
-- Now perform a verification with the correct token and unlimited attempts
verification_result := public.verify_nonce(
token_text, -- Correct token
'unlimited-attempts-test', -- Correct purpose,
NULL, -- No user id
NULL, -- No required scopes
0 -- Unlimited attempts (disabled)
);
-- Check token status to verify it was not revoked
status_result := public.get_nonce_status(token_id);
-- Store results for assertions outside the DO block
perform set_config('app.settings.unlimited_attempts_status', status_result::text, false);
end $$;
-- Check unlimited attempts results outside the DO block
select is(
(current_setting('app.settings.unlimited_attempts_status', false)::jsonb)->>'revoked',
'false',
'Token should not be revoked when max attempts is disabled'
);
select cmp_ok(
(current_setting('app.settings.unlimited_attempts_status', false)::jsonb)->>'verification_attempts',
'>=',
'10',
'Token should record at least 10 verification attempts'
);
-- Finish tests
select * from finish();
rollback;

View File

@@ -44,6 +44,16 @@ select
$$, row ('owner'::varchar), $$, row ('owner'::varchar),
'The primary owner should have the owner role for the team account'); 'The primary owner should have the owner role for the team account');
select is(
public.is_account_owner((select
id
from public.accounts
where
slug = 'test')),
true,
'The current user should be the owner of the team account'
);
-- Should be able to see the team account -- Should be able to see the team account
select select
isnt_empty($$ isnt_empty($$
@@ -58,6 +68,16 @@ select
select select
tests.authenticate_as('test2'); tests.authenticate_as('test2');
select is(
public.is_account_owner((select
id
from public.accounts
where
slug = 'test')),
false,
'The current user should not be the owner of the team account'
);
select select
is_empty($$ is_empty($$
select select

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-supabase-saas-kit-turbo", "name": "next-supabase-saas-kit-turbo",
"version": "2.3.0", "version": "2.4.0",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"engines": { "engines": {

View File

@@ -7,7 +7,7 @@ export function CtaButton(
) { ) {
return ( return (
<Button <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} href={props.href}
> >
{props.children} {props.children}

View File

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

View File

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

View File

@@ -79,12 +79,12 @@ export async function renderInviteEmail(props: Props) {
</EmailHeader> </EmailHeader>
<EmailContent> <EmailContent>
<Text className="text-[14px] leading-[24px] text-black"> <Text className="text-[16px] leading-[24px] text-[#242424]">
{hello} {hello}
</Text> </Text>
<Text <Text
className="text-[14px] leading-[24px] text-black" className="text-[16px] leading-[24px] text-[#242424]"
dangerouslySetInnerHTML={{ __html: mainText }} dangerouslySetInnerHTML={{ __html: mainText }}
/> />
@@ -107,7 +107,7 @@ export async function renderInviteEmail(props: Props) {
<CtaButton href={props.link}>{joinTeam}</CtaButton> <CtaButton href={props.link}>{joinTeam}</CtaButton>
</Section> </Section>
<Text className="text-[14px] leading-[24px] text-black"> <Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:copyPasteLink`)}{' '} {t(`${namespace}:copyPasteLink`)}{' '}
<Link href={props.link} className="text-blue-600 no-underline"> <Link href={props.link} className="text-blue-600 no-underline">
{props.link} {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/invite.email';
export * from './emails/account-delete.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/mailers": "workspace:*",
"@kit/monitoring": "workspace:*", "@kit/monitoring": "workspace:*",
"@kit/next": "workspace:*", "@kit/next": "workspace:*",
"@kit/otp": "workspace:*",
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*", "@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*", "@kit/supabase": "workspace:*",

View File

@@ -4,9 +4,11 @@ import { useFormStatus } from 'react-dom';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; 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 { 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 { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { import {
AlertDialog, AlertDialog,
@@ -18,8 +20,7 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from '@kit/ui/alert-dialog'; } from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { Form, FormControl, FormItem, FormLabel } from '@kit/ui/form'; import { Form } from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { DeletePersonalAccountSchema } from '../../schema/delete-personal-account.schema'; import { DeletePersonalAccountSchema } from '../../schema/delete-personal-account.schema';
@@ -46,6 +47,12 @@ export function AccountDangerZone() {
} }
function DeleteAccountModal() { function DeleteAccountModal() {
const { data: user } = useUser();
if (!user?.email) {
return null;
}
return ( return (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
@@ -61,22 +68,39 @@ function DeleteAccountModal() {
</AlertDialogTitle> </AlertDialogTitle>
</AlertDialogHeader> </AlertDialogHeader>
<ErrorBoundary fallback={<DeleteAccountErrorAlert />}> <ErrorBoundary fallback={<DeleteAccountErrorContainer />}>
<DeleteAccountForm /> <DeleteAccountForm email={user.email} />
</ErrorBoundary> </ErrorBoundary>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
); );
} }
function DeleteAccountForm() { function DeleteAccountForm(props: { email: string }) {
const form = useForm({ const form = useForm({
resolver: zodResolver(DeletePersonalAccountSchema), resolver: zodResolver(DeletePersonalAccountSchema),
defaultValues: { 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 ( return (
<Form {...form}> <Form {...form}>
<form <form
@@ -84,9 +108,13 @@ function DeleteAccountForm() {
action={deletePersonalAccountAction} action={deletePersonalAccountAction}
className={'flex flex-col space-y-4'} className={'flex flex-col space-y-4'}
> >
<input type="hidden" name="otp" value={otp} />
<div className={'flex flex-col space-y-6'}> <div className={'flex flex-col space-y-6'}>
<div <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 className={'flex flex-col space-y-2'}>
<div> <div>
@@ -98,25 +126,6 @@ function DeleteAccountForm() {
</div> </div>
</div> </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> </div>
<AlertDialogFooter> <AlertDialogFooter>
@@ -124,21 +133,21 @@ function DeleteAccountForm() {
<Trans i18nKey={'common:cancel'} /> <Trans i18nKey={'common:cancel'} />
</AlertDialogCancel> </AlertDialogCancel>
<DeleteAccountSubmitButton /> <DeleteAccountSubmitButton disabled={!form.formState.isValid} />
</AlertDialogFooter> </AlertDialogFooter>
</form> </form>
</Form> </Form>
); );
} }
function DeleteAccountSubmitButton() { function DeleteAccountSubmitButton(props: { disabled: boolean }) {
const { pending } = useFormStatus(); const { pending } = useFormStatus();
return ( return (
<Button <Button
data-test={'confirm-delete-account-button'} data-test={'confirm-delete-account-button'}
type={'submit'} type={'submit'}
disabled={pending} disabled={pending || props.disabled}
name={'action'} name={'action'}
variant={'destructive'} 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() { function DeleteAccountErrorAlert() {
return ( return (
<Alert variant={'destructive'}> <Alert variant={'destructive'}>

View File

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

View File

@@ -1,5 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
export const DeletePersonalAccountSchema = z.object({ 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 { redirect } from 'next/navigation';
import { enhanceAction } from '@kit/next/actions'; import { enhanceAction } from '@kit/next/actions';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger'; import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -40,6 +41,12 @@ export const deletePersonalAccountAction = enhanceAction(
userId: user.id, userId: user.id,
}; };
const otp = formData.get('otp') as string;
if (!otp) {
throw new Error('OTP is required');
}
if (!enableAccountDeletion) { if (!enableAccountDeletion) {
logger.warn(ctx, `Account deletion is not enabled`); logger.warn(ctx, `Account deletion is not enabled`);
@@ -48,14 +55,33 @@ export const deletePersonalAccountAction = enhanceAction(
logger.info(ctx, `Deleting account...`); logger.info(ctx, `Deleting account...`);
// verify the OTP
const client = getSupabaseServerClient(); 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 // create a new instance of the personal accounts service
const service = createDeletePersonalAccountService(); const service = createDeletePersonalAccountService();
// sign out the user before deleting their account
await client.auth.signOut();
// delete the user's account and cancel all subscriptions // delete the user's account and cancel all subscriptions
await service.deletePersonalAccount({ await service.deletePersonalAccount({
adminClient: getSupabaseServerAdminClient(), adminClient: getSupabaseServerAdminClient(),
@@ -63,6 +89,9 @@ export const deletePersonalAccountAction = enhanceAction(
userEmail: user.email ?? null, userEmail: user.email ?? null,
}); });
// sign out the user after deleting their account
await client.auth.signOut();
logger.info(ctx, `Account request successfully sent`); logger.info(ctx, `Account request successfully sent`);
// clear the cache for all pages // clear the cache for all pages

View File

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

View File

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

View File

@@ -3,8 +3,10 @@
import { useState, useTransition } from 'react'; import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; 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 { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { import {
AlertDialog, AlertDialog,
@@ -16,17 +18,8 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@kit/ui/alert-dialog'; } from '@kit/ui/alert-dialog';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import { Form } from '@kit/ui/form';
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-ownership-confirmation.schema'; import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-ownership-confirmation.schema';
@@ -82,16 +75,44 @@ function TransferOrganizationOwnershipForm({
}) { }) {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(); const [error, setError] = useState<boolean>();
const { data: user } = useUser();
const form = useForm({ const form = useForm<{
accountId: string;
userId: string;
otp: string;
}>({
resolver: zodResolver(TransferOwnershipConfirmationSchema), resolver: zodResolver(TransferOwnershipConfirmationSchema),
defaultValues: { defaultValues: {
confirmation: '',
accountId, accountId,
userId, 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 ( return (
<Form {...form}> <Form {...form}>
<form <form
@@ -112,43 +133,19 @@ function TransferOrganizationOwnershipForm({
<TransferOwnershipErrorAlert /> <TransferOwnershipErrorAlert />
</If> </If>
<p> <div className="border-destructive rounded-md border p-4">
<Trans <p className="text-destructive text-sm">
i18nKey={'teams:transferOwnershipDisclaimer'} <Trans
values={{ i18nKey={'teams:transferOwnershipDisclaimer'}
member: targetDisplayName, values={{
}} member: targetDisplayName,
components={{ b: <b /> }} }}
/> components={{ b: <b /> }}
</p> />
</p>
</div>
<FormField <input type="hidden" name="otp" value={otp} />
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>
);
}}
/>
<div> <div>
<p className={'text-muted-foreground'}> <p className={'text-muted-foreground'}>

View File

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

View File

@@ -1,9 +1,11 @@
import { z } from 'zod'; import { z } from 'zod';
const confirmationString = 'TRANSFER';
export const TransferOwnershipConfirmationSchema = z.object({ export const TransferOwnershipConfirmationSchema = z.object({
userId: z.string().uuid(),
confirmation: z.custom((value) => value === confirmationString),
accountId: z.string().uuid(), 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 type { SupabaseClient } from '@supabase/supabase-js';
import { enhanceAction } from '@kit/next/actions'; import { enhanceAction } from '@kit/next/actions';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger'; import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database'; import type { Database } from '@kit/supabase/database';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -23,6 +24,18 @@ export const deleteTeamAccountAction = enhanceAction(
Object.fromEntries(formData.entries()), 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 = { const ctx = {
name: 'team-accounts.delete', name: 'team-accounts.delete',
userId: user.id, userId: user.id,
@@ -59,7 +72,7 @@ async function deleteTeamAccount(params: {
const service = createDeleteTeamAccountService(); const service = createDeleteTeamAccountService();
// verify that the user has the necessary permissions to delete the team account // 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 // delete the team account
await service.deleteTeamAccount(client, params); await service.deleteTeamAccount(client, params);
@@ -67,20 +80,17 @@ async function deleteTeamAccount(params: {
async function assertUserPermissionsToDeleteTeamAccount( async function assertUserPermissionsToDeleteTeamAccount(
client: SupabaseClient<Database>, client: SupabaseClient<Database>,
params: { accountId: string,
accountId: string;
userId: string;
},
) { ) {
const { data, error } = await client const { data: isOwner, error } = await client
.from('accounts') .rpc('is_account_owner', {
.select('id') account_id: accountId,
.eq('primary_owner_user_id', params.userId) })
.eq('is_personal_account', false)
.eq('id', params.accountId)
.single(); .single();
if (error ?? !data) { if (error || !isOwner) {
throw new Error('Account not found'); 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 { revalidatePath } from 'next/cache';
import { enhanceAction } from '@kit/next/actions'; 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 { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -61,25 +63,66 @@ export const updateMemberRoleAction = enhanceAction(
/** /**
* @name transferOwnershipAction * @name transferOwnershipAction
* @description Transfers the ownership of an account to another member. * @description Transfers the ownership of an account to another member.
* Requires OTP verification for security.
*/ */
export const transferOwnershipAction = enhanceAction( export const transferOwnershipAction = enhanceAction(
async (data) => { async (data, user) => {
const client = getSupabaseServerClient(); 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 // assert that the user is the owner of the account
const { data: isOwner, error } = await client.rpc('is_account_owner', { const { data: isOwner, error } = await client.rpc('is_account_owner', {
account_id: data.accountId, account_id: data.accountId,
}); });
if (error ?? !isOwner) { if (error || !isOwner) {
logger.error(ctx, 'User is not the owner of this account');
throw new Error( throw new Error(
`You must be the owner of the account to transfer ownership`, `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); 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 // so we proceed with the transfer of ownership with admin privileges
const adminClient = getSupabaseServerAdminClient(); const adminClient = getSupabaseServerAdminClient();
@@ -89,6 +132,8 @@ export const transferOwnershipAction = enhanceAction(
// revalidate all pages that depend on the account // revalidate all pages that depend on the account
revalidatePath('/home/[account]', 'layout'); revalidatePath('/home/[account]', 'layout');
logger.info(ctx, 'Team ownership transferred successfully');
return { return {
success: true, 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 = { export type Database = {
graphql_public: { graphql_public: {
Tables: Record<never, never>; Tables: {
Views: Record<never, never>; [_ in never]: never;
};
Views: {
[_ in never]: never;
};
Functions: { Functions: {
graphql: { graphql: {
Args: { Args: {
@@ -21,8 +25,12 @@ export type Database = {
Returns: Json; Returns: Json;
}; };
}; };
Enums: Record<never, never>; Enums: {
CompositeTypes: Record<never, never>; [_ in never]: never;
};
CompositeTypes: {
[_ in never]: never;
};
}; };
public: { public: {
Tables: { Tables: {
@@ -69,29 +77,7 @@ export type Database = {
updated_at?: string | null; updated_at?: string | null;
updated_by?: string | null; updated_by?: string | null;
}; };
Relationships: [ 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'];
},
];
}; };
accounts_memberships: { accounts_memberships: {
Row: { Row: {
@@ -150,27 +136,6 @@ export type Database = {
referencedRelation: 'roles'; referencedRelation: 'roles';
referencedColumns: ['name']; 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: { billing_customers: {
@@ -296,13 +261,6 @@ export type Database = {
referencedRelation: 'user_accounts'; referencedRelation: 'user_accounts';
referencedColumns: ['id']; referencedColumns: ['id'];
}, },
{
foreignKeyName: 'invitations_invited_by_fkey';
columns: ['invited_by'];
isOneToOne: false;
referencedRelation: 'users';
referencedColumns: ['id'];
},
{ {
foreignKeyName: 'invitations_role_fkey'; foreignKeyName: 'invitations_role_fkey';
columns: ['role']; 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: { notifications: {
Row: { Row: {
account_id: string; account_id: string;
@@ -719,6 +740,19 @@ export type Database = {
updated_at: string; 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: { create_team_account: {
Args: { Args: {
account_name: string; account_name: string;
@@ -777,6 +811,12 @@ export type Database = {
Args: Record<PropertyKey, never>; Args: Record<PropertyKey, never>;
Returns: Json; Returns: Json;
}; };
get_nonce_status: {
Args: {
p_id: string;
};
Returns: Json;
};
get_upper_system_role: { get_upper_system_role: {
Args: Record<PropertyKey, never>; Args: Record<PropertyKey, never>;
Returns: string; Returns: string;
@@ -843,6 +883,13 @@ export type Database = {
}; };
Returns: boolean; Returns: boolean;
}; };
revoke_nonce: {
Args: {
p_id: string;
p_reason?: string;
};
Returns: boolean;
};
team_account_workspace: { team_account_workspace: {
Args: { Args: {
account_slug: string; account_slug: string;
@@ -922,6 +969,18 @@ export type Database = {
updated_at: string; 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: { Enums: {
app_permissions: app_permissions:
@@ -1026,6 +1085,7 @@ export type Database = {
owner_id: string | null; owner_id: string | null;
path_tokens: string[] | null; path_tokens: string[] | null;
updated_at: string | null; updated_at: string | null;
user_metadata: Json | null;
version: string | null; version: string | null;
}; };
Insert: { Insert: {
@@ -1039,6 +1099,7 @@ export type Database = {
owner_id?: string | null; owner_id?: string | null;
path_tokens?: string[] | null; path_tokens?: string[] | null;
updated_at?: string | null; updated_at?: string | null;
user_metadata?: Json | null;
version?: string | null; version?: string | null;
}; };
Update: { Update: {
@@ -1052,6 +1113,7 @@ export type Database = {
owner_id?: string | null; owner_id?: string | null;
path_tokens?: string[] | null; path_tokens?: string[] | null;
updated_at?: string | null; updated_at?: string | null;
user_metadata?: Json | null;
version?: string | null; version?: string | null;
}; };
Relationships: [ Relationships: [
@@ -1073,6 +1135,7 @@ export type Database = {
key: string; key: string;
owner_id: string | null; owner_id: string | null;
upload_signature: string; upload_signature: string;
user_metadata: Json | null;
version: string; version: string;
}; };
Insert: { Insert: {
@@ -1083,6 +1146,7 @@ export type Database = {
key: string; key: string;
owner_id?: string | null; owner_id?: string | null;
upload_signature: string; upload_signature: string;
user_metadata?: Json | null;
version: string; version: string;
}; };
Update: { Update: {
@@ -1093,6 +1157,7 @@ export type Database = {
key?: string; key?: string;
owner_id?: string | null; owner_id?: string | null;
upload_signature?: string; upload_signature?: string;
user_metadata?: Json | null;
version?: string; version?: string;
}; };
Relationships: [ Relationships: [
@@ -1160,7 +1225,9 @@ export type Database = {
]; ];
}; };
}; };
Views: Record<never, never>; Views: {
[_ in never]: never;
};
Functions: { Functions: {
can_insert_object: { can_insert_object: {
Args: { Args: {
@@ -1227,6 +1294,10 @@ export type Database = {
updated_at: string; updated_at: string;
}[]; }[];
}; };
operation: {
Args: Record<PropertyKey, never>;
Returns: string;
};
search: { search: {
Args: { Args: {
prefix: string; prefix: string;
@@ -1248,8 +1319,12 @@ export type Database = {
}[]; }[];
}; };
}; };
Enums: Record<never, never>; Enums: {
CompositeTypes: Record<never, never>; [_ in never]: never;
};
CompositeTypes: {
[_ in never]: never;
};
}; };
}; };
@@ -1334,3 +1409,18 @@ export type Enums<
: PublicEnumNameOrOptions extends keyof PublicSchema['Enums'] : PublicEnumNameOrOptions extends keyof PublicSchema['Enums']
? PublicSchema['Enums'][PublicEnumNameOrOptions] ? PublicSchema['Enums'][PublicEnumNameOrOptions]
: never; : 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'; AlertTitle.displayName = 'AlertTitle';
const AlertDescription: React.FC< const AlertDescription: React.FC<React.ComponentPropsWithRef<'div'>> = ({
React.ComponentPropsWithRef<'div'> className,
> = ({ className, ...props }) => ( ...props
}) => (
<div <div
className={cn('text-sm font-normal [&_p]:leading-relaxed', className)} className={cn('text-sm font-normal [&_p]:leading-relaxed', className)}
{...props} {...props}

View File

@@ -18,7 +18,7 @@ const Switch: React.FC<
> >
<SwitchPrimitives.Thumb <SwitchPrimitives.Thumb
className={cn( 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> </SwitchPrimitives.Root>

1152
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ const nextEslintConfig = [
extends: ['next/core-web-vitals', 'next/typescript'], extends: ['next/core-web-vitals', 'next/typescript'],
rules: { rules: {
'@next/next/no-html-link-for-pages': 'off', '@next/next/no-html-link-for-pages': 'off',
'no-undef': 'off' 'no-undef': 'off',
}, },
}), }),
]; ];