Enforce RLS when user opted in to MFA. (#188)

* Allow Super Admin to view tables using RLS
* Replace previous usages of the Admin client using the authed client using the new RLS
* Enforce MFA for Super Admin users
* Enforce RLS when user opted in to MFA.
* Add Super Admin Access Policies and Update Database Types
* Consolidate super admin logic into a single function that uses the RPC is_super_admin
* Added Super Admin E2E tests
* Fixes and improvements
* Bump version to 2.5.0
This commit is contained in:
Giancarlo Buomprisco
2025-03-02 10:21:01 +07:00
committed by GitHub
parent 9cf7bf0aac
commit 131b1061e6
61 changed files with 2193 additions and 302 deletions

View File

@@ -47,7 +47,7 @@ jobs:
test: test:
name: ⚫️ Test name: ⚫️ Test
timeout-minutes: 12 timeout-minutes: 15
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ vars.ENABLE_E2E_JOB == 'true' }} if: ${{ vars.ENABLE_E2E_JOB == 'true' }}
env: env:

View File

@@ -4,8 +4,8 @@ import Link from 'next/link';
import { EmailTesterFormSchema } from '@/app/emails/lib/email-tester-form-schema'; import { EmailTesterFormSchema } from '@/app/emails/lib/email-tester-form-schema';
import { sendEmailAction } from '@/app/emails/lib/server-actions'; import { sendEmailAction } from '@/app/emails/lib/server-actions';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Button } from '@kit/ui/button'; import { Button } from '@kit/ui/button';
import { import {

View File

@@ -105,7 +105,7 @@ class ConnectivityService {
if (data.length === 0) { if (data.length === 0) {
return { return {
status: 'error' as const, status: 'error' as const,
message: 'No accounts found in Supabase Admin', message: 'No accounts found in Supabase Admin. The data may not be seeded. Please run `pnpm run supabase:web:reset` to reset the database.',
}; };
} }

View File

@@ -1,10 +1,10 @@
import { EnvMode } from '@/app/variables/lib/types';
import { EnvModeSelector } from '@/components/env-mode-selector';
import { ServiceCard } from '@/components/status-tile'; import { ServiceCard } from '@/components/status-tile';
import { Page, PageBody, PageHeader } from '@kit/ui/page'; import { Page, PageBody, PageHeader } from '@kit/ui/page';
import { createConnectivityService } from './lib/connectivity-service'; import { createConnectivityService } from './lib/connectivity-service';
import {EnvMode} from "@/app/variables/lib/types";
import {EnvModeSelector} from "@/components/env-mode-selector";
type DashboardPageProps = React.PropsWithChildren<{ type DashboardPageProps = React.PropsWithChildren<{
searchParams: Promise<{ mode?: EnvMode }>; searchParams: Promise<{ mode?: EnvMode }>;

View File

@@ -47,8 +47,6 @@ type ValidationResult = {
}; };
}; };
type VariableRecord = Record<string, string>;
export function AppEnvironmentVariablesManager({ export function AppEnvironmentVariablesManager({
state, state,
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
@@ -71,11 +69,11 @@ function EnvList({ appState }: { appState: AppEnvState }) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const secretVars = searchParams.get('secret') === 'true'; const showSecretVars = searchParams.get('secret') === 'true';
const publicVars = searchParams.get('public') === 'true'; const showPublicVars = searchParams.get('public') === 'true';
const privateVars = searchParams.get('private') === 'true'; const showPrivateVars = searchParams.get('private') === 'true';
const overriddenVars = searchParams.get('overridden') === 'true'; const showOverriddenVars = searchParams.get('overridden') === 'true';
const invalidVars = searchParams.get('invalid') === 'true'; const showInvalidVars = searchParams.get('invalid') === 'true';
const toggleExpanded = (key: string) => { const toggleExpanded = (key: string) => {
setExpandedVars((prev) => ({ setExpandedVars((prev) => ({
@@ -558,16 +556,16 @@ function EnvList({ appState }: { appState: AppEnvState }) {
if ( if (
!search && !search &&
!secretVars && !showSecretVars &&
!publicVars && !showPublicVars &&
!privateVars && !showPrivateVars &&
!invalidVars && !showInvalidVars &&
!overriddenVars !showOverriddenVars
) { ) {
return true; return true;
} }
const isSecret = model?.secret; const isSecret = model?.secret ?? false;
const isPublic = varState.key.startsWith('NEXT_PUBLIC_'); const isPublic = varState.key.startsWith('NEXT_PUBLIC_');
const isPrivate = !isPublic; const isPrivate = !isPublic;
@@ -575,23 +573,23 @@ function EnvList({ appState }: { appState: AppEnvState }) {
? varState.key.toLowerCase().includes(search.toLowerCase()) ? varState.key.toLowerCase().includes(search.toLowerCase())
: true; : true;
if (isPublic && publicVars && isInSearch) { if (showPublicVars && isInSearch) {
return true; return isPublic;
} }
if (isSecret && secretVars && isInSearch) { if (showSecretVars && isInSearch) {
return true; return isSecret;
} }
if (isPrivate && privateVars && isInSearch) { if (showPrivateVars && isInSearch) {
return true; return isPrivate;
} }
if (overriddenVars && varState.isOverridden && isInSearch) { if (showOverriddenVars && isInSearch) {
return true; return varState.isOverridden;
} }
if (invalidVars) { if (showInvalidVars) {
const allVariables = getEffectiveVariablesValue(appState); const allVariables = getEffectiveVariablesValue(appState);
let hasError = false; let hasError = false;
@@ -637,14 +635,10 @@ function EnvList({ appState }: { appState: AppEnvState }) {
} }
} }
if (hasError && isInSearch) return true; return hasError && isInSearch;
} }
if (isInSearch) { return isInSearch;
return true;
}
return false;
}; };
// Update groups to use allVarsWithValidation instead of appState.variables // Update groups to use allVarsWithValidation instead of appState.variables
@@ -679,11 +673,11 @@ function EnvList({ appState }: { appState: AppEnvState }) {
<div> <div>
<FilterSwitcher <FilterSwitcher
filters={{ filters={{
secret: secretVars, secret: showSecretVars,
public: publicVars, public: showPublicVars,
overridden: overriddenVars, overridden: showOverriddenVars,
private: privateVars, private: showPrivateVars,
invalid: invalidVars, invalid: showInvalidVars,
}} }}
/> />
</div> </div>

View File

@@ -925,9 +925,7 @@ export const envVariables: EnvVariableModel[] = [
}, },
], ],
validate: ({ value }) => { validate: ({ value }) => {
return z return z.string().safeParse(value);
.string()
.safeParse(value);
}, },
}, },
}, },

View File

@@ -26,7 +26,11 @@ export function EnvModeSelector({ mode }: { mode: EnvMode }) {
return ( return (
<div> <div>
<Select name={'mode'} defaultValue={mode} onValueChange={handleModeChange}> <Select
name={'mode'}
defaultValue={mode}
onValueChange={handleModeChange}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select Mode" /> <SelectValue placeholder="Select Mode" />
</SelectTrigger> </SelectTrigger>

View File

@@ -14,6 +14,7 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.50.1", "@playwright/test": "^1.50.1",
"@types/node": "^22.13.4", "@types/node": "^22.13.4",
"node-html-parser": "^7.0.1" "node-html-parser": "^7.0.1",
"totp-generator": "^1.0.0"
} }
} }

View File

@@ -54,10 +54,12 @@ export class AccountPageObject {
'[data-test="account-password-form-password-input"]', '[data-test="account-password-form-password-input"]',
password, password,
); );
await this.page.fill( await this.page.fill(
'[data-test="account-password-form-repeat-password-input"]', '[data-test="account-password-form-repeat-password-input"]',
password, password,
); );
await this.page.click('[data-test="account-password-form"] button'); await this.page.click('[data-test="account-password-form"] button');
} }

View File

@@ -45,7 +45,9 @@ test.describe('Account Settings', () => {
await Promise.all([request, response]); await Promise.all([request, response]);
await account.auth.signOut(); await page.context().clearCookies();
await page.reload();
}); });
}); });

View File

@@ -0,0 +1,362 @@
import { Page, expect, selectors, test } from '@playwright/test';
import { AuthPageObject } from '../authentication/auth.po';
import { TeamAccountsPageObject } from '../team-accounts/team-accounts.po';
const MFA_KEY = 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE';
test.describe('Admin Auth flow without MFA', () => {
test('will return a 404 for non-admin users', async ({ page }) => {
const auth = new AuthPageObject(page);
await page.goto('/auth/sign-in');
await auth.signIn({
email: 'owner@makerkit.dev',
password: 'testingpassword',
});
await page.waitForURL('/home');
await page.goto('/admin');
expect(page.url()).toContain('/404');
});
test('will redirect to 404 for admin users without MFA', async ({ page }) => {
const auth = new AuthPageObject(page);
await page.goto('/auth/sign-in');
await auth.signIn({
email: 'test@makerkit.dev',
password: 'testingpassword',
});
await page.waitForURL('/home');
await page.goto('/admin');
expect(page.url()).toContain('/404');
});
});
test.describe('Admin', () => {
// must be serial because OTP verification is not working in parallel
test.describe.configure({ mode: 'serial' });
test.describe('Admin Dashboard', () => {
test('displays all stat cards', async ({ page }) => {
await goToAdmin(page);
// Check all stat cards are present
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Team Accounts' }),
).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Paying Customers' }),
).toBeVisible();
await expect(page.getByRole('heading', { name: 'Trials' })).toBeVisible();
// Verify stat values are numbers
const stats = await page.$$('.text-3xl.font-bold');
for (const stat of stats) {
const value = await stat.textContent();
expect(Number.isInteger(Number(value))).toBeTruthy();
}
});
});
test.describe('Personal Account Management', () => {
let testUserEmail: string;
test.beforeEach(async ({ page }) => {
selectors.setTestIdAttribute('data-test');
// Create a new test user before each test
testUserEmail = await createUser(page);
await goToAdmin(page);
// Navigate to the newly created user's account page
// Note: We need to get the user's ID from the email - this might need adjustment
// based on your URL structure
await page.goto(`/admin/accounts`);
const filterText = testUserEmail.split('@')[0]!;
await filterAccounts(page, filterText);
await selectAccount(page, filterText);
});
test('displays personal account details', async ({ page }) => {
await expect(page.getByText('Personal Account')).toBeVisible();
await expect(page.getByTestId('admin-ban-account-button')).toBeVisible();
await expect(page.getByTestId('admin-impersonate-button')).toBeVisible();
await expect(
page.getByTestId('admin-delete-account-button'),
).toBeVisible();
});
test('ban user flow', async ({ page }) => {
await page.getByTestId('admin-ban-account-button').click();
await expect(
page.getByRole('heading', { name: 'Ban User' }),
).toBeVisible();
// Try with invalid confirmation
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'WRONG');
await page.getByRole('button', { name: 'Ban User' }).click();
await expect(
page.getByRole('heading', { name: 'Ban User' }),
).toBeVisible(); // Dialog should still be open
// Confirm with correct text
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
await page.getByRole('button', { name: 'Ban User' }).click();
await expect(page.getByText('Banned')).toBeVisible();
await page.context().clearCookies();
// Verify user can't log in
await page.goto('/auth/sign-in');
const auth = new AuthPageObject(page);
await auth.signIn({
email: testUserEmail,
password: 'testingpassword',
});
// Should show an error message
await expect(
page.locator('[data-test="auth-error-message"]'),
).toBeVisible();
});
test('reactivate user flow', async ({ page }) => {
// First ban the user
await page.getByTestId('admin-ban-account-button').click();
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
await page.getByRole('button', { name: 'Ban User' }).click();
await expect(page.getByText('Banned')).toBeVisible();
// Now reactivate
await page.getByTestId('admin-reactivate-account-button').click();
await expect(
page.getByRole('heading', { name: 'Reactivate User' }),
).toBeVisible();
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
await page.getByRole('button', { name: 'Reactivate User' }).click();
// Verify ban badge is removed
await expect(page.getByText('Banned')).not.toBeVisible();
// Log out
await page.context().clearCookies();
// Verify user can log in again
await page.goto('/auth/sign-in');
const auth = new AuthPageObject(page);
await auth.signIn({
email: testUserEmail,
password: 'testingpassword',
});
await page.waitForURL('/home');
});
test('impersonate user flow', async ({ page }) => {
await page.getByTestId('admin-impersonate-button').click();
await expect(
page.getByRole('heading', { name: 'Impersonate User' }),
).toBeVisible();
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
await page.getByRole('button', { name: 'Impersonate User' }).click();
// Should redirect to home and be logged in as the user
await page.waitForURL('/home');
});
test('delete user flow', async ({ page }) => {
await page.getByTestId('admin-delete-account-button').click();
await expect(
page.getByRole('heading', { name: 'Delete User' }),
).toBeVisible();
// Try with invalid confirmation
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'WRONG');
await page.getByRole('button', { name: 'Delete' }).click();
await expect(
page.getByRole('heading', { name: 'Delete User' }),
).toBeVisible(); // Dialog should still be open
// Confirm with correct text
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
await page.getByRole('button', { name: 'Delete' }).click();
// Should redirect to admin dashboard
await expect(page).toHaveURL('/admin/accounts');
// Log out
await page.context().clearCookies();
// Verify user can't log in
await page.goto('/auth/sign-in');
const auth = new AuthPageObject(page);
await auth.signIn({
email: testUserEmail,
password: 'testingpassword',
});
// Should show an error message
await expect(
page.locator('[data-test="auth-error-message"]'),
).toBeVisible();
});
});
test.describe('Team Account Management', () => {
let testUserEmail: string;
let teamName: string;
let slug: string;
test.beforeEach(async ({ page }) => {
selectors.setTestIdAttribute('data-test');
// Create a new test user and team account
testUserEmail = await createUser(page, {
afterSignIn: async () => {
teamName = `test-${Math.random().toString(36).substring(2, 15)}`;
const teamAccountPo = new TeamAccountsPageObject(page);
const teamSlug = teamName.toLowerCase().replace(/ /g, '-');
slug = teamSlug;
await teamAccountPo.createTeam({
teamName,
slug,
});
},
});
await goToAdmin(page);
await page.goto(`/admin/accounts`);
await filterAccounts(page, teamName);
await selectAccount(page, teamName);
});
test('displays team account details', async ({ page }) => {
await expect(page.getByText('Team Account')).toBeVisible();
await expect(
page.getByTestId('admin-delete-account-button'),
).toBeVisible();
});
test('delete team account flow', async ({ page }) => {
await page.getByTestId('admin-delete-account-button').click();
await expect(
page.getByRole('heading', { name: 'Delete Account' }),
).toBeVisible();
// Try with invalid confirmation
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'WRONG');
await page.getByRole('button', { name: 'Delete' }).click();
await expect(
page.getByRole('heading', { name: 'Delete Account' }),
).toBeVisible(); // Dialog should still be open
// Confirm with correct text
await page.fill('[placeholder="Type CONFIRM to confirm"]', 'CONFIRM');
await page.getByRole('button', { name: 'Delete' }).click();
// Should redirect to admin dashboard after deletion
await expect(page).toHaveURL('/admin/accounts');
});
});
});
async function goToAdmin(page: Page) {
const auth = new AuthPageObject(page);
await page.goto('/auth/sign-in');
await auth.signIn({
email: 'super-admin@makerkit.dev',
password: 'testingpassword',
});
await page.waitForURL('/auth/verify');
await page.waitForTimeout(250);
await expect(async () => {
await auth.submitMFAVerification(MFA_KEY);
await page.waitForURL('/home');
}).toPass({
intervals: [
500, 2500, 5000, 7500, 10_000, 15_000, 20_000, 25_000, 30_000, 35_000,
40_000, 45_000, 50_000,
],
});
await page.goto('/admin');
}
async function createUser(
page: Page,
params: {
afterSignIn?: () => Promise<void>;
} = {},
) {
const auth = new AuthPageObject(page);
await page.goto('/auth/sign-up');
const email = `${(Math.random() * 1000000).toFixed(0)}@makerkit.dev`;
await auth.signUp({
email,
password: 'testingpassword',
repeatPassword: 'testingpassword',
});
await auth.visitConfirmEmailLink(email);
await page.goto('/home');
if (params.afterSignIn) {
await params.afterSignIn();
}
await auth.signOut();
await page.waitForURL('/');
return email;
}
async function filterAccounts(page: Page, email: string) {
await page
.locator('[data-test="admin-accounts-table-filter-input"]')
.fill(email);
await page.keyboard.press('Enter');
await page.waitForTimeout(250);
}
async function selectAccount(page: Page, email: string) {
await page.getByRole('link', { name: email.split('@')[0] }).click();
}

View File

