diff --git a/README.md b/README.md index 22cb4e1e8..13eafde53 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,13 @@ The roadmap for the project is as follows: ## Features - **Authentication**: Sign up, sign in, sign out, forgot password, update profile, and more. -- **Billing**: Subscription management, payment methods, invoices, and more. +- **Billing**: Subscription management, one-off payments, flat subscriptions, per-seat subscriptions, and more. - **Personal Account**: Manage your account, profile picture, and more. -- **Team Accounts**: Invite members, manage roles, and more. +- **Team Accounts**: Invite members, manage roles, and more. Manage resources within a team. +- **RBAC**: Simple-to-use role-based access control. Customize roles and permissions (coming soon). - **Admin Dashboard**: Manage users, subscriptions, and more. - **Pluggable**: Easily add new features and packages to your SaaS application. +- **Super UI**: Beautiful UI using Shadcn UI and Tailwind CSS. The most notable difference between this version and the original version is the use of Turborepo to manage multiple packages in a single repository. diff --git a/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx b/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx index 06cc3f904..b6494fcc1 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx @@ -5,7 +5,7 @@ import { useState, useTransition } from 'react'; import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { EmbeddedCheckout, PlanPicker } from '@kit/billing-gateway/components'; -import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; +import { Alert, AlertTitle } from '@kit/ui/alert'; import { Card, CardContent, @@ -14,6 +14,7 @@ import { CardTitle, } from '@kit/ui/card'; import { If } from '@kit/ui/if'; +import { Trans } from '@kit/ui/trans'; import billingConfig from '~/config/billing.config'; @@ -79,13 +80,11 @@ export function PersonalAccountCheckoutForm() { function ErrorAlert() { return ( - + - Sorry, we encountered an error. - - - We couldn't process your request. Please try again. - + + + ); } diff --git a/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx b/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx index a6eaf695a..afea8dcb6 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx +++ b/apps/web/app/(dashboard)/home/[account]/billing/_components/team-account-checkout-form.tsx @@ -12,6 +12,7 @@ import { CardHeader, CardTitle, } from '@kit/ui/card'; +import { Trans } from '@kit/ui/trans'; import billingConfig from '~/config/billing.config'; @@ -36,9 +37,13 @@ export function TeamAccountCheckoutForm(params: { accountId: string }) { return ( - Manage your Team Plan + + + - You can change your plan at any time. + + + diff --git a/apps/web/app/(dashboard)/home/[account]/billing/page.tsx b/apps/web/app/(dashboard)/home/[account]/billing/page.tsx index c529cd6a8..d3b727641 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/page.tsx +++ b/apps/web/app/(dashboard)/home/[account]/billing/page.tsx @@ -3,6 +3,7 @@ import { CurrentPlanCard, } from '@kit/billing-gateway/components'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { If } from '@kit/ui/if'; import { PageBody, PageHeader } from '@kit/ui/page'; import { Trans } from '@kit/ui/trans'; @@ -34,6 +35,8 @@ async function TeamAccountBillingPage({ params }: Params) { const workspace = await loadTeamWorkspace(params.account); const accountId = workspace.account.id; const [subscription, customerId] = await loadAccountData(accountId); + const canManageBilling = + workspace.account.permissions.includes('billing.manage'); return ( <> @@ -44,17 +47,25 @@ async function TeamAccountBillingPage({ params }: Params) {
+ + + +
} + fallback={ + + + + } > {(data) => ( )} - +
@@ -71,6 +82,19 @@ async function TeamAccountBillingPage({ params }: Params) { export default withI18n(TeamAccountBillingPage); +function CannotManageBillingAlert() { + return ( + + + + + + + + + ); +} + async function loadAccountData(accountId: string) { const client = getSupabaseServerComponentClient(); diff --git a/apps/web/app/(dashboard)/home/[account]/members/page.tsx b/apps/web/app/(dashboard)/home/[account]/members/page.tsx index 7bd82debe..564279558 100644 --- a/apps/web/app/(dashboard)/home/[account]/members/page.tsx +++ b/apps/web/app/(dashboard)/home/[account]/members/page.tsx @@ -17,6 +17,7 @@ import { CardHeader, CardTitle, } from '@kit/ui/card'; +import { If } from '@kit/ui/if'; import { PageBody, PageHeader } from '@kit/ui/page'; import { Trans } from '@kit/ui/trans'; @@ -99,13 +100,10 @@ async function TeamAccountMembersPage({ params }: Params) { ); const canManageRoles = account.permissions.includes('roles.manage'); - const isPrimaryOwner = account.primary_owner_user_id === user.id; + const canManageInvitations = account.permissions.includes('invites.manage'); - const permissions = { - canUpdateRole: canManageRoles, - canRemoveFromAccount: canManageRoles, - canTransferOwnership: isPrimaryOwner, - }; + const isPrimaryOwner = account.primary_owner_user_id === user.id; + const currentUserRoleHierarchy = account.role_hierarchy_level; return ( <> @@ -126,23 +124,32 @@ async function TeamAccountMembersPage({ params }: Params) { - Here you can manage the members of your organization. +
- - - + + + + + @@ -150,11 +157,12 @@ async function TeamAccountMembersPage({ params }: Params) {
- Pending Invitations + + + - Here you can manage the pending invitations to your - organization. +
@@ -164,6 +172,7 @@ async function TeamAccountMembersPage({ params }: Params) { permissions={{ canUpdateInvitation: canManageRoles, canRemoveInvitation: canManageRoles, + currentUserRoleHierarchy, }} invitations={invitations} /> diff --git a/apps/web/app/(marketing)/_components/site-header-account-section.tsx b/apps/web/app/(marketing)/_components/site-header-account-section.tsx index f6fb20fb4..9758ae8e5 100644 --- a/apps/web/app/(marketing)/_components/site-header-account-section.tsx +++ b/apps/web/app/(marketing)/_components/site-header-account-section.tsx @@ -2,14 +2,13 @@ import Link from 'next/link'; -import { Session, User } from '@supabase/supabase-js'; +import type { User } from '@supabase/supabase-js'; import { ChevronRight } from 'lucide-react'; import { PersonalAccountDropdown } from '@kit/accounts/personal-account-dropdown'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; import { useUser } from '@kit/supabase/hooks/use-user'; -import { useUserSession } from '@kit/supabase/hooks/use-user-session'; import { Button } from '@kit/ui/button'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; diff --git a/apps/web/app/(marketing)/blog/[slug]/page.tsx b/apps/web/app/(marketing)/blog/[slug]/page.tsx index 9ce0c447f..275a97765 100644 --- a/apps/web/app/(marketing)/blog/[slug]/page.tsx +++ b/apps/web/app/(marketing)/blog/[slug]/page.tsx @@ -23,7 +23,7 @@ export async function generateMetadata({ const { title, date, description, image, slug } = post; const url = [appConfig.url, 'blog', slug].join('/'); - return { + return Promise.resolve({ title, description, openGraph: { @@ -46,7 +46,7 @@ export async function generateMetadata({ description, images: image ? [image] : [], }, - }; + }); } function BlogPost({ params }: { params: { slug: string } }) { diff --git a/apps/web/app/(marketing)/docs/_lib/build-documentation-tree.ts b/apps/web/app/(marketing)/docs/_lib/build-documentation-tree.ts index ca624f2f9..60b34f7a9 100644 --- a/apps/web/app/(marketing)/docs/_lib/build-documentation-tree.ts +++ b/apps/web/app/(marketing)/docs/_lib/build-documentation-tree.ts @@ -25,6 +25,7 @@ export const buildDocumentationTree = cache( .filter( (_) => _.pathSegments.length === level + 1 && + // eslint-disable-next-line @typescript-eslint/no-unsafe-call _.pathSegments .map(({ pathName }: { pathName: string }) => pathName) .join('/') @@ -37,6 +38,7 @@ export const buildDocumentationTree = cache( return pages.map((doc, index) => { const children = buildDocumentationTree( docs, + // eslint-disable-next-line @typescript-eslint/no-unsafe-call doc.pathSegments.map(({ pathName }: { pathName: string }) => pathName), ); @@ -44,7 +46,7 @@ export const buildDocumentationTree = cache( ...doc, pathSegments: doc.pathSegments || ([] as string[]), collapsible: children.length > 0, - nextPage: children[0] || pages[index + 1], + nextPage: children[0] ?? pages[index + 1], previousPage: pages[index - 1], children, }; diff --git a/apps/web/app/(marketing)/docs/layout.tsx b/apps/web/app/(marketing)/docs/layout.tsx index 204352079..a2c52ef27 100644 --- a/apps/web/app/(marketing)/docs/layout.tsx +++ b/apps/web/app/(marketing)/docs/layout.tsx @@ -1,4 +1,3 @@ -import type { DocumentationPage } from 'contentlayer/generated'; import { allDocumentationPages } from 'contentlayer/generated'; import DocsNavigation from '~/(marketing)/docs/_components/docs-navigation'; diff --git a/apps/web/package.json b/apps/web/package.json index 6293c5270..e70326a32 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,7 +8,7 @@ "build": "pnpm with-env next build", "clean": "git clean -xdf .next .turbo node_modules", "dev": "pnpm with-env next dev --turbo", - "lint": "next lint", + "next:lint": "next lint", "format": "prettier --check \"**/*.{js,cjs,mjs,ts,tsx,md,json}\"", "start": "pnpm with-env next start", "typecheck": "tsc --noEmit", diff --git a/apps/web/public/locales/en/billing.json b/apps/web/public/locales/en/billing.json index 36649d70f..3843dc1b3 100644 --- a/apps/web/public/locales/en/billing.json +++ b/apps/web/public/locales/en/billing.json @@ -16,6 +16,10 @@ "checkoutSuccessTitle": "Done! You're all set.", "checkoutSuccessDescription": "Thank you for subscribing, we have successfully processed your subscription! A confirmation email will be sent to {{ customerEmail }}.", "checkoutSuccessBackButton": "Proceed to App", + "cannotManageBillingAlertTitle": "You cannot manage billing", + "cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your organization owner.", + "manageTeamPlan": "Manage your Team Plan", + "manageTeamPlanDescription": "Choose a plan that fits your team's needs. You can upgrade or downgrade your plan at any time.", "status": { "free": { "badge": "Free Plan", diff --git a/apps/web/public/locales/en/teams.json b/apps/web/public/locales/en/teams.json index 5f77f57f7..7286a9302 100644 --- a/apps/web/public/locales/en/teams.json +++ b/apps/web/public/locales/en/teams.json @@ -30,7 +30,7 @@ "primaryOwnerLabel": "Primary Owner", "joinedAtLabel": "Joined at", "invitedAtLabel": "Invited at", - "membersTabSubheading": "Manage and Invite members", + "membersTabDescription": "Here you can manage the members of your team.", "inviteMembersPageSubheading": "Invite members to your Team", "createTeamModalHeading": "Create Team", "createTeamModalDescription": "Create a new Team to manage your projects and members.", @@ -96,10 +96,10 @@ "inviteMembersDescription": "Invite members to your team by entering their email and role.", "emailPlaceholder": "member@email.com", "membersPageHeading": "Members", - "inviteMembersButtonLabel": "Invite Members", + "inviteMembersButton": "Invite Members", "invitingMembers": "Inviting members...", "pendingInvitesHeading": "Pending Invites", - "pendingInvitesSubheading": "Manage invites not yet accepted", + "pendingInvitesDescription": " Here you can manage the pending invitations to your team.", "noPendingInvites": "No pending invites found", "loadingMembers": "Loading members...", "loadMembersError": "Sorry, we couldn't fetch your team's members.", diff --git a/packages/billing-gateway/src/components/embedded-checkout.tsx b/packages/billing-gateway/src/components/embedded-checkout.tsx index 68672c93c..02466b229 100644 --- a/packages/billing-gateway/src/components/embedded-checkout.tsx +++ b/packages/billing-gateway/src/components/embedded-checkout.tsx @@ -78,7 +78,7 @@ function buildLazyComponent< return ( - {/* @ts-expect-error */} + {/* @ts-expect-error: weird TS */} [] { const { t } = useTranslation('teams'); @@ -197,6 +199,7 @@ function ActionsDropdown({ setIsOpen={setIsUpdatingRole} invitationId={invitation.id} userRole={invitation.role} + userRoleHierarchy={permissions.currentUserRoleHierarchy} /> diff --git a/packages/features/team-accounts/src/components/invitations/update-invitation-dialog.tsx b/packages/features/team-accounts/src/components/invitations/update-invitation-dialog.tsx index 5c97d6439..b8cef8942 100644 --- a/packages/features/team-accounts/src/components/invitations/update-invitation-dialog.tsx +++ b/packages/features/team-accounts/src/components/invitations/update-invitation-dialog.tsx @@ -4,7 +4,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { Database } from '@kit/supabase/database'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Button } from '@kit/ui/button'; import { @@ -29,15 +28,17 @@ import { Trans } from '@kit/ui/trans'; import { UpdateRoleSchema } from '../../schema/update-role-schema'; import { updateInvitationAction } from '../../server/actions/team-invitations-server-actions'; import { MembershipRoleSelector } from '../members/membership-role-selector'; +import { RolesDataProvider } from '../members/roles-data-provider'; -type Role = Database['public']['Enums']['account_role']; +type Role = string; export const UpdateInvitationDialog: React.FC<{ isOpen: boolean; setIsOpen: (isOpen: boolean) => void; invitationId: number; userRole: Role; -}> = ({ isOpen, setIsOpen, invitationId, userRole }) => { + userRoleHierarchy: number; +}> = ({ isOpen, setIsOpen, invitationId, userRole, userRoleHierarchy }) => { return ( @@ -51,11 +52,16 @@ export const UpdateInvitationDialog: React.FC<{ - + + {(roles) => ( + + )} + ); @@ -64,10 +70,12 @@ export const UpdateInvitationDialog: React.FC<{ function UpdateInvitationForm({ invitationId, userRole, + userRoleHierarchy, setIsOpen, }: React.PropsWithChildren<{ invitationId: number; userRole: Role; + userRoleHierarchy: number; setIsOpen: (isOpen: boolean) => void; }>) { const { t } = useTranslation('teams'); @@ -128,11 +136,18 @@ function UpdateInvitationForm({ - form.setValue('role', newRole)} - /> + + {(roles) => ( + + form.setValue(field.name, newRole) + } + /> + )} + diff --git a/packages/features/team-accounts/src/components/members/account-members-table.tsx b/packages/features/team-accounts/src/components/members/account-members-table.tsx index e2c1cbd60..bce170118 100644 --- a/packages/features/team-accounts/src/components/members/account-members-table.tsx +++ b/packages/features/team-accounts/src/components/members/account-members-table.tsx @@ -28,25 +28,38 @@ import { UpdateMemberRoleDialog } from './update-member-role-dialog'; type Members = Database['public']['Functions']['get_account_members']['Returns']; +interface Permissions { + canUpdateRole: (roleHierarchy: number) => boolean; + canRemoveFromAccount: (roleHierarchy: number) => boolean; + canTransferOwnership: boolean; +} + type AccountMembersTableProps = { members: Members; - currentUserId: string; - - permissions: { - canUpdateRole: boolean; - canTransferOwnership: boolean; - canRemoveFromAccount: boolean; - }; + userRoleHierarchy: number; + isPrimaryOwner: boolean; + canManageRoles: boolean; }; export function AccountMembersTable({ members, - permissions, currentUserId, + isPrimaryOwner, + userRoleHierarchy, + canManageRoles, }: AccountMembersTableProps) { const [search, setSearch] = useState(''); const { t } = useTranslation('teams'); + + const permissions = { + canUpdateRole: (targetRole: number) => + canManageRoles && targetRole < userRoleHierarchy, + canRemoveFromAccount: (targetRole: number) => + canManageRoles && targetRole < userRoleHierarchy, + canTransferOwnership: isPrimaryOwner, + }; + const columns = useGetColumns(permissions, currentUserId); const filteredMembers = members.filter((member) => { @@ -73,11 +86,7 @@ export function AccountMembersTable({ } function useGetColumns( - permissions: { - canUpdateRole: boolean; - canTransferOwnership: boolean; - canRemoveFromAccount: boolean; - }, + permissions: Permissions, currentUserId: string, ): ColumnDef[] { const { t } = useTranslation('teams'); @@ -173,7 +182,7 @@ function ActionsDropdown({ member, currentUserId, }: { - permissions: AccountMembersTableProps['permissions']; + permissions: Permissions; member: Members[0]; currentUserId: string; }) { @@ -188,6 +197,22 @@ function ActionsDropdown({ return null; } + const memberRoleHierarchy = member.role_hierarchy_level; + const canUpdateRole = permissions.canUpdateRole(memberRoleHierarchy); + + const canRemoveFromAccount = + permissions.canRemoveFromAccount(memberRoleHierarchy); + + // if has no permission to update role, transfer ownership or remove from account + // do not render the dropdown menu + if ( + !canUpdateRole && + !permissions.canTransferOwnership && + !canRemoveFromAccount + ) { + return null; + } + return ( <> @@ -198,7 +223,7 @@ function ActionsDropdown({ - + setIsUpdatingRole(true)}> @@ -210,7 +235,7 @@ function ActionsDropdown({ - + setIsRemoving(true)}> @@ -234,6 +259,7 @@ function ActionsDropdown({ accountId={member.id} userId={member.user_id} userRole={member.role} + userRoleHierarchy={memberRoleHierarchy} /> diff --git a/packages/features/team-accounts/src/components/members/invite-members-dialog-container.tsx b/packages/features/team-accounts/src/components/members/invite-members-dialog-container.tsx index 09682a641..987882cff 100644 --- a/packages/features/team-accounts/src/components/members/invite-members-dialog-container.tsx +++ b/packages/features/team-accounts/src/components/members/invite-members-dialog-container.tsx @@ -7,7 +7,6 @@ import { Plus, X } from 'lucide-react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { Database } from '@kit/supabase/database'; import { Button } from '@kit/ui/button'; import { Dialog, @@ -36,16 +35,19 @@ import { Trans } from '@kit/ui/trans'; import { InviteMembersSchema } from '../../schema/invite-members.schema'; import { createInvitationsAction } from '../../server/actions/team-invitations-server-actions'; import { MembershipRoleSelector } from './membership-role-selector'; +import { RolesDataProvider } from './roles-data-provider'; type InviteModel = ReturnType; -type Role = Database['public']['Enums']['account_role']; +type Role = string; export function InviteMembersDialogContainer({ account, + userRoleHierarchy, children, }: React.PropsWithChildren<{ account: string; + userRoleHierarchy: number; }>) { const [pending, startTransition] = useTransition(); const [isOpen, setIsOpen] = useState(false); @@ -65,19 +67,24 @@ export function InviteMembersDialogContainer({ - { - startTransition(async () => { - await createInvitationsAction({ - account, - invitations: data.invitations, - }); + + {(roles) => ( + { + startTransition(async () => { + await createInvitationsAction({ + account, + invitations: data.invitations, + }); - setIsOpen(false); - }); - }} - /> + setIsOpen(false); + }); + }} + /> + )} + ); @@ -85,10 +92,12 @@ export function InviteMembersDialogContainer({ function InviteMembersForm({ onSubmit, + roles, pending, }: { onSubmit: (data: { invitations: InviteModel[] }) => void; pending: boolean; + roles: string[]; }) { const { t } = useTranslation('teams'); @@ -156,6 +165,7 @@ function InviteMembersForm({ { form.setValue(field.name, role); diff --git a/packages/features/team-accounts/src/components/members/membership-role-selector.tsx b/packages/features/team-accounts/src/components/members/membership-role-selector.tsx index 25461f567..6f3a329ed 100644 --- a/packages/features/team-accounts/src/components/members/membership-role-selector.tsx +++ b/packages/features/team-accounts/src/components/members/membership-role-selector.tsx @@ -1,4 +1,3 @@ -import { Database } from '@kit/supabase/database'; import { Select, SelectContent, @@ -8,15 +7,14 @@ import { } from '@kit/ui/select'; import { Trans } from '@kit/ui/trans'; -type Role = Database['public']['Enums']['account_role']; +type Role = string; export const MembershipRoleSelector: React.FC<{ + roles: Role[]; value: Role; currentUserRole?: Role; onChange: (role: Role) => unknown; -}> = ({ value, currentUserRole, onChange }) => { - const rolesList: Role[] = ['owner', 'member']; - +}> = ({ roles, value, currentUserRole, onChange }) => { return (