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:
Giancarlo Buomprisco
2025-10-05 17:54:16 +08:00
committed by GitHub
parent 195cf41680
commit 2e20d3e76f
60 changed files with 3760 additions and 1009 deletions

View File

@@ -0,0 +1,369 @@
'use client';
import { useMemo } from 'react';
import { Filter, Plus, Settings } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
} from '@kit/ui/button-group';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { Label } from '@kit/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import { Separator } from '@kit/ui/separator';
import { Switch } from '@kit/ui/switch';
import { cn } from '@kit/ui/utils';
import {
formatCodeBlock,
generatePropsString,
useStoryControls,
} from '../lib/story-utils';
import { ComponentStoryLayout } from './story-layout';
import { SimpleStorySelect } from './story-select';
interface ButtonGroupControls {
orientation: 'horizontal' | 'vertical';
size: 'sm' | 'default' | 'lg';
withLabel: boolean;
withSeparator: boolean;
withFilter: boolean;
withPrimary: boolean;
withSelect: boolean;
fullWidth: boolean;
}
const orientationOptions = [
{
value: 'horizontal',
label: 'Horizontal',
description: 'Buttons arranged side-by-side',
},
{
value: 'vertical',
label: 'Vertical',
description: 'Stack buttons vertically',
},
] as const;
const sizeOptions = [
{
value: 'sm',
label: 'Small',
description: 'Compact 36px controls',
},
{
value: 'default',
label: 'Default',
description: 'Standard 40px controls',
},
{
value: 'lg',
label: 'Large',
description: 'Spacious 44px controls',
},
] as const;
export function ButtonGroupStory() {
const { controls, updateControl } = useStoryControls<ButtonGroupControls>({
orientation: 'horizontal',
size: 'sm',
withLabel: false,
withSeparator: false,
withFilter: false,
withPrimary: false,
withSelect: false,
fullWidth: false,
});
const buttonGroupPropsString = useMemo(
() =>
generatePropsString(
{
orientation: controls.orientation,
className: controls.fullWidth ? 'w-full' : undefined,
},
{
orientation: 'horizontal',
className: undefined,
},
),
[controls.fullWidth, controls.orientation],
);
const generatedCode = useMemo(() => {
const separatorOrientation =
controls.orientation === 'vertical' ? 'horizontal' : 'vertical';
const buttonSizeProp =
controls.size === 'default' ? '' : ` size="${controls.size}"`;
const selectTriggerClasses = [
'w-[140px] justify-between',
controls.size === 'sm' ? 'h-9 text-sm' : null,
controls.size === 'lg' ? 'h-11 text-base' : null,
]
.filter(Boolean)
.join(' ');
const labelClasses = [
'min-w-[120px] justify-between',
controls.size === 'sm' ? 'text-sm' : null,
controls.size === 'lg' ? 'text-base' : null,
'gap-2',
]
.filter(Boolean)
.join(' ');
let code = `<ButtonGroup${buttonGroupPropsString}>`;
if (controls.withLabel) {
code += `\n <ButtonGroupText className="${labelClasses}">`;
code += `\n Views`;
code += `\n <Settings className="h-4 w-4" />`;
code += `\n </ButtonGroupText>`;
}
code += `\n <Button variant="outline"${buttonSizeProp}>Overview</Button>`;
code += `\n <Button variant="outline"${buttonSizeProp}>Activity</Button>`;
code += `\n <Button variant="outline"${buttonSizeProp}>Calendar</Button>`;
if (controls.withSeparator) {
code += `\n <ButtonGroupSeparator orientation="${separatorOrientation}" />`;
}
if (controls.withFilter) {
code += `\n <Button variant="ghost" className="gap-2"${buttonSizeProp}>`;
code += `\n <Filter className="h-4 w-4" />`;
code += `\n Filters`;
code += `\n </Button>`;
}
if (controls.withSelect) {
code += `\n <Select defaultValue="all">`;
code += `\n <SelectTrigger data-slot="select-trigger" className="${selectTriggerClasses}">`;
code += `\n <SelectValue placeholder="Segment" />`;
code += `\n </SelectTrigger>`;
code += `\n <SelectContent>`;
code += `\n <SelectItem value="all">All tasks</SelectItem>`;
code += `\n <SelectItem value="mine">Assigned to me</SelectItem>`;
code += `\n <SelectItem value="review">Needs review</SelectItem>`;
code += `\n </SelectContent>`;
code += `\n </Select>`;
}
if (controls.withPrimary) {
code += `\n <Button${buttonSizeProp}>`;
code += `\n <Plus className="mr-2 h-4 w-4" />`;
code += `\n New view`;
code += `\n </Button>`;
}
code += `\n</ButtonGroup>`;
return formatCodeBlock(code, [
"import { Filter, Plus, Settings } from 'lucide-react';",
"import { Button } from '@kit/ui/button';",
"import { ButtonGroup, ButtonGroupSeparator, ButtonGroupText } from '@kit/ui/button-group';",
"import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select';",
]);
}, [buttonGroupPropsString, controls]);
const separatorOrientation =
controls.orientation === 'vertical' ? 'horizontal' : 'vertical';
const preview = (
<div
className={cn(
'bg-background flex min-h-[220px] items-center justify-center rounded-xl border p-6',
controls.fullWidth && 'items-start',
)}
>
<ButtonGroup
orientation={controls.orientation}
className={cn(controls.fullWidth && 'w-full justify-between')}
>
{controls.withLabel && (
<ButtonGroupText
className={cn(
'min-w-[120px] justify-between gap-2',
controls.size === 'sm' && 'text-sm',
controls.size === 'lg' && 'text-base',
)}
>
<span>Views</span>
<Settings className="h-4 w-4" />
</ButtonGroupText>
)}
<Button variant="outline" size={controls.size}>
Overview
</Button>
<Button variant="outline" size={controls.size}>
Activity
</Button>
<Button variant="outline" size={controls.size}>
Calendar
</Button>
{controls.withSeparator && (
<ButtonGroupSeparator orientation={separatorOrientation} />
)}
{controls.withFilter && (
<Button variant="ghost" size={controls.size} className="gap-2">
<Filter className="h-4 w-4" />
Filters
</Button>
)}
{controls.withPrimary && (
<Button size={controls.size} className="gap-2">
<Plus className="h-4 w-4" />
New view
</Button>
)}
</ButtonGroup>
</div>
);
const controlsPanel = (
<>
<div className="space-y-2">
<Label htmlFor="orientation">Orientation</Label>
<SimpleStorySelect
value={controls.orientation}
onValueChange={(value) => updateControl('orientation', value)}
options={orientationOptions}
/>
</div>
<div className="space-y-2">
<Label htmlFor="size">Size</Label>
<SimpleStorySelect
value={controls.size}
onValueChange={(value) => updateControl('size', value)}
options={sizeOptions}
/>
</div>
<Separator />
<div className="flex items-center justify-between gap-3">
<Label htmlFor="withLabel" className="text-sm font-medium">
Show label
</Label>
<Switch
id="withLabel"
checked={controls.withLabel}
onCheckedChange={(checked) => updateControl('withLabel', checked)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="withFilter" className="text-sm font-medium">
Show filter button
</Label>
<Switch
id="withFilter"
checked={controls.withFilter}
onCheckedChange={(checked) => updateControl('withFilter', checked)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="withPrimary" className="text-sm font-medium">
Show primary action
</Label>
<Switch
id="withPrimary"
checked={controls.withPrimary}
onCheckedChange={(checked) => updateControl('withPrimary', checked)}
/>
</div>
</>
);
const examples = (
<Card>
<CardHeader>
<CardTitle>Button group sizes</CardTitle>
<CardDescription>
Mirror the documentation examples with small, default, and large
buttons.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ButtonGroup>
<Button variant="outline" size="sm">
Small
</Button>
<Button variant="outline" size="sm">
Button
</Button>
<Button variant="outline" size="sm">
Group
</Button>
<Button variant="outline" size="sm">
<Plus className="h-4 w-4" />
</Button>
</ButtonGroup>
<ButtonGroup>
<Button variant="outline">Default</Button>
<Button variant="outline">Button</Button>
<Button variant="outline">Group</Button>
<Button variant="outline">
<Plus className="h-4 w-4" />
</Button>
</ButtonGroup>
<ButtonGroup>
<Button variant="outline" size="lg">
Large
</Button>
<Button variant="outline" size="lg">
Button
</Button>
<Button variant="outline" size="lg">
Group
</Button>
<Button variant="outline" size="lg">
<Plus className="h-4 w-4" />
</Button>
</ButtonGroup>
</CardContent>
</Card>
);
return (
<ComponentStoryLayout
preview={preview}
controls={controlsPanel}
generatedCode={generatedCode}
examples={examples}
previewTitle="Interactive button group"
previewDescription="Coordinate related actions, filters, and dropdowns in a single control cluster."
controlsTitle="Configuration"
controlsDescription="Toggle layout options and auxiliary actions to compose the group."
codeTitle="Usage"
codeDescription="Copy the configuration that matches your toolbar requirements."
/>
);
}
export default ButtonGroupStory;

View File

@@ -19,7 +19,7 @@ export function DocsContent({ selectedComponent }: DocsContentProps) {
}
return (
<div className="flex-1 overflow-auto">
<div className="flex-1 overflow-y-auto">
<Suspense fallback={<LoadingFallback />}>
<div className="p-4">
<component.component />

View File

@@ -11,6 +11,7 @@ import {
CardTitle,
} from '@kit/ui/card';
import {
EmptyMedia,
EmptyState,
EmptyStateButton,
EmptyStateHeading,
@@ -290,7 +291,9 @@ export function EmptyStateStory() {
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<EmptyState className="min-h-[200px]">
<Package className="text-muted-foreground mb-4 h-12 w-12" />
<EmptyMedia variant="icon">
<Package className="text-muted-foreground h-8 w-8" />
</EmptyMedia>
<EmptyStateHeading>No products</EmptyStateHeading>
<EmptyStateText>
Add your first product to start selling.
@@ -299,7 +302,9 @@ export function EmptyStateStory() {
</EmptyState>
<EmptyState className="min-h-[200px]">
<FileText className="text-muted-foreground mb-4 h-12 w-12" />
<EmptyMedia variant="icon">
<FileText className="text-muted-foreground h-8 w-8" />
</EmptyMedia>
<EmptyStateHeading>No documents</EmptyStateHeading>
<EmptyStateText>
Upload or create your first document.

View File

@@ -0,0 +1,468 @@
'use client';
import { useMemo } from 'react';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
} from '@kit/ui/field';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { RadioGroup, RadioGroupItem } from '@kit/ui/radio-group';
import { Separator } from '@kit/ui/separator';
import { Switch } from '@kit/ui/switch';
import { Textarea } from '@kit/ui/textarea';
import {
formatCodeBlock,
generatePropsString,
useStoryControls,
} from '../lib/story-utils';
import { ComponentStoryLayout } from './story-layout';
import { SimpleStorySelect } from './story-select';
interface FieldControls {
orientation: 'vertical' | 'horizontal';
showDescriptions: boolean;
showErrors: boolean;
useLegend: boolean;
includeSeparator: boolean;
}
const orientationOptions = [
{
value: 'vertical',
label: 'Vertical',
description: 'Label and controls stacked',
},
{
value: 'horizontal',
label: 'Horizontal',
description: 'Label inline with controls',
},
] as const;
export function FieldStory() {
const { controls, updateControl } = useStoryControls<FieldControls>({
orientation: 'horizontal',
showDescriptions: true,
showErrors: false,
useLegend: true,
includeSeparator: true,
});
const fieldPropsString = useMemo(
() =>
generatePropsString(
{
orientation: controls.orientation,
},
{ orientation: 'vertical' },
),
[controls.orientation],
);
const generatedCode = useMemo(() => {
const separatorLine = controls.includeSeparator
? '\n <FieldSeparator className="mt-4">Preferences</FieldSeparator>'
: '';
const descriptionLine = controls.showDescriptions
? '\n <FieldDescription>The name that will appear on invoices.</FieldDescription>'
: '';
const errorLine = controls.showErrors
? '\n <FieldError errors={[{ message: "Please provide your full name." }]} />'
: '';
const code = `<FieldSet className="mx-auto w-full max-w-3xl space-y-8">
${controls.useLegend ? '<FieldLegend className="text-base font-semibold">Account Details</FieldLegend>\n ' : ''}<FieldGroup className="space-y-6">
<Field orientation="horizontal" data-invalid={${controls.showErrors}} className="items-start gap-4 sm:gap-6">
<FieldLabel htmlFor="full-name" className="text-sm font-medium sm:w-48">Full name</FieldLabel>
<FieldContent className="flex w-full max-w-xl flex-col gap-2">
<Input id="full-name" placeholder="Ada Lovelace" aria-invalid={${controls.showErrors}} />${descriptionLine}${errorLine}
</FieldContent>
</Field>${separatorLine}
<Field orientation="horizontal" className="items-start gap-4 sm:gap-6">
<FieldLabel htmlFor="email" className="text-sm font-medium sm:w-48">Email address</FieldLabel>
<FieldContent className="flex w-full max-w-xl flex-col gap-2">
<Input id="email" type="email" placeholder="ada@lovelace.dev" />${
controls.showDescriptions
? '\n <FieldDescription>Used for sign-in and notifications.</FieldDescription>'
: ''
}
</FieldContent>
</Field>
<Field orientation="horizontal" className="items-start gap-4 sm:gap-6">
<FieldLabel htmlFor="bio" className="text-sm font-medium sm:w-48">Bio</FieldLabel>
<FieldContent className="flex w-full max-w-xl flex-col gap-2">
<Textarea id="bio" rows={4} placeholder="Tell us about your work." />${
controls.showDescriptions
? '\n <FieldDescription>Supports Markdown formatting.</FieldDescription>'
: ''
}
</FieldContent>
</Field>
<Field orientation="horizontal" className="items-start gap-4 sm:gap-6">
<FieldLabel className="text-sm font-medium sm:w-48">
<FieldTitle>Notifications</FieldTitle>
</FieldLabel>
<FieldContent className="flex w-full max-w-xl flex-col gap-2">
<Switch id="notifications" defaultChecked />\n ${
controls.showDescriptions
? '\n <FieldDescription>Receive updates about comments and mentions.</FieldDescription>'
: ''
}
</FieldContent>
</Field>
<Field orientation="horizontal" className="items-start gap-4 sm:gap-6">
<FieldLabel className="text-sm font-medium sm:w-48">
<FieldTitle>Preferred contact</FieldTitle>
</FieldLabel>
<FieldContent className="flex w-full max-w-xl flex-col gap-3">
<RadioGroup defaultValue="email" className="grid gap-3 sm:grid-cols-2">
<div className="flex items-center gap-2">
<RadioGroupItem value="email" id="contact-email" />
<Label htmlFor="contact-email">Email</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="sms" id="contact-sms" />
<Label htmlFor="contact-sms">SMS</Label>
</div>
</RadioGroup>${
controls.showDescriptions
? '\n <FieldDescription>Select how we should reach out for account activity.</FieldDescription>'
: ''
}
</FieldContent>
</Field>
</FieldGroup>
</FieldSet>`;
return formatCodeBlock(code, [
"import { Button } from '@kit/ui/button';",
`import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle
} from '@kit/ui/field';`,
"import { Input } from '@kit/ui/input';",
"import { Label } from '@kit/ui/label';",
"import { RadioGroup, RadioGroupItem } from '@kit/ui/radio-group';",
"import { Switch } from '@kit/ui/switch';",
"import { Textarea } from '@kit/ui/textarea';",
]);
}, [
controls.includeSeparator,
controls.orientation,
controls.showDescriptions,
controls.showErrors,
controls.useLegend,
fieldPropsString,
]);
const preview = (
<Card>
<CardHeader>
<CardTitle>Profile settings</CardTitle>
<CardDescription>
Organize related form controls with consistent spacing and error
handling.
</CardDescription>
</CardHeader>
<CardContent>
<FieldSet className="mx-auto max-w-3xl space-y-8">
{controls.useLegend && (
<FieldLegend className="text-base font-semibold">
Account details
</FieldLegend>
)}
<FieldGroup className="space-y-6">
<Field
orientation={controls.orientation}
data-invalid={controls.showErrors || undefined}
className="items-start"
>
<FieldLabel
htmlFor="story-full-name"
className="text-sm font-medium sm:w-48"
>
Full name
</FieldLabel>
<FieldContent className="space-y-2 sm:max-w-xl">
<Input
id="story-full-name"
placeholder="Ada Lovelace"
aria-invalid={controls.showErrors}
/>
{controls.showDescriptions && (
<FieldDescription>
The name that will appear on invoices.
</FieldDescription>
)}
{controls.showErrors && (
<FieldError
errors={[{ message: 'Please provide your full name.' }]}
/>
)}
</FieldContent>
</Field>
{controls.includeSeparator && (
<FieldSeparator className="mt-4">Preferences</FieldSeparator>
)}
<Field orientation={controls.orientation} className="items-start">
<FieldLabel
htmlFor="story-email"
className="text-sm font-medium sm:w-48"
>
Email address
</FieldLabel>
<FieldContent className="space-y-2 sm:max-w-xl">
<Input
id="story-email"
type="email"
placeholder="ada@lovelace.dev"
/>
{controls.showDescriptions && (
<FieldDescription>
Used for sign-in and important notifications.
</FieldDescription>
)}
</FieldContent>
</Field>
<Field orientation={controls.orientation} className="items-start">
<FieldLabel
htmlFor="story-bio"
className="text-sm font-medium sm:w-48"
>
Bio
</FieldLabel>
<FieldContent className="space-y-2 sm:max-w-xl">
<Textarea
id="story-bio"
rows={4}
placeholder="Tell us about your work."
/>
{controls.showDescriptions && (
<FieldDescription>
Share a short summary. Markdown is supported.
</FieldDescription>
)}
</FieldContent>
</Field>
<Field orientation={controls.orientation} className="items-start">
<FieldLabel className="text-sm font-medium sm:w-48">
<FieldTitle>Notifications</FieldTitle>
</FieldLabel>
<FieldContent className="space-y-2 sm:max-w-xl">
<Switch id="story-notifications" defaultChecked />
{controls.showDescriptions && (
<FieldDescription>
Receive updates about comments, mentions, and reminders.
</FieldDescription>
)}
</FieldContent>
</Field>
<Field orientation={controls.orientation} className="items-start">
<FieldLabel className="text-sm font-medium sm:w-48">
<FieldTitle>Preferred contact</FieldTitle>
</FieldLabel>
<FieldContent className="space-y-3 sm:max-w-xl">
<RadioGroup
defaultValue="email"
className="grid grid-cols-1 gap-3 sm:grid-cols-2"
>
<Label
htmlFor="story-contact-email"
className="flex items-center gap-2 rounded-md border p-3"
>
<RadioGroupItem value="email" id="story-contact-email" />
Email
</Label>
<Label
htmlFor="story-contact-sms"
className="flex items-center gap-2 rounded-md border p-3"
>
<RadioGroupItem value="sms" id="story-contact-sms" />
SMS
</Label>
</RadioGroup>
{controls.showDescriptions && (
<FieldDescription>
We will use this channel for account and security updates.
</FieldDescription>
)}
</FieldContent>
</Field>
</FieldGroup>
</FieldSet>
</CardContent>
<CardFooter className="flex justify-end gap-2">
<Button variant="outline">Cancel</Button>
<Button>Save changes</Button>
</CardFooter>
</Card>
);
const controlsPanel = (
<>
<div className="space-y-2">
<Label htmlFor="field-orientation">Orientation</Label>
<SimpleStorySelect
value={controls.orientation}
onValueChange={(value) => updateControl('orientation', value)}
options={orientationOptions}
/>
</div>
<Separator />
<div className="flex items-center justify-between gap-3">
<Label htmlFor="legend-toggle" className="text-sm font-medium">
Show legend
</Label>
<Switch
id="legend-toggle"
checked={controls.useLegend}
onCheckedChange={(checked) => updateControl('useLegend', checked)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="descriptions-toggle" className="text-sm font-medium">
Show descriptions
</Label>
<Switch
id="descriptions-toggle"
checked={controls.showDescriptions}
onCheckedChange={(checked) =>
updateControl('showDescriptions', checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="errors-toggle" className="text-sm font-medium">
Show validation errors
</Label>
<Switch
id="errors-toggle"
checked={controls.showErrors}
onCheckedChange={(checked) => updateControl('showErrors', checked)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="separator-toggle" className="text-sm font-medium">
Include separator
</Label>
<Switch
id="separator-toggle"
checked={controls.includeSeparator}
onCheckedChange={(checked) =>
updateControl('includeSeparator', checked)
}
/>
</div>
</>
);
const examples = (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Inline layout</CardTitle>
<CardDescription>
Use the horizontal orientation when labels and inputs share a row.
</CardDescription>
</CardHeader>
<CardContent>
<Field orientation="horizontal">
<FieldLabel htmlFor="inline-name">Full name</FieldLabel>
<FieldContent>
<Input id="inline-name" placeholder="Grace Hopper" />
</FieldContent>
</Field>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Stacked layout</CardTitle>
<CardDescription>
Vertical orientation keeps dense forms readable on small screens.
</CardDescription>
</CardHeader>
<CardContent>
<Field orientation="vertical">
<FieldLabel htmlFor="stacked-bio">Bio</FieldLabel>
<FieldContent>
<Textarea id="stacked-bio" rows={3} />
</FieldContent>
</Field>
</CardContent>
</Card>
</div>
);
return (
<ComponentStoryLayout
preview={preview}
controls={controlsPanel}
generatedCode={generatedCode}
examples={examples}
previewTitle="Field primitives"
previewDescription="Compose accessible field layouts with consistent spacing, descriptions, and error messaging."
controlsTitle="Configuration"
controlsDescription="Switch between orientations and auxiliary helpers to match your form design."
codeTitle="Usage"
codeDescription="Combine the primitives to build structured forms."
/>
);
}
export default FieldStory;

View File

@@ -0,0 +1,394 @@
'use client';
import { useMemo } from 'react';
import { Calendar, Clock, Mail, Search } from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea,
} from '@kit/ui/input-group';
import { Kbd, KbdGroup } from '@kit/ui/kbd';
import { Label } from '@kit/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
import { Separator } from '@kit/ui/separator';
import { Switch } from '@kit/ui/switch';
import {
formatCodeBlock,
generatePropsString,
useStoryControls,
} from '../lib/story-utils';
import { ComponentStoryLayout } from './story-layout';
import { SimpleStorySelect } from './story-select';
interface InputGroupControls {
prefixAlign: 'inline-start' | 'inline-end' | 'block-start' | 'block-end';
showPrefix: boolean;
showSuffix: boolean;
showKeyboardHint: boolean;
showPrimaryAction: boolean;
useTextarea: boolean;
}
const alignmentOptions = [
{
value: 'inline-start',
label: 'Inline Start',
description: 'Display prefix before the input',
},
{
value: 'inline-end',
label: 'Inline End',
description: 'Display prefix after the input',
},
{
value: 'block-start',
label: 'Block Start',
description: 'Stack prefix above the control',
},
{
value: 'block-end',
label: 'Block End',
description: 'Stack prefix below the control',
},
] as const;
export function InputGroupStory() {
const { controls, updateControl } = useStoryControls<InputGroupControls>({
prefixAlign: 'inline-start',
showPrefix: true,
showSuffix: true,
showKeyboardHint: true,
showPrimaryAction: true,
useTextarea: false,
});
const inputGroupPropsString = useMemo(() => generatePropsString({}, {}), []);
const generatedCode = useMemo(() => {
const lines: string[] = [];
lines.push(`<InputGroup${inputGroupPropsString}>`);
if (controls.showPrefix) {
lines.push(
` <InputGroupAddon align="${controls.prefixAlign}">`,
` <InputGroupText className="gap-2">`,
` <Search className="h-4 w-4" />`,
` Search`,
' </InputGroupText>',
' </InputGroupAddon>',
);
}
if (controls.useTextarea) {
lines.push(
' <InputGroupTextarea rows={3} placeholder="Leave a message" />',
);
} else {
lines.push(
' <InputGroupInput type="search" placeholder="Find tasks, docs, people..." />',
);
}
if (controls.showKeyboardHint) {
lines.push(
' <InputGroupAddon align="inline-end">',
' <KbdGroup>',
' <Kbd>⌘</Kbd>',
' <Kbd>K</Kbd>',
' </KbdGroup>',
' </InputGroupAddon>',
);
}
if (controls.showSuffix) {
lines.push(
' <InputGroupAddon align="inline-end">',
' <InputGroupText>',
' <Clock className="h-4 w-4" />',
' Recent',
' </InputGroupText>',
' </InputGroupAddon>',
);
}
if (controls.showPrimaryAction) {
lines.push(
' <InputGroupAddon align="inline-end">',
' <InputGroupButton size="sm">Search</InputGroupButton>',
' </InputGroupAddon>',
);
}
lines.push('</InputGroup>');
return formatCodeBlock(lines.join('\n'), [
"import { Calendar, Clock, Mail, Search } from 'lucide-react';",
`import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupText,
InputGroupTextarea
} from '@kit/ui/input-group';`,
"import { Kbd, KbdGroup } from '@kit/ui/kbd';",
"import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select';",
]);
}, [controls, inputGroupPropsString]);
const preview = (
<div className="flex flex-col gap-6">
<InputGroup>
{controls.showPrefix && (
<InputGroupAddon align={controls.prefixAlign}>
<InputGroupText className="gap-2">
<Search className="h-4 w-4" />
Search
</InputGroupText>
</InputGroupAddon>
)}
{controls.useTextarea ? (
<InputGroupTextarea rows={3} placeholder="Leave a message" />
) : (
<InputGroupInput
type="search"
placeholder="Find tasks, docs, people..."
/>
)}
{controls.showKeyboardHint && (
<InputGroupAddon align="inline-end">
<KbdGroup>
<Kbd></Kbd>
<Kbd>K</Kbd>
</KbdGroup>
</InputGroupAddon>
)}
{controls.showSuffix && (
<InputGroupAddon align="inline-end">
<InputGroupText className="gap-2">
<Clock className="h-4 w-4" />
Recent
</InputGroupText>
</InputGroupAddon>
)}
{controls.showPrimaryAction && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="sm">Search</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<InputGroup>
<InputGroupAddon align="inline-start">
<InputGroupText className="gap-2">
<Mail className="h-4 w-4" />
Invite
</InputGroupText>
</InputGroupAddon>
<InputGroupInput type="email" placeholder="Enter teammate email" />
<InputGroupAddon align="inline-end">
<Select defaultValue="editor">
<SelectTrigger
data-slot="input-group-control"
className="hover:bg-muted h-8 border-transparent shadow-none"
>
<SelectValue placeholder="Role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Viewer</SelectItem>
<SelectItem value="editor">Editor</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
</InputGroupAddon>
<InputGroupAddon align="inline-end">
<InputGroupButton size="sm">Send invite</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<InputGroup>
<InputGroupAddon align="block-start">
<InputGroupText className="gap-2">
<Calendar className="h-4 w-4" />
Availability window
</InputGroupText>
</InputGroupAddon>
<InputGroupTextarea
placeholder="Share a short update for the team."
rows={3}
/>
</InputGroup>
</div>
);
const controlsPanel = (
<>
<div className="space-y-2">
<Label htmlFor="prefix-align">Prefix alignment</Label>
<SimpleStorySelect
value={controls.prefixAlign}
onValueChange={(value) => updateControl('prefixAlign', value)}
options={alignmentOptions}
/>
</div>
<Separator />
<div className="flex items-center justify-between gap-3">
<Label htmlFor="showPrefix" className="text-sm font-medium">
Show prefix
</Label>
<Switch
id="showPrefix"
checked={controls.showPrefix}
onCheckedChange={(checked) => updateControl('showPrefix', checked)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="showSuffix" className="text-sm font-medium">
Show suffix label
</Label>
<Switch
id="showSuffix"
checked={controls.showSuffix}
onCheckedChange={(checked) => updateControl('showSuffix', checked)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="showShortcut" className="text-sm font-medium">
Show keyboard hint
</Label>
<Switch
id="showShortcut"
checked={controls.showKeyboardHint}
onCheckedChange={(checked) =>
updateControl('showKeyboardHint', checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="showPrimary" className="text-sm font-medium">
Show primary action
</Label>
<Switch
id="showPrimary"
checked={controls.showPrimaryAction}
onCheckedChange={(checked) =>
updateControl('showPrimaryAction', checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="useTextarea" className="text-sm font-medium">
Use textarea control
</Label>
<Switch
id="useTextarea"
checked={controls.useTextarea}
onCheckedChange={(checked) => updateControl('useTextarea', checked)}
/>
</div>
</>
);
const examples = (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Search with hotkey</CardTitle>
<CardDescription>
Combine keyboard shortcuts and actions within the group wrapper.
</CardDescription>
</CardHeader>
<CardContent>
<InputGroup>
<InputGroupAddon align="inline-start">
<InputGroupText className="gap-2">
<Search className="h-4 w-4" />
Quick search
</InputGroupText>
</InputGroupAddon>
<InputGroupInput placeholder="Search knowledge base" />
<InputGroupAddon align="inline-end">
<KbdGroup>
<Kbd></Kbd>
<Kbd>K</Kbd>
</KbdGroup>
</InputGroupAddon>
</InputGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Comment composer</CardTitle>
<CardDescription>
Switch to a textarea while keeping prefixes and suffixes aligned.
</CardDescription>
</CardHeader>
<CardContent>
<InputGroup>
<InputGroupAddon align="block-start">
<InputGroupText>Comment</InputGroupText>
</InputGroupAddon>
<InputGroupTextarea
rows={3}
placeholder="Share an update with the team"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton size="sm">Post</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</CardContent>
</Card>
</div>
);
return (
<ComponentStoryLayout
preview={preview}
controls={controlsPanel}
generatedCode={generatedCode}
examples={examples}
previewTitle="Flexible input groups"
previewDescription="Compose inputs with inline buttons, keyboard hints, and stacked addons."
controlsTitle="Configuration"
controlsDescription="Toggle prefixes, suffixes, and alignment options for the group."
codeTitle="Usage"
codeDescription="Copy the layout that matches your form control requirements."
/>
);
}
export default InputGroupStory;

View File

@@ -0,0 +1,412 @@
'use client';
import { useMemo } from 'react';
import { ArrowUpRight, Calendar, CheckCircle2, Clock3 } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemFooter,
ItemGroup,
ItemHeader,
ItemMedia,
ItemSeparator,
ItemTitle,
} from '@kit/ui/item';
import { Label } from '@kit/ui/label';
import { Separator } from '@kit/ui/separator';
import { Switch } from '@kit/ui/switch';
import { cn } from '@kit/ui/utils';
import {
formatCodeBlock,
generatePropsString,
useStoryControls,
} from '../lib/story-utils';
import { ComponentStoryLayout } from './story-layout';
import { SimpleStorySelect } from './story-select';
interface ItemControls {
variant: 'default' | 'outline' | 'muted';
size: 'default' | 'sm';
showMedia: boolean;
showDescription: boolean;
showActions: boolean;
showFooter: boolean;
showSeparator: boolean;
}
const variantOptions = [
{ value: 'default', label: 'Default', description: 'Unstyled surface' },
{ value: 'outline', label: 'Outline', description: 'Bordered list item' },
{ value: 'muted', label: 'Muted', description: 'Soft background highlight' },
] as const;
const sizeOptions = [
{ value: 'default', label: 'Default', description: 'Spacious item' },
{ value: 'sm', label: 'Small', description: 'Compact height' },
] as const;
export function ItemStory() {
const { controls, updateControl } = useStoryControls<ItemControls>({
variant: 'default',
size: 'default',
showMedia: true,
showDescription: true,
showActions: true,
showFooter: true,
showSeparator: true,
});
const itemPropsString = useMemo(
() =>
generatePropsString(
{
variant: controls.variant,
size: controls.size,
},
{
variant: 'default',
size: 'default',
},
),
[controls.variant, controls.size],
);
const generatedCode = useMemo(() => {
const lines: string[] = [];
lines.push(`<Item${itemPropsString}>`);
if (controls.showMedia) {
lines.push(
' <ItemMedia variant="icon">',
' <Calendar className="h-4 w-4" />',
' </ItemMedia>',
);
}
lines.push(' <ItemContent>');
lines.push(' <ItemHeader>');
lines.push(' <ItemTitle>Weekly planning</ItemTitle>');
if (controls.showActions) {
lines.push(
' <ItemActions>',
' <Badge variant="secondary">12 tasks</Badge>',
' <Button variant="ghost" size="sm" className="gap-1">',
' View',
' <ArrowUpRight className="h-4 w-4" />',
' </Button>',
' </ItemActions>',
);
}
lines.push(' </ItemHeader>');
if (controls.showDescription) {
lines.push(
' <ItemDescription>Plan upcoming sprints and capture blockers from the team sync.</ItemDescription>',
);
}
if (controls.showFooter) {
lines.push(
' <ItemFooter>',
' <div className="flex items-center gap-2 text-xs text-muted-foreground">',
' <Clock3 className="h-3.5 w-3.5" />',
' Updated 2 hours ago',
' </div>',
' <Badge variant="outline">In progress</Badge>',
' </ItemFooter>',
);
}
lines.push(' </ItemContent>');
lines.push('</Item>');
return formatCodeBlock(lines.join('\n'), [
"import { ArrowUpRight, Calendar, Clock3 } from 'lucide-react';",
"import { Badge } from '@kit/ui/badge';",
"import { Button } from '@kit/ui/button';",
"import { Item, ItemActions, ItemContent, ItemDescription, ItemFooter, ItemMedia, ItemTitle } from '@kit/ui/item';",
]);
}, [controls, itemPropsString]);
const preview = (
<ItemGroup className="gap-3">
<Item
variant={controls.variant}
size={controls.size}
className={cn('w-full')}
>
{controls.showMedia && (
<ItemMedia variant="icon">
<Calendar className="h-4 w-4" />
</ItemMedia>
)}
<ItemContent>
<ItemHeader>
<ItemTitle className="gap-2">
Weekly planning
<Badge variant="secondary">Sprint</Badge>
</ItemTitle>
{controls.showActions && (
<ItemActions className="gap-2">
<Badge variant="outline">12 tasks</Badge>
<Button variant="ghost" size="sm" className="gap-1">
View
<ArrowUpRight className="h-4 w-4" />
</Button>
</ItemActions>
)}
</ItemHeader>
{controls.showDescription && (
<ItemDescription>
Plan upcoming sprints and capture blockers from the team sync.
</ItemDescription>
)}
{controls.showFooter && (
<ItemFooter>
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<Clock3 className="h-3.5 w-3.5" />
Updated 2 hours ago
</div>
<Badge variant="outline">In progress</Badge>
</ItemFooter>
)}
</ItemContent>
</Item>
{controls.showSeparator && <ItemSeparator />}
<Item variant="muted" size="sm">
<ItemMedia>
<Avatar className="h-9 w-9">
<AvatarImage src="https://daily.jstor.org/wp-content/uploads/2019/10/ada_lovelace_pioneer_1050x700.jpg" />
<AvatarFallback>AL</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemHeader>
<ItemTitle className="gap-2">
Ada Lovelace
<Badge variant="outline">Owner</Badge>
</ItemTitle>
{controls.showActions && (
<ItemActions className="gap-2">
<Button size="sm" variant="ghost" className="gap-1">
Message
<ArrowUpRight className="h-4 w-4" />
</Button>
</ItemActions>
)}
</ItemHeader>
{controls.showDescription && (
<ItemDescription>
Building the analytics module. Next milestone due Friday.
</ItemDescription>
)}
{controls.showFooter && (
<ItemFooter>
<Badge variant="secondary" className="gap-1">
<CheckCircle2 className="h-3.5 w-3.5" />
Active
</Badge>
<span className="text-muted-foreground text-xs">Joined 2023</span>
</ItemFooter>
)}
</ItemContent>
</Item>
</ItemGroup>
);
const controlsPanel = (
<>
<div className="space-y-2">
<Label htmlFor="item-variant">Variant</Label>
<SimpleStorySelect
value={controls.variant}
onValueChange={(value) => updateControl('variant', value)}
options={variantOptions}
/>
</div>
<div className="space-y-2">
<Label htmlFor="item-size">Size</Label>
<SimpleStorySelect
value={controls.size}
onValueChange={(value) => updateControl('size', value)}
options={sizeOptions}
/>
</div>
<Separator />
<div className="flex items-center justify-between gap-3">
<Label htmlFor="show-media" className="text-sm font-medium">
Show media
</Label>
<Switch
id="show-media"
checked={controls.showMedia}
onCheckedChange={(checked) => updateControl('showMedia', checked)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="show-description" className="text-sm font-medium">
Show description
</Label>
<Switch
id="show-description"
checked={controls.showDescription}
onCheckedChange={(checked) =>
updateControl('showDescription', checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="show-actions" className="text-sm font-medium">
Show actions
</Label>
<Switch
id="show-actions"
checked={controls.showActions}
onCheckedChange={(checked) => updateControl('showActions', checked)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="show-footer" className="text-sm font-medium">
Show footer
</Label>
<Switch
id="show-footer"
checked={controls.showFooter}
onCheckedChange={(checked) => updateControl('showFooter', checked)}
/>
</div>
<div className="flex items-center justify-between gap-3">
<Label htmlFor="show-separator" className="text-sm font-medium">
Show separator
</Label>
<Switch
id="show-separator"
checked={controls.showSeparator}
onCheckedChange={(checked) => updateControl('showSeparator', checked)}
/>
</div>
</>
);
const examples = (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Checklist</CardTitle>
<CardDescription>
Stack compact items for task summaries or changelog entries.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<ItemGroup className="gap-y-4">
<Item size="sm" variant="outline">
<ItemContent>
<ItemHeader>
<ItemTitle className="gap-2">
Deployment checklist
<Badge variant="secondary">Today</Badge>
</ItemTitle>
</ItemHeader>
<ItemDescription>
Review release notes, QA smoke tests, and notify support.
</ItemDescription>
</ItemContent>
</Item>
<ItemSeparator />
<Item size="sm" variant="outline">
<ItemContent>
<ItemTitle>QA sign-off</ItemTitle>
<ItemFooter>
<Badge variant="outline">Pending</Badge>
<span className="text-muted-foreground text-xs">Due 5pm</span>
</ItemFooter>
</ItemContent>
</Item>
</ItemGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Directory</CardTitle>
<CardDescription>
Combine avatar media with actions to build list views.
</CardDescription>
</CardHeader>
<CardContent>
<ItemGroup className="gap-3">
<Item variant="muted" size="sm">
<ItemMedia>
<Avatar className="h-9 w-9">
<AvatarFallback>GH</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemHeader>
<ItemTitle>Grace Hopper</ItemTitle>
<ItemActions>
<Button variant="ghost" size="sm" className="gap-1">
Profile
<ArrowUpRight className="h-4 w-4" />
</Button>
</ItemActions>
</ItemHeader>
<ItemDescription>Staff engineer · Platform</ItemDescription>
</ItemContent>
</Item>
</ItemGroup>
</CardContent>
</Card>
</div>
);
return (
<ComponentStoryLayout
preview={preview}
controls={controlsPanel}
generatedCode={generatedCode}
examples={examples}
previewTitle="Composable list items"
previewDescription="Mix media, headers, and actions to create rich list presentations."
controlsTitle="Configuration"
controlsDescription="Toggle structural primitives to match your layout requirements."
codeTitle="Usage"
codeDescription="Start from a base item and add media, actions, or metadata as needed."
/>
);
}
export default ItemStory;

View File

@@ -0,0 +1,277 @@
'use client';
import { useMemo } from 'react';
import { Command, Search } from 'lucide-react';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Kbd, KbdGroup } from '@kit/ui/kbd';
import { Label } from '@kit/ui/label';
import { Separator } from '@kit/ui/separator';
import { Switch } from '@kit/ui/switch';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@kit/ui/tooltip';
import { formatCodeBlock, useStoryControls } from '../lib/story-utils';
import { ComponentStoryLayout } from './story-layout';
import { SimpleStorySelect } from './story-select';
interface KbdControls {
preset: 'command-k' | 'shift-option-s' | 'control-shift-p' | 'custom';
showTooltip: boolean;
customShortcut: string;
}
const presetOptions = [
{
value: 'command-k',
label: 'Command + K',
description: 'Open global command palette',
},
{
value: 'shift-option-s',
label: 'Shift + Option + S',
description: 'Capture screenshot or share',
},
{
value: 'control-shift-p',
label: 'Ctrl + Shift + P',
description: 'Trigger quick action menu',
},
{
value: 'custom',
label: 'Custom',
description: 'Provide your own keys',
},
] as const;
function resolveKeys(preset: KbdControls['preset'], custom: string) {
if (preset === 'custom') {
return custom
.split(/\+|\s+/)
.map((key) => key.trim())
.filter(Boolean);
}
if (preset === 'command-k') {
return ['⌘', 'K'];
}
if (preset === 'shift-option-s') {
return ['⇧ Shift', '⌥ Option', 'S'];
}
return ['Ctrl', 'Shift', 'P'];
}
export function KbdStory() {
const { controls, updateControl } = useStoryControls<KbdControls>({
preset: 'command-k',
showTooltip: true,
customShortcut: 'Ctrl+Shift+P',
});
const keys = useMemo(
() => resolveKeys(controls.preset, controls.customShortcut),
[controls.customShortcut, controls.preset],
);
const generatedCode = useMemo(() => {
const groupLines: string[] = [];
groupLines.push('<KbdGroup>');
keys.forEach((key) => {
groupLines.push(` <Kbd>${key}</Kbd>`);
});
groupLines.push('</KbdGroup>');
let snippet = groupLines.join('\n');
if (controls.showTooltip) {
snippet = `<TooltipProvider>\n <Tooltip>\n <TooltipTrigger asChild>\n <Button variant="outline">Command palette</Button>\n </TooltipTrigger>\n <TooltipContent className="flex items-center gap-2">\n <span>Press</span>\n ${groupLines.join('\n ')}\n </TooltipContent>\n </Tooltip>\n</TooltipProvider>`;
}
return formatCodeBlock(snippet, [
"import { Button } from '@kit/ui/button';",
"import { Kbd, KbdGroup } from '@kit/ui/kbd';",
"import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kit/ui/tooltip';",
]);
}, [controls.showTooltip, keys]);
const preview = (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-center gap-4">
{controls.showTooltip ? (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" className="gap-2">
<Command className="h-4 w-4" />
Command palette
</Button>
</TooltipTrigger>
<TooltipContent className="flex items-center gap-2">
<span>Press</span>
<KbdGroup>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</KbdGroup>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<KbdGroup>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</KbdGroup>
)}
</div>
<div className="bg-muted/30 rounded-xl border p-6">
<div className="flex flex-col gap-3">
<div className="text-sm font-medium">Keyboard shortcut hint</div>
<div className="text-muted-foreground text-sm">
Use the keyboard primitives to surface power-user workflows in
menus, tooltips, or helper text.
</div>
<div className="flex flex-wrap items-center gap-2">
<SpanShortcut keys={keys} />
</div>
</div>
</div>
</div>
);
const controlsPanel = (
<>
<div className="space-y-2">
<Label htmlFor="preset">Preset</Label>
<SimpleStorySelect
value={controls.preset}
onValueChange={(value) => updateControl('preset', value)}
options={presetOptions}
/>
</div>
{controls.preset === 'custom' && (
<div className="space-y-2">
<Label htmlFor="custom-shortcut">Custom keys</Label>
<Input
id="custom-shortcut"
value={controls.customShortcut}
onChange={(event) =>
updateControl('customShortcut', event.target.value)
}
placeholder="Ctrl+Alt+Delete"
/>
</div>
)}
<Separator />
<div className="flex items-center justify-between gap-3">
<Label htmlFor="show-tooltip" className="text-sm font-medium">
Show tooltip usage
</Label>
<Switch
id="show-tooltip"
checked={controls.showTooltip}
onCheckedChange={(checked) => updateControl('showTooltip', checked)}
/>
</div>
</>
);
const examples = (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Shortcut legend</CardTitle>
<CardDescription>
Combine keyboard hints with descriptions for quick reference.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between rounded-md border p-3">
<span>Search workspace</span>
<KbdGroup>
<Kbd></Kbd>
<Kbd>K</Kbd>
</KbdGroup>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<span>Toggle spotlight</span>
<KbdGroup>
<Kbd>Ctrl</Kbd>
<Kbd>Space</Kbd>
</KbdGroup>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Empty state helper</CardTitle>
<CardDescription>
Incorporate shortcuts into product education moments.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-background rounded-md border p-4 text-center">
<Search className="text-muted-foreground mx-auto mb-2 h-5 w-5" />
<div className="text-sm font-medium">No results yet</div>
<div className="text-muted-foreground text-sm">
Try launching the command palette with
</div>
<div className="mt-2 flex justify-center">
<KbdGroup>
<Kbd></Kbd>
<Kbd>K</Kbd>
</KbdGroup>
</div>
</div>
</CardContent>
</Card>
</div>
);
return (
<ComponentStoryLayout
preview={preview}
controls={controlsPanel}
generatedCode={generatedCode}
examples={examples}
previewTitle="Keyboard input primitives"
previewDescription="Display keyboard shortcuts inline, in tooltips, or within helper content."
controlsTitle="Configuration"
controlsDescription="Select a preset or provide a custom shortcut combination."
codeTitle="Usage"
codeDescription="Wrap shortcut keys in the keyboard primitives wherever hints are required."
/>
);
}
function SpanShortcut({ keys }: { keys: string[] }) {
return (
<KbdGroup>
{keys.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</KbdGroup>
);
}
export default KbdStory;

View File

@@ -1,5 +1,6 @@
'use client';
import { Button } from '@kit/ui/button';
import {
Card,
CardContent,
@@ -10,7 +11,11 @@ import {
import { Label } from '@kit/ui/label';
import { Spinner } from '@kit/ui/spinner';
import { generatePropsString, useStoryControls } from '../lib/story-utils';
import {
formatCodeBlock,
generatePropsString,
useStoryControls,
} from '../lib/story-utils';
import { ComponentStoryLayout } from './story-layout';
import { SimpleStorySelect } from './story-select';
@@ -44,7 +49,9 @@ export function SpinnerStory() {
{ className: undefined },
);
return `<Spinner${propsString} />`;
return formatCodeBlock(`<Spinner${propsString} />`, [
"import { Spinner } from '@kit/ui/spinner';",
]);
};
const renderPreview = () => (
@@ -105,10 +112,10 @@ export function SpinnerStory() {
<CardContent className="space-y-4">
<div className="space-y-2">
<h4 className="text-sm font-semibold">Button Loading</h4>
<button className="bg-primary text-primary-foreground inline-flex items-center rounded-md px-4 py-2">
<Spinner className="mr-2 h-4 w-4" />
<Button className="gap-2" disabled>
<Spinner className="h-4 w-4" />
Loading...
</button>
</Button>
</div>
<div className="space-y-2">

View File

@@ -77,6 +77,16 @@ const ButtonStory = dynamic(
},
);
const ButtonGroupStory = dynamic(
() =>
import('../components/button-group-story').then((mod) => ({
default: mod.ButtonGroupStory,
})),
{
loading: () => <LoadingFallback />,
},
);
const CardStory = dynamic(
() =>
import('../components/card-story').then((mod) => ({
@@ -287,6 +297,16 @@ const FormStory = dynamic(
},
);
const FieldStory = dynamic(
() =>
import('../components/field-story').then((mod) => ({
default: mod.FieldStory,
})),
{
loading: () => <LoadingFallback />,
},
);
const HeadingStory = dynamic(
() =>
import('../components/heading-story').then((mod) => ({
@@ -297,6 +317,16 @@ const HeadingStory = dynamic(
},
);
const KbdStory = dynamic(
() =>
import('../components/kbd-story').then((mod) => ({
default: mod.KbdStory,
})),
{
loading: () => <LoadingFallback />,
},
);
const InputOTPStory = dynamic(
() =>
import('../components/input-otp-story').then((mod) => ({
@@ -307,6 +337,16 @@ const InputOTPStory = dynamic(
},
);
const InputGroupStory = dynamic(
() =>
import('../components/input-group-story').then((mod) => ({
default: mod.InputGroupStory,
})),
{
loading: () => <LoadingFallback />,
},
);
const RadioGroupStory = dynamic(
() =>
import('../components/radio-group-story').then((mod) => ({
@@ -367,6 +407,16 @@ const SimpleTableStory = dynamic(
},
);
const ItemStory = dynamic(
() =>
import('../components/item-story').then((mod) => ({
default: mod.ItemStory,
})),
{
loading: () => <LoadingFallback />,
},
);
// Component type definition
export interface ComponentInfo {
id: string;
@@ -405,6 +455,34 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [
icon: Type,
},
{
id: 'field',
name: 'Field',
category: 'Forms',
subcategory: 'Structure',
description:
'Primitive set for arranging labels, descriptions, and validation messaging.',
status: 'stable',
component: FieldStory,
sourceFile: '@kit/ui/field',
props: ['orientation', 'className', 'data-invalid'],
icon: FileText,
},
{
id: 'input-group',
name: 'Input Group',
category: 'Forms',
subcategory: 'Fields',
description:
'Wrap inputs with inline addons, keyboard hints, and primary actions.',
status: 'stable',
component: InputGroupStory,
sourceFile: '@kit/ui/input-group',
props: ['className', 'role', 'children'],
icon: ToggleLeft,
},
{
id: 'card-button',
name: 'Card Button',
@@ -806,6 +884,20 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [
icon: HeadingIcon,
},
{
id: 'kbd',
name: 'Keyboard Key',
category: 'Display',
subcategory: 'Helpers',
description:
'Display keyboard shortcuts inline, in tooltips, or within helper text.',
status: 'stable',
component: KbdStory,
sourceFile: '@kit/ui/kbd',
props: ['className', 'children'],
icon: Command,
},
// Interaction Components
{
id: 'button',
@@ -820,6 +912,20 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [
icon: MousePointer,
},
{
id: 'button-group',
name: 'Button Group',
category: 'Interaction',
subcategory: 'Actions',
description:
'Coordinate related buttons, dropdowns, and inputs within a shared toolbar.',
status: 'stable',
component: ButtonGroupStory,
sourceFile: '@kit/ui/button-group',
props: ['orientation', 'className', 'children'],
icon: CircleDot,
},
// Layout Components
{
id: 'card',
@@ -834,6 +940,20 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [
icon: Layout,
},
{
id: 'item',
name: 'Item',
category: 'Layout',
subcategory: 'Lists',
description:
'Composable list item primitive with media, actions, and metadata slots.',
status: 'stable',
component: ItemStory,
sourceFile: '@kit/ui/item',
props: ['variant', 'size', 'asChild', 'className'],
icon: Layers,
},
{
id: 'badge',
name: 'Badge',

View File

@@ -18,10 +18,10 @@ async function ComponentDocsPage(props: ComponentDocsPageProps) {
}
return (
<div className="bg-background flex h-screen">
<div className="bg-background flex h-screen overflow-x-hidden">
<DocsSidebar selectedComponent={component} selectedCategory={category} />
<div className="flex flex-1 flex-col">
<div className="flex flex-1 flex-col overflow-x-hidden">
<DocsHeader selectedComponent={component} />
<DocsContent selectedComponent={component} />
</div>

View File

@@ -6,6 +6,9 @@ const nextConfig: NextConfig = {
experimental: {
reactCompiler: true,
},
devIndicators: {
position: 'bottom-right',
},
logging: {
fetches: {
fullUrl: true,

View File

@@ -70,8 +70,10 @@ test.describe('Admin', () => {
// use the email as the filter text
const filterText = testUserEmail;
await filterAccounts(page, filterText);
await selectAccount(page, filterText);
await expect(async () => {
await filterAccounts(page, filterText);
await selectAccount(page, filterText);
}).toPass();
});
test('ban user flow', async ({ page }) => {
@@ -224,8 +226,10 @@ test.describe('Admin', () => {
await page.goto(`/admin/accounts`);
await filterAccounts(page, filterText);
await selectAccount(page, filterText);
await expect(async () => {
await filterAccounts(page, filterText);
await selectAccount(page, filterText);
}).toPass();
await page.getByTestId('admin-impersonate-button').click();
@@ -283,8 +287,10 @@ test.describe('Team Account Management', () => {
await page.goto(`/admin/accounts`);
await filterAccounts(page, teamName);
await selectAccount(page, teamName);
await expect(async () => {
await filterAccounts(page, teamName);
await selectAccount(page, teamName);
}).toPass();
});
test('delete team account flow', async ({ page }) => {
@@ -340,15 +346,13 @@ async function filterAccounts(page: Page, email: string) {
}
async function selectAccount(page: Page, email: string) {
await expect(async () => {
const link = page
.locator('tr', { hasText: email.split('@')[0] })
.locator('a');
const link = page
.locator('tr', { hasText: email.split('@')[0] })
.locator('a');
await expect(link).toBeVisible();
await expect(link).toBeVisible();
await link.click();
await link.click();
await page.waitForURL(/\/admin\/accounts\/[^\/]+/);
}).toPass();
await page.waitForURL(/\/admin\/accounts\/[^\/]+/);
}

View File

@@ -45,15 +45,7 @@ test.describe('Auth flow', () => {
password: 'password',
});
await page.waitForURL('**/home', {
timeout: 5_000,
});
expect(page.url()).toContain('/home');
await auth.signOut();
expect(page.url()).toContain('/');
await page.waitForURL('**/home');
});
test('will sign out using the dropdown', async ({ page }) => {
@@ -99,151 +91,3 @@ test.describe('Protected routes', () => {
expect(page.url()).toContain('/auth/sign-in?next=/home/settings');
});
});
test.describe('Last auth method tracking', () => {
let testEmail: string;
test.beforeEach(async ({ page }) => {
const auth = new AuthPageObject(page);
testEmail = auth.createRandomEmail();
// First, sign up with password
await auth.goToSignUp();
await auth.signUp({
email: testEmail,
password: 'password123',
repeatPassword: 'password123',
});
await auth.visitConfirmEmailLink(testEmail);
await page.waitForURL('**/home', {
timeout: 5_000,
});
// Sign out
await auth.signOut();
await page.waitForURL('/');
});
test('should show last used method hint on sign-in page after password sign-in', async ({
page,
}) => {
const auth = new AuthPageObject(page);
// Go to sign-in page and check for last method hint
await auth.goToSignIn();
// Check if the last used method hint is visible
const lastMethodHint = page.locator('[data-test="last-auth-method-hint"]');
await expect(lastMethodHint).toBeVisible();
// Verify it shows the correct method (password)
const passwordMethodText = page.locator('text=email and password');
await expect(passwordMethodText).toBeVisible();
});
test('should show existing account hint on sign-up page after previous sign-in', async ({
page,
}) => {
const auth = new AuthPageObject(page);
// Go to sign-up page (user already signed in with password in previous test)
await auth.goToSignUp();
// Check if the existing account hint is visible
const existingAccountHint = page.locator(
'[data-test="existing-account-hint"]',
);
await expect(existingAccountHint).toBeVisible();
});
test('should track method after successful sign-in', async ({ page }) => {
const auth = new AuthPageObject(page);
// Clear cookies to simulate a fresh session
await page.context().clearCookies();
// Sign in with the test email
await auth.goToSignIn();
await auth.signIn({
email: testEmail,
password: 'password123',
});
await page.waitForURL('**/home', {
timeout: 5_000,
});
// Sign out and check the method is still tracked
await auth.signOut();
await page.waitForURL('/');
// Go to sign-in page and check for last method hint
await auth.goToSignIn();
// The hint should still be visible after signing in again
const lastMethodHint = page.locator('[data-test="last-auth-method-hint"]');
await expect(lastMethodHint).toBeVisible();
});
test('should clear localStorage after 30 days simulation', async ({
page,
}) => {
const auth = new AuthPageObject(page);
// Go to sign-in page first
await auth.goToSignIn();
// Simulate old timestamp (31 days ago) by directly modifying localStorage
const thirtyOneDaysAgo = Date.now() - 31 * 24 * 60 * 60 * 1000;
await page.evaluate((timestamp) => {
const oldAuthMethod = {
method: 'password',
email: 'old@example.com',
timestamp: timestamp,
};
localStorage.setItem('auth_last_method', JSON.stringify(oldAuthMethod));
}, thirtyOneDaysAgo);
// Reload the page to trigger the expiry check
await page.reload();
// The hint should not be visible for expired data
const lastMethodHint = page.locator('[data-test="last-auth-method-hint"]');
await expect(lastMethodHint).not.toBeVisible();
// Verify localStorage was cleared
const storedMethod = await page.evaluate(() => {
return localStorage.getItem('auth_last_method');
});
expect(storedMethod).toBeNull();
});
test('should handle localStorage errors gracefully', async ({ page }) => {
const auth = new AuthPageObject(page);
await auth.goToSignIn();
// Simulate corrupted localStorage data
await page.evaluate(() => {
localStorage.setItem('auth_last_method', 'invalid-json-data');
});
// Reload the page
await page.reload();
// Should not crash and not show the hint
const lastMethodHint = page.locator('[data-test="last-auth-method-hint"]');
await expect(lastMethodHint).not.toBeVisible();
// Page should still be functional
await expect(page.locator('input[name="email"]')).toBeVisible();
});
});

View File

@@ -29,9 +29,7 @@ test.describe('Password Reset Flow', () => {
subject: 'Reset your password',
});
await page.waitForURL('/update-password', {
timeout: 1000,
});
await page.waitForURL('/update-password');
await auth.updatePassword(newPassword);
@@ -44,7 +42,7 @@ test.describe('Password Reset Flow', () => {
await page.waitForURL('/home');
}).toPass();
await auth.signOut();
await page.context().clearCookies();
await page.waitForURL('/');
await page.goto('/auth/sign-in');

View File

@@ -120,16 +120,6 @@ test.describe('Full Invitation Flow', () => {
await invitations.auth.visitConfirmEmailLink(firstEmail);
console.log(`Signing up with ${firstEmail} ...`);
await invitations.auth.signUp({
email: firstEmail,
password: 'password',
repeatPassword: 'password',
});
await invitations.auth.visitConfirmEmailLink(firstEmail);
console.log(`Accepting invitation as ${firstEmail}`);
await invitations.acceptInvitation();

View File

@@ -37,14 +37,6 @@ async function setupTeamWithMember(page: Page, memberRole = 'member') {
// Sign up with the new member email and accept the invitation
await invitations.auth.visitConfirmEmailLink(memberEmail);
await invitations.auth.signUp({
email: memberEmail,
password: 'password',
repeatPassword: 'password',
});
await invitations.auth.visitConfirmEmailLink(memberEmail);
await invitations.acceptInvitation();
await invitations.teamAccounts.openAccountsSelector();
@@ -248,9 +240,7 @@ test.describe('Team Ownership Transfer', () => {
const memberRow = page.getByRole('row', { name: memberEmail });
// Check for the primary owner badge on the member's row
await expect(memberRow.locator('text=Primary Owner')).toBeVisible({
timeout: 5000,
});
await expect(memberRow.locator('text=Primary Owner')).toBeVisible();
// The original owner should no longer have the primary owner badge
const ownerRow = page.getByRole('row', { name: ownerEmail.split('@')[0] });

View File

@@ -56,16 +56,6 @@ test.describe('Team Invitation with MFA Flow', () => {
await auth.visitConfirmEmailLink('super-admin@makerkit.dev');
await page
.locator('[data-test="existing-account-hint"]')
.getByRole('link', { name: 'Already have an account?' })
.click();
await auth.signIn({
email: 'super-admin@makerkit.dev',
password: 'testingpassword',
});
// Complete MFA verification
await expect(async () => {
await auth.submitMFAVerification(AuthPageObject.MFA_KEY);

View File

@@ -65,7 +65,13 @@ async function DocumentationPage({ params }: DocumentationPageProps) {
<section
className={'flex flex-col gap-y-1 border-b border-dashed pb-4'}
>
<h1 className={'text-foreground text-3xl'}>{page.title}</h1>
<h1
className={
'text-foreground text-3xl font-semibold tracking-tighter'
}
>
{page.title}
</h1>
<h2 className={'text-secondary-foreground/80 text-lg'}>
{description}

View File

@@ -26,11 +26,7 @@ export const generateMetadata = async () => {
};
async function SignInPage({ searchParams }: SignInPageProps) {
const { invite_token: inviteToken, next } = await searchParams;
const signUpPath =
pathsConfig.auth.signUp +
(inviteToken ? `?invite_token=${inviteToken}` : '');
const { next } = await searchParams;
const paths = {
callback: pathsConfig.auth.callback,
@@ -50,15 +46,11 @@ async function SignInPage({ searchParams }: SignInPageProps) {
</p>
</div>
<SignInMethodsContainer
inviteToken={inviteToken}
paths={paths}
providers={authConfig.providers}
/>
<SignInMethodsContainer paths={paths} providers={authConfig.providers} />
<div className={'flex justify-center'}>
<Button asChild variant={'link'} size={'sm'}>
<Link href={signUpPath} prefetch={true}>
<Link href={pathsConfig.auth.signUp} prefetch={true}>
<Trans i18nKey={'auth:doNotHaveAccountYet'} />
</Link>
</Button>

View File

@@ -18,24 +18,12 @@ export const generateMetadata = async () => {
};
};
interface Props {
searchParams: Promise<{
invite_token?: string;
}>;
}
const paths = {
callback: pathsConfig.auth.callback,
appHome: pathsConfig.app.home,
};
async function SignUpPage({ searchParams }: Props) {
const inviteToken = (await searchParams).invite_token;
const signInPath =
pathsConfig.auth.signIn +
(inviteToken ? `?invite_token=${inviteToken}` : '');
async function SignUpPage() {
return (
<>
<div className={'flex flex-col items-center gap-1'}>
@@ -51,13 +39,12 @@ async function SignUpPage({ searchParams }: Props) {
<SignUpMethodsContainer
providers={authConfig.providers}
displayTermsCheckbox={authConfig.displayTermsCheckbox}
inviteToken={inviteToken}
paths={paths}
/>
<div className={'flex justify-center'}>
<Button asChild variant={'link'} size={'sm'}>
<Link href={signInPath} prefetch={true}>
<Link href={pathsConfig.auth.signIn} prefetch={true}>
<Trans i18nKey={'auth:alreadyHaveAnAccount'} />
</Link>
</Button>

View File

@@ -56,12 +56,12 @@ async function JoinTeamAccountPage(props: JoinTeamAccountPageProps) {
const verifyMfaUrl = `${pathsConfig.auth.verifyMfa}?${urlParams.toString()}`;
// if the user needs to verify MFA, redirect them to the MFA verification page
// if the user needs to verify MFA
// redirect them to the MFA verification page
redirect(verifyMfaUrl);
} else {
const urlParams = new URLSearchParams({
invite_token: token,
email: searchParams.email ?? '',
});
const nextUrl = `${pathsConfig.auth.signUp}?${urlParams.toString()}`;
@@ -78,8 +78,10 @@ async function JoinTeamAccountPage(props: JoinTeamAccountPageProps) {
// the user is logged in, we can now check if the token is valid
const invitation = await api.getInvitation(adminClient, token);
// the invitation is not found or expired
if (!invitation) {
// the invitation is not found or expired or the email is not the same as the user's email
const isInvitationValid = invitation?.email === auth.data.email;
if (!isInvitationValid) {
return (
<AuthLayoutShell Logo={AppLogo}>
<InviteNotFoundOrExpired />

View File

@@ -135,5 +135,6 @@
"accountUnlinked": "Account successfully unlinked",
"linkEmailPassword": "Email & Password",
"linkEmailPasswordDescription": "Add an email and password to your account for additional sign-in options",
"noAccountsAvailable": "No additional accounts available to link"
"noAccountsAvailable": "No additional accounts available to link",
"linkAccountDescription": "Link account to sign in with {{provider}}"
}

View File

@@ -5,19 +5,6 @@
-- In production, you should manually create webhooks in the Supabase dashboard (or create a migration to do so).
-- We don't do it because you'll need to manually add your webhook URL and secret key.
-- this webhook will be triggered after deleting an account
create trigger "accounts_teardown"
after delete
on "public"."accounts"
for each row
execute function "supabase_functions"."http_request"(
'http://host.docker.internal:3000/api/db/webhook',
'POST',
'{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}',
'{}',
'5000'
);
-- this webhook will be triggered after a delete on the subscriptions table
-- which should happen when a user deletes their account (and all their subscriptions)
create trigger "subscriptions_delete"
@@ -32,21 +19,6 @@ execute function "supabase_functions"."http_request"(
'5000'
);
-- this webhook will be triggered after every insert on the invitations table
-- which should happen when a user invites someone to their account
create trigger "invitations_insert"
after insert
on "public"."invitations"
for each row
execute function "supabase_functions"."http_request"(
'http://host.docker.internal:3000/api/db/webhook',
'POST',
'{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}',
'{}',
'5000'
);
-- DATA SEED
-- This is a data dump for testing purposes. It should be used to seed the database with data for testing.

View File

@@ -1,6 +1,6 @@
{
"name": "next-supabase-saas-kit-turbo",
"version": "2.17.1",
"version": "2.18.0",
"private": true,
"sideEffects": false,
"engines": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +0,0 @@
export * from './account-webhooks.service';
export * from './account-invitations-webhook.service';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

3
pnpm-lock.yaml generated
View File

@@ -634,9 +634,6 @@ importers:
'@kit/supabase':
specifier: workspace:*
version: link:../supabase
'@kit/team-accounts':
specifier: workspace:*
version: link:../features/team-accounts
'@kit/tsconfig':
specifier: workspace:*
version: link:../../tooling/typescript