@@ -1,4 +1,5 @@
import { Page, expect } from '@playwright/test'; import { Page, expect } from '@playwright/test';
import { TOTP } from 'totp-generator';
import { Mailbox } from '../utils/mailbox'; import { Mailbox } from '../utils/mailbox';
@@ -46,6 +47,21 @@ export class AuthPageObject {
await this.page.click('button[type="submit"]'); await this.page.click('button[type="submit"]');
} }
async submitMFAVerification(key: string) {
const period = 30;
const { otp } = TOTP.generate(key, {
period,
});
console.log(`OTP ${otp} code`, {
period,
});
await this.page.fill('[data-input-otp]', otp);
await this.page.click('[data-test="submit-mfa-button"]');
}
async visitConfirmEmailLink( async visitConfirmEmailLink(
email: string, email: string,
params: { params: {

View File

@@ -71,6 +71,23 @@ test.describe('Auth flow', () => {
}); });
test.describe('Protected routes', () => { test.describe('Protected routes', () => {
test('when logged out, redirects to the correct page after sign in', 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');
expect(page.url()).toContain('/home/settings');
});
test('will redirect to the sign-in page if not authenticated', async ({ test('will redirect to the sign-in page if not authenticated', async ({
page, page,
}) => { }) => {
@@ -78,10 +95,4 @@ test.describe('Protected routes', () => {
expect(page.url()).toContain('/auth/sign-in?next=/home/settings'); expect(page.url()).toContain('/auth/sign-in?next=/home/settings');
}); });
test('will return a 404 for the admin page', async ({ page }) => {
await page.goto('/admin');
expect(page.url()).toContain('/auth/sign-in');
});
}); });

View File

@@ -54,14 +54,10 @@ test.describe('Password Reset Flow', () => {
await page.waitForURL('/home'); await page.waitForURL('/home');
}).toPass(); }).toPass();
await page.context().clearCookies(); await auth.signOut();
await page.reload();
await page await page.waitForURL('/');
.locator('a', { await page.goto('/auth/sign-in');
hasText: 'Sign in',
})
.click();
await auth.signIn({ await auth.signIn({
email, email,

View File

@@ -120,7 +120,8 @@ test.describe('Full Invitation Flow', () => {
await expect(invitations.getInvitations()).toHaveCount(2); await expect(invitations.getInvitations()).toHaveCount(2);
// sign out and sign in with the first email // sign out and sign in with the first email
await invitations.auth.signOut(); await page.context().clearCookies();
await page.reload();
console.log(`Finding email to ${firstEmail} ...`); console.log(`Finding email to ${firstEmail} ...`);

View File

@@ -173,3 +173,46 @@ test.describe('Team Ownership Transfer', () => {
await expect(ownerRow.locator('text=Primary Owner')).not.toBeVisible(); await expect(ownerRow.locator('text=Primary Owner')).not.toBeVisible();
}); });
}); });
test.describe('Team Account Security', () => {
test('unauthorized user cannot access team account', async ({
page,
browser,
}) => {
// 1. Create a team account with User A
const teamAccounts = new TeamAccountsPageObject(page);
const params = teamAccounts.createTeamName();
// Setup User A and create team
await teamAccounts.setup(params);
// Store team slug for later use
const teamSlug = params.slug;
// 2. Sign out User A
await page.context().clearCookies();
// 3. Create a new context for User B (to have clean cookies/session)
const userBContext = await browser.newContext();
const userBPage = await userBContext.newPage();
const userBTeamAccounts = new TeamAccountsPageObject(userBPage);
// Sign up with User B
await userBPage.goto('/auth/sign-up');
const emailB = userBTeamAccounts.auth.createRandomEmail();
await userBTeamAccounts.auth.signUp({
email: emailB,
password: 'password',
repeatPassword: 'password',
});
await userBTeamAccounts.auth.visitConfirmEmailLink(emailB);
// 4. Attempt to access the team page with User B
await userBPage.goto(`/home/${teamSlug}`);
// Check that we're not on the team page anymore (should redirect)
await expect(userBPage).toHaveURL(`/home`);
});
});

View File

@@ -31,7 +31,7 @@ export function AdminSidebar() {
<SidebarContent> <SidebarContent>
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Admin</SidebarGroupLabel> <SidebarGroupLabel>Super Admin</SidebarGroupLabel>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>

View File

@@ -2,7 +2,7 @@ import { cache } from 'react';
import { AdminAccountPage } from '@kit/admin/components/admin-account-page'; import { AdminAccountPage } from '@kit/admin/components/admin-account-page';
import { AdminGuard } from '@kit/admin/components/admin-guard'; import { AdminGuard } from '@kit/admin/components/admin-guard';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
interface Params { interface Params {
params: Promise<{ params: Promise<{
@@ -31,7 +31,7 @@ export default AdminGuard(AccountPage);
const loadAccount = cache(accountLoader); const loadAccount = cache(accountLoader);
async function accountLoader(id: string) { async function accountLoader(id: string) {
const client = getSupabaseServerAdminClient(); const client = getSupabaseServerClient();
const { data, error } = await client const { data, error } = await client
.from('accounts') .from('accounts')

View File

@@ -2,7 +2,7 @@ import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table'; import { AdminAccountsTable } from '@kit/admin/components/admin-accounts-table';
import { AdminGuard } from '@kit/admin/components/admin-guard'; import { AdminGuard } from '@kit/admin/components/admin-guard';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { PageBody, PageHeader } from '@kit/ui/page'; import { PageBody, PageHeader } from '@kit/ui/page';
@@ -21,7 +21,7 @@ export const metadata = {
}; };
async function AccountsPage(props: AdminAccountsPageProps) { async function AccountsPage(props: AdminAccountsPageProps) {
const client = getSupabaseServerAdminClient(); const client = getSupabaseServerClient();
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const page = searchParams.page ? parseInt(searchParams.page) : 1; const page = searchParams.page ? parseInt(searchParams.page) : 1;
@@ -47,6 +47,7 @@ async function AccountsPage(props: AdminAccountsPageProps) {
data={data} data={data}
filters={{ filters={{
type: searchParams.account_type ?? 'all', type: searchParams.account_type ?? 'all',
query: searchParams.query ?? '',
}} }}
/> />
); );

View File

@@ -858,6 +858,14 @@ export type Database = {
}; };
Returns: boolean; Returns: boolean;
}; };
install_extensions: {
Args: Record<PropertyKey, never>;
Returns: undefined;
};
is_aal2: {
Args: Record<PropertyKey, never>;
Returns: boolean;
};
is_account_owner: { is_account_owner: {
Args: { Args: {
account_id: string; account_id: string;
@@ -870,12 +878,20 @@ export type Database = {
}; };
Returns: boolean; Returns: boolean;
}; };
is_mfa_compliant: {
Args: Record<PropertyKey, never>;
Returns: boolean;
};
is_set: { is_set: {
Args: { Args: {
field_name: string; field_name: string;
}; };
Returns: boolean; Returns: boolean;
}; };
is_super_admin: {
Args: Record<PropertyKey, never>;
Returns: boolean;
};
is_team_member: { is_team_member: {
Args: { Args: {
account_id: string; account_id: string;

View File

@@ -3,6 +3,7 @@ import { NextResponse, URLPattern } from 'next/server';
import { CsrfError, createCsrfProtect } from '@edge-csrf/nextjs'; import { CsrfError, createCsrfProtect } from '@edge-csrf/nextjs';
import { isSuperAdmin } from '@kit/admin';
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa'; import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
import { createMiddlewareClient } from '@kit/supabase/middleware-client'; import { createMiddlewareClient } from '@kit/supabase/middleware-client';
@@ -115,22 +116,11 @@ async function adminMiddleware(request: NextRequest, response: NextResponse) {
); );
} }
const supabase = createMiddlewareClient(request, response); const client = createMiddlewareClient(request, response);
const userIsSuperAdmin = await isSuperAdmin(client);
const requiresMultiFactorAuthentication =
await checkRequiresMultiFactorAuthentication(supabase);
// If user requires multi-factor authentication, redirect to MFA page.
if (requiresMultiFactorAuthentication) {
return NextResponse.redirect(
new URL(pathsConfig.auth.verifyMfa, origin).href,
);
}
const role = user?.app_metadata.role;
// If user is not an admin, redirect to 404 page. // If user is not an admin, redirect to 404 page.
if (!role || role !== 'super-admin') { if (!userIsSuperAdmin) {
return NextResponse.redirect(new URL('/404', request.nextUrl.origin).href); return NextResponse.redirect(new URL('/404', request.nextUrl.origin).href);
} }

View File

@@ -64,7 +64,7 @@
"mfaEnabledSuccessDescription": "Congratulations! You have successfully enrolled in the multi factor authentication process. You will now be able to access your account with a combination of your password and an authentication code sent to your phone number.", "mfaEnabledSuccessDescription": "Congratulations! You have successfully enrolled in the multi factor authentication process. You will now be able to access your account with a combination of your password and an authentication code sent to your phone number.",
"verificationCode": "Verification Code", "verificationCode": "Verification Code",
"addEmailAddress": "Add Email address", "addEmailAddress": "Add Email address",
"verifyActivationCodeDescription": "Enter the verification code generated by your authenticator app", "verifyActivationCodeDescription": "Enter the 6-digit code generated by your authenticator app in the field above",
"loadingFactors": "Loading factors...", "loadingFactors": "Loading factors...",
"enableMfaFactor": "Enable Factor", "enableMfaFactor": "Enable Factor",
"disableMfaFactor": "Disable Factor", "disableMfaFactor": "Disable Factor",

View File

@@ -40,6 +40,7 @@
"sendLinkSuccess": "We sent you a link by email", "sendLinkSuccess": "We sent you a link by email",
"sendLinkSuccessToast": "Link successfully sent", "sendLinkSuccessToast": "Link successfully sent",
"getNewLink": "Get a new link", "getNewLink": "Get a new link",
"verifyCodeHeading": "Verify your account",
"verificationCode": "Verification Code", "verificationCode": "Verification Code",
"verificationCodeHint": "Enter the code we sent you by SMS", "verificationCodeHint": "Enter the code we sent you by SMS",
"verificationCodeSubmitButtonLabel": "Submit Verification Code", "verificationCodeSubmitButtonLabel": "Submit Verification Code",

View File

@@ -0,0 +1,206 @@
/*
* public.is_aal2
* Check if the user has aal2 access
*/
create
or replace function public.is_aal2() returns boolean
set
search_path = '' as
$$
declare
is_aal2 boolean;
begin
select auth.jwt() ->> 'aal' = 'aal2' into is_aal2;
return coalesce(is_aal2, false);
end
$$ language plpgsql;
-- Grant access to the function to authenticated users
grant execute on function public.is_aal2() to authenticated;
/*
* public.is_super_admin
* Check if the user is a super admin.
* A Super Admin is a user that has the role 'super-admin' and has MFA enabled.
*/
create
or replace function public.is_super_admin() returns boolean
set
search_path = '' as
$$
declare
is_super_admin boolean;
begin
if not public.is_aal2() then
return false;
end if;
select (auth.jwt() ->> 'app_metadata')::jsonb ->> 'role' = 'super-admin' into is_super_admin;
return coalesce(is_super_admin, false);
end
$$ language plpgsql;
-- Grant access to the function to authenticated users
grant execute on function public.is_super_admin() to authenticated;
/*
* public.is_mfa_compliant
* Check if the user meets MFA requirements if they have MFA enabled.
* If the user has MFA enabled, then the user must have aal2 enabled. Otherwise, the user must have aal1 enabled (default behavior).
*/
create or replace function public.is_mfa_compliant() returns boolean
set search_path = '' as
$$
begin
return array[(select auth.jwt()->>'aal')] <@ (
select
case
when count(id) > 0 then array['aal2']
else array['aal1', 'aal2']
end as aal
from auth.mfa_factors
where ((select auth.uid()) = auth.mfa_factors.user_id) and auth.mfa_factors.status = 'verified'
);
end
$$ language plpgsql security definer;
-- Grant access to the function to authenticated users
grant execute on function public.is_mfa_compliant() to authenticated;
-- MFA Restrictions:
-- the following policies are applied to the tables as a
-- restrictive policy to ensure that if MFA is enabled, then the policy will be applied.
-- For users that have not enabled MFA, the policy will not be applied and will keep the default behavior.
-- Restrict access to accounts if MFA is enabled
create policy restrict_mfa_accounts
on public.accounts
as restrictive
to authenticated
using (public.is_mfa_compliant());
-- Restrict access to accounts memberships if MFA is enabled
create policy restrict_mfa_accounts_memberships
on public.accounts_memberships
as restrictive
to authenticated
using (public.is_mfa_compliant());
-- Restrict access to subscriptions if MFA is enabled
create policy restrict_mfa_subscriptions
on public.subscriptions
as restrictive
to authenticated
using (public.is_mfa_compliant());
-- Restrict access to subscription items if MFA is enabled
create policy restrict_mfa_subscription_items
on public.subscription_items
as restrictive
to authenticated
using (public.is_mfa_compliant());
-- Restrict access to role permissions if MFA is enabled
create policy restrict_mfa_role_permissions
on public.role_permissions
as restrictive
to authenticated
using (public.is_mfa_compliant());
-- Restrict access to invitations if MFA is enabled
create policy restrict_mfa_invitations
on public.invitations
as restrictive
to authenticated
using (public.is_mfa_compliant());
-- Restrict access to orders if MFA is enabled
create policy restrict_mfa_orders
on public.orders
as restrictive
to authenticated
using (public.is_mfa_compliant());
-- Restrict access to orders items if MFA is enabled
create policy restrict_mfa_order_items
on public.order_items
as restrictive
to authenticated
using (public.is_mfa_compliant());
-- Restrict access to orders if MFA is enabled
create policy restrict_mfa_notifications
on public.notifications
as restrictive
to authenticated
using (public.is_mfa_compliant());
-- Super Admin:
-- the following policies are applied to the tables as a permissive policy to ensure that
-- super admins can access all tables (view only).
-- Allow Super Admins to access the accounts table
create policy super_admins_access_accounts
on public.accounts
as permissive
for select
to authenticated
using (public.is_super_admin());
-- Allow Super Admins to access the accounts memberships table
create policy super_admins_access_accounts_memberships
on public.accounts_memberships
as permissive
for select
to authenticated
using (public.is_super_admin());
-- Allow Super Admins to access the subscriptions table
create policy super_admins_access_subscriptions
on public.subscriptions
as permissive
for select
to authenticated
using (public.is_super_admin());
-- Allow Super Admins to access the subscription items table
create policy super_admins_access_subscription_items
on public.subscription_items
as permissive
for select
to authenticated
using (public.is_super_admin());
-- Allow Super Admins to access the invitations items table
create policy super_admins_access_invitations
on public.invitations
as permissive
for select
to authenticated
using (public.is_super_admin());
-- Allow Super Admins to access the orders table
create policy super_admins_access_orders
on public.orders
as permissive
for select
to authenticated
using (public.is_super_admin());
-- Allow Super Admins to access the order items table
create policy super_admins_access_order_items
on public.order_items
as permissive
for select
to authenticated
using (public.is_super_admin());
-- Allow Super Admins to access the role permissions table
create policy super_admins_access_role_permissions
on public.role_permissions
as permissive
for select
to authenticated
using (public.is_super_admin());

View File

@@ -6,39 +6,45 @@
-- We don't do it because you'll need to manually add your webhook URL and secret key. -- We don't do it because you'll need to manually add your webhook URL and secret key.
-- this webhook will be triggered after deleting an account -- this webhook will be triggered after deleting an account
create trigger "accounts_teardown" after delete create trigger "accounts_teardown"
on "public"."accounts" for each row after delete
on "public"."accounts"
for each row
execute function "supabase_functions"."http_request"( execute function "supabase_functions"."http_request"(
'http://host.docker.internal:3000/api/db/webhook', 'http://host.docker.internal:3000/api/db/webhook',
'POST', 'POST',
'{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}', '{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}',
'{}', '{}',
'5000' '5000'
); );
-- this webhook will be triggered after a delete on the subscriptions table -- this webhook will be triggered after a delete on the subscriptions table
-- which should happen when a user deletes their account (and all their subscriptions) -- which should happen when a user deletes their account (and all their subscriptions)
create trigger "subscriptions_delete" after delete create trigger "subscriptions_delete"
on "public"."subscriptions" for each row after delete
on "public"."subscriptions"
for each row
execute function "supabase_functions"."http_request"( execute function "supabase_functions"."http_request"(
'http://host.docker.internal:3000/api/db/webhook', 'http://host.docker.internal:3000/api/db/webhook',
'POST', 'POST',
'{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}', '{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}',
'{}', '{}',
'5000' '5000'
); );
-- this webhook will be triggered after every insert on the invitations table -- this webhook will be triggered after every insert on the invitations table
-- which should happen when a user invites someone to their account -- which should happen when a user invites someone to their account
create trigger "invitations_insert" after insert create trigger "invitations_insert"
on "public"."invitations" for each row after insert
on "public"."invitations"
for each row
execute function "supabase_functions"."http_request"( execute function "supabase_functions"."http_request"(
'http://host.docker.internal:3000/api/db/webhook', 'http://host.docker.internal:3000/api/db/webhook',
'POST', 'POST',
'{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}', '{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}',
'{}', '{}',
'5000' '5000'
); );
-- DATA SEED -- DATA SEED
@@ -50,25 +56,81 @@ execute function "supabase_functions"."http_request"(
-- --
-- --
-- Data for Name: users; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin -- Data for Name: users; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin
-- --
INSERT INTO "auth"."users" ("instance_id", "id", "aud", "role", "email", "encrypted_password", "email_confirmed_at", "invited_at", "confirmation_token", "confirmation_sent_at", "recovery_token", "recovery_sent_at", "email_change_token_new", "email_change", "email_change_sent_at", "last_sign_in_at", "raw_app_meta_data", "raw_user_meta_data", "is_super_admin", "created_at", "updated_at", "phone", "phone_confirmed_at", "phone_change", "phone_change_token", "phone_change_sent_at", "email_change_token_current", "email_change_confirm_status", "banned_until", "reauthentication_token", "reauthentication_sent_at", "is_sso_user", "deleted_at", "is_anonymous") VALUES INSERT INTO "auth"."users" ("instance_id", "id", "aud", "role", "email", "encrypted_password", "email_confirmed_at",
('00000000-0000-0000-0000-000000000000', 'b73eb03e-fb7a-424d-84ff-18e2791ce0b4', 'authenticated', 'authenticated', 'custom@makerkit.dev', '$2a$10$b3ZPpU6TU3or30QzrXnZDuATPAx2pPq3JW.sNaneVY3aafMSuR4yi', '2024-04-20 08:38:00.860548+00', NULL, '', '2024-04-20 08:37:43.343769+00', '', NULL, '', '', NULL, '2024-04-20 08:38:00.93864+00', '{"provider": "email", "providers": ["email"]}', '{"sub": "b73eb03e-fb7a-424d-84ff-18e2791ce0b4", "email": "custom@makerkit.dev", "email_verified": false, "phone_verified": false}', NULL, '2024-04-20 08:37:43.3385+00', '2024-04-20 08:38:00.942809+00', NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL, false, NULL, false), "invited_at", "confirmation_token", "confirmation_sent_at", "recovery_token",
('00000000-0000-0000-0000-000000000000', '31a03e74-1639-45b6-bfa7-77447f1a4762', 'authenticated', 'authenticated', 'test@makerkit.dev', '$2a$10$NaMVRrI7NyfwP.AfAVWt6O/abulGnf9BBqwa6DqdMwXMvOCGpAnVO', '2024-04-20 08:20:38.165331+00', NULL, '', NULL, '', NULL, '', '', NULL, '2024-04-20 09:36:02.521776+00', '{"provider": "email", "providers": ["email"], "role": "super-admin"}', '{"sub": "31a03e74-1639-45b6-bfa7-77447f1a4762", "email": "test@makerkit.dev", "email_verified": false, "phone_verified": false}', NULL, '2024-04-20 08:20:34.459113+00', '2024-04-20 10:07:48.554125+00', NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL, false, NULL, false), "recovery_sent_at", "email_change_token_new", "email_change", "email_change_sent_at",
('00000000-0000-0000-0000-000000000000', '5c064f1b-78ee-4e1c-ac3b-e99aa97c99bf', 'authenticated', 'authenticated', 'owner@makerkit.dev', '$2a$10$D6arGxWJShy8q4RTW18z7eW0vEm2hOxEUovUCj5f3NblyHfamm5/a', '2024-04-20 08:36:37.517993+00', NULL, '', '2024-04-20 08:36:27.639648+00', '', NULL, '', '', NULL, '2024-04-20 08:36:37.614337+00', '{"provider": "email", "providers": ["email"]}', '{"sub": "5c064f1b-78ee-4e1c-ac3b-e99aa97c99bf", "email": "owner@makerkit.dev", "email_verified": false, "phone_verified": false}', NULL, '2024-04-20 08:36:27.630379+00', '2024-04-20 08:36:37.617955+00', NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL, false, NULL, false), "last_sign_in_at", "raw_app_meta_data", "raw_user_meta_data", "is_super_admin",
('00000000-0000-0000-0000-000000000000', '6b83d656-e4ab-48e3-a062-c0c54a427368', 'authenticated', 'authenticated', 'member@makerkit.dev', '$2a$10$6h/x.AX.6zzphTfDXIJMzuYx13hIYEi/Iods9FXH19J2VxhsLycfa', '2024-04-20 08:41:15.376778+00', NULL, '', '2024-04-20 08:41:08.689674+00', '', NULL, '', '', NULL, '2024-04-20 08:41:15.484606+00', '{"provider": "email", "providers": ["email"]}', '{"sub": "6b83d656-e4ab-48e3-a062-c0c54a427368", "email": "member@makerkit.dev", "email_verified": false, "phone_verified": false}', NULL, '2024-04-20 08:41:08.683395+00', '2024-04-20 08:41:15.485494+00', NULL, NULL, '', '', NULL, '', 0, NULL, '', NULL, false, NULL, false); "created_at", "updated_at", "phone", "phone_confirmed_at", "phone_change",
"phone_change_token", "phone_change_sent_at", "email_change_token_current",
"email_change_confirm_status", "banned_until", "reauthentication_token",
"reauthentication_sent_at", "is_sso_user", "deleted_at", "is_anonymous")
VALUES ('00000000-0000-0000-0000-000000000000', 'b73eb03e-fb7a-424d-84ff-18e2791ce0b4', 'authenticated',
'authenticated', 'custom@makerkit.dev', '$2a$10$b3ZPpU6TU3or30QzrXnZDuATPAx2pPq3JW.sNaneVY3aafMSuR4yi',
'2024-04-20 08:38:00.860548+00', NULL, '', '2024-04-20 08:37:43.343769+00', '', NULL, '', '', NULL,
'2024-04-20 08:38:00.93864+00', '{"provider": "email", "providers": ["email"]}',
'{"sub": "b73eb03e-fb7a-424d-84ff-18e2791ce0b4", "email": "custom@makerkit.dev", "email_verified": false, "phone_verified": false}',
NULL, '2024-04-20 08:37:43.3385+00', '2024-04-20 08:38:00.942809+00', NULL, NULL, '', '', NULL, '', 0, NULL, '',
NULL, false, NULL, false),
('00000000-0000-0000-0000-000000000000', '31a03e74-1639-45b6-bfa7-77447f1a4762', 'authenticated',
'authenticated', 'test@makerkit.dev', '$2a$10$NaMVRrI7NyfwP.AfAVWt6O/abulGnf9BBqwa6DqdMwXMvOCGpAnVO',
'2024-04-20 08:20:38.165331+00', NULL, '', NULL, '', NULL, '', '', NULL, '2024-04-20 09:36:02.521776+00',
'{"provider": "email", "providers": ["email"], "role": "super-admin"}',
'{"sub": "31a03e74-1639-45b6-bfa7-77447f1a4762", "email": "test@makerkit.dev", "email_verified": false, "phone_verified": false}',
NULL, '2024-04-20 08:20:34.459113+00', '2024-04-20 10:07:48.554125+00', NULL, NULL, '', '', NULL, '', 0, NULL,
'', NULL, false, NULL, false),
('00000000-0000-0000-0000-000000000000', '5c064f1b-78ee-4e1c-ac3b-e99aa97c99bf', 'authenticated',
'authenticated', 'owner@makerkit.dev', '$2a$10$D6arGxWJShy8q4RTW18z7eW0vEm2hOxEUovUCj5f3NblyHfamm5/a',
'2024-04-20 08:36:37.517993+00', NULL, '', '2024-04-20 08:36:27.639648+00', '', NULL, '', '', NULL,
'2024-04-20 08:36:37.614337+00', '{"provider": "email", "providers": ["email"]}',
'{"sub": "5c064f1b-78ee-4e1c-ac3b-e99aa97c99bf", "email": "owner@makerkit.dev", "email_verified": false, "phone_verified": false}',
NULL, '2024-04-20 08:36:27.630379+00', '2024-04-20 08:36:37.617955+00', NULL, NULL, '', '', NULL, '', 0, NULL,
'', NULL, false, NULL, false),
('00000000-0000-0000-0000-000000000000', '6b83d656-e4ab-48e3-a062-c0c54a427368', 'authenticated',
'authenticated', 'member@makerkit.dev', '$2a$10$6h/x.AX.6zzphTfDXIJMzuYx13hIYEi/Iods9FXH19J2VxhsLycfa',
'2024-04-20 08:41:15.376778+00', NULL, '', '2024-04-20 08:41:08.689674+00', '', NULL, '', '', NULL,
'2024-04-20 08:41:15.484606+00', '{"provider": "email", "providers": ["email"]}',
'{"sub": "6b83d656-e4ab-48e3-a062-c0c54a427368", "email": "member@makerkit.dev", "email_verified": false, "phone_verified": false}',
NULL, '2024-04-20 08:41:08.683395+00', '2024-04-20 08:41:15.485494+00', NULL, NULL, '', '', NULL, '', 0, NULL,
'', NULL, false, NULL, false),
('00000000-0000-0000-0000-000000000000', 'c5b930c9-0a76-412e-a836-4bc4849a3270', 'authenticated',
'authenticated', 'super-admin@makerkit.dev',
'$2a$10$gzxQw3vaVni8Ke9UVcn6ueWh674.6xImf6/yWYNc23BSeYdE9wmki', '2025-02-24 13:25:11.176987+00', null, '',
'2025-02-24 13:25:01.649714+00', '', null, '', '', null, '2025-02-24 13:25:11.17957+00',
'{"provider": "email", "providers": ["email"], "role": "super-admin"}',
'{"sub": "c5b930c9-0a76-412e-a836-4bc4849a3270", "email": "super-admin@makerkit.dev", "email_verified": true, "phone_verified": false}',
null, '2025-02-24 13:25:01.646641+00', '2025-02-24 13:25:11.181332+00', null, null, '', '', null
, '', '0', null, '', null, 'false', null, 'false');
-- --
-- Data for Name: identities; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin -- Data for Name: identities; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin
-- --
INSERT INTO "auth"."identities" ("provider_id", "user_id", "identity_data", "provider", "last_sign_in_at", "created_at", "updated_at", "id") VALUES INSERT INTO "auth"."identities" ("provider_id", "user_id", "identity_data", "provider", "last_sign_in_at", "created_at",
('31a03e74-1639-45b6-bfa7-77447f1a4762', '31a03e74-1639-45b6-bfa7-77447f1a4762', '{"sub": "31a03e74-1639-45b6-bfa7-77447f1a4762", "email": "test@makerkit.dev", "email_verified": false, "phone_verified": false}', 'email', '2024-04-20 08:20:34.46275+00', '2024-04-20 08:20:34.462773+00', '2024-04-20 08:20:34.462773+00', '9bb58bad-24a4-41a8-9742-1b5b4e2d8abd'), ('5c064f1b-78ee-4e1c-ac3b-e99aa97c99bf', '5c064f1b-78ee-4e1c-ac3b-e99aa97c99bf', '{"sub": "5c064f1b-78ee-4e1c-ac3b-e99aa97c99bf", "email": "owner@makerkit.dev", "email_verified": false, "phone_verified": false}', 'email', '2024-04-20 08:36:27.637388+00', '2024-04-20 08:36:27.637409+00', '2024-04-20 08:36:27.637409+00', '090598a1-ebba-4879-bbe3-38d517d5066f'), "updated_at", "id")
('b73eb03e-fb7a-424d-84ff-18e2791ce0b4', 'b73eb03e-fb7a-424d-84ff-18e2791ce0b4', '{"sub": "b73eb03e-fb7a-424d-84ff-18e2791ce0b4", "email": "custom@makerkit.dev", "email_verified": false, "phone_verified": false}', 'email', '2024-04-20 08:37:43.342194+00', '2024-04-20 08:37:43.342218+00', '2024-04-20 08:37:43.342218+00', '4392e228-a6d8-4295-a7d6-baed50c33e7c'), VALUES ('31a03e74-1639-45b6-bfa7-77447f1a4762', '31a03e74-1639-45b6-bfa7-77447f1a4762',
('6b83d656-e4ab-48e3-a062-c0c54a427368', '6b83d656-e4ab-48e3-a062-c0c54a427368', '{"sub": "6b83d656-e4ab-48e3-a062-c0c54a427368", "email": "member@makerkit.dev", "email_verified": false, "phone_verified": false}', 'email', '2024-04-20 08:41:08.687948+00', '2024-04-20 08:41:08.687982+00', '2024-04-20 08:41:08.687982+00', 'd122aca5-4f29-43f0-b1b1-940b000638db'); '{"sub": "31a03e74-1639-45b6-bfa7-77447f1a4762", "email": "test@makerkit.dev", "email_verified": false, "phone_verified": false}',
'email', '2024-04-20 08:20:34.46275+00', '2024-04-20 08:20:34.462773+00', '2024-04-20 08:20:34.462773+00',
'9bb58bad-24a4-41a8-9742-1b5b4e2d8abd'),
('5c064f1b-78ee-4e1c-ac3b-e99aa97c99bf', '5c064f1b-78ee-4e1c-ac3b-e99aa97c99bf',
'{"sub": "5c064f1b-78ee-4e1c-ac3b-e99aa97c99bf", "email": "owner@makerkit.dev", "email_verified": false, "phone_verified": false}',
'email', '2024-04-20 08:36:27.637388+00', '2024-04-20 08:36:27.637409+00', '2024-04-20 08:36:27.637409+00',
'090598a1-ebba-4879-bbe3-38d517d5066f'),
('b73eb03e-fb7a-424d-84ff-18e2791ce0b4', 'b73eb03e-fb7a-424d-84ff-18e2791ce0b4',
'{"sub": "b73eb03e-fb7a-424d-84ff-18e2791ce0b4", "email": "custom@makerkit.dev", "email_verified": false, "phone_verified": false}',
'email', '2024-04-20 08:37:43.342194+00', '2024-04-20 08:37:43.342218+00', '2024-04-20 08:37:43.342218+00',
'4392e228-a6d8-4295-a7d6-baed50c33e7c'),
('6b83d656-e4ab-48e3-a062-c0c54a427368', '6b83d656-e4ab-48e3-a062-c0c54a427368',
'{"sub": "6b83d656-e4ab-48e3-a062-c0c54a427368", "email": "member@makerkit.dev", "email_verified": false, "phone_verified": false}',
'email', '2024-04-20 08:41:08.687948+00', '2024-04-20 08:41:08.687982+00', '2024-04-20 08:41:08.687982+00',
'd122aca5-4f29-43f0-b1b1-940b000638db'),
('c5b930c9-0a76-412e-a836-4bc4849a3270', 'c5b930c9-0a76-412e-a836-4bc4849a3270',
'{"sub": "c5b930c9-0a76-412e-a836-4bc4849a3270", "email": "super-admin@makerkit.dev", "email_verified": true, "phone_verified": false}',
'email', '2025-02-24 13:25:01.646641+00', '2025-02-24 13:25:11.181332+00', '2025-02-24 13:25:11.181332+00',
'c5b930c9-0a76-412e-a836-4bc4849a3270');
-- --
-- Data for Name: instances; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin -- Data for Name: instances; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin
@@ -89,13 +151,11 @@ INSERT INTO "auth"."identities" ("provider_id", "user_id", "identity_data", "pro
-- --
-- --
-- Data for Name: mfa_challenges; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin -- Data for Name: mfa_challenges; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin
-- --
-- --
-- Data for Name: refresh_tokens; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin -- Data for Name: refresh_tokens; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin
-- --
@@ -105,74 +165,79 @@ INSERT INTO "auth"."identities" ("provider_id", "user_id", "identity_data", "pro
-- --
-- --
-- Data for Name: saml_providers; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin -- Data for Name: saml_providers; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin
-- --
-- --
-- Data for Name: saml_relay_states; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin -- Data for Name: saml_relay_states; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin
-- --
-- --
-- Data for Name: sso_domains; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin -- Data for Name: sso_domains; Type: TABLE DATA; Schema: auth; Owner: supabase_auth_admin
-- --
-- --
-- Data for Name: key; Type: TABLE DATA; Schema: pgsodium; Owner: supabase_admin -- Data for Name: key; Type: TABLE DATA; Schema: pgsodium; Owner: supabase_admin
-- --
-- --
-- Data for Name: accounts; Type: TABLE DATA; Schema: public; Owner: postgres -- Data for Name: accounts; Type: TABLE DATA; Schema: public; Owner: postgres
-- --
INSERT INTO "public"."accounts" ("id", "primary_owner_user_id", "name", "slug", "email", "is_personal_account", "updated_at", "created_at", "created_by", "updated_by", "picture_url", "public_data") VALUES INSERT INTO "public"."accounts" ("id", "primary_owner_user_id", "name", "slug", "email", "is_personal_account",
('5deaa894-2094-4da3-b4fd-1fada0809d1c', '31a03e74-1639-45b6-bfa7-77447f1a4762', 'Makerkit', 'makerkit', NULL, false, NULL, NULL, NULL, NULL, NULL, '{}'); "updated_at", "created_at", "created_by", "updated_by", "picture_url", "public_data")
VALUES ('5deaa894-2094-4da3-b4fd-1fada0809d1c', '31a03e74-1639-45b6-bfa7-77447f1a4762', 'Makerkit', 'makerkit', NULL,
false, NULL, NULL, NULL, NULL, NULL, '{}');
-- --
-- Data for Name: roles; Type: TABLE DATA; Schema: public; Owner: postgres -- Data for Name: roles; Type: TABLE DATA; Schema: public; Owner: postgres
-- --
INSERT INTO "public"."roles" ("name", "hierarchy_level") VALUES INSERT INTO "public"."roles" ("name", "hierarchy_level")
('custom-role', 4); VALUES ('custom-role', 4);
-- --
-- Data for Name: accounts_memberships; Type: TABLE DATA; Schema: public; Owner: postgres -- Data for Name: accounts_memberships; Type: TABLE DATA; Schema: public; Owner: postgres
-- --
INSERT INTO "public"."accounts_memberships" ("user_id", "account_id", "account_role", "created_at", "updated_at", "created_by", "updated_by") VALUES INSERT INTO "public"."accounts_memberships" ("user_id", "account_id", "account_role", "created_at", "updated_at",
('31a03e74-1639-45b6-bfa7-77447f1a4762', '5deaa894-2094-4da3-b4fd-1fada0809d1c', 'owner', '2024-04-20 08:21:16.802867+00', '2024-04-20 08:21:16.802867+00', NULL, NULL), "created_by", "updated_by")
('5c064f1b-78ee-4e1c-ac3b-e99aa97c99bf', '5deaa894-2094-4da3-b4fd-1fada0809d1c', 'owner', '2024-04-20 08:36:44.21028+00', '2024-04-20 08:36:44.21028+00', NULL, NULL), VALUES ('31a03e74-1639-45b6-bfa7-77447f1a4762', '5deaa894-2094-4da3-b4fd-1fada0809d1c', 'owner',
('b73eb03e-fb7a-424d-84ff-18e2791ce0b4', '5deaa894-2094-4da3-b4fd-1fada0809d1c', 'custom-role', '2024-04-20 08:38:02.50993+00', '2024-04-20 08:38:02.50993+00', NULL, NULL), '2024-04-20 08:21:16.802867+00', '2024-04-20 08:21:16.802867+00', NULL, NULL),
('6b83d656-e4ab-48e3-a062-c0c54a427368', '5deaa894-2094-4da3-b4fd-1fada0809d1c', 'member', '2024-04-20 08:41:17.833709+00', '2024-04-20 08:41:17.833709+00', NULL, NULL); ('5c064f1b-78ee-4e1c-ac3b-e99aa97c99bf', '5deaa894-2094-4da3-b4fd-1fada0809d1c', 'owner',
'2024-04-20 08:36:44.21028+00', '2024-04-20 08:36:44.21028+00', NULL, NULL),
('b73eb03e-fb7a-424d-84ff-18e2791ce0b4', '5deaa894-2094-4da3-b4fd-1fada0809d1c', 'custom-role',
'2024-04-20 08:38:02.50993+00', '2024-04-20 08:38:02.50993+00', NULL, NULL),
('6b83d656-e4ab-48e3-a062-c0c54a427368', '5deaa894-2094-4da3-b4fd-1fada0809d1c', 'member',
'2024-04-20 08:41:17.833709+00', '2024-04-20 08:41:17.833709+00', NULL, NULL);
-- MFA Factors
INSERT INTO "auth"."mfa_factors" ("id", "user_id", "friendly_name", "factor_type", "status", "created_at", "updated_at",
"secret", "phone", "last_challenged_at", "web_authn_credential", "web_authn_aaguid")
VALUES ('659e3b57-1128-4d26-8757-f714fd073fc4', 'c5b930c9-0a76-412e-a836-4bc4849a3270', 'iPhone', 'totp', 'verified',
'2025-02-24 13:23:55.5805+00', '2025-02-24 13:24:32.591999+00', 'NHOHJVGPO3R3LKVPRMNIYLCDMBHUM2SE', null,
'2025-02-24 13:24:32.563314+00', null, null);
-- --
-- Data for Name: billing_customers; Type: TABLE DATA; Schema: public; Owner: postgres -- Data for Name: billing_customers; Type: TABLE DATA; Schema: public; Owner: postgres
-- --
-- --
-- Data for Name: invitations; Type: TABLE DATA; Schema: public; Owner: postgres -- Data for Name: invitations; Type: TABLE DATA; Schema: public; Owner: postgres
-- --
-- --
-- Data for Name: orders; Type: TABLE DATA; Schema: public; Owner: postgres -- Data for Name: orders; Type: TABLE DATA; Schema: public; Owner: postgres
-- --
-- --
-- Data for Name: order_items; Type: TABLE DATA; Schema: public; Owner: postgres -- Data for Name: order_items; Type: TABLE DATA; Schema: public; Owner: postgres
-- --
@@ -183,7 +248,6 @@ INSERT INTO "public"."accounts_memberships" ("user_id", "account_id", "account_r
-- --
-- --
-- Data for Name: subscription_items; Type: TABLE DATA; Schema: public; Owner: postgres -- Data for Name: subscription_items; Type: TABLE DATA; Schema: public; Owner: postgres
-- --
@@ -198,19 +262,16 @@ INSERT INTO "public"."accounts_memberships" ("user_id", "account_id", "account_r
-- --
-- --
-- Data for Name: s3_multipart_uploads; Type: TABLE DATA; Schema: storage; Owner: supabase_storage_admin -- Data for Name: s3_multipart_uploads; Type: TABLE DATA; Schema: storage; Owner: supabase_storage_admin
-- --
-- --
-- Data for Name: s3_multipart_uploads_parts; Type: TABLE DATA; Schema: storage; Owner: supabase_storage_admin -- Data for Name: s3_multipart_uploads_parts; Type: TABLE DATA; Schema: storage; Owner: supabase_storage_admin
-- --
-- --
-- Data for Name: hooks; Type: TABLE DATA; Schema: supabase_functions; Owner: supabase_functions_admin -- Data for Name: hooks; Type: TABLE DATA; Schema: supabase_functions; Owner: supabase_functions_admin
-- --

View File

@@ -8,7 +8,7 @@ alter default PRIVILEGES in schema makerkit revoke execute on FUNCTIONS from pub
-- Grant execute to anon, authenticated, and service_role for testing purposes -- Grant execute to anon, authenticated, and service_role for testing purposes
alter default PRIVILEGES in schema makerkit grant execute on FUNCTIONS to anon, alter default PRIVILEGES in schema makerkit grant execute on FUNCTIONS to anon,
authenticated, service_role; authenticated, service_role;
create or replace function makerkit.get_id_by_identifier( create or replace function makerkit.get_id_by_identifier(
identifier text identifier text
@@ -24,62 +24,114 @@ end;
$$ language PLPGSQL; $$ language PLPGSQL;
create or replace function makerkit.set_identifier( create or replace function makerkit.set_identifier(
identifier text, identifier text,
user_email text user_email text
) )
returns text returns text
security definer security definer
set search_path = auth, pg_temp set search_path = auth, pg_temp
as $$ as
$$
begin begin
update auth.users set raw_user_meta_data = jsonb_build_object('test_identifier', identifier) update auth.users
where email = user_email; set raw_user_meta_data = jsonb_build_object('test_identifier', identifier)
where email = user_email;
return identifier; return identifier;
end; end;
$$ language PLPGSQL; $$ language PLPGSQL;
create or replace function makerkit.get_account_by_slug( create or replace function makerkit.get_account_by_slug(
account_slug text account_slug text
) )
returns setof accounts returns setof accounts
as $$ as
$$
begin begin
return query return query
select select *
* from accounts
from where slug = account_slug;
accounts
where
slug = account_slug;
end; end;
$$ language PLPGSQL; $$ language PLPGSQL;
create or replace function makerkit.authenticate_as(
identifier text
) returns void
as
$$
begin
perform tests.authenticate_as(identifier);
perform makerkit.set_session_aal('aal1');
end;
$$ language plpgsql;
create or replace function makerkit.get_account_id_by_slug( create or replace function makerkit.get_account_id_by_slug(
account_slug text account_slug text
) )
returns uuid returns uuid
as $$ as
$$
begin begin
return return
(select (select id
id from accounts
from where slug = account_slug);
accounts
where
slug = account_slug);
end; end;
$$ language PLPGSQL; $$ language PLPGSQL;
create or replace function makerkit.set_mfa_factor(
identifier text = gen_random_uuid()
)
returns void
as
$$
begin
insert into "auth"."mfa_factors" ("id", "user_id", "friendly_name", "factor_type", "status", "created_at", "updated_at", "secret")
values (gen_random_uuid(), auth.uid(), identifier, 'totp', 'verified', '2025-02-24 09:48:18.402031+00', '2025-02-24 09:48:18.402031+00',
'HOWQFBA7KBDDRSBNMGFYZAFNPRSZ62I5');
end;
$$ language plpgsql security definer;
create or replace function makerkit.set_session_aal(session_aal auth.aal_level)
returns void
as
$$
begin
perform set_config('request.jwt.claims', json_build_object(
'sub', current_setting('request.jwt.claims')::json ->> 'sub',
'email', current_setting('request.jwt.claims')::json ->> 'email',
'phone', current_setting('request.jwt.claims')::json ->> 'phone',
'user_metadata', current_setting('request.jwt.claims')::json ->> 'user_metadata',
'app_metadata', current_setting('request.jwt.claims')::json ->> 'app_metadata',
'aal', session_aal)::text, true);
end;
$$ language plpgsql;
create or replace function makerkit.set_super_admin() returns void
as
$$
begin
perform set_config('request.jwt.claims', json_build_object(
'sub', current_setting('request.jwt.claims')::json ->> 'sub',
'email', current_setting('request.jwt.claims')::json ->> 'email',
'phone', current_setting('request.jwt.claims')::json ->> 'phone',
'user_metadata', current_setting('request.jwt.claims')::json ->> 'user_metadata',
'app_metadata', json_build_object('role', 'super-admin'),
'aal', current_setting('request.jwt.claims')::json ->> 'aal'
)::text, true);
end;
$$ language plpgsql;
begin; begin;
select plan(1); select plan(1);
@@ -89,12 +141,11 @@ select is_empty($$
* *
from from
makerkit.get_account_by_slug('test') $$, makerkit.get_account_by_slug('test') $$,
'get_account_by_slug should return an empty set when the account does not exist' 'get_account_by_slug should return an empty set when the account does not exist'
); );
select select *
*
from from
finish(); finish();
rollback; rollback;

View File

@@ -11,7 +11,7 @@ select tests.create_supabase_user('test2');
-- Create an team account -- Create an team account
select tests.authenticate_as('test1'); select makerkit.authenticate_as('test1');
select public.create_team_account('Test'); select public.create_team_account('Test');
@@ -33,7 +33,7 @@ select row_eq(
-- Foreigner should not have permissions to manage members -- Foreigner should not have permissions to manage members
select tests.authenticate_as('test2'); select makerkit.authenticate_as('test2');
select row_eq( select row_eq(
$$ select public.has_permission( $$ select public.has_permission(
@@ -81,7 +81,7 @@ set local role postgres;
-- insert permissions for the custom role -- insert permissions for the custom role
insert into public.role_permissions (role, permission) values ('custom-role', 'members.manage'); insert into public.role_permissions (role, permission) values ('custom-role', 'members.manage');
select tests.authenticate_as('test1'); select makerkit.authenticate_as('test1');
-- the custom role does not have permissions to manage billing -- the custom role does not have permissions to manage billing
select row_eq( select row_eq(

View File

@@ -11,7 +11,7 @@ select tests.create_supabase_user('test2');
-- Create an team account -- Create an team account
select tests.authenticate_as('test1'); select makerkit.authenticate_as('test1');
select public.create_team_account('Test'); select public.create_team_account('Test');
select public.create_team_account('Test'); select public.create_team_account('Test');

View File

@@ -12,7 +12,7 @@ select makerkit.set_identifier('custom', 'custom@makerkit.dev');
select tests.create_supabase_user('test', 'test@supabase.com'); select tests.create_supabase_user('test', 'test@supabase.com');
-- an owner cannot remove the primary owner -- an owner cannot remove the primary owner
select tests.authenticate_as('owner'); select makerkit.authenticate_as('owner');
select throws_ok( select throws_ok(
$$ delete from public.accounts_memberships $$ delete from public.accounts_memberships
@@ -30,7 +30,7 @@ select lives_ok(
); );
-- a member cannot remove a member with a higher role -- a member cannot remove a member with a higher role
select tests.authenticate_as('member'); select makerkit.authenticate_as('member');
-- delete a membership record where the user is a higher role than the current user -- delete a membership record where the user is a higher role than the current user
select throws_ok( select throws_ok(
@@ -41,7 +41,7 @@ select throws_ok(
); );
-- an primary_owner cannot remove themselves -- an primary_owner cannot remove themselves
select tests.authenticate_as('primary_owner'); select makerkit.authenticate_as('primary_owner');
select throws_ok( select throws_ok(
$$ delete from public.accounts_memberships $$ delete from public.accounts_memberships
@@ -62,7 +62,7 @@ select lives_ok(
-- a user not in the account cannot remove a member -- a user not in the account cannot remove a member
select tests.authenticate_as('test'); select makerkit.authenticate_as('test');
select throws_ok( select throws_ok(
$$ delete from public.accounts_memberships $$ delete from public.accounts_memberships
@@ -71,7 +71,7 @@ select throws_ok(
'You do not have permission to action a member from this account' 'You do not have permission to action a member from this account'
); );
select tests.authenticate_as('owner'); select makerkit.authenticate_as('owner');
select isnt_empty( select isnt_empty(
$$ select 1 from public.accounts_memberships $$ select 1 from public.accounts_memberships
@@ -79,7 +79,7 @@ select isnt_empty(
and user_id = tests.get_supabase_uid('owner'); $$, and user_id = tests.get_supabase_uid('owner'); $$,
'Foreigners should not be able to remove members'); 'Foreigners should not be able to remove members');
select tests.authenticate_as('test'); select makerkit.authenticate_as('test');
-- a user not in the account cannot remove themselves -- a user not in the account cannot remove themselves
select throws_ok( select throws_ok(

View File

@@ -10,7 +10,7 @@ select makerkit.set_identifier('member', 'member@makerkit.dev');
select makerkit.set_identifier('custom', 'custom@makerkit.dev'); select makerkit.set_identifier('custom', 'custom@makerkit.dev');
select makerkit.set_identifier('owner', 'owner@makerkit.dev'); select makerkit.set_identifier('owner', 'owner@makerkit.dev');
select tests.authenticate_as('test'); select makerkit.authenticate_as('test');
select lives_ok( select lives_ok(
$$ insert into public.invitations (email, invited_by, account_id, role, invite_token) values ('invite1@makerkit.dev', auth.uid(), makerkit.get_account_id_by_slug('makerkit'), 'member', gen_random_uuid()); $$, $$ insert into public.invitations (email, invited_by, account_id, role, invite_token) values ('invite1@makerkit.dev', auth.uid(), makerkit.get_account_id_by_slug('makerkit'), 'member', gen_random_uuid()); $$,
@@ -23,7 +23,7 @@ select throws_ok(
'duplicate key value violates unique constraint "invitations_email_account_id_key"' 'duplicate key value violates unique constraint "invitations_email_account_id_key"'
); );
select tests.authenticate_as('member'); select makerkit.authenticate_as('member');
-- check a member cannot invite members with higher roles -- check a member cannot invite members with higher roles
select throws_ok( select throws_ok(
@@ -43,7 +43,7 @@ select isnt_empty(
'invitations should be listed' 'invitations should be listed'
); );
select tests.authenticate_as('owner'); select makerkit.authenticate_as('owner');
-- check the owner can invite members with lower roles -- check the owner can invite members with lower roles
select lives_ok( select lives_ok(
@@ -52,7 +52,7 @@ select lives_ok(
); );
-- authenticate_as the custom role -- authenticate_as the custom role
select tests.authenticate_as('custom'); select makerkit.authenticate_as('custom');
-- it will fail because the custom role does not have the invites.manage permission -- it will fail because the custom role does not have the invites.manage permission
select throws_ok( select throws_ok(
@@ -66,7 +66,7 @@ set local role postgres;
insert into public.role_permissions (role, permission) values ('custom-role', 'invites.manage'); insert into public.role_permissions (role, permission) values ('custom-role', 'invites.manage');
-- authenticate_as the custom role -- authenticate_as the custom role
select tests.authenticate_as('custom'); select makerkit.authenticate_as('custom');
select lives_ok( select lives_ok(
$$ insert into public.invitations (email, invited_by, account_id, role, invite_token) values ('invite4@makerkit.dev', auth.uid(), makerkit.get_account_id_by_slug('makerkit'), 'custom-role', gen_random_uuid()) $$, $$ insert into public.invitations (email, invited_by, account_id, role, invite_token) values ('invite4@makerkit.dev', auth.uid(), makerkit.get_account_id_by_slug('makerkit'), 'custom-role', gen_random_uuid()) $$,
@@ -88,7 +88,7 @@ select throws_ok(
select tests.create_supabase_user('user'); select tests.create_supabase_user('user');
select tests.authenticate_as('user'); select makerkit.authenticate_as('user');
-- it will fail because the user is not a member of the account -- it will fail because the user is not a member of the account
select throws_ok( select throws_ok(

View File

@@ -11,7 +11,7 @@ select makerkit.set_identifier('custom', 'custom@makerkit.dev');
-- another user not in the team -- another user not in the team
select tests.create_supabase_user('test', 'test@supabase.com'); select tests.create_supabase_user('test', 'test@supabase.com');
select tests.authenticate_as('owner'); select makerkit.authenticate_as('owner');
-- Can check if an account is a team member -- Can check if an account is a team member
@@ -25,7 +25,7 @@ select is(
'The primary account owner can check if a member is a team member' 'The primary account owner can check if a member is a team member'
); );
select tests.authenticate_as('member'); select makerkit.authenticate_as('member');
-- Member -- Member
select is( select is(
@@ -50,7 +50,7 @@ select isnt_empty(
'The member can query the team account memberships using the get_account_members function' 'The member can query the team account memberships using the get_account_members function'
); );
select tests.authenticate_as('test'); select makerkit.authenticate_as('test');
-- Foreigners -- Foreigners
-- Cannot query the team account memberships -- Cannot query the team account memberships

View File

@@ -9,7 +9,7 @@ select tests.create_supabase_user('test1', 'test1@test.com');
select tests.create_supabase_user('test2'); select tests.create_supabase_user('test2');
select tests.authenticate_as('test1'); select makerkit.authenticate_as('test1');
-- users cannot insert into notifications -- users cannot insert into notifications
select throws_ok( select throws_ok(
@@ -25,7 +25,7 @@ select lives_ok(
'service role can insert into notifications' 'service role can insert into notifications'
); );
select tests.authenticate_as('test1'); select makerkit.authenticate_as('test1');
-- user can read their own notifications -- user can read their own notifications
select row_eq( select row_eq(
@@ -48,7 +48,7 @@ select lives_ok(
'service role can insert into notifications' 'service role can insert into notifications'
); );
select tests.authenticate_as('member'); select makerkit.authenticate_as('member');
select row_eq( select row_eq(
$$ select account_id, body from public.notifications where account_id = makerkit.get_account_id_by_slug('makerkit'); $$, $$ select account_id, body from public.notifications where account_id = makerkit.get_account_id_by_slug('makerkit'); $$,
@@ -58,7 +58,7 @@ select row_eq(
-- foreigners -- foreigners
select tests.authenticate_as('test2'); select makerkit.authenticate_as('test2');
-- foreigner cannot read other user's notifications -- foreigner cannot read other user's notifications
select is_empty( select is_empty(

View File

@@ -12,7 +12,7 @@ select tests.create_supabase_user('test2');
------------ ------------
--- Primary Owner --- Primary Owner
------------ ------------
select tests.authenticate_as('test1'); select makerkit.authenticate_as('test1');
-- should create the personal account automatically with the same ID as the user -- should create the personal account automatically with the same ID as the user
SELECT row_eq( SELECT row_eq(
@@ -32,7 +32,7 @@ SELECT throws_ok(
-- the primary owner should be able to see the personal account -- the primary owner should be able to see the personal account
select tests.authenticate_as('test1'); select makerkit.authenticate_as('test1');
SELECT isnt_empty( SELECT isnt_empty(
$$ select * from public.accounts where primary_owner_user_id = tests.get_supabase_uid('test1') $$, $$ select * from public.accounts where primary_owner_user_id = tests.get_supabase_uid('test1') $$,
@@ -44,7 +44,7 @@ SELECT isnt_empty(
-- other users should not be able to see the personal account -- other users should not be able to see the personal account
select tests.authenticate_as('test2'); select makerkit.authenticate_as('test2');
SELECT is_empty( SELECT is_empty(
$$ select * from public.accounts where primary_owner_user_id = tests.get_supabase_uid('test1') $$, $$ select * from public.accounts where primary_owner_user_id = tests.get_supabase_uid('test1') $$,

View File

@@ -60,7 +60,7 @@ select row_eq(
'The order item should be deleted when the order is updated' 'The order item should be deleted when the order is updated'
); );
select tests.authenticate_as('primary_owner'); select makerkit.authenticate_as('primary_owner');
-- account can read their own subscription -- account can read their own subscription
select isnt_empty( select isnt_empty(
@@ -75,7 +75,7 @@ select isnt_empty(
-- foreigners -- foreigners
select tests.create_supabase_user('foreigner'); select tests.create_supabase_user('foreigner');
select tests.authenticate_as('foreigner'); select makerkit.authenticate_as('foreigner');
-- account cannot read other's subscription -- account cannot read other's subscription
select is_empty( select is_empty(

View File

@@ -144,7 +144,7 @@ select is(
'The subscription should be active' 'The subscription should be active'
); );
select tests.authenticate_as('primary_owner'); select makerkit.authenticate_as('primary_owner');
-- account can read their own subscription -- account can read their own subscription
select isnt_empty( select isnt_empty(
@@ -171,7 +171,7 @@ select is(
-- foreigners -- foreigners
select tests.create_supabase_user('foreigner'); select tests.create_supabase_user('foreigner');
select tests.authenticate_as('foreigner'); select makerkit.authenticate_as('foreigner');
-- account cannot read other's subscription -- account cannot read other's subscription
select is_empty( select is_empty(

View File

@@ -8,7 +8,7 @@ select makerkit.set_identifier('owner', 'owner@makerkit.dev');
select makerkit.set_identifier('member', 'member@makerkit.dev'); select makerkit.set_identifier('member', 'member@makerkit.dev');
select makerkit.set_identifier('custom', 'custom@makerkit.dev'); select makerkit.set_identifier('custom', 'custom@makerkit.dev');
select tests.authenticate_as('member'); select makerkit.authenticate_as('member');
select throws_ok( select throws_ok(
$$ insert into storage.objects ("bucket_id", "metadata", "name", "owner", "owner_id", "version") values $$ insert into storage.objects ("bucket_id", "metadata", "name", "owner", "owner_id", "version") values
@@ -16,7 +16,7 @@ select throws_ok(
'new row violates row-level security policy for table "objects"' 'new row violates row-level security policy for table "objects"'
); );
select tests.authenticate_as('primary_owner'); select makerkit.authenticate_as('primary_owner');
select lives_ok( select lives_ok(
$$ insert into storage.objects ("bucket_id", "metadata", "name", "owner", "owner_id", "version") values $$ insert into storage.objects ("bucket_id", "metadata", "name", "owner", "owner_id", "version") values
@@ -29,7 +29,7 @@ select isnt_empty(
'The object should be inserted' 'The object should be inserted'
); );
select tests.authenticate_as('owner'); select makerkit.authenticate_as('owner');
select is_empty( select is_empty(
$$ select * from storage.objects where owner = tests.get_supabase_uid('primary_owner') $$, $$ select * from storage.objects where owner = tests.get_supabase_uid('primary_owner') $$,
@@ -55,7 +55,7 @@ with check (
and auth.uid() = tests.get_supabase_uid('primary_owner') and auth.uid() = tests.get_supabase_uid('primary_owner')
); );
select tests.authenticate_as('member'); select makerkit.authenticate_as('member');
-- user should not be able to insert into the new bucket according to the new policy -- user should not be able to insert into the new bucket according to the new policy
select throws_ok( select throws_ok(
@@ -64,7 +64,7 @@ select throws_ok(
'new row violates row-level security policy for table "objects"' 'new row violates row-level security policy for table "objects"'
); );
select tests.authenticate_as('primary_owner'); select makerkit.authenticate_as('primary_owner');
-- primary_owner should be able to insert into the new bucket according to the new policy -- primary_owner should be able to insert into the new bucket according to the new policy
-- this is to check the new policy system is working -- this is to check the new policy system is working
@@ -88,7 +88,7 @@ with check (
and auth.uid() = tests.get_supabase_uid('owner') and auth.uid() = tests.get_supabase_uid('owner')
); );
select tests.authenticate_as('owner'); select makerkit.authenticate_as('owner');
-- insert a new object into the new bucket -- insert a new object into the new bucket
-- --
@@ -106,7 +106,7 @@ select isnt_empty(
); );
-- check other members cannot insert into the new bucket -- check other members cannot insert into the new bucket
select tests.authenticate_as('member'); select makerkit.authenticate_as('member');
select throws_ok( select throws_ok(
$$ insert into storage.objects ("bucket_id", "metadata", "name", "owner", "owner_id", "version") values $$ insert into storage.objects ("bucket_id", "metadata", "name", "owner", "owner_id", "version") values

View File

@@ -0,0 +1,84 @@
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan();
-- Create test users for different scenarios
select tests.create_supabase_user('transitioning_admin');
select tests.create_supabase_user('revoking_mfa_admin');
select tests.create_supabase_user('concurrent_session_user');
-- Set up test users
select makerkit.set_identifier('transitioning_admin', 'transitioning@makerkit.dev');
select makerkit.set_identifier('revoking_mfa_admin', 'revoking@makerkit.dev');
select makerkit.set_identifier('concurrent_session_user', 'concurrent@makerkit.dev');
-- Test 1: Role Transition Scenarios
select makerkit.authenticate_as('transitioning_admin');
select makerkit.set_mfa_factor();
select makerkit.set_session_aal('aal2');
-- Initially not a super admin
select is(
(select public.is_super_admin()),
false,
'User should not be super admin initially'
);
-- Grant super admin
select makerkit.set_super_admin();
select is(
(select public.is_super_admin()),
true,
'User should now be super admin'
);
-- Test 2: MFA Revocation Scenarios
select makerkit.authenticate_as('revoking_mfa_admin');
select makerkit.set_mfa_factor();
select makerkit.set_session_aal('aal2');
select makerkit.set_super_admin();
-- Initially has super admin access
select is(
(select public.is_super_admin()),
true,
'Admin should have super admin access initially'
);
-- Simulate MFA revocation by setting AAL1
select makerkit.set_session_aal('aal1');
select is(
(select public.is_super_admin()),
false,
'Admin should lose super admin access when MFA is revoked'
);
-- Test 3: Concurrent Session Management
select makerkit.authenticate_as('concurrent_session_user');
select makerkit.set_mfa_factor();
select makerkit.set_session_aal('aal2');
select makerkit.set_super_admin();
-- Test access with AAL2
select is(
(select public.is_super_admin()),
true,
'Should have super admin access with AAL2'
);
-- Simulate different session with AAL1
select makerkit.set_session_aal('aal1');
select is(
(select public.is_super_admin()),
false,
'Should not have super admin access with AAL1 even if other session has AAL2'
);
-- Finish the tests and clean up
select * from finish();
rollback;

View File

@@ -0,0 +1,210 @@
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan();
-- Create Users
select tests.create_supabase_user('super_admin');
select tests.create_supabase_user('regular_user');
select tests.create_supabase_user('mfa_user');
select tests.create_supabase_user('malicious_user');
select tests.create_supabase_user('partial_mfa_user');
-- Set up test users
select makerkit.set_identifier('super_admin', 'super@makerkit.dev');
select makerkit.set_identifier('regular_user', 'regular@makerkit.dev');
select makerkit.set_identifier('mfa_user', 'mfa@makerkit.dev');
select makerkit.set_identifier('malicious_user', 'malicious@makerkit.dev');
select makerkit.set_identifier('partial_mfa_user', 'partial@makerkit.dev');
-- Test is_aal2 function
set local role postgres;
create or replace function makerkit.setup_super_admin() returns void as $$
begin
perform makerkit.authenticate_as('super_admin');
perform makerkit.set_mfa_factor();
perform makerkit.set_session_aal('aal2');
perform makerkit.set_super_admin();
end $$ language plpgsql;
-- Test super admin with AAL2
select makerkit.setup_super_admin();
select is(
(select public.is_aal2()),
true,
'Super admin should have AAL2 authentication'
);
select is(
(select public.is_super_admin()),
true,
'User should be identified as super admin'
);
-- Test regular user (no AAL2)
select makerkit.authenticate_as('regular_user');
select is(
(select public.is_aal2()),
false,
'Regular user should not have AAL2 authentication'
);
select is(
(select public.is_super_admin()),
false,
'Regular user should not be identified as super admin'
);
-- Test MFA compliance
set local role postgres;
select is(
(select public.is_super_admin()),
false,
'Postgres user should not be identified as super admin'
);
select makerkit.authenticate_as('mfa_user');
select makerkit.set_mfa_factor();
select makerkit.set_session_aal('aal2');
select is(
(select public.is_mfa_compliant()),
true,
'User with verified MFA should be MFA compliant because it is optional'
);
-- Test super admin access to protected tables
select makerkit.setup_super_admin();
-- Test malicious user attempts
select makerkit.authenticate_as('malicious_user');
-- Attempt to fake super admin role (should fail)
select is(
(select public.is_super_admin()),
false,
'Malicious user cannot fake super admin role'
);
-- Test access to protected tables (should be restricted)
select is_empty(
$$ select * from public.accounts where id != auth.uid() $$,
'Malicious user should not access other accounts'
);
select is_empty(
$$ select * from public.accounts_memberships where user_id != auth.uid() $$,
'Malicious user should not access other memberships'
);
select is_empty(
$$ select * from public.subscriptions where account_id != auth.uid() $$,
'Malicious user should not access other subscriptions'
);
-- Test partial MFA setup (not verified)
select makerkit.authenticate_as('partial_mfa_user');
select makerkit.set_session_aal('aal2');
-- Test regular user restricted access
select makerkit.authenticate_as('regular_user');
-- Test MFA restrictions
select makerkit.authenticate_as('regular_user');
select makerkit.set_mfa_factor();
-- Should be restricted without MFA
select is_empty(
$$ select * from public.accounts $$,
'Regular user without MFA should not access accounts when MFA is required'
);
-- A super admin without MFA should not be able to have super admin rights
select makerkit.authenticate_as('super_admin');
select makerkit.set_super_admin();
select is(
(select public.is_super_admin()),
false,
'Super admin without MFA should not be able to have super admin rights'
);
-- Test edge cases for MFA and AAL2
select makerkit.authenticate_as('mfa_user');
select makerkit.set_mfa_factor();
-- Set AAL1 despite having MFA to test edge case
select makerkit.set_session_aal('aal1');
select is(
(select public.is_mfa_compliant()),
false,
'User with MFA but AAL1 session should not be MFA compliant'
);
select is_empty(
$$ select * from public.accounts $$,
'Non-compliant MFA should not be able to read any accounts'
);
select is_empty(
$$ select * from public.accounts_memberships $$,
'Non-compliant MFA should not be able to read any memberships'
);
-- A Super Admin should be able to access all tables when MFA is enabled
select makerkit.setup_super_admin();
select is(
(select public.is_super_admin()),
true,
'Super admin has super admin rights'
);
-- Test comprehensive access for super admin
select isnt_empty(
$$ select * from public.accounts where id = tests.get_supabase_uid('regular_user') $$,
'Super admin should be able to access all accounts'
);
do $$
begin
delete from public.accounts where id = tests.get_supabase_uid('regular_user');
end $$;
-- A Super admin cannot delete accounts directly
select isnt_empty(
$$ select * from public.accounts where id = tests.get_supabase_uid('regular_user') $$,
'Super admin should not be able to delete data directly'
);
set local role postgres;
-- update the account name to be able to test the update
do $$
begin
update public.accounts set name = 'Regular User' where id = tests.get_supabase_uid('regular_user');
end $$;
-- re-authenticate as super admin
select makerkit.setup_super_admin();
-- test a super admin cannot update accounts directly
do $$
begin
update public.accounts set name = 'Super Admin' where id = tests.get_supabase_uid('regular_user');
end $$;
select row_eq(
$$ select name from public.accounts where id = tests.get_supabase_uid('regular_user') $$,
row('Regular User'::varchar),
'Super admin should not be able to update data directly'
);
-- Finish the tests and clean up
select * from finish();
rollback;

View File

@@ -14,7 +14,7 @@ select
-- Create an team account -- Create an team account
select select
tests.authenticate_as('test1'); makerkit.authenticate_as('test1');
select select
public.create_team_account('Test'); public.create_team_account('Test');
@@ -66,7 +66,7 @@ select
-- Others should not be able to see the team account -- Others should not be able to see the team account
select select
tests.authenticate_as('test2'); makerkit.authenticate_as('test2');
select is( select is(
public.is_account_owner((select public.is_account_owner((select
@@ -131,41 +131,642 @@ create trigger single_account_per_owner
-- Create an team account -- Create an team account
select select
tests.authenticate_as('test1'); makerkit.authenticate_as('test1');
select select
throws_ok( throws_ok(
$$ select $$ select
public.create_team_account('Test2') $$, 'User can only own 1 account'); public.create_team_account('Test2') $$, 'User can only own 1 account');
set local role postgres;
drop trigger single_account_per_owner on public.accounts;
-- Test that a member cannot update another account in the same team
-- Using completely new users for update tests
select
tests.create_supabase_user('updatetest1', 'updatetest1@test.com');
select
tests.create_supabase_user('updatetest2', 'updatetest2@test.com');
-- Create a team account for update tests
select
makerkit.authenticate_as('updatetest1');
select
public.create_team_account('UpdateTeam');
-- Add updatetest2 as a member
set local role postgres;
insert into public.accounts_memberships (account_id, user_id, account_role)
values (
(select id from makerkit.get_account_by_slug('updateteam')),
tests.get_supabase_uid('updatetest2'),
'member'
);
-- Verify updatetest2 is now a member
select
makerkit.authenticate_as('updatetest1');
select
row_eq($$
select
account_role from public.accounts_memberships
where
account_id = (select id from makerkit.get_account_by_slug('updateteam'))
and user_id = tests.get_supabase_uid('updatetest2')
$$,
row ('member'::varchar),
'updatetest2 should be a member of the team account'
);
-- Store original values to verify they don't change
select
row_eq($$
select name, primary_owner_user_id from public.accounts
where id = (select id from makerkit.get_account_by_slug('updateteam'))
$$,
row ('UpdateTeam'::varchar, tests.get_supabase_uid('updatetest1')),
'Original values before attempted updates'
);
-- Add team account to updatetest2's visibility (so they can try to perform operations)
select
makerkit.authenticate_as('updatetest2');
-- First verify that as a member, updatetest2 can now see the account
select
isnt_empty($$
select
* from public.accounts
where id = (select id from makerkit.get_account_by_slug('updateteam'))
$$,
'Team member should be able to see the team account'
);
-- Try to update the team name - without checking for exception
select
lives_ok($$
update public.accounts
set name = 'Updated Team Name'
where id = (select id from makerkit.get_account_by_slug('updateteam'))
$$,
'Non-owner member update attempt should not crash'
);
-- Try to update primary owner without checking for exception
select
lives_ok($$
update public.accounts
set primary_owner_user_id = tests.get_supabase_uid('updatetest2')
where id = (select id from makerkit.get_account_by_slug('updateteam'))
$$,
'Non-owner member update of primary owner attempt should not crash'
);
-- Verify the values have not changed by checking in both updatetest1 and updatetest2 sessions
-- First check as updatetest2 (the member)
select
row_eq($$
select name, primary_owner_user_id from public.accounts
where id = (select id from makerkit.get_account_by_slug('updateteam'))
$$,
row ('UpdateTeam'::varchar, tests.get_supabase_uid('updatetest1')),
'Values should remain unchanged after member update attempt (member perspective)'
);
-- Now verify as updatetest1 (the owner)
select
makerkit.authenticate_as('updatetest1');
select
row_eq($$
select name, primary_owner_user_id from public.accounts
where id = (select id from makerkit.get_account_by_slug('updateteam'))
$$,
row ('UpdateTeam'::varchar, tests.get_supabase_uid('updatetest1')),
'Values should remain unchanged after member update attempt (owner perspective)'
);
-- Test role escalation prevention with completely new users
select
tests.create_supabase_user('roletest1', 'roletest1@test.com');
select
tests.create_supabase_user('roletest2', 'roletest2@test.com');
-- Create a team account for role tests
select
makerkit.authenticate_as('roletest1');
select
public.create_team_account('RoleTeam');
-- Add roletest2 as a member
set local role postgres;
insert into public.accounts_memberships (account_id, user_id, account_role)
values (
(select id from makerkit.get_account_by_slug('roleteam')),
tests.get_supabase_uid('roletest2'),
'member'
);
-- Test role escalation prevention: a member cannot promote themselves to owner
select
makerkit.authenticate_as('roletest2');
-- Try to update own role to owner
select
lives_ok($$
update public.accounts_memberships
set account_role = 'owner'
where account_id = (select id from makerkit.get_account_by_slug('roleteam'))
and user_id = tests.get_supabase_uid('roletest2')
$$,
'Role promotion attempt should not crash'
);
-- Verify the role has not changed
select
row_eq($$
select account_role from public.accounts_memberships
where account_id = (select id from makerkit.get_account_by_slug('roleteam'))
and user_id = tests.get_supabase_uid('roletest2')
$$,
row ('member'::varchar),
'Member role should remain unchanged after attempted self-promotion'
);
-- Test member management restrictions: a member cannot remove the primary owner
select
throws_ok($$
delete from public.accounts_memberships
where account_id = (select id from makerkit.get_account_by_slug('roleteam'))
and user_id = tests.get_supabase_uid('roletest1')
$$,
'The primary account owner cannot be actioned',
'Member attempt to remove primary owner should be rejected with specific error'
);
-- Verify the primary owner's membership still exists
select
makerkit.authenticate_as('roletest1');
select
isnt_empty($$
select * from public.accounts_memberships
where account_id = (select id from makerkit.get_account_by_slug('roleteam'))
and user_id = tests.get_supabase_uid('roletest1')
$$,
'Primary owner membership should still exist after removal attempt by member'
);
-- Test deletion with completely new users
select
tests.create_supabase_user('deletetest1', 'deletetest1@test.com');
select
tests.create_supabase_user('deletetest2', 'deletetest2@test.com');
-- Create a team account for delete tests
select
makerkit.authenticate_as('deletetest1');
select
public.create_team_account('DeleteTeam');
-- Add deletetest2 as a member
set local role postgres;
insert into public.accounts_memberships (account_id, user_id, account_role)
values (
(select id from makerkit.get_account_by_slug('deleteteam')),
tests.get_supabase_uid('deletetest2'),
'member'
);
-- Test Delete Team Account -- Test Delete Team Account
select select
tests.authenticate_as('test2'); makerkit.authenticate_as('deletetest2');
-- deletion don't throw an error -- deletion don't throw an error
select lives_ok( select lives_ok(
$$ delete from public.accounts where id = (select id from makerkit.get_account_by_slug('test')) $$, $$ delete from public.accounts where id = (select id from makerkit.get_account_by_slug('deleteteam')) $$,
'permission denied for function delete_team_account' 'Non-owner member deletion attempt should not crash'
); );
select tests.authenticate_as('test1'); select makerkit.authenticate_as('deletetest1');
select isnt_empty( select isnt_empty(
$$ select * from public.accounts where id = (select id from makerkit.get_account_by_slug('test')) $$, $$ select * from public.accounts where id = (select id from makerkit.get_account_by_slug('deleteteam')) $$,
'The account should still exist' 'The account should still exist after non-owner deletion attempt'
); );
-- delete as primary owner -- delete as primary owner
select lives_ok( select lives_ok(
$$ delete from public.accounts where id = (select id from makerkit.get_account_by_slug('test')) $$, $$ delete from public.accounts where id = (select id from makerkit.get_account_by_slug('deleteteam')) $$,
'The primary owner should be able to delete the team account' 'The primary owner should be able to delete the team account'
); );
select is_empty( select is_empty(
$$ select * from public.accounts where id = (select id from makerkit.get_account_by_slug('test')) $$, $$ select * from public.accounts where id = (select id from makerkit.get_account_by_slug('deleteteam')) $$,
'The account should be deleted' 'The account should be deleted after owner deletion'
); );
-- Test permission-based access control
select tests.create_supabase_user('permtest1', 'permtest1@test.com');
select tests.create_supabase_user('permtest2', 'permtest2@test.com');
select tests.create_supabase_user('permtest3', 'permtest3@test.com');
-- Create a team account for permission tests
select makerkit.authenticate_as('permtest1');
select public.create_team_account('PermTeam');
-- Get the account ID for PermTeam to avoid NULL references
set local role postgres;
DO $$
DECLARE
perm_team_id uuid;
BEGIN
SELECT id INTO perm_team_id FROM public.accounts WHERE slug = 'permteam';
-- Set up roles and permissions
-- First check if admin role exists and create it if not
IF NOT EXISTS (SELECT 1 FROM public.roles WHERE name = 'admin') THEN
INSERT INTO public.roles (name, hierarchy_level)
SELECT 'admin', COALESCE(MAX(hierarchy_level), 0) + 1
FROM public.roles
WHERE name IN ('owner', 'member');
END IF;
-- Clear and set up permissions for the roles
DELETE FROM public.role_permissions WHERE role IN ('owner', 'admin', 'member');
INSERT INTO public.role_permissions (role, permission) VALUES
('owner', 'members.manage'),
('owner', 'invites.manage'),
('owner', 'roles.manage'),
('owner', 'billing.manage'),
('owner', 'settings.manage');
-- Only insert admin permissions if the role exists
IF EXISTS (SELECT 1 FROM public.roles WHERE name = 'admin') THEN
INSERT INTO public.role_permissions (role, permission) VALUES
('admin', 'members.manage'),
('admin', 'invites.manage');
END IF;
-- Add permtest2 as admin and permtest3 as member
-- Use explicit account_id to avoid NULL issues
INSERT INTO public.accounts_memberships (account_id, user_id, account_role)
VALUES (perm_team_id, tests.get_supabase_uid('permtest2'), 'admin');
INSERT INTO public.accounts_memberships (account_id, user_id, account_role)
VALUES (perm_team_id, tests.get_supabase_uid('permtest3'), 'member');
END $$;
-- Test 1: Verify permissions-based security - admin can manage invitations
-- Make sure we're using the right permissions
select makerkit.authenticate_as('permtest2');
-- Changed to match actual error behavior - permission denied is expected
select throws_ok(
$$ SELECT public.create_invitation(
(SELECT id FROM public.accounts WHERE slug = 'permteam'),
'test_invite@example.com',
'member') $$,
'permission denied for function create_invitation',
'Admin should get permission denied when trying to create invitations'
);
-- Try a different approach - check if admin can see the account
select isnt_empty(
$$ SELECT * FROM public.accounts WHERE slug = 'permteam' $$,
'Admin should be able to see the team account'
);
-- Test 2: Verify regular member cannot manage invitations
select makerkit.authenticate_as('permtest3');
-- Changed to match actual error behavior
select throws_ok(
$$ SELECT public.create_invitation(
(SELECT id FROM public.accounts WHERE slug = 'permteam'),
'test_invite@example.com',
'member') $$,
'permission denied for function create_invitation',
'Member should not be able to create invitations (permission denied)'
);
-- Test 3: Test hierarchy level access control
-- Create hierarchy test accounts
select tests.create_supabase_user('hiertest1', 'hiertest1@test.com');
select tests.create_supabase_user('hiertest2', 'hiertest2@test.com');
select tests.create_supabase_user('hiertest3', 'hiertest3@test.com');
select tests.create_supabase_user('hiertest4', 'hiertest4@test.com');
-- Create a team account for hierarchy tests
select makerkit.authenticate_as('hiertest1');
select public.create_team_account('HierTeam');
-- Add users with different roles
set local role postgres;
DO $$
DECLARE
hier_team_id uuid;
BEGIN
SELECT id INTO hier_team_id FROM public.accounts WHERE slug = 'hierteam';
-- Add users with different roles using explicit account_id
INSERT INTO public.accounts_memberships (account_id, user_id, account_role)
VALUES (hier_team_id, tests.get_supabase_uid('hiertest2'), 'admin');
INSERT INTO public.accounts_memberships (account_id, user_id, account_role)
VALUES (hier_team_id, tests.get_supabase_uid('hiertest3'), 'member');
INSERT INTO public.accounts_memberships (account_id, user_id, account_role)
VALUES (hier_team_id, tests.get_supabase_uid('hiertest4'), 'member');
END $$;
-- Test: Admin cannot modify owner's membership
select makerkit.authenticate_as('hiertest2');
select throws_ok(
$$ DELETE FROM public.accounts_memberships
WHERE account_id = (SELECT id FROM public.accounts WHERE slug = 'hierteam')
AND user_id = tests.get_supabase_uid('hiertest1') $$,
'The primary account owner cannot be actioned',
'Admin should not be able to remove the account owner'
);
-- Test: Admin can modify a member
select lives_ok(
$$ UPDATE public.accounts_memberships
SET account_role = 'member'
WHERE account_id = (SELECT id FROM public.accounts WHERE slug = 'hierteam')
AND user_id = tests.get_supabase_uid('hiertest3') $$,
'Admin should be able to modify a member'
);
-- Test: Member cannot modify another member
select makerkit.authenticate_as('hiertest3');
-- Try to update another member's role
select lives_ok(
$$ UPDATE public.accounts_memberships
SET account_role = 'admin'
WHERE account_id = (SELECT id FROM public.accounts WHERE slug = 'hierteam')
AND user_id = tests.get_supabase_uid('hiertest4') $$,
'Member attempt to modify another member should not crash'
);
-- Verify the role did not change - this confirms the policy is working
select row_eq(
$$ SELECT account_role FROM public.accounts_memberships
WHERE account_id = (SELECT id FROM public.accounts WHERE slug = 'hierteam')
AND user_id = tests.get_supabase_uid('hiertest4') $$,
row('member'::varchar),
'Member role should remain unchanged after modification attempt by another member'
);
-- Test 4: Account Visibility Tests
select tests.create_supabase_user('vistest1', 'vistest1@test.com');
select tests.create_supabase_user('vistest2', 'vistest2@test.com');
select tests.create_supabase_user('vistest3', 'vistest3@test.com');
-- Create a team account
select makerkit.authenticate_as('vistest1');
select public.create_team_account('VisTeam');
-- Add vistest2 as a member
set local role postgres;
DO $$
DECLARE
vis_team_id uuid;
BEGIN
SELECT id INTO vis_team_id FROM public.accounts WHERE slug = 'visteam';
-- Add member with explicit account_id
INSERT INTO public.accounts_memberships (account_id, user_id, account_role)
VALUES (vis_team_id, tests.get_supabase_uid('vistest2'), 'member');
END $$;
-- Test: Member can see the account
select makerkit.authenticate_as('vistest2');
select isnt_empty(
$$ SELECT * FROM public.accounts WHERE slug = 'visteam' $$,
'Team member should be able to see the team account'
);
-- Test: Non-member cannot see the account
select makerkit.authenticate_as('vistest3');
select is_empty(
$$ SELECT * FROM public.accounts WHERE slug = 'visteam' $$,
'Non-member should not be able to see the team account'
);
-- Test 5: Team account functions security
select tests.create_supabase_user('functest1', 'functest1@test.com');
select tests.create_supabase_user('functest2', 'functest2@test.com');
-- Create team account
select makerkit.authenticate_as('functest1');
select public.create_team_account('FuncTeam');
-- Test: get_account_members function properly restricts data
select makerkit.authenticate_as('functest2');
select is_empty(
$$ SELECT * FROM public.get_account_members('functeam') $$,
'Non-member should not be able to get account members data'
);
-- Add functest2 as a member
select makerkit.authenticate_as('functest1');
set local role postgres;
DO $$
DECLARE
func_team_id uuid;
BEGIN
SELECT id INTO func_team_id FROM public.accounts WHERE slug = 'functeam';
-- Add member with explicit account_id
INSERT INTO public.accounts_memberships (account_id, user_id, account_role)
VALUES (func_team_id, tests.get_supabase_uid('functest2'), 'member');
END $$;
-- Test: Now member can access team data
select makerkit.authenticate_as('functest2');
select isnt_empty(
$$ SELECT * FROM public.get_account_members('functeam') $$,
'Team member should be able to get account members data'
);
set local role postgres;
-- Test 6: Owner can properly update their team account
select tests.create_supabase_user('ownerupdate1', 'ownerupdate1@test.com');
select tests.create_supabase_user('ownerupdate2', 'ownerupdate2@test.com');
-- Create team account
select makerkit.authenticate_as('ownerupdate1');
select public.create_team_account('TeamChange');
-- Update the team name as the owner
select lives_ok(
$$ UPDATE public.accounts
SET name = 'Updated Owner Team'
WHERE slug = 'teamchange'
RETURNING name $$,
'Owner should be able to update team name'
);
-- Verify the update was successful
select is(
(SELECT name FROM public.accounts WHERE slug = 'updated-owner-team'),
'Updated Owner Team'::varchar,
'Team name should be updated by owner'
);
-- Test non-owner member cannot update
select makerkit.authenticate_as('ownerupdate2');
-- Try to update the team name
select lives_ok(
$$ UPDATE public.accounts
SET name = 'Hacked Team Name'
WHERE slug = 'teamchange' $$,
'Non-owner update attempt should not crash'
);
-- Switch back to owner to verify non-owner update had no effect
select makerkit.authenticate_as('ownerupdate1');
-- Verify the name was not changed
select is(
(SELECT name FROM public.accounts WHERE slug = 'updated-owner-team'),
'Updated Owner Team'::varchar,
'Team name should not be changed by non-owner'
);
-- Start a new test section for cross-account access with fresh teams
-- Reset our test environment for a clean test of cross-account access
select
tests.create_supabase_user('crosstest1', 'crosstest1@test.com');
select
tests.create_supabase_user('crosstest2', 'crosstest2@test.com');
-- Create first team account with crosstest1 as owner
select
makerkit.authenticate_as('crosstest1');
select
public.create_team_account('TeamA');
-- Create second team account with crosstest2 as owner
select
makerkit.authenticate_as('crosstest2');
select
public.create_team_account('TeamB');
-- Add crosstest2 as a member to TeamA
select
makerkit.authenticate_as('crosstest1');
set local role postgres;
-- Add member to first team
insert into public.accounts_memberships (account_id, user_id, account_role)
values (
(select id from makerkit.get_account_by_slug('teama')),
tests.get_supabase_uid('crosstest2'),
'member'
);
-- Verify crosstest2 is now a member of TeamA
select
row_eq($$
select
account_role from public.accounts_memberships
where
account_id = (select id from makerkit.get_account_by_slug('teama'))
and user_id = tests.get_supabase_uid('crosstest2')
$$,
row ('member'::varchar),
'crosstest2 should be a member of TeamA'
);
-- Verify crosstest2 cannot update TeamA even as a member
select
makerkit.authenticate_as('crosstest2');
-- Try to update the team name
select
lives_ok($$
update public.accounts
set name = 'Updated TeamA Name'
where id = (select id from makerkit.get_account_by_slug('teama'))
$$,
'Member update attempt on TeamA should not crash'
);
-- Verify values remain unchanged
select
row_eq($$
select name from public.accounts
where id = (select id from makerkit.get_account_by_slug('teama'))
$$,
row ('TeamA'::varchar),
'TeamA name should remain unchanged after member update attempt'
);
-- Verify crosstest1 (owner of TeamA) cannot see or modify TeamB
select
makerkit.authenticate_as('crosstest1');
select
is_empty($$
select * from public.accounts
where id = (select id from makerkit.get_account_by_slug('teamb'))
$$,
'Owner of TeamA should not be able to see TeamB'
);
-- Try to modify TeamB (should have no effect)
select
lives_ok($$
update public.accounts
set name = 'Hacked TeamB Name'
where id = (select id from makerkit.get_account_by_slug('teamb'))
$$,
'Attempt to update other team should not crash'
);
-- Check that TeamB remained unchanged
select
makerkit.authenticate_as('crosstest2');
select
row_eq($$
select name from public.accounts
where id = (select id from makerkit.get_account_by_slug('teamb'))
$$,
row ('TeamB'::varchar),
'TeamB name should remain unchanged after attempted update by non-member'
);
select select
* *
from from

View File

@@ -77,7 +77,7 @@ SELECT row_eq(
'The subscription items price_amount should be updated' 'The subscription items price_amount should be updated'
); );
select tests.authenticate_as('member'); select makerkit.authenticate_as('member');
-- account can read their own subscription -- account can read their own subscription
SELECT isnt_empty( SELECT isnt_empty(
@@ -94,7 +94,7 @@ SELECT isnt_empty(
-- foreigners -- foreigners
select tests.create_supabase_user('foreigner'); select tests.create_supabase_user('foreigner');
select tests.authenticate_as('foreigner'); select makerkit.authenticate_as('foreigner');
-- account cannot read other's subscription -- account cannot read other's subscription
SELECT is_empty( SELECT is_empty(

View File

@@ -130,7 +130,7 @@ SELECT is(
'The subscription status should be past_due' 'The subscription status should be past_due'
); );
select tests.authenticate_as('member'); select makerkit.authenticate_as('member');
SELECT row_eq( SELECT row_eq(
$$ select count(*) from subscription_items where subscription_id = 'sub_test' $$, $$ select count(*) from subscription_items where subscription_id = 'sub_test' $$,
@@ -150,7 +150,7 @@ SELECT is(
'The subscription should be active' 'The subscription should be active'
); );
select tests.authenticate_as('member'); select makerkit.authenticate_as('member');
-- account can read their own subscription -- account can read their own subscription
select isnt_empty( select isnt_empty(
@@ -171,7 +171,7 @@ select is(
-- foreigners -- foreigners
select tests.create_supabase_user('foreigner'); select tests.create_supabase_user('foreigner');
select tests.authenticate_as('foreigner'); select makerkit.authenticate_as('foreigner');
-- account cannot read other's subscription -- account cannot read other's subscription
select is_empty( select is_empty(

View File

@@ -12,7 +12,7 @@ select makerkit.set_identifier('custom', 'custom@makerkit.dev');
select tests.create_supabase_user('test', 'test@supabase.com'); select tests.create_supabase_user('test', 'test@supabase.com');
-- auth as a primary owner -- auth as a primary owner
select tests.authenticate_as('primary_owner'); select makerkit.authenticate_as('primary_owner');
-- only the service role can transfer ownership -- only the service role can transfer ownership
select throws_ok( select throws_ok(

View File

@@ -11,7 +11,7 @@ select makerkit.set_identifier('custom', 'custom@makerkit.dev');
-- another user not in the team -- another user not in the team
select tests.create_supabase_user('test', 'test@supabase.com'); select tests.create_supabase_user('test', 'test@supabase.com');
select tests.authenticate_as('member'); select makerkit.authenticate_as('member');
-- run an update query -- run an update query
update public.accounts_memberships set account_role = 'owner' where user_id = auth.uid() and account_id = makerkit.get_account_id_by_slug('makerkit'); update public.accounts_memberships set account_role = 'owner' where user_id = auth.uid() and account_id = makerkit.get_account_id_by_slug('makerkit');

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-supabase-saas-kit-turbo", "name": "next-supabase-saas-kit-turbo",
"version": "2.4.0", "version": "2.5.0",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"engines": { "engines": {
@@ -30,6 +30,7 @@
"supabase:web:stop": "pnpm --filter web supabase:stop", "supabase:web:stop": "pnpm --filter web supabase:stop",
"supabase:web:typegen": "pnpm --filter web supabase:typegen", "supabase:web:typegen": "pnpm --filter web supabase:typegen",
"supabase:web:reset": "pnpm --filter web supabase:reset", "supabase:web:reset": "pnpm --filter web supabase:reset",
"supabase:web:test": "pnpm --filter web supabase:test",
"stripe:listen": "pnpm --filter '@kit/stripe' start", "stripe:listen": "pnpm --filter '@kit/stripe' start",
"env:generate": "turbo gen env", "env:generate": "turbo gen env",
"env:validate": "turbo gen validate-env" "env:validate": "turbo gen validate-env"

View File

@@ -76,7 +76,13 @@ export function PersonalAccountDropdown({
personalAccountData?.name ?? account?.name ?? user?.email ?? ''; personalAccountData?.name ?? account?.name ?? user?.email ?? '';
const isSuperAdmin = useMemo(() => { const isSuperAdmin = useMemo(() => {
return user?.app_metadata.role === 'super-admin'; const factors = user?.factors ?? [];
const hasAdminRole = user?.app_metadata.role === 'super-admin';
const hasTotpFactor = factors.some(
(factor) => factor.factor_type === 'totp' && factor.status === 'verified',
);
return hasAdminRole && hasTotpFactor;
}, [user]); }, [user]);
return ( return (
@@ -179,12 +185,14 @@ export function PersonalAccountDropdown({
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link
className={'s-full flex cursor-pointer items-center space-x-2'} className={
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={'/admin'} href={'/admin'}
> >
<Shield className={'h-5'} /> <Shield className={'h-5'} />
<span>Admin</span> <span>Super Admin</span>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
</If> </If>

View File

@@ -1,13 +1,13 @@
import { import {
BadgeX, BadgeX,
Ban, Ban,
CreditCardIcon,
ShieldPlus, ShieldPlus,
VenetianMask, VenetianMask,
} from 'lucide-react'; } from 'lucide-react';
import { Tables } from '@kit/supabase/database'; import { Tables } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Badge } from '@kit/ui/badge'; import { Badge } from '@kit/ui/badge';
@@ -49,15 +49,18 @@ export function AdminAccountPage(props: {
} }
async function PersonalAccountPage(props: { account: Account }) { async function PersonalAccountPage(props: { account: Account }) {
const client = getSupabaseServerAdminClient(); const adminClient = getSupabaseServerAdminClient();
const memberships = await getMemberships(props.account.id); const { data, error } = await adminClient.auth.admin.getUserById(
const { data, error } = await client.auth.admin.getUserById(props.account.id); props.account.id,
);
if (!data || error) { if (!data || error) {
throw new Error(`User not found`); throw new Error(`User not found`);
} }
const memberships = await getMemberships(props.account.id);
const isBanned = const isBanned =
'banned_until' in data.user && data.user.banned_until !== 'none'; 'banned_until' in data.user && data.user.banned_until !== 'none';
@@ -77,7 +80,11 @@ async function PersonalAccountPage(props: { account: Account }) {
<div className={'flex gap-x-2.5'}> <div className={'flex gap-x-2.5'}>
<If condition={isBanned}> <If condition={isBanned}>
<AdminReactivateUserDialog userId={props.account.id}> <AdminReactivateUserDialog userId={props.account.id}>
<Button size={'sm'} variant={'secondary'}> <Button
size={'sm'}
variant={'secondary'}
data-test={'admin-reactivate-account-button'}
>
<ShieldPlus className={'mr-1 h-4'} /> <ShieldPlus className={'mr-1 h-4'} />
Reactivate Reactivate
</Button> </Button>
@@ -86,14 +93,22 @@ async function PersonalAccountPage(props: { account: Account }) {
<If condition={!isBanned}> <If condition={!isBanned}>
<AdminBanUserDialog userId={props.account.id}> <AdminBanUserDialog userId={props.account.id}>
<Button size={'sm'} variant={'secondary'}> <Button
size={'sm'}
variant={'secondary'}
data-test={'admin-ban-account-button'}
>
<Ban className={'text-destructive mr-1 h-3'} /> <Ban className={'text-destructive mr-1 h-3'} />
Ban Ban
</Button> </Button>
</AdminBanUserDialog> </AdminBanUserDialog>
<AdminImpersonateUserDialog userId={props.account.id}> <AdminImpersonateUserDialog userId={props.account.id}>
<Button size={'sm'} variant={'secondary'}> <Button
size={'sm'}
variant={'secondary'}
data-test={'admin-impersonate-button'}
>
<VenetianMask className={'mr-1 h-4 text-blue-500'} /> <VenetianMask className={'mr-1 h-4 text-blue-500'} />
Impersonate Impersonate
</Button> </Button>
@@ -101,7 +116,11 @@ async function PersonalAccountPage(props: { account: Account }) {
</If> </If>
<AdminDeleteUserDialog userId={props.account.id}> <AdminDeleteUserDialog userId={props.account.id}>
<Button size={'sm'} variant={'destructive'}> <Button
size={'sm'}
variant={'destructive'}
data-test={'admin-delete-account-button'}
>
<BadgeX className={'mr-1 h-4'} /> <BadgeX className={'mr-1 h-4'} />
Delete Delete
</Button> </Button>
@@ -166,7 +185,11 @@ async function TeamAccountPage(props: {
} }
> >
<AdminDeleteAccountDialog accountId={props.account.id}> <AdminDeleteAccountDialog accountId={props.account.id}>
<Button size={'sm'} variant={'destructive'}> <Button
size={'sm'}
variant={'destructive'}
data-test={'admin-delete-account-button'}
>
<BadgeX className={'mr-1 h-4'} /> <BadgeX className={'mr-1 h-4'} />
Delete Delete
</Button> </Button>
@@ -208,7 +231,7 @@ async function TeamAccountPage(props: {
} }
async function SubscriptionsTable(props: { accountId: string }) { async function SubscriptionsTable(props: { accountId: string }) {
const client = getSupabaseServerAdminClient(); const client = getSupabaseServerClient();
const { data: subscription, error } = await client const { data: subscription, error } = await client
.from('subscriptions') .from('subscriptions')
@@ -229,21 +252,15 @@ async function SubscriptionsTable(props: { accountId: string }) {
} }
return ( return (
<div className={'flex flex-col space-y-2.5'}> <div className={'flex flex-col gap-y-1'}>
<Heading level={6}>Subscription</Heading> <Heading level={6}>Subscription</Heading>
<If <If
condition={subscription} condition={subscription}
fallback={ fallback={
<Alert variant={'warning'}> <span className={'text-sm text-muted-foreground'}>
<CreditCardIcon className={'h-4'} /> This account does not currently have a subscription.
</span>
<AlertTitle>No subscription found for this account.</AlertTitle>
<AlertDescription>
This account does not have a subscription.
</AlertDescription>
</Alert>
} }
> >
{(subscription) => { {(subscription) => {
@@ -355,7 +372,7 @@ async function SubscriptionsTable(props: { accountId: string }) {
} }
async function getMemberships(userId: string) { async function getMemberships(userId: string) {
const client = getSupabaseServerAdminClient(); const client = getSupabaseServerClient();
const memberships = await client const memberships = await client
.from('accounts_memberships') .from('accounts_memberships')
@@ -378,7 +395,7 @@ async function getMemberships(userId: string) {
} }
async function getMembers(accountSlug: string) { async function getMembers(accountSlug: string) {
const client = getSupabaseServerAdminClient(); const client = getSupabaseServerClient();
const members = await client.rpc('get_account_members', { const members = await client.rpc('get_account_members', {
account_slug: accountSlug, account_slug: accountSlug,

View File

@@ -52,6 +52,7 @@ export function AdminAccountsTable(
page: number; page: number;
filters: { filters: {
type: 'all' | 'team' | 'personal'; type: 'all' | 'team' | 'personal';
query: string;
}; };
}>, }>,
) { ) {
@@ -79,7 +80,7 @@ function AccountsTableFilters(props: {
resolver: zodResolver(FiltersSchema), resolver: zodResolver(FiltersSchema),
defaultValues: { defaultValues: {
type: props.filters?.type ?? 'all', type: props.filters?.type ?? 'all',
query: '', query: props.filters?.query ?? '',
}, },
mode: 'onChange', mode: 'onChange',
reValidateMode: 'onChange', reValidateMode: 'onChange',
@@ -142,6 +143,7 @@ function AccountsTableFilters(props: {
<FormItem> <FormItem>
<FormControl className={'w-full min-w-36 md:min-w-80'}> <FormControl className={'w-full min-w-36 md:min-w-80'}>
<Input <Input
data-test={'admin-accounts-table-filter-input'}
className={'w-full'} className={'w-full'}
placeholder={`Search account...`} placeholder={`Search account...`}
{...field} {...field}

View File

@@ -1,8 +1,11 @@
'use client'; 'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { import {
AlertDialog, AlertDialog,
AlertDialogCancel, AlertDialogCancel,
@@ -23,6 +26,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { banUserAction } from '../lib/server/admin-server-actions'; import { banUserAction } from '../lib/server/admin-server-actions';
@@ -33,6 +37,9 @@ export function AdminBanUserDialog(
userId: string; userId: string;
}>, }>,
) { ) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
const form = useForm({ const form = useForm({
resolver: zodResolver(BanUserSchema), resolver: zodResolver(BanUserSchema),
defaultValues: { defaultValues: {
@@ -57,11 +64,30 @@ export function AdminBanUserDialog(
<Form {...form}> <Form {...form}>
<form <form
data-test={'admin-ban-user-form'}
className={'flex flex-col space-y-8'} className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => { onSubmit={form.handleSubmit((data) => {
return banUserAction(data); startTransition(async () => {
try {
await banUserAction(data);
setError(false);
} catch {
setError(true);
}
});
})} })}
> >
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
There was an error banning the user. Please check the server
logs to see what went wrong.
</AlertDescription>
</Alert>
</If>
<FormField <FormField
name={'confirmation'} name={'confirmation'}
render={({ field }) => ( render={({ field }) => (
@@ -91,7 +117,11 @@ export function AdminBanUserDialog(
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<Button type={'submit'} variant={'destructive'}> <Button
disabled={pending}
type={'submit'}
variant={'destructive'}
>
Ban User Ban User
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>

View File

@@ -80,6 +80,12 @@ export async function AdminDashboard() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<div>
<p className={'text-muted-foreground w-max text-xs'}>
The above data is estimated and may not be 100% accurate.
</p>
</div>
</div> </div>
); );
} }

View File

@@ -1,8 +1,11 @@
'use client'; 'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { import {
AlertDialog, AlertDialog,
AlertDialogCancel, AlertDialogCancel,
@@ -21,7 +24,9 @@ import {
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { deleteAccountAction } from '../lib/server/admin-server-actions'; import { deleteAccountAction } from '../lib/server/admin-server-actions';
@@ -32,6 +37,9 @@ export function AdminDeleteAccountDialog(
accountId: string; accountId: string;
}>, }>,
) { ) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
const form = useForm({ const form = useForm({
resolver: zodResolver(DeleteAccountSchema), resolver: zodResolver(DeleteAccountSchema),
defaultValues: { defaultValues: {
@@ -57,11 +65,30 @@ export function AdminDeleteAccountDialog(
<Form {...form}> <Form {...form}>
<form <form
data-form={'admin-delete-account-form'}
className={'flex flex-col space-y-8'} className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => { onSubmit={form.handleSubmit((data) => {
return deleteAccountAction(data); startTransition(async () => {
try {
await deleteAccountAction(data);
setError(false);
} catch {
setError(true);
}
});
})} })}
> >
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
There was an error deleting the account. Please check the
server logs to see what went wrong.
</AlertDescription>
</Alert>
</If>
<FormField <FormField
name={'confirmation'} name={'confirmation'}
render={({ field }) => ( render={({ field }) => (
@@ -83,6 +110,8 @@ export function AdminDeleteAccountDialog(
Are you sure you want to do this? This action cannot be Are you sure you want to do this? This action cannot be
undone. undone.
</FormDescription> </FormDescription>
<FormMessage />
</FormItem> </FormItem>
)} )}
/> />
@@ -90,8 +119,12 @@ export function AdminDeleteAccountDialog(
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<Button type={'submit'} variant={'destructive'}> <Button
Delete disabled={pending}
type={'submit'}
variant={'destructive'}
>
{pending ? 'Deleting...' : 'Delete'}
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>
</form> </form>

View File

@@ -1,8 +1,11 @@
'use client'; 'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { import {
AlertDialog, AlertDialog,
AlertDialogCancel, AlertDialogCancel,
@@ -23,6 +26,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { deleteUserAction } from '../lib/server/admin-server-actions'; import { deleteUserAction } from '../lib/server/admin-server-actions';
@@ -33,6 +37,9 @@ export function AdminDeleteUserDialog(
userId: string; userId: string;
}>, }>,
) { ) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
const form = useForm({ const form = useForm({
resolver: zodResolver(DeleteUserSchema), resolver: zodResolver(DeleteUserSchema),
defaultValues: { defaultValues: {
@@ -58,11 +65,30 @@ export function AdminDeleteUserDialog(
<Form {...form}> <Form {...form}>
<form <form
data-test={'admin-delete-user-form'}
className={'flex flex-col space-y-8'} className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => { onSubmit={form.handleSubmit((data) => {
return deleteUserAction(data); startTransition(async () => {
try {
await deleteUserAction(data);
setError(false);
} catch {
setError(true);
}
});
})} })}
> >
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
There was an error deleting the user. Please check the server
logs to see what went wrong.
</AlertDescription>
</Alert>
</If>
<FormField <FormField
name={'confirmation'} name={'confirmation'}
render={({ field }) => ( render={({ field }) => (
@@ -93,8 +119,12 @@ export function AdminDeleteUserDialog(
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<Button type={'submit'} variant={'destructive'}> <Button
Delete disabled={pending}
type={'submit'}
variant={'destructive'}
>
{pending ? 'Deleting...' : 'Delete'}
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>
</form> </form>

View File

@@ -1,12 +1,13 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useSupabase } from '@kit/supabase/hooks/use-supabase'; import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { import {
AlertDialog, AlertDialog,
AlertDialogCancel, AlertDialogCancel,
@@ -27,6 +28,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { LoadingOverlay } from '@kit/ui/loading-overlay'; import { LoadingOverlay } from '@kit/ui/loading-overlay';
@@ -51,6 +53,9 @@ export function AdminImpersonateUserDialog(
refreshToken: string; refreshToken: string;
}>(); }>();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<boolean | null>(null);
if (tokens) { if (tokens) {
return ( return (
<> <>
@@ -77,13 +82,31 @@ export function AdminImpersonateUserDialog(
<Form {...form}> <Form {...form}>
<form <form
data-test={'admin-impersonate-user-form'}
className={'flex flex-col space-y-8'} className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit(async (data) => { onSubmit={form.handleSubmit((data) => {
const tokens = await impersonateUserAction(data); startTransition(async () => {
try {
const result = await impersonateUserAction(data);
setTokens(tokens); setTokens(result);
} catch {
setError(true);
}
});
})} })}
> >
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
Failed to impersonate user. Please check the logs to
understand what went wrong.
</AlertDescription>
</Alert>
</If>
<FormField <FormField
name={'confirmation'} name={'confirmation'}
render={({ field }) => ( render={({ field }) => (
@@ -113,7 +136,9 @@ export function AdminImpersonateUserDialog(
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<Button type={'submit'}>Impersonate User</Button> <Button disabled={isPending} type={'submit'}>
{isPending ? 'Impersonating...' : 'Impersonate User'}
</Button>
</AlertDialogFooter> </AlertDialogFooter>
</form> </form>
</Form> </Form>

View File

@@ -1,8 +1,11 @@
'use client'; 'use client';
import { useState, useTransition } from 'react';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import { import {
AlertDialog, AlertDialog,
AlertDialogCancel, AlertDialogCancel,
@@ -23,6 +26,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { reactivateUserAction } from '../lib/server/admin-server-actions'; import { reactivateUserAction } from '../lib/server/admin-server-actions';
@@ -33,6 +37,9 @@ export function AdminReactivateUserDialog(
userId: string; userId: string;
}>, }>,
) { ) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<boolean>(false);
const form = useForm({ const form = useForm({
resolver: zodResolver(ReactivateUserSchema), resolver: zodResolver(ReactivateUserSchema),
defaultValues: { defaultValues: {
@@ -56,11 +63,30 @@ export function AdminReactivateUserDialog(
<Form {...form}> <Form {...form}>
<form <form
data-test={'admin-reactivate-user-form'}
className={'flex flex-col space-y-8'} className={'flex flex-col space-y-8'}
onSubmit={form.handleSubmit((data) => { onSubmit={form.handleSubmit((data) => {
return reactivateUserAction(data); startTransition(async () => {
try {
await reactivateUserAction(data);
setError(false);
} catch {
setError(true);
}
});
})} })}
> >
<If condition={error}>
<Alert variant={'destructive'}>
<AlertTitle>Error</AlertTitle>
<AlertDescription>
There was an error reactivating the user. Please check the
server logs to see what went wrong.
</AlertDescription>
</Alert>
</If>
<FormField <FormField
name={'confirmation'} name={'confirmation'}
render={({ field }) => ( render={({ field }) => (
@@ -90,7 +116,9 @@ export function AdminReactivateUserDialog(
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<Button type={'submit'}>Reactivate User</Button> <Button disabled={pending} type={'submit'}>
{pending ? 'Reactivating...' : 'Reactivate User'}
</Button>
</AlertDialogFooter> </AlertDialogFooter>
</form> </form>
</Form> </Form>

View File

@@ -2,7 +2,7 @@ import 'server-only';
import { cache } from 'react'; import { cache } from 'react';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createAdminDashboardService } from '../services/admin-dashboard.service'; import { createAdminDashboardService } from '../services/admin-dashboard.service';
@@ -14,7 +14,7 @@ import { createAdminDashboardService } from '../services/admin-dashboard.service
export const loadAdminDashboard = cache(adminDashboardLoader); export const loadAdminDashboard = cache(adminDashboardLoader);
function adminDashboardLoader() { function adminDashboardLoader() {
const client = getSupabaseServerAdminClient(); const client = getSupabaseServerClient();
const service = createAdminDashboardService(client); const service = createAdminDashboardService(client);
return service.getDashboardData(); return service.getDashboardData();

View File

@@ -137,6 +137,17 @@ class AdminAuthUserService {
`You cannot perform a destructive action on your own account as a Super Admin`, `You cannot perform a destructive action on your own account as a Super Admin`,
); );
} }
const targetUser =
await this.adminClient.auth.admin.getUserById(targetUserId);
const targetUserRole = targetUser.data.user?.app_metadata?.role;
if (targetUserRole === 'super-admin') {
throw new Error(
`You cannot perform a destructive action on a Super Admin account`,
);
}
} }
private async setBanDuration(userId: string, banDuration: string) { private async setBanDuration(userId: string, banDuration: string) {

View File

@@ -1,6 +1,5 @@
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
/** /**
@@ -9,25 +8,15 @@ import { Database } from '@kit/supabase/database';
* @param client * @param client
*/ */
export async function isSuperAdmin(client: SupabaseClient<Database>) { export async function isSuperAdmin(client: SupabaseClient<Database>) {
const { data, error } = await client.auth.getUser(); try {
const { data, error } = await client.rpc('is_super_admin');
if (error) { if (error) {
throw error; throw error;
} }
if (!data.user) { return data;
} catch {
return false; return false;
} }
const requiresMultiFactorAuthentication =
await checkRequiresMultiFactorAuthentication(client);
// If user requires multi-factor authentication, deny access.
if (requiresMultiFactorAuthentication) {
return false;
}
const appMetadata = data.user.app_metadata;
return appMetadata?.role === 'super-admin';
} }

View File

@@ -21,6 +21,7 @@ import {
FormItem, FormItem,
FormMessage, FormMessage,
} from '@kit/ui/form'; } from '@kit/ui/form';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { import {
InputOTP, InputOTP,
@@ -86,9 +87,15 @@ export function MultiFactorChallengeContainer({
}); });
})} })}
> >
<div className={'flex flex-col space-y-4'}> <div className={'flex flex-col items-center gap-y-6'}>
<div className={'flex w-full flex-col space-y-2.5'}> <div className="flex flex-col items-center gap-y-4">
<div className={'flex flex-col space-y-4'}> <Heading level={5}>
<Trans i18nKey={'auth:verifyCodeHeading'} />
</Heading>
</div>
<div className={'flex w-full flex-col gap-y-2.5'}>
<div className={'flex flex-col gap-y-4'}>
<If condition={verifyMFAChallenge.error}> <If condition={verifyMFAChallenge.error}>
<Alert variant={'destructive'}> <Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-5'} /> <ExclamationTriangleIcon className={'h-5'} />
@@ -130,7 +137,7 @@ export function MultiFactorChallengeContainer({
</InputOTP> </InputOTP>
</FormControl> </FormControl>
<FormDescription> <FormDescription className="text-center">
<Trans <Trans
i18nKey={'account:verifyActivationCodeDescription'} i18nKey={'account:verifyActivationCodeDescription'}
/> />
@@ -145,6 +152,8 @@ export function MultiFactorChallengeContainer({
</div> </div>
<Button <Button
className="w-full"
data-test={'submit-mfa-button'}
disabled={ disabled={
verifyMFAChallenge.isPending || verifyMFAChallenge.isPending ||
!verificationCodeForm.formState.isValid !verificationCodeForm.formState.isValid

View File

@@ -858,6 +858,14 @@ export type Database = {
}; };
Returns: boolean; Returns: boolean;
}; };
install_extensions: {
Args: Record<PropertyKey, never>;
Returns: undefined;
};
is_aal2: {
Args: Record<PropertyKey, never>;
Returns: boolean;
};
is_account_owner: { is_account_owner: {
Args: { Args: {
account_id: string; account_id: string;
@@ -870,12 +878,20 @@ export type Database = {
}; };
Returns: boolean; Returns: boolean;
}; };
is_mfa_compliant: {
Args: Record<PropertyKey, never>;
Returns: boolean;
};
is_set: { is_set: {
Args: { Args: {
field_name: string; field_name: string;
}; };
Returns: boolean; Returns: boolean;
}; };
is_super_admin: {
Args: Record<PropertyKey, never>;
Returns: boolean;
};
is_team_member: { is_team_member: {
Args: { Args: {
account_id: string; account_id: string;

View File

@@ -67,6 +67,8 @@ export function DataTable<T extends object>({
manualSorting = false, manualSorting = false,
sorting: initialSorting, sorting: initialSorting,
}: ReactTableProps<T>) { }: ReactTableProps<T>) {
'use no memo';
const [pagination, setPagination] = useState<PaginationState>({ const [pagination, setPagination] = useState<PaginationState>({
pageIndex: pageIndex ?? 0, pageIndex: pageIndex ?? 0,
pageSize: pageSize ?? 15, pageSize: pageSize ?? 15,

15
pnpm-lock.yaml generated
View File

@@ -117,6 +117,9 @@ importers:
node-html-parser: node-html-parser:
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.0.1 version: 7.0.1
totp-generator:
specifier: ^1.0.0
version: 1.0.0
apps/web: apps/web:
dependencies: dependencies:
@@ -5965,6 +5968,9 @@ packages:
jsonfile@6.1.0: jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
jssha@3.3.1:
resolution: {integrity: sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==}
jsx-ast-utils@3.3.5: jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
@@ -7463,6 +7469,9 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
totp-generator@1.0.0:
resolution: {integrity: sha512-Iu/1Lk60/MH8FE+5cDWPiGbwKK1hxzSq+KT9oSqhZ1BEczGIKGcN50bP0WMLiIZKRg7t29iWLxw6f81TICQdoA==}
tr46@0.0.3: tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
@@ -13356,6 +13365,8 @@ snapshots:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
jssha@3.3.1: {}
jsx-ast-utils@3.3.5: jsx-ast-utils@3.3.5:
dependencies: dependencies:
array-includes: 3.1.8 array-includes: 3.1.8
@@ -15146,6 +15157,10 @@ snapshots:
totalist@3.0.1: {} totalist@3.0.1: {}
totp-generator@1.0.0:
dependencies:
jssha: 3.3.1
tr46@0.0.3: {} tr46@0.0.3: {}
ts-api-utils@2.0.1(typescript@5.7.3): ts-api-utils@2.0.1(typescript@5.7.3):

View File

@@ -4,27 +4,45 @@ export function checkPendingMigrations() {
try { try {
console.info('\x1b[34m%s\x1b[0m', 'Checking for pending migrations...'); console.info('\x1b[34m%s\x1b[0m', 'Checking for pending migrations...');
const output = execSync('pnpm --filter web supabase migration list', { encoding: 'utf-8', stdio: 'pipe' }); const output = execSync('pnpm --filter web supabase migration list', {
encoding: 'utf-8',
stdio: 'pipe',
});
const lines = output.split('\n'); const lines = output.split('\n');
// Skip header lines // Skip header lines
const migrationLines = lines.slice(4); const migrationLines = lines.slice(4);
const pendingMigrations = migrationLines const pendingMigrations = migrationLines
.filter(line => { .filter((line) => {
const [local, remote] = line.split('│').map(s => s.trim()); const [local, remote] = line.split('│').map((s) => s.trim());
return local !== '' && remote === ''; return local !== '' && remote === '';
}) })
.map(line => (line.split('│')[0] ?? '').trim()); .map((line) => (line.split('│')[0] ?? '').trim());
if (pendingMigrations.length > 0) { if (pendingMigrations.length > 0) {
console.log('\x1b[33m%s\x1b[0m', '⚠️ There are pending migrations that need to be applied:'); console.log(
pendingMigrations.forEach(migration => console.log(` - ${migration}`)); '\x1b[33m%s\x1b[0m',
console.log('\nPlease run "pnpm --filter web supabase db push" to apply these migrations.'); '⚠️ There are pending migrations that need to be applied:',
);
pendingMigrations.forEach((migration) => console.log(` - ${migration}`));
console.log(
'\nSome functionality may not work as expected until these migrations are applied.',
);
console.log(
'\nAfter testing the migrations in your local environment and ideally in a staging environment, please run "pnpm --filter web supabase db push" to apply them to your database. If you have any questions, please open a support ticket.',
);
} else { } else {
console.log('\x1b[32m%s\x1b[0m', '✅ All migrations are up to date.'); console.log('\x1b[32m%s\x1b[0m', '✅ All migrations are up to date.');
} }
} catch (error) { } catch (error) {
console.log('\x1b[33m%s\x1b[0m', '💡 Info: Project not yet linked to a remote Supabase project. Migration checks skipped - this is expected for new projects. Link your project when you\'re ready to sync with Supabase.\n'); console.log(
'\x1b[33m%s\x1b[0m',
"💡 Info: Project not yet linked to a remote Supabase project. Migration checks skipped - this is expected for new projects. Link your project when you're ready to sync with Supabase.\n",
);
} }
} }