2.18.0: New Invitation flow, refactored Database Webhooks, new ShadCN UI Components (#384)
* Streamlined invitations flow * Removed web hooks in favor of handling logic directly in server actions * Added new Shadcn UI Components
This commit is contained in:
committed by
GitHub
parent
195cf41680
commit
2e20d3e76f
@@ -20,7 +20,6 @@
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/stripe": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/team-accounts": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@supabase/supabase-js": "2.58.0",
|
||||
"zod": "^3.25.74"
|
||||
|
||||
@@ -24,40 +24,14 @@ class DatabaseWebhookRouterService {
|
||||
*/
|
||||
async handleWebhook(body: RecordChange<keyof Tables>) {
|
||||
switch (body.table) {
|
||||
case 'invitations': {
|
||||
const payload = body as RecordChange<typeof body.table>;
|
||||
|
||||
return this.handleInvitationsWebhook(payload);
|
||||
}
|
||||
|
||||
case 'subscriptions': {
|
||||
const payload = body as RecordChange<typeof body.table>;
|
||||
|
||||
return this.handleSubscriptionsWebhook(payload);
|
||||
}
|
||||
|
||||
case 'accounts': {
|
||||
const payload = body as RecordChange<typeof body.table>;
|
||||
|
||||
return this.handleAccountsWebhook(payload);
|
||||
}
|
||||
|
||||
default: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleInvitationsWebhook(body: RecordChange<'invitations'>) {
|
||||
const { createAccountInvitationsWebhookService } = await import(
|
||||
'@kit/team-accounts/webhooks'
|
||||
);
|
||||
|
||||
const service = createAccountInvitationsWebhookService(this.adminClient);
|
||||
|
||||
return service.handleInvitationWebhook(body.record);
|
||||
}
|
||||
|
||||
private async handleSubscriptionsWebhook(
|
||||
body: RecordChange<'subscriptions'>,
|
||||
) {
|
||||
@@ -71,16 +45,4 @@ class DatabaseWebhookRouterService {
|
||||
return service.handleSubscriptionDeletedWebhook(body.old_record);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAccountsWebhook(body: RecordChange<'accounts'>) {
|
||||
if (body.type === 'DELETE' && body.old_record) {
|
||||
const { createAccountWebhooksService } = await import(
|
||||
'@kit/team-accounts/webhooks'
|
||||
);
|
||||
|
||||
const service = createAccountWebhooksService();
|
||||
|
||||
return service.handleAccountDeletedWebhook(body.old_record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { initializeEmailI18n } from '../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
productName: string;
|
||||
userDisplayName: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
@@ -55,9 +54,7 @@ export async function renderAccountDeleteEmail(props: Props) {
|
||||
|
||||
<EmailContent>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:hello`, {
|
||||
displayName: props.userDisplayName,
|
||||
})}
|
||||
{t(`${namespace}:hello`)}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { CheckIcon } from '@radix-ui/react-icons';
|
||||
import { Mail } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -13,11 +14,14 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@kit/ui/input-group';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
@@ -89,50 +93,57 @@ export function UpdateEmailForm({
|
||||
</If>
|
||||
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'account:newEmail'} />
|
||||
</FormLabel>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InputGroup className="dark:bg-background">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Mail className="h-4 w-4" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'account-email-form-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupInput
|
||||
data-test={'account-email-form-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
placeholder={t('account:newEmail')}
|
||||
{...field}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'email'}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'email'}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'account:repeatEmail'} />
|
||||
</FormLabel>
|
||||
<FormField
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InputGroup className="dark:bg-background">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Mail className="h-4 w-4" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
data-test={'account-email-form-repeat-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupInput
|
||||
{...field}
|
||||
data-test={'account-email-form-repeat-email-input'}
|
||||
required
|
||||
type={'email'}
|
||||
placeholder={t('account:repeatEmail')}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'repeatEmail'}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
name={'repeatEmail'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button disabled={updateUserMutation.isPending}>
|
||||
|
||||
@@ -20,6 +20,15 @@ import {
|
||||
} from '@kit/ui/alert-dialog';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@kit/ui/item';
|
||||
import { OauthProviderLogoImage } from '@kit/ui/oauth-provider-logo-image';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
@@ -90,73 +99,76 @@ export function LinkAccountsList(props: { providers: Provider[] }) {
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
{connectedIdentities.map((identity) => (
|
||||
<div
|
||||
key={identity.id}
|
||||
className="bg-muted/50 flex h-14 items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Item key={identity.id} variant="outline">
|
||||
<ItemMedia>
|
||||
<OauthProviderLogoImage providerId={identity.provider} />
|
||||
</ItemMedia>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-x-2 text-sm font-medium capitalize">
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
<ItemContent>
|
||||
<ItemHeader className="flex items-center gap-3">
|
||||
<div className="flex flex-col">
|
||||
<ItemTitle className="flex items-center gap-x-2 text-sm font-medium capitalize">
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
|
||||
<span>{identity.provider}</span>
|
||||
</span>
|
||||
<span>{identity.provider}</span>
|
||||
</ItemTitle>
|
||||
|
||||
<If condition={identity.identity_data?.email}>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{identity.identity_data?.email as string}
|
||||
</span>
|
||||
</If>
|
||||
</div>
|
||||
</div>
|
||||
<If condition={identity.identity_data?.email}>
|
||||
<ItemDescription>
|
||||
{identity.identity_data?.email as string}
|
||||
</ItemDescription>
|
||||
</If>
|
||||
</div>
|
||||
</ItemHeader>
|
||||
</ItemContent>
|
||||
|
||||
<If condition={hasMultipleIdentities}>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={unlinkMutation.isPending}
|
||||
>
|
||||
<If condition={unlinkMutation.isPending}>
|
||||
<Spinner className="mr-2 h-3 w-3" />
|
||||
</If>
|
||||
<Trans i18nKey={'account:unlinkAccount'} />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans i18nKey={'account:confirmUnlinkAccount'} />
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
<Trans
|
||||
i18nKey={'account:unlinkAccountConfirmation'}
|
||||
values={{ provider: identity.provider }}
|
||||
/>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction
|
||||
onClick={() => handleUnlinkAccount(identity)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
<ItemActions>
|
||||
<If condition={hasMultipleIdentities}>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={unlinkMutation.isPending}
|
||||
>
|
||||
<If condition={unlinkMutation.isPending}>
|
||||
<Spinner className="mr-2 h-3 w-3" />
|
||||
</If>
|
||||
<Trans i18nKey={'account:unlinkAccount'} />
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</If>
|
||||
</div>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
<Trans i18nKey={'account:confirmUnlinkAccount'} />
|
||||
</AlertDialogTitle>
|
||||
|
||||
<AlertDialogDescription>
|
||||
<Trans
|
||||
i18nKey={'account:unlinkAccountConfirmation'}
|
||||
values={{ provider: identity.provider }}
|
||||
/>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
<Trans i18nKey={'common:cancel'} />
|
||||
</AlertDialogCancel>
|
||||
|
||||
<AlertDialogAction
|
||||
onClick={() => handleUnlinkAccount(identity)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
<Trans i18nKey={'account:unlinkAccount'} />
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</If>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,19 +191,28 @@ export function LinkAccountsList(props: { providers: Provider[] }) {
|
||||
|
||||
<div className="flex flex-col space-y-2">
|
||||
{availableProviders.map((provider) => (
|
||||
<button
|
||||
<Item
|
||||
key={provider}
|
||||
className="hover:bg-muted/50 flex h-14 items-center justify-between rounded-lg border p-3 transition-colors"
|
||||
variant="outline"
|
||||
onClick={() => handleLinkAccount(provider)}
|
||||
role="button"
|
||||
className="hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ItemMedia>
|
||||
<OauthProviderLogoImage providerId={provider} />
|
||||
</ItemMedia>
|
||||
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{provider}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<ItemContent>
|
||||
<ItemTitle className="capitalize">{provider}</ItemTitle>
|
||||
|
||||
<ItemDescription>
|
||||
<Trans
|
||||
i18nKey={'account:linkAccountDescription'}
|
||||
values={{ provider }}
|
||||
/>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,13 @@ import {
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@kit/ui/item';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
import {
|
||||
@@ -100,17 +107,21 @@ function FactorsTableContainer(props: { userId: string }) {
|
||||
if (!allFactors.length) {
|
||||
return (
|
||||
<div className={'flex flex-col space-y-4'}>
|
||||
<Alert>
|
||||
<ShieldCheck className={'h-4'} />
|
||||
<Item variant="outline">
|
||||
<ItemMedia>
|
||||
<ShieldCheck className={'h-4'} />
|
||||
</ItemMedia>
|
||||
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'account:multiFactorAuthHeading'} />
|
||||
</AlertTitle>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<Trans i18nKey={'account:multiFactorAuthHeading'} />
|
||||
</ItemTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'account:multiFactorAuthDescription'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<ItemDescription>
|
||||
<Trans i18nKey={'account:multiFactorAuthDescription'} />
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Check, Lock } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -17,12 +17,14 @@ import {
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@kit/ui/input-group';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
@@ -101,62 +103,68 @@ export const UpdatePasswordForm = ({
|
||||
<NeedsReauthenticationAlert />
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name={'newPassword'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Label>
|
||||
<Trans i18nKey={'account:newPassword'} />
|
||||
</Label>
|
||||
</FormLabel>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<FormField
|
||||
name={'newPassword'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InputGroup className="dark:bg-background">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Lock className="h-4 w-4" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'account-password-form-password-input'}
|
||||
autoComplete={'new-password'}
|
||||
required
|
||||
type={'password'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupInput
|
||||
data-test={'account-password-form-password-input'}
|
||||
autoComplete={'new-password'}
|
||||
required
|
||||
type={'password'}
|
||||
placeholder={t('account:newPassword')}
|
||||
{...field}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'repeatPassword'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Label>
|
||||
<Trans i18nKey={'account:repeatPassword'} />
|
||||
</Label>
|
||||
</FormLabel>
|
||||
<FormField
|
||||
name={'repeatPassword'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InputGroup className="dark:bg-background">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Lock className="h-4 w-4" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'account-password-form-repeat-password-input'}
|
||||
required
|
||||
type={'password'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<InputGroupInput
|
||||
data-test={
|
||||
'account-password-form-repeat-password-input'
|
||||
}
|
||||
required
|
||||
type={'password'}
|
||||
placeholder={t('account:repeatPassword')}
|
||||
{...field}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans i18nKey={'account:repeatPasswordDescription'} />
|
||||
</FormDescription>
|
||||
<FormDescription>
|
||||
<Trans i18nKey={'account:repeatPasswordDescription'} />
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button disabled={updateUserMutation.isPending}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { User } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -9,10 +10,13 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@kit/ui/input-group';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
@@ -66,18 +70,20 @@ export function UpdateAccountDetailsForm({
|
||||
name={'displayName'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'account:name'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'account-display-name'}
|
||||
minLength={2}
|
||||
placeholder={''}
|
||||
maxLength={100}
|
||||
{...field}
|
||||
/>
|
||||
<InputGroup className="dark:bg-background">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<User className="h-4 w-4" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<InputGroupInput
|
||||
data-test={'account-display-name'}
|
||||
minLength={2}
|
||||
placeholder={t('account:name')}
|
||||
maxLength={100}
|
||||
{...field}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -85,8 +85,10 @@ export const deletePersonalAccountAction = enhanceAction(
|
||||
// delete the user's account and cancel all subscriptions
|
||||
await service.deletePersonalAccount({
|
||||
adminClient: getSupabaseServerAdminClient(),
|
||||
userId: user.id,
|
||||
userEmail: user.email ?? null,
|
||||
account: {
|
||||
id: user.id,
|
||||
email: user.email ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
// sign out the user after deleting their account
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
@@ -30,13 +32,14 @@ class DeletePersonalAccountService {
|
||||
*/
|
||||
async deletePersonalAccount(params: {
|
||||
adminClient: SupabaseClient<Database>;
|
||||
|
||||
userId: string;
|
||||
userEmail: string | null;
|
||||
account: {
|
||||
id: string;
|
||||
email: string | null;
|
||||
};
|
||||
}) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const userId = params.userId;
|
||||
const userId = params.account.id;
|
||||
const ctx = { userId, name: this.namespace };
|
||||
|
||||
logger.info(
|
||||
@@ -54,6 +57,14 @@ class DeletePersonalAccountService {
|
||||
|
||||
logger.info(ctx, 'User successfully deleted!');
|
||||
|
||||
if (params.account.email) {
|
||||
// dispatch the delete account email. Errors are handled in the method.
|
||||
await this.dispatchDeleteAccountEmail({
|
||||
email: params.account.email,
|
||||
id: params.account.id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
@@ -69,4 +80,71 @@ class DeletePersonalAccountService {
|
||||
throw new Error('Error deleting user');
|
||||
}
|
||||
}
|
||||
|
||||
private async dispatchDeleteAccountEmail(account: {
|
||||
email: string;
|
||||
id: string;
|
||||
}) {
|
||||
const logger = await getLogger();
|
||||
const ctx = { name: this.namespace, userId: account.id };
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Sending delete account email...');
|
||||
|
||||
await this.sendDeleteAccountEmail(account);
|
||||
|
||||
logger.info(ctx, 'Delete account email sent successfully');
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error,
|
||||
},
|
||||
'Failed to send delete account email',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendDeleteAccountEmail(account: { email: string }) {
|
||||
const emailSettings = this.getEmailSettings();
|
||||
|
||||
const { renderAccountDeleteEmail } = await import('@kit/email-templates');
|
||||
const { getMailer } = await import('@kit/mailers');
|
||||
|
||||
const mailer = await getMailer();
|
||||
|
||||
const { html, subject } = await renderAccountDeleteEmail({
|
||||
productName: emailSettings.productName,
|
||||
});
|
||||
|
||||
await mailer.sendEmail({
|
||||
from: emailSettings.fromEmail,
|
||||
html,
|
||||
subject,
|
||||
to: account.email,
|
||||
});
|
||||
}
|
||||
|
||||
private getEmailSettings() {
|
||||
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME;
|
||||
const fromEmail = process.env.EMAIL_SENDER;
|
||||
|
||||
return z
|
||||
.object({
|
||||
productName: z
|
||||
.string({
|
||||
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
|
||||
})
|
||||
.min(1),
|
||||
fromEmail: z
|
||||
.string({
|
||||
required_error: 'EMAIL_SENDER is required',
|
||||
})
|
||||
.min(1),
|
||||
})
|
||||
.parse({
|
||||
productName,
|
||||
fromEmail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,13 +28,11 @@ import { useLastAuthMethod } from '../hooks/use-last-auth-method';
|
||||
import { TermsAndConditionsFormField } from './terms-and-conditions-form-field';
|
||||
|
||||
export function MagicLinkAuthContainer({
|
||||
inviteToken,
|
||||
redirectUrl,
|
||||
shouldCreateUser,
|
||||
defaultValues,
|
||||
displayTermsCheckbox,
|
||||
}: {
|
||||
inviteToken?: string;
|
||||
redirectUrl: string;
|
||||
shouldCreateUser: boolean;
|
||||
displayTermsCheckbox?: boolean;
|
||||
@@ -63,10 +61,6 @@ export function MagicLinkAuthContainer({
|
||||
const onSubmit = ({ email }: { email: string }) => {
|
||||
const url = new URL(redirectUrl);
|
||||
|
||||
if (inviteToken) {
|
||||
url.searchParams.set('invite_token', inviteToken);
|
||||
}
|
||||
|
||||
const emailRedirectTo = url.href;
|
||||
|
||||
const promise = async () => {
|
||||
|
||||
@@ -32,7 +32,6 @@ const OAUTH_SCOPES: Partial<Record<Provider, string>> = {
|
||||
};
|
||||
|
||||
export const OauthProviders: React.FC<{
|
||||
inviteToken?: string;
|
||||
shouldCreateUser: boolean;
|
||||
enabledProviders: Provider[];
|
||||
queryParams?: Record<string, string>;
|
||||
@@ -86,10 +85,6 @@ export const OauthProviders: React.FC<{
|
||||
queryParams.set('next', props.paths.returnPath);
|
||||
}
|
||||
|
||||
if (props.inviteToken) {
|
||||
queryParams.set('invite_token', props.inviteToken);
|
||||
}
|
||||
|
||||
const redirectPath = [
|
||||
props.paths.callback,
|
||||
queryParams.toString(),
|
||||
|
||||
@@ -36,7 +36,6 @@ const OtpSchema = z.object({ token: z.string().min(6).max(6) });
|
||||
|
||||
type OtpSignInContainerProps = {
|
||||
shouldCreateUser: boolean;
|
||||
inviteToken?: string;
|
||||
};
|
||||
|
||||
export function OtpSignInContainer(props: OtpSignInContainerProps) {
|
||||
@@ -80,19 +79,9 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) {
|
||||
recordAuthMethod('otp', { email });
|
||||
|
||||
// on sign ups we redirect to the app home
|
||||
const inviteToken = props.inviteToken;
|
||||
const next = params.get('next') ?? '/home';
|
||||
|
||||
if (inviteToken) {
|
||||
const params = new URLSearchParams({
|
||||
invite_token: inviteToken,
|
||||
next,
|
||||
});
|
||||
|
||||
router.replace(`/join?${params.toString()}`);
|
||||
} else {
|
||||
router.replace(next);
|
||||
}
|
||||
router.replace(next);
|
||||
};
|
||||
|
||||
if (isEmailStep) {
|
||||
|
||||
@@ -18,8 +18,6 @@ import { OtpSignInContainer } from './otp-sign-in-container';
|
||||
import { PasswordSignInContainer } from './password-sign-in-container';
|
||||
|
||||
export function SignInMethodsContainer(props: {
|
||||
inviteToken?: string;
|
||||
|
||||
paths: {
|
||||
callback: string;
|
||||
joinTeam: string;
|
||||
@@ -40,22 +38,10 @@ export function SignInMethodsContainer(props: {
|
||||
: '';
|
||||
|
||||
const onSignIn = useCallback(() => {
|
||||
// if the user has an invite token, we should join the team
|
||||
if (props.inviteToken) {
|
||||
const searchParams = new URLSearchParams({
|
||||
invite_token: props.inviteToken,
|
||||
});
|
||||
const returnPath = props.paths.returnPath || '/home';
|
||||
|
||||
const joinTeamPath = props.paths.joinTeam + '?' + searchParams.toString();
|
||||
|
||||
router.replace(joinTeamPath);
|
||||
} else {
|
||||
const returnPath = props.paths.returnPath || '/home';
|
||||
|
||||
// otherwise, we should redirect to the return path
|
||||
router.replace(returnPath);
|
||||
}
|
||||
}, [props.inviteToken, props.paths.joinTeam, props.paths.returnPath, router]);
|
||||
router.replace(returnPath);
|
||||
}, [props.paths.returnPath, router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -67,17 +53,13 @@ export function SignInMethodsContainer(props: {
|
||||
|
||||
<If condition={props.providers.magicLink}>
|
||||
<MagicLinkAuthContainer
|
||||
inviteToken={props.inviteToken}
|
||||
redirectUrl={redirectUrl}
|
||||
shouldCreateUser={false}
|
||||
/>
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.otp}>
|
||||
<OtpSignInContainer
|
||||
inviteToken={props.inviteToken}
|
||||
shouldCreateUser={false}
|
||||
/>
|
||||
<OtpSignInContainer shouldCreateUser={false} />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.oAuth.length}>
|
||||
@@ -95,7 +77,6 @@ export function SignInMethodsContainer(props: {
|
||||
|
||||
<OauthProviders
|
||||
enabledProviders={props.providers.oAuth}
|
||||
inviteToken={props.inviteToken}
|
||||
shouldCreateUser={false}
|
||||
paths={{
|
||||
callback: props.paths.callback,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import type { Provider } from '@supabase/supabase-js';
|
||||
|
||||
import { isBrowser } from '@kit/shared/utils';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
@@ -28,7 +27,6 @@ export function SignUpMethodsContainer(props: {
|
||||
};
|
||||
|
||||
displayTermsCheckbox?: boolean;
|
||||
inviteToken?: string;
|
||||
}) {
|
||||
const redirectUrl = getCallbackUrl(props);
|
||||
const defaultValues = getDefaultValues();
|
||||
@@ -38,10 +36,6 @@ export function SignUpMethodsContainer(props: {
|
||||
{/* Show hint if user might already have an account */}
|
||||
<ExistingAccountHint />
|
||||
|
||||
<If condition={props.inviteToken}>
|
||||
<InviteAlert />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.password}>
|
||||
<EmailPasswordSignUpContainer
|
||||
emailRedirectTo={redirectUrl}
|
||||
@@ -51,15 +45,11 @@ export function SignUpMethodsContainer(props: {
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.otp}>
|
||||
<OtpSignInContainer
|
||||
inviteToken={props.inviteToken}
|
||||
shouldCreateUser={true}
|
||||
/>
|
||||
<OtpSignInContainer shouldCreateUser={true} />
|
||||
</If>
|
||||
|
||||
<If condition={props.providers.magicLink}>
|
||||
<MagicLinkAuthContainer
|
||||
inviteToken={props.inviteToken}
|
||||
redirectUrl={redirectUrl}
|
||||
shouldCreateUser={true}
|
||||
defaultValues={defaultValues}
|
||||
@@ -82,7 +72,6 @@ export function SignUpMethodsContainer(props: {
|
||||
|
||||
<OauthProviders
|
||||
enabledProviders={props.providers.oAuth}
|
||||
inviteToken={props.inviteToken}
|
||||
shouldCreateUser={true}
|
||||
paths={{
|
||||
callback: props.paths.callback,
|
||||
@@ -99,8 +88,6 @@ function getCallbackUrl(props: {
|
||||
callback: string;
|
||||
appHome: string;
|
||||
};
|
||||
|
||||
inviteToken?: string;
|
||||
}) {
|
||||
if (!isBrowser()) {
|
||||
return '';
|
||||
@@ -110,10 +97,6 @@ function getCallbackUrl(props: {
|
||||
const origin = window.location.origin;
|
||||
const url = new URL(redirectPath, origin);
|
||||
|
||||
if (props.inviteToken) {
|
||||
url.searchParams.set('invite_token', props.inviteToken);
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const next = searchParams.get('next');
|
||||
|
||||
@@ -130,27 +113,8 @@ function getDefaultValues() {
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const inviteToken = searchParams.get('invite_token');
|
||||
|
||||
if (!inviteToken) {
|
||||
return { email: '' };
|
||||
}
|
||||
|
||||
return {
|
||||
email: searchParams.get('email') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function InviteAlert() {
|
||||
return (
|
||||
<Alert variant={'info'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={'auth:inviteAlertHeading'} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={'auth:inviteAlertBody'} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useTransition } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Mail, Plus, X } from 'lucide-react';
|
||||
import { useFieldArray, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -23,11 +23,14 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@kit/ui/input-group';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Spinner } from '@kit/ui/spinner';
|
||||
import {
|
||||
@@ -188,28 +191,26 @@ function InviteMembersForm({
|
||||
data-test={'invite-members-form'}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-col gap-y-2.5">
|
||||
{fieldArray.fields.map((field, index) => {
|
||||
const isFirst = index === 0;
|
||||
|
||||
const emailInputName = `invitations.${index}.email` as const;
|
||||
const roleInputName = `invitations.${index}.role` as const;
|
||||
|
||||
return (
|
||||
<div data-test={'invite-member-form-item'} key={field.id}>
|
||||
<div className={'flex items-end gap-x-1 md:space-x-2'}>
|
||||
<div className={'w-7/12'}>
|
||||
<div className={'flex items-end gap-x-2'}>
|
||||
<InputGroup className={'bg-background w-full'}>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Mail className="h-4 w-4" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<FormField
|
||||
name={emailInputName}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<If condition={isFirst}>
|
||||
<FormLabel>{t('emailLabel')}</FormLabel>
|
||||
</If>
|
||||
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input
|
||||
<InputGroupInput
|
||||
data-test={'invite-email-input'}
|
||||
placeholder={t('emailPlaceholder')}
|
||||
type="email"
|
||||
@@ -223,39 +224,31 @@ function InviteMembersForm({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</InputGroup>
|
||||
|
||||
<div className={'w-4/12'}>
|
||||
<FormField
|
||||
name={roleInputName}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<If condition={isFirst}>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'teams:roleLabel'} />
|
||||
</FormLabel>
|
||||
</If>
|
||||
<FormField
|
||||
name={roleInputName}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<MembershipRoleSelector
|
||||
triggerClassName={'m-0 bg-muted'}
|
||||
roles={roles}
|
||||
value={field.value}
|
||||
onChange={(role) => {
|
||||
form.setValue(field.name, role);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<MembershipRoleSelector
|
||||
triggerClassName={'m-0'}
|
||||
roles={roles}
|
||||
value={field.value}
|
||||
onChange={(role) => {
|
||||
form.setValue(field.name, role);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={'flex w-[40px] items-end justify-end'}>
|
||||
<div className={'flex items-end justify-end'}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -271,7 +264,7 @@ function InviteMembersForm({
|
||||
form.clearErrors(emailInputName);
|
||||
}}
|
||||
>
|
||||
<X className={'h-4 lg:h-5'} />
|
||||
<X className={'h-4'} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTransition } from 'react';
|
||||
import { isRedirectError } from 'next/dist/client/components/redirect-error';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Building } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -14,10 +15,13 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Input } from '@kit/ui/input';
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from '@kit/ui/input-group';
|
||||
import { toast } from '@kit/ui/sonner';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
@@ -87,17 +91,19 @@ export const UpdateTeamAccountNameForm = (props: {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'teams:teamNameInputLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
data-test={'team-name-input'}
|
||||
required
|
||||
placeholder={''}
|
||||
{...field}
|
||||
/>
|
||||
<InputGroup className="dark:bg-background">
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Building className="h-4 w-4" />
|
||||
</InputGroupAddon>
|
||||
|
||||
<InputGroupInput
|
||||
data-test={'team-name-input'}
|
||||
required
|
||||
placeholder={t('teams:teamNameInputLabel')}
|
||||
{...field}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -145,6 +145,7 @@ export const acceptInvitationAction = enhanceAction(
|
||||
const accountId = await service.acceptInvitationToTeam(adminClient, {
|
||||
inviteToken,
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
});
|
||||
|
||||
// If the account ID is not present, throw an error
|
||||
|
||||
@@ -209,6 +209,8 @@ export class TeamAccountsApi {
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
email: string;
|
||||
|
||||
account: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -217,7 +219,10 @@ export class TeamAccountsApi {
|
||||
};
|
||||
}
|
||||
>(
|
||||
'id, expires_at, account: account_id !inner (id, name, slug, picture_url)',
|
||||
`id,
|
||||
expires_at,
|
||||
email,
|
||||
account: account_id !inner (id, name, slug, picture_url)`,
|
||||
)
|
||||
.eq('invite_token', token)
|
||||
.gte('expires_at', new Date().toISOString())
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database, Tables } from '@kit/supabase/database';
|
||||
|
||||
type Invitation = Tables<'invitations'>;
|
||||
|
||||
const invitePath = '/join';
|
||||
const authTokenCallbackPath = '/auth/confirm';
|
||||
|
||||
const siteURL = process.env.NEXT_PUBLIC_SITE_URL;
|
||||
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME ?? '';
|
||||
const emailSender = process.env.EMAIL_SENDER;
|
||||
|
||||
const env = z
|
||||
.object({
|
||||
invitePath: z
|
||||
.string({
|
||||
required_error: 'The property invitePath is required',
|
||||
})
|
||||
.min(1),
|
||||
siteURL: z
|
||||
.string({
|
||||
required_error: 'NEXT_PUBLIC_SITE_URL is required',
|
||||
})
|
||||
.min(1),
|
||||
productName: z
|
||||
.string({
|
||||
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
|
||||
})
|
||||
.min(1),
|
||||
emailSender: z
|
||||
.string({
|
||||
required_error: 'EMAIL_SENDER is required',
|
||||
})
|
||||
.min(1),
|
||||
})
|
||||
.parse({
|
||||
invitePath,
|
||||
siteURL,
|
||||
productName,
|
||||
emailSender,
|
||||
});
|
||||
|
||||
export function createAccountInvitationsDispatchService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return new AccountInvitationsDispatchService(client);
|
||||
}
|
||||
|
||||
class AccountInvitationsDispatchService {
|
||||
private namespace = 'accounts.invitations.webhook';
|
||||
|
||||
constructor(private readonly adminClient: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* @name sendInvitationEmail
|
||||
* @description Sends an invitation email to the invited user
|
||||
* @param invitation - The invitation to send
|
||||
* @returns
|
||||
*/
|
||||
async sendInvitationEmail({
|
||||
invitation,
|
||||
link,
|
||||
}: {
|
||||
invitation: Invitation;
|
||||
link: string;
|
||||
}) {
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{
|
||||
invitationId: invitation.id,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Handling invitation email dispatch...',
|
||||
);
|
||||
|
||||
// retrieve the inviter details
|
||||
const inviter = await this.getInviterDetails(invitation);
|
||||
|
||||
if (inviter.error) {
|
||||
logger.error(
|
||||
{
|
||||
error: inviter.error,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Failed to fetch inviter details',
|
||||
);
|
||||
|
||||
throw inviter.error;
|
||||
}
|
||||
|
||||
// retrieve the team details
|
||||
const team = await this.getTeamDetails(invitation.account_id);
|
||||
|
||||
if (team.error) {
|
||||
logger.error(
|
||||
{
|
||||
error: team.error,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Failed to fetch team details',
|
||||
);
|
||||
|
||||
throw team.error;
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
invitationId: invitation.id,
|
||||
name: this.namespace,
|
||||
};
|
||||
|
||||
try {
|
||||
logger.info(ctx, 'Invite retrieved. Sending invitation email...');
|
||||
|
||||
// send the invitation email
|
||||
await this.sendEmail({
|
||||
invitation,
|
||||
link,
|
||||
inviter: inviter.data,
|
||||
team: team.data,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn({ error, ...ctx }, 'Failed to invite user to team');
|
||||
|
||||
return {
|
||||
error,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getInvitationLink
|
||||
* @description Generates an invitation link for the given token and email
|
||||
* @param token - The token to use for the invitation
|
||||
*/
|
||||
getInvitationLink(token: string) {
|
||||
const siteUrl = env.siteURL;
|
||||
const url = new URL(env.invitePath, siteUrl);
|
||||
|
||||
url.searchParams.set('invite_token', token);
|
||||
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name sendEmail
|
||||
* @description Sends an invitation email to the invited user
|
||||
* @param invitation - The invitation to send
|
||||
* @param link - The link to the invitation
|
||||
* @param inviter - The inviter details
|
||||
* @param team - The team details
|
||||
* @returns
|
||||
*/
|
||||
private async sendEmail({
|
||||
invitation,
|
||||
link,
|
||||
inviter,
|
||||
team,
|
||||
}: {
|
||||
invitation: Invitation;
|
||||
link: string;
|
||||
inviter: { name: string; email: string | null };
|
||||
team: { name: string };
|
||||
}) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
invitationId: invitation.id,
|
||||
name: this.namespace,
|
||||
};
|
||||
|
||||
const { renderInviteEmail } = await import('@kit/email-templates');
|
||||
const { getMailer } = await import('@kit/mailers');
|
||||
|
||||
const mailer = await getMailer();
|
||||
|
||||
const { html, subject } = await renderInviteEmail({
|
||||
link,
|
||||
invitedUserEmail: invitation.email,
|
||||
inviter: inviter.name ?? inviter.email ?? '',
|
||||
productName: env.productName,
|
||||
teamName: team.name,
|
||||
});
|
||||
|
||||
return mailer
|
||||
.sendEmail({
|
||||
from: env.emailSender,
|
||||
to: invitation.email,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(ctx, 'Invitation email successfully sent!');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
logger.error({ error, ...ctx }, 'Failed to send invitation email');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getAuthCallbackUrl
|
||||
* @description Generates an auth token callback url. This redirects the user to a page where the user can sign in with a token.
|
||||
* @param nextLink - The next link to redirect the user to
|
||||
|
||||
* @returns
|
||||
*/
|
||||
getAuthCallbackUrl(nextLink: string) {
|
||||
const url = new URL(authTokenCallbackPath, env.siteURL);
|
||||
|
||||
url.searchParams.set('next', nextLink);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getInviterDetails
|
||||
* @description Fetches the inviter details for the given invitation
|
||||
* @param invitation
|
||||
* @returns
|
||||
*/
|
||||
private getInviterDetails(invitation: Invitation) {
|
||||
return this.adminClient
|
||||
.from('accounts')
|
||||
.select('email, name')
|
||||
.eq('id', invitation.invited_by)
|
||||
.single();
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getTeamDetails
|
||||
* @description Fetches the team details for the given account ID
|
||||
* @param accountId
|
||||
* @returns
|
||||
*/
|
||||
private getTeamDetails(accountId: string) {
|
||||
return this.adminClient
|
||||
.from('accounts')
|
||||
.select('name')
|
||||
.eq('id', accountId)
|
||||
.single();
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,12 @@ import { z } from 'zod';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
import type { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
|
||||
import type { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import type { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
|
||||
import { createAccountInvitationsDispatchService } from './account-invitations-dispatcher.service';
|
||||
|
||||
/**
|
||||
*
|
||||
@@ -212,6 +214,8 @@ class AccountInvitationsService {
|
||||
},
|
||||
'Invitations added to account',
|
||||
);
|
||||
|
||||
await this.dispatchInvitationEmails(ctx, responseInvitations);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,10 +226,12 @@ class AccountInvitationsService {
|
||||
adminClient: SupabaseClient<Database>,
|
||||
params: {
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
inviteToken: string;
|
||||
},
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
name: this.namespace,
|
||||
...params,
|
||||
@@ -233,6 +239,30 @@ class AccountInvitationsService {
|
||||
|
||||
logger.info(ctx, 'Accepting invitation to team');
|
||||
|
||||
const invitation = await adminClient
|
||||
.from('invitations')
|
||||
.select('email')
|
||||
.eq('invite_token', params.inviteToken)
|
||||
.single();
|
||||
|
||||
if (invitation.error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error: invitation.error,
|
||||
},
|
||||
'Failed to get invitation',
|
||||
);
|
||||
}
|
||||
|
||||
// if the invitation email does not match the user email, throw an error
|
||||
if (invitation.data?.email !== params.userEmail) {
|
||||
logger.error({
|
||||
...ctx,
|
||||
error: 'Invitation email does not match user email',
|
||||
});
|
||||
}
|
||||
|
||||
const { error, data } = await adminClient.rpc('accept_invitation', {
|
||||
token: params.inviteToken,
|
||||
user_id: params.userId,
|
||||
@@ -297,4 +327,128 @@ class AccountInvitationsService {
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name dispatchInvitationEmails
|
||||
* @description Dispatches invitation emails to the invited users.
|
||||
* @param ctx
|
||||
* @param invitations
|
||||
* @returns
|
||||
*/
|
||||
private async dispatchInvitationEmails(
|
||||
ctx: { accountSlug: string; name: string },
|
||||
invitations: Database['public']['Tables']['invitations']['Row'][],
|
||||
) {
|
||||
if (!invitations.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logger = await getLogger();
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
const service = createAccountInvitationsDispatchService(this.client);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
invitations.map(async (invitation) => {
|
||||
const joinTeamLink = service.getInvitationLink(invitation.invite_token);
|
||||
const authCallbackUrl = service.getAuthCallbackUrl(joinTeamLink);
|
||||
|
||||
const getEmailLinkType = async () => {
|
||||
const user = await adminClient
|
||||
.from('accounts')
|
||||
.select('*')
|
||||
.eq('email', invitation.email)
|
||||
.single();
|
||||
|
||||
// if the user is not found, return the invite type
|
||||
// this link allows the user to register to the platform
|
||||
if (user.error || !user.data) {
|
||||
return 'invite';
|
||||
}
|
||||
|
||||
// if the user is found, return the email link type to sign in
|
||||
return 'magiclink';
|
||||
};
|
||||
|
||||
const emailLinkType = await getEmailLinkType();
|
||||
|
||||
// generate an invitation link with Supabase admin client
|
||||
// use the "redirectTo" parameter to redirect the user to the invitation page after the link is clicked
|
||||
const generateLinkResponse = await adminClient.auth.admin.generateLink({
|
||||
email: invitation.email,
|
||||
type: emailLinkType,
|
||||
});
|
||||
|
||||
// if the link generation fails, throw an error
|
||||
if (generateLinkResponse.error) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
error: generateLinkResponse.error,
|
||||
},
|
||||
'Failed to generate link',
|
||||
);
|
||||
|
||||
throw generateLinkResponse.error;
|
||||
}
|
||||
|
||||
// get the link from the response
|
||||
const verifyLink = generateLinkResponse.data.properties?.action_link;
|
||||
|
||||
// extract token
|
||||
const token = new URL(verifyLink).searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
// return error
|
||||
throw new Error(
|
||||
'Token in verify link from Supabase Auth was not found',
|
||||
);
|
||||
}
|
||||
|
||||
// add search params to be consumed by /auth/confirm route
|
||||
authCallbackUrl.searchParams.set('token_hash', token);
|
||||
authCallbackUrl.searchParams.set('type', emailLinkType);
|
||||
|
||||
const link = authCallbackUrl.href;
|
||||
|
||||
// send the invitation email
|
||||
const data = await service.sendInvitationEmail({
|
||||
invitation,
|
||||
link,
|
||||
});
|
||||
|
||||
// return the result
|
||||
return {
|
||||
id: invitation.id,
|
||||
data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status !== 'fulfilled' || !result.value.data.success) {
|
||||
logger.error(
|
||||
{
|
||||
...ctx,
|
||||
invitationId:
|
||||
result.status === 'fulfilled' ? result.value.id : result.reason,
|
||||
},
|
||||
'Failed to send invitation email',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const succeeded = results.filter(
|
||||
(result) => result.status === 'fulfilled' && result.value.data.success,
|
||||
);
|
||||
|
||||
if (succeeded.length) {
|
||||
logger.info(
|
||||
{
|
||||
...ctx,
|
||||
count: succeeded.length,
|
||||
},
|
||||
'Invitation emails successfully sent!',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database, Tables } from '@kit/supabase/database';
|
||||
|
||||
type Invitation = Tables<'invitations'>;
|
||||
|
||||
const invitePath = '/join';
|
||||
|
||||
const siteURL = process.env.NEXT_PUBLIC_SITE_URL;
|
||||
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME ?? '';
|
||||
const emailSender = process.env.EMAIL_SENDER;
|
||||
|
||||
const env = z
|
||||
.object({
|
||||
invitePath: z
|
||||
.string({
|
||||
required_error: 'The property invitePath is required',
|
||||
})
|
||||
.min(1),
|
||||
siteURL: z
|
||||
.string({
|
||||
required_error: 'NEXT_PUBLIC_SITE_URL is required',
|
||||
})
|
||||
.min(1),
|
||||
productName: z
|
||||
.string({
|
||||
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
|
||||
})
|
||||
.min(1),
|
||||
emailSender: z
|
||||
.string({
|
||||
required_error: 'EMAIL_SENDER is required',
|
||||
})
|
||||
.min(1),
|
||||
})
|
||||
.parse({
|
||||
invitePath,
|
||||
siteURL,
|
||||
productName,
|
||||
emailSender,
|
||||
});
|
||||
|
||||
export function createAccountInvitationsWebhookService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return new AccountInvitationsWebhookService(client);
|
||||
}
|
||||
|
||||
class AccountInvitationsWebhookService {
|
||||
private namespace = 'accounts.invitations.webhook';
|
||||
|
||||
constructor(private readonly adminClient: SupabaseClient<Database>) {}
|
||||
|
||||
/**
|
||||
* @name handleInvitationWebhook
|
||||
* @description Handles the webhook event for invitations
|
||||
* @param invitation
|
||||
*/
|
||||
async handleInvitationWebhook(invitation: Invitation) {
|
||||
return this.dispatchInvitationEmail(invitation);
|
||||
}
|
||||
|
||||
private async dispatchInvitationEmail(invitation: Invitation) {
|
||||
const logger = await getLogger();
|
||||
|
||||
logger.info(
|
||||
{ invitation, name: this.namespace },
|
||||
'Handling invitation webhook event...',
|
||||
);
|
||||
|
||||
const inviter = await this.adminClient
|
||||
.from('accounts')
|
||||
.select('email, name')
|
||||
.eq('id', invitation.invited_by)
|
||||
.single();
|
||||
|
||||
if (inviter.error) {
|
||||
logger.error(
|
||||
{
|
||||
error: inviter.error,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Failed to fetch inviter details',
|
||||
);
|
||||
|
||||
throw inviter.error;
|
||||
}
|
||||
|
||||
const team = await this.adminClient
|
||||
.from('accounts')
|
||||
.select('name')
|
||||
.eq('id', invitation.account_id)
|
||||
.single();
|
||||
|
||||
if (team.error) {
|
||||
logger.error(
|
||||
{
|
||||
error: team.error,
|
||||
name: this.namespace,
|
||||
},
|
||||
'Failed to fetch team details',
|
||||
);
|
||||
|
||||
throw team.error;
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
invitationId: invitation.id,
|
||||
name: this.namespace,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Invite retrieved. Sending invitation email...');
|
||||
|
||||
try {
|
||||
const { renderInviteEmail } = await import('@kit/email-templates');
|
||||
const { getMailer } = await import('@kit/mailers');
|
||||
|
||||
const mailer = await getMailer();
|
||||
const link = this.getInvitationLink(
|
||||
invitation.invite_token,
|
||||
invitation.email,
|
||||
);
|
||||
|
||||
const { html, subject } = await renderInviteEmail({
|
||||
link,
|
||||
invitedUserEmail: invitation.email,
|
||||
inviter: inviter.data.name ?? inviter.data.email ?? '',
|
||||
productName: env.productName,
|
||||
teamName: team.data.name,
|
||||
});
|
||||
|
||||
await mailer
|
||||
.sendEmail({
|
||||
from: env.emailSender,
|
||||
to: invitation.email,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(ctx, 'Invitation email successfully sent!');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
logger.error({ error, ...ctx }, 'Failed to send invitation email');
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.warn({ error, ...ctx }, 'Failed to invite user to team');
|
||||
|
||||
return {
|
||||
error,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private getInvitationLink(token: string, email: string) {
|
||||
const searchParams = new URLSearchParams({
|
||||
invite_token: token,
|
||||
email,
|
||||
}).toString();
|
||||
|
||||
const href = new URL(env.invitePath, env.siteURL).href;
|
||||
|
||||
return `${href}?${searchParams}`;
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Tables } from '@kit/supabase/database';
|
||||
|
||||
type Account = Tables<'accounts'>;
|
||||
|
||||
export function createAccountWebhooksService() {
|
||||
return new AccountWebhooksService();
|
||||
}
|
||||
|
||||
class AccountWebhooksService {
|
||||
private readonly namespace = 'accounts.webhooks';
|
||||
|
||||
async handleAccountDeletedWebhook(account: Account) {
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
accountId: account.id,
|
||||
namespace: this.namespace,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Received account deleted webhook. Processing...');
|
||||
|
||||
if (account.is_personal_account) {
|
||||
logger.info(ctx, `Account is personal. We send an email to the user.`);
|
||||
|
||||
await this.sendDeleteAccountEmail(account);
|
||||
}
|
||||
}
|
||||
|
||||
private async sendDeleteAccountEmail(account: Account) {
|
||||
const userEmail = account.email;
|
||||
const userDisplayName = account.name ?? userEmail;
|
||||
|
||||
const emailSettings = this.getEmailSettings();
|
||||
|
||||
if (userEmail) {
|
||||
await this.sendAccountDeletionEmail({
|
||||
fromEmail: emailSettings.fromEmail,
|
||||
productName: emailSettings.productName,
|
||||
userDisplayName,
|
||||
userEmail,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async sendAccountDeletionEmail(params: {
|
||||
fromEmail: string;
|
||||
userEmail: string;
|
||||
userDisplayName: string;
|
||||
productName: string;
|
||||
}) {
|
||||
const { renderAccountDeleteEmail } = await import('@kit/email-templates');
|
||||
const { getMailer } = await import('@kit/mailers');
|
||||
|
||||
const mailer = await getMailer();
|
||||
|
||||
const { html, subject } = await renderAccountDeleteEmail({
|
||||
userDisplayName: params.userDisplayName,
|
||||
productName: params.productName,
|
||||
});
|
||||
|
||||
return mailer.sendEmail({
|
||||
to: params.userEmail,
|
||||
from: params.fromEmail,
|
||||
subject,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
private getEmailSettings() {
|
||||
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME;
|
||||
const fromEmail = process.env.EMAIL_SENDER;
|
||||
|
||||
return z
|
||||
.object({
|
||||
productName: z.string(),
|
||||
fromEmail: z
|
||||
.string({
|
||||
required_error: 'EMAIL_SENDER is required',
|
||||
})
|
||||
.min(1),
|
||||
})
|
||||
.parse({
|
||||
productName,
|
||||
fromEmail,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './account-webhooks.service';
|
||||
export * from './account-invitations-webhook.service';
|
||||
@@ -49,26 +49,26 @@ class AuthCallbackService {
|
||||
|
||||
const token_hash = searchParams.get('token_hash');
|
||||
const type = searchParams.get('type') as EmailOtpType | null;
|
||||
const callbackParam =
|
||||
searchParams.get('next') ?? searchParams.get('callback');
|
||||
|
||||
const redirectInfo = this.parseRedirectDestination(
|
||||
searchParams.get('next') ?? searchParams.get('callback'),
|
||||
);
|
||||
|
||||
let nextPath: string | null = null;
|
||||
const callbackUrl = callbackParam ? new URL(callbackParam) : null;
|
||||
|
||||
// if we have a callback url, we check if it has a next path
|
||||
if (callbackUrl) {
|
||||
// if we have a callback url, we check if it has a next path
|
||||
const callbackNextPath = callbackUrl.searchParams.get('next');
|
||||
// if we have a valid redirect destination
|
||||
if (redirectInfo) {
|
||||
nextPath = redirectInfo.path;
|
||||
|
||||
// if we have a next path in the callback url, we use that
|
||||
if (callbackNextPath) {
|
||||
nextPath = callbackNextPath;
|
||||
} else {
|
||||
nextPath = callbackUrl.pathname;
|
||||
}
|
||||
// preserve any query params from the redirect URL (e.g., invite_token)
|
||||
// but exclude 'next' to avoid duplication
|
||||
redirectInfo.params.forEach((value, key) => {
|
||||
if (key !== 'next') {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const inviteToken = callbackUrl?.searchParams.get('invite_token');
|
||||
const errorPath = params.errorPath ?? '/auth/callback/error';
|
||||
|
||||
// remove the query params from the url
|
||||
@@ -81,22 +81,6 @@ class AuthCallbackService {
|
||||
url.pathname = nextPath;
|
||||
}
|
||||
|
||||
// if we have an invite token, we append it to the redirect url
|
||||
if (inviteToken) {
|
||||
// if we have an invite token, we redirect to the join team page
|
||||
// instead of the default next url. This is because the user is trying
|
||||
// to join a team and we want to make sure they are redirected to the
|
||||
// correct page.
|
||||
url.pathname = params.joinTeamPath;
|
||||
searchParams.set('invite_token', inviteToken);
|
||||
|
||||
const emailParam = callbackUrl?.searchParams.get('email');
|
||||
|
||||
if (emailParam) {
|
||||
searchParams.set('email', emailParam);
|
||||
}
|
||||
}
|
||||
|
||||
if (token_hash && type) {
|
||||
const { error } = await this.client.auth.verifyOtp({
|
||||
type,
|
||||
@@ -147,25 +131,9 @@ class AuthCallbackService {
|
||||
const authCode = searchParams.get('code');
|
||||
const error = searchParams.get('error');
|
||||
const nextUrlPathFromParams = searchParams.get('next');
|
||||
const inviteToken = searchParams.get('invite_token');
|
||||
const errorPath = params.errorPath ?? '/auth/callback/error';
|
||||
|
||||
let nextUrl = nextUrlPathFromParams ?? params.redirectPath;
|
||||
|
||||
// if we have an invite token, we redirect to the join team page
|
||||
// instead of the default next url. This is because the user is trying
|
||||
// to join a team and we want to make sure they are redirected to the
|
||||
// correct page.
|
||||
if (inviteToken) {
|
||||
const emailParam = searchParams.get('email');
|
||||
|
||||
const urlParams = new URLSearchParams({
|
||||
invite_token: inviteToken,
|
||||
email: emailParam ?? '',
|
||||
});
|
||||
|
||||
nextUrl = `${params.joinTeamPath}?${urlParams.toString()}`;
|
||||
}
|
||||
const nextUrl = nextUrlPathFromParams ?? params.redirectPath;
|
||||
|
||||
if (authCode) {
|
||||
try {
|
||||
@@ -212,12 +180,49 @@ class AuthCallbackService {
|
||||
}
|
||||
|
||||
private adjustUrlHostForLocalDevelopment(url: URL, host: string | null) {
|
||||
if (this.isLocalhost(url.host) && !this.isLocalhost(host)) {
|
||||
url.host = host as string;
|
||||
if (host && this.isLocalhost(url.host) && !this.isLocalhost(host)) {
|
||||
url.host = host;
|
||||
url.port = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a redirect URL and extracts the destination path and query params
|
||||
* Handles nested 'next' parameters for chained redirects
|
||||
*/
|
||||
private parseRedirectDestination(redirectParam: string | null): {
|
||||
path: string;
|
||||
params: URLSearchParams;
|
||||
} | null {
|
||||
if (!redirectParam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectUrl = new URL(redirectParam);
|
||||
|
||||
// check for nested 'next' parameter (chained redirect)
|
||||
const nestedNext = redirectUrl.searchParams.get('next');
|
||||
|
||||
if (nestedNext) {
|
||||
// use the nested path as the final destination
|
||||
return {
|
||||
path: nestedNext,
|
||||
params: redirectUrl.searchParams,
|
||||
};
|
||||
}
|
||||
|
||||
// no nested redirect, use the pathname directly
|
||||
return {
|
||||
path: redirectUrl.pathname,
|
||||
params: redirectUrl.searchParams,
|
||||
};
|
||||
} catch {
|
||||
// invalid URL, ignore
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private isLocalhost(host: string | null) {
|
||||
if (!host) {
|
||||
return false;
|
||||
|
||||
@@ -90,6 +90,11 @@
|
||||
"./skeleton": "./src/shadcn/skeleton.tsx",
|
||||
"./shadcn-sidebar": "./src/shadcn/sidebar.tsx",
|
||||
"./collapsible": "./src/shadcn/collapsible.tsx",
|
||||
"./kbd": "./src/shadcn/kbd.tsx",
|
||||
"./button-group": "./src/shadcn/button-group.tsx",
|
||||
"./input-group": "./src/shadcn/input-group.tsx",
|
||||
"./item": "./src/shadcn/item.tsx",
|
||||
"./field": "./src/shadcn/field.tsx",
|
||||
"./utils": "./src/lib/utils/index.ts",
|
||||
"./if": "./src/makerkit/if.tsx",
|
||||
"./trans": "./src/makerkit/trans.tsx",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { VariantProps, cva } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from '../shadcn/button';
|
||||
|
||||
@@ -8,7 +10,7 @@ const EmptyStateHeading: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = ({
|
||||
...props
|
||||
}) => (
|
||||
<h3
|
||||
className={cn('text-2xl font-bold tracking-tight', className)}
|
||||
className={cn('text-lg font-medium tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -49,7 +51,16 @@ const EmptyState: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
(child) => React.isValidElement(child) && child.type === EmptyStateButton,
|
||||
);
|
||||
|
||||
const cmps = [EmptyStateHeading, EmptyStateText, EmptyStateButton];
|
||||
const media = childrenArray.find(
|
||||
(child) => React.isValidElement(child) && child.type === EmptyMedia,
|
||||
);
|
||||
|
||||
const cmps = [
|
||||
EmptyStateHeading,
|
||||
EmptyStateText,
|
||||
EmptyStateButton,
|
||||
EmptyMedia,
|
||||
];
|
||||
|
||||
const otherChildren = childrenArray.filter(
|
||||
(child) =>
|
||||
@@ -66,6 +77,7 @@ const EmptyState: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1 text-center">
|
||||
{media}
|
||||
{heading}
|
||||
{text}
|
||||
{button}
|
||||
@@ -76,4 +88,40 @@ const EmptyState: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({
|
||||
};
|
||||
EmptyState.displayName = 'EmptyState';
|
||||
|
||||
export { EmptyState, EmptyStateHeading, EmptyStateText, EmptyStateButton };
|
||||
const emptyMediaVariants = cva(
|
||||
'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function EmptyMedia({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="empty-icon"
|
||||
data-variant={variant}
|
||||
className={cn(emptyMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
EmptyState,
|
||||
EmptyStateHeading,
|
||||
EmptyStateText,
|
||||
EmptyStateButton,
|
||||
EmptyMedia,
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ export function GlobalLoader({
|
||||
<If condition={displaySpinner}>
|
||||
<div
|
||||
className={
|
||||
'zoom-in-80 animate-in fade-in slide-in-from-bottom-12 flex flex-1 flex-col items-center justify-center duration-500'
|
||||
'zoom-in-80 animate-in fade-in slide-in-from-bottom-12 flex flex-1 flex-col items-center justify-center duration-500 ease-out'
|
||||
}
|
||||
>
|
||||
<LoadingOverlay displayLogo={displayLogo} fullPage={fullPage} />
|
||||
|
||||
@@ -33,8 +33,8 @@ export function OauthProviderLogoImage({
|
||||
|
||||
function getOAuthProviderLogos(): Record<string, string | React.ReactNode> {
|
||||
return {
|
||||
email: <AtSign className={'size-[18px]'} />,
|
||||
phone: <Phone className={'size-[18x]'} />,
|
||||
email: <AtSign className={'size-[16px]'} />,
|
||||
phone: <Phone className={'size-[16px]'} />,
|
||||
google: '/images/oauth/google.webp',
|
||||
facebook: '/images/oauth/facebook.webp',
|
||||
github: '/images/oauth/github.webp',
|
||||
|
||||
@@ -1,36 +1,16 @@
|
||||
import { cn } from '../lib/utils';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
|
||||
export function Spinner(
|
||||
props: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
}>,
|
||||
) {
|
||||
import { cn } from '../lib/utils/cn';
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
|
||||
return (
|
||||
<div role="status" aria-label="loading">
|
||||
<svg
|
||||
className={cn(
|
||||
props.className,
|
||||
'stroke-muted-foreground h-6 w-6 animate-spin',
|
||||
)}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_9023_61563)">
|
||||
<path
|
||||
d="M14.6437 2.05426C11.9803 1.2966 9.01686 1.64245 6.50315 3.25548C1.85499 6.23817 0.504864 12.4242 3.48756 17.0724C6.47025 21.7205 12.6563 23.0706 17.3044 20.088C20.4971 18.0393 22.1338 14.4793 21.8792 10.9444"
|
||||
stroke="stroke-current"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<clipPath id="clip0_9023_61563">
|
||||
<rect width="24" height="24" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<Loader2Icon
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn('text-muted-foreground size-6 animate-spin', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Spinner };
|
||||
|
||||
83
packages/ui/src/shadcn/button-group.tsx
Normal file
83
packages/ui/src/shadcn/button-group.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { Slot } from 'radix-ui';
|
||||
|
||||
import { cn } from '../lib/utils/cn';
|
||||
import { Separator } from './separator';
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
|
||||
vertical:
|
||||
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: 'horizontal',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : 'div';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
};
|
||||
245
packages/ui/src/shadcn/field.tsx
Normal file
245
packages/ui/src/shadcn/field.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '../lib/utils/cn';
|
||||
import { Label } from './label';
|
||||
import { Separator } from './separator';
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
'flex flex-col gap-6',
|
||||
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = 'legend',
|
||||
...props
|
||||
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
'mb-3 font-medium',
|
||||
'data-[variant=legend]:text-base',
|
||||
'data-[variant=label]:text-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
'group/field data-[invalid=true]:text-destructive flex w-full gap-3',
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
|
||||
horizontal: [
|
||||
'flex-row items-center',
|
||||
'[&>[data-slot=field-label]]:flex-auto',
|
||||
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||
],
|
||||
responsive: [
|
||||
'flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto',
|
||||
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
|
||||
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
|
||||
],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: 'vertical',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
'group/field-content flex flex-1 flex-col gap-1.5 leading-snug',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
|
||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
|
||||
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
|
||||
'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & {
|
||||
errors?: Array<{ message?: string } | undefined>;
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!errors) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (errors?.length === 1 && errors[0]?.message) {
|
||||
return errors[0].message;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{errors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>,
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}, [children, errors]);
|
||||
|
||||
if (!content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn('text-destructive text-sm font-normal', className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
};
|
||||
171
packages/ui/src/shadcn/input-group.tsx
Normal file
171
packages/ui/src/shadcn/input-group.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '../lib/utils/cn';
|
||||
import { Button } from '../shadcn/button';
|
||||
import { Input } from '../shadcn/input';
|
||||
import { Textarea } from '../shadcn/textarea';
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
|
||||
'h-9 has-[>textarea]:h-auto',
|
||||
|
||||
// Variants based on alignment.
|
||||
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
|
||||
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
|
||||
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
|
||||
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
|
||||
|
||||
// Focus state.
|
||||
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
|
||||
|
||||
// Error state.
|
||||
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
|
||||
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
'inline-start':
|
||||
'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
|
||||
'inline-end':
|
||||
'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
|
||||
'block-start':
|
||||
'order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5 [.border-b]:pb-3',
|
||||
'block-end':
|
||||
'order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5 [.border-t]:pt-3',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: 'inline-start',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = 'inline-start',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('button')) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector('input')?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
'flex items-center gap-2 text-sm shadow-none',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: 'h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5',
|
||||
'icon-xs':
|
||||
'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
|
||||
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'xs',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = 'button',
|
||||
variant = 'ghost',
|
||||
size = 'xs',
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, 'size'> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
};
|
||||
195
packages/ui/src/shadcn/item.tsx
Normal file
195
packages/ui/src/shadcn/item.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import { Slot } from 'radix-ui';
|
||||
|
||||
import { cn } from '../lib/utils';
|
||||
import { Separator } from './separator';
|
||||
|
||||
function ItemGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
role="list"
|
||||
data-slot="item-group"
|
||||
className={cn('group/item-group flex flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="item-separator"
|
||||
orientation="horizontal"
|
||||
className={cn('my-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const itemVariants = cva(
|
||||
'group/item [a]:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-ring/50 flex flex-wrap items-center rounded-md border border-transparent text-sm transition-colors duration-100 outline-none focus-visible:ring-[3px] [a]:transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline: 'border-border',
|
||||
muted: 'bg-muted/50',
|
||||
},
|
||||
size: {
|
||||
default: 'gap-4 p-4',
|
||||
sm: 'gap-2.5 px-4 py-3',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Item({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> &
|
||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : 'div';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="item"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(itemVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const itemMediaVariants = cva(
|
||||
'flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
icon: "bg-muted size-8 rounded-sm border [&_svg:not([class*='size-'])]:size-4",
|
||||
image:
|
||||
'size-10 overflow-hidden rounded-sm [&_img]:size-full [&_img]:object-cover',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ItemMedia({
|
||||
className,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof itemMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-media"
|
||||
data-variant={variant}
|
||||
className={cn(itemMediaVariants({ variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-content"
|
||||
className={cn(
|
||||
'flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-title"
|
||||
className={cn(
|
||||
'flex w-fit items-center gap-2 text-sm leading-snug font-medium',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="item-description"
|
||||
className={cn(
|
||||
'text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance',
|
||||
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemActions({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-actions"
|
||||
className={cn('flex items-center gap-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-header"
|
||||
className={cn(
|
||||
'flex basis-full items-center justify-between gap-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="item-footer"
|
||||
className={cn(
|
||||
'flex basis-full items-center justify-between gap-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Item,
|
||||
ItemMedia,
|
||||
ItemContent,
|
||||
ItemActions,
|
||||
ItemGroup,
|
||||
ItemSeparator,
|
||||
ItemTitle,
|
||||
ItemDescription,
|
||||
ItemHeader,
|
||||
ItemFooter,
|
||||
};
|
||||
28
packages/ui/src/shadcn/kbd.tsx
Normal file
28
packages/ui/src/shadcn/kbd.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cn } from '../lib/utils/cn';
|
||||
|
||||
function Kbd({ className, ...props }: React.ComponentProps<'kbd'>) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd"
|
||||
className={cn(
|
||||
'bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none',
|
||||
"[&_svg:not([class*='size-'])]:size-3",
|
||||
'[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function KbdGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<kbd
|
||||
data-slot="kbd-group"
|
||||
className={cn('inline-flex items-center gap-1', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Kbd, KbdGroup };
|
||||
Reference in New Issue
Block a user