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:
Giancarlo Buomprisco
2026-01-07 17:00:11 +01:00
committed by GitHub
parent 5237d34e6f
commit d5dc6f2528
41 changed files with 2896 additions and 1922 deletions

View File

@@ -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:",

View File

@@ -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:

View File

@@ -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:

View File

@@ -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({});

View File

@@ -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({

View File

@@ -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 });

View File

@@ -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

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

View File

@@ -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

View File

@@ -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",

View File

@@ -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"
}
}
} }
} }

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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($$

View File

@@ -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'

View File

@@ -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;

View File

@@ -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"
} }
} }

View File

@@ -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:",

View File

@@ -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:*",

View File

@@ -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:*",

View File

@@ -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:",

View File

@@ -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:",

View File

@@ -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:",

View File

@@ -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>
);
}

View File

@@ -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:"

View File

@@ -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:",

View File

@@ -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);
} }

View File

@@ -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);
} }

View File

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

View File

@@ -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,

View File

@@ -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

View File

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

View File

@@ -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",

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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:*",

View File

@@ -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"
}, },