B2B-30: add personal code into invitation process

This commit is contained in:
aleksei-milisenko-at-mountbirch
2025-07-02 18:27:28 +03:00
committed by GitHub
55 changed files with 876 additions and 215 deletions

2
.env
View File

@@ -40,6 +40,8 @@ NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true
NEXT_PUBLIC_LANGUAGE_PRIORITY=application NEXT_PUBLIC_LANGUAGE_PRIORITY=application
NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true
NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true
# NEXTJS # NEXTJS
NEXT_TELEMETRY_DISABLED=1 NEXT_TELEMETRY_DISABLED=1

View File

@@ -55,7 +55,7 @@ async function AccountsPage(props: AdminAccountsPageProps) {
} }
if (query) { if (query) {
queryBuilder.or(`name.ilike.%${query}%,email.ilike.%${query}%`); queryBuilder.or(`name.ilike.%${query}%,email.ilike.%${query}%,personal_code.ilike.%${query}%`);
} }
return queryBuilder; return queryBuilder;

View File

@@ -1,6 +1,4 @@
import { ShoppingCart } from 'lucide-react';
import { Button } from '@kit/ui/button';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { AppLogo } from '~/components/app-logo'; import { AppLogo } from '~/components/app-logo';
@@ -11,14 +9,17 @@ import { SIDEBAR_WIDTH } from '../../../../packages/ui/src/shadcn/constants';
// home imports // home imports
import { UserNotifications } from '../_components/user-notifications'; import { UserNotifications } from '../_components/user-notifications';
import { type UserWorkspace } from '../_lib/server/load-user-workspace'; import { type UserWorkspace } from '../_lib/server/load-user-workspace';
import { Button } from '@kit/ui/button';
import { ShoppingCart } from 'lucide-react';
export function HomeMenuNavigation(props: { workspace: UserWorkspace }) { export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
const { workspace, user } = props.workspace; const { workspace, user, accounts } = props.workspace;
return ( return (
<div className={'flex w-full flex-1 items-center justify-between gap-3'}> <div className={'flex w-full flex-1 items-center justify-between gap-3'}>
<div className={`flex items-center w-[${SIDEBAR_WIDTH}]`}> <div className={`flex items-center w-[${SIDEBAR_WIDTH}]`}>
<AppLogo /> <AppLogo />
</div> </div>
<Search <Search
className="flex grow" className="flex grow"
@@ -26,7 +27,10 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
/> />
<div className="flex items-center justify-end gap-3"> <div className="flex items-center justify-end gap-3">
<Button variant="outline"> <Button className='relative px-4 py-2 h-10 border-1 mr-0 cursor-pointer' variant='ghost'>
<span className='flex items-center text-nowrap'> 231,89</span>
</Button>
<Button variant="ghost" className='relative px-4 py-2 h-10 border-1 mr-0 cursor-pointer' >
<ShoppingCart className="stroke-[1.5px]" /> <ShoppingCart className="stroke-[1.5px]" />
<Trans i18nKey="common:shoppingCart" /> (0) <Trans i18nKey="common:shoppingCart" /> (0)
</Button> </Button>
@@ -37,6 +41,7 @@ export function HomeMenuNavigation(props: { workspace: UserWorkspace }) {
user={user} user={user}
account={workspace} account={workspace}
showProfileName showProfileName
accounts={accounts}
/> />
</div> </div>
</div> </div>

View File

@@ -28,15 +28,20 @@ async function workspaceLoader() {
const workspacePromise = api.getAccountWorkspace(); const workspacePromise = api.getAccountWorkspace();
const [accounts, workspace, user] = await Promise.all([ // TODO!: remove before deploy to prod
const tempAccountsPromise = () => api.loadTempUserAccounts();
const [accounts, workspace, user, tempVisibleAccounts] = await Promise.all([
accountsPromise(), accountsPromise(),
workspacePromise, workspacePromise,
requireUserInServerComponent(), requireUserInServerComponent(),
tempAccountsPromise()
]); ]);
return { return {
accounts, accounts,
workspace, workspace,
user, user,
tempVisibleAccounts
}; };
} }

View File

@@ -1,4 +1,3 @@
import { PageBody } from '@kit/ui/page';
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; import { createI18nServerInstance } from '~/lib/i18n/i18n.server';
@@ -7,6 +6,9 @@ import { withI18n } from '~/lib/i18n/with-i18n';
import Dashboard from './_components/dashboard'; import Dashboard from './_components/dashboard';
// local imports // local imports
import { HomeLayoutPageHeader } from './_components/home-page-header'; import { HomeLayoutPageHeader } from './_components/home-page-header';
import { use } from 'react';
import { loadUserWorkspace } from './_lib/server/load-user-workspace';
import { PageBody } from '@kit/ui/page';
export const generateMetadata = async () => { export const generateMetadata = async () => {
const i18n = await createI18nServerInstance(); const i18n = await createI18nServerInstance();
@@ -18,6 +20,7 @@ export const generateMetadata = async () => {
}; };
function UserHomePage() { function UserHomePage() {
const { tempVisibleAccounts } = use(loadUserWorkspace());
return ( return (
<> <>
<HomeLayoutPageHeader <HomeLayoutPageHeader

View File

@@ -6,16 +6,22 @@ import {
import { AppLogo } from '~/components/app-logo'; import { AppLogo } from '~/components/app-logo';
import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container'; import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config'; import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config';
import { TeamAccountAccountsSelector } from '~/home/[account]/_components/team-account-accounts-selector';
// local imports // local imports
import { TeamAccountWorkspace } from '../_lib/server/team-account-workspace.loader'; import { TeamAccountWorkspace } from '../_lib/server/team-account-workspace.loader';
import { TeamAccountNotifications } from './team-account-notifications'; import { TeamAccountNotifications } from './team-account-notifications';
import { useMemo } from 'react';
export function TeamAccountNavigationMenu(props: { export function TeamAccountNavigationMenu(props: {
workspace: TeamAccountWorkspace; workspace: TeamAccountWorkspace;
}) { }) {
const { account, user, accounts } = props.workspace; const { account, user, accounts: rawAccounts } = props.workspace;
const accounts = useMemo(() => rawAccounts.map((account) => ({
label: account.name,
value: account.slug,
image: account.picture_url,
})),[rawAccounts])
const routes = getTeamAccountSidebarConfig(account.slug).routes.reduce< const routes = getTeamAccountSidebarConfig(account.slug).routes.reduce<
Array<{ Array<{
@@ -40,35 +46,17 @@ export function TeamAccountNavigationMenu(props: {
<div className={'flex w-full flex-1 justify-between'}> <div className={'flex w-full flex-1 justify-between'}>
<div className={'flex items-center space-x-8'}> <div className={'flex items-center space-x-8'}>
<AppLogo /> <AppLogo />
<BorderedNavigationMenu>
{routes.map((route) => (
<BorderedNavigationMenuItem {...route} key={route.path} />
))}
</BorderedNavigationMenu>
</div> </div>
<div className={'flex items-center justify-end space-x-2.5'}> <div className={'flex items-center justify-end space-x-2.5 gap-2'}>
<TeamAccountNotifications accountId={account.id} userId={user.id} /> <TeamAccountNotifications accountId={account.id} userId={user.id} />
<TeamAccountAccountsSelector
userId={user.id}
selectedAccount={account.slug}
accounts={accounts.map((account) => ({
label: account.name,
value: account.slug,
image: account.picture_url,
}))}
/>
<div>
<ProfileAccountDropdownContainer <ProfileAccountDropdownContainer
user={user} user={user}
account={account} account={account}
showProfileName={false} showProfileName
accounts={accounts}
/> />
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -28,12 +28,8 @@ export const loadTeamWorkspace = cache(workspaceLoader);
async function workspaceLoader(accountSlug: string) { async function workspaceLoader(accountSlug: string) {
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const api = createTeamAccountsApi(client); const api = createTeamAccountsApi(client);
const user = await requireUserInServerComponent();
const [workspace, user] = await Promise.all([ const workspace = await api.getAccountWorkspace(accountSlug, user.id);
api.getAccountWorkspace(accountSlug),
requireUserInServerComponent(),
]);
// we cannot find any record for the selected account // we cannot find any record for the selected account
// so we redirect the user to the home page // so we redirect the user to the home page
if (!workspace.data?.account) { if (!workspace.data?.account) {
@@ -42,6 +38,7 @@ async function workspaceLoader(accountSlug: string) {
return { return {
...workspace.data, ...workspace.data,
accounts: workspace.data.accounts.map(({ user_accounts }) => ({...user_accounts})),
user, user,
}; };
} }

View File

@@ -4,7 +4,7 @@ import { cookies } from 'next/headers';
import { z } from 'zod'; import { z } from 'zod';
import { TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components'; import { CompanyGuard, TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components';
import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
@@ -113,8 +113,33 @@ function HeaderLayout({
</div> </div>
</PageMobileNavigation> </PageMobileNavigation>
<SidebarProvider defaultOpen>
<Page style={'sidebar'}>
<PageNavigation>
<TeamAccountLayoutSidebar
account={account}
accountId={data.account.id}
accounts={accounts}
user={data.user}
/>
</PageNavigation>
<PageMobileNavigation className={'flex items-center justify-between'}>
<AppLogo />
<div className={'flex space-x-4'}>
<TeamAccountLayoutMobileNavigation
userId={data.user.id}
accounts={accounts}
account={account}
/>
</div>
</PageMobileNavigation>
{children} {children}
</Page> </Page>
</SidebarProvider>
</Page>
</TeamAccountWorkspaceContextProvider> </TeamAccountWorkspaceContextProvider>
); );
} }
@@ -144,4 +169,4 @@ async function getLayoutState(account: string) {
}; };
} }
export default withI18n(TeamWorkspaceLayout); export default withI18n(CompanyGuard(TeamWorkspaceLayout));

View File

@@ -9,6 +9,7 @@ import { withI18n } from '~/lib/i18n/with-i18n';
import { DashboardDemo } from './_components/dashboard-demo'; import { DashboardDemo } from './_components/dashboard-demo';
import { TeamAccountLayoutPageHeader } from './_components/team-account-layout-page-header'; import { TeamAccountLayoutPageHeader } from './_components/team-account-layout-page-header';
import { CompanyGuard } from '@/packages/features/team-accounts/src/components';
interface TeamAccountHomePageProps { interface TeamAccountHomePageProps {
params: Promise<{ account: string }>; params: Promise<{ account: string }>;
@@ -41,4 +42,4 @@ function TeamAccountHomePage({ params }: TeamAccountHomePageProps) {
); );
} }
export default withI18n(TeamAccountHomePage); export default withI18n(CompanyGuard(TeamAccountHomePage));

View File

@@ -109,10 +109,7 @@ async function JoinTeamAccountPage(props: JoinTeamAccountPageProps) {
const signOutNext = `${pathsConfig.auth.signIn}?invite_token=${token}`; const signOutNext = `${pathsConfig.auth.signIn}?invite_token=${token}`;
// once the user accepts the invitation, we redirect them to the account home page // once the user accepts the invitation, we redirect them to the account home page
const accountHome = pathsConfig.app.accountHome.replace( const accountHome = pathsConfig.app.home;
'[account]',
invitation.account.slug,
);
const email = auth.data.email ?? ''; const email = auth.data.email ?? '';

View File

@@ -26,6 +26,11 @@ export function ProfileAccountDropdownContainer(props: {
name: string | null; name: string | null;
picture_url: string | null; picture_url: string | null;
}; };
accounts: {
label: string | null;
value: string | null;
image?: string | null;
}[]
}) { }) {
const signOut = useSignOut(); const signOut = useSignOut();
const user = useUser(props.user); const user = useUser(props.user);
@@ -42,6 +47,7 @@ export function ProfileAccountDropdownContainer(props: {
features={features} features={features}
user={userData} user={userData}
account={props.account} account={props.account}
accounts={props.accounts}
signOutRequested={() => signOut.mutateAsync()} signOutRequested={() => signOut.mutateAsync()}
showProfileName={props.showProfileName} showProfileName={props.showProfileName}
/> />

View File

@@ -112,10 +112,10 @@ export function AccountSelector({
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className={cn( className={cn(
'dark:shadow-primary/10 group w-full min-w-0 px-2 lg:w-auto lg:max-w-fit', 'dark:shadow-primary/10 group w-full min-w-0 px-4 py-2 h-10 border-1 lg:w-auto lg:max-w-fit',
{ {
'justify-start': !collapsed, 'justify-start': !collapsed,
'm-auto justify-center px-2 lg:w-full': collapsed, 'm-auto justify-center px-4 lg:w-full': collapsed,
}, },
className, className,
)} )}
@@ -124,7 +124,7 @@ export function AccountSelector({
condition={selected} condition={selected}
fallback={ fallback={
<span <span
className={cn('flex max-w-full items-center', { className={cn('flex max-w-full items-center size-4', {
'justify-center gap-x-0': collapsed, 'justify-center gap-x-0': collapsed,
'gap-x-4': !collapsed, 'gap-x-4': !collapsed,
})} })}
@@ -148,7 +148,7 @@ export function AccountSelector({
'gap-x-4': !collapsed, 'gap-x-4': !collapsed,
})} })}
> >
<Avatar className={'rounded-xs h-6 w-6'}> <Avatar className={'rounded-md size-6'}>
<AvatarImage src={account.image ?? undefined} /> <AvatarImage src={account.image ?? undefined} />
<AvatarFallback <AvatarFallback
@@ -297,7 +297,7 @@ export function AccountSelector({
function UserAvatar(props: { pictureUrl?: string }) { function UserAvatar(props: { pictureUrl?: string }) {
return ( return (
<Avatar className={'rounded-xs h-6 w-6'}> <Avatar className={'rounded-md size-6'}>
<AvatarImage src={props.pictureUrl} /> <AvatarImage src={props.pictureUrl} />
</Avatar> </Avatar>
); );

View File

@@ -10,7 +10,7 @@ import {
ChevronsUpDown, ChevronsUpDown,
Home, Home,
LogOut, LogOut,
MessageCircleQuestion, UserCircle,
Shield, Shield,
} from 'lucide-react'; } from 'lucide-react';
@@ -28,6 +28,9 @@ import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { usePersonalAccountData } from '../hooks/use-personal-account-data'; import { usePersonalAccountData } from '../hooks/use-personal-account-data';
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
const PERSONAL_ACCOUNT_SLUG = 'personal';
export function PersonalAccountDropdown({ export function PersonalAccountDropdown({
className, className,
@@ -37,6 +40,7 @@ export function PersonalAccountDropdown({
paths, paths,
features, features,
account, account,
accounts = []
}: { }: {
user: User; user: User;
@@ -45,7 +49,11 @@ export function PersonalAccountDropdown({
name: string | null; name: string | null;
picture_url: string | null; picture_url: string | null;
}; };
accounts: {
label: string | null;
value: string | null;
image?: string | null;
}[];
signOutRequested: () => unknown; signOutRequested: () => unknown;
paths: { paths: {
@@ -95,7 +103,7 @@ export function PersonalAccountDropdown({
className ?? '', className ?? '',
{ {
['active:bg-secondary/50 items-center gap-4 rounded-md' + ['active:bg-secondary/50 items-center gap-4 rounded-md' +
' hover:bg-secondary p-2 transition-colors']: showProfileName, ' hover:bg-secondary m-0 transition-colors border-1 rounded-md px-4 py-1 h-10']: showProfileName,
}, },
)} )}
> >
@@ -119,12 +127,6 @@ export function PersonalAccountDropdown({
{displayName} {displayName}
</span> </span>
<span
data-test={'account-dropdown-email'}
className={'text-muted-foreground truncate text-xs'}
>
{signedInAsLabel}
</span>
</div> </div>
<ChevronsUpDown <ChevronsUpDown
@@ -167,15 +169,55 @@ export function PersonalAccountDropdown({
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<If condition={accounts.length > 0}>
<span className='px-2 text-muted-foreground text-xs'>
<Trans
i18nKey={'teams:yourTeams'}
values={{ teamsCount: accounts.length }}
/>
</span>
{accounts.map((account) => (
<DropdownMenuItem key={account.value} asChild>
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={`/home/${account.value}`}
>
<div className={'flex items-center'}>
<Avatar className={'rounded-xs h-5 w-5 ' + account.image}>
<AvatarImage {...(account.image && { src: account.image })} />
<AvatarFallback
className={cn('rounded-md', {
['bg-background']: PERSONAL_ACCOUNT_SLUG === account.value,
['group-hover:bg-background']:
PERSONAL_ACCOUNT_SLUG !== account.value,
})}
>
{account.label ? account.label[0] : ''}
</AvatarFallback>
</Avatar>
<span className={'pl-3'}>
{account.label}
</span>
</div>
</Link>
</DropdownMenuItem>
))}
</If>
<DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link <Link
className={'s-full flex cursor-pointer items-center space-x-2'} className={'s-full flex cursor-pointer items-center space-x-2'}
href={'/docs'} href={'/home/settings'}
> >
<MessageCircleQuestion className={'h-5'} /> <UserCircle className={'h-5'} />
<span> <span>
<Trans i18nKey={'common:documentation'} /> <Trans i18nKey={'common:routes.profile'} />
</span> </span>
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -48,22 +48,60 @@ class AccountsApi {
/** /**
* @name loadUserAccounts * @name loadUserAccounts
* Load the user accounts. * Load only user-owned accounts (not just memberships).
*/ */
async loadUserAccounts() { async loadUserAccounts() {
const authUser = await this.client.auth.getUser();
const {
data,
error: userError,
} = authUser
if (userError) {
throw userError;
}
const { user } = data;
const { data: accounts, error } = await this.client const { data: accounts, error } = await this.client
.from('user_accounts') .from('accounts_memberships')
.select(`name, slug, picture_url`); .select(`
account_id,
user_accounts (
name,
slug,
picture_url
)
`)
.eq('user_id', user.id)
.eq('account_role', 'owner');
if (error) { if (error) {
throw error; throw error;
} }
return accounts.map(({ name, slug, picture_url }) => { return accounts.map(({ user_accounts }) => ({
label: user_accounts.name,
value: user_accounts.slug,
image: user_accounts.picture_url,
}));
}
async loadTempUserAccounts() {
const { data: accounts, error } = await this.client
.from('user_accounts')
.select(`name, slug`);
if (error) {
throw error;
}
return accounts.map(({ name, slug }) => {
return { return {
label: name, label: name,
value: slug, value: slug,
image: picture_url,
}; };
}); });
} }

View File

@@ -28,6 +28,12 @@ import { AdminMembersTable } from './admin-members-table';
import { AdminMembershipsTable } from './admin-memberships-table'; import { AdminMembershipsTable } from './admin-memberships-table';
import { AdminReactivateUserDialog } from './admin-reactivate-user-dialog'; import { AdminReactivateUserDialog } from './admin-reactivate-user-dialog';
import {
AccountInvitationsTable,
AccountMembersTable,
InviteMembersDialogContainer,
} from '@kit/team-accounts/components';
type Account = Tables<'accounts'>; type Account = Tables<'accounts'>;
type Membership = Tables<'accounts_memberships'>; type Membership = Tables<'accounts_memberships'>;
@@ -146,8 +152,6 @@ async function PersonalAccountPage(props: { account: Account }) {
</div> </div>
<div className={'flex flex-col gap-y-8'}> <div className={'flex flex-col gap-y-8'}>
<SubscriptionsTable accountId={props.account.id} />
<div className={'divider-divider-x flex flex-col gap-y-2.5'}> <div className={'divider-divider-x flex flex-col gap-y-2.5'}>
<Heading level={6}>Companies</Heading> <Heading level={6}>Companies</Heading>
@@ -212,7 +216,7 @@ async function TeamAccountPage(props: {
<div> <div>
<div className={'flex flex-col gap-y-8'}> <div className={'flex flex-col gap-y-8'}>
<div className={'flex flex-col gap-y-2.5'}> <div className={'flex flex-col gap-y-2.5'}>
<Heading level={6}>Company Employees</Heading> <Heading level={6}>Company Members</Heading>
<AdminMembersTable members={members} /> <AdminMembersTable members={members} />
</div> </div>

View File

@@ -179,6 +179,14 @@ function getColumns(): ColumnDef<Account>[] {
header: 'Email', header: 'Email',
accessorKey: 'email', accessorKey: 'email',
}, },
{
id: 'personalCode',
header: 'Personal Code',
accessorKey: 'personalCode',
cell: ({ row }) => {
return row.original.personal_code ?? '-';
},
},
{ {
id: 'type', id: 'type',
header: 'Type', header: 'Type',

View File

@@ -48,8 +48,9 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
email: '', email: '',
password: '', password: '',
emailConfirm: false, emailConfirm: false,
personalCode: ''
}, },
mode: 'onChange', mode: 'onBlur',
}); });
const onSubmit = (data: CreateUserSchemaType) => { const onSubmit = (data: CreateUserSchemaType) => {
@@ -98,6 +99,25 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
</Alert> </Alert>
</If> </If>
<FormField
name={'personalCode'}
render={({ field }) => (
<FormItem>
<FormLabel>Personal code</FormLabel>
<FormControl>
<Input
required
type="text"
placeholder="48506040199"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
name={'email'} name={'email'}
render={({ field }) => ( render={({ field }) => (

View File

@@ -52,7 +52,7 @@ function getColumns(): ColumnDef<Memberships>[] {
{ {
header: 'Role', header: 'Role',
cell: ({ row }) => { cell: ({ row }) => {
return row.original.role === 'owner' ? 'HR' : 'Employee'; return row.original.role === 'owner' ? 'Admin' : 'Member';
}, },
}, },
{ {

View File

@@ -160,7 +160,7 @@ export const deleteAccountAction = adminAction(
*/ */
export const createUserAction = adminAction( export const createUserAction = adminAction(
enhanceAction( enhanceAction(
async ({ email, password, emailConfirm }) => { async ({ email, password, emailConfirm, personalCode }) => {
const adminClient = getSupabaseServerAdminClient(); const adminClient = getSupabaseServerAdminClient();
const logger = await getLogger(); const logger = await getLogger();
@@ -182,6 +182,16 @@ export const createUserAction = adminAction(
`Super Admin has successfully created a new user`, `Super Admin has successfully created a new user`,
); );
const { error: accountError } = await adminClient
.from('accounts')
.update({ personal_code: personalCode })
.eq('id', data.user.id);
if (accountError) {
logger.error({ accountError }, 'Error inserting personal code to accounts');
throw new Error(`Error saving personal code: ${accountError.message}`);
}
revalidateAdmin(); revalidateAdmin();
return { return {

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const CreateUserProfileSchema = z.object({
personalCode: z.string().regex(/^[1-6]\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}\d$/, {
message: 'Invalid Estonian personal code format',
}),
});
export type CreateUserProfileSchemaType = z.infer<typeof CreateUserProfileSchema>;

View File

@@ -1,6 +1,9 @@
import { z } from 'zod'; import { z } from 'zod';
export const CreateUserSchema = z.object({ export const CreateUserSchema = z.object({
personalCode: z.string().regex(/^[1-6]\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}\d$/, {
message: 'Invalid Estonian personal code format',
}),
email: z.string().email({ message: 'Please enter a valid email address' }), email: z.string().email({ message: 'Please enter a valid email address' }),
password: z password: z
.string() .string()

View File

@@ -30,6 +30,7 @@ interface PasswordSignUpFormProps {
displayTermsCheckbox?: boolean; displayTermsCheckbox?: boolean;
onSubmit: (params: { onSubmit: (params: {
personalCode: string;
email: string; email: string;
password: string; password: string;
repeatPassword: string; repeatPassword: string;
@@ -48,6 +49,7 @@ export function PasswordSignUpForm({
const form = useForm({ const form = useForm({
resolver: zodResolver(PasswordSignUpSchema), resolver: zodResolver(PasswordSignUpSchema),
defaultValues: { defaultValues: {
personalCode: '',
email: defaultValues?.email ?? '', email: defaultValues?.email ?? '',
password: '', password: '',
repeatPassword: '', repeatPassword: '',
@@ -60,6 +62,29 @@ export function PasswordSignUpForm({
className={'flex w-full flex-col gap-y-4'} className={'flex w-full flex-col gap-y-4'}
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
> >
<FormField
control={form.control}
name={'personalCode'}
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans i18nKey={'common:personalCode'} />
</FormLabel>
<FormControl>
<Input
data-test={'personal-code-input'}
required
type="text"
placeholder={t('personalCodePlaceholder')}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name={'email'} name={'email'}

View File

@@ -11,6 +11,7 @@ import { Trans } from '@kit/ui/trans';
import { MagicLinkAuthContainer } from './magic-link-auth-container'; import { MagicLinkAuthContainer } from './magic-link-auth-container';
import { OauthProviders } from './oauth-providers'; import { OauthProviders } from './oauth-providers';
import { EmailPasswordSignUpContainer } from './password-sign-up-container'; import { EmailPasswordSignUpContainer } from './password-sign-up-container';
import { redirect } from 'next/navigation';
export function SignUpMethodsContainer(props: { export function SignUpMethodsContainer(props: {
paths: { paths: {
@@ -42,6 +43,7 @@ export function SignUpMethodsContainer(props: {
emailRedirectTo={redirectUrl} emailRedirectTo={redirectUrl}
defaultValues={defaultValues} defaultValues={defaultValues}
displayTermsCheckbox={props.displayTermsCheckbox} displayTermsCheckbox={props.displayTermsCheckbox}
onSignUp={() => redirect(redirectUrl)}
/> />
</If> </If>

View File

@@ -8,6 +8,7 @@ import { useAppEvents } from '@kit/shared/events';
import { useSignUpWithEmailAndPassword } from '@kit/supabase/hooks/use-sign-up-with-email-password'; import { useSignUpWithEmailAndPassword } from '@kit/supabase/hooks/use-sign-up-with-email-password';
type SignUpCredentials = { type SignUpCredentials = {
personalCode: string;
email: string; email: string;
password: string; password: string;
}; };
@@ -46,7 +47,6 @@ export function usePasswordSignUpFlow({
emailRedirectTo, emailRedirectTo,
captchaToken, captchaToken,
}); });
// emit event to track sign up // emit event to track sign up
appEvents.emit({ appEvents.emit({
type: 'user.signedUp', type: 'user.signedUp',

View File

@@ -4,6 +4,9 @@ import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema';
export const PasswordSignUpSchema = z export const PasswordSignUpSchema = z
.object({ .object({
personalCode: z.string().regex(/^[1-6]\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}\d$/, {
message: 'Invalid Estonian personal code format',
}),
email: z.string().email(), email: z.string().email(),
password: RefinedPasswordSchema, password: RefinedPasswordSchema,
repeatPassword: RefinedPasswordSchema, repeatPassword: RefinedPasswordSchema,

View File

@@ -121,8 +121,8 @@ export function NotificationsPopover(params: {
return ( return (
<Popover modal open={open} onOpenChange={setOpen}> <Popover modal open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button className="relative w-5" variant={'outline'}> <Button className={'relative px-4 py-2 h-10 border-1 mr-0'} variant="ghost">
<Bell className={'min-h-4 min-w-4 stroke-[1.5px]'} /> <Bell className={'size-4'} />
<span <span
className={cn( className={cn(

View File

@@ -9,6 +9,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"exports": { "exports": {
".": "./src/index.ts",
"./api": "./src/server/api.ts", "./api": "./src/server/api.ts",
"./components": "./src/components/index.ts", "./components": "./src/components/index.ts",
"./hooks/*": "./src/hooks/*.ts", "./hooks/*": "./src/hooks/*.ts",

View File

@@ -0,0 +1,31 @@
import { notFound } from 'next/navigation';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isCompanyAdmin } from '../server/utils/is-company-admin';
import { isSuperAdmin } from '@kit/admin'
type LayoutOrPageComponent<Params> = React.ComponentType<Params>;
/**
* CompanyGuard is a server component wrapper that checks if the user is a company admin before rendering the component.
* If the user is not a company admin, we redirect to a 404.
* @param Component - The Page or Layout component to wrap
*/
export function CompanyGuard<Params extends object>(
Component: LayoutOrPageComponent<Params>,
) {
return async function AdminGuardServerComponentWrapper(params: Params) {
//@ts-ignore
const { account } = await params.params;
const client = getSupabaseServerClient();
const [isUserSuperAdmin, isUserCompanyAdmin] = await Promise.all(
[isSuperAdmin(client), isCompanyAdmin(client, account)]
);
if (isUserSuperAdmin || isUserCompanyAdmin) {
return <Component {...params} />;
}
// if the user is not a company admin, we redirect to a 404
notFound();
};
}

View File

@@ -6,3 +6,4 @@ export * from './settings/team-account-settings-container';
export * from './invitations/accept-invitation-container'; export * from './invitations/accept-invitation-container';
export * from './create-team-account-dialog'; export * from './create-team-account-dialog';
export * from './team-account-workspace-context'; export * from './team-account-workspace-context';
export * from './company-guard';

View File

@@ -107,6 +107,14 @@ function useGetColumns(permissions: {
); );
}, },
}, },
{
header: t('personalCode'),
cell: ({ row }) => {
const { personal_code } = row.original;
return personal_code;
},
},
{ {
header: t('roleLabel'), header: t('roleLabel'),
cell: ({ row }) => { cell: ({ row }) => {

View File

@@ -87,7 +87,8 @@ export function AccountMembersTable({
return ( return (
displayName.includes(searchString) || displayName.includes(searchString) ||
member.role.toLowerCase().includes(searchString) member.role.toLowerCase().includes(searchString) ||
(member.personal_code || '').includes(searchString)
); );
}) })
.sort((prev, next) => { .sort((prev, next) => {
@@ -160,6 +161,13 @@ function useGetColumns(
return row.original.email ?? '-'; return row.original.email ?? '-';
}, },
}, },
{
header: t('personalCode'),
accessorKey: 'personal_code',
cell: ({ row }) => {
return row.original.personal_code ?? '-';
},
},
{ {
header: t('roleLabel'), header: t('roleLabel'),
cell: ({ row }) => { cell: ({ row }) => {

View File

@@ -66,7 +66,7 @@ export function InviteMembersDialogContainer({
<Dialog open={isOpen} onOpenChange={setIsOpen} modal> <Dialog open={isOpen} onOpenChange={setIsOpen} modal>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent onInteractOutside={(e) => e.preventDefault()}> <DialogContent className="max-w-[800px]" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans i18nKey={'teams:inviteMembersHeading'} /> <Trans i18nKey={'teams:inviteMembersHeading'} />
@@ -142,13 +142,39 @@ function InviteMembersForm({
{fieldArray.fields.map((field, index) => { {fieldArray.fields.map((field, index) => {
const isFirst = index === 0; const isFirst = index === 0;
const personalCodeInputName = `invitations.${index}.personal_code` as const;
const emailInputName = `invitations.${index}.email` as const; const emailInputName = `invitations.${index}.email` as const;
const roleInputName = `invitations.${index}.role` as const; const roleInputName = `invitations.${index}.role` as const;
return ( return (
<div data-test={'invite-member-form-item'} key={field.id}> <div data-test={'invite-member-form-item'} key={field.id}>
<div className={'flex items-end gap-x-1 md:space-x-2'}> <div className={'flex items-end gap-x-1 md:space-x-2'}>
<div className={'w-7/12'}> <div className={'w-4/12'}>
<FormField
name={personalCodeInputName}
render={({ field }) => {
return (
<FormItem>
<If condition={isFirst}>
<FormLabel>{t('Personal code')}</FormLabel>
</If>
<FormControl>
<Input
placeholder={t('personalCode')}
type="text"
required
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<div className={'w-4/12'}>
<FormField <FormField
name={emailInputName} name={emailInputName}
render={({ field }) => { render={({ field }) => {
@@ -273,5 +299,5 @@ function InviteMembersForm({
} }
function createEmptyInviteModel() { function createEmptyInviteModel() {
return { email: '', role: 'member' as Role }; return { email: '', role: 'member' as Role, personal_code: '' };
} }

View File

@@ -0,0 +1 @@
export * from './server/utils/is-company-admin';

View File

@@ -3,6 +3,9 @@ import { z } from 'zod';
const InviteSchema = z.object({ const InviteSchema = z.object({
email: z.string().email(), email: z.string().email(),
role: z.string().min(1).max(100), role: z.string().min(1).max(100),
personal_code: z.string().regex(/^[1-6]\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}\d$/, {
message: 'Invalid Estonian personal code format',
}),
}); });
export const InviteMembersSchema = z export const InviteMembersSchema = z

View File

@@ -16,6 +16,8 @@ import { RenewInvitationSchema } from '../../schema/renew-invitation.schema';
import { UpdateInvitationSchema } from '../../schema/update-invitation.schema'; import { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
import { createAccountInvitationsService } from '../services/account-invitations.service'; import { createAccountInvitationsService } from '../services/account-invitations.service';
import { createAccountPerSeatBillingService } from '../services/account-per-seat-billing.service'; import { createAccountPerSeatBillingService } from '../services/account-per-seat-billing.service';
import { createNotificationsApi } from '@kit/notifications/api';
import { getLogger } from '@kit/shared/logger';
/** /**
* @name createInvitationsAction * @name createInvitationsAction
@@ -23,14 +25,55 @@ import { createAccountPerSeatBillingService } from '../services/account-per-seat
*/ */
export const createInvitationsAction = enhanceAction( export const createInvitationsAction = enhanceAction(
async (params) => { async (params) => {
const logger = await getLogger();
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const serviceClient = getSupabaseServerAdminClient();
// Create the service
const service = createAccountInvitationsService(client); const service = createAccountInvitationsService(client);
const api = createNotificationsApi(serviceClient);
// send invitations
await service.sendInvitations(params); await service.sendInvitations(params);
const { invitations: invitationParams, accountSlug } = params;
const personalCodes = invitationParams.map(({ personal_code }) => personal_code);
const { data: company, error: companyError } = await client
.from('accounts')
.select('id')
.eq('slug', accountSlug);
logger.debug({ company, companyError, personalCodes })
if (companyError || !company?.length || !company[0]) {
throw new Error(`Failed to fetch company id: ${companyError?.message || 'not found'}`);
}
const { data: invitations, error: invitationError } = await serviceClient.rpc(
'get_invitations_with_account_ids',
{
company_id: company[0].id,
personal_codes: personalCodes,
}
);
logger.debug({ invitations, invitationError })
if (invitationError) {
throw new Error(`Failed to fetch invitations with accounts: ${invitationError.message}`);
}
const notificationPromises = invitations
.map(({ invite_token, account_id }) =>
api.createNotification({
account_id: account_id!,
body: `You are invited to join the company: ${accountSlug}`,
link: `/join?invite_token=${invite_token}`,
})
);
await Promise.all(notificationPromises);
logger.info('All invitation notifications are sent')
revalidateMemberPage(); revalidateMemberPage();
return { return {

View File

@@ -90,12 +90,25 @@ export class TeamAccountsApi {
* @description Get the account workspace data. * @description Get the account workspace data.
* @param slug * @param slug
*/ */
async getAccountWorkspace(slug: string) { async getAccountWorkspace(slug: string, userId: string) {
const accountPromise = this.client.rpc('team_account_workspace', { const accountPromise = this.client.rpc('team_account_workspace', {
account_slug: slug, account_slug: slug,
}); });
const accountsPromise = this.client.from('user_accounts').select('*'); const accountsPromise = this.client
.from('accounts_memberships')
.select(`
account_id,
user_accounts (
id,
role,
name,
slug,
picture_url
)
`)
.eq('user_id', userId)
.eq('account_role', 'owner');
const [accountResult, accountsResult] = await Promise.all([ const [accountResult, accountsResult] = await Promise.all([
accountPromise, accountPromise,

View File

@@ -0,0 +1,24 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
/**
* @name isCompanyAdmin
* @description Check if the current user is a super admin.
* @param client
*/
export async function isCompanyAdmin(client: SupabaseClient<Database>, accountSlug: string) {
try {
const { data, error } = await client.rpc('is_company_admin', {
account_slug: accountSlug,
});
if (error) {
throw error;
}
return data;
} catch {
return false;
}
}

View File

@@ -839,6 +839,7 @@ export type Database = {
id: number id: number
invite_token: string invite_token: string
invited_by: string invited_by: string
personal_code: string | null
role: string role: string
updated_at: string updated_at: string
} }
@@ -850,6 +851,7 @@ export type Database = {
id?: number id?: number
invite_token: string invite_token: string
invited_by: string invited_by: string
personal_code?: string | null
role: string role: string
updated_at?: string updated_at?: string
} }
@@ -861,6 +863,7 @@ export type Database = {
id?: number id?: number
invite_token?: string invite_token?: string
invited_by?: string invited_by?: string
personal_code?: string | null
role?: string role?: string
updated_at?: string updated_at?: string
} }
@@ -1458,6 +1461,10 @@ export type Database = {
Args: { target_team_account_id: string; target_user_id: string } Args: { target_team_account_id: string; target_user_id: string }
Returns: boolean Returns: boolean
} }
check_personal_code_exists: {
Args: { code: string }
Returns: boolean
}
create_invitation: { create_invitation: {
Args: { account_id: string; email: string; role: string } Args: { account_id: string; email: string; role: string }
Returns: { Returns: {
@@ -1468,6 +1475,7 @@ export type Database = {
id: number id: number
invite_token: string invite_token: string
invited_by: string invited_by: string
personal_code: string | null
role: string role: string
updated_at: string updated_at: string
} }
@@ -1516,6 +1524,7 @@ export type Database = {
created_at: string created_at: string
updated_at: string updated_at: string
expires_at: string expires_at: string
personal_code: string
inviter_name: string inviter_name: string
inviter_email: string inviter_email: string
}[] }[]
@@ -1531,6 +1540,7 @@ export type Database = {
primary_owner_user_id: string primary_owner_user_id: string
name: string name: string
email: string email: string
personal_code: string
picture_url: string picture_url: string
created_at: string created_at: string
updated_at: string updated_at: string
@@ -1540,6 +1550,14 @@ export type Database = {
Args: Record<PropertyKey, never> Args: Record<PropertyKey, never>
Returns: Json Returns: Json
} }
get_invitations_with_account_ids: {
Args: { company_id: string; personal_codes: string[] }
Returns: {
invite_token: string
personal_code: string
account_id: string
}[]
}
get_nonce_status: { get_nonce_status: {
Args: { p_id: string } Args: { p_id: string }
Returns: Json Returns: Json
@@ -1596,6 +1614,10 @@ export type Database = {
Args: { target_account_id: string } Args: { target_account_id: string }
Returns: boolean Returns: boolean
} }
is_company_admin: {
Args: { account_slug: string }
Returns: boolean
}
is_mfa_compliant: { is_mfa_compliant: {
Args: Record<PropertyKey, never> Args: Record<PropertyKey, never>
Returns: boolean Returns: boolean
@@ -1748,6 +1770,7 @@ export type Database = {
invitation: { invitation: {
email: string | null email: string | null
role: string | null role: string | null
personal_code: string | null
} }
} }
} }

View File

@@ -3,6 +3,7 @@ import { useMutation } from '@tanstack/react-query';
import { useSupabase } from './use-supabase'; import { useSupabase } from './use-supabase';
interface Credentials { interface Credentials {
personalCode: string;
email: string; email: string;
password: string; password: string;
emailRedirectTo: string; emailRedirectTo: string;
@@ -14,13 +15,17 @@ export function useSignUpWithEmailAndPassword() {
const mutationKey = ['auth', 'sign-up-with-email-password']; const mutationKey = ['auth', 'sign-up-with-email-password'];
const mutationFn = async (params: Credentials) => { const mutationFn = async (params: Credentials) => {
const { emailRedirectTo, captchaToken, ...credentials } = params; const { emailRedirectTo, captchaToken, personalCode, ...credentials } = params;
// TODO?: should be a validation of unique personal code before registration
const response = await client.auth.signUp({ const response = await client.auth.signUp({
...credentials, ...credentials,
options: { options: {
emailRedirectTo, emailRedirectTo,
captchaToken, captchaToken,
data: {
personalCode
}
}, },
}); });

View File

@@ -83,7 +83,7 @@ function PageWithHeader(props: PageProps) {
> >
<div <div
className={cn( className={cn(
'bg-muted/40 dark:border-border dark:shadow-primary/10 flex h-14 items-center justify-between px-4 lg:justify-start lg:shadow-xs border-b', 'bg-bg-background border-1 light:border-border dark:border-border dark:shadow-primary/10 flex h-15 items-center justify-between px-4 py-1 lg:justify-start lg:shadow-xs border-b',
{ {
'sticky top-0 z-1000 backdrop-blur-md': props.sticky ?? true, 'sticky top-0 z-1000 backdrop-blur-md': props.sticky ?? true,
}, },

View File

@@ -18,7 +18,7 @@ type ProfileAvatarProps = (SessionProps | TextProps) & {
export function ProfileAvatar(props: ProfileAvatarProps) { export function ProfileAvatar(props: ProfileAvatarProps) {
const avatarClassName = cn( const avatarClassName = cn(
props.className, props.className,
'mx-auto h-9 w-9 group-focus:ring-2', 'mx-auto size-6 group-focus:ring-2',
); );
if ('text' in props) { if ('text' in props) {

View File

@@ -12,12 +12,12 @@
"cancelAtPeriodEndDescription": "Your subscription is scheduled to be canceled on {{- endDate }}.", "cancelAtPeriodEndDescription": "Your subscription is scheduled to be canceled on {{- endDate }}.",
"renewAtPeriodEndDescription": "Your subscription is scheduled to be renewed on {{- endDate }}", "renewAtPeriodEndDescription": "Your subscription is scheduled to be renewed on {{- endDate }}",
"noPermissionsAlertHeading": "You don't have permissions to change the billing settings", "noPermissionsAlertHeading": "You don't have permissions to change the billing settings",
"noPermissionsAlertBody": "Please contact your account owner to change the billing settings for your account.", "noPermissionsAlertBody": "Please contact your account admin to change the billing settings for your account.",
"checkoutSuccessTitle": "Done! You're all set.", "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 }}.", "checkoutSuccessDescription": "Thank you for subscribing, we have successfully processed your subscription! A confirmation email will be sent to {{ customerEmail }}.",
"checkoutSuccessBackButton": "Proceed to App", "checkoutSuccessBackButton": "Proceed to App",
"cannotManageBillingAlertTitle": "You cannot manage billing", "cannotManageBillingAlertTitle": "You cannot manage billing",
"cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your account owner.", "cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your account admin.",
"manageTeamPlan": "Manage your Company Plan", "manageTeamPlan": "Manage your Company Plan",
"manageTeamPlanDescription": "Choose a plan that fits your company's needs. You can upgrade or downgrade your plan at any time.", "manageTeamPlanDescription": "Choose a plan that fits your company's needs. You can upgrade or downgrade your plan at any time.",
"basePlan": "Base Plan", "basePlan": "Base Plan",
@@ -34,9 +34,9 @@
"redirectingToPayment": "Redirecting to checkout. Please wait...", "redirectingToPayment": "Redirecting to checkout. Please wait...",
"proceedToPayment": "Proceed to Payment", "proceedToPayment": "Proceed to Payment",
"startTrial": "Start Trial", "startTrial": "Start Trial",
"perTeamMember": "Per company employee", "perTeamMember": "Per company member",
"perUnit": "Per {{unit}} usage", "perUnit": "Per {{unit}} usage",
"teamMembers": "Company Employees", "teamMembers": "Company Members",
"includedUpTo": "Up to {{upTo}} {{unit}} included in the plan", "includedUpTo": "Up to {{upTo}} {{unit}} included in the plan",
"fromPreviousTierUpTo": "for each {{unit}} for the next {{ upTo }} {{ unit }}", "fromPreviousTierUpTo": "for each {{unit}} for the next {{ upTo }} {{ unit }}",
"andAbove": "above {{ previousTier }} {{ unit }}", "andAbove": "above {{ previousTier }} {{ unit }}",
@@ -116,5 +116,8 @@
"heading": "Your payment failed", "heading": "Your payment failed",
"description": "Your payment failed. Please update your payment method." "description": "Your payment failed. Please update your payment method."
} }
},
"cart": {
"label": "Cart ({{ items }})"
} }
} }

View File

@@ -1,8 +1,8 @@
{ {
"homeTabLabel": "Home", "homeTabLabel": "Home",
"homeTabDescription": "Welcome to your home page", "homeTabDescription": "Welcome to your home page",
"accountMembers": "Company Employees", "accountMembers": "Company Members",
"membersTabDescription": "Here you can manage the employees of your company.", "membersTabDescription": "Here you can manage the members of your company.",
"billingTabLabel": "Billing", "billingTabLabel": "Billing",
"billingTabDescription": "Manage your billing and subscription", "billingTabDescription": "Manage your billing and subscription",
"dashboardTabLabel": "Dashboard", "dashboardTabLabel": "Dashboard",
@@ -74,7 +74,7 @@
"orderAnalysis": "Order analysis", "orderAnalysis": "Order analysis",
"orderHealthAnalysis": "Telli terviseuuring", "orderHealthAnalysis": "Telli terviseuuring",
"account": "Account", "account": "Account",
"members": "Employees", "members": "Members",
"billing": "Billing", "billing": "Billing",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"settings": "Settings", "settings": "Settings",
@@ -83,10 +83,10 @@
}, },
"roles": { "roles": {
"owner": { "owner": {
"label": "Owner" "label": "Admin"
}, },
"member": { "member": {
"label": "Employee" "label": "Member"
} }
}, },
"otp": { "otp": {

View File

@@ -13,7 +13,7 @@
"dangerZoneDescription": "This section contains actions that are irreversible" "dangerZoneDescription": "This section contains actions that are irreversible"
}, },
"members": { "members": {
"pageTitle": "Employees" "pageTitle": "Members"
}, },
"billing": { "billing": {
"pageTitle": "Billing" "pageTitle": "Billing"
@@ -23,17 +23,17 @@
"creatingTeam": "Creating Company...", "creatingTeam": "Creating Company...",
"personalAccount": "Personal Account", "personalAccount": "Personal Account",
"searchAccount": "Search Account...", "searchAccount": "Search Account...",
"membersTabLabel": "Employees", "membersTabLabel": "Members",
"memberName": "Name", "memberName": "Name",
"youLabel": "You", "youLabel": "You",
"emailLabel": "Email", "emailLabel": "Email",
"roleLabel": "Role", "roleLabel": "Role",
"primaryOwnerLabel": "Primary Owner", "primaryOwnerLabel": "Primary Admin",
"joinedAtLabel": "Joined at", "joinedAtLabel": "Joined at",
"invitedAtLabel": "Invited at", "invitedAtLabel": "Invited at",
"inviteMembersPageSubheading": "Invite employees to your Company", "inviteMembersPageSubheading": "Invite members to your Company",
"createTeamModalHeading": "Create Company", "createTeamModalHeading": "Create Company",
"createTeamModalDescription": "Create a new Company to manage your projects and employees.", "createTeamModalDescription": "Create a new Company to manage your projects and members.",
"teamNameLabel": "Company Name", "teamNameLabel": "Company Name",
"teamNameDescription": "Your company name should be unique and descriptive", "teamNameDescription": "Your company name should be unique and descriptive",
"createTeamSubmitLabel": "Create Company", "createTeamSubmitLabel": "Create Company",
@@ -44,34 +44,34 @@
"createTeamDropdownLabel": "New company", "createTeamDropdownLabel": "New company",
"changeRole": "Change Role", "changeRole": "Change Role",
"removeMember": "Remove from Account", "removeMember": "Remove from Account",
"inviteMembersSuccess": "Employees invited successfully!", "inviteMembersSuccess": "Members invited successfully!",
"inviteMembersError": "Sorry, we encountered an error! Please try again", "inviteMembersError": "Sorry, we encountered an error! Please try again",
"inviteMembersLoading": "Inviting employees...", "inviteMembersLoading": "Inviting members...",
"removeInviteButtonLabel": "Remove invite", "removeInviteButtonLabel": "Remove invite",
"addAnotherMemberButtonLabel": "Add another one", "addAnotherMemberButtonLabel": "Add another one",
"inviteMembersButtonLabel": "Send Invites", "inviteMembersButtonLabel": "Send Invites",
"removeMemberModalHeading": "You are removing this user", "removeMemberModalHeading": "You are removing this user",
"removeMemberModalDescription": "Remove this employee from the company. They will no longer have access to the company.", "removeMemberModalDescription": "Remove this member from the company. They will no longer have access to the company.",
"removeMemberSuccessMessage": "Employee removed successfully", "removeMemberSuccessMessage": "Member removed successfully",
"removeMemberErrorMessage": "Sorry, we encountered an error. Please try again", "removeMemberErrorMessage": "Sorry, we encountered an error. Please try again",
"removeMemberErrorHeading": "Sorry, we couldn't remove the selected employee.", "removeMemberErrorHeading": "Sorry, we couldn't remove the selected member.",
"removeMemberLoadingMessage": "Removing employee...", "removeMemberLoadingMessage": "Removing member...",
"removeMemberSubmitLabel": "Remove User from Company", "removeMemberSubmitLabel": "Remove User from Company",
"chooseDifferentRoleError": "Role is the same as the current one", "chooseDifferentRoleError": "Role is the same as the current one",
"updateRole": "Update Role", "updateRole": "Update Role",
"updateRoleLoadingMessage": "Updating role...", "updateRoleLoadingMessage": "Updating role...",
"updateRoleSuccessMessage": "Role updated successfully", "updateRoleSuccessMessage": "Role updated successfully",
"updatingRoleErrorMessage": "Sorry, we encountered an error. Please try again.", "updatingRoleErrorMessage": "Sorry, we encountered an error. Please try again.",
"updateMemberRoleModalHeading": "Update Employee's Role", "updateMemberRoleModalHeading": "Update Member's Role",
"updateMemberRoleModalDescription": "Change the role of the selected member. The role determines the permissions of the member.", "updateMemberRoleModalDescription": "Change the role of the selected member. The role determines the permissions of the member.",
"roleMustBeDifferent": "Role must be different from the current one", "roleMustBeDifferent": "Role must be different from the current one",
"memberRoleInputLabel": "Member role", "memberRoleInputLabel": "Member role",
"updateRoleDescription": "Pick a role for this member.", "updateRoleDescription": "Pick a role for this member.",
"updateRoleSubmitLabel": "Update Role", "updateRoleSubmitLabel": "Update Role",
"transferOwnership": "Transfer Ownership", "transferOwnership": "Transfer Ownership",
"transferOwnershipDescription": "Transfer ownership of the company account to another employee.", "transferOwnershipDescription": "Transfer ownership of the company account to another member.",
"transferOwnershipInputLabel": "Please type TRANSFER to confirm the transfer of ownership.", "transferOwnershipInputLabel": "Please type TRANSFER to confirm the transfer of ownership.",
"transferOwnershipInputDescription": "By transferring ownership, you will no longer be the primary owner of the company account.", "transferOwnershipInputDescription": "By transferring ownership, you will no longer be the primary admin of the company account.",
"deleteInvitation": "Delete Invitation", "deleteInvitation": "Delete Invitation",
"deleteInvitationDialogDescription": "You are about to delete the invitation. The user will no longer be able to join the company account.", "deleteInvitationDialogDescription": "You are about to delete the invitation. The user will no longer be able to join the company account.",
"deleteInviteSuccessMessage": "Invite deleted successfully", "deleteInviteSuccessMessage": "Invite deleted successfully",
@@ -92,21 +92,21 @@
"teamLogoInputHeading": "Upload your company's Logo", "teamLogoInputHeading": "Upload your company's Logo",
"teamLogoInputSubheading": "Please choose a photo to upload as your company logo.", "teamLogoInputSubheading": "Please choose a photo to upload as your company logo.",
"updateTeamSubmitLabel": "Update Company", "updateTeamSubmitLabel": "Update Company",
"inviteMembersHeading": "Invite Employees to your Company", "inviteMembersHeading": "Invite Members to your Company",
"inviteMembersDescription": "Invite employees to your company by entering their email and role.", "inviteMembersDescription": "Invite member to your company by entering their email and role.",
"emailPlaceholder": "employee@email.com", "emailPlaceholder": "member@email.com",
"membersPageHeading": "Employees", "membersPageHeading": "Members",
"inviteMembersButton": "Invite Employees", "inviteMembersButton": "Invite Members",
"invitingMembers": "Inviting employees...", "invitingMembers": "Inviting members...",
"inviteMembersSuccessMessage": "Employees invited successfully", "inviteMembersSuccessMessage": "Members invited successfully",
"inviteMembersErrorMessage": "Sorry, employees could not be invited. Please try again.", "inviteMembersErrorMessage": "Sorry, members could not be invited. Please try again.",
"pendingInvitesHeading": "Pending Invites", "pendingInvitesHeading": "Pending Invites",
"pendingInvitesDescription": " Here you can manage the pending invitations to your company.", "pendingInvitesDescription": " Here you can manage the pending invitations to your company.",
"noPendingInvites": "No pending invites found", "noPendingInvites": "No pending invites found",
"loadingMembers": "Loading employees...", "loadingMembers": "Loading members...",
"loadMembersError": "Sorry, we couldn't fetch your company's employees.", "loadMembersError": "Sorry, we couldn't fetch your company's members.",
"loadInvitedMembersError": "Sorry, we couldn't fetch your company's invited employees.", "loadInvitedMembersError": "Sorry, we couldn't fetch your company's invited members.",
"loadingInvitedMembers": "Loading invited employees...", "loadingInvitedMembers": "Loading invited members...",
"invitedBadge": "Invited", "invitedBadge": "Invited",
"duplicateInviteEmailError": "You have already entered this email address", "duplicateInviteEmailError": "You have already entered this email address",
"invitingOwnAccountError": "Hey, that's your email!", "invitingOwnAccountError": "Hey, that's your email!",
@@ -126,13 +126,13 @@
"leaveTeamDisclaimer": "You are leaving the company {{ teamName }}. You will no longer have access to it.", "leaveTeamDisclaimer": "You are leaving the company {{ teamName }}. You will no longer have access to it.",
"deleteTeamErrorHeading": "Sorry, we couldn't delete your company.", "deleteTeamErrorHeading": "Sorry, we couldn't delete your company.",
"leaveTeamErrorHeading": "Sorry, we couldn't leave your company.", "leaveTeamErrorHeading": "Sorry, we couldn't leave your company.",
"searchMembersPlaceholder": "Search employees", "searchMembersPlaceholder": "Search members",
"createTeamErrorHeading": "Sorry, we couldn't create your company.", "createTeamErrorHeading": "Sorry, we couldn't create your company.",
"createTeamErrorMessage": "We encountered an error creating your company. Please try again.", "createTeamErrorMessage": "We encountered an error creating your company. Please try again.",
"transferTeamErrorHeading": "Sorry, we couldn't transfer ownership of your company account.", "transferTeamErrorHeading": "Sorry, we couldn't transfer ownership of your company account.",
"transferTeamErrorMessage": "We encountered an error transferring ownership of your company account. Please try again.", "transferTeamErrorMessage": "We encountered an error transferring ownership of your company account. Please try again.",
"updateRoleErrorHeading": "Sorry, we couldn't update the role of the selected employee.", "updateRoleErrorHeading": "Sorry, we couldn't update the role of the selected member.",
"updateRoleErrorMessage": "We encountered an error updating the role of the selected employee. Please try again.", "updateRoleErrorMessage": "We encountered an error updating the role of the selected member. Please try again.",
"searchInvitations": "Search Invitations", "searchInvitations": "Search Invitations",
"updateInvitation": "Update Invitation", "updateInvitation": "Update Invitation",
"removeInvitation": "Remove Invitation", "removeInvitation": "Remove Invitation",
@@ -144,7 +144,7 @@
"active": "Active", "active": "Active",
"inviteStatus": "Status", "inviteStatus": "Status",
"inviteNotFoundOrExpired": "Invite not found or expired", "inviteNotFoundOrExpired": "Invite not found or expired",
"inviteNotFoundOrExpiredDescription": "The invite you are looking for is either expired or does not exist. Please contact the company HR to renew the invite.", "inviteNotFoundOrExpiredDescription": "The invite you are looking for is either expired or does not exist. Please contact the company admin to renew the invite.",
"backToHome": "Back to Home", "backToHome": "Back to Home",
"renewInvitationDialogDescription": "You are about to renew the invitation to {{ email }}. The user will be able to join the company.", "renewInvitationDialogDescription": "You are about to renew the invitation to {{ email }}. The user will be able to join the company.",
"renewInvitationErrorTitle": "Sorry, we couldn't renew the invitation.", "renewInvitationErrorTitle": "Sorry, we couldn't renew the invitation.",
@@ -159,5 +159,6 @@
"leaveTeamInputLabel": "Please type LEAVE to confirm leaving the company.", "leaveTeamInputLabel": "Please type LEAVE to confirm leaving the company.",
"leaveTeamInputDescription": "By leaving the company, you will no longer have access to it.", "leaveTeamInputDescription": "By leaving the company, you will no longer have access to it.",
"reservedNameError": "This name is reserved. Please choose a different one.", "reservedNameError": "This name is reserved. Please choose a different one.",
"specialCharactersError": "This name cannot contain special characters. Please choose a different one." "specialCharactersError": "This name cannot contain special characters. Please choose a different one.",
"personalCode": "Personal Code"
} }

View File

@@ -12,12 +12,12 @@
"cancelAtPeriodEndDescription": "Your subscription is scheduled to be canceled on {{- endDate }}.", "cancelAtPeriodEndDescription": "Your subscription is scheduled to be canceled on {{- endDate }}.",
"renewAtPeriodEndDescription": "Your subscription is scheduled to be renewed on {{- endDate }}", "renewAtPeriodEndDescription": "Your subscription is scheduled to be renewed on {{- endDate }}",
"noPermissionsAlertHeading": "You don't have permissions to change the billing settings", "noPermissionsAlertHeading": "You don't have permissions to change the billing settings",
"noPermissionsAlertBody": "Please contact your account owner to change the billing settings for your account.", "noPermissionsAlertBody": "Please contact your account admin to change the billing settings for your account.",
"checkoutSuccessTitle": "Done! You're all set.", "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 }}.", "checkoutSuccessDescription": "Thank you for subscribing, we have successfully processed your subscription! A confirmation email will be sent to {{ customerEmail }}.",
"checkoutSuccessBackButton": "Proceed to App", "checkoutSuccessBackButton": "Proceed to App",
"cannotManageBillingAlertTitle": "You cannot manage billing", "cannotManageBillingAlertTitle": "You cannot manage billing",
"cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your account owner.", "cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your account admin.",
"manageTeamPlan": "Manage your Company Plan", "manageTeamPlan": "Manage your Company Plan",
"manageTeamPlanDescription": "Choose a plan that fits your company's needs. You can upgrade or downgrade your plan at any time.", "manageTeamPlanDescription": "Choose a plan that fits your company's needs. You can upgrade or downgrade your plan at any time.",
"basePlan": "Base Plan", "basePlan": "Base Plan",
@@ -34,9 +34,9 @@
"redirectingToPayment": "Redirecting to checkout. Please wait...", "redirectingToPayment": "Redirecting to checkout. Please wait...",
"proceedToPayment": "Proceed to Payment", "proceedToPayment": "Proceed to Payment",
"startTrial": "Start Trial", "startTrial": "Start Trial",
"perTeamMember": "Per company employee", "perTeamMember": "Per company member",
"perUnit": "Per {{unit}} usage", "perUnit": "Per {{unit}} usage",
"teamMembers": "Company Employees", "teamMembers": "Company Members",
"includedUpTo": "Up to {{upTo}} {{unit}} included in the plan", "includedUpTo": "Up to {{upTo}} {{unit}} included in the plan",
"fromPreviousTierUpTo": "for each {{unit}} for the next {{ upTo }} {{ unit }}", "fromPreviousTierUpTo": "for each {{unit}} for the next {{ upTo }} {{ unit }}",
"andAbove": "above {{ previousTier }} {{ unit }}", "andAbove": "above {{ previousTier }} {{ unit }}",
@@ -116,5 +116,8 @@
"heading": "Your payment failed", "heading": "Your payment failed",
"description": "Your payment failed. Please update your payment method." "description": "Your payment failed. Please update your payment method."
} }
},
"cart": {
"label": "Cart ({{ items }})"
} }
} }

View File

@@ -1,8 +1,8 @@
{ {
"homeTabLabel": "Home", "homeTabLabel": "Home",
"homeTabDescription": "Welcome to your home page", "homeTabDescription": "Welcome to your home page",
"accountMembers": "Company Employees", "accountMembers": "Company Members",
"membersTabDescription": "Here you can manage the employees of your company.", "membersTabDescription": "Here you can manage the members of your company.",
"billingTabLabel": "Billing", "billingTabLabel": "Billing",
"billingTabDescription": "Manage your billing and subscription", "billingTabDescription": "Manage your billing and subscription",
"dashboardTabLabel": "Dashboard", "dashboardTabLabel": "Dashboard",
@@ -74,7 +74,7 @@
"orderAnalysis": "Telli analüüs", "orderAnalysis": "Telli analüüs",
"orderHealthAnalysis": "Telli terviseuuring", "orderHealthAnalysis": "Telli terviseuuring",
"account": "Account", "account": "Account",
"members": "Employees", "members": "Members",
"billing": "Billing", "billing": "Billing",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"settings": "Settings", "settings": "Settings",
@@ -83,10 +83,10 @@
}, },
"roles": { "roles": {
"owner": { "owner": {
"label": "Owner" "label": "Admin"
}, },
"member": { "member": {
"label": "Employee" "label": "Member"
} }
}, },
"otp": { "otp": {

View File

@@ -13,7 +13,7 @@
"dangerZoneDescription": "This section contains actions that are irreversible" "dangerZoneDescription": "This section contains actions that are irreversible"
}, },
"members": { "members": {
"pageTitle": "Employees" "pageTitle": "Members"
}, },
"billing": { "billing": {
"pageTitle": "Billing" "pageTitle": "Billing"
@@ -23,17 +23,17 @@
"creatingTeam": "Creating Company...", "creatingTeam": "Creating Company...",
"personalAccount": "Personal Account", "personalAccount": "Personal Account",
"searchAccount": "Search Account...", "searchAccount": "Search Account...",
"membersTabLabel": "Employees", "membersTabLabel": "Members",
"memberName": "Name", "memberName": "Name",
"youLabel": "You", "youLabel": "You",
"emailLabel": "Email", "emailLabel": "Email",
"roleLabel": "Role", "roleLabel": "Role",
"primaryOwnerLabel": "Primary Owner", "primaryOwnerLabel": "Primary Admin",
"joinedAtLabel": "Joined at", "joinedAtLabel": "Joined at",
"invitedAtLabel": "Invited at", "invitedAtLabel": "Invited at",
"inviteMembersPageSubheading": "Invite employees to your Company", "inviteMembersPageSubheading": "Invite members to your Company",
"createTeamModalHeading": "Create Company", "createTeamModalHeading": "Create Company",
"createTeamModalDescription": "Create a new Company to manage your projects and employees.", "createTeamModalDescription": "Create a new Company to manage your projects and members.",
"teamNameLabel": "Company Name", "teamNameLabel": "Company Name",
"teamNameDescription": "Your company name should be unique and descriptive", "teamNameDescription": "Your company name should be unique and descriptive",
"createTeamSubmitLabel": "Create Company", "createTeamSubmitLabel": "Create Company",
@@ -44,34 +44,34 @@
"createTeamDropdownLabel": "New company", "createTeamDropdownLabel": "New company",
"changeRole": "Change Role", "changeRole": "Change Role",
"removeMember": "Remove from Account", "removeMember": "Remove from Account",
"inviteMembersSuccess": "Employees invited successfully!", "inviteMembersSuccess": "Members invited successfully!",
"inviteMembersError": "Sorry, we encountered an error! Please try again", "inviteMembersError": "Sorry, we encountered an error! Please try again",
"inviteMembersLoading": "Inviting employees...", "inviteMembersLoading": "Inviting members...",
"removeInviteButtonLabel": "Remove invite", "removeInviteButtonLabel": "Remove invite",
"addAnotherMemberButtonLabel": "Add another one", "addAnotherMemberButtonLabel": "Add another one",
"inviteMembersButtonLabel": "Send Invites", "inviteMembersButtonLabel": "Send Invites",
"removeMemberModalHeading": "You are removing this user", "removeMemberModalHeading": "You are removing this user",
"removeMemberModalDescription": "Remove this employee from the company. They will no longer have access to the company.", "removeMemberModalDescription": "Remove this member from the company. They will no longer have access to the company.",
"removeMemberSuccessMessage": "Employee removed successfully", "removeMemberSuccessMessage": "Member removed successfully",
"removeMemberErrorMessage": "Sorry, we encountered an error. Please try again", "removeMemberErrorMessage": "Sorry, we encountered an error. Please try again",
"removeMemberErrorHeading": "Sorry, we couldn't remove the selected employee.", "removeMemberErrorHeading": "Sorry, we couldn't remove the selected member.",
"removeMemberLoadingMessage": "Removing employee...", "removeMemberLoadingMessage": "Removing member...",
"removeMemberSubmitLabel": "Remove User from Company", "removeMemberSubmitLabel": "Remove User from Company",
"chooseDifferentRoleError": "Role is the same as the current one", "chooseDifferentRoleError": "Role is the same as the current one",
"updateRole": "Update Role", "updateRole": "Update Role",
"updateRoleLoadingMessage": "Updating role...", "updateRoleLoadingMessage": "Updating role...",
"updateRoleSuccessMessage": "Role updated successfully", "updateRoleSuccessMessage": "Role updated successfully",
"updatingRoleErrorMessage": "Sorry, we encountered an error. Please try again.", "updatingRoleErrorMessage": "Sorry, we encountered an error. Please try again.",
"updateMemberRoleModalHeading": "Update Employee's Role", "updateMemberRoleModalHeading": "Update Member's Role",
"updateMemberRoleModalDescription": "Change the role of the selected member. The role determines the permissions of the member.", "updateMemberRoleModalDescription": "Change the role of the selected member. The role determines the permissions of the member.",
"roleMustBeDifferent": "Role must be different from the current one", "roleMustBeDifferent": "Role must be different from the current one",
"memberRoleInputLabel": "Member role", "memberRoleInputLabel": "Member role",
"updateRoleDescription": "Pick a role for this member.", "updateRoleDescription": "Pick a role for this member.",
"updateRoleSubmitLabel": "Update Role", "updateRoleSubmitLabel": "Update Role",
"transferOwnership": "Transfer Ownership", "transferOwnership": "Transfer Ownership",
"transferOwnershipDescription": "Transfer ownership of the company account to another employee.", "transferOwnershipDescription": "Transfer ownership of the company account to another member.",
"transferOwnershipInputLabel": "Please type TRANSFER to confirm the transfer of ownership.", "transferOwnershipInputLabel": "Please type TRANSFER to confirm the transfer of ownership.",
"transferOwnershipInputDescription": "By transferring ownership, you will no longer be the primary owner of the company account.", "transferOwnershipInputDescription": "By transferring ownership, you will no longer be the primary admin of the company account.",
"deleteInvitation": "Delete Invitation", "deleteInvitation": "Delete Invitation",
"deleteInvitationDialogDescription": "You are about to delete the invitation. The user will no longer be able to join the company account.", "deleteInvitationDialogDescription": "You are about to delete the invitation. The user will no longer be able to join the company account.",
"deleteInviteSuccessMessage": "Invite deleted successfully", "deleteInviteSuccessMessage": "Invite deleted successfully",
@@ -92,21 +92,21 @@
"teamLogoInputHeading": "Upload your company's Logo", "teamLogoInputHeading": "Upload your company's Logo",
"teamLogoInputSubheading": "Please choose a photo to upload as your company logo.", "teamLogoInputSubheading": "Please choose a photo to upload as your company logo.",
"updateTeamSubmitLabel": "Update Company", "updateTeamSubmitLabel": "Update Company",
"inviteMembersHeading": "Invite Employees to your Company", "inviteMembersHeading": "Invite Members to your Company",
"inviteMembersDescription": "Invite employees to your company by entering their email and role.", "inviteMembersDescription": "Invite member to your company by entering their email and role.",
"emailPlaceholder": "employee@email.com", "emailPlaceholder": "member@email.com",
"membersPageHeading": "Employees", "membersPageHeading": "Members",
"inviteMembersButton": "Invite Employees", "inviteMembersButton": "Invite Members",
"invitingMembers": "Inviting employees...", "invitingMembers": "Inviting members...",
"inviteMembersSuccessMessage": "Employees invited successfully", "inviteMembersSuccessMessage": "Members invited successfully",
"inviteMembersErrorMessage": "Sorry, employees could not be invited. Please try again.", "inviteMembersErrorMessage": "Sorry, members could not be invited. Please try again.",
"pendingInvitesHeading": "Pending Invites", "pendingInvitesHeading": "Pending Invites",
"pendingInvitesDescription": " Here you can manage the pending invitations to your company.", "pendingInvitesDescription": " Here you can manage the pending invitations to your company.",
"noPendingInvites": "No pending invites found", "noPendingInvites": "No pending invites found",
"loadingMembers": "Loading employees...", "loadingMembers": "Loading members...",
"loadMembersError": "Sorry, we couldn't fetch your company's employees.", "loadMembersError": "Sorry, we couldn't fetch your company's members.",
"loadInvitedMembersError": "Sorry, we couldn't fetch your company's invited employees.", "loadInvitedMembersError": "Sorry, we couldn't fetch your company's invited members.",
"loadingInvitedMembers": "Loading invited employees...", "loadingInvitedMembers": "Loading invited members...",
"invitedBadge": "Invited", "invitedBadge": "Invited",
"duplicateInviteEmailError": "You have already entered this email address", "duplicateInviteEmailError": "You have already entered this email address",
"invitingOwnAccountError": "Hey, that's your email!", "invitingOwnAccountError": "Hey, that's your email!",
@@ -126,13 +126,13 @@
"leaveTeamDisclaimer": "You are leaving the company {{ teamName }}. You will no longer have access to it.", "leaveTeamDisclaimer": "You are leaving the company {{ teamName }}. You will no longer have access to it.",
"deleteTeamErrorHeading": "Sorry, we couldn't delete your company.", "deleteTeamErrorHeading": "Sorry, we couldn't delete your company.",
"leaveTeamErrorHeading": "Sorry, we couldn't leave your company.", "leaveTeamErrorHeading": "Sorry, we couldn't leave your company.",
"searchMembersPlaceholder": "Search employees", "searchMembersPlaceholder": "Search members",
"createTeamErrorHeading": "Sorry, we couldn't create your company.", "createTeamErrorHeading": "Sorry, we couldn't create your company.",
"createTeamErrorMessage": "We encountered an error creating your company. Please try again.", "createTeamErrorMessage": "We encountered an error creating your company. Please try again.",
"transferTeamErrorHeading": "Sorry, we couldn't transfer ownership of your company account.", "transferTeamErrorHeading": "Sorry, we couldn't transfer ownership of your company account.",
"transferTeamErrorMessage": "We encountered an error transferring ownership of your company account. Please try again.", "transferTeamErrorMessage": "We encountered an error transferring ownership of your company account. Please try again.",
"updateRoleErrorHeading": "Sorry, we couldn't update the role of the selected employee.", "updateRoleErrorHeading": "Sorry, we couldn't update the role of the selected member.",
"updateRoleErrorMessage": "We encountered an error updating the role of the selected employee. Please try again.", "updateRoleErrorMessage": "We encountered an error updating the role of the selected member. Please try again.",
"searchInvitations": "Search Invitations", "searchInvitations": "Search Invitations",
"updateInvitation": "Update Invitation", "updateInvitation": "Update Invitation",
"removeInvitation": "Remove Invitation", "removeInvitation": "Remove Invitation",
@@ -144,7 +144,7 @@
"active": "Active", "active": "Active",
"inviteStatus": "Status", "inviteStatus": "Status",
"inviteNotFoundOrExpired": "Invite not found or expired", "inviteNotFoundOrExpired": "Invite not found or expired",
"inviteNotFoundOrExpiredDescription": "The invite you are looking for is either expired or does not exist. Please contact the company HR to renew the invite.", "inviteNotFoundOrExpiredDescription": "The invite you are looking for is either expired or does not exist. Please contact the company admin to renew the invite.",
"backToHome": "Back to Home", "backToHome": "Back to Home",
"renewInvitationDialogDescription": "You are about to renew the invitation to {{ email }}. The user will be able to join the company.", "renewInvitationDialogDescription": "You are about to renew the invitation to {{ email }}. The user will be able to join the company.",
"renewInvitationErrorTitle": "Sorry, we couldn't renew the invitation.", "renewInvitationErrorTitle": "Sorry, we couldn't renew the invitation.",
@@ -159,5 +159,6 @@
"leaveTeamInputLabel": "Please type LEAVE to confirm leaving the company.", "leaveTeamInputLabel": "Please type LEAVE to confirm leaving the company.",
"leaveTeamInputDescription": "By leaving the company, you will no longer have access to it.", "leaveTeamInputDescription": "By leaving the company, you will no longer have access to it.",
"reservedNameError": "This name is reserved. Please choose a different one.", "reservedNameError": "This name is reserved. Please choose a different one.",
"specialCharactersError": "This name cannot contain special characters. Please choose a different one." "specialCharactersError": "This name cannot contain special characters. Please choose a different one.",
"personalCode": "Isikukood"
} }

View File

@@ -12,12 +12,12 @@
"cancelAtPeriodEndDescription": "Your subscription is scheduled to be canceled on {{- endDate }}.", "cancelAtPeriodEndDescription": "Your subscription is scheduled to be canceled on {{- endDate }}.",
"renewAtPeriodEndDescription": "Your subscription is scheduled to be renewed on {{- endDate }}", "renewAtPeriodEndDescription": "Your subscription is scheduled to be renewed on {{- endDate }}",
"noPermissionsAlertHeading": "You don't have permissions to change the billing settings", "noPermissionsAlertHeading": "You don't have permissions to change the billing settings",
"noPermissionsAlertBody": "Please contact your account owner to change the billing settings for your account.", "noPermissionsAlertBody": "Please contact your account admin to change the billing settings for your account.",
"checkoutSuccessTitle": "Done! You're all set.", "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 }}.", "checkoutSuccessDescription": "Thank you for subscribing, we have successfully processed your subscription! A confirmation email will be sent to {{ customerEmail }}.",
"checkoutSuccessBackButton": "Proceed to App", "checkoutSuccessBackButton": "Proceed to App",
"cannotManageBillingAlertTitle": "You cannot manage billing", "cannotManageBillingAlertTitle": "You cannot manage billing",
"cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your account owner.", "cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your account admin.",
"manageTeamPlan": "Manage your Company Plan", "manageTeamPlan": "Manage your Company Plan",
"manageTeamPlanDescription": "Choose a plan that fits your company's needs. You can upgrade or downgrade your plan at any time.", "manageTeamPlanDescription": "Choose a plan that fits your company's needs. You can upgrade or downgrade your plan at any time.",
"basePlan": "Base Plan", "basePlan": "Base Plan",
@@ -34,9 +34,9 @@
"redirectingToPayment": "Redirecting to checkout. Please wait...", "redirectingToPayment": "Redirecting to checkout. Please wait...",
"proceedToPayment": "Proceed to Payment", "proceedToPayment": "Proceed to Payment",
"startTrial": "Start Trial", "startTrial": "Start Trial",
"perTeamMember": "Per company employee", "perTeamMember": "Per company member",
"perUnit": "Per {{unit}} usage", "perUnit": "Per {{unit}} usage",
"teamMembers": "Company Employees", "teamMembers": "Company Members",
"includedUpTo": "Up to {{upTo}} {{unit}} included in the plan", "includedUpTo": "Up to {{upTo}} {{unit}} included in the plan",
"fromPreviousTierUpTo": "for each {{unit}} for the next {{ upTo }} {{ unit }}", "fromPreviousTierUpTo": "for each {{unit}} for the next {{ upTo }} {{ unit }}",
"andAbove": "above {{ previousTier }} {{ unit }}", "andAbove": "above {{ previousTier }} {{ unit }}",
@@ -116,5 +116,8 @@
"heading": "Your payment failed", "heading": "Your payment failed",
"description": "Your payment failed. Please update your payment method." "description": "Your payment failed. Please update your payment method."
} }
},
"cart": {
"label": "Cart ({{ items }})"
} }
} }

View File

@@ -1,8 +1,8 @@
{ {
"homeTabLabel": "Home", "homeTabLabel": "Home",
"homeTabDescription": "Welcome to your home page", "homeTabDescription": "Welcome to your home page",
"accountMembers": "Company Employees", "accountMembers": "Company Members",
"membersTabDescription": "Here you can manage the employees of your company.", "membersTabDescription": "Here you can manage the members of your company.",
"billingTabLabel": "Billing", "billingTabLabel": "Billing",
"billingTabDescription": "Manage your billing and subscription", "billingTabDescription": "Manage your billing and subscription",
"dashboardTabLabel": "Dashboard", "dashboardTabLabel": "Dashboard",
@@ -73,7 +73,7 @@
"orderAnalysisPackage": "Order analysis package", "orderAnalysisPackage": "Order analysis package",
"orderHealthAnalysis": "Order health analysis", "orderHealthAnalysis": "Order health analysis",
"account": "Account", "account": "Account",
"members": "Employees", "members": "Members",
"billing": "Billing", "billing": "Billing",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"settings": "Settings", "settings": "Settings",
@@ -82,10 +82,10 @@
}, },
"roles": { "roles": {
"owner": { "owner": {
"label": "Owner" "label": "Admin"
}, },
"member": { "member": {
"label": "Employee" "label": "Member"
} }
}, },
"otp": { "otp": {

View File

@@ -13,7 +13,7 @@
"dangerZoneDescription": "This section contains actions that are irreversible" "dangerZoneDescription": "This section contains actions that are irreversible"
}, },
"members": { "members": {
"pageTitle": "Employees" "pageTitle": "Members"
}, },
"billing": { "billing": {
"pageTitle": "Billing" "pageTitle": "Billing"
@@ -23,17 +23,17 @@
"creatingTeam": "Creating Company...", "creatingTeam": "Creating Company...",
"personalAccount": "Personal Account", "personalAccount": "Personal Account",
"searchAccount": "Search Account...", "searchAccount": "Search Account...",
"membersTabLabel": "Employees", "membersTabLabel": "Members",
"memberName": "Name", "memberName": "Name",
"youLabel": "You", "youLabel": "You",
"emailLabel": "Email", "emailLabel": "Email",
"roleLabel": "Role", "roleLabel": "Role",
"primaryOwnerLabel": "Primary Owner", "primaryOwnerLabel": "Primary Admin",
"joinedAtLabel": "Joined at", "joinedAtLabel": "Joined at",
"invitedAtLabel": "Invited at", "invitedAtLabel": "Invited at",
"inviteMembersPageSubheading": "Invite employees to your Company", "inviteMembersPageSubheading": "Invite members to your Company",
"createTeamModalHeading": "Create Company", "createTeamModalHeading": "Create Company",
"createTeamModalDescription": "Create a new Company to manage your projects and employees.", "createTeamModalDescription": "Create a new Company to manage your projects and members.",
"teamNameLabel": "Company Name", "teamNameLabel": "Company Name",
"teamNameDescription": "Your company name should be unique and descriptive", "teamNameDescription": "Your company name should be unique and descriptive",
"createTeamSubmitLabel": "Create Company", "createTeamSubmitLabel": "Create Company",
@@ -44,34 +44,34 @@
"createTeamDropdownLabel": "New company", "createTeamDropdownLabel": "New company",
"changeRole": "Change Role", "changeRole": "Change Role",
"removeMember": "Remove from Account", "removeMember": "Remove from Account",
"inviteMembersSuccess": "Employees invited successfully!", "inviteMembersSuccess": "Members invited successfully!",
"inviteMembersError": "Sorry, we encountered an error! Please try again", "inviteMembersError": "Sorry, we encountered an error! Please try again",
"inviteMembersLoading": "Inviting employees...", "inviteMembersLoading": "Inviting members...",
"removeInviteButtonLabel": "Remove invite", "removeInviteButtonLabel": "Remove invite",
"addAnotherMemberButtonLabel": "Add another one", "addAnotherMemberButtonLabel": "Add another one",
"inviteMembersButtonLabel": "Send Invites", "inviteMembersButtonLabel": "Send Invites",
"removeMemberModalHeading": "You are removing this user", "removeMemberModalHeading": "You are removing this user",
"removeMemberModalDescription": "Remove this employee from the company. They will no longer have access to the company.", "removeMemberModalDescription": "Remove this member from the company. They will no longer have access to the company.",
"removeMemberSuccessMessage": "Employee removed successfully", "removeMemberSuccessMessage": "Member removed successfully",
"removeMemberErrorMessage": "Sorry, we encountered an error. Please try again", "removeMemberErrorMessage": "Sorry, we encountered an error. Please try again",
"removeMemberErrorHeading": "Sorry, we couldn't remove the selected employee.", "removeMemberErrorHeading": "Sorry, we couldn't remove the selected member.",
"removeMemberLoadingMessage": "Removing employee...", "removeMemberLoadingMessage": "Removing member...",
"removeMemberSubmitLabel": "Remove User from Company", "removeMemberSubmitLabel": "Remove User from Company",
"chooseDifferentRoleError": "Role is the same as the current one", "chooseDifferentRoleError": "Role is the same as the current one",
"updateRole": "Update Role", "updateRole": "Update Role",
"updateRoleLoadingMessage": "Updating role...", "updateRoleLoadingMessage": "Updating role...",
"updateRoleSuccessMessage": "Role updated successfully", "updateRoleSuccessMessage": "Role updated successfully",
"updatingRoleErrorMessage": "Sorry, we encountered an error. Please try again.", "updatingRoleErrorMessage": "Sorry, we encountered an error. Please try again.",
"updateMemberRoleModalHeading": "Update Employee's Role", "updateMemberRoleModalHeading": "Update Member's Role",
"updateMemberRoleModalDescription": "Change the role of the selected member. The role determines the permissions of the member.", "updateMemberRoleModalDescription": "Change the role of the selected member. The role determines the permissions of the member.",
"roleMustBeDifferent": "Role must be different from the current one", "roleMustBeDifferent": "Role must be different from the current one",
"memberRoleInputLabel": "Member role", "memberRoleInputLabel": "Member role",
"updateRoleDescription": "Pick a role for this member.", "updateRoleDescription": "Pick a role for this member.",
"updateRoleSubmitLabel": "Update Role", "updateRoleSubmitLabel": "Update Role",
"transferOwnership": "Transfer Ownership", "transferOwnership": "Transfer Ownership",
"transferOwnershipDescription": "Transfer ownership of the company account to another employee.", "transferOwnershipDescription": "Transfer ownership of the company account to another member.",
"transferOwnershipInputLabel": "Please type TRANSFER to confirm the transfer of ownership.", "transferOwnershipInputLabel": "Please type TRANSFER to confirm the transfer of ownership.",
"transferOwnershipInputDescription": "By transferring ownership, you will no longer be the primary owner of the company account.", "transferOwnershipInputDescription": "By transferring ownership, you will no longer be the primary admin of the company account.",
"deleteInvitation": "Delete Invitation", "deleteInvitation": "Delete Invitation",
"deleteInvitationDialogDescription": "You are about to delete the invitation. The user will no longer be able to join the company account.", "deleteInvitationDialogDescription": "You are about to delete the invitation. The user will no longer be able to join the company account.",
"deleteInviteSuccessMessage": "Invite deleted successfully", "deleteInviteSuccessMessage": "Invite deleted successfully",
@@ -92,21 +92,21 @@
"teamLogoInputHeading": "Upload your company's Logo", "teamLogoInputHeading": "Upload your company's Logo",
"teamLogoInputSubheading": "Please choose a photo to upload as your company logo.", "teamLogoInputSubheading": "Please choose a photo to upload as your company logo.",
"updateTeamSubmitLabel": "Update Company", "updateTeamSubmitLabel": "Update Company",
"inviteMembersHeading": "Invite Employees to your Company", "inviteMembersHeading": "Invite Members to your Company",
"inviteMembersDescription": "Invite employees to your company by entering their email and role.", "inviteMembersDescription": "Invite member to your company by entering their email and role.",
"emailPlaceholder": "employee@email.com", "emailPlaceholder": "member@email.com",
"membersPageHeading": "Employees", "membersPageHeading": "Members",
"inviteMembersButton": "Invite Employees", "inviteMembersButton": "Invite Members",
"invitingMembers": "Inviting employees...", "invitingMembers": "Inviting members...",
"inviteMembersSuccessMessage": "Employees invited successfully", "inviteMembersSuccessMessage": "Members invited successfully",
"inviteMembersErrorMessage": "Sorry, employees could not be invited. Please try again.", "inviteMembersErrorMessage": "Sorry, members could not be invited. Please try again.",
"pendingInvitesHeading": "Pending Invites", "pendingInvitesHeading": "Pending Invites",
"pendingInvitesDescription": " Here you can manage the pending invitations to your company.", "pendingInvitesDescription": " Here you can manage the pending invitations to your company.",
"noPendingInvites": "No pending invites found", "noPendingInvites": "No pending invites found",
"loadingMembers": "Loading employees...", "loadingMembers": "Loading members...",
"loadMembersError": "Sorry, we couldn't fetch your company's employees.", "loadMembersError": "Sorry, we couldn't fetch your company's members.",
"loadInvitedMembersError": "Sorry, we couldn't fetch your company's invited employees.", "loadInvitedMembersError": "Sorry, we couldn't fetch your company's invited members.",
"loadingInvitedMembers": "Loading invited employees...", "loadingInvitedMembers": "Loading invited members...",
"invitedBadge": "Invited", "invitedBadge": "Invited",
"duplicateInviteEmailError": "You have already entered this email address", "duplicateInviteEmailError": "You have already entered this email address",
"invitingOwnAccountError": "Hey, that's your email!", "invitingOwnAccountError": "Hey, that's your email!",
@@ -126,13 +126,13 @@
"leaveTeamDisclaimer": "You are leaving the company {{ teamName }}. You will no longer have access to it.", "leaveTeamDisclaimer": "You are leaving the company {{ teamName }}. You will no longer have access to it.",
"deleteTeamErrorHeading": "Sorry, we couldn't delete your company.", "deleteTeamErrorHeading": "Sorry, we couldn't delete your company.",
"leaveTeamErrorHeading": "Sorry, we couldn't leave your company.", "leaveTeamErrorHeading": "Sorry, we couldn't leave your company.",
"searchMembersPlaceholder": "Search employees", "searchMembersPlaceholder": "Search members",
"createTeamErrorHeading": "Sorry, we couldn't create your company.", "createTeamErrorHeading": "Sorry, we couldn't create your company.",
"createTeamErrorMessage": "We encountered an error creating your company. Please try again.", "createTeamErrorMessage": "We encountered an error creating your company. Please try again.",
"transferTeamErrorHeading": "Sorry, we couldn't transfer ownership of your company account.", "transferTeamErrorHeading": "Sorry, we couldn't transfer ownership of your company account.",
"transferTeamErrorMessage": "We encountered an error transferring ownership of your company account. Please try again.", "transferTeamErrorMessage": "We encountered an error transferring ownership of your company account. Please try again.",
"updateRoleErrorHeading": "Sorry, we couldn't update the role of the selected employee.", "updateRoleErrorHeading": "Sorry, we couldn't update the role of the selected member.",
"updateRoleErrorMessage": "We encountered an error updating the role of the selected employee. Please try again.", "updateRoleErrorMessage": "We encountered an error updating the role of the selected member. Please try again.",
"searchInvitations": "Search Invitations", "searchInvitations": "Search Invitations",
"updateInvitation": "Update Invitation", "updateInvitation": "Update Invitation",
"removeInvitation": "Remove Invitation", "removeInvitation": "Remove Invitation",
@@ -144,7 +144,7 @@
"active": "Active", "active": "Active",
"inviteStatus": "Status", "inviteStatus": "Status",
"inviteNotFoundOrExpired": "Invite not found or expired", "inviteNotFoundOrExpired": "Invite not found or expired",
"inviteNotFoundOrExpiredDescription": "The invite you are looking for is either expired or does not exist. Please contact the company HR to renew the invite.", "inviteNotFoundOrExpiredDescription": "The invite you are looking for is either expired or does not exist. Please contact the company admin to renew the invite.",
"backToHome": "Back to Home", "backToHome": "Back to Home",
"renewInvitationDialogDescription": "You are about to renew the invitation to {{ email }}. The user will be able to join the company.", "renewInvitationDialogDescription": "You are about to renew the invitation to {{ email }}. The user will be able to join the company.",
"renewInvitationErrorTitle": "Sorry, we couldn't renew the invitation.", "renewInvitationErrorTitle": "Sorry, we couldn't renew the invitation.",
@@ -159,5 +159,6 @@
"leaveTeamInputLabel": "Please type LEAVE to confirm leaving the company.", "leaveTeamInputLabel": "Please type LEAVE to confirm leaving the company.",
"leaveTeamInputDescription": "By leaving the company, you will no longer have access to it.", "leaveTeamInputDescription": "By leaving the company, you will no longer have access to it.",
"reservedNameError": "This name is reserved. Please choose a different one.", "reservedNameError": "This name is reserved. Please choose a different one.",
"specialCharactersError": "This name cannot contain special characters. Please choose a different one." "specialCharactersError": "This name cannot contain special characters. Please choose a different one.",
"personalCode": "Личный код"
} }

View File

@@ -0,0 +1,53 @@
create or replace function kit.setup_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
user_name text;
picture_url text;
personal_code text;
begin
if new.raw_user_meta_data ->> 'name' is not null then
user_name := new.raw_user_meta_data ->> 'name';
end if;
if user_name is null and new.email is not null then
user_name := split_part(new.email, '@', 1);
end if;
if user_name is null then
user_name := '';
end if;
if new.raw_user_meta_data ->> 'avatar_url' is not null then
picture_url := new.raw_user_meta_data ->> 'avatar_url';
else
picture_url := null;
end if;
personal_code := new.raw_user_meta_data ->> 'personalCode';
insert into public.accounts (
id,
primary_owner_user_id,
name,
is_personal_account,
picture_url,
email,
personal_code
)
values (
new.id,
new.id,
user_name,
true,
picture_url,
new.email,
personal_code
);
return new;
end;
$$;

View File

@@ -0,0 +1,20 @@
create or replace function public.get_invitations_with_account_ids(
company_id uuid,
personal_codes text[]
)
returns table (
invite_token text,
personal_code text,
account_id uuid
)
language sql
as $$
select
i.invite_token,
i.personal_code,
a.id as account_id
from public.invitations i
join public.accounts a on a.personal_code = i.personal_code
where i.account_id = company_id
and i.personal_code = any(personal_codes);
$$;

View File

@@ -0,0 +1,27 @@
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'accounts_personal_code_unique'
) THEN
ALTER TABLE public.accounts
ADD CONSTRAINT accounts_personal_code_unique UNIQUE (personal_code);
END IF;
END$$;
ALTER TABLE public.invitations
ALTER COLUMN personal_code TYPE text;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'invitations_personal_code_unique'
) THEN
ALTER TABLE public.invitations
ADD CONSTRAINT invitations_personal_code_unique UNIQUE (personal_code);
END IF;
END$$;

View File

@@ -0,0 +1,146 @@
drop function if exists public.add_invitations_to_account(text, public.invitation[]);
drop type if exists public.invitation;
create type public.invitation as (
email text,
role text,
personal_code char(11)
);
create or replace function public.add_invitations_to_account (
account_slug text,
invitations public.invitation[]
) returns public.invitations[]
set search_path = ''
as $$
declare
new_invitation public.invitations;
all_invitations public.invitations[] := array[]::public.invitations[];
invite_token text;
invite public.invitation;
begin
foreach invite in array invitations loop
invite_token := extensions.uuid_generate_v4();
insert into public.invitations (
email,
account_id,
invited_by,
role,
invite_token,
personal_code
)
values (
invite.email,
(select id from public.accounts where slug = account_slug),
auth.uid(),
invite.role,
invite_token,
invite.personal_code
)
returning * into new_invitation;
all_invitations := array_append(all_invitations, new_invitation);
end loop;
return all_invitations;
end;
$$ language plpgsql;
grant execute on function public.add_invitations_to_account(text, public.invitation[]) to authenticated;
drop function if exists public.get_account_invitations(text);
create function public.get_account_invitations (account_slug text)
returns table (
id integer,
email varchar(255),
account_id uuid,
invited_by uuid,
role varchar(50),
created_at timestamptz,
updated_at timestamptz,
expires_at timestamptz,
personal_code text,
inviter_name varchar,
inviter_email varchar
)
set search_path = ''
as $$
begin
return query
select
invitation.id,
invitation.email,
invitation.account_id,
invitation.invited_by,
invitation.role,
invitation.created_at,
invitation.updated_at,
invitation.expires_at,
invitation.personal_code,
account.name,
account.email
from
public.invitations as invitation
join public.accounts as account on invitation.account_id = account.id
where
account.slug = account_slug;
end;
$$ language plpgsql;
grant execute on function public.get_account_invitations(text) to authenticated, service_role;
drop function if exists public.get_account_members(text);
-- Functions "public.get_account_members"
-- Function to get the members of an account by the account slug
create
or replace function public.get_account_members (account_slug text) returns table (
id uuid,
user_id uuid,
account_id uuid,
role varchar(50),
role_hierarchy_level int,
primary_owner_user_id uuid,
name varchar,
email varchar,
personal_code text,
picture_url varchar,
created_at timestamptz,
updated_at timestamptz
) language plpgsql
set
search_path = '' as $$
begin
return QUERY
select
acc.id,
am.user_id,
am.account_id,
am.account_role,
r.hierarchy_level,
a.primary_owner_user_id,
acc.name,
acc.email,
acc.personal_code,
acc.picture_url,
am.created_at,
am.updated_at
from
public.accounts_memberships am
join public.accounts a on a.id = am.account_id
join public.accounts acc on acc.id = am.user_id
join public.roles r on r.name = am.account_role
where
a.slug = account_slug;
end;
$$;
grant
execute on function public.get_account_members (text) to authenticated,
service_role;

View File

@@ -0,0 +1,22 @@
create or replace function public.is_company_admin(account_slug text)
returns boolean
set search_path = ''
language plpgsql
as $$
declare
is_owner boolean;
begin
select exists (
select 1
from public.accounts_memberships am
join public.accounts a on a.id = am.account_id
where am.user_id = auth.uid()
and am.account_role = 'owner'
and a.slug = account_slug
) into is_owner;
return is_owner;
end;
$$;
grant execute on function public.is_company_admin(text) to authenticated, service_role;