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:
committed by
GitHub
parent
20f7fd2c22
commit
d31f3eb993
@@ -48,6 +48,7 @@ export default async function EmailPage(props: EmailPageProps) {
|
||||
'change-email-address-email': 'Change Email Address Email',
|
||||
'reset-password-email': 'Reset Password Email',
|
||||
'magic-link-email': 'Magic Link Email',
|
||||
'otp-email': 'OTP Email',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -1,45 +1,48 @@
|
||||
import {
|
||||
renderAccountDeleteEmail,
|
||||
renderInviteEmail,
|
||||
renderOtpEmail,
|
||||
} from '@kit/email-templates';
|
||||
|
||||
export async function loadEmailTemplate(id: string) {
|
||||
if (id === 'account-delete-email') {
|
||||
return renderAccountDeleteEmail({
|
||||
productName: 'Makerkit',
|
||||
userDisplayName: 'Giancarlo',
|
||||
});
|
||||
}
|
||||
switch (id) {
|
||||
case 'account-delete-email':
|
||||
return renderAccountDeleteEmail({
|
||||
productName: 'Makerkit',
|
||||
userDisplayName: 'Giancarlo',
|
||||
});
|
||||
|
||||
if (id === 'invite-email') {
|
||||
return renderInviteEmail({
|
||||
teamName: 'Makerkit',
|
||||
teamLogo:
|
||||
'',
|
||||
inviter: 'Giancarlo',
|
||||
invitedUserEmail: 'test@makerkit.dev',
|
||||
link: 'https://makerkit.dev',
|
||||
productName: 'Makerkit',
|
||||
});
|
||||
}
|
||||
case 'invite-email':
|
||||
return renderInviteEmail({
|
||||
teamName: 'Makerkit',
|
||||
teamLogo: '',
|
||||
inviter: 'Giancarlo',
|
||||
invitedUserEmail: 'test@makerkit.dev',
|
||||
link: 'https://makerkit.dev',
|
||||
productName: 'Makerkit',
|
||||
});
|
||||
|
||||
if (id === 'magic-link-email') {
|
||||
return loadFromFileSystem('magic-link');
|
||||
}
|
||||
case 'otp-email':
|
||||
return renderOtpEmail({
|
||||
productName: 'Makerkit',
|
||||
otp: '123456',
|
||||
});
|
||||
|
||||
if (id === 'reset-password-email') {
|
||||
return loadFromFileSystem('reset-password');
|
||||
}
|
||||
case 'magic-link-email':
|
||||
return loadFromFileSystem('magic-link');
|
||||
|
||||
if (id === 'change-email-address-email') {
|
||||
return loadFromFileSystem('change-email-address');
|
||||
}
|
||||
case 'reset-password-email':
|
||||
return loadFromFileSystem('reset-password');
|
||||
|
||||
if (id === 'confirm-email') {
|
||||
return loadFromFileSystem('confirm-email');
|
||||
}
|
||||
case 'change-email-address-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) {
|
||||
|
||||
@@ -75,6 +75,14 @@ export default async function EmailsPage() {
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
|
||||
<CardButton asChild>
|
||||
<Link href={'/emails/otp-email'}>
|
||||
<CardButtonHeader>
|
||||
<CardButtonTitle>OTP Email</CardButtonTitle>
|
||||
</CardButtonHeader>
|
||||
</Link>
|
||||
</CardButton>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
|
||||
@@ -7,7 +7,7 @@ const testIgnore: string[] = [];
|
||||
if (!enableBillingTests) {
|
||||
console.log(
|
||||
`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');
|
||||
@@ -45,15 +45,14 @@ export default defineConfig({
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: 'on-first-retry',
|
||||
navigationTimeout: 5000,
|
||||
},
|
||||
|
||||
// test timeout set to 1 minutes
|
||||
timeout: 60 * 1000,
|
||||
expect: {
|
||||
// expect timeout set to 10 seconds
|
||||
timeout: 10 * 1000,
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
import { AuthPageObject } from '../authentication/auth.po';
|
||||
import { OtpPo } from '../utils/otp.po';
|
||||
|
||||
export class AccountPageObject {
|
||||
private readonly page: Page;
|
||||
public auth: AuthPageObject;
|
||||
private otp: OtpPo;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.auth = new AuthPageObject(page);
|
||||
this.otp = new OtpPo(page);
|
||||
}
|
||||
|
||||
async setup() {
|
||||
@@ -58,32 +61,16 @@ export class AccountPageObject {
|
||||
await this.page.click('[data-test="account-password-form"] button');
|
||||
}
|
||||
|
||||
async deleteAccount() {
|
||||
await expect(async () => {
|
||||
await this.page.click('[data-test="delete-account-button"]');
|
||||
async deleteAccount(email: string) {
|
||||
// Click the delete account button to open the modal
|
||||
await this.page.click('[data-test="delete-account-button"]');
|
||||
|
||||
await this.page.fill(
|
||||
'[data-test="delete-account-input-field"]',
|
||||
'DELETE',
|
||||
);
|
||||
// Complete the OTP verification process
|
||||
await this.otp.completeOtpVerification(email);
|
||||
|
||||
const click = this.page.click(
|
||||
'[data-test="confirm-delete-account-button"]',
|
||||
);
|
||||
await this.page.waitForTimeout(500);
|
||||
|
||||
const response = await this.page
|
||||
.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();
|
||||
await this.page.click('[data-test="confirm-delete-account-button"]');
|
||||
}
|
||||
|
||||
getProfileName() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
|
||||
import { AccountPageObject } from './account.po';
|
||||
import {AuthPageObject} from "../authentication/auth.po";
|
||||
|
||||
test.describe('Account Settings', () => {
|
||||
let page: Page;
|
||||
@@ -51,22 +52,22 @@ test.describe('Account Settings', () => {
|
||||
test.describe('Account Deletion', () => {
|
||||
test('user can delete their own account', async ({ 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
|
||||
.waitForResponse((resp) => {
|
||||
return (
|
||||
resp.url().includes('home/settings') &&
|
||||
resp.request().method() === 'POST'
|
||||
);
|
||||
})
|
||||
.then((response) => {
|
||||
expect(response.status()).toBe(303);
|
||||
});
|
||||
await page.waitForURL('/');
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ export class AuthPageObject {
|
||||
}
|
||||
|
||||
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="password"]', params.password);
|
||||
@@ -37,7 +37,7 @@ export class AuthPageObject {
|
||||
password: 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="password"]', params.password);
|
||||
@@ -50,6 +50,7 @@ export class AuthPageObject {
|
||||
email: string,
|
||||
params: {
|
||||
deleteAfter: boolean;
|
||||
subject?: string;
|
||||
} = {
|
||||
deleteAfter: true,
|
||||
},
|
||||
@@ -79,6 +80,10 @@ export class AuthPageObject {
|
||||
});
|
||||
|
||||
await this.visitConfirmEmailLink(email);
|
||||
|
||||
return {
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
async updatePassword(password: string) {
|
||||
|
||||
@@ -51,6 +51,23 @@ test.describe('Auth flow', () => {
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -2,35 +2,60 @@ import { expect, test } from '@playwright/test';
|
||||
|
||||
import { AuthPageObject } from './auth.po';
|
||||
|
||||
const email = 'owner@makerkit.dev';
|
||||
const newPassword = (Math.random() * 10000).toString();
|
||||
|
||||
test.describe('Password Reset Flow', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('will reset the password and sign in with new one', async ({ page }) => {
|
||||
const auth = new AuthPageObject(page);
|
||||
|
||||
await page.goto('/auth/password-reset');
|
||||
let email = '';
|
||||
|
||||
await page.fill('[name="email"]', email);
|
||||
await page.click('[type="submit"]');
|
||||
await expect(async () => {
|
||||
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
|
||||
.locator('a', {
|
||||
hasText: 'Back to Home Page',
|
||||
})
|
||||
.click();
|
||||
await page.context().clearCookies();
|
||||
await page.reload();
|
||||
|
||||
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
|
||||
.locator('a', {
|
||||
@@ -43,6 +68,8 @@ test.describe('Password Reset Flow', () => {
|
||||
password: newPassword,
|
||||
});
|
||||
|
||||
await page.waitForURL('/home');
|
||||
await page.waitForURL('/home', {
|
||||
timeout: 2000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ export class InvitationsPageObject {
|
||||
})
|
||||
.click();
|
||||
|
||||
return expect(this.page.url()).toContain('members');
|
||||
await this.page.waitForURL('**/home/*/members');
|
||||
}).toPass()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
import { AuthPageObject } from '../authentication/auth.po';
|
||||
import { OtpPo } from '../utils/otp.po';
|
||||
|
||||
export class TeamAccountsPageObject {
|
||||
private readonly page: Page;
|
||||
public auth: AuthPageObject;
|
||||
public otp: OtpPo;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.auth = new AuthPageObject(page);
|
||||
this.otp = new OtpPo(page);
|
||||
}
|
||||
|
||||
async setup(params = this.createTeamName()) {
|
||||
await this.auth.signUpFlow('/home');
|
||||
|
||||
const { email } = await this.auth.signUpFlow('/home');
|
||||
|
||||
await this.createTeam(params);
|
||||
|
||||
return { email, teamName: params.teamName, slug: params.slug };
|
||||
}
|
||||
|
||||
getTeamFromSelector(teamName: string) {
|
||||
@@ -39,6 +44,18 @@ export class TeamAccountsPageObject {
|
||||
}).toPass();
|
||||
}
|
||||
|
||||
goToMembers() {
|
||||
return expect(async () => {
|
||||
await this.page
|
||||
.locator('a', {
|
||||
hasText: 'Members',
|
||||
})
|
||||
.click();
|
||||
|
||||
await this.page.waitForURL('**/home/*/members');
|
||||
}).toPass();
|
||||
}
|
||||
|
||||
goToBilling() {
|
||||
return expect(async () => {
|
||||
await this.page
|
||||
@@ -94,18 +111,11 @@ export class TeamAccountsPageObject {
|
||||
}).toPass();
|
||||
}
|
||||
|
||||
async deleteAccount(teamName: string) {
|
||||
async deleteAccount(email: string) {
|
||||
await expect(async () => {
|
||||
await this.page.click('[data-test="delete-team-trigger"]');
|
||||
|
||||
await expect(
|
||||
this.page.locator('[data-test="delete-team-form-confirm-input"]'),
|
||||
).toBeVisible();
|
||||
|
||||
await this.page.fill(
|
||||
'[data-test="delete-team-form-confirm-input"]',
|
||||
teamName,
|
||||
);
|
||||
await this.otp.completeOtpVerification(email);
|
||||
|
||||
const click = this.page.click(
|
||||
'[data-test="delete-team-form-confirm-button"]',
|
||||
@@ -117,6 +127,51 @@ export class TeamAccountsPageObject {
|
||||
}).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() {
|
||||
const random = (Math.random() * 100000000).toFixed(0);
|
||||
|
||||
|
||||
@@ -1,7 +1,74 @@
|
||||
import { Page, expect, test } from '@playwright/test';
|
||||
|
||||
import { InvitationsPageObject } from '../invitations/invitations.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', () => {
|
||||
let page: Page;
|
||||
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 }) => {
|
||||
const teamAccounts = new TeamAccountsPageObject(page);
|
||||
const params = teamAccounts.createTeamName();
|
||||
|
||||
await teamAccounts.setup(params);
|
||||
const { email } = await teamAccounts.setup(params);
|
||||
await teamAccounts.goToSettings();
|
||||
|
||||
await teamAccounts.deleteAccount(params.teamName);
|
||||
await teamAccounts.deleteAccount(email);
|
||||
await teamAccounts.openAccountsSelector();
|
||||
|
||||
await expect(
|
||||
@@ -47,3 +114,62 @@ test.describe('Account Deletion', () => {
|
||||
).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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export class Mailbox {
|
||||
email: string,
|
||||
params: {
|
||||
deleteAfter: boolean;
|
||||
subject?: string;
|
||||
},
|
||||
) {
|
||||
const mailbox = email.split('@')[0];
|
||||
@@ -18,13 +19,17 @@ export class Mailbox {
|
||||
throw new Error('Invalid email');
|
||||
}
|
||||
|
||||
const json = await this.getInviteEmail(mailbox, params);
|
||||
const json = await this.getEmail(mailbox, params);
|
||||
|
||||
if (!json?.body) {
|
||||
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 el = parse(html);
|
||||
@@ -40,10 +45,49 @@ export class Mailbox {
|
||||
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,
|
||||
params: {
|
||||
deleteAfter: boolean;
|
||||
subject?: string;
|
||||
},
|
||||
) {
|
||||
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}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as Array<{ id: string }>;
|
||||
const json = (await response.json()) as Array<{
|
||||
id: string;
|
||||
subject: string;
|
||||
}>;
|
||||
|
||||
if (!json || !json.length) {
|
||||
console.log(`No emails found for mailbox ${mailbox}`);
|
||||
|
||||
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 messageResponse = await fetch(messageUrl);
|
||||
|
||||
63
apps/e2e/tests/utils/otp.po.ts
Normal file
63
apps/e2e/tests/utils/otp.po.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ async function getLayoutState() {
|
||||
|
||||
const sidebarOpenCookieValue = sidebarOpenCookie
|
||||
? sidebarOpenCookie.value === 'false'
|
||||
: personalAccountNavigationConfig.sidebarCollapsed;
|
||||
: !personalAccountNavigationConfig.sidebarCollapsed;
|
||||
|
||||
const style =
|
||||
layoutStyleCookie?.value ?? personalAccountNavigationConfig.style;
|
||||
|
||||
@@ -126,12 +126,13 @@ async function getLayoutState(account: string) {
|
||||
const cookieStore = await cookies();
|
||||
const sidebarOpenCookie = cookieStore.get('sidebar:state');
|
||||
const layoutCookie = cookieStore.get('layout-style');
|
||||
|
||||
const layoutStyle = layoutCookie?.value as PageLayoutStyle;
|
||||
const config = getTeamAccountSidebarConfig(account);
|
||||
|
||||
const sidebarOpenCookieValue = sidebarOpenCookie
|
||||
? sidebarOpenCookie.value === 'false'
|
||||
: config.sidebarCollapsed;
|
||||
: !config.sidebarCollapsed;
|
||||
|
||||
return {
|
||||
open: sidebarOpenCookieValue,
|
||||
|
||||
@@ -77,29 +77,7 @@ export type Database = {
|
||||
updated_at?: string | null;
|
||||
updated_by?: string | null;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'accounts_created_by_fkey';
|
||||
columns: ['created_by'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'accounts_primary_owner_user_id_fkey';
|
||||
columns: ['primary_owner_user_id'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'accounts_updated_by_fkey';
|
||||
columns: ['updated_by'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
];
|
||||
Relationships: [];
|
||||
};
|
||||
accounts_memberships: {
|
||||
Row: {
|
||||
@@ -158,27 +136,6 @@ export type Database = {
|
||||
referencedRelation: 'roles';
|
||||
referencedColumns: ['name'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'accounts_memberships_created_by_fkey';
|
||||
columns: ['created_by'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'accounts_memberships_updated_by_fkey';
|
||||
columns: ['updated_by'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'accounts_memberships_user_id_fkey';
|
||||
columns: ['user_id'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
];
|
||||
};
|
||||
billing_customers: {
|
||||
@@ -304,13 +261,6 @@ export type Database = {
|
||||
referencedRelation: 'user_accounts';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'invitations_invited_by_fkey';
|
||||
columns: ['invited_by'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'invitations_role_fkey';
|
||||
columns: ['role'];
|
||||
@@ -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: {
|
||||
Row: {
|
||||
account_id: string;
|
||||
@@ -727,6 +740,19 @@ export type Database = {
|
||||
updated_at: string;
|
||||
};
|
||||
};
|
||||
create_nonce: {
|
||||
Args: {
|
||||
p_user_id?: string;
|
||||
p_purpose?: string;
|
||||
p_expires_in_seconds?: number;
|
||||
p_metadata?: Json;
|
||||
p_description?: string;
|
||||
p_tags?: string[];
|
||||
p_scopes?: string[];
|
||||
p_revoke_previous?: boolean;
|
||||
};
|
||||
Returns: Json;
|
||||
};
|
||||
create_team_account: {
|
||||
Args: {
|
||||
account_name: string;
|
||||
@@ -785,6 +811,12 @@ export type Database = {
|
||||
Args: Record<PropertyKey, never>;
|
||||
Returns: Json;
|
||||
};
|
||||
get_nonce_status: {
|
||||
Args: {
|
||||
p_id: string;
|
||||
};
|
||||
Returns: Json;
|
||||
};
|
||||
get_upper_system_role: {
|
||||
Args: Record<PropertyKey, never>;
|
||||
Returns: string;
|
||||
@@ -851,6 +883,13 @@ export type Database = {
|
||||
};
|
||||
Returns: boolean;
|
||||
};
|
||||
revoke_nonce: {
|
||||
Args: {
|
||||
p_id: string;
|
||||
p_reason?: string;
|
||||
};
|
||||
Returns: boolean;
|
||||
};
|
||||
team_account_workspace: {
|
||||
Args: {
|
||||
account_slug: string;
|
||||
@@ -930,6 +969,18 @@ export type Database = {
|
||||
updated_at: string;
|
||||
};
|
||||
};
|
||||
verify_nonce: {
|
||||
Args: {
|
||||
p_token: string;
|
||||
p_purpose: string;
|
||||
p_user_id?: string;
|
||||
p_required_scopes?: string[];
|
||||
p_max_verification_attempts?: number;
|
||||
p_ip?: unknown;
|
||||
p_user_agent?: string;
|
||||
};
|
||||
Returns: Json;
|
||||
};
|
||||
};
|
||||
Enums: {
|
||||
app_permissions:
|
||||
@@ -1034,6 +1085,7 @@ export type Database = {
|
||||
owner_id: string | null;
|
||||
path_tokens: string[] | null;
|
||||
updated_at: string | null;
|
||||
user_metadata: Json | null;
|
||||
version: string | null;
|
||||
};
|
||||
Insert: {
|
||||
@@ -1047,6 +1099,7 @@ export type Database = {
|
||||
owner_id?: string | null;
|
||||
path_tokens?: string[] | null;
|
||||
updated_at?: string | null;
|
||||
user_metadata?: Json | null;
|
||||
version?: string | null;
|
||||
};
|
||||
Update: {
|
||||
@@ -1060,6 +1113,7 @@ export type Database = {
|
||||
owner_id?: string | null;
|
||||
path_tokens?: string[] | null;
|
||||
updated_at?: string | null;
|
||||
user_metadata?: Json | null;
|
||||
version?: string | null;
|
||||
};
|
||||
Relationships: [
|
||||
@@ -1081,6 +1135,7 @@ export type Database = {
|
||||
key: string;
|
||||
owner_id: string | null;
|
||||
upload_signature: string;
|
||||
user_metadata: Json | null;
|
||||
version: string;
|
||||
};
|
||||
Insert: {
|
||||
@@ -1091,6 +1146,7 @@ export type Database = {
|
||||
key: string;
|
||||
owner_id?: string | null;
|
||||
upload_signature: string;
|
||||
user_metadata?: Json | null;
|
||||
version: string;
|
||||
};
|
||||
Update: {
|
||||
@@ -1101,6 +1157,7 @@ export type Database = {
|
||||
key?: string;
|
||||
owner_id?: string | null;
|
||||
upload_signature?: string;
|
||||
user_metadata?: Json | null;
|
||||
version?: string;
|
||||
};
|
||||
Relationships: [
|
||||
@@ -1237,6 +1294,10 @@ export type Database = {
|
||||
updated_at: string;
|
||||
}[];
|
||||
};
|
||||
operation: {
|
||||
Args: Record<PropertyKey, never>;
|
||||
Returns: string;
|
||||
};
|
||||
search: {
|
||||
Args: {
|
||||
prefix: string;
|
||||
@@ -1348,3 +1409,18 @@ export type Enums<
|
||||
: PublicEnumNameOrOptions extends keyof PublicSchema['Enums']
|
||||
? PublicSchema['Enums'][PublicEnumNameOrOptions]
|
||||
: never;
|
||||
|
||||
export type CompositeTypes<
|
||||
PublicCompositeTypeNameOrOptions extends
|
||||
| keyof PublicSchema['CompositeTypes']
|
||||
| { schema: keyof Database },
|
||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
}
|
||||
? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes']
|
||||
: never = never,
|
||||
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName]
|
||||
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema['CompositeTypes']
|
||||
? PublicSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions]
|
||||
: never;
|
||||
|
||||
@@ -73,6 +73,20 @@
|
||||
"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": {
|
||||
"title": "Hey, we use cookies \uD83C\uDF6A",
|
||||
"description": "This website uses cookies to ensure you get the best experience on our website.",
|
||||
|
||||
346
apps/web/supabase/migrations/20240610000000_one_time_tokens.sql
Normal file
346
apps/web/supabase/migrations/20240610000000_one_time_tokens.sql
Normal 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';
|
||||
@@ -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,
|
||||
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(
|
||||
identifier text,
|
||||
user_email text
|
||||
|
||||
853
apps/web/supabase/tests/database/otp.test.sql
Normal file
853
apps/web/supabase/tests/database/otp.test.sql
Normal 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;
|
||||
|
||||
@@ -44,6 +44,16 @@ select
|
||||
$$, row ('owner'::varchar),
|
||||
'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
|
||||
select
|
||||
isnt_empty($$
|
||||
@@ -58,6 +68,16 @@ select
|
||||
select
|
||||
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
|
||||
is_empty($$
|
||||
select
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "next-supabase-saas-kit-turbo",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"engines": {
|
||||
|
||||
@@ -7,7 +7,7 @@ export function CtaButton(
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
className="w-full bg-[#000000] rounded text-white text-[16px] font-semibold no-underline text-center py-3"
|
||||
className="w-full rounded bg-[#000000] py-3 text-center text-[16px] font-semibold text-white no-underline"
|
||||
href={props.href}
|
||||
>
|
||||
{props.children}
|
||||
|
||||
@@ -3,9 +3,7 @@ import { Container, Section } from '@react-email/components';
|
||||
export function EmailHeader(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<Container>
|
||||
<Section>
|
||||
{props.children}
|
||||
</Section>
|
||||
<Section>{props.children}</Section>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,29 +54,29 @@ export async function renderAccountDeleteEmail(props: Props) {
|
||||
</EmailHeader>
|
||||
|
||||
<EmailContent>
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:hello`, {
|
||||
displayName: props.userDisplayName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:paragraph1`, {
|
||||
productName: props.productName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:paragraph2`)}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:paragraph3`, {
|
||||
productName: props.productName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:paragraph4`, {
|
||||
productName: props.productName,
|
||||
})}
|
||||
|
||||
@@ -79,12 +79,12 @@ export async function renderInviteEmail(props: Props) {
|
||||
</EmailHeader>
|
||||
|
||||
<EmailContent>
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{hello}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
className="text-[14px] leading-[24px] text-black"
|
||||
className="text-[16px] leading-[24px] text-[#242424]"
|
||||
dangerouslySetInnerHTML={{ __html: mainText }}
|
||||
/>
|
||||
|
||||
@@ -107,7 +107,7 @@ export async function renderInviteEmail(props: Props) {
|
||||
<CtaButton href={props.link}>{joinTeam}</CtaButton>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[14px] leading-[24px] text-black">
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:copyPasteLink`)}{' '}
|
||||
<Link href={props.link} className="text-blue-600 no-underline">
|
||||
{props.link}
|
||||
|
||||
97
packages/email-templates/src/emails/otp.email.tsx
Normal file
97
packages/email-templates/src/emails/otp.email.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './emails/invite.email';
|
||||
export * from './emails/account-delete.email';
|
||||
export * from './emails/otp.email';
|
||||
|
||||
7
packages/email-templates/src/locales/en/otp-email.json
Normal file
7
packages/email-templates/src/locales/en/otp-email.json
Normal 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."
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
"@kit/mailers": "workspace:*",
|
||||
"@kit/monitoring": "workspace:*",
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/otp": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useFormStatus } from 'react-dom';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { ErrorBoundary } from '@kit/monitoring/components';
|
||||
import { VerifyOtpForm } from '@kit/otp/components';
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -18,8 +20,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Form, FormControl, FormItem, FormLabel } from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Form } from '@kit/ui/form';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { DeletePersonalAccountSchema } from '../../schema/delete-personal-account.schema';
|
||||
@@ -46,6 +47,12 @@ export function AccountDangerZone() {
|
||||
}
|
||||
|
||||
function DeleteAccountModal() {
|
||||
const { data: user } = useUser();
|
||||
|
||||
if (!user?.email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -61,22 +68,39 @@ function DeleteAccountModal() {
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<ErrorBoundary fallback={<DeleteAccountErrorAlert />}>
|
||||
<DeleteAccountForm />
|
||||
<ErrorBoundary fallback={<DeleteAccountErrorContainer />}>
|
||||
<DeleteAccountForm email={user.email} />
|
||||
</ErrorBoundary>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountForm() {
|
||||
function DeleteAccountForm(props: { email: string }) {
|
||||
const form = useForm({
|
||||
resolver: zodResolver(DeletePersonalAccountSchema),
|
||||
defaultValues: {
|
||||
confirmation: '' as 'DELETE'
|
||||
otp: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { otp } = useWatch({ control: form.control });
|
||||
|
||||
if (!otp) {
|
||||
return (
|
||||
<VerifyOtpForm
|
||||
purpose={'delete-personal-account'}
|
||||
email={props.email}
|
||||
onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })}
|
||||
CancelButton={
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -84,9 +108,13 @@ function DeleteAccountForm() {
|
||||
action={deletePersonalAccountAction}
|
||||
className={'flex flex-col space-y-4'}
|
||||
>
|
||||
<input type="hidden" name="otp" value={otp} />
|
||||
|
||||
<div className={'flex flex-col space-y-6'}>
|
||||
<div
|
||||
className={'border-destructive text-destructive border p-4 text-sm'}
|
||||
className={
|
||||
'border-destructive text-destructive rounded-md border p-4 text-sm'
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<div>
|
||||
@@ -98,25 +126,6 @@ function DeleteAccountForm() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'account:deleteProfileConfirmationInputLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete={'off'}
|
||||
data-test={'delete-account-input-field'}
|
||||
required
|
||||
name={'confirmation'}
|
||||
type={'text'}
|
||||
className={'w-full'}
|
||||
placeholder={''}
|
||||
pattern={`DELETE`}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
@@ -124,21 +133,21 @@ function DeleteAccountForm() {
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<DeleteAccountSubmitButton />
|
||||
<DeleteAccountSubmitButton disabled={!form.formState.isValid} />
|
||||
</AlertDialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountSubmitButton() {
|
||||
function DeleteAccountSubmitButton(props: { disabled: boolean }) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-test={'confirm-delete-account-button'}
|
||||
type={'submit'}
|
||||
disabled={pending}
|
||||
disabled={pending || props.disabled}
|
||||
name={'action'}
|
||||
variant={'destructive'}
|
||||
>
|
||||
@@ -151,6 +160,20 @@ function DeleteAccountSubmitButton() {
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountErrorContainer() {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<DeleteAccountErrorAlert />
|
||||
|
||||
<div>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
|
||||
@@ -412,8 +412,15 @@ function FactorNameForm(
|
||||
}
|
||||
|
||||
function QrImage({ src }: { src: string }) {
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img alt={'QR Code'} src={src} width={160} height={160} className={'p-2 bg-white'} />;
|
||||
return (
|
||||
<img
|
||||
alt={'QR Code'}
|
||||
src={src}
|
||||
width={160}
|
||||
height={160}
|
||||
className={'bg-white p-2'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useEnrollFactor(userId: string) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DeletePersonalAccountSchema = z.object({
|
||||
confirmation: z.string().refine((value) => value === 'DELETE'),
|
||||
otp: z.string().min(6),
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { createOtpApi } from '@kit/otp';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
@@ -40,6 +41,12 @@ export const deletePersonalAccountAction = enhanceAction(
|
||||
userId: user.id,
|
||||
};
|
||||
|
||||
const otp = formData.get('otp') as string;
|
||||
|
||||
if (!otp) {
|
||||
throw new Error('OTP is required');
|
||||
}
|
||||
|
||||
if (!enableAccountDeletion) {
|
||||
logger.warn(ctx, `Account deletion is not enabled`);
|
||||
|
||||
@@ -48,14 +55,33 @@ export const deletePersonalAccountAction = enhanceAction(
|
||||
|
||||
logger.info(ctx, `Deleting account...`);
|
||||
|
||||
// verify the OTP
|
||||
const client = getSupabaseServerClient();
|
||||
const otpApi = createOtpApi(client);
|
||||
|
||||
const otpResult = await otpApi.verifyToken({
|
||||
token: otp,
|
||||
userId: user.id,
|
||||
purpose: 'delete-personal-account',
|
||||
});
|
||||
|
||||
if (!otpResult.valid) {
|
||||
throw new Error('Invalid OTP');
|
||||
}
|
||||
|
||||
// validate the user ID matches the nonce's user ID
|
||||
if (otpResult.user_id !== user.id) {
|
||||
logger.error(
|
||||
ctx,
|
||||
`This token was meant to be used by a different user. Exiting.`,
|
||||
);
|
||||
|
||||
throw new Error('Nonce mismatch');
|
||||
}
|
||||
|
||||
// create a new instance of the personal accounts service
|
||||
const service = createDeletePersonalAccountService();
|
||||
|
||||
// sign out the user before deleting their account
|
||||
await client.auth.signOut();
|
||||
|
||||
// delete the user's account and cancel all subscriptions
|
||||
await service.deletePersonalAccount({
|
||||
adminClient: getSupabaseServerAdminClient(),
|
||||
@@ -63,6 +89,9 @@ export const deletePersonalAccountAction = enhanceAction(
|
||||
userEmail: user.email ?? null,
|
||||
});
|
||||
|
||||
// sign out the user after deleting their account
|
||||
await client.auth.signOut();
|
||||
|
||||
logger.info(ctx, `Account request successfully sent`);
|
||||
|
||||
// clear the cache for all pages
|
||||
|
||||
@@ -47,6 +47,12 @@ class DeletePersonalAccountService {
|
||||
// execute the deletion of the user
|
||||
try {
|
||||
await params.adminClient.auth.admin.deleteUser(userId);
|
||||
|
||||
logger.info(ctx, 'User successfully deleted!');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
@@ -58,7 +64,5 @@ class DeletePersonalAccountService {
|
||||
|
||||
throw new Error('Error deleting user');
|
||||
}
|
||||
|
||||
logger.info(ctx, 'User successfully deleted!');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@kit/mailers": "workspace:*",
|
||||
"@kit/monitoring": "workspace:*",
|
||||
"@kit/next": "workspace:*",
|
||||
"@kit/otp": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { VerifyOtpForm } from '@kit/otp/components';
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -16,17 +18,8 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Form } from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-ownership-confirmation.schema';
|
||||
@@ -82,16 +75,44 @@ function TransferOrganizationOwnershipForm({
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<boolean>();
|
||||
const { data: user } = useUser();
|
||||
|
||||
const form = useForm({
|
||||
const form = useForm<{
|
||||
accountId: string;
|
||||
userId: string;
|
||||
otp: string;
|
||||
}>({
|
||||
resolver: zodResolver(TransferOwnershipConfirmationSchema),
|
||||
defaultValues: {
|
||||
confirmation: '',
|
||||
accountId,
|
||||
userId,
|
||||
otp: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { otp } = useWatch({ control: form.control });
|
||||
|
||||
// If no OTP has been entered yet, show the OTP verification form
|
||||
if (!otp) {
|
||||
return (
|
||||
<div className="flex flex-col space-y-6">
|
||||
<VerifyOtpForm
|
||||
purpose={`transfer-team-ownership-${accountId}`}
|
||||
email={user?.email || ''}
|
||||
onSuccess={(otpValue) => {
|
||||
form.setValue('otp', otpValue, { shouldValidate: true });
|
||||
}}
|
||||
CancelButton={
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
}
|
||||
data-test="verify-otp-form"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -112,43 +133,19 @@ function TransferOrganizationOwnershipForm({
|
||||
<TransferOwnershipErrorAlert />
|
||||
</If>
|
||||
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey={'teams:transferOwnershipDisclaimer'}
|
||||
values={{
|
||||
member: targetDisplayName,
|
||||
}}
|
||||
components={{ b: <b /> }}
|
||||
/>
|
||||
</p>
|
||||
<div className="border-destructive rounded-md border p-4">
|
||||
<p className="text-destructive text-sm">
|
||||
<Trans
|
||||
i18nKey={'teams:transferOwnershipDisclaimer'}
|
||||
values={{
|
||||
member: targetDisplayName,
|
||||
}}
|
||||
components={{ b: <b /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
name={'confirmation'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'teams:transferOwnershipInputLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete={'off'}
|
||||
type={'text'}
|
||||
required
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey={'teams:transferOwnershipInputDescription'} />
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<input type="hidden" name="otp" value={otp} />
|
||||
|
||||
<div>
|
||||
<p className={'text-muted-foreground'}>
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { useFormStatus } from 'react-dom';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorBoundary } from '@kit/monitoring/components';
|
||||
import { VerifyOtpForm } from '@kit/otp/components';
|
||||
import { useUser } from '@kit/supabase/hooks/use-user';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import {
|
||||
@@ -61,8 +62,12 @@ export function TeamAccountDangerZone({
|
||||
// Only the primary owner can delete the team account
|
||||
const userIsPrimaryOwner = user.id === primaryOwnerUserId;
|
||||
|
||||
if (userIsPrimaryOwner && features.enableTeamDeletion) {
|
||||
return <DeleteTeamContainer account={account} />;
|
||||
if (userIsPrimaryOwner) {
|
||||
if (features.enableTeamDeletion) {
|
||||
return <DeleteTeamContainer account={account} />;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// A primary owner can't leave the team account
|
||||
@@ -79,7 +84,7 @@ function DeleteTeamContainer(props: {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<span className={'font-medium'}>
|
||||
<span className={'text-sm font-medium'}>
|
||||
<Trans i18nKey={'teams:deleteTeam'} />
|
||||
</span>
|
||||
|
||||
@@ -139,22 +144,42 @@ function DeleteTeamConfirmationForm({
|
||||
name: string;
|
||||
id: string;
|
||||
}) {
|
||||
const { data: user } = useUser();
|
||||
|
||||
const form = useForm({
|
||||
mode: 'onChange',
|
||||
reValidateMode: 'onChange',
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
name: z.string().refine((value) => value === name, {
|
||||
message: 'Name does not match',
|
||||
path: ['name'],
|
||||
}),
|
||||
otp: z.string().min(6).max(6),
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
name: ''
|
||||
otp: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { otp } = useWatch({ control: form.control });
|
||||
|
||||
if (!user?.email) {
|
||||
return <LoadingOverlay fullPage={false} />;
|
||||
}
|
||||
|
||||
if (!otp) {
|
||||
return (
|
||||
<VerifyOtpForm
|
||||
purpose={`delete-team-account-${id}`}
|
||||
email={user.email}
|
||||
onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })}
|
||||
CancelButton={
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={<DeleteTeamErrorAlert />}>
|
||||
<Form {...form}>
|
||||
@@ -166,8 +191,7 @@ function DeleteTeamConfirmationForm({
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<div
|
||||
className={
|
||||
'border-2 border-red-500 p-4 text-sm text-red-500' +
|
||||
' my-4 flex flex-col space-y-2'
|
||||
'border-destructive text-destructive my-4 flex flex-col space-y-2 rounded-md border-2 p-4 text-sm'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
@@ -185,36 +209,7 @@ function DeleteTeamConfirmationForm({
|
||||
</div>
|
||||
|
||||
<input type="hidden" value={id} name={'accountId'} />
|
||||
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'teams:teamNameInputLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'delete-team-form-confirm-input'}
|
||||
required
|
||||
type={'text'}
|
||||
autoComplete={'off'}
|
||||
className={'w-full'}
|
||||
placeholder={''}
|
||||
pattern={name}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey={'teams:deleteTeamInputField'} />
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'confirm'}
|
||||
/>
|
||||
<input type="hidden" value={otp} name={'otp'} />
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
@@ -260,7 +255,7 @@ function LeaveTeamContainer(props: {
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
confirmation: '' as 'LEAVE'
|
||||
confirmation: '' as 'LEAVE',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -375,7 +370,7 @@ function LeaveTeamSubmitButton() {
|
||||
|
||||
function LeaveTeamErrorAlert() {
|
||||
return (
|
||||
<>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'teams:leaveTeamErrorHeading'} />
|
||||
@@ -391,20 +386,28 @@ function LeaveTeamErrorAlert() {
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
</AlertDialogFooter>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteTeamErrorAlert() {
|
||||
return (
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'teams:deleteTeamErrorHeading'} />
|
||||
</AlertTitle>
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert variant={'destructive'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'teams:deleteTeamErrorHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'common:genericError'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'common:genericError'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
</AlertDialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ import { z } from 'zod';
|
||||
|
||||
export const DeleteTeamAccountSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
otp: z.string().min(1),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const confirmationString = 'TRANSFER';
|
||||
|
||||
export const TransferOwnershipConfirmationSchema = z.object({
|
||||
userId: z.string().uuid(),
|
||||
confirmation: z.custom((value) => value === confirmationString),
|
||||
accountId: z.string().uuid(),
|
||||
userId: z.string().uuid(),
|
||||
otp: z.string().min(6),
|
||||
});
|
||||
|
||||
export type TransferOwnershipConfirmationData = z.infer<
|
||||
typeof TransferOwnershipConfirmationSchema
|
||||
>;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { createOtpApi } from '@kit/otp';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
@@ -23,6 +24,18 @@ export const deleteTeamAccountAction = enhanceAction(
|
||||
Object.fromEntries(formData.entries()),
|
||||
);
|
||||
|
||||
const otpService = createOtpApi(getSupabaseServerClient());
|
||||
|
||||
const otpResult = await otpService.verifyToken({
|
||||
purpose: `delete-team-account-${params.accountId}`,
|
||||
userId: user.id,
|
||||
token: params.otp,
|
||||
});
|
||||
|
||||
if (!otpResult.valid) {
|
||||
throw new Error('Invalid OTP code');
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
name: 'team-accounts.delete',
|
||||
userId: user.id,
|
||||
@@ -59,7 +72,7 @@ async function deleteTeamAccount(params: {
|
||||
const service = createDeleteTeamAccountService();
|
||||
|
||||
// verify that the user has the necessary permissions to delete the team account
|
||||
await assertUserPermissionsToDeleteTeamAccount(client, params);
|
||||
await assertUserPermissionsToDeleteTeamAccount(client, params.accountId);
|
||||
|
||||
// delete the team account
|
||||
await service.deleteTeamAccount(client, params);
|
||||
@@ -67,20 +80,17 @@ async function deleteTeamAccount(params: {
|
||||
|
||||
async function assertUserPermissionsToDeleteTeamAccount(
|
||||
client: SupabaseClient<Database>,
|
||||
params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
},
|
||||
accountId: string,
|
||||
) {
|
||||
const { data, error } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('primary_owner_user_id', params.userId)
|
||||
.eq('is_personal_account', false)
|
||||
.eq('id', params.accountId)
|
||||
const { data: isOwner, error } = await client
|
||||
.rpc('is_account_owner', {
|
||||
account_id: accountId,
|
||||
})
|
||||
.single();
|
||||
|
||||
if (error ?? !data) {
|
||||
throw new Error('Account not found');
|
||||
if (error || !isOwner) {
|
||||
throw new Error('You do not have permission to delete this account');
|
||||
}
|
||||
|
||||
return isOwner;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { createOtpApi } from '@kit/otp';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
@@ -61,25 +63,66 @@ export const updateMemberRoleAction = enhanceAction(
|
||||
/**
|
||||
* @name transferOwnershipAction
|
||||
* @description Transfers the ownership of an account to another member.
|
||||
* Requires OTP verification for security.
|
||||
*/
|
||||
export const transferOwnershipAction = enhanceAction(
|
||||
async (data) => {
|
||||
async (data, user) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
name: 'teams.transferOwnership',
|
||||
userId: user.id,
|
||||
accountId: data.accountId,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Processing team ownership transfer request...');
|
||||
|
||||
// assert that the user is the owner of the account
|
||||
const { data: isOwner, error } = await client.rpc('is_account_owner', {
|
||||
account_id: data.accountId,
|
||||
});
|
||||
|
||||
if (error ?? !isOwner) {
|
||||
if (error || !isOwner) {
|
||||
logger.error(ctx, 'User is not the owner of this account');
|
||||
|
||||
throw new Error(
|
||||
`You must be the owner of the account to transfer ownership`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the OTP
|
||||
const otpApi = createOtpApi(client);
|
||||
|
||||
const otpResult = await otpApi.verifyToken({
|
||||
token: data.otp,
|
||||
userId: user.id,
|
||||
purpose: `transfer-team-ownership-${data.accountId}`,
|
||||
});
|
||||
|
||||
if (!otpResult.valid) {
|
||||
logger.error(ctx, 'Invalid OTP provided');
|
||||
throw new Error('Invalid OTP');
|
||||
}
|
||||
|
||||
// validate the user ID matches the nonce's user ID
|
||||
if (otpResult.user_id !== user.id) {
|
||||
logger.error(
|
||||
ctx,
|
||||
`This token was meant to be used by a different user. Exiting.`,
|
||||
);
|
||||
|
||||
throw new Error('Nonce mismatch');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
ctx,
|
||||
'OTP verification successful. Proceeding with ownership transfer...',
|
||||
);
|
||||
|
||||
const service = createAccountMembersService(client);
|
||||
|
||||
// at this point, the user is authenticated and is the owner of the account
|
||||
// at this point, the user is authenticated, is the owner of the account, and has verified via OTP
|
||||
// so we proceed with the transfer of ownership with admin privileges
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
@@ -89,6 +132,8 @@ export const transferOwnershipAction = enhanceAction(
|
||||
// revalidate all pages that depend on the account
|
||||
revalidatePath('/home/[account]', 'layout');
|
||||
|
||||
logger.info(ctx, 'Team ownership transferred successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
|
||||
3
packages/otp/README.md
Normal file
3
packages/otp/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# One-Time Password (OTP) - @kit/otp
|
||||
|
||||
This package provides a service for working with one-time passwords and tokens in Supabase.
|
||||
3
packages/otp/eslint.config.mjs
Normal file
3
packages/otp/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '@kit/eslint-config/base.js';
|
||||
|
||||
export default baseConfig;
|
||||
43
packages/otp/package.json
Normal file
43
packages/otp/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@kit/otp",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/api/index.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
117
packages/otp/src/api/index.ts
Normal file
117
packages/otp/src/api/index.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @file API for one-time passwords/tokens
|
||||
*
|
||||
* Usage
|
||||
*
|
||||
* ```typescript
|
||||
* import { createOtpApi } from '@kit/otp/api';
|
||||
* import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
* import { NoncePurpose } from '@kit/otp/types';
|
||||
*
|
||||
* const client = getSupabaseServerClient();
|
||||
* const api = createOtpApi(client);
|
||||
*
|
||||
* // Create a one-time password token
|
||||
* const { token } = await api.createToken({
|
||||
* userId: user.id,
|
||||
* purpose: NoncePurpose.PASSWORD_RESET, // Or use a custom string like 'password-reset'
|
||||
* expiresInSeconds: 3600, // 1 hour
|
||||
* metadata: { redirectTo: '/reset-password' },
|
||||
* });
|
||||
*
|
||||
* // Verify a token
|
||||
* const result = await api.verifyToken({
|
||||
* token: '...',
|
||||
* purpose: NoncePurpose.PASSWORD_RESET, // Must match the purpose used when creating
|
||||
* });
|
||||
*
|
||||
* if (result.valid) {
|
||||
* // Token is valid
|
||||
* const { userId, metadata } = result;
|
||||
* // Proceed with the operation
|
||||
* } else {
|
||||
* // Token is invalid or expired
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import { createOtpEmailService } from '../server/otp-email.service';
|
||||
import { createOtpService } from '../server/otp.service';
|
||||
import {
|
||||
CreateNonceParams,
|
||||
GetNonceStatusParams,
|
||||
RevokeNonceParams,
|
||||
SendOtpEmailParams,
|
||||
VerifyNonceParams,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* @name createOtpApi
|
||||
* @description Create an instance of the OTP API
|
||||
* @param client
|
||||
*/
|
||||
export function createOtpApi(client: SupabaseClient<Database>) {
|
||||
return new OtpApi(client);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name OtpApi
|
||||
* @description API for working with one-time tokens/passwords
|
||||
*/
|
||||
class OtpApi {
|
||||
private readonly service: ReturnType<typeof createOtpService>;
|
||||
private readonly emailService: ReturnType<typeof createOtpEmailService>;
|
||||
|
||||
constructor(client: SupabaseClient<Database>) {
|
||||
this.service = createOtpService(client);
|
||||
this.emailService = createOtpEmailService();
|
||||
}
|
||||
|
||||
/**
|
||||
* @name sendOtpEmail
|
||||
* @description Sends an OTP email to the user
|
||||
* @param params
|
||||
*/
|
||||
sendOtpEmail(params: SendOtpEmailParams) {
|
||||
return this.emailService.sendOtpEmail(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name createToken
|
||||
* @description Creates a new one-time token
|
||||
* @param params
|
||||
*/
|
||||
createToken(params: CreateNonceParams) {
|
||||
return this.service.createNonce(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name verifyToken
|
||||
* @description Verifies a one-time token
|
||||
* @param params
|
||||
*/
|
||||
verifyToken(params: VerifyNonceParams) {
|
||||
return this.service.verifyNonce(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name revokeToken
|
||||
* @description Revokes a one-time token to prevent its use
|
||||
* @param params
|
||||
*/
|
||||
revokeToken(params: RevokeNonceParams) {
|
||||
return this.service.revokeNonce(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getTokenStatus
|
||||
* @description Gets the status of a one-time token
|
||||
* @param params
|
||||
*/
|
||||
getTokenStatus(params: GetNonceStatusParams) {
|
||||
return this.service.getNonceStatus(params);
|
||||
}
|
||||
}
|
||||
1
packages/otp/src/components/index.ts
Normal file
1
packages/otp/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { VerifyOtpForm } from './verify-otp-form';
|
||||
257
packages/otp/src/components/verify-otp-form.tsx
Normal file
257
packages/otp/src/components/verify-otp-form.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
} from '@kit/ui/input-otp';
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { sendOtpEmailAction } from '../server/server-actions';
|
||||
|
||||
// Email form schema
|
||||
const SendOtpSchema = z.object({
|
||||
email: z.string().email({ message: 'Please enter a valid email address' }),
|
||||
});
|
||||
|
||||
// OTP verification schema
|
||||
const VerifyOtpSchema = z.object({
|
||||
otp: z.string().min(6, { message: 'Please enter a valid OTP code' }).max(6),
|
||||
});
|
||||
|
||||
type VerifyOtpFormProps = {
|
||||
// Purpose of the OTP (e.g., 'email-verification', 'password-reset')
|
||||
purpose: string;
|
||||
// Callback when OTP is successfully verified
|
||||
onSuccess: (otp: string) => void;
|
||||
// Email address to send the OTP to
|
||||
email: string;
|
||||
// Customize form appearance
|
||||
className?: string;
|
||||
// Optional cancel button
|
||||
CancelButton?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function VerifyOtpForm({
|
||||
purpose,
|
||||
email,
|
||||
className,
|
||||
CancelButton,
|
||||
onSuccess,
|
||||
}: VerifyOtpFormProps) {
|
||||
// Track the current step (email entry or OTP verification)
|
||||
const [step, setStep] = useState<'email' | 'otp'>('email');
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Track errors
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Track verification success
|
||||
const [, setVerificationSuccess] = useState(false);
|
||||
|
||||
// Email form
|
||||
const emailForm = useForm<z.infer<typeof SendOtpSchema>>({
|
||||
resolver: zodResolver(SendOtpSchema),
|
||||
defaultValues: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
// OTP verification form
|
||||
const otpForm = useForm<z.infer<typeof VerifyOtpSchema>>({
|
||||
resolver: zodResolver(VerifyOtpSchema),
|
||||
defaultValues: {
|
||||
otp: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Handle sending OTP email
|
||||
const handleSendOtp = () => {
|
||||
setError(null);
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await sendOtpEmailAction({
|
||||
purpose,
|
||||
email,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setStep('otp');
|
||||
} else {
|
||||
setError(result.error || 'Failed to send OTP. Please try again.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An unexpected error occurred. Please try again.');
|
||||
console.error('Error sending OTP:', err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Handle OTP verification
|
||||
const handleVerifyOtp = (data: z.infer<typeof VerifyOtpSchema>) => {
|
||||
setVerificationSuccess(true);
|
||||
onSuccess(data.otp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{step === 'email' ? (
|
||||
<Form {...emailForm}>
|
||||
<form
|
||||
className="flex flex-col gap-y-8"
|
||||
onSubmit={emailForm.handleSubmit(handleSendOtp)}
|
||||
>
|
||||
<div className="flex flex-col gap-y-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<Trans
|
||||
i18nKey="common:otp.requestVerificationCodeDescription"
|
||||
values={{ email }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<If condition={Boolean(error)}>
|
||||
<Alert variant="destructive">
|
||||
<ExclamationTriangleIcon className="h-4 w-4" />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey="common:otp.errorSendingCode" />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<div className="flex w-full justify-end gap-2">
|
||||
{CancelButton}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
data-test="otp-send-verification-button"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
<Trans i18nKey="common:otp.sendingCode" />
|
||||
</>
|
||||
) : (
|
||||
<Trans i18nKey="common:otp.sendVerificationCode" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<Form {...otpForm}>
|
||||
<div className="flex w-full flex-col items-center gap-y-8">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
<Trans i18nKey="common:otp.codeSentToEmail" values={{ email }} />
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex w-full flex-col items-center space-y-8"
|
||||
onSubmit={otpForm.handleSubmit(handleVerifyOtp)}
|
||||
>
|
||||
<If condition={Boolean(error)}>
|
||||
<Alert variant="destructive">
|
||||
<ExclamationTriangleIcon className="h-4 w-4" />
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey="common:error" />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name="otp"
|
||||
control={otpForm.control}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
disabled={isPending}
|
||||
data-test="otp-input"
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} data-slot="0" />
|
||||
<InputOTPSlot index={1} data-slot="1" />
|
||||
<InputOTPSlot index={2} data-slot="2" />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} data-slot="3" />
|
||||
<InputOTPSlot index={4} data-slot="4" />
|
||||
<InputOTPSlot index={5} data-slot="5" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey="common:otp.enterCodeFromEmail" />
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-between gap-2">
|
||||
{CancelButton}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={isPending}
|
||||
onClick={() => setStep('email')}
|
||||
>
|
||||
<Trans i18nKey="common:otp.requestNewCode" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
data-test="otp-verify-button"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Spinner className="mr-2 h-4 w-4" />
|
||||
<Trans i18nKey="common:otp.verifying" />
|
||||
</>
|
||||
) : (
|
||||
<Trans i18nKey="common:otp.verifyCode" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
packages/otp/src/server/index.ts
Normal file
1
packages/otp/src/server/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './otp.service';
|
||||
62
packages/otp/src/server/otp-email.service.ts
Normal file
62
packages/otp/src/server/otp-email.service.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { renderOtpEmail } from '@kit/email-templates';
|
||||
import { getMailer } from '@kit/mailers';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
const EMAIL_SENDER = z
|
||||
.string({
|
||||
required_error: 'EMAIL_SENDER is required',
|
||||
})
|
||||
.min(1)
|
||||
.parse(process.env.EMAIL_SENDER);
|
||||
|
||||
const PRODUCT_NAME = z
|
||||
.string({
|
||||
required_error: 'PRODUCT_NAME is required',
|
||||
})
|
||||
.min(1)
|
||||
.parse(process.env.NEXT_PUBLIC_PRODUCT_NAME);
|
||||
|
||||
/**
|
||||
* @name createOtpEmailService
|
||||
* @description Creates a new OtpEmailService
|
||||
* @returns {OtpEmailService}
|
||||
*/
|
||||
export function createOtpEmailService() {
|
||||
return new OtpEmailService();
|
||||
}
|
||||
|
||||
/**
|
||||
* @name OtpEmailService
|
||||
* @description Service for sending OTP emails
|
||||
*/
|
||||
class OtpEmailService {
|
||||
async sendOtpEmail(params: { email: string; otp: string }) {
|
||||
const logger = await getLogger();
|
||||
const { email, otp } = params;
|
||||
const mailer = await getMailer();
|
||||
|
||||
const { html, subject } = await renderOtpEmail({
|
||||
otp,
|
||||
productName: PRODUCT_NAME,
|
||||
});
|
||||
|
||||
try {
|
||||
logger.info({ otp }, 'Sending OTP email...');
|
||||
|
||||
await mailer.sendEmail({
|
||||
to: email,
|
||||
subject,
|
||||
html,
|
||||
from: EMAIL_SENDER,
|
||||
});
|
||||
|
||||
logger.info({ otp }, 'OTP email sent');
|
||||
} catch (error) {
|
||||
logger.error({ otp, error }, 'Error sending OTP email');
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
267
packages/otp/src/server/otp.service.ts
Normal file
267
packages/otp/src/server/otp.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
88
packages/otp/src/server/server-actions.ts
Normal file
88
packages/otp/src/server/server-actions.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
115
packages/otp/src/types/index.ts
Normal file
115
packages/otp/src/types/index.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @name CreateNonceParams - Parameters for creating a nonce
|
||||
*/
|
||||
export interface CreateNonceParams {
|
||||
userId?: string;
|
||||
purpose: string;
|
||||
expiresInSeconds?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
scopes?: string[];
|
||||
revokePrevious?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name VerifyNonceParams - Parameters for verifying a nonce
|
||||
*/
|
||||
export interface VerifyNonceParams {
|
||||
token: string;
|
||||
purpose: string;
|
||||
userId?: string;
|
||||
requiredScopes?: string[];
|
||||
maxVerificationAttempts?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name RevokeNonceParams - Parameters for revoking a nonce
|
||||
*/
|
||||
export interface RevokeNonceParams {
|
||||
id: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name CreateNonceResult - Result of creating a nonce
|
||||
*/
|
||||
export interface CreateNonceResult {
|
||||
id: string;
|
||||
token: string;
|
||||
expires_at: string;
|
||||
revoked_previous_count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name ValidNonceResult - Result of verifying a nonce
|
||||
*/
|
||||
type ValidNonceResult = {
|
||||
valid: boolean;
|
||||
user_id?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
message?: string;
|
||||
scopes?: string[];
|
||||
purpose?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @name InvalidNonceResult - Result of verifying a nonce
|
||||
*/
|
||||
type InvalidNonceResult = {
|
||||
valid: false;
|
||||
message: string;
|
||||
max_attempts_exceeded?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @name VerifyNonceResult - Result of verifying a nonce
|
||||
*/
|
||||
export type VerifyNonceResult = ValidNonceResult | InvalidNonceResult;
|
||||
|
||||
/**
|
||||
* @name GetNonceStatusParams - Parameters for getting nonce status
|
||||
*/
|
||||
export interface GetNonceStatusParams {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name SuccessGetNonceStatusResult - Result of getting nonce status
|
||||
*/
|
||||
type SuccessGetNonceStatusResult = {
|
||||
exists: true;
|
||||
purpose?: string;
|
||||
user_id?: string;
|
||||
created_at?: string;
|
||||
expires_at?: string;
|
||||
used_at?: string;
|
||||
revoked?: boolean;
|
||||
revoked_reason?: string;
|
||||
verification_attempts?: number;
|
||||
last_verification_at?: string;
|
||||
last_verification_ip?: string;
|
||||
is_valid?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @name FailedGetNonceStatusResult - Result of getting nonce status
|
||||
*/
|
||||
type FailedGetNonceStatusResult = {
|
||||
exists: false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @name GetNonceStatusResult - Result of getting nonce status
|
||||
*/
|
||||
export type GetNonceStatusResult =
|
||||
| SuccessGetNonceStatusResult
|
||||
| FailedGetNonceStatusResult;
|
||||
|
||||
/**
|
||||
* @name SendOtpEmailParams - Parameters for sending an OTP email
|
||||
*/
|
||||
export interface SendOtpEmailParams {
|
||||
email: string;
|
||||
otp: string;
|
||||
}
|
||||
8
packages/otp/tsconfig.json
Normal file
8
packages/otp/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -8,8 +8,12 @@ export type Json =
|
||||
|
||||
export type Database = {
|
||||
graphql_public: {
|
||||
Tables: Record<never, never>;
|
||||
Views: Record<never, never>;
|
||||
Tables: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Views: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
graphql: {
|
||||
Args: {
|
||||
@@ -21,8 +25,12 @@ export type Database = {
|
||||
Returns: Json;
|
||||
};
|
||||
};
|
||||
Enums: Record<never, never>;
|
||||
CompositeTypes: Record<never, never>;
|
||||
Enums: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
CompositeTypes: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
};
|
||||
public: {
|
||||
Tables: {
|
||||
@@ -69,29 +77,7 @@ export type Database = {
|
||||
updated_at?: string | null;
|
||||
updated_by?: string | null;
|
||||
};
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'accounts_created_by_fkey';
|
||||
columns: ['created_by'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'accounts_primary_owner_user_id_fkey';
|
||||
columns: ['primary_owner_user_id'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'accounts_updated_by_fkey';
|
||||
columns: ['updated_by'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
];
|
||||
Relationships: [];
|
||||
};
|
||||
accounts_memberships: {
|
||||
Row: {
|
||||
@@ -150,27 +136,6 @@ export type Database = {
|
||||
referencedRelation: 'roles';
|
||||
referencedColumns: ['name'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'accounts_memberships_created_by_fkey';
|
||||
columns: ['created_by'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'accounts_memberships_updated_by_fkey';
|
||||
columns: ['updated_by'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'accounts_memberships_user_id_fkey';
|
||||
columns: ['user_id'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
];
|
||||
};
|
||||
billing_customers: {
|
||||
@@ -296,13 +261,6 @@ export type Database = {
|
||||
referencedRelation: 'user_accounts';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'invitations_invited_by_fkey';
|
||||
columns: ['invited_by'];
|
||||
isOneToOne: false;
|
||||
referencedRelation: 'users';
|
||||
referencedColumns: ['id'];
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'invitations_role_fkey';
|
||||
columns: ['role'];
|
||||
@@ -312,6 +270,69 @@ export type Database = {
|
||||
},
|
||||
];
|
||||
};
|
||||
nonces: {
|
||||
Row: {
|
||||
client_token: string;
|
||||
created_at: string;
|
||||
description: string | null;
|
||||
expires_at: string;
|
||||
id: string;
|
||||
last_verification_at: string | null;
|
||||
last_verification_ip: unknown | null;
|
||||
last_verification_user_agent: string | null;
|
||||
metadata: Json | null;
|
||||
nonce: string;
|
||||
purpose: string;
|
||||
revoked: boolean;
|
||||
revoked_reason: string | null;
|
||||
scopes: string[] | null;
|
||||
tags: string[] | null;
|
||||
used_at: string | null;
|
||||
user_id: string | null;
|
||||
verification_attempts: number;
|
||||
};
|
||||
Insert: {
|
||||
client_token: string;
|
||||
created_at?: string;
|
||||
description?: string | null;
|
||||
expires_at: string;
|
||||
id?: string;
|
||||
last_verification_at?: string | null;
|
||||
last_verification_ip?: unknown | null;
|
||||
last_verification_user_agent?: string | null;
|
||||
metadata?: Json | null;
|
||||
nonce: string;
|
||||
purpose: string;
|
||||
revoked?: boolean;
|
||||
revoked_reason?: string | null;
|
||||
scopes?: string[] | null;
|
||||
tags?: string[] | null;
|
||||
used_at?: string | null;
|
||||
user_id?: string | null;
|
||||
verification_attempts?: number;
|
||||
};
|
||||
Update: {
|
||||
client_token?: string;
|
||||
created_at?: string;
|
||||
description?: string | null;
|
||||
expires_at?: string;
|
||||
id?: string;
|
||||
last_verification_at?: string | null;
|
||||
last_verification_ip?: unknown | null;
|
||||
last_verification_user_agent?: string | null;
|
||||
metadata?: Json | null;
|
||||
nonce?: string;
|
||||
purpose?: string;
|
||||
revoked?: boolean;
|
||||
revoked_reason?: string | null;
|
||||
scopes?: string[] | null;
|
||||
tags?: string[] | null;
|
||||
used_at?: string | null;
|
||||
user_id?: string | null;
|
||||
verification_attempts?: number;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
notifications: {
|
||||
Row: {
|
||||
account_id: string;
|
||||
@@ -719,6 +740,19 @@ export type Database = {
|
||||
updated_at: string;
|
||||
};
|
||||
};
|
||||
create_nonce: {
|
||||
Args: {
|
||||
p_user_id?: string;
|
||||
p_purpose?: string;
|
||||
p_expires_in_seconds?: number;
|
||||
p_metadata?: Json;
|
||||
p_description?: string;
|
||||
p_tags?: string[];
|
||||
p_scopes?: string[];
|
||||
p_revoke_previous?: boolean;
|
||||
};
|
||||
Returns: Json;
|
||||
};
|
||||
create_team_account: {
|
||||
Args: {
|
||||
account_name: string;
|
||||
@@ -777,6 +811,12 @@ export type Database = {
|
||||
Args: Record<PropertyKey, never>;
|
||||
Returns: Json;
|
||||
};
|
||||
get_nonce_status: {
|
||||
Args: {
|
||||
p_id: string;
|
||||
};
|
||||
Returns: Json;
|
||||
};
|
||||
get_upper_system_role: {
|
||||
Args: Record<PropertyKey, never>;
|
||||
Returns: string;
|
||||
@@ -843,6 +883,13 @@ export type Database = {
|
||||
};
|
||||
Returns: boolean;
|
||||
};
|
||||
revoke_nonce: {
|
||||
Args: {
|
||||
p_id: string;
|
||||
p_reason?: string;
|
||||
};
|
||||
Returns: boolean;
|
||||
};
|
||||
team_account_workspace: {
|
||||
Args: {
|
||||
account_slug: string;
|
||||
@@ -922,6 +969,18 @@ export type Database = {
|
||||
updated_at: string;
|
||||
};
|
||||
};
|
||||
verify_nonce: {
|
||||
Args: {
|
||||
p_token: string;
|
||||
p_purpose: string;
|
||||
p_user_id?: string;
|
||||
p_required_scopes?: string[];
|
||||
p_max_verification_attempts?: number;
|
||||
p_ip?: unknown;
|
||||
p_user_agent?: string;
|
||||
};
|
||||
Returns: Json;
|
||||
};
|
||||
};
|
||||
Enums: {
|
||||
app_permissions:
|
||||
@@ -1026,6 +1085,7 @@ export type Database = {
|
||||
owner_id: string | null;
|
||||
path_tokens: string[] | null;
|
||||
updated_at: string | null;
|
||||
user_metadata: Json | null;
|
||||
version: string | null;
|
||||
};
|
||||
Insert: {
|
||||
@@ -1039,6 +1099,7 @@ export type Database = {
|
||||
owner_id?: string | null;
|
||||
path_tokens?: string[] | null;
|
||||
updated_at?: string | null;
|
||||
user_metadata?: Json | null;
|
||||
version?: string | null;
|
||||
};
|
||||
Update: {
|
||||
@@ -1052,6 +1113,7 @@ export type Database = {
|
||||
owner_id?: string | null;
|
||||
path_tokens?: string[] | null;
|
||||
updated_at?: string | null;
|
||||
user_metadata?: Json | null;
|
||||
version?: string | null;
|
||||
};
|
||||
Relationships: [
|
||||
@@ -1073,6 +1135,7 @@ export type Database = {
|
||||
key: string;
|
||||
owner_id: string | null;
|
||||
upload_signature: string;
|
||||
user_metadata: Json | null;
|
||||
version: string;
|
||||
};
|
||||
Insert: {
|
||||
@@ -1083,6 +1146,7 @@ export type Database = {
|
||||
key: string;
|
||||
owner_id?: string | null;
|
||||
upload_signature: string;
|
||||
user_metadata?: Json | null;
|
||||
version: string;
|
||||
};
|
||||
Update: {
|
||||
@@ -1093,6 +1157,7 @@ export type Database = {
|
||||
key?: string;
|
||||
owner_id?: string | null;
|
||||
upload_signature?: string;
|
||||
user_metadata?: Json | null;
|
||||
version?: string;
|
||||
};
|
||||
Relationships: [
|
||||
@@ -1160,7 +1225,9 @@ export type Database = {
|
||||
];
|
||||
};
|
||||
};
|
||||
Views: Record<never, never>;
|
||||
Views: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
Functions: {
|
||||
can_insert_object: {
|
||||
Args: {
|
||||
@@ -1227,6 +1294,10 @@ export type Database = {
|
||||
updated_at: string;
|
||||
}[];
|
||||
};
|
||||
operation: {
|
||||
Args: Record<PropertyKey, never>;
|
||||
Returns: string;
|
||||
};
|
||||
search: {
|
||||
Args: {
|
||||
prefix: string;
|
||||
@@ -1248,8 +1319,12 @@ export type Database = {
|
||||
}[];
|
||||
};
|
||||
};
|
||||
Enums: Record<never, never>;
|
||||
CompositeTypes: Record<never, never>;
|
||||
Enums: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
CompositeTypes: {
|
||||
[_ in never]: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1334,3 +1409,18 @@ export type Enums<
|
||||
: PublicEnumNameOrOptions extends keyof PublicSchema['Enums']
|
||||
? PublicSchema['Enums'][PublicEnumNameOrOptions]
|
||||
: never;
|
||||
|
||||
export type CompositeTypes<
|
||||
PublicCompositeTypeNameOrOptions extends
|
||||
| keyof PublicSchema['CompositeTypes']
|
||||
| { schema: keyof Database },
|
||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof Database;
|
||||
}
|
||||
? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes']
|
||||
: never = never,
|
||||
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
|
||||
? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName]
|
||||
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema['CompositeTypes']
|
||||
? PublicSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions]
|
||||
: never;
|
||||
|
||||
@@ -47,9 +47,10 @@ const AlertTitle: React.FC<React.ComponentPropsWithRef<'h5'>> = ({
|
||||
);
|
||||
AlertTitle.displayName = 'AlertTitle';
|
||||
|
||||
const AlertDescription: React.FC<
|
||||
React.ComponentPropsWithRef<'div'>
|
||||
> = ({ className, ...props }) => (
|
||||
const AlertDescription: React.FC<React.ComponentPropsWithRef<'div'>> = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn('text-sm font-normal [&_p]:leading-relaxed', className)}
|
||||
{...props}
|
||||
|
||||
@@ -18,7 +18,7 @@ const Switch: React.FC<
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'bg-background pointer-events-none block h-4 w-4 rounded-full ring-0 shadow-lg transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||
'bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
1152
pnpm-lock.yaml
generated
1152
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ const nextEslintConfig = [
|
||||
extends: ['next/core-web-vitals', 'next/typescript'],
|
||||
rules: {
|
||||
'@next/next/no-html-link-for-pages': 'off',
|
||||
'no-undef': 'off'
|
||||
'no-undef': 'off',
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user