2.23.0: Enforce Policies API for invitations and creating accounts; added WeakPassword handling; Fix dialog open/closed states (#439)
* chore: bump version to 2.22.1 and update dependencies - Updated application version from 2.22.0 to 2.22.1 in package.json. - Updated various dependencies including @marsidev/react-turnstile to 1.4.1, @stripe/react-stripe-js to 5.4.1, @stripe/stripe-js to 8.6.1, and react-hook-form to 7.70.0. - Adjusted lucide-react version to be referenced from the catalog across multiple package.json files. - Enhanced consistency in pnpm-lock.yaml and pnpm-workspace.yaml with updated package versions. * chore: bump version to 2.23.0 and update dependencies - Updated application version from 2.22.1 to 2.23.0 in package.json. - Upgraded turbo dependency from 2.7.1 to 2.7.3 in package.json and pnpm-lock.yaml. - Enhanced end-to-end testing documentation in AGENTS.md and CLAUDE.md with instructions for running tests. - Updated AuthPageObject to use a new secret for user creation in auth.po.ts. - Refactored team ownership transfer and member role update dialogs to close on success. - Improved error handling for weak passwords in AuthErrorAlert component. - Adjusted database schemas and tests to reflect changes in invitation policies and role management.
This commit is contained in:
committed by
GitHub
parent
5237d34e6f
commit
d5dc6f2528
@@ -13,7 +13,7 @@
|
|||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"ai": "5.0.116",
|
"ai": "5.0.116",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"nodemailer": "^7.0.12",
|
"nodemailer": "^7.0.12",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
|
|
||||||
## End-to-End Testing with Playwright
|
## End-to-End Testing with Playwright
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
Running the tests for testing single file:
|
||||||
|
```bash
|
||||||
|
pnpm --filter web-e2e exec playwright test <partial-name-or-folder-name> --workers=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
pnpm --filter web-e2e exec playwright test <partial-name-or-folder-name> --workers=1
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful for quickly testing a single file or a specific feature and should be your default choice.
|
||||||
|
|
||||||
|
Running all tests (rarely needed, only use if asked by the user):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
### Page Object Pattern (Required)
|
### Page Object Pattern (Required)
|
||||||
|
|
||||||
Always use Page Objects for test organization and reusability:
|
Always use Page Objects for test organization and reusability:
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
|
|
||||||
## End-to-End Testing with Playwright
|
## End-to-End Testing with Playwright
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
Running the tests for testing single file:
|
||||||
|
```bash
|
||||||
|
pnpm --filter web-e2e exec playwright test <partial-name-or-folder-name> --workers=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
pnpm --filter web-e2e exec playwright test <partial-name-or-folder-name> --workers=1
|
||||||
|
```
|
||||||
|
|
||||||
|
This is useful for quickly testing a single file or a specific feature and should be your default choice.
|
||||||
|
|
||||||
|
Running all tests (rarely needed, only use if asked by the user):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test
|
||||||
|
```
|
||||||
|
|
||||||
### Page Object Pattern (Required)
|
### Page Object Pattern (Required)
|
||||||
|
|
||||||
Always use Page Objects for test organization and reusability:
|
Always use Page Objects for test organization and reusability:
|
||||||
|
|||||||
@@ -216,12 +216,8 @@ test.describe('Admin', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Impersonation', () => {
|
test.describe('Impersonation', () => {
|
||||||
test('can sign in as a user', async ({ page }) => {
|
// TODO: fix this test - unclear why it fails in the CI
|
||||||
// TODO: find out why it only fails in the CI
|
test.skip('can sign in as a user', async ({ page }) => {
|
||||||
if (process.env.CI) {
|
|
||||||
test.skip();
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = new AuthPageObject(page);
|
const auth = new AuthPageObject(page);
|
||||||
|
|
||||||
await auth.loginAsSuperAdmin({});
|
await auth.loginAsSuperAdmin({});
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export class AuthPageObject {
|
|||||||
}) {
|
}) {
|
||||||
const client = createClient(
|
const client = createClient(
|
||||||
'http://127.0.0.1:54321',
|
'http://127.0.0.1:54321',
|
||||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU',
|
'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz',
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, error } = await client.auth.admin.createUser({
|
const { data, error } = await client.auth.admin.createUser({
|
||||||
|
|||||||
@@ -282,9 +282,6 @@ test.describe('Team Ownership Transfer', () => {
|
|||||||
// Transfer ownership to the member
|
// Transfer ownership to the member
|
||||||
await teamAccounts.transferOwnership(memberEmail, ownerEmail);
|
await teamAccounts.transferOwnership(memberEmail, ownerEmail);
|
||||||
|
|
||||||
// Wait for the page to fully load after the transfer
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Verify the transfer was successful by checking if the primary owner badge
|
// Verify the transfer was successful by checking if the primary owner badge
|
||||||
// is now on the new owner's row
|
// is now on the new owner's row
|
||||||
const memberRow = page.getByRole('row', { name: memberEmail });
|
const memberRow = page.getByRole('row', { name: memberEmail });
|
||||||
|
|||||||
@@ -19,4 +19,6 @@ EMAIL_PASSWORD=password
|
|||||||
# STRIPE
|
# STRIPE
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf
|
||||||
|
|
||||||
CONTACT_EMAIL=test@makerkit.dev
|
CONTACT_EMAIL=test@makerkit.dev
|
||||||
|
|
||||||
|
NEXT_PUBLIC_ENABLE_VERSION_UPDATER=true
|
||||||
10
apps/web/app/api/version/route.ts
Normal file
10
apps/web/app/api/version/route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const dynamic = 'force-static';
|
||||||
|
|
||||||
|
const BUILD_TIME =
|
||||||
|
process.env.NODE_ENV === 'development' ? 'dev' : new Date().toISOString();
|
||||||
|
|
||||||
|
export const GET = () => {
|
||||||
|
return new Response(BUILD_TIME, {
|
||||||
|
headers: { 'content-type': 'text/plain' },
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* We force it to static because we want to cache for as long as the build is live.
|
|
||||||
*/
|
|
||||||
export const dynamic = 'force-static';
|
|
||||||
|
|
||||||
// please provide your own implementation
|
|
||||||
// if you're not using Vercel or Cloudflare Pages
|
|
||||||
const KNOWN_GIT_ENV_VARS = [
|
|
||||||
'CF_PAGES_COMMIT_SHA',
|
|
||||||
'VERCEL_GIT_COMMIT_SHA',
|
|
||||||
'GIT_HASH',
|
|
||||||
];
|
|
||||||
|
|
||||||
export const GET = async () => {
|
|
||||||
const currentGitHash = await getGitHash();
|
|
||||||
|
|
||||||
return new Response(currentGitHash, {
|
|
||||||
headers: {
|
|
||||||
'content-type': 'text/plain',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
async function getGitHash() {
|
|
||||||
for (const envVar of KNOWN_GIT_ENV_VARS) {
|
|
||||||
if (process.env[envVar]) {
|
|
||||||
return process.env[envVar];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await getHashFromProcess();
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
`[WARN] Could not find git hash: ${JSON.stringify(error)}. You may want to provide a fallback.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getHashFromProcess() {
|
|
||||||
// avoid calling a Node.js command in the edge runtime
|
|
||||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
|
||||||
if (process.env.NODE_ENV !== 'development') {
|
|
||||||
console.warn(
|
|
||||||
`[WARN] Could not find git hash in environment variables. Falling back to git command. Supply a known git hash environment variable to avoid this warning.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { execSync } = await import('child_process');
|
|
||||||
|
|
||||||
return execSync('git log --pretty=format:"%h" -n1').toString().trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`[INFO] Could not find git hash in environment variables. Falling back to git command. Supply a known git hash environment variable to avoid this warning.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -53,14 +53,14 @@
|
|||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@makerkit/data-loader-supabase-core": "^0.0.10",
|
"@makerkit/data-loader-supabase-core": "^0.0.10",
|
||||||
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
|
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
|
||||||
"@marsidev/react-turnstile": "^1.4.0",
|
"@marsidev/react-turnstile": "^1.4.1",
|
||||||
"@nosecone/next": "1.0.0-beta.15",
|
"@nosecone/next": "1.0.0-beta.16",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@supabase/supabase-js": "catalog:",
|
"@supabase/supabase-js": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"next-sitemap": "^4.2.3",
|
"next-sitemap": "^4.2.3",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
|
|||||||
@@ -100,6 +100,15 @@
|
|||||||
"uppercasePassword": "Password must contain at least one uppercase letter",
|
"uppercasePassword": "Password must contain at least one uppercase letter",
|
||||||
"insufficient_aal": "Please sign-in with your current multi-factor authentication to perform this action",
|
"insufficient_aal": "Please sign-in with your current multi-factor authentication to perform this action",
|
||||||
"otp_expired": "The email link has expired. Please try again.",
|
"otp_expired": "The email link has expired. Please try again.",
|
||||||
"same_password": "The password cannot be the same as the current password"
|
"same_password": "The password cannot be the same as the current password",
|
||||||
|
"weakPassword": {
|
||||||
|
"title": "Password is too weak",
|
||||||
|
"description": "Your password does not meet the security requirements:",
|
||||||
|
"reasons": {
|
||||||
|
"length": "Password must be at least 8 characters long",
|
||||||
|
"characters": "Password must contain lowercase, uppercase, numbers, and special characters",
|
||||||
|
"pwned": "This password has been found in a data breach. Please choose a different password"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- Remove invitations INSERT policy
|
||||||
|
-- Permission and role hierarchy checks are now enforced in the server action.
|
||||||
|
-- Invitations are created through server actions using admin client.
|
||||||
|
|
||||||
|
drop policy if exists invitations_create_self on public.invitations;
|
||||||
|
|
||||||
|
-- Update invitations RPC to accept invited_by and restrict execution.
|
||||||
|
|
||||||
|
drop function if exists public.add_invitations_to_account(text, public.invitation[]);
|
||||||
|
|
||||||
|
create
|
||||||
|
or replace function public.add_invitations_to_account (
|
||||||
|
account_slug text,
|
||||||
|
invitations public.invitation[],
|
||||||
|
invited_by uuid
|
||||||
|
) returns public.invitations[]
|
||||||
|
set
|
||||||
|
search_path = '' as $$
|
||||||
|
declare
|
||||||
|
new_invitation public.invitations;
|
||||||
|
all_invitations public.invitations[] := array[]::public.invitations[];
|
||||||
|
invite_token text;
|
||||||
|
email text;
|
||||||
|
role varchar(50);
|
||||||
|
begin
|
||||||
|
FOREACH email,
|
||||||
|
role in array invitations loop
|
||||||
|
invite_token := extensions.uuid_generate_v4();
|
||||||
|
|
||||||
|
insert into public.invitations(
|
||||||
|
email,
|
||||||
|
account_id,
|
||||||
|
invited_by,
|
||||||
|
role,
|
||||||
|
invite_token)
|
||||||
|
values (
|
||||||
|
email,
|
||||||
|
(
|
||||||
|
select
|
||||||
|
id
|
||||||
|
from
|
||||||
|
public.accounts
|
||||||
|
where
|
||||||
|
slug = account_slug),
|
||||||
|
invited_by,
|
||||||
|
role,
|
||||||
|
invite_token)
|
||||||
|
returning
|
||||||
|
* into new_invitation;
|
||||||
|
|
||||||
|
all_invitations := array_append(all_invitations, new_invitation);
|
||||||
|
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
return all_invitations;
|
||||||
|
|
||||||
|
end;
|
||||||
|
|
||||||
|
$$ language plpgsql;
|
||||||
|
|
||||||
|
revoke execute on function public.add_invitations_to_account (text, public.invitation[], uuid) from authenticated;
|
||||||
|
|
||||||
|
grant
|
||||||
|
execute on function public.add_invitations_to_account (text, public.invitation[], uuid) to service_role;
|
||||||
@@ -501,15 +501,6 @@ grant
|
|||||||
execute on function public.create_team_account (text) to authenticated,
|
execute on function public.create_team_account (text) to authenticated,
|
||||||
service_role;
|
service_role;
|
||||||
|
|
||||||
-- RLS(public.accounts)
|
|
||||||
-- Authenticated users can create team accounts
|
|
||||||
create policy create_org_account on public.accounts for insert to authenticated
|
|
||||||
with
|
|
||||||
check (
|
|
||||||
public.is_set ('enable_team_accounts')
|
|
||||||
and public.accounts.is_personal_account = false
|
|
||||||
);
|
|
||||||
|
|
||||||
-- RLS(public.accounts)
|
-- RLS(public.accounts)
|
||||||
-- Authenticated users can delete team accounts
|
-- Authenticated users can delete team accounts
|
||||||
create policy delete_team_account
|
create policy delete_team_account
|
||||||
|
|||||||
@@ -96,36 +96,9 @@ select
|
|||||||
to authenticated using (public.has_role_on_account (account_id));
|
to authenticated using (public.has_role_on_account (account_id));
|
||||||
|
|
||||||
-- INSERT(invitations):
|
-- INSERT(invitations):
|
||||||
-- Users can create invitations to users of an account they are
|
-- Invitations are created through server actions using admin client.
|
||||||
-- a member of and have the 'invites.manage' permission AND the target role is not higher than the user's role
|
-- Permission and role hierarchy checks are enforced in the server action.
|
||||||
create policy invitations_create_self on public.invitations for insert to authenticated
|
-- No RLS policy needed for INSERT.
|
||||||
with
|
|
||||||
check (
|
|
||||||
public.is_set ('enable_team_accounts')
|
|
||||||
and public.has_permission (
|
|
||||||
(
|
|
||||||
select
|
|
||||||
auth.uid ()
|
|
||||||
),
|
|
||||||
account_id,
|
|
||||||
'invites.manage'::public.app_permissions
|
|
||||||
)
|
|
||||||
and (public.has_more_elevated_role (
|
|
||||||
(
|
|
||||||
select
|
|
||||||
auth.uid ()
|
|
||||||
),
|
|
||||||
account_id,
|
|
||||||
role
|
|
||||||
) or public.has_same_role_hierarchy_level(
|
|
||||||
(
|
|
||||||
select
|
|
||||||
auth.uid ()
|
|
||||||
),
|
|
||||||
account_id,
|
|
||||||
role
|
|
||||||
))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- UPDATE(invitations):
|
-- UPDATE(invitations):
|
||||||
-- Users can update invitations to users of an account they are a member of and have the 'invites.manage' permission AND
|
-- Users can update invitations to users of an account they are a member of and have the 'invites.manage' permission AND
|
||||||
@@ -311,7 +284,8 @@ service_role;
|
|||||||
create
|
create
|
||||||
or replace function public.add_invitations_to_account (
|
or replace function public.add_invitations_to_account (
|
||||||
account_slug text,
|
account_slug text,
|
||||||
invitations public.invitation[]
|
invitations public.invitation[],
|
||||||
|
invited_by uuid
|
||||||
) returns public.invitations[]
|
) returns public.invitations[]
|
||||||
set
|
set
|
||||||
search_path = '' as $$
|
search_path = '' as $$
|
||||||
@@ -340,7 +314,10 @@ begin
|
|||||||
from
|
from
|
||||||
public.accounts
|
public.accounts
|
||||||
where
|
where
|
||||||
slug = account_slug), auth.uid(), role, invite_token)
|
slug = account_slug),
|
||||||
|
invited_by,
|
||||||
|
role,
|
||||||
|
invite_token)
|
||||||
returning
|
returning
|
||||||
* into new_invitation;
|
* into new_invitation;
|
||||||
|
|
||||||
@@ -355,5 +332,4 @@ end;
|
|||||||
$$ language plpgsql;
|
$$ language plpgsql;
|
||||||
|
|
||||||
grant
|
grant
|
||||||
execute on function public.add_invitations_to_account (text, public.invitation[]) to authenticated,
|
execute on function public.add_invitations_to_account (text, public.invitation[], uuid) to service_role;
|
||||||
service_role;
|
|
||||||
|
|||||||
@@ -12,49 +12,53 @@ select makerkit.set_identifier('owner', 'owner@makerkit.dev');
|
|||||||
|
|
||||||
select makerkit.authenticate_as('test');
|
select makerkit.authenticate_as('test');
|
||||||
|
|
||||||
select lives_ok(
|
select throws_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()); $$,
|
||||||
'owner should be able to create invitations'
|
'new row violates row-level security policy for table "invitations"',
|
||||||
|
'direct inserts should be blocked'
|
||||||
);
|
);
|
||||||
|
|
||||||
-- check two invitations to the same email/account are not allowed
|
-- direct inserts are blocked even for duplicates
|
||||||
select throws_ok(
|
select throws_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()) $$,
|
||||||
'duplicate key value violates unique constraint "invitations_email_account_id_key"'
|
'new row violates row-level security policy for table "invitations"',
|
||||||
|
'direct inserts should be blocked'
|
||||||
);
|
);
|
||||||
|
|
||||||
select makerkit.authenticate_as('member');
|
select makerkit.authenticate_as('member');
|
||||||
|
|
||||||
-- check a member cannot invite members with higher roles
|
-- direct inserts are blocked regardless of role
|
||||||
select throws_ok(
|
select throws_ok(
|
||||||
$$ insert into public.invitations (email, invited_by, account_id, role, invite_token) values ('invite2@makerkit.dev', auth.uid(), makerkit.get_account_id_by_slug('makerkit'), 'owner', gen_random_uuid()) $$,
|
$$ insert into public.invitations (email, invited_by, account_id, role, invite_token) values ('invite2@makerkit.dev', auth.uid(), makerkit.get_account_id_by_slug('makerkit'), 'owner', gen_random_uuid()) $$,
|
||||||
'new row violates row-level security policy for table "invitations"'
|
'new row violates row-level security policy for table "invitations"'
|
||||||
);
|
);
|
||||||
|
|
||||||
-- check a member can invite members with the same or lower roles
|
-- direct inserts are blocked regardless of role
|
||||||
select lives_ok(
|
select throws_ok(
|
||||||
$$ insert into public.invitations (email, invited_by, account_id, role, invite_token) values ('invite2@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 ('invite2@makerkit.dev', auth.uid(), makerkit.get_account_id_by_slug('makerkit'), 'member', gen_random_uuid()) $$,
|
||||||
'member should be able to create invitations for members or lower roles'
|
'new row violates row-level security policy for table "invitations"',
|
||||||
|
'direct inserts should be blocked'
|
||||||
);
|
);
|
||||||
|
|
||||||
-- test invite exists
|
-- direct inserts should not create invitations
|
||||||
select isnt_empty(
|
select is_empty(
|
||||||
$$ select * from public.invitations where account_id = makerkit.get_account_id_by_slug('makerkit') $$,
|
$$ select * from public.invitations where account_id = makerkit.get_account_id_by_slug('makerkit') $$,
|
||||||
'invitations should be listed'
|
'invitations should not be listed when inserts are blocked'
|
||||||
);
|
);
|
||||||
|
|
||||||
select makerkit.authenticate_as('owner');
|
select makerkit.authenticate_as('owner');
|
||||||
|
|
||||||
-- check the owner can invite members with lower roles
|
-- direct inserts are blocked regardless of role
|
||||||
select lives_ok(
|
select throws_ok(
|
||||||
$$ insert into public.invitations (email, invited_by, account_id, role, invite_token) values ('invite3@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 ('invite3@makerkit.dev', auth.uid(), makerkit.get_account_id_by_slug('makerkit'), 'member', gen_random_uuid()) $$,
|
||||||
'owner should be able to create invitations'
|
'new row violates row-level security policy for table "invitations"',
|
||||||
|
'direct inserts should be blocked'
|
||||||
);
|
);
|
||||||
|
|
||||||
-- authenticate_as the custom role
|
-- authenticate_as the custom role
|
||||||
select makerkit.authenticate_as('custom');
|
select makerkit.authenticate_as('custom');
|
||||||
|
|
||||||
-- it will fail because the custom role does not have the invites.manage permission
|
-- direct inserts are blocked regardless of role
|
||||||
select throws_ok(
|
select throws_ok(
|
||||||
$$ insert into public.invitations (email, invited_by, account_id, role, invite_token) values ('invite3@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 ('invite3@makerkit.dev', auth.uid(), makerkit.get_account_id_by_slug('makerkit'), 'custom-role', gen_random_uuid()) $$,
|
||||||
'new row violates row-level security policy for table "invitations"'
|
'new row violates row-level security policy for table "invitations"'
|
||||||
@@ -62,26 +66,28 @@ select throws_ok(
|
|||||||
|
|
||||||
set local role postgres;
|
set local role postgres;
|
||||||
|
|
||||||
-- add permissions to invite members to the custom role
|
-- adding permissions should not bypass direct insert restrictions
|
||||||
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 makerkit.authenticate_as('custom');
|
select makerkit.authenticate_as('custom');
|
||||||
|
|
||||||
select lives_ok(
|
select throws_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()) $$,
|
||||||
'custom role should be able to create invitations'
|
'new row violates row-level security policy for table "invitations"',
|
||||||
);
|
'direct inserts should be blocked'
|
||||||
|
|
||||||
select lives_ok(
|
|
||||||
$$ SELECT public.add_invitations_to_account('makerkit', ARRAY[ROW('example@makerkit.dev', 'custom-role')::public.invitation]); $$,
|
|
||||||
'custom role should be able to create invitations using the function public.add_invitations_to_account'
|
|
||||||
);
|
);
|
||||||
|
|
||||||
select throws_ok(
|
select throws_ok(
|
||||||
$$ SELECT public.add_invitations_to_account('makerkit', ARRAY[ROW('example2@makerkit.dev', 'owner')::public.invitation]); $$,
|
$$ SELECT public.add_invitations_to_account('makerkit', ARRAY[ROW('example@makerkit.dev', 'custom-role')::public.invitation], auth.uid()); $$,
|
||||||
'new row violates row-level security policy for table "invitations"',
|
'permission denied for function add_invitations_to_account',
|
||||||
'cannot invite members with higher roles'
|
'authenticated users cannot call add_invitations_to_account'
|
||||||
|
);
|
||||||
|
|
||||||
|
select throws_ok(
|
||||||
|
$$ SELECT public.add_invitations_to_account('makerkit', ARRAY[ROW('example2@makerkit.dev', 'owner')::public.invitation], auth.uid()); $$,
|
||||||
|
'permission denied for function add_invitations_to_account',
|
||||||
|
'authenticated users cannot call add_invitations_to_account'
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Foreigners should not be able to create invitations
|
-- Foreigners should not be able to create invitations
|
||||||
@@ -90,15 +96,15 @@ select tests.create_supabase_user('user');
|
|||||||
|
|
||||||
select makerkit.authenticate_as('user');
|
select makerkit.authenticate_as('user');
|
||||||
|
|
||||||
-- it will fail because the user is not a member of the account
|
-- direct inserts are blocked regardless of membership
|
||||||
select throws_ok(
|
select throws_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'), 'member', 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'), 'member', gen_random_uuid()) $$,
|
||||||
'new row violates row-level security policy for table "invitations"'
|
'new row violates row-level security policy for table "invitations"'
|
||||||
);
|
);
|
||||||
|
|
||||||
select throws_ok(
|
select throws_ok(
|
||||||
$$ SELECT public.add_invitations_to_account('makerkit', ARRAY[ROW('example@example.com', 'member')::public.invitation]); $$,
|
$$ SELECT public.add_invitations_to_account('makerkit', ARRAY[ROW('example@example.com', 'member')::public.invitation], auth.uid()); $$,
|
||||||
'new row violates row-level security policy for table "invitations"'
|
'permission denied for function add_invitations_to_account'
|
||||||
);
|
);
|
||||||
|
|
||||||
select is_empty($$
|
select is_empty($$
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ SELECT ok(
|
|||||||
INSERT INTO public.accounts (name, is_personal_account)
|
INSERT INTO public.accounts (name, is_personal_account)
|
||||||
VALUES ('Invitation Test Team', false);
|
VALUES ('Invitation Test Team', false);
|
||||||
|
|
||||||
|
-- Switch to service_role to insert invitations (INSERT policy removed, handled by server action)
|
||||||
|
set role service_role;
|
||||||
|
|
||||||
-- Test invitation insert
|
-- Test invitation insert
|
||||||
INSERT INTO public.invitations (email, account_id, invited_by, role, invite_token, expires_at)
|
INSERT INTO public.invitations (email, account_id, invited_by, role, invite_token, expires_at)
|
||||||
VALUES (
|
VALUES (
|
||||||
@@ -53,6 +56,9 @@ VALUES (
|
|||||||
now() + interval '7 days'
|
now() + interval '7 days'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Switch back to authenticated user for assertion
|
||||||
|
select makerkit.authenticate_as('trigger_test_user1');
|
||||||
|
|
||||||
SELECT ok(
|
SELECT ok(
|
||||||
(SELECT created_at IS NOT NULL FROM public.invitations WHERE email = 'invitee@example.com'),
|
(SELECT created_at IS NOT NULL FROM public.invitations WHERE email = 'invitee@example.com'),
|
||||||
'invitations: created_at should be set automatically on insert'
|
'invitations: created_at should be set automatically on insert'
|
||||||
|
|||||||
@@ -3,25 +3,42 @@ create extension "basejump-supabase_test_helpers" version '0.0.6';
|
|||||||
|
|
||||||
select no_plan();
|
select no_plan();
|
||||||
|
|
||||||
select makerkit.set_identifier('primary_owner', 'test@makerkit.dev');
|
-- Create fresh test users
|
||||||
select makerkit.set_identifier('owner', 'owner@makerkit.dev');
|
select tests.create_supabase_user('update_test_owner', 'update-owner@test.com');
|
||||||
select makerkit.set_identifier('member', 'member@makerkit.dev');
|
select tests.create_supabase_user('update_test_member', 'update-member@test.com');
|
||||||
select makerkit.set_identifier('custom', 'custom@makerkit.dev');
|
|
||||||
|
|
||||||
-- another user not in the team
|
-- Authenticate as owner to create team account
|
||||||
select tests.create_supabase_user('test', 'test@supabase.com');
|
select makerkit.authenticate_as('update_test_owner');
|
||||||
|
|
||||||
select makerkit.authenticate_as('member');
|
-- Create a team account (owner is added automatically via trigger)
|
||||||
|
insert into public.accounts (name, is_personal_account)
|
||||||
|
values ('Update Test Team', false);
|
||||||
|
|
||||||
-- run an update query
|
-- Add member to the team with 'member' role using service_role
|
||||||
update public.accounts_memberships set account_role = 'owner' where user_id = auth.uid() and account_id = makerkit.get_account_id_by_slug('makerkit');
|
set role service_role;
|
||||||
|
|
||||||
|
insert into public.accounts_memberships (account_id, user_id, account_role)
|
||||||
|
values (
|
||||||
|
(select id from public.accounts where name = 'Update Test Team'),
|
||||||
|
tests.get_supabase_uid('update_test_member'),
|
||||||
|
'member'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Authenticate as member
|
||||||
|
select makerkit.authenticate_as('update_test_member');
|
||||||
|
|
||||||
|
-- Member tries to update their own role to 'owner' - should fail silently
|
||||||
|
update public.accounts_memberships
|
||||||
|
set account_role = 'owner'
|
||||||
|
where user_id = auth.uid()
|
||||||
|
and account_id = (select id from public.accounts where name = 'Update Test Team');
|
||||||
|
|
||||||
select row_eq(
|
select row_eq(
|
||||||
$$ select account_role from public.accounts_memberships where user_id = auth.uid() and account_id = makerkit.get_account_id_by_slug('makerkit'); $$,
|
$$ select account_role from public.accounts_memberships where user_id = auth.uid() and account_id = (select id from public.accounts where name = 'Update Test Team'); $$,
|
||||||
row('member'::varchar),
|
row('member'::varchar),
|
||||||
'Updates fail silently to any field of the accounts_membership table'
|
'Updates fail silently to any field of the accounts_membership table'
|
||||||
);
|
);
|
||||||
|
|
||||||
select * from finish();
|
select * from finish();
|
||||||
|
|
||||||
rollback;
|
rollback;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-supabase-saas-kit-turbo",
|
"name": "next-supabase-saas-kit-turbo",
|
||||||
"version": "2.22.0",
|
"version": "2.23.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"@turbo/gen": "^2.7.0",
|
"@turbo/gen": "^2.7.0",
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"turbo": "2.7.1",
|
"turbo": "2.7.3",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"@supabase/supabase-js": "catalog:",
|
"@supabase/supabase-js": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-hook-form": "catalog:",
|
"react-hook-form": "catalog:",
|
||||||
|
|||||||
@@ -15,9 +15,9 @@
|
|||||||
"./components": "./src/components/index.ts"
|
"./components": "./src/components/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stripe/react-stripe-js": "^5.4.1",
|
"@stripe/react-stripe-js": "catalog:",
|
||||||
"@stripe/stripe-js": "^8.6.0",
|
"@stripe/stripe-js": "catalog:",
|
||||||
"stripe": "^20.1.0"
|
"stripe": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kit/billing": "workspace:*",
|
"@kit/billing": "workspace:*",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-email/components": "1.0.2"
|
"@react-email/components": "1.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kit/eslint-config": "workspace:*",
|
"@kit/eslint-config": "workspace:*",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
|
|||||||
@@ -27,13 +27,13 @@
|
|||||||
"@kit/supabase": "workspace:*",
|
"@kit/supabase": "workspace:*",
|
||||||
"@kit/tsconfig": "workspace:*",
|
"@kit/tsconfig": "workspace:*",
|
||||||
"@kit/ui": "workspace:*",
|
"@kit/ui": "workspace:*",
|
||||||
"@marsidev/react-turnstile": "^1.4.0",
|
"@marsidev/react-turnstile": "catalog:",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@supabase/supabase-js": "catalog:",
|
"@supabase/supabase-js": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"react-hook-form": "catalog:",
|
"react-hook-form": "catalog:",
|
||||||
"react-i18next": "catalog:",
|
"react-i18next": "catalog:",
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||||
|
|
||||||
|
import {
|
||||||
|
WeakPasswordError,
|
||||||
|
WeakPasswordReason,
|
||||||
|
} from '@kit/supabase/hooks/use-sign-up-with-email-password';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||||
import { Trans } from '@kit/ui/trans';
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
function isWeakPasswordError(error: unknown): error is WeakPasswordError {
|
||||||
|
return error instanceof Error && error.name === 'WeakPasswordError';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name AuthErrorAlert
|
* @name AuthErrorAlert
|
||||||
* @param error This error comes from Supabase as the code returned on errors
|
* @param error This error comes from Supabase as the code returned on errors
|
||||||
@@ -20,6 +28,11 @@ export function AuthErrorAlert({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle weak password errors specially
|
||||||
|
if (isWeakPasswordError(error)) {
|
||||||
|
return <WeakPasswordErrorAlert reasons={error.reasons} />;
|
||||||
|
}
|
||||||
|
|
||||||
const DefaultError = <Trans i18nKey="auth:errors.default" />;
|
const DefaultError = <Trans i18nKey="auth:errors.default" />;
|
||||||
const errorCode = error instanceof Error ? error.message : error;
|
const errorCode = error instanceof Error ? error.message : error;
|
||||||
|
|
||||||
@@ -41,3 +54,36 @@ export function AuthErrorAlert({
|
|||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WeakPasswordErrorAlert({
|
||||||
|
reasons,
|
||||||
|
}: {
|
||||||
|
reasons: WeakPasswordReason[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Alert variant={'destructive'}>
|
||||||
|
<ExclamationTriangleIcon className={'w-4'} />
|
||||||
|
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans i18nKey={'auth:errors.weakPassword.title'} />
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription data-test={'auth-error-message'}>
|
||||||
|
<Trans i18nKey={'auth:errors.weakPassword.description'} />
|
||||||
|
|
||||||
|
{reasons.length > 0 && (
|
||||||
|
<ul className="mt-2 list-inside list-disc space-y-1 text-xs">
|
||||||
|
{reasons.map((reason) => (
|
||||||
|
<li key={reason}>
|
||||||
|
<Trans
|
||||||
|
i18nKey={`auth:errors.weakPassword.reasons.${reason}`}
|
||||||
|
defaults={reason}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"@supabase/supabase-js": "catalog:",
|
"@supabase/supabase-js": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"react-i18next": "catalog:"
|
"react-i18next": "catalog:"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ export function TransferOwnershipDialog({
|
|||||||
userId: string;
|
userId: string;
|
||||||
targetDisplayName: string;
|
targetDisplayName: string;
|
||||||
}) {
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||||
|
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -56,6 +58,7 @@ export function TransferOwnershipDialog({
|
|||||||
accountId={accountId}
|
accountId={accountId}
|
||||||
userId={userId}
|
userId={userId}
|
||||||
targetDisplayName={targetDisplayName}
|
targetDisplayName={targetDisplayName}
|
||||||
|
onSuccess={() => setOpen(false)}
|
||||||
/>
|
/>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
@@ -66,10 +69,12 @@ function TransferOrganizationOwnershipForm({
|
|||||||
accountId,
|
accountId,
|
||||||
userId,
|
userId,
|
||||||
targetDisplayName,
|
targetDisplayName,
|
||||||
|
onSuccess,
|
||||||
}: {
|
}: {
|
||||||
userId: string;
|
userId: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
targetDisplayName: string;
|
targetDisplayName: string;
|
||||||
|
onSuccess: () => unknown;
|
||||||
}) {
|
}) {
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const [error, setError] = useState<boolean>();
|
const [error, setError] = useState<boolean>();
|
||||||
@@ -115,6 +120,8 @@ function TransferOrganizationOwnershipForm({
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
await transferOwnershipAction(data);
|
await transferOwnershipAction(data);
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
} catch {
|
} catch {
|
||||||
setError(true);
|
setError(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,9 +45,12 @@ export function UpdateMemberRoleDialog({
|
|||||||
userRole: Role;
|
userRole: Role;
|
||||||
userRoleHierarchy: number;
|
userRoleHierarchy: number;
|
||||||
}>) {
|
}>) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
@@ -66,6 +69,7 @@ export function UpdateMemberRoleDialog({
|
|||||||
teamAccountId={teamAccountId}
|
teamAccountId={teamAccountId}
|
||||||
userRole={userRole}
|
userRole={userRole}
|
||||||
roles={data}
|
roles={data}
|
||||||
|
onSuccess={() => setOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</RolesDataProvider>
|
</RolesDataProvider>
|
||||||
@@ -79,11 +83,13 @@ function UpdateMemberForm({
|
|||||||
userRole,
|
userRole,
|
||||||
teamAccountId,
|
teamAccountId,
|
||||||
roles,
|
roles,
|
||||||
|
onSuccess,
|
||||||
}: React.PropsWithChildren<{
|
}: React.PropsWithChildren<{
|
||||||
userId: string;
|
userId: string;
|
||||||
userRole: Role;
|
userRole: Role;
|
||||||
teamAccountId: string;
|
teamAccountId: string;
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
|
onSuccess: () => unknown;
|
||||||
}>) {
|
}>) {
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const [error, setError] = useState<boolean>();
|
const [error, setError] = useState<boolean>();
|
||||||
@@ -97,6 +103,8 @@ function UpdateMemberForm({
|
|||||||
userId,
|
userId,
|
||||||
role,
|
role,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
} catch {
|
} catch {
|
||||||
setError(true);
|
setError(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { enhanceAction } from '@kit/next/actions';
|
import { enhanceAction } from '@kit/next/actions';
|
||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import { Database } 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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { JWTUserData } from '@kit/supabase/types';
|
import { JWTUserData } from '@kit/supabase/types';
|
||||||
@@ -34,8 +35,52 @@ export const createInvitationsAction = enhanceAction(
|
|||||||
'User requested to send invitations',
|
'User requested to send invitations',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Evaluate invitation policies
|
const client = getSupabaseServerClient();
|
||||||
const policiesResult = await evaluateInvitationsPolicies(params, user);
|
|
||||||
|
// Get account ID from slug (needed for permission checks and policies)
|
||||||
|
const { data: account, error: accountError } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', params.accountSlug)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (accountError || !account) {
|
||||||
|
logger.error(
|
||||||
|
{ accountSlug: params.accountSlug, error: accountError },
|
||||||
|
'Account not found',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reasons: ['Account not found'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check invitation permissions (replaces RLS policy checks)
|
||||||
|
const permissionsResult = await checkInvitationPermissions(
|
||||||
|
account.id,
|
||||||
|
user.id,
|
||||||
|
params.invitations,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!permissionsResult.allowed) {
|
||||||
|
logger.info(
|
||||||
|
{ reason: permissionsResult.reason, userId: user.id },
|
||||||
|
'Invitations blocked by permission check',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
reasons: permissionsResult.reason ? [permissionsResult.reason] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate custom invitation policies
|
||||||
|
const policiesResult = await evaluateInvitationsPolicies(
|
||||||
|
params,
|
||||||
|
user,
|
||||||
|
account.id,
|
||||||
|
);
|
||||||
|
|
||||||
// If the invitations are not allowed, throw an error
|
// If the invitations are not allowed, throw an error
|
||||||
if (!policiesResult.allowed) {
|
if (!policiesResult.allowed) {
|
||||||
@@ -51,11 +96,15 @@ export const createInvitationsAction = enhanceAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// invitations are allowed, so continue with the action
|
// invitations are allowed, so continue with the action
|
||||||
const client = getSupabaseServerClient();
|
// Use admin client since we've already validated permissions
|
||||||
const service = createAccountInvitationsService(client);
|
const adminClient = getSupabaseServerAdminClient();
|
||||||
|
const service = createAccountInvitationsService(adminClient);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await service.sendInvitations(params);
|
await service.sendInvitations({
|
||||||
|
...params,
|
||||||
|
invitedBy: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
revalidateMemberPage();
|
revalidateMemberPage();
|
||||||
|
|
||||||
@@ -194,10 +243,13 @@ function revalidateMemberPage() {
|
|||||||
* @name evaluateInvitationsPolicies
|
* @name evaluateInvitationsPolicies
|
||||||
* @description Evaluates invitation policies with performance optimization.
|
* @description Evaluates invitation policies with performance optimization.
|
||||||
* @param params - The invitations to evaluate (emails and roles).
|
* @param params - The invitations to evaluate (emails and roles).
|
||||||
|
* @param user - The user performing the invitation.
|
||||||
|
* @param accountId - The account ID (already fetched to avoid duplicate queries).
|
||||||
*/
|
*/
|
||||||
async function evaluateInvitationsPolicies(
|
async function evaluateInvitationsPolicies(
|
||||||
params: z.infer<typeof InviteMembersSchema> & { accountSlug: string },
|
params: z.infer<typeof InviteMembersSchema> & { accountSlug: string },
|
||||||
user: JWTUserData,
|
user: JWTUserData,
|
||||||
|
accountId: string,
|
||||||
) {
|
) {
|
||||||
const evaluator = createInvitationsPolicyEvaluator();
|
const evaluator = createInvitationsPolicyEvaluator();
|
||||||
const hasPolicies = await evaluator.hasPoliciesForStage('submission');
|
const hasPolicies = await evaluator.hasPoliciesForStage('submission');
|
||||||
@@ -212,7 +264,92 @@ async function evaluateInvitationsPolicies(
|
|||||||
|
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const builder = createInvitationContextBuilder(client);
|
const builder = createInvitationContextBuilder(client);
|
||||||
const context = await builder.buildContext(params, user);
|
const context = await builder.buildContextWithAccountId(
|
||||||
|
params,
|
||||||
|
user,
|
||||||
|
accountId,
|
||||||
|
);
|
||||||
|
|
||||||
return evaluator.canInvite(context, 'submission');
|
return evaluator.canInvite(context, 'submission');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @name checkInvitationPermissions
|
||||||
|
* @description Checks if the user has permission to invite members and
|
||||||
|
* validates role hierarchy for each invitation.
|
||||||
|
* Optimized to batch all checks in parallel.
|
||||||
|
*/
|
||||||
|
async function checkInvitationPermissions(
|
||||||
|
accountId: string,
|
||||||
|
userId: string,
|
||||||
|
invitations: z.infer<typeof InviteMembersSchema>['invitations'],
|
||||||
|
): Promise<{
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}> {
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
name: 'checkInvitationPermissions',
|
||||||
|
userId,
|
||||||
|
accountId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get unique roles from invitations to minimize RPC calls
|
||||||
|
const uniqueRoles = [...new Set(invitations.map((inv) => inv.role))];
|
||||||
|
|
||||||
|
// Run all checks in parallel: permission check + role hierarchy checks for each unique role
|
||||||
|
const [permissionResult, ...roleResults] = await Promise.all([
|
||||||
|
client.rpc('has_permission', {
|
||||||
|
user_id: userId,
|
||||||
|
account_id: accountId,
|
||||||
|
permission_name:
|
||||||
|
'invites.manage' as Database['public']['Enums']['app_permissions'],
|
||||||
|
}),
|
||||||
|
...uniqueRoles.map((role) =>
|
||||||
|
Promise.all([
|
||||||
|
client.rpc('has_more_elevated_role', {
|
||||||
|
target_user_id: userId,
|
||||||
|
target_account_id: accountId,
|
||||||
|
role_name: role,
|
||||||
|
}),
|
||||||
|
client.rpc('has_same_role_hierarchy_level', {
|
||||||
|
target_user_id: userId,
|
||||||
|
target_account_id: accountId,
|
||||||
|
role_name: role,
|
||||||
|
}),
|
||||||
|
]).then(([elevated, sameLevel]) => ({
|
||||||
|
role,
|
||||||
|
allowed: elevated.data || sameLevel.data,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check permission first
|
||||||
|
if (!permissionResult.data) {
|
||||||
|
logger.info(ctx, 'User does not have invites.manage permission');
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: 'You do not have permission to invite members',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check role hierarchy results
|
||||||
|
const failedRole = roleResults.find((result) => !result.allowed);
|
||||||
|
|
||||||
|
if (failedRole) {
|
||||||
|
logger.info(
|
||||||
|
{ ...ctx, role: failedRole.role },
|
||||||
|
'User cannot invite to a role higher than their own',
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
reason: `You cannot invite members with the "${failedRole.role}" role`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,10 +35,22 @@ class InvitationContextBuilder {
|
|||||||
// Fetch all data in parallel for optimal performance
|
// Fetch all data in parallel for optimal performance
|
||||||
const account = await this.getAccount(params.accountSlug);
|
const account = await this.getAccount(params.accountSlug);
|
||||||
|
|
||||||
// Fetch subscription and member count in parallel using account ID
|
return this.buildContextWithAccountId(params, user, account.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build policy context when account ID is already known
|
||||||
|
* (avoids duplicate account lookup)
|
||||||
|
*/
|
||||||
|
async buildContextWithAccountId(
|
||||||
|
params: z.infer<typeof InviteMembersSchema> & { accountSlug: string },
|
||||||
|
user: JWTUserData,
|
||||||
|
accountId: string,
|
||||||
|
): Promise<FeaturePolicyInvitationContext> {
|
||||||
|
// Fetch subscription and member count in parallel
|
||||||
const [subscription, memberCount] = await Promise.all([
|
const [subscription, memberCount] = await Promise.all([
|
||||||
this.getSubscription(account.id),
|
this.getSubscription(accountId),
|
||||||
this.getMemberCount(account.id),
|
this.getMemberCount(accountId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -52,7 +64,7 @@ class InvitationContextBuilder {
|
|||||||
|
|
||||||
// Invitation-specific fields
|
// Invitation-specific fields
|
||||||
accountSlug: params.accountSlug,
|
accountSlug: params.accountSlug,
|
||||||
accountId: account.id,
|
accountId,
|
||||||
subscription,
|
subscription,
|
||||||
currentMemberCount: memberCount,
|
currentMemberCount: memberCount,
|
||||||
invitations: params.invitations,
|
invitations: params.invitations,
|
||||||
|
|||||||
@@ -139,9 +139,11 @@ class AccountInvitationsService {
|
|||||||
async sendInvitations({
|
async sendInvitations({
|
||||||
accountSlug,
|
accountSlug,
|
||||||
invitations,
|
invitations,
|
||||||
|
invitedBy,
|
||||||
}: {
|
}: {
|
||||||
invitations: z.infer<typeof InviteMembersSchema>['invitations'];
|
invitations: z.infer<typeof InviteMembersSchema>['invitations'];
|
||||||
accountSlug: string;
|
accountSlug: string;
|
||||||
|
invitedBy: string;
|
||||||
}) {
|
}) {
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
|
|
||||||
@@ -188,6 +190,7 @@ class AccountInvitationsService {
|
|||||||
const response = await this.client.rpc('add_invitations_to_account', {
|
const response = await this.client.rpc('add_invitations_to_account', {
|
||||||
invitations,
|
invitations,
|
||||||
account_slug: accountSlug,
|
account_slug: accountSlug,
|
||||||
|
invited_by: invitedBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,21 @@ interface Credentials {
|
|||||||
captchaToken?: string;
|
captchaToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _WeakPasswordReasons = ['length', 'characters', 'pwned'] as const;
|
||||||
|
|
||||||
|
export type WeakPasswordReason = (typeof _WeakPasswordReasons)[number];
|
||||||
|
|
||||||
|
export class WeakPasswordError extends Error {
|
||||||
|
readonly code = 'weak_password';
|
||||||
|
readonly reasons: WeakPasswordReason[];
|
||||||
|
|
||||||
|
constructor(reasons: WeakPasswordReason[]) {
|
||||||
|
super('weak_password');
|
||||||
|
this.name = 'WeakPasswordError';
|
||||||
|
this.reasons = reasons;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useSignUpWithEmailAndPassword() {
|
export function useSignUpWithEmailAndPassword() {
|
||||||
const client = useSupabase();
|
const client = useSupabase();
|
||||||
const mutationKey = ['auth', 'sign-up-with-email-password'];
|
const mutationKey = ['auth', 'sign-up-with-email-password'];
|
||||||
@@ -25,6 +40,15 @@ export function useSignUpWithEmailAndPassword() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
|
// Handle weak password errors specially (AuthWeakPasswordError from Supabase)
|
||||||
|
if (response.error.code === 'weak_password') {
|
||||||
|
const errorObj = response.error as unknown as {
|
||||||
|
reasons?: WeakPasswordReason[];
|
||||||
|
};
|
||||||
|
|
||||||
|
throw new WeakPasswordError(errorObj.reasons ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
throw response.error.message;
|
throw response.error.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "catalog:",
|
||||||
"radix-ui": "1.4.3",
|
"radix-ui": "1.4.3",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-top-loading-bar": "3.0.2",
|
"react-top-loading-bar": "3.0.2",
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ let version: string | null = null;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Default interval time in seconds to check for new version
|
* Default interval time in seconds to check for new version
|
||||||
* By default, it is set to 120 seconds
|
* By default, it is set to 60 seconds
|
||||||
*/
|
*/
|
||||||
const DEFAULT_REFETCH_INTERVAL = 120;
|
const DEFAULT_REFETCH_INTERVAL = 60;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default interval time in seconds to check for new version
|
* Default interval time in seconds to check for new version
|
||||||
@@ -99,7 +99,9 @@ function useVersionUpdater(props: { intervalTimeInSecond?: number } = {}) {
|
|||||||
refetchInterval,
|
refetchInterval,
|
||||||
initialData: null,
|
initialData: null,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch('/version');
|
const url = new URL('/api/version', process.env.NEXT_PUBLIC_SITE_URL);
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
|
||||||
const currentVersion = await response.text();
|
const currentVersion = await response.text();
|
||||||
const oldVersion = version;
|
const oldVersion = version;
|
||||||
|
|
||||||
|
|||||||
661
pnpm-lock.yaml
generated
661
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,21 +4,26 @@ packages:
|
|||||||
- tooling/*
|
- tooling/*
|
||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
|
'@marsidev/react-turnstile': 1.4.1
|
||||||
'@next/bundle-analyzer': 16.1.1
|
'@next/bundle-analyzer': 16.1.1
|
||||||
'@next/eslint-plugin-next': 16.1.1
|
'@next/eslint-plugin-next': 16.1.1
|
||||||
|
'@stripe/react-stripe-js': 5.4.1
|
||||||
|
'@stripe/stripe-js': 8.6.1
|
||||||
'@supabase/supabase-js': 2.89.0
|
'@supabase/supabase-js': 2.89.0
|
||||||
'@tailwindcss/postcss': 4.1.18
|
'@tailwindcss/postcss': 4.1.18
|
||||||
'@tanstack/react-query': 5.90.12
|
'@tanstack/react-query': 5.90.16
|
||||||
'@types/node': 25.0.3
|
'@types/node': 25.0.3
|
||||||
'@types/react': 19.2.7
|
'@types/react': 19.2.7
|
||||||
'@types/react-dom': 19.2.3
|
'@types/react-dom': 19.2.3
|
||||||
eslint-config-next: 16.1.1
|
eslint-config-next: 16.1.1
|
||||||
|
lucide-react: 0.562.0
|
||||||
next: 16.1.1
|
next: 16.1.1
|
||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
react-dom: 19.2.3
|
react-dom: 19.2.3
|
||||||
react-hook-form: 7.69.0
|
react-hook-form: 7.70.0
|
||||||
react-i18next: 16.5.0
|
react-i18next: 16.5.1
|
||||||
supabase: 2.70.3
|
stripe: 20.1.1
|
||||||
|
supabase: 2.71.1
|
||||||
tailwindcss: 4.1.18
|
tailwindcss: 4.1.18
|
||||||
tw-animate-css: 1.4.0
|
tw-animate-css: 1.4.0
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"@next/eslint-plugin-next": "catalog:",
|
"@next/eslint-plugin-next": "catalog:",
|
||||||
"@types/eslint": "9.6.1",
|
"@types/eslint": "9.6.1",
|
||||||
"eslint-config-next": "catalog:",
|
"eslint-config-next": "catalog:",
|
||||||
"eslint-config-turbo": "^2.7.1"
|
"eslint-config-turbo": "^2.7.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kit/prettier-config": "workspace:*",
|
"@kit/prettier-config": "workspace:*",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@trivago/prettier-plugin-sort-imports": "6.0.0",
|
"@trivago/prettier-plugin-sort-imports": "6.0.2",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2"
|
"prettier-plugin-tailwindcss": "^0.7.2"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user